diff --git a/app-instance/backend/.dockerignore b/app-instance/backend-old/.dockerignore similarity index 100% rename from app-instance/backend/.dockerignore rename to app-instance/backend-old/.dockerignore diff --git a/app-instance/backend/.gitignore b/app-instance/backend-old/.gitignore similarity index 100% rename from app-instance/backend/.gitignore rename to app-instance/backend-old/.gitignore diff --git a/app-instance/backend/A2A_Multiagent_change.md b/app-instance/backend-old/A2A_Multiagent_change.md similarity index 100% rename from app-instance/backend/A2A_Multiagent_change.md rename to app-instance/backend-old/A2A_Multiagent_change.md diff --git a/app-instance/backend/COMMUNICATION.md b/app-instance/backend-old/COMMUNICATION.md similarity index 100% rename from app-instance/backend/COMMUNICATION.md rename to app-instance/backend-old/COMMUNICATION.md diff --git a/app-instance/backend/Dockerfile b/app-instance/backend-old/Dockerfile similarity index 100% rename from app-instance/backend/Dockerfile rename to app-instance/backend-old/Dockerfile diff --git a/app-instance/backend/LICENSE b/app-instance/backend-old/LICENSE similarity index 100% rename from app-instance/backend/LICENSE rename to app-instance/backend-old/LICENSE diff --git a/app-instance/backend-old/README.md b/app-instance/backend-old/README.md new file mode 100644 index 0000000..c161703 --- /dev/null +++ b/app-instance/backend-old/README.md @@ -0,0 +1,470 @@ +# Boardware Genius Backend + +这是 `Boardware Genius` 的后端服务仓库;当前技术命令和包名仍沿用 `nanobot`,但产品品牌按 `Boardware Genius` 表述: + +- `nanobot web`:单用户 FastAPI 后端,供独立前端或 `/docs` 调试使用 +- `nanobot gateway`:常驻 worker,负责渠道接入、cron、heartbeat +- MCP 动态工具接入 +- Outlook 集成:通过外部 `BW_Outlook_Mcp` 服务接入 Microsoft Graph / Exchange EWS +- 工作区文件、技能、插件、代理、MCP 管理等 Web API + +如果你后续要把它打包成 Docker 丢到服务器,这份 README 就是给开发和部署同事看的执行文档。 + +## 这套仓库现在是什么 + +这不是一个自带前端静态页面的全栈仓库,而是后端仓库: + +- Web 模式启动的是 FastAPI API 服务 +- Gateway 模式启动的是常驻 agent / channel / cron 进程 +- WhatsApp 相关逻辑依赖 `bridge/` 里的 Node 20 bridge +- Outlook 不是仓库内置模块,而是通过外部 `BW_Outlook_Mcp` 仓库接进来 + +更细的执行链路可以看 [workflow.md](./workflow.md)。 + +## 目录结构 + +```text +. +├── nanobot/ # Python 主体:CLI、agent、web、channels、config、MCP +├── bridge/ # WhatsApp bridge(Node 20) +├── tests/ # 测试 +├── Dockerfile # 当前镜像构建文件 +├── docker-compose.yml # 当前自带 compose 示例(偏 gateway / CLI) +└── workflow.md # 运行链路说明 +``` + +## 运行模式 + +| 命令 | 用途 | 默认端口 | 适合谁 | +| --- | --- | --- | --- | +| `nanobot agent` | 本地单轮 / 交互调试 | 无 | 开发排查 | +| `nanobot web` | 启动 FastAPI 后端 | `18080` | 独立前端、接口调试、单用户使用 | +| `nanobot gateway` | 启动常驻 worker | 无固定 HTTP 入口 | Telegram/Slack/Email/cron/heartbeat | +| `nanobot status` | 查看配置和 provider 状态 | 无 | 开发、运维 | + +注意: + +- 如果你是给 Web 前端提供后端,请启动 `nanobot web`,不要误用 `gateway` +- `gateway` 当前不是对外 Web API 服务 +- `web` 和 `gateway` 都会碰到同一份 workspace / cron / MCP 状态,通常不要在同一份数据目录上无脑同时跑两套 + +## 环境要求 + +- Python `>=3.11` +- 推荐使用 `uv` +- 如果要构建 WhatsApp bridge 或使用仓库自带 Dockerfile,需要 Node.js `20` + +本地开发最省事的方式: + +```bash +uv sync --extra dev +``` + +如果你不用 `uv`,也可以: + +```bash +python3 -m venv .venv +. .venv/bin/activate +pip install -e ".[dev]" +``` + +## 本地快速启动 + +### 1. 初始化配置 + +```bash +nanobot onboard +``` + +初始化后默认会生成: + +- 配置文件:`~/.nanobot/config.json` +- 工作区:`~/.nanobot/workspace` + +### 2. 填最小配置 + +下面是一份适合服务器环境的最小示例,重点是: + +- 用绝对路径的 workspace +- 建议打开 `restrictToWorkspace` +- 先用 API Key provider,少踩 OAuth 交互坑 + +```json +{ + "agents": { + "defaults": { + "workspace": "/root/.nanobot/workspace", + "model": "openai/gpt-5" + } + }, + "providers": { + "openai": { + "apiKey": "sk-xxxx" + } + }, + "tools": { + "restrictToWorkspace": true + } +} +``` + +如果你不是跑在容器里,把 `/root/.nanobot/workspace` 换成你自己的绝对路径。 + +### 3. 检查配置 + +```bash +nanobot status +``` + +### 4. 本地调试 agent + +```bash +nanobot agent -m "你好" +``` + +### 5. 启动 Web 后端 + +```bash +nanobot web --host 0.0.0.0 --port 18080 +``` + +启动后可直接访问: + +- `http://127.0.0.1:18080/docs` +- `http://127.0.0.1:18080/api/ping` + +## Web API 能力概览 + +当前 `nanobot web` 提供的 API 大致包括: + +- 聊天与流式输出 +- 会话管理 +- cron 任务管理 +- skills / plugins / agents 管理 +- 工作区文件浏览、上传、下载、删除 +- MCP server 管理与测试 +- Outlook 集成状态、连接测试、连接/断开、Overview、Message Detail + +如果你有独立前端,这个后端就是给前端接的;如果没有前端,也可以直接走 `/docs` 调试。 + +## Outlook MCP 集成 + +这是当前仓库里最容易部署时踩坑的一块。 + +### 关系先说清楚 + +当前后端不会自己实现 Outlook 协议,它依赖外部仓库 `BW_Outlook_Mcp`: + +- 后端代码位置:`nanobot/web/outlook.py` +- 默认查找逻辑: + 1. 先看环境变量 `NANOBOT_OUTLOOK_MCP_ROOT` + 2. 再看与本仓库同级目录的 `../BW_Outlook_Mcp` + 3. 如果以上都没有,就尝试直接执行 PATH 里的 `bw-outlook-mcp` + +也就是说,部署同事必须额外把 `BW_Outlook_Mcp` 这个仓库准备好,或者把它直接安装进镜像。 + +### 推荐的两种接法 + +#### 方案 A:把 `BW_Outlook_Mcp` 安装进同一个 Python 环境 + +这是生产环境更稳的方案。 + +部署同事需要: + +```bash +git clone <你们的 BW_Outlook_Mcp 仓库地址> /srv/BW_Outlook_Mcp +cd /srv/BW_Outlook_Mcp +pip install -e . +``` + +安装完成后,容器或宿主机里能直接执行: + +```bash +bw-outlook-mcp --help +``` + +这样 Boardware Genius 就会直接用 PATH 里的 `bw-outlook-mcp`,不依赖额外挂载路径。 + +#### 方案 B:把 `BW_Outlook_Mcp` 作为外部目录挂进来 + +这是开发或临时部署更方便的方案。 + +部署同事需要至少做到两件事: + +1. 把 `BW_Outlook_Mcp` 仓库拉到服务器 +2. 让这个目录里存在一个可执行的 `bw-outlook-mcp` + +最简单的约定是: + +```bash +git clone <你们的 BW_Outlook_Mcp 仓库地址> /srv/BW_Outlook_Mcp +cd /srv/BW_Outlook_Mcp +python3 -m venv .venv +. .venv/bin/activate +pip install -e . +``` + +然后给 Boardware Genius 设置: + +```bash +export NANOBOT_OUTLOOK_MCP_ROOT=/srv/BW_Outlook_Mcp +``` + +因为当前后端会优先寻找: + +```text +$NANOBOT_OUTLOOK_MCP_ROOT/.venv/bin/bw-outlook-mcp +``` + +如果你挂了仓库目录但里面没有 `.venv/bin/bw-outlook-mcp`,那就必须确保 `bw-outlook-mcp` 已经在容器 PATH 里。 + +### Outlook 的认证和配置 + +`BW_Outlook_Mcp` 本身支持两套后端: + +- `graph`:Microsoft 365 / Exchange Online +- `ews`:本地或回迁后的 Exchange Server + +#### Graph 登录 + +```bash +bw-outlook-mcp auth login-graph \ + --workspace /root/.nanobot/workspace \ + --client-id YOUR_CLIENT_ID \ + --tenant-id YOUR_TENANT_ID +``` + +#### EWS 配置 + +```bash +bw-outlook-mcp auth setup-ews \ + --workspace /root/.nanobot/workspace \ + --email you@example.com \ + --username your_username \ + --domain example.com \ + --server mail.example.com +``` + +如果你已经有固定 EWS URL,也可以改用: + +```bash +bw-outlook-mcp auth setup-ews \ + --workspace /root/.nanobot/workspace \ + --email you@example.com \ + --username your_username \ + --service-endpoint https://mail.example.com/EWS/Exchange.asmx +``` + +#### 查看状态 + +```bash +bw-outlook-mcp auth status --workspace /root/.nanobot/workspace +``` + +### Outlook 状态文件会落在哪里 + +所有 Outlook 相关状态默认都落在 workspace 下: + +```text +/state/bw_outlook_mcp/ +├── config.json +├── secrets.json +├── graph_token_cache.bin +├── delta_store.json +└── idempotency.sqlite3 +``` + +所以 Docker 部署时,不要只挂配置文件;要把整份 `~/.nanobot` 或至少 workspace 做持久化。 + +### Nanobot 里如何注册 Outlook MCP + +如果你通过 Web 接口完成 Outlook 连接,后端会自动把 MCP server 注册到配置里。 + +手工写配置时,结构类似这样: + +```json +{ + "tools": { + "mcpServers": { + "outlook": { + "command": "bw-outlook-mcp", + "args": ["serve", "--workspace", "/root/.nanobot/workspace"], + "sensitive": true, + "toolTimeout": 60 + } + } + } +} +``` + +这里一定要用绝对路径,不要写 `~/.nanobot/workspace`。 + +### 可选的 Outlook 环境变量 + +| 变量 | 作用 | +| --- | --- | +| `NANOBOT_OUTLOOK_MCP_ROOT` | 指向外部 `BW_Outlook_Mcp` 仓库目录 | +| `NANOBOT_OUTLOOK_MCP_COMMAND` | 强制指定 `bw-outlook-mcp` 可执行文件 | +| `NANOBOT_OUTLOOK_MCP_EXTRA_ARGS` | 给 `bw-outlook-mcp serve` 追加参数 | +| `NANOBOT_OUTLOOK_DEFAULT_DOMAIN` | Web 连接表单的默认域名 | +| `NANOBOT_OUTLOOK_DEFAULT_EWS_URL` | Web 连接表单默认 EWS 地址 | +| `NANOBOT_OUTLOOK_DEFAULT_EWS_SERVER` | Web 连接表单默认 Exchange 主机 | +| `NANOBOT_OUTLOOK_DEFAULT_TIMEZONE` | Web 连接表单默认时区 | +| `NANOBOT_OUTLOOK_DEFAULT_AUTODISCOVER` | Web 连接表单默认是否启用 autodiscover | + +## Docker 部署 + +### 先说结论 + +服务器部署时,最重要的是持久化这份目录: + +```text +/root/.nanobot +``` + +因为它里面不只是 `config.json`,还包括: + +- workspace +- sessions +- cron 状态 +- Web 登录信息 +- Outlook 状态与 token 缓存 + +### 构建镜像 + +```bash +docker build -t nanobot-backend:latest . +``` + +### 首次初始化 + +第一次跑容器时,先执行一次: + +```bash +docker run --rm \ + -v /srv/nanobot/data:/root/.nanobot \ + nanobot-backend:latest \ + onboard +``` + +然后去编辑宿主机上的: + +```text +/srv/nanobot/data/config.json +``` + +或者先进去执行: + +```bash +docker run --rm -it \ + -v /srv/nanobot/data:/root/.nanobot \ + nanobot-backend:latest \ + status +``` + +### 作为 Web 后端启动 + +如果你是给前端项目配后端,推荐这样跑: + +```bash +docker run -d \ + --name nanobot-web \ + -p 18080:18080 \ + -v /srv/nanobot/data:/root/.nanobot \ + -e NANOBOT_OUTLOOK_MCP_ROOT=/opt/BW_Outlook_Mcp \ + -v /srv/BW_Outlook_Mcp:/opt/BW_Outlook_Mcp \ + nanobot-backend:latest \ + web --host 0.0.0.0 --port 18080 +``` + +如果你已经把 `bw-outlook-mcp` 安装进镜像了,就不需要挂 `/srv/BW_Outlook_Mcp`,也不需要 `NANOBOT_OUTLOOK_MCP_ROOT`。 + +### 作为 Gateway/Worker 启动 + +如果你要接 Telegram / Slack / Email / cron 之类的常驻能力,再跑 gateway: + +```bash +docker run -d \ + --name nanobot-gateway \ + -v /srv/nanobot/data:/root/.nanobot \ + nanobot-backend:latest \ + gateway +``` + +### 推荐的服务器 compose 片段 + +仓库自带的 [docker-compose.yml](./docker-compose.yml) 更偏本地 gateway/CLI 示例。 +如果你是部署 Web 后端到服务器,更建议单独写成这样: + +```yaml +services: + nanobot-web: + image: nanobot-backend:latest + container_name: nanobot-web + command: ["web", "--host", "0.0.0.0", "--port", "18080"] + restart: unless-stopped + ports: + - "18080:18080" + volumes: + - /srv/nanobot/data:/root/.nanobot + - /srv/BW_Outlook_Mcp:/opt/BW_Outlook_Mcp + environment: + NANOBOT_OUTLOOK_MCP_ROOT: /opt/BW_Outlook_Mcp +``` + +如果你想把 Outlook 依赖做得更稳,推荐直接把 `BW_Outlook_Mcp` 安装进镜像,而不是运行时挂载仓库。 + +## 部署给同事时,至少要交代这几件事 + +1. 这是后端仓库,不带前端静态页面,前端请单独部署 +2. Web API 用 `nanobot web` 启动,不是 `gateway` +3. 数据目录必须持久化到 `/root/.nanobot` +4. 如果要 Outlook,必须额外拉取 `BW_Outlook_Mcp` +5. Outlook 有两种接法:装进镜像,或者挂外部仓库并设置 `NANOBOT_OUTLOOK_MCP_ROOT` +6. Outlook 的状态文件也在 workspace 里,删容器不挂卷就会丢 + +## 常用命令 + +```bash +nanobot onboard +nanobot status +nanobot agent -m "你好" +nanobot web --host 0.0.0.0 --port 18080 +nanobot gateway +nanobot provider login openai-codex +``` + +## 开发备注 + +- `workflow.md` 记录了当前代码实际运行链路,和旧版 README 更接近“真实代码” +- `nanobot/web/outlook.py` 是当前 Outlook 集成入口 +- `tests/` 里有 Web API、Email、Docker 相关测试 +- 如果要上服务器,建议在配置里显式打开 `tools.restrictToWorkspace=true` + +## 排错 + +### Web 启动了,但 Outlook 相关接口报错 + +优先检查: + +- `bw-outlook-mcp` 是否能在当前容器里执行 +- `NANOBOT_OUTLOOK_MCP_ROOT` 是否指向正确目录 +- 如果走目录挂载模式,目录里是否真的有 `.venv/bin/bw-outlook-mcp` + +### MCP 注册了,但工具没有出现 + +检查: + +- `config.json` 里的 `tools.mcpServers` +- `nanobot web` 或 `nanobot agent` 启动时是否用了同一份 `~/.nanobot` +- Outlook MCP 是否能单独执行 `bw-outlook-mcp auth status --workspace ...` + +### Docker 里配置改了没生效 + +优先检查你挂载的是不是整份: + +```text +/srv/nanobot/data:/root/.nanobot +``` + +不是只挂了某一个文件。 diff --git a/app-instance/backend/SECURITY.md b/app-instance/backend-old/SECURITY.md similarity index 100% rename from app-instance/backend/SECURITY.md rename to app-instance/backend-old/SECURITY.md diff --git a/app-instance/backend/agent_workspace/error.txt b/app-instance/backend-old/agent_workspace/error.txt similarity index 100% rename from app-instance/backend/agent_workspace/error.txt rename to app-instance/backend-old/agent_workspace/error.txt diff --git a/app-instance/backend/bridge/package.json b/app-instance/backend-old/bridge/package.json similarity index 100% rename from app-instance/backend/bridge/package.json rename to app-instance/backend-old/bridge/package.json diff --git a/app-instance/backend/bridge/src/index.ts b/app-instance/backend-old/bridge/src/index.ts similarity index 100% rename from app-instance/backend/bridge/src/index.ts rename to app-instance/backend-old/bridge/src/index.ts diff --git a/app-instance/backend/bridge/src/server.ts b/app-instance/backend-old/bridge/src/server.ts similarity index 100% rename from app-instance/backend/bridge/src/server.ts rename to app-instance/backend-old/bridge/src/server.ts diff --git a/app-instance/backend/bridge/src/types.d.ts b/app-instance/backend-old/bridge/src/types.d.ts similarity index 100% rename from app-instance/backend/bridge/src/types.d.ts rename to app-instance/backend-old/bridge/src/types.d.ts diff --git a/app-instance/backend/bridge/src/whatsapp.ts b/app-instance/backend-old/bridge/src/whatsapp.ts similarity index 100% rename from app-instance/backend/bridge/src/whatsapp.ts rename to app-instance/backend-old/bridge/src/whatsapp.ts diff --git a/app-instance/backend/bridge/tsconfig.json b/app-instance/backend-old/bridge/tsconfig.json similarity index 100% rename from app-instance/backend/bridge/tsconfig.json rename to app-instance/backend-old/bridge/tsconfig.json diff --git a/app-instance/backend/case/code.gif b/app-instance/backend-old/case/code.gif similarity index 100% rename from app-instance/backend/case/code.gif rename to app-instance/backend-old/case/code.gif diff --git a/app-instance/backend/case/memory.gif b/app-instance/backend-old/case/memory.gif similarity index 100% rename from app-instance/backend/case/memory.gif rename to app-instance/backend-old/case/memory.gif diff --git a/app-instance/backend/case/scedule.gif b/app-instance/backend-old/case/scedule.gif similarity index 100% rename from app-instance/backend/case/scedule.gif rename to app-instance/backend-old/case/scedule.gif diff --git a/app-instance/backend/case/search.gif b/app-instance/backend-old/case/search.gif similarity index 100% rename from app-instance/backend/case/search.gif rename to app-instance/backend-old/case/search.gif diff --git a/app-instance/backend-old/change.md b/app-instance/backend-old/change.md new file mode 100644 index 0000000..b6ba4f3 --- /dev/null +++ b/app-instance/backend-old/change.md @@ -0,0 +1,799 @@ +# Beaver Backend 重构蓝图 + +## 命名说明 + +当前项目正式名称已经不是 `nanobot`,而是 `beaver`。 + +这份文档里如果出现 `nanobot/...`,一律表示“当前仓库里还没迁走的历史代码路径 / 现状实现位置”,不代表目标命名。 + +后续重构目标应统一收敛到: + +1. 产品名、项目名、运行时内核名统一按 `beaver` 表达。 +2. `nanobot` 只作为迁移期遗留路径存在,最终应逐步退出目录、模块和文档命名。 +3. 新增目录、新增模块、新增文档都应优先使用 `beaver` 命名,而不是继续扩散 `nanobot`。 + +## 1. 这次重构到底要解决什么 + +当前后端已经不是“功能不够”,而是“能力已经长出来了,但结构还停留在早期阶段”。 + +现在项目里同时存在这些事实: + +1. `AgentLoop` 已经承担了太多职责,既管主 agent 对话,又管工具、委派、MCP、会话、事件、memory。 +2. `web/server.py` 已经变成超大文件,FastAPI app factory、chat API、session、文件、skills、cron、A2A、Outlook 都放在一起。 +3. `agent_team` 已经接上了 `swarms`,但目前更像“业务层直接借用第三方 runtime”,不是“我们自己的多智能体平台”。 +4. `skills` 已经有加载、安装、审核,但本质还是 Markdown 说明书,不是可学习、可演化、可评估的能力对象。 +5. 项目里已经隐约出现了三个方向,但还没有被统一成一个完整架构: + - `swarms` 提供多智能体架构能力 + - `hermes-agent` 提供 skill 生命周期与长期演进思路 + - `OpenHarness` 提供模块化的 harness 设计方法 + +所以这次重构不是简单“整理目录”,而是把项目从“围绕一个 CLI 主 agent 生长出来的系统”升级成“所有 agent 共享同一内核的自有 agent harness 平台”。 + +## 2. 我是怎么想的 + +我的核心判断是:我们不能继续把第三方库、业务流程、执行控制、UI/API 接口揉在一起,而是应该先定义我们自己的稳定边界,再让第三方能力挂进来。 + +换句话说,目标不是“把仓库改得更像 swarms / hermes / OpenHarness”,而是: + +1. 用 `swarms` 的强项来解决“团队编排”。 +2. 用 `hermes-agent` 的强项来解决“skills 怎么创建、维护、学习、沉淀”。 +3. 用 `OpenHarness` 的强项来解决“工程边界、模块职责、可维护性”。 +4. 最终收口成我们自己的抽象和目录,而不是长期让第三方结构反向塑造我们。 + +这意味着后续所有设计都应遵守四条原则: + +### 2.1 我们要有自己的抽象 + +不能让业务代码直接依赖: + +- `third_party/swarms` 的导入路径 +- `SwarmRouter` 的参数细节 +- 某个第三方 skill 文件格式 +- 某个第三方 runtime 的副作用 + +我们应该先定义自己的核心对象,例如: + +- `AgentDescriptor` +- `SkillSpec` +- `SkillVersion` +- `TeamSpec` +- `ExecutionPlan` +- `ProcedureRecord` +- `RunRecord` +- `BridgeResult` + +第三方库只能作为 adapter / backend 存在。 + +### 2.2 所有 agent 共享同一套运行内核 + +后面不应该再保留“CLI 单 agent”和“其他 agent 另一套执行方式”这种概念分叉。 + +正确做法应该是: + +1. 所有 agent 都复用同一个 `AgentLoop` / engine。 +2. 主 agent、subagent、team member、A2A local specialist 都只是不同的运行配置和上下文。 +3. tools、skills、memory、permissions、MCP、delegation 都在同一套内核里装载。 +4. CLI 只是一个 interface,作用是把用户输入送进内核,而不是代表一种单独的 agent 类型。 + +这样做的意义是: + +1. 所有 agent 的能力边界一致。 +2. 不会再出现“这个能力只在 CLI 主 agent 可用,子 agent 不一致”的问题。 +3. agent 的差异只存在于 profile / policy / prompt / runtime context,而不是存在于不同执行栈里。 + +### 2.3 Harness 和业务要分开 + +当前很多逻辑混在一起:既有“平台级能力”,也有“具体产品接入”。 + +后面应该分成两层: + +1. Harness 层 + - tool use + - skills + - memory + - delegation + - orchestration + - governance +2. Product / Interface 层 + - web API + - gateway + - channel adapters + - Outlook / WhatsApp / 外部服务接入 + +这样平台能力才能稳定,接入层才能随产品变化而变化。 + +### 2.4 多智能体是平台能力,不是工具技巧 + +现在 `spawn_agent_team` 已经存在,但在结构上还像“一个高级工具”。 + +后面应该把 multi-agent 当成正式 runtime 能力: + +- 有 plan 层 (计划) +- 有 strategy 层 (策略) +- 有 execution backend 层 (执行后端) +- 有 result normalization 层 (结果归一化) +- 有 memory / procedure reuse 层 (内存/过程重用) +- 有 governance / safety / skill constraints (治理/安全/技能限制) + +这里要特别说明 `2.4` 和 `2.5` 的关系: + +1. multi-agent 不是独立于 skills 的第二套指导系统。 +2. 我们仍然保留之前的群组讨论机制,也就是“探索式协作 + 流程化执行”两种能力都保留。 +3. 但无论是探索式 group discussion,还是流程化 sequential / rearrange / hierarchy,都必须受 skills 指引和约束。 +4. 也就是说,skills 决定“应该如何思考、遵守什么边界、优先采用什么方法”,而 multi-agent 负责“由几个人、以什么结构去执行”。 + +所以后续正确关系应是: + +`skills -> 约束与方法指导` +`multi-agent -> 在 skills 约束下进行探索、讨论、流程化执行` + +### 2.5 skills 必须变成生命周期系统 + +现在的 skills 更像可读文档包,适合“手工维护”,不适合“自动学习”。 + +如果以后要做到自动创建、自动修订、自动推荐、自动淘汰,skills 必须具备: + +- 结构化元数据 +- 版本号 +- 来源与 lineage +- 审核状态 +- 效果统计 +- 与 procedure 的映射关系 +- 可回滚、可禁用、可发布 + +并且这里的 `skills` 不应只服务于“工具使用技巧”,而应成为整个 agent 系统的统一指引层,包括: + +1. 主 agent 如何规划和执行 +2. subagent / team member 如何行动 +3. memory 如何参与判断 +4. procedure reuse 如何被触发和约束 +5. multi-agent 讨论时允许采用哪些方法、角色分工和输出习惯 + +换句话说: + +1. memory / procedure reuse 不是独立于 skills 的平行系统。 +2. 但 memory 的实现标准要以 `hermes-agent` 为准,而不是继续沿用当前偏自由发挥的记忆模型。 +3. skills 提供全局行为指引;memory 只保存跨会话仍然有价值的稳定事实;session_search 负责找回历史细节;procedure 只作为可选优化层。 + +这里要明确四者分工: + +1. `skills` + - 指导“怎么做” + - 约束工具使用、讨论方式、流程化执行方式 +2. `memory` + - 保存 durable facts + - 例如用户偏好、环境事实、项目约定、工具 quirks +3. `session_search` + - 检索历史会话细节 + - 不把大量过程细节直接塞进 memory +4. `procedure` + - 作为 coordinator 内部的复用优化 + - 不是主 memory 契约,也不是主要 prompt 注入来源 + +## 3. 现有项目现在是咋样的 + +### 3.1 当前的主结构 + +从代码上看,`app-instance/backend` 当前大致是这几块。 + +注意:下面这些路径仍写作 `nanobot/...`,是因为这里描述的是“现状代码位置”,不是目标命名。 + +1. 启动与装配 + - `nanobot/cli/commands.py` + - `nanobot/__main__.py` +2. agent 运行时 + - `nanobot/agent/loop.py` + - `nanobot/agent/context.py` + - `nanobot/agent/tools/*` + - `nanobot/session/*` + - `nanobot/providers/*` +3. 多 agent / 委派 + - `nanobot/agent/delegation.py` + - `nanobot/agent_team/*` + - `nanobot/a2a/*` +4. Web / Gateway / Channels + - `nanobot/web/server.py` + - `nanobot/channels/*` + - `bridge/` +5. 技能与插件 + - `nanobot/skills/*` + - `nanobot/agent/skills.py` + - `nanobot/agent/plugins.py` +6. 外部运行时耦合点 + - 当前主要是 vendored `swarms` + +### 3.2 当前已经有的优点 + +这套代码不是没基础,相反已经有几个很有价值的雏形: + +1. 已经有 `AgentRegistry`、`DelegationManager`、`agent_team`,说明“统一委派层”思路已经出现。 +2. 已经有 `ProcedureMemory` 和 `RunMemory`,说明“从执行中学习”的基础数据层已经出现。 +3. 已经有 `skills` 的加载、安装、审核,说明“受控扩展机制”已经存在。 +4. 已经有 `SwarmsBridge`、`SwarmsPolicy`、`SwarmsRunPlanner`,说明多智能体桥接已经不是空白。 + +所以这次重构不是推倒重来,而是把这些散落的雏形收敛成一个完整架构。 + +### 3.3 当前最主要的问题 + +#### 问题一:装配逻辑散落 + +同一个后端能力,在 CLI、Web、Gateway 中经常重复装配,甚至行为已经开始漂移。 + +这会导致: + +1. 同样的配置在不同入口行为不同。 +2. 改一个入口容易漏另一个入口。 +3. 测试覆盖变难。 + +#### 问题二:`AgentLoop` 太重,但又没有成为唯一内核 + +`AgentLoop` 已经不是纯 loop,而是“半个 runtime 内核”。 + +这会导致: + +1. 主 agent 与其他 agent 的边界不清。 +2. tool、memory、delegation、session、events 相互缠绕。 +3. 很多能力只能靠继续往 `AgentLoop` 里塞。 +4. 同时又没有真正做到“所有 agent 都统一复用它”。 + +#### 问题三:`swarms` 接入边界不干净,而且 `third_party` 目录本身会持续恶化维护成本 + +当前 `agent_team` 虽然有 bridge,但仍然直接依赖: + +1. `sys.path` 注入 vendored `swarms` +2. 顶层 `swarms` 包导入副作用 +3. `SwarmRouter` 的参数细节 +4. `AutoSwarmBuilder` 自己的 LLM 栈 + +这意味着现在不是“我们调度 swarms”,而是“我们的平台有一部分被 swarms runtime 反向定义了”。 + +另外,`third_party/` 这种目录在这个项目里不应该长期存在。它会带来两个问题: + +1. 仓库边界不清,到底哪些代码是我们的,哪些不是,很难维护。 +2. 一旦改动第三方源码,升级、回滚、排障都会变得更脆弱。 + +#### 问题四:skills 还是静态文档包 + +现在的 skill 系统适合: + +- 展示 +- 人工安装 +- prompt 注入 + +但不适合: + +- 自动学习 +- 自动合并 +- 自动评估 +- 版本回滚 +- 基于效果做选择 + +#### 问题五:接口层和核心层耦合过深 + +`web/server.py` 过大说明一个事实: + +平台内核与外部 API、外部接入、外部服务没有完成分层。 + +## 4. 后面应该怎么改 + +## 4.1 先把系统改成 OpenHarness 风格的能力分组 + +这里我建议明确参考 OpenHarness 那种“按能力分组、核心目录更扁平”的结构,而不是继续按历史演化路径堆目录。 + +核心思路是: + +1. 用 `engine` 作为唯一运行内核。 +2. 用 `coordinator` 负责委派和多 agent 编排。 +3. 用 `tools`、`skills`、`memory`、`permissions` 作为独立能力层。 +4. 用 `interfaces` 只放 CLI / Web / Gateway / Channels 这类入口。 +5. 用 `integrations` 放外部协议和外部系统适配。 + +这样拆完之后,模块关系应变成: + +`interfaces -> engine/coordinator/tools/skills/memory -> foundation` + +而不是像现在这样互相横穿。 + +## 4.2 彻底去掉 `third_party/`,把 `swarms` 改造成可替换 backend + +### 当前状态 + +现在的 `agent_team` 已经接通: + +- `GroupChat` +- `SequentialWorkflow` +- `ConcurrentWorkflow` +- `AgentRearrange` +- `MixtureOfAgents` +- `HierarchicalSwarm` + +但这些能力还不是“平台正式能力集合”,而是“当前 bridge 恰好能跑通的一部分 swarms 类型”。 + +更重要的是,当前它们依赖 `third_party/swarms` 这个 vendored 目录,这是后续必须去掉的。 + +### 目标状态 + +后续应该先定义我们自己的团队执行抽象: + +```text +TeamSpec + -> TeamPlanner + -> ExecutionPlan + -> StrategyBackend + -> NormalizedResult +``` + +然后: + +1. `SwarmsBackend` 只是 `StrategyBackend` 的一个实现。 +2. 平台对外暴露的是自己的策略名和能力矩阵。 +3. `swarms` 只负责执行,不再负责定义平台边界。 +4. 仓库内不再保留 `third_party/`。 +5. `swarms` 要么作为外部依赖安装,要么把真正需要的最小能力内聚到我们自己的 backend 模块中。 + +### 具体改法 + +1. 抽出 `coordinator/backends/base.py` + - 定义统一 backend 接口 +2. 抽出 `coordinator/backends/swarms/` + - 把 `swarms_adapter.py` + - `swarms_bridge.py` + - `swarms_policy.py` + - `swarms_planner.py` 中 swarms 相关逻辑收进去 +3. 在平台层定义正式支持的 strategy + - `group_chat` + - `sequential` + - `concurrent` + - `rearrange` + - `mixture` + - `hierarchical` + - 后续预留 `graph` + - 后续预留 `heavy` +4. 所有 strategy 的输入输出都转成我们的统一模型 + +### 结果 + +改完之后: + +1. `third_party/` 目录消失。 +2. 上层不再知道 `third_party/swarms` 这个路径。 +3. 对上层透明的是 `SwarmsBackend`,不是 vendored 源码目录。 + +## 4.3 把 `skills` 从静态文档升级成能力生命周期系统 + +### 当前状态 + +现在 skill 基本等于: + +- 一个目录 +- 一个 `SKILL.md` +- 一点 frontmatter +- 一点审核流程 + +### 目标状态 + +后续 skill 至少要分成三类对象: + +1. `SkillDraft` + - 自动生成或人工创建 + - 还没发布 +2. `SkillVersion` + - 某个稳定版本 + - 可启用/禁用/回滚 +3. `SkillRuntimeView` + - 当前对模型暴露的生效版本 + +同时 skill 应该带这些元信息: + +- `id` +- `name` +- `version` +- `summary` +- `usage_rules` +- `inputs` +- `outputs` +- `dependencies` +- `source` +- `derived_from_procedure` +- `review_status` +- `metrics` + +### 自动学习建议 + +不要直接让 agent 在线改 live skills。 + +正确链路应该是: + +`run result -> procedure candidate -> skill draft -> review -> publish -> runtime use` + +这比“自动改 `SKILL.md`”安全得多,也更适合生产环境。 + +### 结果 + +改完之后,skills 不再只是 prompt 资源,而是平台知识层的一等对象。 + +## 4.4 以 `hermes-agent` 的 memory 模型为基线重做 memory 层 + +这里要明确:新的 memory 设计不再以当前 `ProcedureMemory` 为中心,而是以 `hermes-agent` 的 memory 模型为准。 + +### 主 memory 契约 + +新的主 memory 契约应是: + +1. 一个统一的 `memory` tool +2. 三个核心动作: + - `add` + - `replace` + - `remove` +3. 两个目标存储: + - `memory`:agent 的环境事实、项目约定、工具经验 + - `user`:用户画像、偏好、习惯、纠正记录 + +它的行为应对齐 Hermes: + +1. `add` + - 追加新条目 + - 精确重复时跳过 + - 超限时返回当前条目和占用情况 +2. `replace` + - 用 `old_text` 的短语义片段匹配条目并整体替换 + - 多条匹配时要求更精确的 `old_text` +3. `remove` + - 也是通过 `old_text` 的语义片段删除 + - 多条匹配时同样要求更精确匹配 + +这里要采用“子串匹配”而不是 UUID,因为这更符合 LLM 的操作习惯。 + +### 写入安全与并发安全 + +新的 memory 层应保留 Hermes 这几个关键约束: + +1. 写入前扫描注入/渗透模式 +2. 在锁内重新从磁盘加载目标文件 +3. 做重复检测和字符上限检测 +4. 通过临时文件 + `os.replace()` 做原子写入 + +也就是说,并发安全的关键不是“先读后写”,而是: + +`scan -> lock -> reload -> validate -> atomic write` + +### 冻结快照模式 + +新的 memory 层必须采用 frozen snapshot,而不是“每次 memory 写入都改 system prompt”。 + +规则是: + +1. 会话开始时,从磁盘加载 `memory` 和 `user` +2. 立刻冻结成 system prompt snapshot +3. 会话中写入 memory 时,只更新磁盘上的 live state +4. 当前会话里的 system prompt 保持不变 +5. 下一个会话开始时,再重新加载最新 memory + +### session_search 取代“把所有过程细节塞进 memory” + +大量过程细节不应继续塞进 `memory`。 + +因此新后端应该明确区分: + +1. `memory` + - 保存小而精的、跨会话稳定有效的事实 +2. `session_search` + - 检索历史会话 + - 支持“无 query 浏览最近会话”和“有 query 的全文搜索 + 摘要” + +这个能力后续应在 Beaver 中落成: + +- `beaver/memory/curated/*` +- `beaver/memory/search/*` +- `beaver/tools/builtins/memory.py` +- `beaver/tools/builtins/session_search.py` + +### `ProcedureMemory` 的新定位 + +这不表示 `ProcedureMemory` 没价值,而是它的地位要下降: + +1. `ProcedureMemory` 不再是主 memory 契约 +2. 它不应该直接承担“跨会话记忆”职责 +3. 它更适合作为 coordinator 内部的流程复用与路由优化层 + +新的优先级应是: + +1. 用户偏好、纠正、环境事实 -> `memory` +2. 历史会话细节 -> `session_search` +3. 稳定方法论和工作法 -> `skills` +4. 团队/流程复用优化 -> `ProcedureMemory` + +## 4.5 CLI 不再代表单 agent 模式,只保留为薄入口 + +当前入口层太厚,后续应该改成: + +1. CLI 只做参数解析与 runtime 启动 +2. Web 只做 API 与 request/response 映射 +3. Gateway 只做渠道接入与消息转发 + +所有核心能力都由统一的 application services 提供,例如: + +- `ChatApplicationService` +- `DelegationApplicationService` +- `TeamRunApplicationService` +- `SkillApplicationService` +- `MemoryApplicationService` + +同时要明确一条原则: + +CLI 不是“单 agent 专用模式”。 + +它只是这些 interface 之一: + +- CLI +- Web +- Gateway +- Channel + +无论从哪个入口进来,最终都进入同一套 `AgentLoop` / engine。 + +这样就不会再出现“CLI 一套 agent,其他入口另一套 agent”的问题。 + +## 5. 具体改动后会是什么样 + +## 5.1 所有 agent 共用同一套 engine + +### 现在 + +`CLI/Web/Gateway -> 各自装配一套 AgentLoop 或相关依赖` + +### 之后 + +`CLI/Web/Gateway/Channel -> AgentEntryService -> AgentLoop(engine) -> tools/skills/memory/permissions/delegation` + +结果是: + +1. 主 agent、subagent、team member 复用同一套 engine。 +2. 装载逻辑只在 engine 内统一处理一次。 +3. 不再保留“CLI 单 agent 概念”。 +4. 测试可以直接测 engine 和 service,而不是分别测入口分支。 + +## 5.2 多 agent 场景 + +### 现在 + +`spawn_agent_team -> DelegationManager -> AgentTeamOrchestrator -> SwarmsPlanner/Bridge -> SwarmRouter` + +### 之后 + +`spawn_agent_team` +`-> DelegationService` +`-> TeamApplicationService` +`-> TeamPlanner` +`-> ExecutionPlan` +`-> StrategyBackendRegistry` +`-> SwarmsBackend` +`-> NormalizedTeamResult` + +结果是: + +1. 团队能力不再绑定某个第三方 runtime 结构。 +2. 可以逐步增加第二种 backend,而不推翻平台层。 +3. `swarms` 只是其中一个可插拔执行器。 + +## 5.3 skill 场景 + +### 现在 + +`SkillsLoader -> 读 SKILL.md -> 摘要注入 / 手动审核安装` + +### 之后 + +`SkillCatalog` +`-> SkillDraftStore` +`-> SkillReviewService` +`-> SkillPublisher` +`-> SkillRuntimeResolver` + +结果是: + +1. skill 可以有版本。 +2. skill 可以从 procedure 生成。 +3. skill 可以审核和回滚。 +4. skill 可以做效果分析和推荐。 + +## 5.4 运行学习场景 + +### 现在 + +`Run details 混在 session / memory / procedure 中` + +### 之后 + +`Run transcript` +`-> session_search index` + +`Durable fact` +`-> memory(add/replace/remove)` + +`Stable method / workaround / reusable workflow` +`-> SkillCandidateGenerator` +`-> SkillDraft` +`-> Review` +`-> Publish` + +`Repeated execution pattern` +`-> optional ProcedureMemory` + +结果是: + +1. durable facts、历史细节、稳定方法三类信息终于分层。 +2. 自动学习不会把临时过程污染到主 memory。 +3. skills 仍是最高层指导系统,而 memory 变成受控 CRUD 系统。 + +## 6. 分阶段落地建议 + +这次重构不应该一次性推翻,建议分四期做。 + +### 第一期:边界清理 + +目标: + +1. 把入口装配统一掉 +2. 把 `web/server.py` 开始拆分 +3. 把 swarms 相关代码聚到单独 backend 目录 + +交付物: + +- 统一 app factory / service wiring +- 初步拆分 web routes +- `orchestration/backends/swarms/` + +### 第二期:平台抽象固化 + +目标: + +1. 定义 team / skill / memory / session_search 的正式模型 +2. 让上层只依赖平台模型 + +交付物: + +- `TeamSpec` +- `SkillSpec` +- `ExecutionPlan` +- `MemoryEntry` +- `MemorySnapshot` +- `SessionSearchResult` +- `SkillDraft` +- `SkillVersion` + +### 第三期:skills 生命周期 + +目标: + +1. 从“文档技能”升级到“版本化能力” +2. 打通“稳定方法 -> SkillDraft” +3. 按 Hermes 基线完成 memory CRUD、frozen snapshot、session_search + +交付物: + +- skill catalog +- review/publish flow +- runtime resolver +- memory tool +- session search tool + +### 第四期:高级多智能体能力 + +目标: + +1. 放开更多正式支持的 strategy +2. 评估 `GraphWorkflow`、`HeavySwarm` +3. 增加 fallback / retry / policy routing + +交付物: + +- 完整 strategy registry +- 多 backend 能力矩阵 +- team execution fallback + +## 7. 重构后的推荐目录 + +下面这个目录我已经按你说的方向收紧了: + +1. 不保留 `third_party/` +2. 不保留“CLI 单 agent”这类结构暗示 +3. 尽量参考 OpenHarness 那种按能力分组、观感更规整的布局 +4. 每个目录后面都加中文说明 + +```text +app-instance/backend/ +├── change.md # 这份重构蓝图 +├── README.md # 后端总说明 +├── workflow.md # 运行链路说明 +├── docs/ # 架构文档和迁移文档 +│ ├── architecture/ # 核心架构说明 +│ └── migration/ # 分阶段迁移计划 +├── beaver/ +│ ├── foundation/ # 最底层公共设施:配置、模型、事件、错误、工具函数 +│ │ ├── config/ # 配置定义与加载 +│ │ ├── models/ # 全局共享数据模型 +│ │ ├── events/ # 统一事件模型与事件派发 +│ │ ├── errors/ # 统一错误类型 +│ │ └── utils/ # 通用工具函数 +│ ├── engine/ # 统一 agent 内核,所有 agent 都复用这里 +│ │ ├── loop.py # AgentLoop 主循环与执行入口 +│ │ ├── loader.py # tools、skills、memory、permissions 的统一装载 +│ │ ├── context/ # 上下文拼装 +│ │ ├── session/ # 会话状态与持久化 +│ │ ├── providers/ # LLM provider 适配 +│ │ └── runtime/ # 运行时辅助对象与执行上下文 +│ ├── tools/ # 工具系统 +│ │ ├── registry/ # 工具注册与发现 +│ │ ├── builtins/ # 内置工具 +│ │ ├── mcp/ # MCP 工具适配 +│ │ └── policies/ # 工具权限与调用约束 +│ ├── skills/ # 技能系统 +│ │ ├── builtin/ # 内置技能内容 +│ │ ├── catalog/ # 技能目录、索引与查询 +│ │ ├── drafts/ # 自动生成或待审核的 skill draft +│ │ ├── reviews/ # 技能审核流 +│ │ ├── publisher/ # 技能发布与版本切换 +│ │ └── resolver/ # 运行时技能解析与注入 +│ ├── memory/ # 记忆与经验沉淀系统 +│ │ ├── curated/ # Hermes 风格的 MEMORY / USER 持久记忆 +│ │ ├── search/ # session_search 与历史会话检索 +│ │ ├── runs/ # 单次执行记录 +│ │ ├── procedures/ # 可选的流程复用优化层 +│ │ └── stores/ # 底层存储与原子写实现 +│ ├── permissions/ # 权限、沙箱、治理规则 +│ │ ├── policies/ # 权限策略 +│ │ ├── guards/ # 执行前检查 +│ │ └── profiles/ # 不同 agent 运行权限画像 +│ ├── coordinator/ # 多 agent 协调层,参考 OpenHarness 的 coordinator 风格 +│ │ ├── delegation/ # 委派与任务分发 +│ │ ├── registry/ # agent registry 与 agent descriptor +│ │ ├── planner/ # 团队 planning 与 execution plan 生成 +│ │ ├── execution/ # 执行控制、fallback、聚合 +│ │ ├── backends/ # 可替换的多 agent backend +│ │ │ ├── base.py # backend 抽象接口 +│ │ │ └── swarms/ # swarms backend 封装,不再直接暴露第三方目录 +│ │ └── team/ # team 级模型与编排对象 +│ ├── services/ # application services,对外提供统一能力入口 +│ │ ├── agent_service.py # 统一 agent 运行入口 +│ │ ├── team_service.py # 多 agent 执行入口 +│ │ ├── skill_service.py # 技能管理入口 +│ │ ├── memory_service.py # memory 查询与写入入口 +│ │ └── admin_service.py # 平台管理入口 +│ ├── interfaces/ # 薄入口层,不承载核心业务 +│ │ ├── cli/ # CLI 入口,只负责把请求送进 services/engine +│ │ ├── web/ # FastAPI 接口层 +│ │ │ ├── app.py # Web app factory +│ │ │ ├── routes/ # 路由拆分 +│ │ │ ├── schemas/ # Web 请求/响应模型 +│ │ │ └── deps.py # Web 依赖装配 +│ │ ├── gateway/ # 常驻 worker / gateway 入口 +│ │ └── channels/ # Telegram/Slack/Email 等渠道入口 +│ ├── integrations/ # 外部系统与协议集成 +│ │ ├── a2a/ # A2A 协议与 client +│ │ ├── mcp/ # MCP 连接与管理 +│ │ ├── outlook/ # Outlook 集成 +│ │ ├── whatsapp/ # WhatsApp bridge 适配 +│ │ └── providers/ # 外部 provider 特定集成 +│ ├── plugins/ # 插件系统 +│ │ ├── loader.py # 插件发现与装载 +│ │ ├── registry.py # 插件注册表 +│ │ └── hooks.py # 插件 hooks +│ └── templates/ # 默认模板、system prompt 模板、内置文本资源 +├── tests/ # 测试 +│ ├── unit/ # 单元测试 +│ ├── integration/ # 集成测试 +│ ├── e2e/ # 端到端测试 +│ └── fixtures/ # 测试数据与夹具 +└── bridge/ # 独立 Node/bridge 代码,作为外部桥接层保留 +``` + +## 8. 最终结论 + +这次重构的本质不是“把代码拆小一点”,而是完成三件事: + +1. 把当前项目从“围绕 `AgentLoop` 生长的单体系统”升级成“所有 agent 共用一个 engine 的可维护 harness 平台”。 +2. 把 `swarms` 从“放在 `third_party/` 里的深耦合运行时”降级成“可替换的多智能体 backend”。 +3. 把 `skills` 从“静态 Markdown 包”升级成“可学习、可审核、可发布、可回滚的能力系统”。 + +如果这三件事做成了,后面再扩多智能体架构、自动学习、插件生态、外部接入,代码就不会继续失控。 diff --git a/app-instance/backend/core_agent_lines.sh b/app-instance/backend-old/core_agent_lines.sh similarity index 100% rename from app-instance/backend/core_agent_lines.sh rename to app-instance/backend-old/core_agent_lines.sh diff --git a/app-instance/backend/docker-compose.yml b/app-instance/backend-old/docker-compose.yml similarity index 100% rename from app-instance/backend/docker-compose.yml rename to app-instance/backend-old/docker-compose.yml diff --git a/app-instance/backend/guide.md b/app-instance/backend-old/guide.md similarity index 100% rename from app-instance/backend/guide.md rename to app-instance/backend-old/guide.md diff --git a/app-instance/backend/nanobot/__init__.py b/app-instance/backend-old/nanobot/__init__.py similarity index 100% rename from app-instance/backend/nanobot/__init__.py rename to app-instance/backend-old/nanobot/__init__.py diff --git a/app-instance/backend/nanobot/__main__.py b/app-instance/backend-old/nanobot/__main__.py similarity index 100% rename from app-instance/backend/nanobot/__main__.py rename to app-instance/backend-old/nanobot/__main__.py diff --git a/app-instance/backend/nanobot/a2a/__init__.py b/app-instance/backend-old/nanobot/a2a/__init__.py similarity index 100% rename from app-instance/backend/nanobot/a2a/__init__.py rename to app-instance/backend-old/nanobot/a2a/__init__.py diff --git a/app-instance/backend/nanobot/a2a/client.py b/app-instance/backend-old/nanobot/a2a/client.py similarity index 100% rename from app-instance/backend/nanobot/a2a/client.py rename to app-instance/backend-old/nanobot/a2a/client.py diff --git a/app-instance/backend/nanobot/agent/__init__.py b/app-instance/backend-old/nanobot/agent/__init__.py similarity index 100% rename from app-instance/backend/nanobot/agent/__init__.py rename to app-instance/backend-old/nanobot/agent/__init__.py diff --git a/app-instance/backend/nanobot/agent/agent_registry.py b/app-instance/backend-old/nanobot/agent/agent_registry.py similarity index 100% rename from app-instance/backend/nanobot/agent/agent_registry.py rename to app-instance/backend-old/nanobot/agent/agent_registry.py diff --git a/app-instance/backend/nanobot/agent/context.py b/app-instance/backend-old/nanobot/agent/context.py similarity index 100% rename from app-instance/backend/nanobot/agent/context.py rename to app-instance/backend-old/nanobot/agent/context.py diff --git a/app-instance/backend/nanobot/agent/delegation.py b/app-instance/backend-old/nanobot/agent/delegation.py similarity index 100% rename from app-instance/backend/nanobot/agent/delegation.py rename to app-instance/backend-old/nanobot/agent/delegation.py diff --git a/app-instance/backend/nanobot/agent/loop.py b/app-instance/backend-old/nanobot/agent/loop.py similarity index 100% rename from app-instance/backend/nanobot/agent/loop.py rename to app-instance/backend-old/nanobot/agent/loop.py diff --git a/app-instance/backend/nanobot/agent/marketplace.py b/app-instance/backend-old/nanobot/agent/marketplace.py similarity index 100% rename from app-instance/backend/nanobot/agent/marketplace.py rename to app-instance/backend-old/nanobot/agent/marketplace.py diff --git a/app-instance/backend/nanobot/agent/memory.py b/app-instance/backend-old/nanobot/agent/memory.py similarity index 100% rename from app-instance/backend/nanobot/agent/memory.py rename to app-instance/backend-old/nanobot/agent/memory.py diff --git a/app-instance/backend/nanobot/agent/plugins.py b/app-instance/backend-old/nanobot/agent/plugins.py similarity index 100% rename from app-instance/backend/nanobot/agent/plugins.py rename to app-instance/backend-old/nanobot/agent/plugins.py diff --git a/app-instance/backend/nanobot/agent/process_events.py b/app-instance/backend-old/nanobot/agent/process_events.py similarity index 100% rename from app-instance/backend/nanobot/agent/process_events.py rename to app-instance/backend-old/nanobot/agent/process_events.py diff --git a/app-instance/backend/nanobot/agent/run_result.py b/app-instance/backend-old/nanobot/agent/run_result.py similarity index 100% rename from app-instance/backend/nanobot/agent/run_result.py rename to app-instance/backend-old/nanobot/agent/run_result.py diff --git a/app-instance/backend/nanobot/agent/skill_reviews.py b/app-instance/backend-old/nanobot/agent/skill_reviews.py similarity index 100% rename from app-instance/backend/nanobot/agent/skill_reviews.py rename to app-instance/backend-old/nanobot/agent/skill_reviews.py diff --git a/app-instance/backend/nanobot/agent/skills.py b/app-instance/backend-old/nanobot/agent/skills.py similarity index 100% rename from app-instance/backend/nanobot/agent/skills.py rename to app-instance/backend-old/nanobot/agent/skills.py diff --git a/app-instance/backend/nanobot/agent/subagent.py b/app-instance/backend-old/nanobot/agent/subagent.py similarity index 100% rename from app-instance/backend/nanobot/agent/subagent.py rename to app-instance/backend-old/nanobot/agent/subagent.py diff --git a/app-instance/backend/nanobot/agent/subagents.py b/app-instance/backend-old/nanobot/agent/subagents.py similarity index 100% rename from app-instance/backend/nanobot/agent/subagents.py rename to app-instance/backend-old/nanobot/agent/subagents.py diff --git a/app-instance/backend/nanobot/agent/tools/__init__.py b/app-instance/backend-old/nanobot/agent/tools/__init__.py similarity index 100% rename from app-instance/backend/nanobot/agent/tools/__init__.py rename to app-instance/backend-old/nanobot/agent/tools/__init__.py diff --git a/app-instance/backend/nanobot/agent/tools/base.py b/app-instance/backend-old/nanobot/agent/tools/base.py similarity index 100% rename from app-instance/backend/nanobot/agent/tools/base.py rename to app-instance/backend-old/nanobot/agent/tools/base.py diff --git a/app-instance/backend/nanobot/agent/tools/cron.py b/app-instance/backend-old/nanobot/agent/tools/cron.py similarity index 100% rename from app-instance/backend/nanobot/agent/tools/cron.py rename to app-instance/backend-old/nanobot/agent/tools/cron.py diff --git a/app-instance/backend/nanobot/agent/tools/cron_action.py b/app-instance/backend-old/nanobot/agent/tools/cron_action.py similarity index 100% rename from app-instance/backend/nanobot/agent/tools/cron_action.py rename to app-instance/backend-old/nanobot/agent/tools/cron_action.py diff --git a/app-instance/backend/nanobot/agent/tools/filesystem.py b/app-instance/backend-old/nanobot/agent/tools/filesystem.py similarity index 100% rename from app-instance/backend/nanobot/agent/tools/filesystem.py rename to app-instance/backend-old/nanobot/agent/tools/filesystem.py diff --git a/app-instance/backend/nanobot/agent/tools/mcp.py b/app-instance/backend-old/nanobot/agent/tools/mcp.py similarity index 100% rename from app-instance/backend/nanobot/agent/tools/mcp.py rename to app-instance/backend-old/nanobot/agent/tools/mcp.py diff --git a/app-instance/backend/nanobot/agent/tools/message.py b/app-instance/backend-old/nanobot/agent/tools/message.py similarity index 100% rename from app-instance/backend/nanobot/agent/tools/message.py rename to app-instance/backend-old/nanobot/agent/tools/message.py diff --git a/app-instance/backend/nanobot/agent/tools/registry.py b/app-instance/backend-old/nanobot/agent/tools/registry.py similarity index 100% rename from app-instance/backend/nanobot/agent/tools/registry.py rename to app-instance/backend-old/nanobot/agent/tools/registry.py diff --git a/app-instance/backend/nanobot/agent/tools/shell.py b/app-instance/backend-old/nanobot/agent/tools/shell.py similarity index 100% rename from app-instance/backend/nanobot/agent/tools/shell.py rename to app-instance/backend-old/nanobot/agent/tools/shell.py diff --git a/app-instance/backend/nanobot/agent/tools/spawn.py b/app-instance/backend-old/nanobot/agent/tools/spawn.py similarity index 100% rename from app-instance/backend/nanobot/agent/tools/spawn.py rename to app-instance/backend-old/nanobot/agent/tools/spawn.py diff --git a/app-instance/backend/nanobot/agent/tools/web.py b/app-instance/backend-old/nanobot/agent/tools/web.py similarity index 100% rename from app-instance/backend/nanobot/agent/tools/web.py rename to app-instance/backend-old/nanobot/agent/tools/web.py diff --git a/app-instance/backend/nanobot/agent_team/__init__.py b/app-instance/backend-old/nanobot/agent_team/__init__.py similarity index 100% rename from app-instance/backend/nanobot/agent_team/__init__.py rename to app-instance/backend-old/nanobot/agent_team/__init__.py diff --git a/app-instance/backend/nanobot/agent_team/memory.py b/app-instance/backend-old/nanobot/agent_team/memory.py similarity index 100% rename from app-instance/backend/nanobot/agent_team/memory.py rename to app-instance/backend-old/nanobot/agent_team/memory.py diff --git a/app-instance/backend/nanobot/agent_team/orchestrator.py b/app-instance/backend-old/nanobot/agent_team/orchestrator.py similarity index 100% rename from app-instance/backend/nanobot/agent_team/orchestrator.py rename to app-instance/backend-old/nanobot/agent_team/orchestrator.py diff --git a/app-instance/backend/nanobot/agent_team/provisioning.py b/app-instance/backend-old/nanobot/agent_team/provisioning.py similarity index 100% rename from app-instance/backend/nanobot/agent_team/provisioning.py rename to app-instance/backend-old/nanobot/agent_team/provisioning.py diff --git a/app-instance/backend/nanobot/agent_team/runtime_pseudocode_flow.md b/app-instance/backend-old/nanobot/agent_team/runtime_pseudocode_flow.md similarity index 100% rename from app-instance/backend/nanobot/agent_team/runtime_pseudocode_flow.md rename to app-instance/backend-old/nanobot/agent_team/runtime_pseudocode_flow.md diff --git a/app-instance/backend/nanobot/agent_team/swarms_adapter.py b/app-instance/backend-old/nanobot/agent_team/swarms_adapter.py similarity index 100% rename from app-instance/backend/nanobot/agent_team/swarms_adapter.py rename to app-instance/backend-old/nanobot/agent_team/swarms_adapter.py diff --git a/app-instance/backend/nanobot/agent_team/swarms_bridge.py b/app-instance/backend-old/nanobot/agent_team/swarms_bridge.py similarity index 100% rename from app-instance/backend/nanobot/agent_team/swarms_bridge.py rename to app-instance/backend-old/nanobot/agent_team/swarms_bridge.py diff --git a/app-instance/backend/nanobot/agent_team/swarms_planner.py b/app-instance/backend-old/nanobot/agent_team/swarms_planner.py similarity index 100% rename from app-instance/backend/nanobot/agent_team/swarms_planner.py rename to app-instance/backend-old/nanobot/agent_team/swarms_planner.py diff --git a/app-instance/backend/nanobot/agent_team/swarms_policy.py b/app-instance/backend-old/nanobot/agent_team/swarms_policy.py similarity index 100% rename from app-instance/backend/nanobot/agent_team/swarms_policy.py rename to app-instance/backend-old/nanobot/agent_team/swarms_policy.py diff --git a/app-instance/backend/nanobot/agent_team/target_resolver.py b/app-instance/backend-old/nanobot/agent_team/target_resolver.py similarity index 100% rename from app-instance/backend/nanobot/agent_team/target_resolver.py rename to app-instance/backend-old/nanobot/agent_team/target_resolver.py diff --git a/app-instance/backend/nanobot/agent_team/types.py b/app-instance/backend-old/nanobot/agent_team/types.py similarity index 100% rename from app-instance/backend/nanobot/agent_team/types.py rename to app-instance/backend-old/nanobot/agent_team/types.py diff --git a/app-instance/backend/nanobot/authz/__init__.py b/app-instance/backend-old/nanobot/authz/__init__.py similarity index 100% rename from app-instance/backend/nanobot/authz/__init__.py rename to app-instance/backend-old/nanobot/authz/__init__.py diff --git a/app-instance/backend/nanobot/authz/client.py b/app-instance/backend-old/nanobot/authz/client.py similarity index 100% rename from app-instance/backend/nanobot/authz/client.py rename to app-instance/backend-old/nanobot/authz/client.py diff --git a/app-instance/backend/nanobot/bus/__init__.py b/app-instance/backend-old/nanobot/bus/__init__.py similarity index 100% rename from app-instance/backend/nanobot/bus/__init__.py rename to app-instance/backend-old/nanobot/bus/__init__.py diff --git a/app-instance/backend/nanobot/bus/events.py b/app-instance/backend-old/nanobot/bus/events.py similarity index 100% rename from app-instance/backend/nanobot/bus/events.py rename to app-instance/backend-old/nanobot/bus/events.py diff --git a/app-instance/backend/nanobot/bus/queue.py b/app-instance/backend-old/nanobot/bus/queue.py similarity index 100% rename from app-instance/backend/nanobot/bus/queue.py rename to app-instance/backend-old/nanobot/bus/queue.py diff --git a/app-instance/backend/nanobot/channels/__init__.py b/app-instance/backend-old/nanobot/channels/__init__.py similarity index 100% rename from app-instance/backend/nanobot/channels/__init__.py rename to app-instance/backend-old/nanobot/channels/__init__.py diff --git a/app-instance/backend/nanobot/channels/base.py b/app-instance/backend-old/nanobot/channels/base.py similarity index 100% rename from app-instance/backend/nanobot/channels/base.py rename to app-instance/backend-old/nanobot/channels/base.py diff --git a/app-instance/backend/nanobot/channels/dingtalk.py b/app-instance/backend-old/nanobot/channels/dingtalk.py similarity index 100% rename from app-instance/backend/nanobot/channels/dingtalk.py rename to app-instance/backend-old/nanobot/channels/dingtalk.py diff --git a/app-instance/backend/nanobot/channels/discord.py b/app-instance/backend-old/nanobot/channels/discord.py similarity index 100% rename from app-instance/backend/nanobot/channels/discord.py rename to app-instance/backend-old/nanobot/channels/discord.py diff --git a/app-instance/backend/nanobot/channels/email.py b/app-instance/backend-old/nanobot/channels/email.py similarity index 100% rename from app-instance/backend/nanobot/channels/email.py rename to app-instance/backend-old/nanobot/channels/email.py diff --git a/app-instance/backend/nanobot/channels/feishu.py b/app-instance/backend-old/nanobot/channels/feishu.py similarity index 100% rename from app-instance/backend/nanobot/channels/feishu.py rename to app-instance/backend-old/nanobot/channels/feishu.py diff --git a/app-instance/backend/nanobot/channels/manager.py b/app-instance/backend-old/nanobot/channels/manager.py similarity index 100% rename from app-instance/backend/nanobot/channels/manager.py rename to app-instance/backend-old/nanobot/channels/manager.py diff --git a/app-instance/backend/nanobot/channels/matrix.py b/app-instance/backend-old/nanobot/channels/matrix.py similarity index 100% rename from app-instance/backend/nanobot/channels/matrix.py rename to app-instance/backend-old/nanobot/channels/matrix.py diff --git a/app-instance/backend/nanobot/channels/mochat.py b/app-instance/backend-old/nanobot/channels/mochat.py similarity index 100% rename from app-instance/backend/nanobot/channels/mochat.py rename to app-instance/backend-old/nanobot/channels/mochat.py diff --git a/app-instance/backend/nanobot/channels/qq.py b/app-instance/backend-old/nanobot/channels/qq.py similarity index 100% rename from app-instance/backend/nanobot/channels/qq.py rename to app-instance/backend-old/nanobot/channels/qq.py diff --git a/app-instance/backend/nanobot/channels/slack.py b/app-instance/backend-old/nanobot/channels/slack.py similarity index 100% rename from app-instance/backend/nanobot/channels/slack.py rename to app-instance/backend-old/nanobot/channels/slack.py diff --git a/app-instance/backend/nanobot/channels/telegram.py b/app-instance/backend-old/nanobot/channels/telegram.py similarity index 100% rename from app-instance/backend/nanobot/channels/telegram.py rename to app-instance/backend-old/nanobot/channels/telegram.py diff --git a/app-instance/backend/nanobot/channels/whatsapp.py b/app-instance/backend-old/nanobot/channels/whatsapp.py similarity index 100% rename from app-instance/backend/nanobot/channels/whatsapp.py rename to app-instance/backend-old/nanobot/channels/whatsapp.py diff --git a/app-instance/backend/nanobot/cli/__init__.py b/app-instance/backend-old/nanobot/cli/__init__.py similarity index 100% rename from app-instance/backend/nanobot/cli/__init__.py rename to app-instance/backend-old/nanobot/cli/__init__.py diff --git a/app-instance/backend/nanobot/cli/commands.py b/app-instance/backend-old/nanobot/cli/commands.py similarity index 100% rename from app-instance/backend/nanobot/cli/commands.py rename to app-instance/backend-old/nanobot/cli/commands.py diff --git a/app-instance/backend/nanobot/config/__init__.py b/app-instance/backend-old/nanobot/config/__init__.py similarity index 100% rename from app-instance/backend/nanobot/config/__init__.py rename to app-instance/backend-old/nanobot/config/__init__.py diff --git a/app-instance/backend/nanobot/config/loader.py b/app-instance/backend-old/nanobot/config/loader.py similarity index 100% rename from app-instance/backend/nanobot/config/loader.py rename to app-instance/backend-old/nanobot/config/loader.py diff --git a/app-instance/backend/nanobot/config/paths.py b/app-instance/backend-old/nanobot/config/paths.py similarity index 100% rename from app-instance/backend/nanobot/config/paths.py rename to app-instance/backend-old/nanobot/config/paths.py diff --git a/app-instance/backend/nanobot/config/schema.py b/app-instance/backend-old/nanobot/config/schema.py similarity index 100% rename from app-instance/backend/nanobot/config/schema.py rename to app-instance/backend-old/nanobot/config/schema.py diff --git a/app-instance/backend/nanobot/cron/__init__.py b/app-instance/backend-old/nanobot/cron/__init__.py similarity index 100% rename from app-instance/backend/nanobot/cron/__init__.py rename to app-instance/backend-old/nanobot/cron/__init__.py diff --git a/app-instance/backend/nanobot/cron/runtime.py b/app-instance/backend-old/nanobot/cron/runtime.py similarity index 100% rename from app-instance/backend/nanobot/cron/runtime.py rename to app-instance/backend-old/nanobot/cron/runtime.py diff --git a/app-instance/backend/nanobot/cron/service.py b/app-instance/backend-old/nanobot/cron/service.py similarity index 100% rename from app-instance/backend/nanobot/cron/service.py rename to app-instance/backend-old/nanobot/cron/service.py diff --git a/app-instance/backend/nanobot/cron/types.py b/app-instance/backend-old/nanobot/cron/types.py similarity index 100% rename from app-instance/backend/nanobot/cron/types.py rename to app-instance/backend-old/nanobot/cron/types.py diff --git a/app-instance/backend/nanobot/heartbeat/__init__.py b/app-instance/backend-old/nanobot/heartbeat/__init__.py similarity index 100% rename from app-instance/backend/nanobot/heartbeat/__init__.py rename to app-instance/backend-old/nanobot/heartbeat/__init__.py diff --git a/app-instance/backend/nanobot/heartbeat/service.py b/app-instance/backend-old/nanobot/heartbeat/service.py similarity index 100% rename from app-instance/backend/nanobot/heartbeat/service.py rename to app-instance/backend-old/nanobot/heartbeat/service.py diff --git a/app-instance/backend/nanobot/llm_audit.py b/app-instance/backend-old/nanobot/llm_audit.py similarity index 100% rename from app-instance/backend/nanobot/llm_audit.py rename to app-instance/backend-old/nanobot/llm_audit.py diff --git a/app-instance/backend/nanobot/providers/__init__.py b/app-instance/backend-old/nanobot/providers/__init__.py similarity index 100% rename from app-instance/backend/nanobot/providers/__init__.py rename to app-instance/backend-old/nanobot/providers/__init__.py diff --git a/app-instance/backend/nanobot/providers/base.py b/app-instance/backend-old/nanobot/providers/base.py similarity index 100% rename from app-instance/backend/nanobot/providers/base.py rename to app-instance/backend-old/nanobot/providers/base.py diff --git a/app-instance/backend/nanobot/providers/custom_provider.py b/app-instance/backend-old/nanobot/providers/custom_provider.py similarity index 100% rename from app-instance/backend/nanobot/providers/custom_provider.py rename to app-instance/backend-old/nanobot/providers/custom_provider.py diff --git a/app-instance/backend/nanobot/providers/litellm_provider.py b/app-instance/backend-old/nanobot/providers/litellm_provider.py similarity index 100% rename from app-instance/backend/nanobot/providers/litellm_provider.py rename to app-instance/backend-old/nanobot/providers/litellm_provider.py diff --git a/app-instance/backend/nanobot/providers/openai_codex_provider.py b/app-instance/backend-old/nanobot/providers/openai_codex_provider.py similarity index 100% rename from app-instance/backend/nanobot/providers/openai_codex_provider.py rename to app-instance/backend-old/nanobot/providers/openai_codex_provider.py diff --git a/app-instance/backend/nanobot/providers/registry.py b/app-instance/backend-old/nanobot/providers/registry.py similarity index 100% rename from app-instance/backend/nanobot/providers/registry.py rename to app-instance/backend-old/nanobot/providers/registry.py diff --git a/app-instance/backend/nanobot/providers/transcription.py b/app-instance/backend-old/nanobot/providers/transcription.py similarity index 100% rename from app-instance/backend/nanobot/providers/transcription.py rename to app-instance/backend-old/nanobot/providers/transcription.py diff --git a/app-instance/backend/nanobot/session/__init__.py b/app-instance/backend-old/nanobot/session/__init__.py similarity index 100% rename from app-instance/backend/nanobot/session/__init__.py rename to app-instance/backend-old/nanobot/session/__init__.py diff --git a/app-instance/backend/nanobot/session/manager.py b/app-instance/backend-old/nanobot/session/manager.py similarity index 100% rename from app-instance/backend/nanobot/session/manager.py rename to app-instance/backend-old/nanobot/session/manager.py diff --git a/app-instance/backend/nanobot/skills/README.md b/app-instance/backend-old/nanobot/skills/README.md similarity index 100% rename from app-instance/backend/nanobot/skills/README.md rename to app-instance/backend-old/nanobot/skills/README.md diff --git a/app-instance/backend/nanobot/skills/clawhub/SKILL.md b/app-instance/backend-old/nanobot/skills/clawhub/SKILL.md similarity index 100% rename from app-instance/backend/nanobot/skills/clawhub/SKILL.md rename to app-instance/backend-old/nanobot/skills/clawhub/SKILL.md diff --git a/app-instance/backend/nanobot/skills/cron/SKILL.md b/app-instance/backend-old/nanobot/skills/cron/SKILL.md similarity index 100% rename from app-instance/backend/nanobot/skills/cron/SKILL.md rename to app-instance/backend-old/nanobot/skills/cron/SKILL.md diff --git a/app-instance/backend/nanobot/skills/github/SKILL.md b/app-instance/backend-old/nanobot/skills/github/SKILL.md similarity index 100% rename from app-instance/backend/nanobot/skills/github/SKILL.md rename to app-instance/backend-old/nanobot/skills/github/SKILL.md diff --git a/app-instance/backend/nanobot/skills/memory/SKILL.md b/app-instance/backend-old/nanobot/skills/memory/SKILL.md similarity index 100% rename from app-instance/backend/nanobot/skills/memory/SKILL.md rename to app-instance/backend-old/nanobot/skills/memory/SKILL.md diff --git a/app-instance/backend/nanobot/skills/outlook/SKILL.md b/app-instance/backend-old/nanobot/skills/outlook/SKILL.md similarity index 100% rename from app-instance/backend/nanobot/skills/outlook/SKILL.md rename to app-instance/backend-old/nanobot/skills/outlook/SKILL.md diff --git a/app-instance/backend/nanobot/skills/skill-creator/SKILL.md b/app-instance/backend-old/nanobot/skills/skill-creator/SKILL.md similarity index 100% rename from app-instance/backend/nanobot/skills/skill-creator/SKILL.md rename to app-instance/backend-old/nanobot/skills/skill-creator/SKILL.md diff --git a/app-instance/backend/nanobot/skills/subagent-manager/SKILL.md b/app-instance/backend-old/nanobot/skills/subagent-manager/SKILL.md similarity index 100% rename from app-instance/backend/nanobot/skills/subagent-manager/SKILL.md rename to app-instance/backend-old/nanobot/skills/subagent-manager/SKILL.md diff --git a/app-instance/backend/nanobot/skills/subagent-manager/scripts/subagentctl.py b/app-instance/backend-old/nanobot/skills/subagent-manager/scripts/subagentctl.py similarity index 100% rename from app-instance/backend/nanobot/skills/subagent-manager/scripts/subagentctl.py rename to app-instance/backend-old/nanobot/skills/subagent-manager/scripts/subagentctl.py diff --git a/app-instance/backend/nanobot/skills/summarize/SKILL.md b/app-instance/backend-old/nanobot/skills/summarize/SKILL.md similarity index 100% rename from app-instance/backend/nanobot/skills/summarize/SKILL.md rename to app-instance/backend-old/nanobot/skills/summarize/SKILL.md diff --git a/app-instance/backend/nanobot/skills/tmux/SKILL.md b/app-instance/backend-old/nanobot/skills/tmux/SKILL.md similarity index 100% rename from app-instance/backend/nanobot/skills/tmux/SKILL.md rename to app-instance/backend-old/nanobot/skills/tmux/SKILL.md diff --git a/app-instance/backend/nanobot/skills/tmux/scripts/find-sessions.sh b/app-instance/backend-old/nanobot/skills/tmux/scripts/find-sessions.sh similarity index 100% rename from app-instance/backend/nanobot/skills/tmux/scripts/find-sessions.sh rename to app-instance/backend-old/nanobot/skills/tmux/scripts/find-sessions.sh diff --git a/app-instance/backend/nanobot/skills/tmux/scripts/wait-for-text.sh b/app-instance/backend-old/nanobot/skills/tmux/scripts/wait-for-text.sh similarity index 100% rename from app-instance/backend/nanobot/skills/tmux/scripts/wait-for-text.sh rename to app-instance/backend-old/nanobot/skills/tmux/scripts/wait-for-text.sh diff --git a/app-instance/backend/nanobot/skills/weather/SKILL.md b/app-instance/backend-old/nanobot/skills/weather/SKILL.md similarity index 100% rename from app-instance/backend/nanobot/skills/weather/SKILL.md rename to app-instance/backend-old/nanobot/skills/weather/SKILL.md diff --git a/app-instance/backend/nanobot/templates/AGENTS.md b/app-instance/backend-old/nanobot/templates/AGENTS.md similarity index 100% rename from app-instance/backend/nanobot/templates/AGENTS.md rename to app-instance/backend-old/nanobot/templates/AGENTS.md diff --git a/app-instance/backend/nanobot/templates/HEARTBEAT.md b/app-instance/backend-old/nanobot/templates/HEARTBEAT.md similarity index 100% rename from app-instance/backend/nanobot/templates/HEARTBEAT.md rename to app-instance/backend-old/nanobot/templates/HEARTBEAT.md diff --git a/app-instance/backend/nanobot/templates/SOUL.md b/app-instance/backend-old/nanobot/templates/SOUL.md similarity index 100% rename from app-instance/backend/nanobot/templates/SOUL.md rename to app-instance/backend-old/nanobot/templates/SOUL.md diff --git a/app-instance/backend/nanobot/templates/TOOLS.md b/app-instance/backend-old/nanobot/templates/TOOLS.md similarity index 100% rename from app-instance/backend/nanobot/templates/TOOLS.md rename to app-instance/backend-old/nanobot/templates/TOOLS.md diff --git a/app-instance/backend/nanobot/templates/USER.md b/app-instance/backend-old/nanobot/templates/USER.md similarity index 100% rename from app-instance/backend/nanobot/templates/USER.md rename to app-instance/backend-old/nanobot/templates/USER.md diff --git a/app-instance/backend/nanobot/templates/__init__.py b/app-instance/backend-old/nanobot/templates/__init__.py similarity index 100% rename from app-instance/backend/nanobot/templates/__init__.py rename to app-instance/backend-old/nanobot/templates/__init__.py diff --git a/app-instance/backend/nanobot/templates/memory/MEMORY.md b/app-instance/backend-old/nanobot/templates/memory/MEMORY.md similarity index 100% rename from app-instance/backend/nanobot/templates/memory/MEMORY.md rename to app-instance/backend-old/nanobot/templates/memory/MEMORY.md diff --git a/app-instance/backend/nanobot/templates/memory/__init__.py b/app-instance/backend-old/nanobot/templates/memory/__init__.py similarity index 100% rename from app-instance/backend/nanobot/templates/memory/__init__.py rename to app-instance/backend-old/nanobot/templates/memory/__init__.py diff --git a/app-instance/backend/nanobot/utils/__init__.py b/app-instance/backend-old/nanobot/utils/__init__.py similarity index 100% rename from app-instance/backend/nanobot/utils/__init__.py rename to app-instance/backend-old/nanobot/utils/__init__.py diff --git a/app-instance/backend/nanobot/utils/helpers.py b/app-instance/backend-old/nanobot/utils/helpers.py similarity index 100% rename from app-instance/backend/nanobot/utils/helpers.py rename to app-instance/backend-old/nanobot/utils/helpers.py diff --git a/app-instance/backend/nanobot/web/__init__.py b/app-instance/backend-old/nanobot/web/__init__.py similarity index 100% rename from app-instance/backend/nanobot/web/__init__.py rename to app-instance/backend-old/nanobot/web/__init__.py diff --git a/app-instance/backend/nanobot/web/files.py b/app-instance/backend-old/nanobot/web/files.py similarity index 100% rename from app-instance/backend/nanobot/web/files.py rename to app-instance/backend-old/nanobot/web/files.py diff --git a/app-instance/backend/nanobot/web/outlook.py b/app-instance/backend-old/nanobot/web/outlook.py similarity index 90% rename from app-instance/backend/nanobot/web/outlook.py rename to app-instance/backend-old/nanobot/web/outlook.py index 68fd6fe..dae5a9e 100644 --- a/app-instance/backend/nanobot/web/outlook.py +++ b/app-instance/backend-old/nanobot/web/outlook.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import importlib import json import os @@ -27,6 +28,23 @@ OUTLOOK_OVERVIEW_EVENT_LIMIT = 20 OUTLOOK_MAX_PAGE_SIZE = 100 +def _read_outlook_timeout(name: str, default: float) -> float: + raw = os.getenv(name, "").strip() + if not raw: + return default + try: + value = float(raw) + except ValueError: + return default + return max(1.0, value) + + +OUTLOOK_MCP_CALL_TIMEOUT_SECONDS = _read_outlook_timeout( + "NANOBOT_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS", + 10.0, +) + + class OutlookIntegrationError(RuntimeError): """Raised when the Outlook integration backend is unavailable or misconfigured.""" @@ -215,6 +233,7 @@ async def _call_outlook_mcp_tool( arguments: dict[str, Any], *, scopes: list[str] | None = None, + timeout_seconds: float | None = None, ) -> dict[str, Any]: from mcp import ClientSession, types from mcp.client.streamable_http import streamable_http_client @@ -239,13 +258,17 @@ async def _call_outlook_mcp_tool( if not access_token: raise OutlookIntegrationError("Failed to obtain an Outlook MCP access token.") - try: + async def _invoke() -> dict[str, Any]: + from mcp import ClientSession, types + from mcp.client.streamable_http import streamable_http_client + async with AsyncExitStack() as stack: http_client = await stack.enter_async_context( httpx.AsyncClient( headers={"Authorization": f"Bearer {access_token}"}, follow_redirects=True, trust_env=False, + timeout=timeout_seconds or OUTLOOK_MCP_CALL_TIMEOUT_SECONDS, ) ) read, write, _ = await stack.enter_async_context( @@ -254,26 +277,61 @@ async def _call_outlook_mcp_tool( session = await stack.enter_async_context(ClientSession(read, write)) await session.initialize() result = await session.call_tool(tool_name, arguments=arguments) - except Exception as exc: # noqa: BLE001 - raise _coerce_outlook_mcp_exception(exc, url=url) from exc - parts: list[str] = [] - for block in result.content: - if isinstance(block, types.TextContent): - parts.append(block.text) - else: - parts.append(str(block)) - output = "\n".join(parts).strip() - if not output: - return {} + parts: list[str] = [] + for block in result.content: + if isinstance(block, types.TextContent): + parts.append(block.text) + else: + parts.append(str(block)) + output = "\n".join(parts).strip() + if not output: + return {} + try: + parsed = json.loads(output) + except json.JSONDecodeError: + return { + "backend_id": backend_id, + "text": output, + } + return parsed if isinstance(parsed, dict) else {"value": parsed} + + timeout_value = timeout_seconds or OUTLOOK_MCP_CALL_TIMEOUT_SECONDS + task = asyncio.create_task(_invoke()) + log_outlook_debug( + "outlook_mcp_call_started", + tool_name=tool_name, + timeout_seconds=timeout_value, + ) + try: - parsed = json.loads(output) - except json.JSONDecodeError: - return { - "backend_id": backend_id, - "text": output, - } - return parsed if isinstance(parsed, dict) else {"value": parsed} + done, _pending = await asyncio.wait({task}, timeout=timeout_value) + if not done: + task.cancel() + log_outlook_debug( + "outlook_mcp_call_timeout", + tool_name=tool_name, + timeout_seconds=timeout_value, + ) + raise OutlookIntegrationError( + f"Outlook MCP 请求超时:{tool_name} 超过 {int(timeout_value)}s" + ) + payload = await task + log_outlook_debug( + "outlook_mcp_call_finished", + tool_name=tool_name, + ) + return payload + except OutlookIntegrationError: + raise + except TimeoutError as exc: + task.cancel() + raise OutlookIntegrationError( + f"Outlook MCP 请求超时:{tool_name} 超过 {int(timeout_value)}s" + ) from exc + except Exception as exc: # noqa: BLE001 + task.cancel() + raise _coerce_outlook_mcp_exception(exc, url=url) from exc def _candidate_roots() -> list[Path]: @@ -777,33 +835,41 @@ async def get_overview(config: Config) -> dict[str, Any]: saved = await _authz_client(config).get_outlook_settings(_require_backend_identity(config)) if not saved.get("configured"): raise OutlookIntegrationError("Outlook is not configured for this backend.") + log_outlook_debug("outlook_overview_started", storage_mode="authz") timezone_name = str(saved.get("default_timezone") or "Asia/Shanghai") now = datetime.now(ZoneInfo(timezone_name)) start_of_day = datetime.combine(now.date(), time.min, tzinfo=now.tzinfo) end_of_day = start_of_day + timedelta(days=1) warnings: list[str] = [] - try: - inbox = await _call_outlook_mcp_tool( + + async def _load_section(label: str, coro: Any) -> tuple[dict[str, Any], str | None]: + try: + payload = await coro + return payload if isinstance(payload, dict) else {"value": []}, None + except Exception as exc: # noqa: BLE001 + return {"value": []}, f"{label} unavailable: {exc}" + + inbox_task = _load_section( + "inbox", + _call_outlook_mcp_tool( config, "mail_list_messages", {"folder": "inbox", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0}, scopes=["list_tools", "tool:mail_list_messages"], - ) - except Exception as exc: # noqa: BLE001 - inbox = {"value": []} - warnings.append(f"inbox unavailable: {exc}") - try: - sent = await _call_outlook_mcp_tool( + ), + ) + sent_task = _load_section( + "sent items", + _call_outlook_mcp_tool( config, "mail_list_messages", {"folder": "sentitems", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0}, scopes=["list_tools", "tool:mail_list_messages"], - ) - except Exception as exc: # noqa: BLE001 - sent = {"value": []} - warnings.append(f"sent items unavailable: {exc}") - try: - calendar = await _call_outlook_mcp_tool( + ), + ) + calendar_task = _load_section( + "calendar", + _call_outlook_mcp_tool( config, "calendar_list_events", { @@ -813,10 +879,20 @@ async def get_overview(config: Config) -> dict[str, Any]: "skip": 0, }, scopes=["list_tools", "tool:calendar_list_events"], - ) - except Exception as exc: # noqa: BLE001 - calendar = {"value": []} - warnings.append(f"calendar unavailable: {exc}") + ), + ) + (inbox, inbox_warning), (sent, sent_warning), (calendar, calendar_warning) = await asyncio.gather( + inbox_task, + sent_task, + calendar_task, + ) + for warning in (inbox_warning, sent_warning, calendar_warning): + if warning: + warnings.append(warning) + log_outlook_debug( + "outlook_overview_finished", + warning_count=len(warnings), + ) meta = _update_meta( config.workspace_path, diff --git a/app-instance/backend/nanobot/web/server.py b/app-instance/backend-old/nanobot/web/server.py similarity index 100% rename from app-instance/backend/nanobot/web/server.py rename to app-instance/backend-old/nanobot/web/server.py diff --git a/app-instance/backend/nanobot_arch.png b/app-instance/backend-old/nanobot_arch.png similarity index 100% rename from app-instance/backend/nanobot_arch.png rename to app-instance/backend-old/nanobot_arch.png diff --git a/app-instance/backend/nanobot_logo.png b/app-instance/backend-old/nanobot_logo.png similarity index 100% rename from app-instance/backend/nanobot_logo.png rename to app-instance/backend-old/nanobot_logo.png diff --git a/app-instance/backend/package-lock.json b/app-instance/backend-old/package-lock.json similarity index 100% rename from app-instance/backend/package-lock.json rename to app-instance/backend-old/package-lock.json diff --git a/app-instance/backend-old/pyproject.toml b/app-instance/backend-old/pyproject.toml new file mode 100644 index 0000000..33facd4 --- /dev/null +++ b/app-instance/backend-old/pyproject.toml @@ -0,0 +1,122 @@ +[project] +name = "nanobot-ai" +version = "0.1.4.post1" +description = "A lightweight personal AI assistant framework" +requires-python = ">=3.11" +license = {text = "MIT"} +authors = [ + {name = "nanobot contributors"} +] +keywords = ["ai", "agent", "chatbot"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] + +dependencies = [ + "typer>=0.20.0,<1.0.0", + "litellm>=1.81.5,<2.0.0", + "pydantic>=2.12.0,<3.0.0", + "pydantic-settings>=2.12.0,<3.0.0", + "websockets>=16.0,<17.0", + "websocket-client>=1.9.0,<2.0.0", + "httpx>=0.28.0,<1.0.0", + "oauth-cli-kit>=0.1.3,<1.0.0", + "loguru>=0.7.3,<1.0.0", + "readability-lxml>=0.8.4,<1.0.0", + "rich>=14.0.0,<15.0.0", + "croniter>=6.0.0,<7.0.0", + "dingtalk-stream>=0.24.0,<1.0.0", + "python-telegram-bot[socks]>=22.0,<23.0", + "lark-oapi>=1.5.0,<2.0.0", + "socksio>=1.0.0,<2.0.0", + "python-socketio>=5.16.0,<6.0.0", + "msgpack>=1.1.0,<2.0.0", + "slack-sdk>=3.39.0,<4.0.0", + "slackify-markdown>=0.2.0,<1.0.0", + "qq-botpy>=1.2.0,<2.0.0", + "python-socks[asyncio]>=2.8.0,<3.0.0", + "prompt-toolkit>=3.0.50,<4.0.0", + "mcp>=1.26.0,<2.0.0", + "json-repair>=0.57.0,<1.0.0", + "fastapi>=0.115.0,<1.0.0", + "uvicorn[standard]>=0.34.0,<1.0.0", + "psutil>=7.2.2", + "python-dotenv>=1.2.1", + "pyyaml>=6.0.3", + "toml>=0.10.2", + "pypdf==5.1.0", + "ratelimit>=2.2.1", + "tenacity>=9.1.4", + "networkx>=3.6.1", + "aiofiles>=24.1.0", + "requests>=2.32.5", + "aiohttp>=3.13.3", + "numpy>=2.4.4", + "schedule>=1.2.2", + "setuptools>=82.0.1", + "chardet<6", +] + +[project.optional-dependencies] +matrix = [ + "matrix-nio[e2e]>=0.25.2", + "mistune>=3.0.0,<4.0.0", + "nh3>=0.2.17,<1.0.0", +] +dev = [ + "pytest>=9.0.0,<10.0.0", + "pytest-asyncio>=1.3.0,<2.0.0", + "ruff>=0.1.0", + "matrix-nio[e2e]>=0.25.2", + "mistune>=3.0.0,<4.0.0", + "nh3>=0.2.17,<1.0.0", +] + +[project.scripts] +nanobot = "nanobot.cli.commands:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["nanobot"] + +[tool.hatch.build.targets.wheel.sources] +"nanobot" = "nanobot" + +# Include non-Python files in skills and templates +[tool.hatch.build] +include = [ + "nanobot/**/*.py", + "nanobot/templates/**/*.md", + "nanobot/skills/**/*.md", + "nanobot/skills/**/*.sh", +] + +[tool.hatch.build.targets.sdist] +include = [ + "nanobot/", + "bridge/", + "README.md", + "LICENSE", +] + +[tool.hatch.build.targets.wheel.force-include] +"bridge" = "nanobot/bridge" + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] +ignore = ["E501"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/app-instance/backend/third_party/swarms b/app-instance/backend-old/third_party/swarms similarity index 100% rename from app-instance/backend/third_party/swarms rename to app-instance/backend-old/third_party/swarms diff --git a/app-instance/backend/uv.lock b/app-instance/backend-old/uv.lock similarity index 100% rename from app-instance/backend/uv.lock rename to app-instance/backend-old/uv.lock diff --git a/app-instance/backend/web_auth_users.json b/app-instance/backend-old/web_auth_users.json similarity index 100% rename from app-instance/backend/web_auth_users.json rename to app-instance/backend-old/web_auth_users.json diff --git a/app-instance/backend/workflow.md b/app-instance/backend-old/workflow.md similarity index 100% rename from app-instance/backend/workflow.md rename to app-instance/backend-old/workflow.md diff --git a/app-instance/backend/鉴权.md b/app-instance/backend-old/鉴权.md similarity index 100% rename from app-instance/backend/鉴权.md rename to app-instance/backend-old/鉴权.md diff --git a/app-instance/backend/README.md b/app-instance/backend/README.md index c161703..bcef4e7 100644 --- a/app-instance/backend/README.md +++ b/app-instance/backend/README.md @@ -1,470 +1,34 @@ -# Boardware Genius Backend +# Beaver Backend -这是 `Boardware Genius` 的后端服务仓库;当前技术命令和包名仍沿用 `nanobot`,但产品品牌按 `Boardware Genius` 表述: +这是新的 `Beaver` 后端代码骨架。 -- `nanobot web`:单用户 FastAPI 后端,供独立前端或 `/docs` 调试使用 -- `nanobot gateway`:常驻 worker,负责渠道接入、cron、heartbeat -- MCP 动态工具接入 -- Outlook 集成:通过外部 `BW_Outlook_Mcp` 服务接入 Microsoft Graph / Exchange EWS -- 工作区文件、技能、插件、代理、MCP 管理等 Web API +旧实现已保留在 [backend-old](/home/ivan/xuan/nano_project/app-instance/backend-old),新目录用于按 [change.md](/home/ivan/xuan/nano_project/app-instance/backend/change.md) 的蓝图逐步重建后端。 -如果你后续要把它打包成 Docker 丢到服务器,这份 README 就是给开发和部署同事看的执行文档。 +当前阶段目标: -## 这套仓库现在是什么 +1. 先建立新的目录边界和包结构。 +2. 明确 `beaver` 作为统一命名。 +3. 以统一 `engine` 为核心,后续让所有 agent 共享同一套运行内核。 -这不是一个自带前端静态页面的全栈仓库,而是后端仓库: +## 当前结构 -- Web 模式启动的是 FastAPI API 服务 -- Gateway 模式启动的是常驻 agent / channel / cron 进程 -- WhatsApp 相关逻辑依赖 `bridge/` 里的 Node 20 bridge -- Outlook 不是仓库内置模块,而是通过外部 `BW_Outlook_Mcp` 仓库接进来 +- `beaver/foundation`:底层公共设施 +- `beaver/engine`:统一 agent 内核 +- `beaver/coordinator`:多 agent 协调层 +- `beaver/tools`:工具系统 +- `beaver/skills`:技能系统 +- `beaver/memory`:记忆与经验沉淀 +- `beaver/permissions`:权限与治理 +- `beaver/services`:应用服务层 +- `beaver/interfaces`:CLI / Web / Gateway / Channels 薄入口 +- `beaver/integrations`:外部系统与协议集成 -更细的执行链路可以看 [workflow.md](./workflow.md)。 +## 说明 -## 目录结构 +这个目录当前还是第一版骨架,不等于完成迁移。 -```text -. -├── nanobot/ # Python 主体:CLI、agent、web、channels、config、MCP -├── bridge/ # WhatsApp bridge(Node 20) -├── tests/ # 测试 -├── Dockerfile # 当前镜像构建文件 -├── docker-compose.yml # 当前自带 compose 示例(偏 gateway / CLI) -└── workflow.md # 运行链路说明 -``` +后续迁移原则: -## 运行模式 - -| 命令 | 用途 | 默认端口 | 适合谁 | -| --- | --- | --- | --- | -| `nanobot agent` | 本地单轮 / 交互调试 | 无 | 开发排查 | -| `nanobot web` | 启动 FastAPI 后端 | `18080` | 独立前端、接口调试、单用户使用 | -| `nanobot gateway` | 启动常驻 worker | 无固定 HTTP 入口 | Telegram/Slack/Email/cron/heartbeat | -| `nanobot status` | 查看配置和 provider 状态 | 无 | 开发、运维 | - -注意: - -- 如果你是给 Web 前端提供后端,请启动 `nanobot web`,不要误用 `gateway` -- `gateway` 当前不是对外 Web API 服务 -- `web` 和 `gateway` 都会碰到同一份 workspace / cron / MCP 状态,通常不要在同一份数据目录上无脑同时跑两套 - -## 环境要求 - -- Python `>=3.11` -- 推荐使用 `uv` -- 如果要构建 WhatsApp bridge 或使用仓库自带 Dockerfile,需要 Node.js `20` - -本地开发最省事的方式: - -```bash -uv sync --extra dev -``` - -如果你不用 `uv`,也可以: - -```bash -python3 -m venv .venv -. .venv/bin/activate -pip install -e ".[dev]" -``` - -## 本地快速启动 - -### 1. 初始化配置 - -```bash -nanobot onboard -``` - -初始化后默认会生成: - -- 配置文件:`~/.nanobot/config.json` -- 工作区:`~/.nanobot/workspace` - -### 2. 填最小配置 - -下面是一份适合服务器环境的最小示例,重点是: - -- 用绝对路径的 workspace -- 建议打开 `restrictToWorkspace` -- 先用 API Key provider,少踩 OAuth 交互坑 - -```json -{ - "agents": { - "defaults": { - "workspace": "/root/.nanobot/workspace", - "model": "openai/gpt-5" - } - }, - "providers": { - "openai": { - "apiKey": "sk-xxxx" - } - }, - "tools": { - "restrictToWorkspace": true - } -} -``` - -如果你不是跑在容器里,把 `/root/.nanobot/workspace` 换成你自己的绝对路径。 - -### 3. 检查配置 - -```bash -nanobot status -``` - -### 4. 本地调试 agent - -```bash -nanobot agent -m "你好" -``` - -### 5. 启动 Web 后端 - -```bash -nanobot web --host 0.0.0.0 --port 18080 -``` - -启动后可直接访问: - -- `http://127.0.0.1:18080/docs` -- `http://127.0.0.1:18080/api/ping` - -## Web API 能力概览 - -当前 `nanobot web` 提供的 API 大致包括: - -- 聊天与流式输出 -- 会话管理 -- cron 任务管理 -- skills / plugins / agents 管理 -- 工作区文件浏览、上传、下载、删除 -- MCP server 管理与测试 -- Outlook 集成状态、连接测试、连接/断开、Overview、Message Detail - -如果你有独立前端,这个后端就是给前端接的;如果没有前端,也可以直接走 `/docs` 调试。 - -## Outlook MCP 集成 - -这是当前仓库里最容易部署时踩坑的一块。 - -### 关系先说清楚 - -当前后端不会自己实现 Outlook 协议,它依赖外部仓库 `BW_Outlook_Mcp`: - -- 后端代码位置:`nanobot/web/outlook.py` -- 默认查找逻辑: - 1. 先看环境变量 `NANOBOT_OUTLOOK_MCP_ROOT` - 2. 再看与本仓库同级目录的 `../BW_Outlook_Mcp` - 3. 如果以上都没有,就尝试直接执行 PATH 里的 `bw-outlook-mcp` - -也就是说,部署同事必须额外把 `BW_Outlook_Mcp` 这个仓库准备好,或者把它直接安装进镜像。 - -### 推荐的两种接法 - -#### 方案 A:把 `BW_Outlook_Mcp` 安装进同一个 Python 环境 - -这是生产环境更稳的方案。 - -部署同事需要: - -```bash -git clone <你们的 BW_Outlook_Mcp 仓库地址> /srv/BW_Outlook_Mcp -cd /srv/BW_Outlook_Mcp -pip install -e . -``` - -安装完成后,容器或宿主机里能直接执行: - -```bash -bw-outlook-mcp --help -``` - -这样 Boardware Genius 就会直接用 PATH 里的 `bw-outlook-mcp`,不依赖额外挂载路径。 - -#### 方案 B:把 `BW_Outlook_Mcp` 作为外部目录挂进来 - -这是开发或临时部署更方便的方案。 - -部署同事需要至少做到两件事: - -1. 把 `BW_Outlook_Mcp` 仓库拉到服务器 -2. 让这个目录里存在一个可执行的 `bw-outlook-mcp` - -最简单的约定是: - -```bash -git clone <你们的 BW_Outlook_Mcp 仓库地址> /srv/BW_Outlook_Mcp -cd /srv/BW_Outlook_Mcp -python3 -m venv .venv -. .venv/bin/activate -pip install -e . -``` - -然后给 Boardware Genius 设置: - -```bash -export NANOBOT_OUTLOOK_MCP_ROOT=/srv/BW_Outlook_Mcp -``` - -因为当前后端会优先寻找: - -```text -$NANOBOT_OUTLOOK_MCP_ROOT/.venv/bin/bw-outlook-mcp -``` - -如果你挂了仓库目录但里面没有 `.venv/bin/bw-outlook-mcp`,那就必须确保 `bw-outlook-mcp` 已经在容器 PATH 里。 - -### Outlook 的认证和配置 - -`BW_Outlook_Mcp` 本身支持两套后端: - -- `graph`:Microsoft 365 / Exchange Online -- `ews`:本地或回迁后的 Exchange Server - -#### Graph 登录 - -```bash -bw-outlook-mcp auth login-graph \ - --workspace /root/.nanobot/workspace \ - --client-id YOUR_CLIENT_ID \ - --tenant-id YOUR_TENANT_ID -``` - -#### EWS 配置 - -```bash -bw-outlook-mcp auth setup-ews \ - --workspace /root/.nanobot/workspace \ - --email you@example.com \ - --username your_username \ - --domain example.com \ - --server mail.example.com -``` - -如果你已经有固定 EWS URL,也可以改用: - -```bash -bw-outlook-mcp auth setup-ews \ - --workspace /root/.nanobot/workspace \ - --email you@example.com \ - --username your_username \ - --service-endpoint https://mail.example.com/EWS/Exchange.asmx -``` - -#### 查看状态 - -```bash -bw-outlook-mcp auth status --workspace /root/.nanobot/workspace -``` - -### Outlook 状态文件会落在哪里 - -所有 Outlook 相关状态默认都落在 workspace 下: - -```text -/state/bw_outlook_mcp/ -├── config.json -├── secrets.json -├── graph_token_cache.bin -├── delta_store.json -└── idempotency.sqlite3 -``` - -所以 Docker 部署时,不要只挂配置文件;要把整份 `~/.nanobot` 或至少 workspace 做持久化。 - -### Nanobot 里如何注册 Outlook MCP - -如果你通过 Web 接口完成 Outlook 连接,后端会自动把 MCP server 注册到配置里。 - -手工写配置时,结构类似这样: - -```json -{ - "tools": { - "mcpServers": { - "outlook": { - "command": "bw-outlook-mcp", - "args": ["serve", "--workspace", "/root/.nanobot/workspace"], - "sensitive": true, - "toolTimeout": 60 - } - } - } -} -``` - -这里一定要用绝对路径,不要写 `~/.nanobot/workspace`。 - -### 可选的 Outlook 环境变量 - -| 变量 | 作用 | -| --- | --- | -| `NANOBOT_OUTLOOK_MCP_ROOT` | 指向外部 `BW_Outlook_Mcp` 仓库目录 | -| `NANOBOT_OUTLOOK_MCP_COMMAND` | 强制指定 `bw-outlook-mcp` 可执行文件 | -| `NANOBOT_OUTLOOK_MCP_EXTRA_ARGS` | 给 `bw-outlook-mcp serve` 追加参数 | -| `NANOBOT_OUTLOOK_DEFAULT_DOMAIN` | Web 连接表单的默认域名 | -| `NANOBOT_OUTLOOK_DEFAULT_EWS_URL` | Web 连接表单默认 EWS 地址 | -| `NANOBOT_OUTLOOK_DEFAULT_EWS_SERVER` | Web 连接表单默认 Exchange 主机 | -| `NANOBOT_OUTLOOK_DEFAULT_TIMEZONE` | Web 连接表单默认时区 | -| `NANOBOT_OUTLOOK_DEFAULT_AUTODISCOVER` | Web 连接表单默认是否启用 autodiscover | - -## Docker 部署 - -### 先说结论 - -服务器部署时,最重要的是持久化这份目录: - -```text -/root/.nanobot -``` - -因为它里面不只是 `config.json`,还包括: - -- workspace -- sessions -- cron 状态 -- Web 登录信息 -- Outlook 状态与 token 缓存 - -### 构建镜像 - -```bash -docker build -t nanobot-backend:latest . -``` - -### 首次初始化 - -第一次跑容器时,先执行一次: - -```bash -docker run --rm \ - -v /srv/nanobot/data:/root/.nanobot \ - nanobot-backend:latest \ - onboard -``` - -然后去编辑宿主机上的: - -```text -/srv/nanobot/data/config.json -``` - -或者先进去执行: - -```bash -docker run --rm -it \ - -v /srv/nanobot/data:/root/.nanobot \ - nanobot-backend:latest \ - status -``` - -### 作为 Web 后端启动 - -如果你是给前端项目配后端,推荐这样跑: - -```bash -docker run -d \ - --name nanobot-web \ - -p 18080:18080 \ - -v /srv/nanobot/data:/root/.nanobot \ - -e NANOBOT_OUTLOOK_MCP_ROOT=/opt/BW_Outlook_Mcp \ - -v /srv/BW_Outlook_Mcp:/opt/BW_Outlook_Mcp \ - nanobot-backend:latest \ - web --host 0.0.0.0 --port 18080 -``` - -如果你已经把 `bw-outlook-mcp` 安装进镜像了,就不需要挂 `/srv/BW_Outlook_Mcp`,也不需要 `NANOBOT_OUTLOOK_MCP_ROOT`。 - -### 作为 Gateway/Worker 启动 - -如果你要接 Telegram / Slack / Email / cron 之类的常驻能力,再跑 gateway: - -```bash -docker run -d \ - --name nanobot-gateway \ - -v /srv/nanobot/data:/root/.nanobot \ - nanobot-backend:latest \ - gateway -``` - -### 推荐的服务器 compose 片段 - -仓库自带的 [docker-compose.yml](./docker-compose.yml) 更偏本地 gateway/CLI 示例。 -如果你是部署 Web 后端到服务器,更建议单独写成这样: - -```yaml -services: - nanobot-web: - image: nanobot-backend:latest - container_name: nanobot-web - command: ["web", "--host", "0.0.0.0", "--port", "18080"] - restart: unless-stopped - ports: - - "18080:18080" - volumes: - - /srv/nanobot/data:/root/.nanobot - - /srv/BW_Outlook_Mcp:/opt/BW_Outlook_Mcp - environment: - NANOBOT_OUTLOOK_MCP_ROOT: /opt/BW_Outlook_Mcp -``` - -如果你想把 Outlook 依赖做得更稳,推荐直接把 `BW_Outlook_Mcp` 安装进镜像,而不是运行时挂载仓库。 - -## 部署给同事时,至少要交代这几件事 - -1. 这是后端仓库,不带前端静态页面,前端请单独部署 -2. Web API 用 `nanobot web` 启动,不是 `gateway` -3. 数据目录必须持久化到 `/root/.nanobot` -4. 如果要 Outlook,必须额外拉取 `BW_Outlook_Mcp` -5. Outlook 有两种接法:装进镜像,或者挂外部仓库并设置 `NANOBOT_OUTLOOK_MCP_ROOT` -6. Outlook 的状态文件也在 workspace 里,删容器不挂卷就会丢 - -## 常用命令 - -```bash -nanobot onboard -nanobot status -nanobot agent -m "你好" -nanobot web --host 0.0.0.0 --port 18080 -nanobot gateway -nanobot provider login openai-codex -``` - -## 开发备注 - -- `workflow.md` 记录了当前代码实际运行链路,和旧版 README 更接近“真实代码” -- `nanobot/web/outlook.py` 是当前 Outlook 集成入口 -- `tests/` 里有 Web API、Email、Docker 相关测试 -- 如果要上服务器,建议在配置里显式打开 `tools.restrictToWorkspace=true` - -## 排错 - -### Web 启动了,但 Outlook 相关接口报错 - -优先检查: - -- `bw-outlook-mcp` 是否能在当前容器里执行 -- `NANOBOT_OUTLOOK_MCP_ROOT` 是否指向正确目录 -- 如果走目录挂载模式,目录里是否真的有 `.venv/bin/bw-outlook-mcp` - -### MCP 注册了,但工具没有出现 - -检查: - -- `config.json` 里的 `tools.mcpServers` -- `nanobot web` 或 `nanobot agent` 启动时是否用了同一份 `~/.nanobot` -- Outlook MCP 是否能单独执行 `bw-outlook-mcp auth status --workspace ...` - -### Docker 里配置改了没生效 - -优先检查你挂载的是不是整份: - -```text -/srv/nanobot/data:/root/.nanobot -``` - -不是只挂了某一个文件。 +1. 不再新增 `nanobot` 命名。 +2. 不在新目录中保留 `third_party/`。 +3. 所有 agent 最终都复用 `beaver.engine`。 diff --git a/app-instance/backend/beaver/__init__.py b/app-instance/backend/beaver/__init__.py new file mode 100644 index 0000000..894ba0c --- /dev/null +++ b/app-instance/backend/beaver/__init__.py @@ -0,0 +1,6 @@ +"""Beaver backend package.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" + diff --git a/app-instance/backend/beaver/coordinator/__init__.py b/app-instance/backend/beaver/coordinator/__init__.py new file mode 100644 index 0000000..6518fe2 --- /dev/null +++ b/app-instance/backend/beaver/coordinator/__init__.py @@ -0,0 +1,2 @@ +"""Multi-agent coordination layer.""" + diff --git a/app-instance/backend/beaver/coordinator/backends/__init__.py b/app-instance/backend/beaver/coordinator/backends/__init__.py new file mode 100644 index 0000000..88735a7 --- /dev/null +++ b/app-instance/backend/beaver/coordinator/backends/__init__.py @@ -0,0 +1,2 @@ +"""Pluggable multi-agent backends.""" + diff --git a/app-instance/backend/beaver/coordinator/backends/base.py b/app-instance/backend/beaver/coordinator/backends/base.py new file mode 100644 index 0000000..8cefaec --- /dev/null +++ b/app-instance/backend/beaver/coordinator/backends/base.py @@ -0,0 +1,20 @@ +"""Backend interfaces for multi-agent execution.""" + +from dataclasses import dataclass +from typing import Protocol + + +@dataclass(slots=True) +class BackendResult: + """Normalized result returned by a coordination backend.""" + + success: bool + summary: str + + +class CoordinationBackend(Protocol): + """Protocol implemented by pluggable coordination backends.""" + + def run(self, task: str) -> BackendResult: + """Execute a team task and return a normalized result.""" + diff --git a/app-instance/backend/beaver/coordinator/backends/swarms/__init__.py b/app-instance/backend/beaver/coordinator/backends/swarms/__init__.py new file mode 100644 index 0000000..295c8bf --- /dev/null +++ b/app-instance/backend/beaver/coordinator/backends/swarms/__init__.py @@ -0,0 +1,6 @@ +"""Swarms backend wrapper for Beaver. + +This package is intentionally local to Beaver's coordinator layer. +There is no `third_party/` directory in the new backend layout. +""" + diff --git a/app-instance/backend/beaver/coordinator/delegation/__init__.py b/app-instance/backend/beaver/coordinator/delegation/__init__.py new file mode 100644 index 0000000..0a34f90 --- /dev/null +++ b/app-instance/backend/beaver/coordinator/delegation/__init__.py @@ -0,0 +1,2 @@ +"""Delegation orchestration.""" + diff --git a/app-instance/backend/beaver/coordinator/execution/__init__.py b/app-instance/backend/beaver/coordinator/execution/__init__.py new file mode 100644 index 0000000..577fdc6 --- /dev/null +++ b/app-instance/backend/beaver/coordinator/execution/__init__.py @@ -0,0 +1,2 @@ +"""Execution control, retry, and aggregation.""" + diff --git a/app-instance/backend/beaver/coordinator/planner/__init__.py b/app-instance/backend/beaver/coordinator/planner/__init__.py new file mode 100644 index 0000000..8e914a0 --- /dev/null +++ b/app-instance/backend/beaver/coordinator/planner/__init__.py @@ -0,0 +1,2 @@ +"""Team planning and execution-plan generation.""" + diff --git a/app-instance/backend/beaver/coordinator/registry/__init__.py b/app-instance/backend/beaver/coordinator/registry/__init__.py new file mode 100644 index 0000000..00a7bc2 --- /dev/null +++ b/app-instance/backend/beaver/coordinator/registry/__init__.py @@ -0,0 +1,2 @@ +"""Agent registry and descriptors.""" + diff --git a/app-instance/backend/beaver/coordinator/team/__init__.py b/app-instance/backend/beaver/coordinator/team/__init__.py new file mode 100644 index 0000000..c68ec36 --- /dev/null +++ b/app-instance/backend/beaver/coordinator/team/__init__.py @@ -0,0 +1,2 @@ +"""Team models and orchestration objects.""" + diff --git a/app-instance/backend/beaver/engine/__init__.py b/app-instance/backend/beaver/engine/__init__.py new file mode 100644 index 0000000..37695c5 --- /dev/null +++ b/app-instance/backend/beaver/engine/__init__.py @@ -0,0 +1,31 @@ +"""Unified Beaver agent engine. + +这里不做顶层 eager import,避免子模块导入时触发循环依赖。 +对外仍然保留同样的导出名称,但改成按需加载。 +""" + +from __future__ import annotations + +from typing import Any + +__all__ = ["AgentLoop", "AgentProfile", "AgentRunResult", "EngineLoader", "EngineLoadResult"] + + +def __getattr__(name: str) -> Any: + if name == "EngineLoader": + from .loader import EngineLoader + + return EngineLoader + if name == "EngineLoadResult": + from .loader import EngineLoadResult + + return EngineLoadResult + if name in {"AgentLoop", "AgentProfile", "AgentRunResult"}: + from .loop import AgentLoop, AgentProfile, AgentRunResult + + return { + "AgentLoop": AgentLoop, + "AgentProfile": AgentProfile, + "AgentRunResult": AgentRunResult, + }[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/app-instance/backend/beaver/engine/context/__init__.py b/app-instance/backend/beaver/engine/context/__init__.py new file mode 100644 index 0000000..090d3a3 --- /dev/null +++ b/app-instance/backend/beaver/engine/context/__init__.py @@ -0,0 +1,17 @@ +"""Context assembly for agent runs.""" + +from .builder import ( + ContextBuildInput, + ContextBuildResult, + ContextBuilder, + SessionContext, + SkillContext, +) + +__all__ = [ + "ContextBuildInput", + "ContextBuildResult", + "ContextBuilder", + "SessionContext", + "SkillContext", +] diff --git a/app-instance/backend/beaver/engine/context/builder.py b/app-instance/backend/beaver/engine/context/builder.py new file mode 100644 index 0000000..64e5270 --- /dev/null +++ b/app-instance/backend/beaver/engine/context/builder.py @@ -0,0 +1,331 @@ +"""Beaver 运行时上下文装配器。 + +这个模块是 `session` 和 `provider` 之间的中间层,职责非常明确: + +1. 把运行前已经准备好的静态/半静态上下文拼成一份稳定的 system prompt +2. 把从 session 事件流里裁剪出的“可见历史”和当前用户输入整理成 provider 可直接消费的 messages +3. 在 tool loop 中,持续把 assistant/tool 消息按统一格式追加回消息数组 + +为什么这层必须单独存在: + +1. `AgentLoop` 不应该自己拼 prompt,否则很快又会长成一个大文件 +2. `memory`、`skills`、`session` 的注入顺序需要固定,否则模型行为会漂移 +3. tool loop 前后追加消息的格式必须统一,否则不同 provider 很容易出兼容问题 + +这一版 builder 的设计目标是“最小但稳定”: + +1. 先服务单 agent 主链 +2. 先支持 frozen curated memory,而不是 live memory +3. skills 按 Hermes 风格支持“显式激活消息注入”,不在这里做磁盘扫描 +4. 为后续 channel / gateway / team metadata 预留注入位,但不提前做复杂逻辑 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from beaver.memory.curated.snapshot import MemorySnapshot + + +@dataclass(slots=True) +class SkillContext: + """单个已激活 skill 的最小表示。 + + 这里故意不把 skill 设计成复杂对象,只保留 builder 真正关心的两部分: + + - `name`:用于生成激活提示 + - `content`:skill 的完整正文 + + 注意:按当前 Hermes 风格实现,skill 正文不再塞进 system prompt,而是转成显式消息注入。 + """ + + name: str + content: str + + +@dataclass(slots=True) +class SessionContext: + """当前运行轮次的会话元数据。 + + 这不是 session store 里的完整 record,而是 prompt builder 关心的那一小部分: + - 哪个 session + - 来源是什么 + - 当前使用什么 model + - 是否有 channel/chat/user 这类运行路由信息 + + 把它单独抽出来的原因是: + 1. builder 不应该知道 SQLite row 长什么样 + 2. 不同入口(CLI/Web/Gateway)都可以把自己的 metadata 收敛成同一种结构 + """ + + session_id: str | None = None + source: str | None = None + model: str | None = None + user_id: str | None = None + channel: str | None = None + chat_id: str | None = None + parent_session_id: str | None = None + + +@dataclass(slots=True) +class ContextBuildInput: + """一次上下文构建所需的全部输入。 + + 这个对象的作用不是“炫技式封装”,而是把主链里零散的数据显式收口。 + 这样一来,后面 `AgentLoop.process_direct()` 在组装参数时会更清晰,也更容易测试。 + + 字段分组: + - 身份/基础段:`base_system_prompt` + - 会话可见历史:`history` + - 当前输入:`current_user_input` + - 冻结记忆:`memory_snapshot` + - 技能:`activated_skills` + - 运行元数据:`session_context` / `execution_context` + - 额外扩展:`extra_sections` + """ + + base_system_prompt: str = "" + history: list[dict[str, Any]] = field(default_factory=list) + current_user_input: str | list[dict[str, Any]] | None = None + memory_snapshot: MemorySnapshot | None = None + activated_skills: list[SkillContext] = field(default_factory=list) + session_context: SessionContext | None = None + execution_context: str | None = None + extra_sections: list[str] = field(default_factory=list) + + +@dataclass(slots=True) +class ContextBuildResult: + """一次上下文构建后的结果。 + + 保留 `system_prompt` 的原因: + 1. `SessionManager.update_system_prompt()` 需要把最终注入的 prompt snapshot 落盘 + 2. 调试时经常需要区分“system prompt 长什么样”和“messages 长什么样” + 3. 后面如果做 prompt audit / replay,也会直接复用这个结果 + """ + + system_prompt: str + messages: list[dict[str, Any]] + + +class ContextBuilder: + """负责把运行时输入装配成稳定上下文。 + + 这一层故意保持“无 IO、无数据库、无网络”: + - 不直接读 session store + - 不直接读 memory store + - 不直接扫描 skills 目录 + + 这样 builder 的行为只由输入决定,便于单测,也便于后面并到真正的 AgentLoop 主链里。 + """ + + def build_system_prompt( + self, + build_input: ContextBuildInput, + ) -> str: + """构建 system prompt。 + + 顺序固定非常重要,当前约定是: + + 1. base system prompt + 2. session metadata + 3. execution context + 4. frozen memory snapshot + 5. extra sections + + 这样设计的原因: + - 身份与总规则要最靠前 + - session/execution 是本轮运行语境,优先级高于长期记忆 + - memory 必须是 frozen snapshot,避免中途写 memory 后 prompt 失真 + - activated skill 正文按 Hermes 风格放到显式消息里,避免 system prompt 持续膨胀 + """ + + sections: list[str] = [] + + base_system_prompt = (build_input.base_system_prompt or "").strip() + if base_system_prompt: + sections.append(base_system_prompt) + + session_section = self._render_session_section(build_input.session_context) + if session_section: + sections.append(session_section) + + execution_context = (build_input.execution_context or "").strip() + if execution_context: + sections.append(f"# Execution Context\n\n{execution_context}") + + if build_input.memory_snapshot is not None: + # 这里明确只读 frozen snapshot,而不是去读 live memory store。 + # 否则一旦当前会话中途写 memory,system prompt 语义就会和会话开头不一致。 + snapshot_sections = build_input.memory_snapshot.as_prompt_sections() + if snapshot_sections: + sections.extend(snapshot_sections) + + for extra in build_input.extra_sections: + cleaned = (extra or "").strip() + if cleaned: + sections.append(cleaned) + + return "\n\n---\n\n".join(sections) + + def build_messages( + self, + build_input: ContextBuildInput, + ) -> ContextBuildResult: + """构建一次模型调用的完整 messages。 + + 这里做三件事: + 1. 先生成最终 system prompt + 2. 按 Hermes 风格,把已激活 skill 的完整正文作为显式消息注入 + 3. 把历史消息按原顺序接到后面 + 4. 如果存在当前用户输入,则把本轮输入追加为最后一条 user message + + 注意: + - `history` 默认被视为“已经由 session/context 上游从完整事件流中裁剪好的可见结构” + - builder 不负责裁剪历史窗口,这件事应由 session/loop 上层决定 + - builder 只做最小格式统一 + """ + + system_prompt = self.build_system_prompt(build_input) + messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}] + + messages.extend(self.build_skill_activation_messages(build_input.activated_skills)) + + for message in build_input.history: + # 当前 builder 自己负责生成唯一的 system prompt。 + # 如果上游 history 已经混入 system 消息,这里要主动跳过,避免双 system。 + if message.get("role") == "system": + continue + messages.append(dict(message)) + + if build_input.current_user_input is not None: + messages.append( + { + "role": "user", + "content": build_input.current_user_input, + } + ) + + return ContextBuildResult( + system_prompt=system_prompt, + messages=messages, + ) + + def add_tool_result( + self, + messages: list[dict[str, Any]], + *, + tool_call_id: str, + tool_name: str, + result: str, + ) -> list[dict[str, Any]]: + """向消息数组追加一条 tool result。 + + 为什么这个函数放在 builder,而不是塞回 `AgentLoop`: + - tool message 的结构必须和 provider 兼容 + - 统一在这里追加,可以避免不同执行路径拼出不同字段名 + - 后面如果要兼容更多 provider 差异,也只改这一层 + + 这里返回原 list 本身,保持旧项目的“可链式追加”习惯。 + """ + + messages.append( + { + "role": "tool", + "tool_call_id": tool_call_id, + "name": tool_name, + "content": result, + } + ) + return messages + + def add_assistant_message( + self, + messages: list[dict[str, Any]], + *, + content: str | None, + tool_calls: list[dict[str, Any]] | None = None, + reasoning_content: str | None = None, + ) -> list[dict[str, Any]]: + """向消息数组追加 assistant 消息。 + + 这里有两个实现细节非常重要: + + 1. 无论 `content` 是否为空,都显式写入 `content` 键 + 原因是部分 provider 在 assistant 带 `tool_calls` 时仍要求消息里存在 `content` + + 2. `reasoning_content` 只有在非空时才附带 + 因为这属于思考模型扩展字段,不应污染普通 provider 路径 + """ + + message: dict[str, Any] = { + "role": "assistant", + "content": content, + } + if tool_calls: + message["tool_calls"] = tool_calls + if reasoning_content is not None: + message["reasoning_content"] = reasoning_content + messages.append(message) + return messages + + def _render_session_section(self, session_context: SessionContext | None) -> str | None: + """把运行时 session metadata 渲染成一个可读 section。 + + 这一段的目标不是让模型“记住所有数据库字段”,而是给它足够的当前运行语境。 + 常见用途包括: + - 知道当前来自 CLI 还是 Web/Gateway + - 知道当前使用什么 model + - 知道当前 channel/chat_id,便于后续多渠道行为约束 + """ + + if session_context is None: + return None + + rows: list[str] = [] + if session_context.session_id: + rows.append(f"Session ID: {session_context.session_id}") + if session_context.source: + rows.append(f"Source: {session_context.source}") + if session_context.model: + rows.append(f"Model: {session_context.model}") + if session_context.user_id: + rows.append(f"User ID: {session_context.user_id}") + if session_context.channel: + rows.append(f"Channel: {session_context.channel}") + if session_context.chat_id: + rows.append(f"Chat ID: {session_context.chat_id}") + if session_context.parent_session_id: + rows.append(f"Parent Session ID: {session_context.parent_session_id}") + + if not rows: + return None + return "# Current Session\n\n" + "\n".join(rows) + + def build_skill_activation_messages(self, activated_skills: list[SkillContext]) -> list[dict[str, str]]: + """按 Hermes 风格把已激活 skill 转成显式消息。 + + 关键区别: + - system prompt 只保留轻量 skills index + - 真正生效的 skill 正文通过额外消息块显式加载 + + 这样模型不需要“从摘要里猜怎么读到正文”,而是直接拿到完整指导内容。 + """ + + messages: list[dict[str, str]] = [] + for skill in activated_skills: + content = (skill.content or "").strip() + if not content: + continue + messages.append( + { + "role": "user", + "content": ( + f'[SYSTEM: The "{skill.name}" skill is active for this run. ' + "Follow its instructions as active guidance unless the user overrides them.]\n\n" + f"{content}" + ), + } + ) + return messages diff --git a/app-instance/backend/beaver/engine/loader.py b/app-instance/backend/beaver/engine/loader.py new file mode 100644 index 0000000..89e0fcb --- /dev/null +++ b/app-instance/backend/beaver/engine/loader.py @@ -0,0 +1,154 @@ +"""Centralized runtime loading for Beaver agents.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable + +from beaver.engine.context import ContextBuilder +from beaver.engine.session import SessionManager +from beaver.memory.curated.store import MemoryStore +from beaver.services.memory_service import MemoryService +from beaver.skills import SkillAssembler, SkillsLoader +from beaver.tools import ObjectBackedTool, ToolExecutor, ToolRegistry +from beaver.tools.builtins import EchoTool, MemoryTool, SessionSearchTool, SkillViewTool + + +@dataclass(slots=True) +class EngineLoadResult: + """描述当前 agent runtime 已经装好的依赖。 + + 这里同时保留两类字段: + 1. `tools/skills/memory_stores/permissions` + - 便于做状态展示、调试、轻量测试 + 2. `session_manager/tool_registry/...` + - 供真正的运行时主链直接使用 + """ + + workspace: Path + tools: list[str] = field(default_factory=list) + skills: list[str] = field(default_factory=list) + memory_stores: list[str] = field(default_factory=list) + permissions: list[str] = field(default_factory=list) + session_manager: SessionManager | None = None + curated_memory_store: MemoryStore | None = None + memory_service: MemoryService | None = None + tool_registry: ToolRegistry | None = None + tool_executor: ToolExecutor | None = None + context_builder: ContextBuilder | None = None + skills_loader: SkillsLoader | None = None + skill_assembler: SkillAssembler | None = None + closeables: list[tuple[str, Callable[[], None]]] = field(default_factory=list, repr=False) + closed: bool = False + + def register_closeable(self, name: str, close_fn: Callable[[], None]) -> None: + """登记一个由 runtime 统一关闭的资源。""" + + self.closeables.append((name, close_fn)) + + def close(self) -> None: + """按后进先出顺序关闭 runtime 资源。 + + 这一步先保持同步、最小、可组合: + 1. 只管理已经明确需要关闭的资源 + 2. 暂不引入 async shutdown 协议 + 3. 为后续 Web/Gateway lifespan 留统一入口 + """ + + if self.closed: + return + + errors: list[tuple[str, BaseException]] = [] + for name, close_fn in reversed(self.closeables): + try: + close_fn() + except BaseException as exc: # pragma: no cover - defensive cleanup path + errors.append((name, exc)) + self.closed = True + + if errors: + parts = ", ".join(f"{name}: {exc}" for name, exc in errors) + raise RuntimeError(f"Runtime shutdown failed for {parts}") + + +class EngineLoader: + """为任意 Beaver agent 装载共享 runtime 能力。 + + 当前先做“最小可运行主链”需要的装配: + - session manager + - curated memory store + - context builder + - built-in tools + - tool executor + + 等主链跑稳后,再把 skills、权限、MCP、delegation 逐步加进来。 + """ + + def __init__( + self, + *, + workspace: str | Path | None = None, + session_manager: SessionManager | None = None, + curated_memory_store: MemoryStore | None = None, + memory_service: MemoryService | None = None, + tool_registry: ToolRegistry | None = None, + context_builder: ContextBuilder | None = None, + skills_loader: SkillsLoader | None = None, + skill_assembler: SkillAssembler | None = None, + ) -> None: + self.workspace = Path(workspace or Path.cwd()) + self._session_manager = session_manager + self._curated_memory_store = curated_memory_store + self._memory_service = memory_service + self._tool_registry = tool_registry + self._context_builder = context_builder + self._skills_loader = skills_loader + self._skill_assembler = skill_assembler + + def load(self) -> EngineLoadResult: + """装配当前主链需要的最小 runtime 对象。""" + + workspace = self.workspace + session_manager = self._session_manager or SessionManager(workspace) + + curated_root = workspace / "memory" / "curated" + curated_memory_store = self._curated_memory_store or MemoryStore(curated_root) + memory_service = self._memory_service or MemoryService(curated_root, store=curated_memory_store) + memory_service.initialize() + + tool_registry = self._tool_registry or ToolRegistry() + skills_loader = self._skills_loader or SkillsLoader(workspace) + if self._tool_registry is None: + # 这里先注册最小工具集,满足主链的 tool loop。 + tool_registry.register_many( + [ + ObjectBackedTool(EchoTool()), + ObjectBackedTool(MemoryTool(store=memory_service.get_store())), + ObjectBackedTool(SkillViewTool(loader=skills_loader)), + ObjectBackedTool(SessionSearchTool(db=session_manager)), + ] + ) + + context_builder = self._context_builder or ContextBuilder() + tool_executor = ToolExecutor(tool_registry) + skill_assembler = self._skill_assembler or SkillAssembler(skills_loader) + + result = EngineLoadResult( + workspace=workspace, + tools=[spec.name for spec in tool_registry.list_specs()], + skills=[record.name for record in skills_loader.list_skills(filter_unavailable=False)], + memory_stores=["curated"], + permissions=[], + session_manager=session_manager, + curated_memory_store=memory_service.get_store(), + memory_service=memory_service, + tool_registry=tool_registry, + tool_executor=tool_executor, + context_builder=context_builder, + skills_loader=skills_loader, + skill_assembler=skill_assembler, + ) + if self._session_manager is None: + result.register_closeable("session_manager", session_manager.close) + return result diff --git a/app-instance/backend/beaver/engine/loop.py b/app-instance/backend/beaver/engine/loop.py new file mode 100644 index 0000000..66afaea --- /dev/null +++ b/app-instance/backend/beaver/engine/loop.py @@ -0,0 +1,689 @@ +"""Unified agent loop used by all Beaver agents.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from typing import Any +from uuid import uuid4 + +from beaver.engine.context import ContextBuildInput, SessionContext +from beaver.engine.providers import ProviderBundle, make_provider_bundle +from beaver.tools import ToolContext + +from .loader import EngineLoader, EngineLoadResult + + +@dataclass(slots=True) +class AgentProfile: + """Runtime profile for a Beaver agent instance.""" + + name: str = "default" + system_prompt: str = "" + default_model: str = "gpt-4.1-mini" + max_tokens: int = 4096 + temperature: float = 0.2 + max_tool_iterations: int = 8 + + +@dataclass(slots=True) +class AgentRunResult: + """一次 direct run 的最小结果结构。""" + + session_id: str + run_id: str + output_text: str + finish_reason: str + tool_iterations: int + provider_name: str | None = None + model: str | None = None + usage: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class _DirectRunRequest: + """运行循环中的单个 direct task。""" + + task: str + kwargs: dict[str, Any] + future: asyncio.Future[AgentRunResult] + + +class AgentLoop: + """Single execution kernel shared by root agents and delegated agents.""" + + def __init__(self, *, profile: AgentProfile | None = None, loader: EngineLoader | None = None) -> None: + self.profile = profile or AgentProfile() + self.loader = loader or EngineLoader() + self.loaded: EngineLoadResult | None = None + self._run_queue: asyncio.Queue[_DirectRunRequest | None] | None = None + self._running = False + self._stop_requested = False + + def boot(self) -> EngineLoadResult: + """Load shared runtime capabilities once for this agent instance.""" + if self.loaded is None: + self.loaded = self.loader.load() + return self.loaded + + @property + def is_running(self) -> bool: + return self._running + + async def run(self) -> None: + """启动最小运行循环,顺序消费提交进来的 direct tasks。 + + 第一版故意保持克制: + 1. 只做单消费者串行消费 + 2. 真正执行仍复用 `process_direct()` + 3. 不引入 bus / worker / priority / retry + """ + + if self._running: + raise RuntimeError("AgentLoop.run() is already active") + + self.boot() + self._run_queue = asyncio.Queue() + self._running = True + self._stop_requested = False + + try: + while True: + item = await self._run_queue.get() + if item is None: + if self._stop_requested: + break + continue + + if item.future.cancelled(): + continue + + try: + result = await self._process_direct_impl(item.task, **item.kwargs) + except asyncio.CancelledError: + if not item.future.done(): + item.future.cancel() + raise + except Exception as exc: # pragma: no cover - defensive queue path + if not item.future.done(): + item.future.set_exception(exc) + else: + if not item.future.done(): + item.future.set_result(result) + finally: + if self._run_queue is not None: + while True: + try: + pending = self._run_queue.get_nowait() + except asyncio.QueueEmpty: + break + if isinstance(pending, _DirectRunRequest) and not pending.future.done(): + pending.future.set_exception( + RuntimeError("AgentLoop.run() stopped before processing the queued task") + ) + self._running = False + self._stop_requested = False + self._run_queue = None + + async def stop(self) -> None: + """停止运行循环。 + + 第一版语义: + - 不再接收新任务 + - 当前已经取出的任务允许收尾 + - 不自动 close runtime + """ + + if not self._running or self._run_queue is None: + return + self._stop_requested = True + await self._run_queue.put(None) + + async def submit_direct( + self, + task: str, + **kwargs: Any, + ) -> AgentRunResult: + """向运行中的 loop 提交一个 direct task,并等待结果。""" + + if not self._running or self._run_queue is None: + raise RuntimeError("AgentLoop.submit_direct() requires an active run() loop") + if self._stop_requested: + raise RuntimeError("AgentLoop.submit_direct() is not accepting new tasks after stop()") + + future: asyncio.Future[AgentRunResult] = asyncio.get_running_loop().create_future() + await self._run_queue.put(_DirectRunRequest(task=task, kwargs=dict(kwargs), future=future)) + return await future + + def close(self) -> None: + """关闭当前 loop 持有的 runtime。 + + 第 6 阶段先把生命周期最小骨架立住: + - `boot()` 负责建立 runtime + - `close()` 负责释放由 runtime 持有的资源 + - 之后再在此基础上扩 `run()/stop()/shutdown hooks` + """ + + if self._running: + raise RuntimeError("AgentLoop.close() requires the run loop to be stopped first") + if self.loaded is None: + return + try: + self.loaded.close() + finally: + self.loaded = None + + async def process_direct( + self, + task: str, + *, + session_id: str | None = None, + source: str = "direct", + user_id: str | None = None, + title: str | None = None, + execution_context: str | None = None, + model: str | None = None, + provider_name: str | None = None, + api_key: str | None = None, + api_base: str | None = None, + extra_headers: dict[str, str] | None = None, + routing: Any = None, + fallback_target: dict[str, Any] | None = None, + auxiliary_target: dict[str, Any] | None = None, + embedding_target: dict[str, Any] | None = None, + embedding_model: str | None = None, + max_tokens: int | None = None, + temperature: float | None = None, + max_tool_iterations: int | None = None, + provider_bundle: ProviderBundle | None = None, + ) -> AgentRunResult: + """跑通最小 direct run 主链。 + + 当前主链刻意保持克制,只解决这些事情: + 1. 确保 session 存在 + 2. 用 frozen memory + history 组 prompt + 3. 调 provider + 4. 若有 tool calls,则进入最小 tool loop + 5. 把 user/assistant/tool 消息和 usage 写回 session + """ + + if self._running: + raise RuntimeError( + "AgentLoop.process_direct() is disabled while run() is active; " + "submit tasks via submit_direct() instead." + ) + return await self._process_direct_impl( + task, + session_id=session_id, + source=source, + user_id=user_id, + title=title, + execution_context=execution_context, + model=model, + provider_name=provider_name, + api_key=api_key, + api_base=api_base, + extra_headers=extra_headers, + routing=routing, + fallback_target=fallback_target, + auxiliary_target=auxiliary_target, + embedding_target=embedding_target, + embedding_model=embedding_model, + max_tokens=max_tokens, + temperature=temperature, + max_tool_iterations=max_tool_iterations, + provider_bundle=provider_bundle, + ) + + async def _process_direct_impl( + self, + task: str, + *, + session_id: str | None = None, + source: str = "direct", + user_id: str | None = None, + title: str | None = None, + execution_context: str | None = None, + model: str | None = None, + provider_name: str | None = None, + api_key: str | None = None, + api_base: str | None = None, + extra_headers: dict[str, str] | None = None, + routing: Any = None, + fallback_target: dict[str, Any] | None = None, + auxiliary_target: dict[str, Any] | None = None, + embedding_target: dict[str, Any] | None = None, + embedding_model: str | None = None, + max_tokens: int | None = None, + temperature: float | None = None, + max_tool_iterations: int | None = None, + provider_bundle: ProviderBundle | None = None, + ) -> AgentRunResult: + """真正执行一轮 direct run 的内部实现。 + + 规则: + - 外部直接调用时走 `process_direct()` + - 运行循环内部消费时走 `_process_direct_impl()` + - 这样才能保证 run 模式下外部不能绕过队列直接执行 + """ + + loaded = self.boot() + session_manager = self._require_loaded("session_manager") + memory_service = self._require_loaded("memory_service") + context_builder = self._require_loaded("context_builder") + tool_registry = self._require_loaded("tool_registry") + tool_executor = self._require_loaded("tool_executor") + skill_assembler = self._require_loaded("skill_assembler") + + resolved_session_id = session_id or uuid4().hex + resolved_run_id = uuid4().hex + resolved_model = model or self.profile.default_model + resolved_max_tokens = max_tokens or self.profile.max_tokens + resolved_temperature = self.profile.temperature if temperature is None else temperature + resolved_max_tool_iterations = ( + self.profile.max_tool_iterations if max_tool_iterations is None else max_tool_iterations + ) + + # 每次新运行开始前都通过 MemoryService 刷新 live state。 + # 这样 memory policy 会收口在 service,而不是散在 loop 里。 + memory_service.reload_for_new_run() + + session_manager.ensure_session( + resolved_session_id, + source=source, + model=resolved_model, + title=title, + user_id=user_id, + ) + session_manager.append_message( + resolved_session_id, + run_id=resolved_run_id, + role="system", + event_type="run_started", + event_payload={ + "source": source, + "model": resolved_model, + "agent_name": self.profile.name, + }, + content=task, + context_visible=False, + source=source, + title=title, + model=resolved_model, + user_id=user_id, + ) + + user_message_recorded = False + iterations = 0 + final_usage: dict[str, Any] = {} + final_provider_name: str | None = provider_name + final_model: str | None = resolved_model + try: + bundle = provider_bundle or make_provider_bundle( + model=resolved_model, + provider_name=provider_name, + api_key=api_key, + api_base=api_base, + extra_headers=extra_headers, + routing=routing, + fallback_target=fallback_target, + auxiliary_target=auxiliary_target, + embedding_target=embedding_target, + embedding_model=embedding_model or "text-embedding-v4", + ) + skill_selector_provider = bundle.auxiliary_provider or bundle.main_provider + skill_selector_model = ( + bundle.auxiliary_runtime.model + if bundle.auxiliary_runtime is not None + else bundle.main_runtime.model + ) + assembled_skills = await skill_assembler.assemble( + task_description=task, + provider=skill_selector_provider, + model=skill_selector_model, + embedding_runtime=bundle.embedding_runtime, + ) + skill_activation_messages = context_builder.build_skill_activation_messages( + assembled_skills.activated_skills + ) + + if skill_activation_messages: + session_manager.append_message( + resolved_session_id, + run_id=resolved_run_id, + role="system", + event_type="skill_activation_snapshotted", + event_payload={ + "activation_messages": skill_activation_messages, + }, + content="\n\n".join(message["content"] for message in skill_activation_messages) or None, + context_visible=False, + source=source, + title=title, + model=resolved_model, + user_id=user_id, + ) + + build_input = ContextBuildInput( + base_system_prompt=self.profile.system_prompt, + history=session_manager.get_history(resolved_session_id), + current_user_input=task, + memory_snapshot=memory_service.get_snapshot(), + activated_skills=assembled_skills.activated_skills, + session_context=SessionContext( + session_id=resolved_session_id, + source=source, + model=resolved_model, + user_id=user_id, + ), + execution_context=execution_context, + ) + context_result = context_builder.build_messages(build_input) + session_manager.update_system_prompt(resolved_session_id, context_result.system_prompt) + session_manager.append_message( + resolved_session_id, + run_id=resolved_run_id, + role="system", + event_type="system_prompt_snapshotted", + event_payload={ + "source": source, + "model": resolved_model, + "system_prompt_length": len(context_result.system_prompt), + }, + content=context_result.system_prompt, + context_visible=False, + source=source, + title=title, + model=resolved_model, + user_id=user_id, + ) + session_manager.append_message( + resolved_session_id, + run_id=resolved_run_id, + role="user", + event_type="user_message_added", + content=task, + source=source, + title=title, + model=resolved_model, + user_id=user_id, + ) + user_message_recorded = True + + provider = bundle.main_provider + messages = list(context_result.messages) + tool_schemas = tool_registry.export_provider_schemas() + tool_context = ToolContext( + workspace=str(loaded.workspace), + session_id=resolved_session_id, + user_id=user_id, + services={ + "session_manager": session_manager, + "memory_service": memory_service, + "memory_store": memory_service.get_store(), + "tool_registry": tool_registry, + }, + metadata={ + "source": source, + "agent_name": self.profile.name, + }, + ) + + final_text = "" + final_finish_reason = "stop" + final_provider_name = bundle.main_runtime.provider_name + final_model = bundle.main_runtime.model + + while True: + response = await provider.chat( + messages=messages, + tools=tool_schemas, + model=final_model, + max_tokens=resolved_max_tokens, + temperature=resolved_temperature, + ) + final_provider_name = response.provider_name or final_provider_name + final_model = response.model or final_model + final_usage = self._merge_usage(final_usage, response.usage or {}) + self._record_usage(session_manager, resolved_session_id, response.usage or {}) + + assistant_tool_calls = self._serialize_tool_calls(response.tool_calls) + session_manager.append_message( + resolved_session_id, + run_id=resolved_run_id, + role="assistant", + event_type="assistant_message_added", + content=response.content, + tool_calls=assistant_tool_calls or None, + finish_reason=response.finish_reason, + reasoning=response.reasoning_content, + source=source, + title=title, + model=final_model, + user_id=user_id, + ) + context_builder.add_assistant_message( + messages, + content=response.content, + tool_calls=assistant_tool_calls or None, + reasoning_content=response.reasoning_content, + ) + + if not response.has_tool_calls: + final_text = response.content or "" + final_finish_reason = response.finish_reason or "stop" + break + + if iterations >= resolved_max_tool_iterations: + final_text = response.content or "Tool loop stopped after reaching the configured iteration limit." + final_finish_reason = "max_tool_iterations" + session_manager.append_message( + resolved_session_id, + run_id=resolved_run_id, + role="assistant", + event_type="assistant_message_added", + content=final_text, + finish_reason=final_finish_reason, + source=source, + title=title, + model=final_model, + user_id=user_id, + ) + context_builder.add_assistant_message( + messages, + content=final_text, + ) + break + + iterations += 1 + for tool_call in response.tool_calls: + result = await tool_executor.execute_tool_call(tool_call, context=tool_context) + session_manager.append_message( + resolved_session_id, + run_id=resolved_run_id, + role="tool", + event_type="tool_result_recorded", + event_payload={ + "success": result.success, + "error": result.error, + }, + content=result.content, + tool_name=result.tool_name, + tool_call_id=tool_call.id, + source=source, + title=title, + model=final_model, + user_id=user_id, + ) + context_builder.add_tool_result( + messages, + tool_call_id=tool_call.id, + tool_name=result.tool_name, + result=result.content, + ) + + session_manager.append_message( + resolved_session_id, + run_id=resolved_run_id, + role="system", + event_type="run_completed", + event_payload={ + "finish_reason": final_finish_reason, + "tool_iterations": iterations, + }, + content=final_text, + finish_reason=final_finish_reason, + context_visible=False, + source=source, + title=title, + model=final_model, + user_id=user_id, + ) + return AgentRunResult( + session_id=resolved_session_id, + run_id=resolved_run_id, + output_text=final_text, + finish_reason=final_finish_reason, + tool_iterations=iterations, + provider_name=final_provider_name, + model=final_model, + usage=final_usage, + ) + except Exception as exc: + if not user_message_recorded: + session_manager.append_message( + resolved_session_id, + run_id=resolved_run_id, + role="user", + event_type="user_message_added", + content=task, + source=source, + title=title, + model=resolved_model, + user_id=user_id, + ) + return self._build_error_result( + session_manager=session_manager, + session_id=resolved_session_id, + run_id=resolved_run_id, + source=source, + title=title, + user_id=user_id, + model=final_model or resolved_model, + message=f"Run failed before completion: {exc}", + tool_iterations=iterations, + provider_name=final_provider_name, + usage=final_usage, + ) + + def _require_loaded(self, field_name: str) -> Any: + loaded = self.boot() + value = getattr(loaded, field_name) + if value is None: + raise RuntimeError(f"Engine loader did not provide required dependency {field_name!r}") + return value + + @staticmethod + def _serialize_tool_calls(tool_calls: list[Any]) -> list[dict[str, Any]]: + payload: list[dict[str, Any]] = [] + for tool_call in tool_calls: + payload.append( + { + "id": tool_call.id, + "type": "function", + "function": { + "name": tool_call.name, + "arguments": tool_call.arguments, + }, + } + ) + return payload + + @staticmethod + def _record_usage(session_manager: Any, session_id: str, usage: dict[str, Any]) -> None: + """把 provider usage 映射到 session usage 字段。 + + 这里先做最常见字段的最小映射: + - prompt_tokens -> input_tokens + - completion_tokens -> output_tokens + + 后面如果 provider 层补了更细的 cache/reasoning/cost,再往这里扩。 + """ + + if not usage: + return + session_manager.update_usage( + session_id, + input_tokens=int(usage.get("input_tokens", usage.get("prompt_tokens", 0)) or 0), + output_tokens=int(usage.get("output_tokens", usage.get("completion_tokens", 0)) or 0), + reasoning_tokens=int(usage.get("reasoning_tokens", 0) or 0), + ) + + @staticmethod + def _merge_usage(total: dict[str, Any], delta: dict[str, Any]) -> dict[str, Any]: + """把多轮 provider usage 合并成一次 run 的累计 usage。""" + + merged = dict(total) + for key, value in delta.items(): + if isinstance(value, (int, float)) and isinstance(merged.get(key, 0), (int, float)): + merged[key] = merged.get(key, 0) + value + else: + merged[key] = value + return merged + + @staticmethod + def _build_error_result( + *, + session_manager: Any, + session_id: str, + run_id: str, + source: str, + title: str | None, + user_id: str | None, + model: str | None, + message: str, + tool_iterations: int, + provider_name: str | None, + usage: dict[str, Any], + ) -> AgentRunResult: + """把主链中的未处理异常收口成可追踪的 assistant error turn。""" + + session_manager.append_message( + session_id, + run_id=run_id, + role="assistant", + event_type="assistant_message_added", + content=message, + finish_reason="error", + source=source, + title=title, + model=model, + user_id=user_id, + ) + session_manager.append_message( + session_id, + run_id=run_id, + role="system", + event_type="run_failed", + event_payload={ + "tool_iterations": tool_iterations, + "provider_name": provider_name, + }, + content=message, + finish_reason="error", + context_visible=False, + source=source, + title=title, + model=model, + user_id=user_id, + ) + return AgentRunResult( + session_id=session_id, + run_id=run_id, + output_text=message, + finish_reason="error", + tool_iterations=tool_iterations, + provider_name=provider_name, + model=model, + usage=usage, + ) diff --git a/app-instance/backend/beaver/engine/providers/__init__.py b/app-instance/backend/beaver/engine/providers/__init__.py new file mode 100644 index 0000000..2d6f5bf --- /dev/null +++ b/app-instance/backend/beaver/engine/providers/__init__.py @@ -0,0 +1,33 @@ +"""LLM provider adapters.""" + +from .base import LLMProvider, LLMResponse, ToolCallRequest +from .chain import FallbackProviderChain +from .factory import ( + ProviderBundle, + ProviderRoutingConfig, + ProviderRuntime, + ProviderTarget, + build_provider_runtime, + make_aux_provider, + make_fallback_provider, + make_main_provider, + make_provider_bundle, + make_provider_from_runtime, +) + +__all__ = [ + "FallbackProviderChain", + "LLMProvider", + "LLMResponse", + "ProviderBundle", + "ProviderRoutingConfig", + "ProviderRuntime", + "ProviderTarget", + "ToolCallRequest", + "build_provider_runtime", + "make_aux_provider", + "make_fallback_provider", + "make_main_provider", + "make_provider_bundle", + "make_provider_from_runtime", +] diff --git a/app-instance/backend/beaver/engine/providers/anthropic.py b/app-instance/backend/beaver/engine/providers/anthropic.py new file mode 100644 index 0000000..60a66c9 --- /dev/null +++ b/app-instance/backend/beaver/engine/providers/anthropic.py @@ -0,0 +1,173 @@ +"""Native Anthropic Messages API provider.""" + +from __future__ import annotations + +import json +from typing import Any + +from .base import LLMProvider, LLMResponse, ToolCallRequest + +try: # pragma: no cover - optional dependency + import anthropic +except ModuleNotFoundError: # pragma: no cover + anthropic = None # type: ignore[assignment] + + +class AnthropicProvider(LLMProvider): + """使用 Anthropic 原生 Messages API,而不是强行走 OpenAI-compatible path。""" + + def __init__( + self, + api_key: str | None = None, + default_model: str = "claude-sonnet-4-5", + api_base: str | None = None, + request_timeout_seconds: float | None = None, + ) -> None: + super().__init__(api_key, api_base, request_timeout_seconds=request_timeout_seconds) + self.default_model = default_model + self._client = None + + def _client_or_raise(self): + if anthropic is None: + raise RuntimeError("anthropic package is not installed") + if self._client is None: + self._client = anthropic.AsyncAnthropic( + api_key=self.api_key, + base_url=self.api_base, + timeout=self.request_timeout_seconds, + ) + return self._client + + async def chat( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + ) -> LLMResponse: + try: + client = self._client_or_raise() + except Exception as exc: + return LLMResponse(content=f"Error: {exc}", finish_reason="error", provider_name="anthropic") + + system_prompt, anthropic_messages = _convert_messages(messages) + kwargs: dict[str, Any] = { + "model": model or self.default_model, + "system": system_prompt or "", + "messages": anthropic_messages, + "max_tokens": max(1, max_tokens), + "temperature": temperature, + } + if tools: + kwargs["tools"] = _convert_tools(tools) + + try: + response = await client.messages.create(**kwargs) + except Exception as exc: + return LLMResponse(content=f"Error: {exc}", finish_reason="error", provider_name="anthropic") + + content_parts: list[str] = [] + tool_calls: list[ToolCallRequest] = [] + for block in response.content: + if block.type == "text": + content_parts.append(block.text) + elif block.type == "tool_use": + tool_calls.append( + ToolCallRequest( + id=block.id, + name=block.name, + arguments=block.input, + ) + ) + usage_payload = {} + if getattr(response, "usage", None): + usage_payload = { + "input_tokens": getattr(response.usage, "input_tokens", 0), + "output_tokens": getattr(response.usage, "output_tokens", 0), + } + return LLMResponse( + content="".join(content_parts) or None, + tool_calls=tool_calls, + finish_reason=getattr(response, "stop_reason", "stop") or "stop", + usage=usage_payload, + provider_name="anthropic", + model=model or self.default_model, + ) + + def get_default_model(self) -> str: + return self.default_model + + +def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]: + system_prompt = "" + converted: list[dict[str, Any]] = [] + for message in messages: + role = message.get("role") + if role == "system": + content = message.get("content") + system_prompt = content if isinstance(content, str) else "" + continue + if role == "tool": + converted.append( + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": message.get("tool_call_id"), + "content": message.get("content") or "", + } + ], + } + ) + continue + if role == "assistant" and message.get("tool_calls"): + content_blocks: list[dict[str, Any]] = [] + if message.get("content"): + content_blocks.append({"type": "text", "text": message["content"]}) + for tool_call in message.get("tool_calls", []): + function = tool_call.get("function", tool_call) + arguments = function.get("arguments") + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except json.JSONDecodeError: + arguments = {} + content_blocks.append( + { + "type": "tool_use", + "id": tool_call.get("id"), + "name": function.get("name"), + "input": arguments or {}, + } + ) + converted.append({"role": "assistant", "content": content_blocks}) + continue + + content = message.get("content") + if isinstance(content, list): + blocks = [] + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + blocks.append({"type": "text", "text": item.get("text", "")}) + converted.append({"role": role, "content": blocks or [{"type": "text", "text": ""}]}) + else: + converted.append({"role": role, "content": content or ""}) + return system_prompt, converted + + +def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]: + converted: list[dict[str, Any]] = [] + for tool in tools: + fn = (tool.get("function") or {}) if tool.get("type") == "function" else tool + if not fn.get("name"): + continue + converted.append( + { + "name": fn["name"], + "description": fn.get("description") or "", + "input_schema": fn.get("parameters") or {"type": "object", "properties": {}}, + } + ) + return converted diff --git a/app-instance/backend/beaver/engine/providers/base.py b/app-instance/backend/beaver/engine/providers/base.py new file mode 100644 index 0000000..98756d8 --- /dev/null +++ b/app-instance/backend/beaver/engine/providers/base.py @@ -0,0 +1,98 @@ +"""Beaver provider 子系统的统一契约。""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(slots=True) +class ToolCallRequest: + """模型返回的一次工具调用请求。""" + + id: str + name: str + arguments: dict[str, Any] + + +@dataclass(slots=True) +class LLMResponse: + """统一的模型响应结构。""" + + content: str | None + tool_calls: list[ToolCallRequest] = field(default_factory=list) + finish_reason: str = "stop" + usage: dict[str, Any] = field(default_factory=dict) + reasoning_content: str | None = None + provider_name: str | None = None + model: str | None = None + + @property + def has_tool_calls(self) -> bool: + return bool(self.tool_calls) + + +class LLMProvider(ABC): + """所有 provider 实现必须遵守的统一接口。""" + + def __init__( + self, + api_key: str | None = None, + api_base: str | None = None, + request_timeout_seconds: float | None = None, + ) -> None: + self.api_key = api_key + self.api_base = api_base + self.request_timeout_seconds = ( + max(1.0, float(request_timeout_seconds)) + if request_timeout_seconds is not None + else None + ) + + @staticmethod + def sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """清理 provider 普遍不接受的空 content。""" + + result: list[dict[str, Any]] = [] + for message in messages: + content = message.get("content") + if isinstance(content, str) and content == "": + clean = dict(message) + clean["content"] = None if (message.get("role") == "assistant" and message.get("tool_calls")) else "(empty)" + result.append(clean) + continue + if isinstance(content, list): + filtered = [ + item + for item in content + if not ( + isinstance(item, dict) + and item.get("type") in ("text", "input_text", "output_text") + and not item.get("text") + ) + ] + if len(filtered) != len(content): + clean = dict(message) + clean["content"] = filtered or "(empty)" + if message.get("role") == "assistant" and message.get("tool_calls") and not filtered: + clean["content"] = None + result.append(clean) + continue + result.append(message) + return result + + @abstractmethod + async def chat( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + ) -> LLMResponse: + """统一聊天接口。""" + + @abstractmethod + def get_default_model(self) -> str: + """返回 provider 的默认模型名。""" diff --git a/app-instance/backend/beaver/engine/providers/chain.py b/app-instance/backend/beaver/engine/providers/chain.py new file mode 100644 index 0000000..7dc60ab --- /dev/null +++ b/app-instance/backend/beaver/engine/providers/chain.py @@ -0,0 +1,145 @@ +"""Provider chain helpers. + +这里先实现最小可用的 fallback chain: +- 每次调用都先尝试主 provider +- 本次调用主 provider 返回 `finish_reason=error` 时,再切到 fallback +- fallback 只影响当前这一次调用,不会污染下一次 run 的首选链路 + +这样后面 `AgentLoop` 不需要自己处理“主模型挂了再换一个 provider”。 +""" + +from __future__ import annotations + +from .base import LLMProvider, LLMResponse +from .runtime import ProviderRuntime + + +class FallbackProviderChain(LLMProvider): + """把 primary/fallback provider 封装成一个统一的 LLMProvider。""" + + def __init__( + self, + primary_runtime: ProviderRuntime, + primary_provider: LLMProvider, + fallback_runtime: ProviderRuntime | None = None, + fallback_provider: LLMProvider | None = None, + ) -> None: + super().__init__( + api_key=primary_runtime.api_key, + api_base=primary_runtime.api_base, + request_timeout_seconds=primary_runtime.request_timeout_seconds, + ) + self.primary_runtime = primary_runtime + self.primary_provider = primary_provider + self.fallback_runtime = fallback_runtime + self.fallback_provider = fallback_provider + # 这里只记录“最近一次 chat 实际用了哪条链”,用于调试和测试。 + # 真正的选路决策必须按调用粒度重新从 primary 开始,不能跨调用粘住 fallback。 + self._last_runtime = primary_runtime + self._last_provider = primary_provider + self._last_call_used_fallback = False + + @property + def fallback_activated(self) -> bool: + """最近一次 chat 是否实际用到了 fallback。""" + + return self._last_call_used_fallback + + @property + def active_runtime(self) -> ProviderRuntime: + """最近一次 chat 实际使用的 runtime。""" + + return self._last_runtime + + async def chat( + self, + messages: list[dict], + tools: list[dict] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + ) -> LLMResponse: + self._last_provider = self.primary_provider + self._last_runtime = self.primary_runtime + self._last_call_used_fallback = False + + response = await self._safe_chat( + self.primary_provider, + self.primary_runtime, + messages=messages, + tools=tools, + model=model or self.primary_runtime.model, + max_tokens=max_tokens, + temperature=temperature, + ) + response = self._decorate_response(response, self.primary_runtime) + if not self._should_activate_fallback(response): + return response + + assert self.fallback_provider is not None + assert self.fallback_runtime is not None + + self._last_provider = self.fallback_provider + self._last_runtime = self.fallback_runtime + self._last_call_used_fallback = True + + response = await self._safe_chat( + self.fallback_provider, + self.fallback_runtime, + messages=messages, + tools=tools, + model=self.fallback_runtime.model, + max_tokens=max_tokens, + temperature=temperature, + ) + return self._decorate_response(response, self.fallback_runtime) + + def get_default_model(self) -> str: + return self.primary_runtime.model + + def _should_activate_fallback(self, response: LLMResponse) -> bool: + return ( + self.fallback_provider is not None + and self.fallback_runtime is not None + and response.finish_reason == "error" + ) + + @staticmethod + async def _safe_chat( + provider: LLMProvider, + runtime: ProviderRuntime, + *, + messages: list[dict], + tools: list[dict] | None, + model: str, + max_tokens: int, + temperature: float, + ) -> LLMResponse: + """把 provider 抛出的异常也收敛成统一 error response。 + + 这样 fallback 的触发条件就不依赖“每个 provider 都记得自己 catch 异常”。 + """ + + try: + return await provider.chat( + messages=messages, + tools=tools, + model=model, + max_tokens=max_tokens, + temperature=temperature, + ) + except Exception as exc: + return LLMResponse( + content=f"Error: {exc}", + finish_reason="error", + provider_name=runtime.provider_name, + model=runtime.model, + ) + + @staticmethod + def _decorate_response(response: LLMResponse, runtime: ProviderRuntime) -> LLMResponse: + if response.provider_name is None: + response.provider_name = runtime.provider_name + if response.model is None: + response.model = runtime.model + return response diff --git a/app-instance/backend/beaver/engine/providers/codex.py b/app-instance/backend/beaver/engine/providers/codex.py new file mode 100644 index 0000000..49ba00f --- /dev/null +++ b/app-instance/backend/beaver/engine/providers/codex.py @@ -0,0 +1,274 @@ +"""OpenAI Codex Responses provider.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from typing import Any, AsyncGenerator + +from .base import LLMProvider, LLMResponse, ToolCallRequest + +try: # pragma: no cover - optional dependency + import httpx +except ModuleNotFoundError: # pragma: no cover + httpx = None # type: ignore[assignment] + +try: # pragma: no cover - optional dependency + from oauth_cli_kit import get_token as get_codex_token +except ModuleNotFoundError: # pragma: no cover + get_codex_token = None # type: ignore[assignment] + +DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses" +DEFAULT_ORIGINATOR = "beaver" + + +class OpenAICodexProvider(LLMProvider): + """使用 Codex OAuth 调用 Responses API。""" + + def __init__( + self, + default_model: str = "openai-codex/gpt-5.1-codex", + request_timeout_seconds: float | None = None, + ) -> None: + super().__init__(api_key=None, api_base=None, request_timeout_seconds=request_timeout_seconds) + self.default_model = default_model + + async def chat( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + ) -> LLMResponse: + if httpx is None or get_codex_token is None: + return LLMResponse(content="Error: codex dependencies are not installed", finish_reason="error", provider_name="openai_codex") + + resolved_model = model or self.default_model + system_prompt, input_items = _convert_messages(messages) + token = await asyncio.to_thread(get_codex_token) + headers = _build_headers(token.account_id, token.access) + body: dict[str, Any] = { + "model": _strip_model_prefix(resolved_model), + "store": False, + "stream": True, + "instructions": system_prompt, + "input": input_items, + "text": {"verbosity": "medium"}, + "include": ["reasoning.encrypted_content"], + "prompt_cache_key": _prompt_cache_key(messages), + "tool_choice": "auto", + "parallel_tool_calls": True, + } + if tools: + body["tools"] = _convert_tools(tools) + + try: + content, tool_calls, finish_reason = await _request_codex( + DEFAULT_CODEX_URL, + headers, + body, + verify=True, + timeout_seconds=self.request_timeout_seconds or 600.0, + ) + except Exception as exc: + return LLMResponse(content=f"Error calling Codex: {exc}", finish_reason="error", provider_name="openai_codex") + + return LLMResponse( + content=content, + tool_calls=tool_calls, + finish_reason=finish_reason, + provider_name="openai_codex", + model=resolved_model, + ) + + def get_default_model(self) -> str: + return self.default_model + + +def _strip_model_prefix(model: str) -> str: + if model.startswith("openai-codex/") or model.startswith("openai_codex/"): + return model.split("/", 1)[1] + return model + + +def _build_headers(account_id: str, token: str) -> dict[str, str]: + return { + "Authorization": f"Bearer {token}", + "chatgpt-account-id": account_id, + "OpenAI-Beta": "responses=experimental", + "originator": DEFAULT_ORIGINATOR, + "User-Agent": "beaver (python)", + "accept": "text/event-stream", + "content-type": "application/json", + } + + +async def _request_codex( + url: str, + headers: dict[str, str], + body: dict[str, Any], + verify: bool, + timeout_seconds: float, +) -> tuple[str, list[ToolCallRequest], str]: + async with httpx.AsyncClient(timeout=timeout_seconds, verify=verify) as client: + async with client.stream("POST", url, headers=headers, json=body) as response: + if response.status_code != 200: + text = await response.aread() + raise RuntimeError(_friendly_error(response.status_code, text.decode("utf-8", "ignore"))) + return await _consume_sse(response) + + +def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]: + converted: list[dict[str, Any]] = [] + for tool in tools: + fn = (tool.get("function") or {}) if tool.get("type") == "function" else tool + name = fn.get("name") + if not name: + continue + params = fn.get("parameters") or {} + converted.append( + { + "type": "function", + "name": name, + "description": fn.get("description") or "", + "parameters": params if isinstance(params, dict) else {}, + } + ) + return converted + + +def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]: + system_prompt = "" + input_items: list[dict[str, Any]] = [] + for index, message in enumerate(messages): + role = message.get("role") + content = message.get("content") + if role == "system": + system_prompt = content if isinstance(content, str) else "" + continue + if role == "user": + input_items.append(_convert_user_message(content)) + continue + if role == "assistant": + if isinstance(content, str) and content: + input_items.append( + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": content}], + "status": "completed", + "id": f"msg_{index}", + } + ) + for tool_call in message.get("tool_calls", []) or []: + fn = tool_call.get("function") or {} + call_id, item_id = _split_tool_call_id(tool_call.get("id")) + input_items.append( + { + "type": "function_call", + "id": item_id or f"fc_{index}", + "call_id": call_id or f"call_{index}", + "name": fn.get("name"), + "arguments": fn.get("arguments") or "{}", + } + ) + continue + if role == "tool": + call_id, _ = _split_tool_call_id(message.get("tool_call_id")) + output_text = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False) + input_items.append( + { + "type": "function_call_output", + "call_id": call_id, + "output": output_text, + } + ) + return system_prompt, input_items + + +def _convert_user_message(content: Any) -> dict[str, Any]: + if isinstance(content, str): + return {"role": "user", "content": [{"type": "input_text", "text": content}]} + if isinstance(content, list): + converted: list[dict[str, Any]] = [] + for item in content: + if not isinstance(item, dict): + continue + if item.get("type") == "text": + converted.append({"type": "input_text", "text": item.get("text", "")}) + elif item.get("type") == "image_url": + url = (item.get("image_url") or {}).get("url") + if url: + converted.append({"type": "input_image", "image_url": url, "detail": "auto"}) + if converted: + return {"role": "user", "content": converted} + return {"role": "user", "content": [{"type": "input_text", "text": ""}]} + + +def _split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]: + if isinstance(tool_call_id, str) and tool_call_id: + if "|" in tool_call_id: + call_id, item_id = tool_call_id.split("|", 1) + return call_id, item_id or None + return tool_call_id, None + return "call_0", None + + +def _prompt_cache_key(messages: list[dict[str, Any]]) -> str: + raw = json.dumps(messages, ensure_ascii=True, sort_keys=True) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +async def _iter_sse(response: Any) -> AsyncGenerator[dict[str, Any], None]: + buffer: list[str] = [] + async for line in response.aiter_lines(): + if line == "": + if buffer: + data_lines = [item[5:].strip() for item in buffer if item.startswith("data:")] + buffer = [] + if not data_lines: + continue + data = "\n".join(data_lines).strip() + if not data or data == "[DONE]": + continue + try: + yield json.loads(data) + except Exception: + continue + continue + buffer.append(line) + + +async def _consume_sse(response: Any) -> tuple[str, list[ToolCallRequest], str]: + content_parts: list[str] = [] + tool_calls: list[ToolCallRequest] = [] + finish_reason = "stop" + async for event in _iter_sse(response): + event_type = event.get("type") + if event_type == "response.output_text.delta": + delta = event.get("delta") or "" + content_parts.append(delta) + elif event_type == "response.output_item.added": + item = event.get("item") or {} + if item.get("type") == "function_call": + raw_arguments = item.get("arguments") or "{}" + try: + arguments = json.loads(raw_arguments) if isinstance(raw_arguments, str) else raw_arguments + except json.JSONDecodeError: + arguments = {} + tool_calls.append( + ToolCallRequest( + id=f"{item.get('call_id', 'call')}|{item.get('id', '')}", + name=item.get("name", ""), + arguments=arguments, + ) + ) + elif event_type == "response.completed": + finish_reason = event.get("response", {}).get("status", "completed") + return "".join(content_parts) or None, tool_calls, finish_reason + + +def _friendly_error(status_code: int, body: str) -> str: + return f"Codex API error ({status_code}): {body[:400]}" diff --git a/app-instance/backend/beaver/engine/providers/custom.py b/app-instance/backend/beaver/engine/providers/custom.py new file mode 100644 index 0000000..3d1fee2 --- /dev/null +++ b/app-instance/backend/beaver/engine/providers/custom.py @@ -0,0 +1,106 @@ +"""Direct OpenAI-compatible provider — bypasses LiteLLM.""" + +from __future__ import annotations + +from typing import Any + +from .base import LLMProvider, LLMResponse, ToolCallRequest + +try: # pragma: no cover - optional dependency + import json_repair +except ModuleNotFoundError: # pragma: no cover + json_repair = None # type: ignore[assignment] + +try: # pragma: no cover - optional dependency + from openai import AsyncOpenAI +except ModuleNotFoundError: # pragma: no cover + AsyncOpenAI = None # type: ignore[assignment] + + +class CustomProvider(LLMProvider): + """直接连接任意 OpenAI-compatible endpoint。""" + + def __init__( + self, + api_key: str = "no-key", + api_base: str = "http://localhost:8000/v1", + default_model: str = "default", + request_timeout_seconds: float | None = None, + ) -> None: + super().__init__(api_key, api_base, request_timeout_seconds=request_timeout_seconds) + self.default_model = default_model + self._client = None + + def _client_or_raise(self): + if AsyncOpenAI is None: + raise RuntimeError("openai package is not installed") + if self._client is None: + self._client = AsyncOpenAI( + api_key=self.api_key, + base_url=self.api_base, + timeout=self.request_timeout_seconds, + ) + return self._client + + async def chat( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + ) -> LLMResponse: + client = self._client_or_raise() + kwargs: dict[str, Any] = { + "model": model or self.default_model, + "messages": self.sanitize_empty_content(messages), + "max_tokens": max(1, max_tokens), + "temperature": temperature, + } + if tools: + kwargs.update(tools=tools, tool_choice="auto") + try: + response = await client.chat.completions.create(**kwargs) + except Exception as exc: + return LLMResponse(content=f"Error: {exc}", finish_reason="error", provider_name="custom") + + choice = response.choices[0] + message = choice.message + parsed_tool_calls: list[ToolCallRequest] = [] + for tool_call in message.tool_calls or []: + raw_arguments = tool_call.function.arguments + if isinstance(raw_arguments, str): + if json_repair is not None: + arguments = json_repair.loads(raw_arguments) + else: + import json + arguments = json.loads(raw_arguments) + else: + arguments = raw_arguments + parsed_tool_calls.append( + ToolCallRequest( + id=tool_call.id, + name=tool_call.function.name, + arguments=arguments, + ) + ) + usage = getattr(response, "usage", None) + usage_payload = {} + if usage is not None: + usage_payload = { + "prompt_tokens": getattr(usage, "prompt_tokens", 0), + "completion_tokens": getattr(usage, "completion_tokens", 0), + "total_tokens": getattr(usage, "total_tokens", 0), + } + return LLMResponse( + content=message.content, + tool_calls=parsed_tool_calls, + finish_reason=choice.finish_reason or "stop", + usage=usage_payload, + reasoning_content=getattr(message, "reasoning_content", None), + provider_name="custom", + model=model or self.default_model, + ) + + def get_default_model(self) -> str: + return self.default_model diff --git a/app-instance/backend/beaver/engine/providers/factory.py b/app-instance/backend/beaver/engine/providers/factory.py new file mode 100644 index 0000000..dab1a81 --- /dev/null +++ b/app-instance/backend/beaver/engine/providers/factory.py @@ -0,0 +1,235 @@ +"""Provider runtime 的统一工厂入口。""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from .anthropic import AnthropicProvider +from .base import LLMProvider +from .chain import FallbackProviderChain +from .codex import OpenAICodexProvider +from .custom import CustomProvider +from .litellm import LiteLLMProvider +from .runtime import ( + ProviderRoutingConfig, + ProviderRuntime, + ProviderTarget, + normalize_provider_target, + resolve_auxiliary_runtime, + resolve_embedding_runtime, + resolve_fallback_runtime, + resolve_provider_runtime, +) + + +@dataclass(slots=True) +class ProviderBundle: + """一次运行所需的 provider 组合。 + + 这里把三条常见链路收口到一起: + - `main`:主对话 + - `fallback`:主链失败后的备用 provider + - `auxiliary`:搜索摘要、压缩、memory flush 等辅助任务 + """ + + main_runtime: ProviderRuntime + main_provider: LLMProvider + fallback_runtime: ProviderRuntime | None = None + fallback_provider: LLMProvider | None = None + auxiliary_runtime: ProviderRuntime | None = None + auxiliary_provider: LLMProvider | None = None + embedding_runtime: ProviderRuntime | None = None + + +def build_provider_runtime(**kwargs: Any) -> ProviderRuntime: + """构建统一 provider runtime。""" + + return resolve_provider_runtime(**kwargs) + + +def make_provider_from_runtime(runtime: ProviderRuntime) -> LLMProvider: + """根据 runtime 创建具体 provider 实例。""" + + if runtime.spec.provider_impl == "custom": + return CustomProvider( + api_key=runtime.api_key or "no-key", + api_base=runtime.api_base or "http://localhost:8000/v1", + default_model=runtime.default_model or runtime.model, + request_timeout_seconds=runtime.request_timeout_seconds, + ) + + if runtime.spec.provider_impl == "codex": + return OpenAICodexProvider( + default_model=runtime.default_model or runtime.model, + request_timeout_seconds=runtime.request_timeout_seconds, + ) + + if runtime.spec.provider_impl == "anthropic": + return AnthropicProvider( + api_key=runtime.api_key, + default_model=runtime.default_model or runtime.model, + api_base=runtime.api_base, + request_timeout_seconds=runtime.request_timeout_seconds, + ) + + return LiteLLMProvider( + api_key=runtime.api_key, + api_base=runtime.api_base, + default_model=runtime.default_model or runtime.model, + provider_name=runtime.provider_name, + extra_headers=runtime.extra_headers, + request_timeout_seconds=runtime.request_timeout_seconds, + routing=runtime.routing, + ) + + +def make_main_provider(**kwargs: Any) -> tuple[ProviderRuntime, LLMProvider]: + """构建主对话 provider。""" + + fallback_target = kwargs.pop("fallback_target", None) + if fallback_target is None and "fallback_model" in kwargs: + fallback_target = kwargs.pop("fallback_model") + + runtime = build_provider_runtime( + auxiliary=False, + fallback_target=fallback_target, + role="main", + source="main_config", + **kwargs, + ) + provider = make_provider_from_runtime(runtime) + fallback_pair = make_fallback_provider(runtime, fallback_target) + if fallback_pair is None: + return runtime, provider + fallback_runtime, fallback_provider = fallback_pair + return runtime, FallbackProviderChain(runtime, provider, fallback_runtime, fallback_provider) + + +def make_fallback_provider( + primary_runtime: ProviderRuntime, + fallback_target: ProviderTarget | dict[str, Any] | None = None, +) -> tuple[ProviderRuntime, LLMProvider] | None: + """构建 fallback provider。""" + + runtime = resolve_fallback_runtime(primary_runtime, fallback_target or primary_runtime.fallback_target) + if runtime is None: + return None + return runtime, make_provider_from_runtime(runtime) + + +def make_aux_provider( + main_runtime: ProviderRuntime | None = None, + *, + target: ProviderTarget | dict[str, Any] | None = None, + task_name: str = "auxiliary", + **kwargs: Any, +) -> tuple[ProviderRuntime, LLMProvider]: + """构建辅助任务 provider。""" + + if target is None and kwargs: + target = kwargs + + if main_runtime is not None: + runtime = resolve_auxiliary_runtime(main_runtime, target, task_name=task_name) + else: + normalized = normalize_provider_target(target) + if normalized is None or not normalized.model: + raise ValueError("Auxiliary provider without main_runtime requires at least a model") + runtime = build_provider_runtime( + model=normalized.model, + provider_name=normalized.provider_name, + api_key=normalized.api_key, + api_base=normalized.api_base, + request_timeout_seconds=normalized.request_timeout_seconds, + extra_headers=normalized.extra_headers, + routing=normalized.routing, + auxiliary=True, + role=task_name, + source="auxiliary_config", + ) + return runtime, make_provider_from_runtime(runtime) + + +def make_embedding_runtime( + main_runtime: ProviderRuntime, + *, + target: ProviderTarget | dict[str, Any] | None = None, + default_model: str = "text-embedding-v4", +) -> ProviderRuntime | None: + """构建 embedding 专用 runtime。""" + + return resolve_embedding_runtime(main_runtime, target=target, default_model=default_model) + + +def make_provider_bundle( + *, + auxiliary_target: ProviderTarget | dict[str, Any] | None = None, + auxiliary_task_name: str = "auxiliary", + embedding_target: ProviderTarget | dict[str, Any] | None = None, + embedding_model: str = "text-embedding-v4", + **kwargs: Any, +) -> ProviderBundle: + """一次性构建 main/fallback/aux 三条 provider 链。""" + + runtime_kwargs = dict(kwargs) + fallback_target = runtime_kwargs.pop("fallback_target", None) + if fallback_target is None and "fallback_model" in kwargs: + fallback_target = runtime_kwargs.pop("fallback_model") + + main_runtime = build_provider_runtime( + auxiliary=False, + fallback_target=fallback_target, + role="main", + source="main_config", + **runtime_kwargs, + ) + primary_provider = make_provider_from_runtime(main_runtime) + fallback_pair = make_fallback_provider(main_runtime, fallback_target) + if fallback_pair is None: + main_provider: LLMProvider = primary_provider + fallback_runtime = None + fallback_provider = None + else: + fallback_runtime, fallback_provider = fallback_pair + main_provider = FallbackProviderChain(main_runtime, primary_provider, fallback_runtime, fallback_provider) + + auxiliary_runtime = None + auxiliary_provider = None + if auxiliary_target is not None: + auxiliary_runtime, auxiliary_provider = make_aux_provider( + main_runtime, + target=auxiliary_target, + task_name=auxiliary_task_name, + ) + + embedding_runtime = make_embedding_runtime( + main_runtime, + target=embedding_target, + default_model=embedding_model, + ) + + return ProviderBundle( + main_runtime=main_runtime, + main_provider=main_provider, + fallback_runtime=fallback_runtime, + fallback_provider=fallback_provider, + auxiliary_runtime=auxiliary_runtime, + auxiliary_provider=auxiliary_provider, + embedding_runtime=embedding_runtime, + ) + + +__all__ = [ + "ProviderBundle", + "ProviderRoutingConfig", + "ProviderRuntime", + "ProviderTarget", + "build_provider_runtime", + "make_aux_provider", + "make_embedding_runtime", + "make_fallback_provider", + "make_main_provider", + "make_provider_bundle", + "make_provider_from_runtime", +] diff --git a/app-instance/backend/beaver/engine/providers/litellm.py b/app-instance/backend/beaver/engine/providers/litellm.py new file mode 100644 index 0000000..2e4b76b --- /dev/null +++ b/app-instance/backend/beaver/engine/providers/litellm.py @@ -0,0 +1,230 @@ +"""LiteLLM provider implementation for multi-provider support.""" + +from __future__ import annotations + +from contextlib import contextmanager +import json +import os +from typing import Any + +from .base import LLMProvider, LLMResponse, ToolCallRequest +from .registry import find_by_model, find_gateway +from .runtime import ProviderRoutingConfig + +try: # pragma: no cover - optional dependency + import json_repair +except ModuleNotFoundError: # pragma: no cover + json_repair = None # type: ignore[assignment] + +try: # pragma: no cover - optional dependency + import litellm + from litellm import acompletion +except ModuleNotFoundError: # pragma: no cover + litellm = None # type: ignore[assignment] + acompletion = None # type: ignore[assignment] + +_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name"}) + + +class LiteLLMProvider(LLMProvider): + """通过 LiteLLM 统一访问大多数 provider。""" + + def __init__( + self, + api_key: str | None = None, + api_base: str | None = None, + default_model: str = "anthropic/claude-opus-4-5", + extra_headers: dict[str, str] | None = None, + provider_name: str | None = None, + request_timeout_seconds: float | None = None, + routing: ProviderRoutingConfig | None = None, + ) -> None: + super().__init__(api_key, api_base, request_timeout_seconds=request_timeout_seconds) + self.default_model = default_model + self.extra_headers = extra_headers or {} + self.routing = routing + self.provider_name = provider_name + self._gateway = find_gateway(provider_name, api_key, api_base) + if litellm is not None: + litellm.suppress_debug_info = True + litellm.drop_params = True + + def _build_env_overrides(self, api_key: str | None, api_base: str | None, model: str) -> dict[str, str]: + """为当前请求生成 LiteLLM 依赖的临时环境变量。 + + LiteLLM 对部分 provider 仍然优先读取环境变量。为了避免不同 runtime + 之间互相污染,这里只生成“本次请求需要的 env 覆盖”,真正调用时再临时注入。 + """ + + if not api_key: + return {} + spec = self._gateway or find_by_model(model) + if spec is None or not spec.env_key: + return {} + overrides: dict[str, str] = {spec.env_key: api_key} + effective_base = api_base or spec.default_api_base + for env_name, env_value in spec.env_extras: + resolved = env_value.replace("{api_key}", api_key).replace("{api_base}", effective_base) + overrides[env_name] = resolved + return overrides + + @contextmanager + def _temporary_env(self, overrides: dict[str, str]): + """只在当前请求期间注入 provider 需要的环境变量。""" + + if not overrides: + yield + return + + sentinel = object() + previous: dict[str, object] = {} + for key, value in overrides.items(): + previous[key] = os.environ.get(key, sentinel) + os.environ[key] = value + try: + yield + finally: + for key, old_value in previous.items(): + if old_value is sentinel: + os.environ.pop(key, None) + else: + os.environ[key] = str(old_value) + + def _resolve_model(self, model: str) -> str: + if self._gateway: + prefix = self._gateway.litellm_prefix + resolved = model.split("/")[-1] if self._gateway.strip_model_prefix else model + if prefix and not resolved.startswith(f"{prefix}/"): + resolved = f"{prefix}/{resolved}" + return resolved + spec = find_by_model(model) + if spec and spec.litellm_prefix: + if not any(model.startswith(prefix) for prefix in spec.skip_prefixes): + model = f"{spec.litellm_prefix}/{model}" + return model + + @staticmethod + def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + sanitized = [] + for message in messages: + clean = {key: value for key, value in message.items() if key in _ALLOWED_MSG_KEYS} + if clean.get("role") == "assistant" and "content" not in clean: + clean["content"] = None + sanitized.append(clean) + return sanitized + + def _apply_model_overrides(self, original_model: str, kwargs: dict[str, Any]) -> None: + spec = find_by_model(original_model) + if spec is None: + return + model_lower = original_model.lower() + for pattern, overrides in spec.model_overrides: + if pattern in model_lower: + kwargs.update(overrides) + return + + def _apply_openrouter_routing(self, kwargs: dict[str, Any]) -> None: + if self.provider_name != "openrouter" or self.routing is None: + return + provider_payload: dict[str, Any] = {} + if self.routing.sort: + provider_payload["sort"] = self.routing.sort + if self.routing.only: + provider_payload["only"] = self.routing.only + if self.routing.ignore: + provider_payload["ignore"] = self.routing.ignore + if self.routing.order: + provider_payload["order"] = self.routing.order + if self.routing.require_parameters: + provider_payload["require_parameters"] = True + if self.routing.data_collection: + provider_payload["data_collection"] = self.routing.data_collection + if provider_payload: + kwargs["provider"] = provider_payload + + async def chat( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + ) -> LLMResponse: + if acompletion is None: + return LLMResponse(content="Error: litellm is not installed", finish_reason="error", provider_name=self.provider_name) + + original_model = model or self.default_model + resolved_model = self._resolve_model(original_model) + sanitized_messages = self._sanitize_messages(self.sanitize_empty_content(messages)) + kwargs: dict[str, Any] = { + "model": resolved_model, + "messages": sanitized_messages, + "max_tokens": max(1, max_tokens), + "temperature": temperature, + } + if self.api_key: + kwargs["api_key"] = self.api_key + if self.api_base: + kwargs["api_base"] = self.api_base + if self.extra_headers: + kwargs["extra_headers"] = self.extra_headers + if tools: + kwargs["tools"] = tools + kwargs["tool_choice"] = "auto" + self._apply_model_overrides(original_model, kwargs) + self._apply_openrouter_routing(kwargs) + env_overrides = self._build_env_overrides(self.api_key, self.api_base, original_model) + + try: + with self._temporary_env(env_overrides): + response = await acompletion(**kwargs) + except Exception as exc: + return LLMResponse(content=f"Error: {exc}", finish_reason="error", provider_name=self.provider_name, model=resolved_model) + + choice = response.choices[0] + message = choice.message + tool_calls: list[ToolCallRequest] = [] + for tool_call in message.tool_calls or []: + raw_arguments = tool_call.function.arguments + if isinstance(raw_arguments, str): + try: + if json_repair is not None: + arguments = json_repair.loads(raw_arguments) + else: + arguments = json.loads(raw_arguments) + except Exception as exc: + # 这里不要因为单个 tool_call 参数坏掉而直接炸掉整轮请求。 + # 后面的 ToolExecutor 会把这个标记转换成一条标准 tool failure。 + arguments = { + "__beaver_tool_argument_parse_error__": str(exc), + "__raw_arguments__": raw_arguments, + } + else: + arguments = raw_arguments + tool_calls.append( + ToolCallRequest( + id=tool_call.id, + name=tool_call.function.name, + arguments=arguments, + ) + ) + usage = getattr(response, "usage", None) + usage_payload = {} + if usage is not None: + usage_payload = { + "prompt_tokens": getattr(usage, "prompt_tokens", 0), + "completion_tokens": getattr(usage, "completion_tokens", 0), + "total_tokens": getattr(usage, "total_tokens", 0), + } + return LLMResponse( + content=getattr(message, "content", None), + tool_calls=tool_calls, + finish_reason=getattr(choice, "finish_reason", "stop") or "stop", + usage=usage_payload, + reasoning_content=getattr(message, "reasoning_content", None), + provider_name=self.provider_name or "litellm", + model=resolved_model, + ) + + def get_default_model(self) -> str: + return self.default_model diff --git a/app-instance/backend/beaver/engine/providers/registry.py b/app-instance/backend/beaver/engine/providers/registry.py new file mode 100644 index 0000000..b7317a9 --- /dev/null +++ b/app-instance/backend/beaver/engine/providers/registry.py @@ -0,0 +1,249 @@ +"""Provider registry: 统一维护 provider 元数据与匹配规则。""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True, slots=True) +class ProviderSpec: + """单个 provider 的元数据定义。""" + + name: str + keywords: tuple[str, ...] + env_key: str + display_name: str = "" + litellm_prefix: str = "" + skip_prefixes: tuple[str, ...] = () + env_extras: tuple[tuple[str, str], ...] = () + is_gateway: bool = False + is_local: bool = False + detect_by_key_prefix: str = "" + detect_by_base_keyword: str = "" + default_api_base: str = "" + strip_model_prefix: bool = False + model_overrides: tuple[tuple[str, dict[str, Any]], ...] = () + is_oauth: bool = False + is_direct: bool = False + supports_prompt_caching: bool = False + api_mode: str = "chat_completions" + provider_impl: str = "litellm" + + @property + def label(self) -> str: + return self.display_name or self.name.title() + + +PROVIDERS: tuple[ProviderSpec, ...] = ( + ProviderSpec( + name="custom", + keywords=(), + env_key="", + display_name="Custom", + is_direct=True, + provider_impl="custom", + api_mode="chat_completions", + ), + ProviderSpec( + name="openrouter", + keywords=("openrouter",), + env_key="OPENROUTER_API_KEY", + display_name="OpenRouter", + litellm_prefix="openrouter", + is_gateway=True, + detect_by_key_prefix="sk-or-", + detect_by_base_keyword="openrouter", + default_api_base="https://openrouter.ai/api/v1", + supports_prompt_caching=True, + ), + ProviderSpec( + name="aihubmix", + keywords=("aihubmix",), + env_key="OPENAI_API_KEY", + display_name="AiHubMix", + litellm_prefix="openai", + is_gateway=True, + detect_by_base_keyword="aihubmix", + default_api_base="https://aihubmix.com/v1", + strip_model_prefix=True, + ), + ProviderSpec( + name="siliconflow", + keywords=("siliconflow",), + env_key="OPENAI_API_KEY", + display_name="SiliconFlow", + litellm_prefix="openai", + is_gateway=True, + detect_by_base_keyword="siliconflow", + default_api_base="https://api.siliconflow.cn/v1", + ), + ProviderSpec( + name="volcengine", + keywords=("volcengine", "volces", "ark"), + env_key="OPENAI_API_KEY", + display_name="VolcEngine", + litellm_prefix="volcengine", + is_gateway=True, + detect_by_base_keyword="volces", + default_api_base="https://ark.cn-beijing.volces.com/api/v3", + ), + ProviderSpec( + name="anthropic", + keywords=("anthropic", "claude"), + env_key="ANTHROPIC_API_KEY", + display_name="Anthropic", + supports_prompt_caching=True, + api_mode="anthropic_messages", + provider_impl="anthropic", + ), + ProviderSpec( + name="openai", + keywords=("openai", "gpt"), + env_key="OPENAI_API_KEY", + display_name="OpenAI", + ), + ProviderSpec( + name="openai_codex", + keywords=("openai-codex", "codex"), + env_key="", + display_name="OpenAI Codex", + is_oauth=True, + detect_by_base_keyword="codex", + default_api_base="https://chatgpt.com/backend-api", + api_mode="codex_responses", + provider_impl="codex", + ), + ProviderSpec( + name="github_copilot", + keywords=("github_copilot", "copilot"), + env_key="", + display_name="Github Copilot", + litellm_prefix="github_copilot", + skip_prefixes=("github_copilot/",), + is_oauth=True, + ), + ProviderSpec( + name="deepseek", + keywords=("deepseek",), + env_key="DEEPSEEK_API_KEY", + display_name="DeepSeek", + litellm_prefix="deepseek", + skip_prefixes=("deepseek/",), + ), + ProviderSpec( + name="gemini", + keywords=("gemini",), + env_key="GEMINI_API_KEY", + display_name="Gemini", + litellm_prefix="gemini", + skip_prefixes=("gemini/",), + ), + ProviderSpec( + name="zhipu", + keywords=("zhipu", "glm", "zai"), + env_key="ZAI_API_KEY", + display_name="Zhipu AI", + litellm_prefix="zai", + skip_prefixes=("zhipu/", "zai/", "openrouter/", "hosted_vllm/"), + env_extras=(("ZHIPUAI_API_KEY", "{api_key}"),), + ), + ProviderSpec( + name="dashscope", + keywords=("qwen", "dashscope"), + env_key="DASHSCOPE_API_KEY", + display_name="DashScope", + litellm_prefix="dashscope", + skip_prefixes=("dashscope/", "openrouter/"), + ), + ProviderSpec( + name="moonshot", + keywords=("moonshot", "kimi"), + env_key="MOONSHOT_API_KEY", + display_name="Moonshot", + litellm_prefix="moonshot", + skip_prefixes=("moonshot/", "openrouter/"), + env_extras=(("MOONSHOT_API_BASE", "{api_base}"),), + default_api_base="https://api.moonshot.ai/v1", + model_overrides=(("kimi-k2.5", {"temperature": 1.0}),), + ), + ProviderSpec( + name="minimax", + keywords=("minimax",), + env_key="MINIMAX_API_KEY", + display_name="MiniMax", + litellm_prefix="minimax", + skip_prefixes=("minimax/", "openrouter/"), + default_api_base="https://api.minimax.io/v1", + ), + ProviderSpec( + name="vllm", + keywords=("vllm",), + env_key="HOSTED_VLLM_API_KEY", + display_name="vLLM/Local", + litellm_prefix="hosted_vllm", + is_local=True, + ), + ProviderSpec( + name="groq", + keywords=("groq",), + env_key="GROQ_API_KEY", + display_name="Groq", + litellm_prefix="groq", + skip_prefixes=("groq/",), + ), +) + + +def find_by_name(name: str) -> ProviderSpec | None: + for spec in PROVIDERS: + if spec.name == name: + return spec + return None + + +def find_by_model(model: str) -> ProviderSpec | None: + """按模型名关键词匹配标准 provider。""" + + model_lower = model.lower() + model_normalized = model_lower.replace("-", "_") + model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" + normalized_prefix = model_prefix.replace("-", "_") + standard_specs = [spec for spec in PROVIDERS if not spec.is_gateway and not spec.is_local] + + # 显式前缀优先级最高。 + # 这里不能只看 standard provider: + # - `openrouter/...` 应该直接命中 openrouter + # - `hosted_vllm/...` 应该能回到 vllm 这个本地 provider + # - `github_copilot/...codex` 也不应被误判成 openai_codex + for spec in PROVIDERS: + aliases = {spec.name} + if spec.litellm_prefix: + aliases.add(spec.litellm_prefix.replace("-", "_")) + if model_prefix and normalized_prefix in aliases: + return spec + + for spec in standard_specs: + if any(keyword in model_lower or keyword.replace("-", "_") in model_normalized for keyword in spec.keywords): + return spec + return None + + +def find_gateway( + provider_name: str | None = None, + api_key: str | None = None, + api_base: str | None = None, +) -> ProviderSpec | None: + """按 config key / api_key / api_base 识别 gateway 或 local provider。""" + + if provider_name: + spec = find_by_name(provider_name) + if spec and (spec.is_gateway or spec.is_local): + return spec + + for spec in PROVIDERS: + if spec.detect_by_key_prefix and api_key and api_key.startswith(spec.detect_by_key_prefix): + return spec + if spec.detect_by_base_keyword and api_base and spec.detect_by_base_keyword in api_base: + return spec + return None diff --git a/app-instance/backend/beaver/engine/providers/runtime.py b/app-instance/backend/beaver/engine/providers/runtime.py new file mode 100644 index 0000000..baf59b8 --- /dev/null +++ b/app-instance/backend/beaver/engine/providers/runtime.py @@ -0,0 +1,408 @@ +"""Hermes 风格的 provider runtime resolution。""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from typing import Any + +from .registry import ProviderSpec, find_by_model, find_by_name, find_gateway + + +@dataclass(slots=True) +class ProviderRoutingConfig: + """OpenRouter provider routing 配置。""" + + sort: str | None = None + only: list[str] = field(default_factory=list) + ignore: list[str] = field(default_factory=list) + order: list[str] = field(default_factory=list) + require_parameters: bool = False + data_collection: str | None = None + + +@dataclass(slots=True) +class ProviderTarget: + """一次 provider 选路请求的标准化配置。 + + 这层不是具体 runtime,而是“调用方想要什么”: + - 用哪个 provider + - 跑哪个 model + - 是否指定自定义 base_url + - 是否带额外 headers / routing + + 后面 `resolve_provider_runtime()` 会把它真正解析成可实例化的 runtime。 + """ + + provider_name: str | None = None + model: str | None = None + api_key: str | None = None + api_base: str | None = None + extra_headers: dict[str, str] = field(default_factory=dict) + request_timeout_seconds: float | None = None + routing: ProviderRoutingConfig | None = None + + +@dataclass(slots=True) +class ProviderRuntime: + """运行时真正使用的 provider 解析结果。""" + + spec: ProviderSpec + model: str + provider_name: str + api_mode: str + api_key: str | None = None + api_base: str | None = None + default_model: str | None = None + request_timeout_seconds: float | None = None + extra_headers: dict[str, str] = field(default_factory=dict) + routing: ProviderRoutingConfig | None = None + fallback_target: ProviderTarget | None = None + auxiliary: bool = False + role: str = "main" + source: str = "runtime" + + +def resolve_provider_runtime( + *, + model: str, + provider_name: str | None = None, + api_key: str | None = None, + api_base: str | None = None, + request_timeout_seconds: float | None = None, + extra_headers: dict[str, str] | None = None, + routing: ProviderRoutingConfig | None = None, + fallback_target: ProviderTarget | dict[str, Any] | None = None, + auxiliary: bool = False, + role: str = "main", + source: str = "runtime", +) -> ProviderRuntime: + """把调用侧传入的配置解析成统一 runtime。""" + + gateway = find_gateway(provider_name, api_key, api_base) + if gateway is not None: + spec = gateway + elif provider_name: + spec = find_by_name(provider_name) + else: + spec = find_by_model(model) + + if spec is None: + if api_base: + spec = find_by_name("custom") + else: + raise ValueError(f"Unable to resolve provider for model={model!r} provider_name={provider_name!r}") + + resolved_model = _resolve_model_name(spec, model, gateway_mode=(gateway is not None)) + resolved_api_base = api_base or spec.default_api_base or None + + return ProviderRuntime( + spec=spec, + model=resolved_model, + provider_name=spec.name, + api_mode=spec.api_mode, + api_key=api_key, + api_base=resolved_api_base, + default_model=resolved_model, + request_timeout_seconds=request_timeout_seconds, + extra_headers=extra_headers or {}, + routing=routing, + fallback_target=normalize_provider_target(fallback_target), + auxiliary=auxiliary, + role=role, + source=source, + ) + + +def normalize_provider_target(target: ProviderTarget | dict[str, Any] | None) -> ProviderTarget | None: + """把 dict/对象形式的 provider 配置收敛成统一结构。 + + 这里兼容几种常见写法,便于后续接 CLI / config / gateway: + - `provider` 或 `provider_name` + - `base_url` 或 `api_base` + - `headers` 或 `extra_headers` + - `timeout` 或 `request_timeout_seconds` + """ + + if target is None: + return None + if isinstance(target, ProviderTarget): + return target + + provider_name = target.get("provider_name") + if provider_name is None: + provider_name = target.get("provider") + + api_base = target.get("api_base") + if api_base is None: + api_base = target.get("base_url") + + extra_headers = target.get("extra_headers") + if extra_headers is None: + extra_headers = target.get("headers") + + request_timeout_seconds = target.get("request_timeout_seconds") + if request_timeout_seconds is None: + request_timeout_seconds = target.get("timeout") + + routing = target.get("routing") + if isinstance(routing, dict): + routing = ProviderRoutingConfig(**routing) + + return ProviderTarget( + provider_name=provider_name, + model=target.get("model"), + api_key=target.get("api_key"), + api_base=api_base, + extra_headers=dict(extra_headers or {}), + request_timeout_seconds=request_timeout_seconds, + routing=routing, + ) + + +def resolve_fallback_runtime( + primary_runtime: ProviderRuntime, + fallback_target: ProviderTarget | dict[str, Any] | None, +) -> ProviderRuntime | None: + """把 fallback 配置解析成独立 runtime。 + + Hermes 的 fallback 是“主 provider 失败后切换到另一个 provider:model”。 + 这里先把 fallback 解析独立出来,具体何时激活交给上层 chain/factory。 + """ + + target = normalize_provider_target(fallback_target) + if target is None or not target.model: + return None + + inferred_provider = target.provider_name + if inferred_provider in {None, "", "main"}: + inferred_provider = primary_runtime.provider_name + + api_key = target.api_key + api_base = target.api_base + extra_headers = dict(target.extra_headers) + + # 只有在 fallback 没明确切换 provider/base 时,才继承主链的凭据与 headers。 + if inferred_provider == primary_runtime.provider_name and not api_base: + api_key = api_key or primary_runtime.api_key + api_base = api_base or primary_runtime.api_base + if not extra_headers: + extra_headers = dict(primary_runtime.extra_headers) + + return resolve_provider_runtime( + model=target.model, + provider_name=inferred_provider, + api_key=api_key, + api_base=api_base, + request_timeout_seconds=target.request_timeout_seconds or primary_runtime.request_timeout_seconds, + extra_headers=extra_headers, + routing=target.routing, + auxiliary=False, + role="fallback", + source="fallback_config", + ) + + +def resolve_auxiliary_runtime( + primary_runtime: ProviderRuntime, + target: ProviderTarget | dict[str, Any] | None = None, + *, + task_name: str = "auxiliary", +) -> ProviderRuntime: + """解析辅助任务专用 runtime。 + + 支持三类输入: + - `None` / `provider=main`:直接复用主链 provider + - 显式 `provider + model`:走独立 provider + - 仅给 `model`:按模型名自动匹配 provider + """ + + normalized = normalize_provider_target(target) + if normalized is None: + return _clone_runtime( + primary_runtime, + auxiliary=True, + role=task_name, + source="main_runtime", + ) + + provider_name = normalized.provider_name + if provider_name in {None, "", "main"} and not normalized.api_base and not normalized.model: + return _clone_runtime( + primary_runtime, + auxiliary=True, + role=task_name, + source="main_runtime", + routing=normalized.routing or primary_runtime.routing, + extra_headers=normalized.extra_headers or primary_runtime.extra_headers, + request_timeout_seconds=normalized.request_timeout_seconds or primary_runtime.request_timeout_seconds, + ) + + if provider_name == "main": + return resolve_provider_runtime( + model=normalized.model or primary_runtime.model, + provider_name=primary_runtime.provider_name, + api_key=normalized.api_key or primary_runtime.api_key, + api_base=normalized.api_base or primary_runtime.api_base, + request_timeout_seconds=normalized.request_timeout_seconds or primary_runtime.request_timeout_seconds, + extra_headers=normalized.extra_headers or primary_runtime.extra_headers, + routing=normalized.routing or primary_runtime.routing, + auxiliary=True, + role=task_name, + source="main_runtime", + ) + + if provider_name in {"auto", None, ""} and not normalized.api_base and normalized.model is None: + return _clone_runtime( + primary_runtime, + auxiliary=True, + role=task_name, + source="auto->main", + ) + + resolved_model = normalized.model or primary_runtime.model + resolved_provider = normalized.provider_name + if resolved_provider in {"auto", "", None} and not normalized.api_base: + # `auto` 的第一阶段实现保持保守: + # - 有显式 model 时按 model 匹配 provider + # - 匹配不到则回退主链 provider + spec = find_by_model(resolved_model) + resolved_provider = spec.name if spec is not None else primary_runtime.provider_name + + api_key = normalized.api_key + api_base = normalized.api_base + extra_headers = dict(normalized.extra_headers) + + if resolved_provider == primary_runtime.provider_name and not api_base: + api_key = api_key or primary_runtime.api_key + api_base = api_base or primary_runtime.api_base + if not extra_headers: + extra_headers = dict(primary_runtime.extra_headers) + + return resolve_provider_runtime( + model=resolved_model, + provider_name=resolved_provider, + api_key=api_key, + api_base=api_base, + request_timeout_seconds=normalized.request_timeout_seconds or primary_runtime.request_timeout_seconds, + extra_headers=extra_headers, + routing=normalized.routing or primary_runtime.routing, + auxiliary=True, + role=task_name, + source="auxiliary_config", + ) + + +def resolve_embedding_runtime( + primary_runtime: ProviderRuntime, + target: ProviderTarget | dict[str, Any] | None = None, + *, + default_model: str = "text-embedding-v4", +) -> ProviderRuntime | None: + """解析 embedding 专用 runtime。 + + 目标是把“embedding 用哪个 model / api_base / api_key”也收进 provider 层, + 避免上层检索逻辑直接偷拿 main/aux provider 的凭据。 + """ + + normalized = normalize_provider_target(target) + + if normalized is None: + # 没有显式 embedding 配置时,只允许在主链本身就是 OpenAI-compatible + # 的情况下,继承它的 api_base/api_key。否则不做模糊猜测。 + if not _supports_openai_embeddings(primary_runtime): + return None + return resolve_provider_runtime( + model=default_model, + provider_name="openai", + api_key=primary_runtime.api_key, + api_base=primary_runtime.api_base, + request_timeout_seconds=primary_runtime.request_timeout_seconds, + extra_headers=dict(primary_runtime.extra_headers), + routing=primary_runtime.routing, + auxiliary=False, + role="embedding", + source="embedding_inherited", + ) + + resolved_model = normalized.model or default_model + resolved_provider = normalized.provider_name + if resolved_provider in {None, "", "main", "auto"}: + resolved_provider = "custom" if normalized.api_base else "openai" + + api_key = normalized.api_key + api_base = normalized.api_base + extra_headers = dict(normalized.extra_headers) + + if not api_base and _supports_openai_embeddings(primary_runtime): + api_key = api_key or primary_runtime.api_key + api_base = api_base or primary_runtime.api_base + if not extra_headers: + extra_headers = dict(primary_runtime.extra_headers) + + runtime = resolve_provider_runtime( + model=resolved_model, + provider_name=resolved_provider, + api_key=api_key, + api_base=api_base, + request_timeout_seconds=normalized.request_timeout_seconds or primary_runtime.request_timeout_seconds, + extra_headers=extra_headers, + routing=normalized.routing, + auxiliary=False, + role="embedding", + source="embedding_config", + ) + if not _supports_openai_embeddings(runtime): + raise ValueError("Embedding runtime currently requires an OpenAI-compatible provider") + return runtime + + +def _supports_openai_embeddings(runtime: ProviderRuntime) -> bool: + """当前 embedding retriever 只支持 OpenAI-compatible `/v1/embeddings`。""" + + return runtime.api_mode == "chat_completions" and runtime.spec.provider_impl in {"litellm", "custom"} + + +def _clone_runtime( + runtime: ProviderRuntime, + **changes: Any, +) -> ProviderRuntime: + """基于现有 runtime 复制一个轻量变体。 + + 用在 `provider=main` 这类场景,避免重复跑一次 registry 解析。 + """ + + payload = { + "extra_headers": dict(runtime.extra_headers), + "routing": runtime.routing, + "fallback_target": runtime.fallback_target, + } + payload.update(changes) + return replace(runtime, **payload) + + +def _resolve_model_name(spec: ProviderSpec, model: str, *, gateway_mode: bool) -> str: + """根据 registry 规则应用必要前缀。""" + + resolved = model + if gateway_mode: + prefix = spec.litellm_prefix + if spec.strip_model_prefix: + resolved = resolved.split("/")[-1] + if prefix and not resolved.startswith(f"{prefix}/"): + resolved = f"{prefix}/{resolved}" + return resolved + + if spec.litellm_prefix: + resolved = _canonicalize_explicit_prefix(resolved, spec.name, spec.litellm_prefix) + if not any(resolved.startswith(item) for item in spec.skip_prefixes): + resolved = f"{spec.litellm_prefix}/{resolved}" + return resolved + + +def _canonicalize_explicit_prefix(model: str, spec_name: str, canonical_prefix: str) -> str: + if "/" not in model: + return model + prefix, remainder = model.split("/", 1) + if prefix.lower().replace("-", "_") != spec_name: + return model + return f"{canonical_prefix}/{remainder}" diff --git a/app-instance/backend/beaver/engine/runtime/__init__.py b/app-instance/backend/beaver/engine/runtime/__init__.py new file mode 100644 index 0000000..8b53fde --- /dev/null +++ b/app-instance/backend/beaver/engine/runtime/__init__.py @@ -0,0 +1,2 @@ +"""Runtime helper objects and execution context.""" + diff --git a/app-instance/backend/beaver/engine/session/__init__.py b/app-instance/backend/beaver/engine/session/__init__.py new file mode 100644 index 0000000..0cb9c2b --- /dev/null +++ b/app-instance/backend/beaver/engine/session/__init__.py @@ -0,0 +1,15 @@ +"""Session state and persistence.""" + +from .manager import SessionManager +from .models import MessageRecord, SessionRecord, SessionUsage +from .search import SessionSearchService +from .store import SessionStore + +__all__ = [ + "MessageRecord", + "SessionManager", + "SessionRecord", + "SessionSearchService", + "SessionStore", + "SessionUsage", +] diff --git a/app-instance/backend/beaver/engine/session/manager.py b/app-instance/backend/beaver/engine/session/manager.py new file mode 100644 index 0000000..8f62ce3 --- /dev/null +++ b/app-instance/backend/beaver/engine/session/manager.py @@ -0,0 +1,143 @@ +"""Beaver session 子系统对 runtime 暴露的统一门面。""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from .models import MessageRecord +from .search import SessionSearchService +from .store import SessionStore + + +class SessionManager: + """供 AgentLoop / services / MCP tools 使用的统一 session facade。""" + + def __init__(self, workspace: str | Path, db_path: str | Path | None = None) -> None: + self.workspace = Path(workspace) + self.sessions_dir = self.workspace / "sessions" + self.sessions_dir.mkdir(parents=True, exist_ok=True) + self.db_path = Path(db_path) if db_path is not None else self.sessions_dir / "state.db" + self.store = SessionStore(self.db_path) + self.search = SessionSearchService(self.store) + + def close(self) -> None: + self.store.close() + + def ensure_session( + self, + session_id: str, + *, + source: str = "unknown", + model: str | None = None, + title: str | None = None, + user_id: str | None = None, + parent_session_id: str | None = None, + ) -> str: + return self.store.ensure_session( + session_id, + source=source, + model=model, + title=title, + user_id=user_id, + parent_session_id=parent_session_id, + ) + + def get_session(self, session_id: str) -> dict[str, Any] | None: + record = self.store.get_session_record(session_id) + return record.to_dict() if record is not None else None + + def get_or_create( + self, + session_id: str, + *, + source: str = "unknown", + model: str | None = None, + title: str | None = None, + user_id: str | None = None, + parent_session_id: str | None = None, + ) -> dict[str, Any]: + self.ensure_session( + session_id, + source=source, + model=model, + title=title, + user_id=user_id, + parent_session_id=parent_session_id, + ) + session = self.get_session(session_id) + if session is None: + raise RuntimeError(f"Failed to create session {session_id!r}") + return session + + def append_message(self, session_id: str, **kwargs: Any) -> int: + return self.store.append_message(session_id, **kwargs) + + def get_event_records(self, session_id: str) -> list[MessageRecord]: + """返回当前 session 的完整事件流。 + + 这里和 `get_messages_as_conversation()` 的区别很关键: + - `get_event_records()` 面向 runtime / replay / audit,保留隐藏系统事件 + - `get_messages_as_conversation()` 面向 prompt builder,只暴露可进上下文的事件 + + 第 6 阶段开始后,session 已不再只是“聊天消息存储”,而是在逐步收敛成 + “外部事件流 + 上层投影视图”。 + """ + + return self.store.get_event_records(session_id) + + def get_run_event_records(self, session_id: str, run_id: str) -> list[MessageRecord]: + """返回某一次 direct run / future bus run 对应的事件片段。""" + + return self.store.get_run_event_records(session_id, run_id) + + def list_run_ids(self, session_id: str) -> list[str]: + """按出现顺序列出当前 session 的所有 run_id。""" + + return self.store.list_run_ids(session_id) + + def get_messages_as_conversation(self, session_id: str) -> list[dict[str, Any]]: + return self.store.get_messages_as_conversation(session_id) + + def get_visible_history(self, session_id: str, max_messages: int = 500) -> list[dict[str, Any]]: + """返回适合注入 prompt 的可见历史切片。 + + 这里故意不直接暴露完整事件流,而是继续提供“模型可消费历史”这个投影视图: + 1. 只包含 `context_visible=True` 的事件 + 2. 继续保留旧式窗口裁剪逻辑,避免当前主链行为突然变化 + 3. 让 `ContextBuilder` 明确消费的是“上游裁剪后的可见片段” + """ + + history = self.get_messages_as_conversation(session_id) + sliced = history[-max_messages:] + for index, message in enumerate(sliced): + if message.get("role") == "user": + sliced = sliced[index:] + break + return sliced + + def get_history(self, session_id: str, max_messages: int = 500) -> list[dict[str, Any]]: + """兼容旧名称,实际返回可见历史切片。""" + + return self.get_visible_history(session_id, max_messages=max_messages) + + def update_system_prompt(self, session_id: str, system_prompt: str) -> None: + self.store.update_system_prompt(session_id, system_prompt) + + def update_usage(self, session_id: str, **kwargs: Any) -> None: + self.store.update_usage(session_id, **kwargs) + + def end_session(self, session_id: str, end_reason: str) -> None: + self.store.end_session(session_id, end_reason) + + def reopen_session(self, session_id: str) -> None: + self.store.reopen_session(session_id) + + def list_sessions_rich(self, **kwargs: Any) -> list[dict[str, Any]]: + return self.search.list_sessions_rich(**kwargs) + + def search_messages(self, **kwargs: Any) -> list[dict[str, Any]]: + return self.search.search_messages(**kwargs) + + def resolve_session_id(self, session_id_or_prefix: str) -> str | None: + return self.search.resolve_session_id(session_id_or_prefix) diff --git a/app-instance/backend/beaver/engine/session/models.py b/app-instance/backend/beaver/engine/session/models.py new file mode 100644 index 0000000..7a15856 --- /dev/null +++ b/app-instance/backend/beaver/engine/session/models.py @@ -0,0 +1,211 @@ +"""Beaver session 子系统的数据模型。 + +这层只定义数据结构,不放数据库读写逻辑。目的是把: +1. SQLite 行结构 +2. 运行时会话对象 +3. 对外暴露的 conversation message + +三件事分开,避免后续所有地方都直接和裸字典耦合。 +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(slots=True) +class SessionUsage: + """会话维度的 usage/cost 统计。""" + + input_tokens: int = 0 + output_tokens: int = 0 + cache_read_tokens: int = 0 + cache_write_tokens: int = 0 + reasoning_tokens: int = 0 + estimated_cost_usd: float = 0.0 + actual_cost_usd: float | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "input_tokens": self.input_tokens, + "output_tokens": self.output_tokens, + "cache_read_tokens": self.cache_read_tokens, + "cache_write_tokens": self.cache_write_tokens, + "reasoning_tokens": self.reasoning_tokens, + "estimated_cost_usd": self.estimated_cost_usd, + "actual_cost_usd": self.actual_cost_usd, + } + + +@dataclass(slots=True) +class MessageRecord: + """单条会话事件的结构化表示。 + + 当前仍然沿用 `messages` 这张表名,但语义已经开始向 event stream 收拢: + 1. 普通 user/assistant/tool 消息本身就是事件 + 2. 运行时的 system snapshot / run lifecycle 也可写成隐藏事件 + 3. 是否进入模型上下文由 `context_visible` 决定,而不是简单看 role + """ + + role: str + content: str | None = None + timestamp: float | None = None + message_id: int | None = None + run_id: str | None = None + event_type: str | None = None + event_payload: dict[str, Any] | None = None + context_visible: bool = True + tool_name: str | None = None + tool_calls: list[dict[str, Any]] | None = None + tool_call_id: str | None = None + finish_reason: str | None = None + reasoning: str | None = None + reasoning_details: Any | None = None + codex_reasoning_items: Any | None = None + + def to_conversation_message(self) -> dict[str, Any]: + """转成 provider / context builder 可直接消费的消息格式。""" + + if not self.context_visible: + raise ValueError("Hidden session events cannot be converted into conversation messages") + + payload: dict[str, Any] = { + "role": self.role, + "content": self.content, + } + if self.tool_name: + payload["tool_name"] = self.tool_name + if self.tool_calls: + payload["tool_calls"] = self.tool_calls + if self.tool_call_id: + payload["tool_call_id"] = self.tool_call_id + if self.finish_reason: + payload["finish_reason"] = self.finish_reason + if self.reasoning: + payload["reasoning"] = self.reasoning + if self.reasoning_details is not None: + payload["reasoning_details"] = self.reasoning_details + if self.codex_reasoning_items is not None: + payload["codex_reasoning_items"] = self.codex_reasoning_items + return payload + + @classmethod + def from_row(cls, row: dict[str, Any]) -> "MessageRecord": + """从 SQLite row/dict 恢复消息模型。""" + + tool_calls = row.get("tool_calls") + if isinstance(tool_calls, str): + try: + tool_calls = json.loads(tool_calls) + except json.JSONDecodeError: + tool_calls = [] + + reasoning_details = row.get("reasoning_details") + if isinstance(reasoning_details, str): + try: + reasoning_details = json.loads(reasoning_details) + except json.JSONDecodeError: + reasoning_details = None + + codex_reasoning_items = row.get("codex_reasoning_items") + if isinstance(codex_reasoning_items, str): + try: + codex_reasoning_items = json.loads(codex_reasoning_items) + except json.JSONDecodeError: + codex_reasoning_items = None + + event_payload = row.get("event_payload") + if isinstance(event_payload, str): + try: + event_payload = json.loads(event_payload) + except json.JSONDecodeError: + event_payload = None + + return cls( + message_id=row.get("id"), + run_id=row.get("run_id"), + role=row["role"], + content=row.get("content"), + event_type=row.get("event_type") or row.get("role"), + event_payload=event_payload, + context_visible=bool(row.get("context_visible", 1)), + tool_name=row.get("tool_name"), + tool_calls=tool_calls, + tool_call_id=row.get("tool_call_id"), + timestamp=row.get("timestamp"), + finish_reason=row.get("finish_reason"), + reasoning=row.get("reasoning"), + reasoning_details=reasoning_details, + codex_reasoning_items=codex_reasoning_items, + ) + + +@dataclass(slots=True) +class SessionRecord: + """单个 session 的结构化表示。""" + + session_id: str + source: str + started_at: float + last_active: float + user_id: str | None = None + title: str | None = None + model: str | None = None + system_prompt: str | None = None + parent_session_id: str | None = None + ended_at: float | None = None + end_reason: str | None = None + message_count: int = 0 + tool_call_count: int = 0 + preview: str | None = None + usage: SessionUsage = field(default_factory=SessionUsage) + + def to_dict(self) -> dict[str, Any]: + payload = { + "id": self.session_id, + "source": self.source, + "user_id": self.user_id, + "title": self.title, + "model": self.model, + "system_prompt": self.system_prompt, + "parent_session_id": self.parent_session_id, + "started_at": self.started_at, + "last_active": self.last_active, + "ended_at": self.ended_at, + "end_reason": self.end_reason, + "message_count": self.message_count, + "tool_call_count": self.tool_call_count, + "preview": self.preview, + } + payload.update(self.usage.to_dict()) + return payload + + @classmethod + def from_row(cls, row: dict[str, Any]) -> "SessionRecord": + return cls( + session_id=row["id"], + source=row["source"], + user_id=row.get("user_id"), + title=row.get("title"), + model=row.get("model"), + system_prompt=row.get("system_prompt"), + parent_session_id=row.get("parent_session_id"), + started_at=row["started_at"], + last_active=row["last_active"], + ended_at=row.get("ended_at"), + end_reason=row.get("end_reason"), + message_count=row.get("message_count", 0), + tool_call_count=row.get("tool_call_count", 0), + preview=row.get("preview"), + usage=SessionUsage( + input_tokens=row.get("input_tokens", 0), + output_tokens=row.get("output_tokens", 0), + cache_read_tokens=row.get("cache_read_tokens", 0), + cache_write_tokens=row.get("cache_write_tokens", 0), + reasoning_tokens=row.get("reasoning_tokens", 0), + estimated_cost_usd=row.get("estimated_cost_usd", 0.0) or 0.0, + actual_cost_usd=row.get("actual_cost_usd"), + ), + ) diff --git a/app-instance/backend/beaver/engine/session/search.py b/app-instance/backend/beaver/engine/session/search.py new file mode 100644 index 0000000..f1d6d65 --- /dev/null +++ b/app-instance/backend/beaver/engine/session/search.py @@ -0,0 +1,151 @@ +"""Beaver session 子系统的检索能力。""" + +from __future__ import annotations + +import re +import sqlite3 +from typing import Any + +from .store import SessionStore + + +class SessionSearchService: + """围绕 `SessionStore` 提供 browsing / FTS / lineage 辅助能力。""" + + def __init__(self, store: SessionStore) -> None: + self.store = store + + @staticmethod + def _sanitize_fts5_query(query: str) -> str: + quoted_parts: list[str] = [] + + def preserve(match: re.Match[str]) -> str: + quoted_parts.append(match.group(0)) + return f"\x00Q{len(quoted_parts) - 1}\x00" + + sanitized = re.sub(r'"[^"]*"', preserve, query) + sanitized = re.sub(r'[+{}()\"^]', " ", sanitized) + sanitized = re.sub(r"\*+", "*", sanitized) + sanitized = re.sub(r"(^|\s)\*", r"\1", sanitized) + sanitized = re.sub(r"(?i)^(AND|OR|NOT)\b\s*", "", sanitized.strip()) + sanitized = re.sub(r"(?i)\s+(AND|OR|NOT)\s*$", "", sanitized.strip()) + sanitized = re.sub(r"\b(\w+(?:[.-]\w+)+)\b", r'"\1"', sanitized) + + for index, quoted in enumerate(quoted_parts): + sanitized = sanitized.replace(f"\x00Q{index}\x00", quoted) + return sanitized.strip() + + def resolve_session_id(self, session_id_or_prefix: str) -> str | None: + """用完整 ID 或唯一前缀解析出目标 session_id。""" + + exact = self.store.get_session_record(session_id_or_prefix) + if exact is not None: + return exact.session_id + + escaped = ( + session_id_or_prefix + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + ) + rows = self.store._fetchall( + """ + SELECT id + FROM sessions + WHERE id LIKE ? ESCAPE '\\' + ORDER BY started_at DESC + LIMIT 2 + """, + (f"{escaped}%",), + ) + if len(rows) == 1: + return rows[0]["id"] + return None + + def list_sessions_rich( + self, + *, + limit: int = 20, + offset: int = 0, + include_children: bool = False, + source: str | None = None, + exclude_sources: list[str] | None = None, + ) -> list[dict[str, Any]]: + """列出最近活跃的 session 及其摘要元数据。""" + + clauses: list[str] = [] + params: list[Any] = [] + + if not include_children: + clauses.append("parent_session_id IS NULL") + if source: + clauses.append("source = ?") + params.append(source) + if exclude_sources: + placeholders = ",".join("?" for _ in exclude_sources) + clauses.append(f"source NOT IN ({placeholders})") + params.extend(exclude_sources) + + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + params.extend([limit, offset]) + rows = self.store._fetchall( + f""" + SELECT * + FROM sessions + {where} + ORDER BY last_active DESC + LIMIT ? OFFSET ? + """, + tuple(params), + ) + return rows + + def search_messages( + self, + *, + query: str, + role_filter: list[str] | None = None, + exclude_sources: list[str] | None = None, + limit: int = 20, + offset: int = 0, + ) -> list[dict[str, Any]]: + """使用 FTS5 搜索 session transcript。""" + + query = self._sanitize_fts5_query(query) + if not query: + return [] + + clauses = ["messages_fts MATCH ?", "m.context_visible = 1"] + params: list[Any] = [query] + + if exclude_sources: + placeholders = ",".join("?" for _ in exclude_sources) + clauses.append(f"s.source NOT IN ({placeholders})") + params.extend(exclude_sources) + if role_filter: + placeholders = ",".join("?" for _ in role_filter) + clauses.append(f"m.role IN ({placeholders})") + params.extend(role_filter) + + params.extend([limit, offset]) + sql = f""" + SELECT + m.id, + m.session_id, + m.role, + s.source, + s.model, + s.started_at AS session_started, + snippet(messages_fts, 0, '>>>', '<<<', '...', 40) AS snippet + FROM messages_fts + JOIN messages m ON m.id = messages_fts.rowid + JOIN sessions s ON s.id = m.session_id + WHERE {' AND '.join(clauses)} + ORDER BY rank + LIMIT ? OFFSET ? + """ + + try: + return self.store._fetchall(sql, tuple(params)) + except sqlite3.Error as exc: + raise RuntimeError(f"Session transcript search failed for query={query!r}") from exc diff --git a/app-instance/backend/beaver/engine/session/store.py b/app-instance/backend/beaver/engine/session/store.py new file mode 100644 index 0000000..f65f0a5 --- /dev/null +++ b/app-instance/backend/beaver/engine/session/store.py @@ -0,0 +1,467 @@ +"""Beaver session 子系统的 SQLite 存储实现。 + +设计来源主要参考 Hermes-agent: +1. SQLite 作为统一 session/transcript backend +2. WAL 模式支持多读单写 +3. FTS5 支持跨 session 文本检索 +4. `parent_session_id` 支持 lineage + +这层只负责“存”和“取”,复杂检索逻辑由 `search.py` 承担。 +""" + +from __future__ import annotations + +import json +import sqlite3 +import threading +import time +from pathlib import Path +from typing import Any, Callable, TypeVar + +from .models import MessageRecord, SessionRecord + +T = TypeVar("T") + +SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + user_id TEXT, + title TEXT, + model TEXT, + system_prompt TEXT, + parent_session_id TEXT, + started_at REAL NOT NULL, + last_active REAL NOT NULL, + ended_at REAL, + end_reason TEXT, + message_count INTEGER DEFAULT 0, + tool_call_count INTEGER DEFAULT 0, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + cache_read_tokens INTEGER DEFAULT 0, + cache_write_tokens INTEGER DEFAULT 0, + reasoning_tokens INTEGER DEFAULT 0, + estimated_cost_usd REAL DEFAULT 0, + actual_cost_usd REAL, + preview TEXT, + FOREIGN KEY (parent_session_id) REFERENCES sessions(id) +); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id), + run_id TEXT, + role TEXT NOT NULL, + event_type TEXT, + event_payload TEXT, + context_visible INTEGER NOT NULL DEFAULT 1, + content TEXT, + tool_name TEXT, + tool_calls TEXT, + tool_call_id TEXT, + timestamp REAL NOT NULL, + finish_reason TEXT, + reasoning TEXT, + reasoning_details TEXT, + codex_reasoning_items TEXT +); + +CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC); +CREATE INDEX IF NOT EXISTS idx_sessions_last_active ON sessions(last_active DESC); +CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id); +CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp, id); +CREATE INDEX IF NOT EXISTS idx_messages_run ON messages(session_id, run_id, timestamp, id); +""" + +FTS_TABLE_SQL = """ +CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + content, + content=messages, + content_rowid=id +); +""" + +FTS_TRIGGER_SQL = """ +DROP TRIGGER IF EXISTS messages_fts_insert; +DROP TRIGGER IF EXISTS messages_fts_delete; +DROP TRIGGER IF EXISTS messages_fts_update; + +CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts(rowid, content) + SELECT new.id, new.content + WHERE new.context_visible = 1 AND new.content IS NOT NULL; +END; + +CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) + SELECT 'delete', old.id, old.content + WHERE old.context_visible = 1 AND old.content IS NOT NULL; +END; + +CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, content) + SELECT 'delete', old.id, old.content + WHERE old.context_visible = 1 AND old.content IS NOT NULL; + INSERT INTO messages_fts(rowid, content) + SELECT new.id, new.content + WHERE new.context_visible = 1 AND new.content IS NOT NULL; +END; +""" + + +class SessionStore: + """SQLite-backed session store.""" + + def __init__(self, db_path: str | Path) -> None: + self.db_path = Path(db_path) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._lock = threading.Lock() + self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False, isolation_level=None) + self._conn.row_factory = sqlite3.Row + self._conn.execute("PRAGMA journal_mode=WAL") + self._conn.execute("PRAGMA foreign_keys=ON") + self._init_schema() + + def _init_schema(self) -> None: + with self._lock: + self._conn.executescript(SCHEMA_SQL) + try: + self._conn.execute("SELECT * FROM messages_fts LIMIT 0") + except sqlite3.OperationalError: + self._conn.executescript(FTS_TABLE_SQL) + self._conn.executescript(FTS_TRIGGER_SQL) + # 旧版本可能把 hidden 事件也写进了 FTS;初始化时顺手清掉这些噪声项。 + self._conn.execute( + """ + INSERT INTO messages_fts(messages_fts, rowid, content) + SELECT 'delete', id, content + FROM messages + WHERE context_visible = 0 AND content IS NOT NULL + """ + ) + self._conn.commit() + + def close(self) -> None: + with self._lock: + self._conn.close() + + def _execute_write(self, fn: Callable[[sqlite3.Connection], T]) -> T: + with self._lock: + self._conn.execute("BEGIN IMMEDIATE") + try: + result = fn(self._conn) + self._conn.commit() + return result + except BaseException: + self._conn.rollback() + raise + + def _fetchone(self, sql: str, params: tuple[Any, ...] = ()) -> dict[str, Any] | None: + with self._lock: + row = self._conn.execute(sql, params).fetchone() + return dict(row) if row else None + + def _fetchall(self, sql: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]: + with self._lock: + rows = self._conn.execute(sql, params).fetchall() + return [dict(row) for row in rows] + + def ensure_session( + self, + session_id: str, + *, + source: str = "unknown", + model: str | None = None, + title: str | None = None, + user_id: str | None = None, + parent_session_id: str | None = None, + ) -> str: + """确保 session 行存在;若不存在则创建,若存在则尽量补全缺失元数据。""" + + now = time.time() + + def _do(conn: sqlite3.Connection) -> str: + conn.execute( + """ + INSERT INTO sessions ( + id, source, user_id, title, model, parent_session_id, started_at, last_active + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + source = CASE + WHEN sessions.source = 'unknown' AND excluded.source != 'unknown' THEN excluded.source + ELSE sessions.source + END, + user_id = COALESCE(sessions.user_id, excluded.user_id), + title = COALESCE(sessions.title, excluded.title), + model = COALESCE(sessions.model, excluded.model), + parent_session_id = COALESCE(sessions.parent_session_id, excluded.parent_session_id) + """, + (session_id, source, user_id, title, model, parent_session_id, now, now), + ) + return session_id + + return self._execute_write(_do) + + def get_session_record(self, session_id: str) -> SessionRecord | None: + row = self._fetchone("SELECT * FROM sessions WHERE id = ?", (session_id,)) + return SessionRecord.from_row(row) if row else None + + def update_system_prompt(self, session_id: str, system_prompt: str) -> None: + """保存本 session 组装后的完整 system prompt snapshot。""" + + def _do(conn: sqlite3.Connection) -> None: + conn.execute( + """ + UPDATE sessions + SET system_prompt = ?, last_active = ? + WHERE id = ? + """, + (system_prompt, time.time(), session_id), + ) + + self._execute_write(_do) + + def update_usage( + self, + session_id: str, + *, + input_tokens: int = 0, + output_tokens: int = 0, + cache_read_tokens: int = 0, + cache_write_tokens: int = 0, + reasoning_tokens: int = 0, + estimated_cost_usd: float = 0.0, + actual_cost_usd: float | None = None, + absolute: bool = False, + ) -> None: + """更新会话 usage。默认按增量累加。""" + + if absolute: + sql = """ + UPDATE sessions + SET input_tokens = ?, + output_tokens = ?, + cache_read_tokens = ?, + cache_write_tokens = ?, + reasoning_tokens = ?, + estimated_cost_usd = ?, + actual_cost_usd = ?, + last_active = ? + WHERE id = ? + """ + params = ( + input_tokens, + output_tokens, + cache_read_tokens, + cache_write_tokens, + reasoning_tokens, + estimated_cost_usd, + actual_cost_usd, + time.time(), + session_id, + ) + else: + sql = """ + UPDATE sessions + SET input_tokens = input_tokens + ?, + output_tokens = output_tokens + ?, + cache_read_tokens = cache_read_tokens + ?, + cache_write_tokens = cache_write_tokens + ?, + reasoning_tokens = reasoning_tokens + ?, + estimated_cost_usd = estimated_cost_usd + ?, + actual_cost_usd = CASE + WHEN ? IS NULL THEN actual_cost_usd + ELSE COALESCE(actual_cost_usd, 0) + ? + END, + last_active = ? + WHERE id = ? + """ + params = ( + input_tokens, + output_tokens, + cache_read_tokens, + cache_write_tokens, + reasoning_tokens, + estimated_cost_usd, + actual_cost_usd, + actual_cost_usd, + time.time(), + session_id, + ) + + def _do(conn: sqlite3.Connection) -> None: + conn.execute(sql, params) + + self._execute_write(_do) + + def append_message( + self, + session_id: str, + *, + run_id: str | None = None, + role: str, + event_type: str | None = None, + event_payload: dict[str, Any] | None = None, + context_visible: bool = True, + content: str | None = None, + tool_name: str | None = None, + tool_calls: list[dict[str, Any]] | None = None, + tool_call_id: str | None = None, + finish_reason: str | None = None, + reasoning: str | None = None, + reasoning_details: Any | None = None, + codex_reasoning_items: Any | None = None, + source: str = "unknown", + title: str | None = None, + model: str | None = None, + user_id: str | None = None, + parent_session_id: str | None = None, + ) -> int: + """向指定 session 追加一条消息。""" + + self.ensure_session( + session_id, + source=source, + model=model, + title=title, + user_id=user_id, + parent_session_id=parent_session_id, + ) + now = time.time() + tool_calls_json = json.dumps(tool_calls) if tool_calls is not None else None + event_payload_json = json.dumps(event_payload) if event_payload is not None else None + reasoning_details_json = json.dumps(reasoning_details) if reasoning_details is not None else None + codex_items_json = json.dumps(codex_reasoning_items) if codex_reasoning_items is not None else None + preview = (content or "")[:120] if role == "user" and content else None + tool_call_count = len(tool_calls) if isinstance(tool_calls, list) else (1 if tool_calls else 0) + + def _do(conn: sqlite3.Connection) -> int: + cursor = conn.execute( + """ + INSERT INTO messages ( + session_id, run_id, role, event_type, event_payload, context_visible, content, + tool_name, tool_calls, tool_call_id, timestamp, finish_reason, reasoning, + reasoning_details, codex_reasoning_items + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + session_id, + run_id, + role, + event_type or role, + event_payload_json, + 1 if context_visible else 0, + content, + tool_name, + tool_calls_json, + tool_call_id, + now, + finish_reason, + reasoning, + reasoning_details_json, + codex_items_json, + ), + ) + conn.execute( + """ + UPDATE sessions + SET last_active = ?, + message_count = message_count + 1, + tool_call_count = tool_call_count + ?, + model = COALESCE(model, ?), + preview = CASE + WHEN preview IS NULL AND ? IS NOT NULL THEN ? + ELSE preview + END + WHERE id = ? + """, + (now, tool_call_count, model, preview, preview, session_id), + ) + return int(cursor.lastrowid) + + return self._execute_write(_do) + + def get_message_records(self, session_id: str) -> list[MessageRecord]: + rows = self._fetchall( + """ + SELECT * + FROM messages + WHERE session_id = ? + ORDER BY timestamp, id + """, + (session_id,), + ) + return [MessageRecord.from_row(row) for row in rows] + + def get_event_records(self, session_id: str) -> list[MessageRecord]: + """返回当前 session 的完整事件流。 + + 当前阶段里,事件流仍复用 `messages` 表承载,所以这里等价于读取全部 message records。 + 后面如果单独拆出 run/checkpoint/system event 表,上层 manager 仍可以继续保持这个接口不变。 + """ + + return self.get_message_records(session_id) + + def list_run_ids(self, session_id: str) -> list[str]: + """按时间顺序列出当前 session 中出现过的 run_id。""" + + rows = self._fetchall( + """ + SELECT run_id + FROM messages + WHERE session_id = ? AND run_id IS NOT NULL + GROUP BY run_id + ORDER BY MIN(timestamp), MIN(id) + """, + (session_id,), + ) + return [str(row["run_id"]) for row in rows if row.get("run_id")] + + def get_run_event_records(self, session_id: str, run_id: str) -> list[MessageRecord]: + """返回某一次 run 对应的事件片段。""" + + rows = self._fetchall( + """ + SELECT * + FROM messages + WHERE session_id = ? AND run_id = ? + ORDER BY timestamp, id + """, + (session_id, run_id), + ) + return [MessageRecord.from_row(row) for row in rows] + + def get_messages_as_conversation(self, session_id: str) -> list[dict[str, Any]]: + messages: list[dict[str, Any]] = [] + for record in self.get_event_records(session_id): + if not record.context_visible: + continue + messages.append(record.to_conversation_message()) + return messages + + def end_session(self, session_id: str, end_reason: str) -> None: + def _do(conn: sqlite3.Connection) -> None: + conn.execute( + """ + UPDATE sessions + SET ended_at = ?, end_reason = ?, last_active = ? + WHERE id = ? + """, + (time.time(), end_reason, time.time(), session_id), + ) + + self._execute_write(_do) + + def reopen_session(self, session_id: str) -> None: + def _do(conn: sqlite3.Connection) -> None: + conn.execute( + """ + UPDATE sessions + SET ended_at = NULL, end_reason = NULL, last_active = ? + WHERE id = ? + """, + (time.time(), session_id), + ) + + self._execute_write(_do) diff --git a/app-instance/backend/beaver/foundation/__init__.py b/app-instance/backend/beaver/foundation/__init__.py new file mode 100644 index 0000000..44db7ed --- /dev/null +++ b/app-instance/backend/beaver/foundation/__init__.py @@ -0,0 +1,2 @@ +"""Foundation layer for shared Beaver primitives.""" + diff --git a/app-instance/backend/beaver/foundation/config/__init__.py b/app-instance/backend/beaver/foundation/config/__init__.py new file mode 100644 index 0000000..42cd1cb --- /dev/null +++ b/app-instance/backend/beaver/foundation/config/__init__.py @@ -0,0 +1,2 @@ +"""Configuration models and loaders.""" + diff --git a/app-instance/backend/beaver/foundation/errors/__init__.py b/app-instance/backend/beaver/foundation/errors/__init__.py new file mode 100644 index 0000000..c4a50b4 --- /dev/null +++ b/app-instance/backend/beaver/foundation/errors/__init__.py @@ -0,0 +1,2 @@ +"""Shared error types.""" + diff --git a/app-instance/backend/beaver/foundation/events/__init__.py b/app-instance/backend/beaver/foundation/events/__init__.py new file mode 100644 index 0000000..34a3cd6 --- /dev/null +++ b/app-instance/backend/beaver/foundation/events/__init__.py @@ -0,0 +1,5 @@ +"""Event contracts and dispatch helpers.""" + +from .message_bus import InboundMessage, MessageBus, OutboundMessage + +__all__ = ["InboundMessage", "MessageBus", "OutboundMessage"] diff --git a/app-instance/backend/beaver/foundation/events/message_bus.py b/app-instance/backend/beaver/foundation/events/message_bus.py new file mode 100644 index 0000000..1db5810 --- /dev/null +++ b/app-instance/backend/beaver/foundation/events/message_bus.py @@ -0,0 +1,72 @@ +"""Minimal message bus for gateway-style host integration.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any +from uuid import uuid4 + + +@dataclass(slots=True) +class InboundMessage: + """A minimal inbound message accepted by the gateway bridge.""" + + channel: str + content: str + session_id: str | None = None + user_id: str | None = None + title: str | None = None + execution_context: str | None = None + model: str | None = None + provider_name: str | None = None + embedding_model: str | None = None + message_id: str = field(default_factory=lambda: str(uuid4())) + metadata: dict[str, Any] = field(default_factory=dict) + timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +@dataclass(slots=True) +class OutboundMessage: + """A minimal outbound message produced by the gateway bridge.""" + + channel: str + content: str + session_id: str | None + finish_reason: str + message_id: str = field(default_factory=lambda: str(uuid4())) + run_id: str | None = None + provider_name: str | None = None + model: str | None = None + usage: dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +class MessageBus: + """Minimal async message bus with inbound/outbound queues.""" + + def __init__(self) -> None: + self.inbound: asyncio.Queue[InboundMessage] = asyncio.Queue() + self.outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue() + + async def publish_inbound(self, message: InboundMessage) -> None: + await self.inbound.put(message) + + async def consume_inbound(self) -> InboundMessage: + return await self.inbound.get() + + async def publish_outbound(self, message: OutboundMessage) -> None: + await self.outbound.put(message) + + async def consume_outbound(self) -> OutboundMessage: + return await self.outbound.get() + + @property + def inbound_size(self) -> int: + return self.inbound.qsize() + + @property + def outbound_size(self) -> int: + return self.outbound.qsize() diff --git a/app-instance/backend/beaver/foundation/models/__init__.py b/app-instance/backend/beaver/foundation/models/__init__.py new file mode 100644 index 0000000..d8bdfd3 --- /dev/null +++ b/app-instance/backend/beaver/foundation/models/__init__.py @@ -0,0 +1,2 @@ +"""Shared data models.""" + diff --git a/app-instance/backend/beaver/foundation/utils/__init__.py b/app-instance/backend/beaver/foundation/utils/__init__.py new file mode 100644 index 0000000..8981f5c --- /dev/null +++ b/app-instance/backend/beaver/foundation/utils/__init__.py @@ -0,0 +1,2 @@ +"""Common utility helpers.""" + diff --git a/app-instance/backend/beaver/integrations/__init__.py b/app-instance/backend/beaver/integrations/__init__.py new file mode 100644 index 0000000..29b2813 --- /dev/null +++ b/app-instance/backend/beaver/integrations/__init__.py @@ -0,0 +1,2 @@ +"""External integrations.""" + diff --git a/app-instance/backend/beaver/integrations/a2a/__init__.py b/app-instance/backend/beaver/integrations/a2a/__init__.py new file mode 100644 index 0000000..88dab41 --- /dev/null +++ b/app-instance/backend/beaver/integrations/a2a/__init__.py @@ -0,0 +1,2 @@ +"""A2A integration.""" + diff --git a/app-instance/backend/beaver/integrations/mcp/__init__.py b/app-instance/backend/beaver/integrations/mcp/__init__.py new file mode 100644 index 0000000..0adc85b --- /dev/null +++ b/app-instance/backend/beaver/integrations/mcp/__init__.py @@ -0,0 +1,2 @@ +"""MCP integration.""" + diff --git a/app-instance/backend/beaver/integrations/outlook/__init__.py b/app-instance/backend/beaver/integrations/outlook/__init__.py new file mode 100644 index 0000000..55484f3 --- /dev/null +++ b/app-instance/backend/beaver/integrations/outlook/__init__.py @@ -0,0 +1,2 @@ +"""Outlook integration.""" + diff --git a/app-instance/backend/beaver/integrations/providers/__init__.py b/app-instance/backend/beaver/integrations/providers/__init__.py new file mode 100644 index 0000000..21f607f --- /dev/null +++ b/app-instance/backend/beaver/integrations/providers/__init__.py @@ -0,0 +1,2 @@ +"""Provider-specific integrations.""" + diff --git a/app-instance/backend/beaver/integrations/whatsapp/__init__.py b/app-instance/backend/beaver/integrations/whatsapp/__init__.py new file mode 100644 index 0000000..5386606 --- /dev/null +++ b/app-instance/backend/beaver/integrations/whatsapp/__init__.py @@ -0,0 +1,2 @@ +"""WhatsApp integration.""" + diff --git a/app-instance/backend/beaver/interfaces/__init__.py b/app-instance/backend/beaver/interfaces/__init__.py new file mode 100644 index 0000000..117b7d0 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/__init__.py @@ -0,0 +1,2 @@ +"""Thin interface layer for Beaver.""" + diff --git a/app-instance/backend/beaver/interfaces/channels/__init__.py b/app-instance/backend/beaver/interfaces/channels/__init__.py new file mode 100644 index 0000000..bf9247a --- /dev/null +++ b/app-instance/backend/beaver/interfaces/channels/__init__.py @@ -0,0 +1,2 @@ +"""Channel interfaces.""" + diff --git a/app-instance/backend/beaver/interfaces/cli/__init__.py b/app-instance/backend/beaver/interfaces/cli/__init__.py new file mode 100644 index 0000000..50c073c --- /dev/null +++ b/app-instance/backend/beaver/interfaces/cli/__init__.py @@ -0,0 +1,2 @@ +"""CLI interface.""" + diff --git a/app-instance/backend/beaver/interfaces/cli/main.py b/app-instance/backend/beaver/interfaces/cli/main.py new file mode 100644 index 0000000..40f5ebc --- /dev/null +++ b/app-instance/backend/beaver/interfaces/cli/main.py @@ -0,0 +1,59 @@ +"""CLI entry for Beaver.""" + +try: + import typer +except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments + class _FallbackTyper: + def __init__(self, *_args, **_kwargs) -> None: + pass + + def command(self): + def decorator(func): + return func + + return decorator + + def __call__(self) -> None: + raise RuntimeError("typer is not installed") + + @staticmethod + def echo(message: str) -> None: + print(message) + + @staticmethod + def Option(default=None, *_args, **_kwargs): + return default + + typer = _FallbackTyper() # type: ignore[assignment] + +from beaver.services.agent_service import AgentService + +app = typer.Typer(help="Beaver backend CLI") if hasattr(typer, "Typer") else typer + + +@app.command() +def run( + message: str | None = typer.Option(None, "--message", "-m", help="Run one direct Beaver request."), + workspace: str | None = typer.Option(None, "--workspace", help="Workspace root for this run."), +) -> None: + """Thin CLI wrapper around AgentService. + + CLI 现在不再自己维护执行逻辑,只负责: + 1. 解析命令行参数 + 2. 调 AgentService + 3. 打印结果 + """ + + service = AgentService(workspace=workspace) + if not message: + service.create_loop() + typer.echo("Beaver engine booted.") + return + + result = service.run_direct(message, source="cli") + typer.echo(result.output_text) + + +def main() -> None: + """Project script entrypoint.""" + app() diff --git a/app-instance/backend/beaver/interfaces/gateway/__init__.py b/app-instance/backend/beaver/interfaces/gateway/__init__.py new file mode 100644 index 0000000..f1bd83e --- /dev/null +++ b/app-instance/backend/beaver/interfaces/gateway/__init__.py @@ -0,0 +1,2 @@ +"""Gateway interface.""" + diff --git a/app-instance/backend/beaver/interfaces/gateway/main.py b/app-instance/backend/beaver/interfaces/gateway/main.py new file mode 100644 index 0000000..1ceac26 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/gateway/main.py @@ -0,0 +1,189 @@ +"""Gateway entrypoint for Beaver. + +当前阶段先不扩 bus / channels adapter,只做最小消息桥接: +1. 启动时托管 `AgentService.start()` +2. 常驻消费 `MessageBus.inbound` +3. 调 `service.submit_direct(...)` +4. 将结果写回 `MessageBus.outbound` +5. 退出时走 `AgentService.shutdown()` +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage +from beaver.services.agent_service import AgentService + + +async def _publish_bridge_error( + bus: MessageBus, + inbound: InboundMessage, + *, + detail: str, + finish_reason: str = "error", +) -> None: + """把 bridge 处理失败转换成结构化 outbound 错误消息。""" + + await bus.publish_outbound( + OutboundMessage( + message_id=inbound.message_id, + channel=inbound.channel, + session_id=inbound.session_id, + content=detail, + finish_reason=finish_reason, + metadata={"error": detail, "inbound_metadata": dict(inbound.metadata)}, + ) + ) + + +async def _flush_pending_inbound(bus: MessageBus, *, reason: str) -> None: + """把尚未处理的 inbound 明确冲刷成 outbound 错误,而不是静默丢弃。""" + + while True: + try: + pending = bus.inbound.get_nowait() + except asyncio.QueueEmpty: + break + await _publish_bridge_error(bus, pending, detail=reason, finish_reason="stopped") + + +async def _await_bridge_shutdown(task: asyncio.Task[None], *, timeout_seconds: float = 1.0) -> None: + """等待 bridge 退出;超时则取消,避免 shutdown 被桥接层反向卡死。""" + + try: + await asyncio.wait_for(task, timeout=timeout_seconds) + except asyncio.CancelledError: + pass + except asyncio.TimeoutError: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + +async def _bridge_inbound_to_runtime( + service: AgentService, + bus: MessageBus, + stop_event: asyncio.Event, +) -> None: + """Consume inbound messages, run the agent, and publish outbound results.""" + + while True: + if stop_event.is_set(): + await _flush_pending_inbound( + bus, + reason="Gateway stopped before processing the inbound message", + ) + break + + try: + inbound = await asyncio.wait_for(bus.consume_inbound(), timeout=0.25) + except asyncio.TimeoutError: + continue + + try: + result = await service.submit_direct( + inbound.content, + session_id=inbound.session_id, + source=f"gateway:{inbound.channel}", + user_id=inbound.user_id, + title=inbound.title, + execution_context=inbound.execution_context, + model=inbound.model, + provider_name=inbound.provider_name, + embedding_model=inbound.embedding_model, + ) + except asyncio.CancelledError: + await _publish_bridge_error( + bus, + inbound, + detail="Gateway stopped before completing the inbound message", + finish_reason="cancelled", + ) + raise + except Exception as exc: # pragma: no cover - defensive bridge path + await _publish_bridge_error( + bus, + inbound, + detail=str(exc), + ) + else: + await bus.publish_outbound( + OutboundMessage( + message_id=inbound.message_id, + channel=inbound.channel, + session_id=result.session_id, + run_id=result.run_id, + content=result.output_text, + finish_reason=result.finish_reason, + provider_name=result.provider_name, + model=result.model, + usage=dict(result.usage), + metadata={"inbound_metadata": dict(inbound.metadata)}, + ) + ) + + +async def run_gateway( + *, + workspace: str | Path | None = None, + service: AgentService | None = None, + bus: MessageBus | None = None, + manage_service_lifecycle: bool | None = None, + stop_event: asyncio.Event | None = None, + shutdown_timeout_seconds: float | None = 5.0, + shutdown_force: bool = True, +) -> None: + """运行最小 gateway 宿主层与消息桥接。 + + 默认 ownership 语义: + - 未传 `service`:gateway 自己创建并接管其 lifecycle + - 传入外部 `service`:默认只使用,不自动 start/shutdown + """ + + attached_service = service or AgentService(workspace=workspace) + attached_bus = bus or MessageBus() + owns_service = manage_service_lifecycle if manage_service_lifecycle is not None else service is None + owned_stop_event = stop_event or asyncio.Event() + started = False + if owns_service: + try: + await attached_service.start() + started = True + except Exception: + attached_service.close() + raise + + if not attached_service.is_running: + raise RuntimeError( + "Gateway requires AgentService running mode; start the injected service first " + "or allow the gateway to manage its lifecycle." + ) + + bridge_task = asyncio.create_task(_bridge_inbound_to_runtime(attached_service, attached_bus, owned_stop_event)) + try: + await owned_stop_event.wait() + finally: + owned_stop_event.set() + if owns_service and started: + try: + await attached_service.shutdown( + timeout_seconds=shutdown_timeout_seconds, + force=shutdown_force, + ) + finally: + await _await_bridge_shutdown(bridge_task) + else: + await _await_bridge_shutdown(bridge_task) + + +def main() -> None: + """同步 gateway 入口。""" + + try: + asyncio.run(run_gateway()) + except KeyboardInterrupt: + pass diff --git a/app-instance/backend/beaver/interfaces/mcp/__init__.py b/app-instance/backend/beaver/interfaces/mcp/__init__.py new file mode 100644 index 0000000..e3683dd --- /dev/null +++ b/app-instance/backend/beaver/interfaces/mcp/__init__.py @@ -0,0 +1,2 @@ +"""MCP server entrypoints.""" + diff --git a/app-instance/backend/beaver/interfaces/mcp/memory_server.py b/app-instance/backend/beaver/interfaces/mcp/memory_server.py new file mode 100644 index 0000000..8a7c49a --- /dev/null +++ b/app-instance/backend/beaver/interfaces/mcp/memory_server.py @@ -0,0 +1,210 @@ +"""Beaver memory MCP server. + +这个 server 用最精简的方式把两个内部能力暴露成 streamable-http MCP tools: +1. `memory` +2. `session_search` + +运行方式: +1. 直接用 Python: + `python -m beaver.interfaces.mcp.memory_server --host 127.0.0.1 --port 8001` +2. 或者用 FastMCP CLI: + `fastmcp run beaver/interfaces/mcp/memory_server.py:mcp --transport http --port 8001` + +默认 MCP 路径是 `/mcp`,FastMCP 的 HTTP transport 就是 streamable HTTP。 +""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +from typing import Any + +from beaver.engine.session import SessionManager +from beaver.memory.curated.store import MemoryStore +from beaver.tools.builtins.memory import memory_tool +from beaver.tools.builtins.session_search import session_search as run_session_search + +try: # pragma: no cover - import guard for environments without fastmcp + from fastmcp import Context, FastMCP + from fastmcp.server.lifespan import lifespan +except ModuleNotFoundError: # pragma: no cover - handled at runtime in main() + FastMCP = None # type: ignore[assignment] + Context = Any # type: ignore[assignment] + lifespan = None # type: ignore[assignment] + + +def _require_fastmcp() -> None: + if FastMCP is None or lifespan is None: + raise RuntimeError( + "fastmcp is not installed. Install it with `pip install fastmcp` " + "or via this project's dependencies." + ) + + +def _resolve_workspace_path(workspace: str | Path | None = None) -> Path: + """决定 memory server 使用的 workspace 根目录。""" + + if workspace is not None: + return Path(workspace).expanduser().resolve() + env_workspace = os.getenv("BEAVER_WORKSPACE") + if env_workspace: + return Path(env_workspace).expanduser().resolve() + return Path.cwd() + + +def _resolve_memory_dir(workspace: Path) -> Path: + """curated memory 的默认目录。""" + + return workspace / "memory" / "curated" + + +def _resolve_session_db_path(workspace: Path) -> Path: + """session store 的默认路径。""" + + return workspace / "sessions" / "state.db" + + +def create_memory_server( + *, + workspace: str | Path | None = None, + memory_dir: str | Path | None = None, + session_db_path: str | Path | None = None, +): + """创建并返回 FastMCP memory server 实例。""" + + _require_fastmcp() + workspace_path = _resolve_workspace_path(workspace) + resolved_memory_dir = Path(memory_dir).expanduser().resolve() if memory_dir else _resolve_memory_dir(workspace_path) + resolved_session_db = ( + Path(session_db_path).expanduser().resolve() + if session_db_path + else _resolve_session_db_path(workspace_path) + ) + + @lifespan + async def memory_server_lifespan(_server): + """在 server 生命周期内初始化共享 store/db。""" + + store = MemoryStore(resolved_memory_dir) + store.load_from_disk() + session_manager = SessionManager(workspace=workspace_path, db_path=resolved_session_db) + try: + yield { + "workspace_path": workspace_path, + "memory_dir": resolved_memory_dir, + "session_db_path": resolved_session_db, + "memory_store": store, + "session_manager": session_manager, + } + finally: + session_manager.close() + + server = FastMCP( + name="Beaver Memory Server", + instructions=( + "Provides two MCP tools: `memory` for durable curated memory CRUD, " + "and `session_search` for cross-session recall from transcript storage." + ), + lifespan=memory_server_lifespan, + ) + + @server.custom_route("/health", methods=["GET"]) + async def health_check(_request): + """最小 health check,方便远程探活。""" + + from starlette.responses import JSONResponse + + return JSONResponse( + { + "ok": True, + "server": "beaver-memory", + "transport": "streamable-http", + "workspace": str(workspace_path), + "memory_dir": str(resolved_memory_dir), + "session_db_path": str(resolved_session_db), + } + ) + + @server.tool() + async def memory( + action: str, + target: str = "memory", + content: str | None = None, + old_text: str | None = None, + ctx: Context | None = None, + ) -> dict[str, Any]: + """CRUD for curated memory.""" + + if ctx is None: + raise RuntimeError("FastMCP context is required.") + raw_result = memory_tool( + action=action, + target=target, + content=content, + old_text=old_text, + store=ctx.lifespan_context["memory_store"], + ) + return json.loads(raw_result) + + @server.tool() + async def session_search( + query: str = "", + role_filter: str | None = None, + limit: int = 3, + ctx: Context | None = None, + ) -> dict[str, Any]: + """Search prior sessions or browse recent ones.""" + + if ctx is None: + raise RuntimeError("FastMCP context is required.") + raw_result = await run_session_search( + query=query, + role_filter=role_filter, + limit=limit, + db=ctx.lifespan_context["session_manager"], + current_session_id=getattr(ctx, "session_id", None), + ) + return json.loads(raw_result) + + return server + + +def build_arg_parser() -> argparse.ArgumentParser: + """构建最小命令行参数解析器。""" + + parser = argparse.ArgumentParser(description="Run Beaver memory MCP server over streamable HTTP.") + parser.add_argument("--workspace", default=None, help="Workspace root. Defaults to BEAVER_WORKSPACE or cwd.") + parser.add_argument("--memory-dir", default=None, help="Override curated memory directory.") + parser.add_argument("--session-db", default=None, help="Override session SQLite database path.") + parser.add_argument("--host", default="127.0.0.1", help="HTTP bind host.") + parser.add_argument("--port", default=8001, type=int, help="HTTP bind port.") + parser.add_argument("--path", default="/mcp", help="MCP endpoint path.") + return parser + + +def main() -> None: + """以 streamable HTTP 启动 memory server。""" + + parser = build_arg_parser() + args = parser.parse_args() + server = create_memory_server( + workspace=args.workspace, + memory_dir=args.memory_dir, + session_db_path=args.session_db, + ) + server.run( + transport="http", + host=args.host, + port=args.port, + path=args.path, + ) + + +if FastMCP is not None: + mcp = create_memory_server() + + +if __name__ == "__main__": + main() diff --git a/app-instance/backend/beaver/interfaces/web/__init__.py b/app-instance/backend/beaver/interfaces/web/__init__.py new file mode 100644 index 0000000..b8ce1c2 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/web/__init__.py @@ -0,0 +1,2 @@ +"""Web interface.""" + diff --git a/app-instance/backend/beaver/interfaces/web/app.py b/app-instance/backend/beaver/interfaces/web/app.py new file mode 100644 index 0000000..c3a14a5 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/web/app.py @@ -0,0 +1,198 @@ +"""FastAPI app factory for Beaver.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +from beaver.services.agent_service import AgentService + +from .deps import get_agent_service +from .schemas import WebChatRequest, WebChatResponse, WebErrorResponse, WebStatusResponse + +try: + from fastapi import FastAPI, HTTPException, Request +except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments + class HTTPException(Exception): + """Minimal fallback exception matching FastAPI's constructor shape.""" + + def __init__(self, status_code: int, detail: str) -> None: + super().__init__(detail) + self.status_code = status_code + self.detail = detail + + class Request: # type: ignore[override] + """Fallback request shim used only for import-time compatibility.""" + + def __init__(self, app: Any) -> None: + self.app = app + + class FastAPI: # type: ignore[override] + """Small fallback shim so the package can import before dependencies are installed.""" + + def __init__(self, *, title: str, lifespan: Callable[..., Any] | None = None) -> None: + self.title = title + self.lifespan = lifespan + self.state = SimpleNamespace() + + def get(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + return func + + return decorator + + def post(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + return func + + return decorator + + +@asynccontextmanager +async def _app_lifespan( + app: FastAPI, + *, + workspace: str | Path | None, + service: AgentService | None, + manage_service_lifecycle: bool | None, + shutdown_timeout_seconds: float | None, + shutdown_force: bool, +) -> AsyncIterator[None]: + """把 Web app 接到 AgentService lifecycle 上。""" + + attached_service = service or AgentService(workspace=workspace) + owns_service = manage_service_lifecycle if manage_service_lifecycle is not None else service is None + app.state.agent_service = attached_service + started = False + if owns_service: + try: + await attached_service.start() + started = True + except Exception: + attached_service.close() + raise + try: + yield + finally: + if owns_service and started: + await attached_service.shutdown( + timeout_seconds=shutdown_timeout_seconds, + force=shutdown_force, + ) + + +def create_app( + *, + workspace: str | Path | None = None, + service: AgentService | None = None, + manage_service_lifecycle: bool | None = None, + shutdown_timeout_seconds: float | None = 5.0, + shutdown_force: bool = True, +) -> FastAPI: + """Create a Beaver web app hosted by AgentService running mode. + + 默认 ownership 语义: + - 未传 `service`:app 自己创建并接管其 lifecycle + - 传入外部 `service`:默认只挂载,不自动 start/shutdown + + 如果确实需要覆盖默认行为,可以显式传 `manage_service_lifecycle=True/False`。 + """ + + app = FastAPI( + title="Beaver Backend", + lifespan=lambda fastapi_app: _app_lifespan( + fastapi_app, + workspace=workspace, + service=service, + manage_service_lifecycle=manage_service_lifecycle, + shutdown_timeout_seconds=shutdown_timeout_seconds, + shutdown_force=shutdown_force, + ), + ) + + @app.get("/api/ping", response_model=WebStatusResponse) + async def ping(request: Request) -> WebStatusResponse: + agent_service = get_agent_service(request) + running = agent_service.is_running + return WebStatusResponse( + status="ok", + running=running, + mode="running" if running else ("direct" if agent_service.has_loop else "idle"), + ) + + @app.post( + "/api/chat", + response_model=WebChatResponse, + responses={ + 400: {"model": WebErrorResponse}, + 409: {"model": WebErrorResponse}, + 503: {"model": WebErrorResponse}, + }, + ) + async def chat(request: Request, payload: WebChatRequest) -> WebChatResponse: + agent_service = get_agent_service(request) + message = payload.message.strip() + if not message: + raise HTTPException(status_code=400, detail="'message' is required") + + fallback_target = _model_dump(payload.fallback_target) + auxiliary_target = _model_dump(payload.auxiliary_target) + embedding_target = _model_dump(payload.embedding_target) + + try: + result = await agent_service.submit_direct( + message, + session_id=payload.session_id, + source="web", + user_id=payload.user_id, + title=payload.title, + execution_context=payload.execution_context, + model=payload.model, + provider_name=payload.provider_name, + embedding_model=payload.embedding_model, + temperature=payload.temperature, + max_tokens=payload.max_tokens, + max_tool_iterations=payload.max_tool_iterations, + fallback_target=fallback_target, + auxiliary_target=auxiliary_target, + embedding_target=embedding_target, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + detail = str(exc) + if "requires an active run() loop" in detail or "not ready" in detail: + status_code = 503 + elif "submit_direct" in detail or "running" in detail: + status_code = 409 + else: + status_code = 503 + raise HTTPException(status_code=status_code, detail=detail) from exc + + return WebChatResponse( + session_id=result.session_id, + run_id=result.run_id, + output_text=result.output_text, + finish_reason=result.finish_reason, + tool_iterations=result.tool_iterations, + provider_name=result.provider_name, + model=result.model, + usage=result.usage, + ) + + return app + + +def _model_dump(value: Any) -> dict[str, Any] | None: + """兼容 Pydantic v1/v2 的最小导出辅助。""" + + if value is None: + return None + if hasattr(value, "model_dump"): + return value.model_dump(exclude_none=True) + if hasattr(value, "dict"): + return value.dict(exclude_none=True) + return dict(value) diff --git a/app-instance/backend/beaver/interfaces/web/deps.py b/app-instance/backend/beaver/interfaces/web/deps.py new file mode 100644 index 0000000..93a26cb --- /dev/null +++ b/app-instance/backend/beaver/interfaces/web/deps.py @@ -0,0 +1,27 @@ +"""Web dependency wiring.""" + +from __future__ import annotations + +from typing import Any + +from beaver.services.agent_service import AgentService + +try: + from fastapi import HTTPException +except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments + class HTTPException(Exception): + """Minimal fallback exception matching FastAPI's constructor shape.""" + + def __init__(self, status_code: int, detail: str) -> None: + super().__init__(detail) + self.status_code = status_code + self.detail = detail + + +def get_agent_service(request: Any) -> AgentService: + """从 app state 里取当前宿主层托管的 AgentService。""" + + service = getattr(request.app.state, "agent_service", None) + if not isinstance(service, AgentService): + raise HTTPException(status_code=503, detail="AgentService is not ready") + return service diff --git a/app-instance/backend/beaver/interfaces/web/routes/__init__.py b/app-instance/backend/beaver/interfaces/web/routes/__init__.py new file mode 100644 index 0000000..35115e0 --- /dev/null +++ b/app-instance/backend/beaver/interfaces/web/routes/__init__.py @@ -0,0 +1,2 @@ +"""Web routes.""" + diff --git a/app-instance/backend/beaver/interfaces/web/schemas/__init__.py b/app-instance/backend/beaver/interfaces/web/schemas/__init__.py new file mode 100644 index 0000000..f48810a --- /dev/null +++ b/app-instance/backend/beaver/interfaces/web/schemas/__init__.py @@ -0,0 +1,11 @@ +"""Web request and response schemas.""" + +from .chat import WebChatRequest, WebChatResponse, WebErrorResponse, WebProviderTarget, WebStatusResponse + +__all__ = [ + "WebChatRequest", + "WebChatResponse", + "WebErrorResponse", + "WebProviderTarget", + "WebStatusResponse", +] diff --git a/app-instance/backend/beaver/interfaces/web/schemas/chat.py b/app-instance/backend/beaver/interfaces/web/schemas/chat.py new file mode 100644 index 0000000..70cc26a --- /dev/null +++ b/app-instance/backend/beaver/interfaces/web/schemas/chat.py @@ -0,0 +1,93 @@ +"""Chat-related web schemas.""" + +from __future__ import annotations + +from typing import Any + +try: + from pydantic import BaseModel, Field +except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments + class BaseModel: + """Very small fallback shim used only so imports work without pydantic.""" + + def __init__(self, **kwargs: Any) -> None: + annotations = getattr(self.__class__, "__annotations__", {}) + for name in annotations: + default = getattr(self.__class__, name, None) + if name in kwargs: + value = kwargs[name] + else: + value = default + setattr(self, name, value) + + def model_dump(self, *, exclude_none: bool = False) -> dict[str, Any]: + data = dict(self.__dict__) + if exclude_none: + data = {key: value for key, value in data.items() if value is not None} + return data + + def Field(default: Any = None, **kwargs: Any) -> Any: + default_factory = kwargs.get("default_factory") + if default_factory is not None: + return default_factory() + return default + + +class WebProviderTarget(BaseModel): + """Web-facing provider target shape. + + 先保持和 runtime 里的 `ProviderTarget` 接近,但只暴露 Web 当前需要的字段。 + 后面如果 provider 层扩字段,再由这里显式补齐。 + """ + + provider: str | None = None + model: str | None = None + api_key: str | None = None + api_base: str | None = None + extra_headers: dict[str, str] | None = None + + +class WebChatRequest(BaseModel): + """最小正式 chat 请求结构。""" + + message: str = Field(min_length=1) + session_id: str | None = None + user_id: str | None = None + title: str | None = None + execution_context: str | None = None + model: str | None = None + provider_name: str | None = None + embedding_model: str | None = None + temperature: float | None = None + max_tokens: int | None = None + max_tool_iterations: int | None = None + fallback_target: WebProviderTarget | None = None + auxiliary_target: WebProviderTarget | None = None + embedding_target: WebProviderTarget | None = None + + +class WebChatResponse(BaseModel): + """最小正式 chat 响应结构。""" + + session_id: str + run_id: str + output_text: str + finish_reason: str + tool_iterations: int + provider_name: str | None = None + model: str | None = None + usage: dict[str, Any] = Field(default_factory=dict) + + +class WebStatusResponse(BaseModel): + """Web 宿主层状态响应。""" + + status: str + running: bool + mode: str + + +class WebErrorResponse(BaseModel): + """统一错误响应结构。""" + + detail: str diff --git a/app-instance/backend/beaver/memory/__init__.py b/app-instance/backend/beaver/memory/__init__.py new file mode 100644 index 0000000..67a98e4 --- /dev/null +++ b/app-instance/backend/beaver/memory/__init__.py @@ -0,0 +1,2 @@ +"""Memory and experience stores.""" + diff --git a/app-instance/backend/beaver/memory/curated/__init__.py b/app-instance/backend/beaver/memory/curated/__init__.py new file mode 100644 index 0000000..db67bcd --- /dev/null +++ b/app-instance/backend/beaver/memory/curated/__init__.py @@ -0,0 +1,11 @@ +"""Curated long-term memory primitives.""" + +from .snapshot import MemorySnapshot, capture_memory_snapshot +from .store import MemoryStore, scan_memory_content + +__all__ = [ + "MemorySnapshot", + "MemoryStore", + "capture_memory_snapshot", + "scan_memory_content", +] diff --git a/app-instance/backend/beaver/memory/curated/snapshot.py b/app-instance/backend/beaver/memory/curated/snapshot.py new file mode 100644 index 0000000..81515ce --- /dev/null +++ b/app-instance/backend/beaver/memory/curated/snapshot.py @@ -0,0 +1,52 @@ +"""curated memory 的冻结快照工具。 + +这个文件很小,但职责非常关键:它把“长期记忆的 live state”和“当前会话注入 prompt +时使用的 frozen snapshot”明确分开。 + +设计目的: +1. 让调用侧显式意识到:system prompt 使用的是一份冻结视图 +2. 避免后续 engine/context builder 直接偷读 live store,破坏 frozen snapshot 语义 +3. 给 prompt 组装层一个简单、稳定、可测试的数据结构 +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .store import MemoryStore + + +@dataclass(frozen=True, slots=True) +class MemorySnapshot: + """当前 session 使用的冻结记忆快照。 + + 这里不是 memory store 本体,而是“给 prompt builder 的只读投影”。 + 一旦 capture 完成,这个对象就代表本 session 的注入视图,不应在会话中途被修改。 + """ + + memory_block: str | None + user_block: str | None + + def as_prompt_sections(self) -> list[str]: + """按稳定顺序返回可直接拼接进 prompt 的 section 列表。 + + 顺序固定为: + 1. user profile + 2. agent memory + + 这样后续 context builder 的输出更稳定,测试也更容易写。 + """ + + return [section for section in (self.user_block, self.memory_block) if section] + + +def capture_memory_snapshot(store: MemoryStore) -> MemorySnapshot: + """从 `MemoryStore` 提取当前 session 的 frozen snapshot。 + + 前提是 `store.load_from_disk()` 已经在 session 启动时调用过,否则拿到的只是空快照。 + """ + + return MemorySnapshot( + memory_block=store.format_for_system_prompt("memory"), + user_block=store.format_for_system_prompt("user"), + ) diff --git a/app-instance/backend/beaver/memory/curated/store.py b/app-instance/backend/beaver/memory/curated/store.py new file mode 100644 index 0000000..a02616f --- /dev/null +++ b/app-instance/backend/beaver/memory/curated/store.py @@ -0,0 +1,463 @@ +"""Beaver 的精炼长期记忆存储层。 + +这个文件实现的是以 Hermes-agent 为基线的 curated memory 模型,目标不是 +“把所有历史都存下来”,而是只保存跨会话仍然值得保留的稳定事实。 + +核心设计: +1. 只保留两个持久化记忆桶: + - ``memory``: agent 自己对环境、项目、工具 quirks 的长期备注 + - ``user``: 对用户偏好、习惯、身份信息的长期理解 +2. ``replace`` / ``remove`` 不使用 UUID,而是使用短语义片段做子串匹配。 + 这是为了适配 LLM 更擅长“记住一句话片段”而不是“追踪一个随机 ID”的现实。 +3. 写入前先做安全扫描,避免把 prompt injection / secrets exfiltration + 一类危险内容写入长期记忆,再在未来会话中反向污染 system prompt。 +4. 写入协议严格遵守: + - scan + - lock + - reload + - validate + - atomic write +5. 本文件维护两份状态: + - live state: 当前内存中的真实条目,tool 写入后立刻变化 + - frozen snapshot: 会话开始时冻结的一份 prompt 注入快照 + +其中最重要的一点是:本会话中新增的记忆会立刻写盘,但不会反向修改本会话 +已经冻结的 system prompt。这样可以保住 prefix cache,也避免“会话中途 prompt +变了导致行为抖动”的问题。 +""" + +from __future__ import annotations + +import os +import re +import tempfile +from contextlib import contextmanager +from pathlib import Path +from typing import Any + +try: + import fcntl +except ImportError: # pragma: no cover - Windows fallback + fcntl = None + +try: + import msvcrt +except ImportError: # pragma: no cover - Unix platforms + msvcrt = None + +ENTRY_DELIMITER = "\n§\n" +DEFAULT_MEMORY_FILENAME = "MEMORY.md" +DEFAULT_USER_FILENAME = "USER.md" + +_MEMORY_THREAT_PATTERNS: list[tuple[str, str]] = [ + (r"ignore\s+(previous|all|above|prior)\s+instructions", "prompt_injection"), + (r"you\s+are\s+now\s+", "role_hijack"), + (r"do\s+not\s+tell\s+the\s+user", "deception_hide"), + (r"system\s+prompt\s+override", "sys_prompt_override"), + (r"disregard\s+(your|all|any)\s+(instructions|rules|guidelines)", "disregard_rules"), + (r"act\s+as\s+(if|though)\s+you\s+(have\s+no|don't\s+have)\s+(restrictions|limits|rules)", "bypass_restrictions"), + (r"curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)", "exfil_curl"), + (r"wget\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)", "exfil_wget"), + (r"cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass|\.npmrc|\.pypirc)", "read_secrets"), + (r"authorized_keys", "ssh_backdoor"), + (r"\$HOME/\.ssh|\~/\.ssh", "ssh_access"), + (r"\$HOME/\.beaver/\.env|\~/\.beaver/\.env", "beaver_env"), +] + +_INVISIBLE_CHARS = { + "\u200b", + "\u200c", + "\u200d", + "\u2060", + "\ufeff", + "\u202a", + "\u202b", + "\u202c", + "\u202d", + "\u202e", +} + + +def scan_memory_content(content: str) -> str | None: + """扫描待写入内容,拦截明显危险的记忆条目。 + + 这里不是在做完备的安全审计,而是在做“进入长期记忆之前的最低限度闸门”。 + 因为长期记忆会在未来会话中重新注入 system prompt,所以一旦把恶意文本写进去, + 风险远高于普通临时上下文。 + """ + + for char in _INVISIBLE_CHARS: + if char in content: + return ( + f"Blocked: content contains invisible unicode character " + f"U+{ord(char):04X}." + ) + + for pattern, pattern_id in _MEMORY_THREAT_PATTERNS: + if re.search(pattern, content, re.IGNORECASE): + return ( + f"Blocked: content matches threat pattern '{pattern_id}'. " + "Memory entries are injected into future system prompts." + ) + + return None + + +class MemoryStore: + """带容量上限的长期记忆存储。 + + 这个类负责: + 1. 从磁盘加载 `MEMORY.md` / `USER.md` + 2. 在 session 启动时冻结 prompt snapshot + 3. 为 `add / replace / remove` 提供安全写接口 + 4. 维护 live state 与 frozen snapshot 的边界 + + 它不负责: + 1. 自动从对话里抽取要记住的内容 + 2. session transcript 检索 + 3. skills 的学习和发布 + """ + + def __init__( + self, + root: str | Path, + *, + memory_char_limit: int = 2200, + user_char_limit: int = 1375, + ) -> None: + self.root = Path(root) + self.memory_char_limit = memory_char_limit + self.user_char_limit = user_char_limit + self.memory_entries: list[str] = [] + self.user_entries: list[str] = [] + self._system_prompt_snapshot: dict[str, str] = {"memory": "", "user": ""} + + def load_from_disk(self) -> None: + """从磁盘加载 live state,并冻结当前 session 的 prompt snapshot。 + + 调用时机应该是“会话启动时”,而不是每次工具写入后。 + 如果在每次写入后都重新 load 并更新 system prompt,就会破坏 frozen snapshot + 这个设计,导致本轮会话 prompt 前缀发生变化。 + """ + + self.root.mkdir(parents=True, exist_ok=True) + self.memory_entries = list(dict.fromkeys(self._read_file(self._path_for("memory")))) + self.user_entries = list(dict.fromkeys(self._read_file(self._path_for("user")))) + self._system_prompt_snapshot = { + "memory": self._render_block("memory", self.memory_entries), + "user": self._render_block("user", self.user_entries), + } + + @contextmanager + def _file_lock(self, path: Path): + """对目标记忆文件加排他锁。 + + 锁文件使用 sibling `.lock` 文件,而不是直接锁业务文件本身。 + 原因是业务文件使用的是“临时文件写入 + os.replace 原子替换”,如果直接锁目标 + 文件,替换时会让锁语义和文件句柄关系变得更脆弱。 + """ + + lock_path = path.with_suffix(path.suffix + ".lock") + lock_path.parent.mkdir(parents=True, exist_ok=True) + + if fcntl is None and msvcrt is None: + yield + return + + if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0): + lock_path.write_text(" ", encoding="utf-8") + + fd = open(lock_path, "r+" if msvcrt else "a+", encoding="utf-8") + try: + if fcntl is not None: + fcntl.flock(fd, fcntl.LOCK_EX) + elif msvcrt is not None: # pragma: no cover - Windows fallback + fd.seek(0) + msvcrt.locking(fd.fileno(), msvcrt.LK_LOCK, 1) + yield + finally: + if fcntl is not None: + fcntl.flock(fd, fcntl.LOCK_UN) + elif msvcrt is not None: # pragma: no cover - Windows fallback + try: + fd.seek(0) + msvcrt.locking(fd.fileno(), msvcrt.LK_UNLCK, 1) + except OSError: + pass + fd.close() + + def _path_for(self, target: str) -> Path: + """根据目标桶返回实际文件路径。""" + if target == "user": + return self.root / DEFAULT_USER_FILENAME + return self.root / DEFAULT_MEMORY_FILENAME + + def _entries_for(self, target: str) -> list[str]: + """读取某个目标桶当前的 live entries。""" + if target == "user": + return self.user_entries + return self.memory_entries + + def _set_entries(self, target: str, entries: list[str]) -> None: + """更新某个目标桶在内存中的 live entries。""" + if target == "user": + self.user_entries = entries + else: + self.memory_entries = entries + + def _char_limit(self, target: str) -> int: + """返回目标桶的字符预算。 + + 这里使用字符数而不是 token 数,是因为字符预算更稳定,也不依赖具体模型。 + """ + return self.user_char_limit if target == "user" else self.memory_char_limit + + def _char_count(self, target: str) -> int: + """返回目标桶当前 live state 的字符占用。""" + entries = self._entries_for(target) + return len(ENTRY_DELIMITER.join(entries)) if entries else 0 + + def _reload_target(self, target: str) -> None: + """在持锁状态下重新从磁盘读取目标桶。 + + 这是并发安全协议里最关键的一步之一。 + 必须在拿到锁之后 reload,才能确保当前进程不会覆盖掉其他并发会话刚刚写入 + 的最新内容。 + """ + fresh = list(dict.fromkeys(self._read_file(self._path_for(target)))) + self._set_entries(target, fresh) + + def save_to_disk(self, target: str) -> None: + """把当前 live entries 持久化到磁盘。""" + self.root.mkdir(parents=True, exist_ok=True) + self._write_file(self._path_for(target), self._entries_for(target)) + + def add(self, target: str, content: str) -> dict[str, Any]: + """追加一条新的长期记忆。 + + 规则: + 1. 空内容拒绝 + 2. 安全扫描不通过拒绝 + 3. 精确重复拒绝 + 4. 超出字符预算拒绝 + 5. 否则追加并立即写盘 + """ + + content = content.strip() + if not content: + return {"success": False, "error": "Content cannot be empty."} + + scan_error = scan_memory_content(content) + if scan_error: + return {"success": False, "error": scan_error} + + with self._file_lock(self._path_for(target)): + self._reload_target(target) + entries = self._entries_for(target) + if content in entries: + return self._success_response(target, "Entry already exists (skipped duplicate).") + + new_entries = entries + [content] + new_total = len(ENTRY_DELIMITER.join(new_entries)) + limit = self._char_limit(target) + if new_total > limit: + current = self._char_count(target) + return { + "success": False, + "error": ( + f"Memory at {current:,}/{limit:,} chars. " + f"Adding this entry ({len(content)} chars) would exceed the limit." + ), + "current_entries": list(entries), + "usage": f"{current:,}/{limit:,}", + } + + entries.append(content) + self._set_entries(target, entries) + self.save_to_disk(target) + + return self._success_response(target, "Entry added.") + + def replace(self, target: str, old_text: str, new_content: str) -> dict[str, Any]: + """用新的内容替换一条已有记忆。 + + 这里按 `old_text in entry` 做子串匹配,而不是要求调用方提供完整条目或 UUID。 + 如果命中多条且它们内容不同,会要求调用方给出更精确的片段,避免误替换。 + """ + + old_text = old_text.strip() + new_content = new_content.strip() + if not old_text: + return {"success": False, "error": "old_text cannot be empty."} + if not new_content: + return { + "success": False, + "error": "new_content cannot be empty. Use remove to delete entries.", + } + + scan_error = scan_memory_content(new_content) + if scan_error: + return {"success": False, "error": scan_error} + + with self._file_lock(self._path_for(target)): + self._reload_target(target) + entries = self._entries_for(target) + matches = [(index, entry) for index, entry in enumerate(entries) if old_text in entry] + if not matches: + return {"success": False, "error": f"No entry matched '{old_text}'."} + + if len(matches) > 1: + unique_texts = {entry for _, entry in matches} + if len(unique_texts) > 1: + return { + "success": False, + "error": f"Multiple entries matched '{old_text}'. Be more specific.", + "matches": [ + entry[:80] + ("..." if len(entry) > 80 else "") + for _, entry in matches + ], + } + + index = matches[0][0] + candidate_entries = list(entries) + candidate_entries[index] = new_content + new_total = len(ENTRY_DELIMITER.join(candidate_entries)) + limit = self._char_limit(target) + if new_total > limit: + return { + "success": False, + "error": ( + f"Replacement would put memory at {new_total:,}/{limit:,} chars. " + "Shorten the new content or remove other entries first." + ), + } + + entries[index] = new_content + self._set_entries(target, entries) + self.save_to_disk(target) + + return self._success_response(target, "Entry replaced.") + + def remove(self, target: str, old_text: str) -> dict[str, Any]: + """删除一条已有记忆。 + + 删除和替换共享同样的匹配策略:优先服务于 LLM 可操作性,而不是数据库式的强 ID。 + """ + + old_text = old_text.strip() + if not old_text: + return {"success": False, "error": "old_text cannot be empty."} + + with self._file_lock(self._path_for(target)): + self._reload_target(target) + entries = self._entries_for(target) + matches = [(index, entry) for index, entry in enumerate(entries) if old_text in entry] + if not matches: + return {"success": False, "error": f"No entry matched '{old_text}'."} + + if len(matches) > 1: + unique_texts = {entry for _, entry in matches} + if len(unique_texts) > 1: + return { + "success": False, + "error": f"Multiple entries matched '{old_text}'. Be more specific.", + "matches": [ + entry[:80] + ("..." if len(entry) > 80 else "") + for _, entry in matches + ], + } + + entries.pop(matches[0][0]) + self._set_entries(target, entries) + self.save_to_disk(target) + + return self._success_response(target, "Entry removed.") + + def format_for_system_prompt(self, target: str) -> str | None: + """返回 session 启动时冻结下来的 prompt block。 + + 这里明确返回的是 frozen snapshot,而不是 live state。 + 所以如果 session 中途调用 `add()` 写入了新记忆,这里不会立刻变化。 + """ + + block = self._system_prompt_snapshot.get(target, "") + return block or None + + def _success_response(self, target: str, message: str | None = None) -> dict[str, Any]: + """统一生成 memory tool 的成功响应。 + + 响应里返回 live entries 和占用信息,目的是让模型能“看到自己刚写进去什么”, + 即使 system prompt 仍然保持冻结不变。 + """ + current = self._char_count(target) + limit = self._char_limit(target) + percent = min(100, int((current / limit) * 100)) if limit > 0 else 0 + payload: dict[str, Any] = { + "success": True, + "target": target, + "entries": list(self._entries_for(target)), + "entry_count": len(self._entries_for(target)), + "usage": f"{percent}% — {current:,}/{limit:,} chars", + } + if message: + payload["message"] = message + return payload + + def _render_block(self, target: str, entries: list[str]) -> str: + """把条目渲染成适合注入 system prompt 的块。""" + if not entries: + return "" + + current = len(ENTRY_DELIMITER.join(entries)) + limit = self._char_limit(target) + percent = min(100, int((current / limit) * 100)) if limit > 0 else 0 + if target == "user": + header = f"USER PROFILE (who the user is) [{percent}% — {current:,}/{limit:,} chars]" + else: + header = f"MEMORY (your personal notes) [{percent}% — {current:,}/{limit:,} chars]" + separator = "═" * 46 + return f"{separator}\n{header}\n{separator}\n{ENTRY_DELIMITER.join(entries)}" + + @staticmethod + def _read_file(path: Path) -> list[str]: + """读取记忆文件并按 entry delimiter 拆分。 + + 这里不额外加读锁,因为写入采用的是原子替换:读者只会看到旧完整文件或新完整文件, + 不会看到半写入状态。 + """ + if not path.exists(): + return [] + try: + raw = path.read_text(encoding="utf-8") + except OSError: + return [] + if not raw.strip(): + return [] + return [entry for entry in (item.strip() for item in raw.split(ENTRY_DELIMITER)) if entry] + + @staticmethod + def _write_file(path: Path, entries: list[str]) -> None: + """以原子方式写入记忆文件。 + + 这里不能直接 `open(path, "w")`,因为那会先截断原文件,再写新内容。 + 如果恰好此时别的进程正在读,就可能读到空文件或半成品。 + + 正确方式是: + 1. 在同目录创建临时文件 + 2. 写入并 fsync + 3. 使用 `os.replace()` 原子替换 + """ + content = ENTRY_DELIMITER.join(entries) if entries else "" + fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp", prefix=".mem_") + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp_path, path) + except BaseException: + try: + os.unlink(tmp_path) + except OSError: + pass + raise diff --git a/app-instance/backend/beaver/memory/procedures/__init__.py b/app-instance/backend/beaver/memory/procedures/__init__.py new file mode 100644 index 0000000..4c36bf0 --- /dev/null +++ b/app-instance/backend/beaver/memory/procedures/__init__.py @@ -0,0 +1,2 @@ +"""Reusable procedures.""" + diff --git a/app-instance/backend/beaver/memory/runs/__init__.py b/app-instance/backend/beaver/memory/runs/__init__.py new file mode 100644 index 0000000..19f26fa --- /dev/null +++ b/app-instance/backend/beaver/memory/runs/__init__.py @@ -0,0 +1,2 @@ +"""Run records.""" + diff --git a/app-instance/backend/beaver/memory/search/__init__.py b/app-instance/backend/beaver/memory/search/__init__.py new file mode 100644 index 0000000..9c689a6 --- /dev/null +++ b/app-instance/backend/beaver/memory/search/__init__.py @@ -0,0 +1,5 @@ +"""Session transcript search storage.""" + +from .transcript_store import TranscriptStore + +__all__ = ["TranscriptStore"] diff --git a/app-instance/backend/beaver/memory/search/transcript_store.py b/app-instance/backend/beaver/memory/search/transcript_store.py new file mode 100644 index 0000000..a0d2b7a --- /dev/null +++ b/app-instance/backend/beaver/memory/search/transcript_store.py @@ -0,0 +1,46 @@ +"""兼容层:过渡期把旧 transcript store 导向新的 session 子系统。 + +真正的主实现现在在: +1. `beaver.engine.session.store` +2. `beaver.engine.session.search` +3. `beaver.engine.session.manager` + +保留这个文件只是为了避免已经写好的 MCP server / tool 导入立刻断掉。 +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from beaver.engine.session.manager import SessionManager + + +class TranscriptStore: + """兼容旧接口的薄封装。""" + + def __init__(self, db_path: str | Path) -> None: + path = Path(db_path) + workspace = path.parent.parent if path.parent.name == "sessions" else path.parent + self.manager = SessionManager(workspace=workspace, db_path=path) + + def close(self) -> None: + self.manager.close() + + def ensure_session(self, session_id: str, **kwargs: Any) -> str: + return self.manager.ensure_session(session_id, **kwargs) + + def append_message(self, session_id: str, **kwargs: Any) -> int: + return self.manager.append_message(session_id, **kwargs) + + def get_session(self, session_id: str) -> dict[str, Any] | None: + return self.manager.get_session(session_id) + + def list_sessions_rich(self, **kwargs: Any) -> list[dict[str, Any]]: + return self.manager.list_sessions_rich(**kwargs) + + def get_messages_as_conversation(self, session_id: str) -> list[dict[str, Any]]: + return self.manager.get_messages_as_conversation(session_id) + + def search_messages(self, **kwargs: Any) -> list[dict[str, Any]]: + return self.manager.search_messages(**kwargs) diff --git a/app-instance/backend/beaver/memory/skills/__init__.py b/app-instance/backend/beaver/memory/skills/__init__.py new file mode 100644 index 0000000..2d64a53 --- /dev/null +++ b/app-instance/backend/beaver/memory/skills/__init__.py @@ -0,0 +1,2 @@ +"""Memory related to skill evolution.""" + diff --git a/app-instance/backend/beaver/memory/stores/__init__.py b/app-instance/backend/beaver/memory/stores/__init__.py new file mode 100644 index 0000000..d079288 --- /dev/null +++ b/app-instance/backend/beaver/memory/stores/__init__.py @@ -0,0 +1,2 @@ +"""Storage backends for memory.""" + diff --git a/app-instance/backend/beaver/permissions/__init__.py b/app-instance/backend/beaver/permissions/__init__.py new file mode 100644 index 0000000..9af03f8 --- /dev/null +++ b/app-instance/backend/beaver/permissions/__init__.py @@ -0,0 +1,2 @@ +"""Permission and governance layer.""" + diff --git a/app-instance/backend/beaver/permissions/guards/__init__.py b/app-instance/backend/beaver/permissions/guards/__init__.py new file mode 100644 index 0000000..3bf68bd --- /dev/null +++ b/app-instance/backend/beaver/permissions/guards/__init__.py @@ -0,0 +1,2 @@ +"""Execution guards.""" + diff --git a/app-instance/backend/beaver/permissions/policies/__init__.py b/app-instance/backend/beaver/permissions/policies/__init__.py new file mode 100644 index 0000000..94b4128 --- /dev/null +++ b/app-instance/backend/beaver/permissions/policies/__init__.py @@ -0,0 +1,2 @@ +"""Permission policies.""" + diff --git a/app-instance/backend/beaver/permissions/profiles/__init__.py b/app-instance/backend/beaver/permissions/profiles/__init__.py new file mode 100644 index 0000000..46afe41 --- /dev/null +++ b/app-instance/backend/beaver/permissions/profiles/__init__.py @@ -0,0 +1,2 @@ +"""Agent permission profiles.""" + diff --git a/app-instance/backend/beaver/plugins/__init__.py b/app-instance/backend/beaver/plugins/__init__.py new file mode 100644 index 0000000..a9e5f3d --- /dev/null +++ b/app-instance/backend/beaver/plugins/__init__.py @@ -0,0 +1,2 @@ +"""Plugin system for Beaver.""" + diff --git a/app-instance/backend/beaver/plugins/hooks.py b/app-instance/backend/beaver/plugins/hooks.py new file mode 100644 index 0000000..49569a0 --- /dev/null +++ b/app-instance/backend/beaver/plugins/hooks.py @@ -0,0 +1,2 @@ +"""Plugin extension hooks.""" + diff --git a/app-instance/backend/beaver/plugins/loader.py b/app-instance/backend/beaver/plugins/loader.py new file mode 100644 index 0000000..80ff70c --- /dev/null +++ b/app-instance/backend/beaver/plugins/loader.py @@ -0,0 +1,2 @@ +"""Plugin loading hooks.""" + diff --git a/app-instance/backend/beaver/plugins/registry.py b/app-instance/backend/beaver/plugins/registry.py new file mode 100644 index 0000000..198e436 --- /dev/null +++ b/app-instance/backend/beaver/plugins/registry.py @@ -0,0 +1,2 @@ +"""Plugin registry.""" + diff --git a/app-instance/backend/beaver/services/__init__.py b/app-instance/backend/beaver/services/__init__.py new file mode 100644 index 0000000..69a53e3 --- /dev/null +++ b/app-instance/backend/beaver/services/__init__.py @@ -0,0 +1,6 @@ +"""Application services for Beaver.""" + +from .agent_service import AgentService +from .memory_service import MemoryService + +__all__ = ["AgentService", "MemoryService"] diff --git a/app-instance/backend/beaver/services/admin_service.py b/app-instance/backend/beaver/services/admin_service.py new file mode 100644 index 0000000..73bd4b6 --- /dev/null +++ b/app-instance/backend/beaver/services/admin_service.py @@ -0,0 +1,2 @@ +"""Administrative application service.""" + diff --git a/app-instance/backend/beaver/services/agent_service.py b/app-instance/backend/beaver/services/agent_service.py new file mode 100644 index 0000000..0c479e9 --- /dev/null +++ b/app-instance/backend/beaver/services/agent_service.py @@ -0,0 +1,212 @@ +"""Application service for agent entry. + +这层的职责是把“接口层如何调用 AgentLoop”统一收口。 + +接口层以后不应该各自做这些事情: +1. 自己 new `AgentLoop` +2. 自己决定何时 `boot()` +3. 自己处理 direct run 的同步/异步包装 + +统一放在 `AgentService` 后,CLI / Web / Gateway 才能共享同一条运行主链。 +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any + +from beaver.engine import AgentLoop, AgentProfile, AgentRunResult, EngineLoader + + +class AgentService: + """面向 interfaces 的统一 agent 运行入口。 + + 这里明确区分两种调用模式: + 1. direct mode + - 不启动后台运行循环 + - 直接调用 `process_direct()` / `run_direct()` + 2. running mode + - 先 `await start()` + - 之后所有外部任务都必须走 `submit_direct()` + - 不允许再直接调用 `process_direct()` + """ + + def __init__( + self, + *, + workspace: str | Path | None = None, + profile: AgentProfile | None = None, + loader: EngineLoader | None = None, + ) -> None: + self.profile = profile or AgentProfile() + self.loader = loader or EngineLoader(workspace=workspace) + self._loop: AgentLoop | None = None + self._run_task: asyncio.Task[None] | None = None + + def create_loop(self) -> AgentLoop: + """创建并缓存当前 service 使用的 AgentLoop。""" + + if self._loop is None: + self._loop = AgentLoop(profile=self.profile, loader=self.loader) + self._loop.boot() + return self._loop + + @property + def has_loop(self) -> bool: + """当前 service 是否已经创建过 loop。""" + + return self._loop is not None + + @property + def is_running(self) -> bool: + """当前 service 是否处于 running mode。""" + + return self._run_task is not None and not self._run_task.done() + + def close(self) -> None: + """关闭当前 service 持有的 runtime。""" + + if self._run_task is not None and not self._run_task.done(): + raise RuntimeError("AgentService.close() requires stop() before closing a running loop") + self._run_task = None + if self._loop is None: + return + try: + self._loop.close() + finally: + self._loop = None + + async def start(self) -> None: + """启动后台运行循环,进入 running mode。 + + 进入 running mode 后: + - 外部任务必须通过 `submit_direct()` 提交 + - `process_direct()` 不再允许直接调用 + """ + + if self._run_task is not None and not self._run_task.done(): + return + loop = self.create_loop() + self._run_task = asyncio.create_task(loop.run()) + while not loop.is_running: + if self._run_task.done(): + await self._run_task + break + await asyncio.sleep(0) + + async def _stop_impl( + self, + *, + timeout_seconds: float | None = None, + force: bool = False, + ) -> None: + """内部停止实现,支持 graceful timeout 和可选 force cancel。""" + + if self._run_task is None: + return + run_task = self._run_task + loop = self.create_loop() + try: + await loop.stop() + if timeout_seconds is None: + await run_task + else: + try: + await asyncio.wait_for(asyncio.shield(run_task), timeout=timeout_seconds) + except asyncio.TimeoutError as exc: + if force: + run_task.cancel() + try: + await run_task + except asyncio.CancelledError: + pass + else: + raise TimeoutError( + f"AgentService.stop() timed out after {timeout_seconds} seconds while draining queued tasks" + ) from exc + finally: + if run_task.done(): + self._run_task = None + + async def stop( + self, + *, + timeout_seconds: float | None = None, + force: bool = False, + ) -> None: + """停止后台运行循环并等待退出。 + + 参数: + - `timeout_seconds`: graceful drain 的最长等待时间;`None` 表示一直等 + - `force`: 超时后是否 cancel 掉运行循环 task + """ + + await self._stop_impl(timeout_seconds=timeout_seconds, force=force) + + async def shutdown( + self, + *, + timeout_seconds: float | None = None, + force: bool = False, + ) -> None: + """先停运行循环,再释放 runtime。""" + + await self._stop_impl(timeout_seconds=timeout_seconds, force=force) + self.close() + + async def process_direct( + self, + message: str, + **kwargs: Any, + ) -> AgentRunResult: + """异步 direct run 入口。 + + 仅在 direct mode 下可用。 + + 如果 service 已经 `start()` 进入 running mode, + 调用方必须改用 `submit_direct()`,不能绕过运行队列直接执行。 + """ + + if self._run_task is not None and not self._run_task.done(): + raise RuntimeError( + "AgentService.process_direct() is unavailable while the service is running; " + "use 'await AgentService.submit_direct(...)' after start()." + ) + loop = self.create_loop() + return await loop.process_direct(message, **kwargs) + + async def submit_direct( + self, + message: str, + **kwargs: Any, + ) -> AgentRunResult: + """向 running mode 下的 loop 提交 direct task。 + + 这是 `start()` 之后唯一合法的外部任务入口。 + """ + + loop = self.create_loop() + return await loop.submit_direct(message, **kwargs) + + def run_direct( + self, + message: str, + **kwargs: Any, + ) -> AgentRunResult: + """同步 direct run 包装。 + + 主要给当前 CLI 或简单脚本使用。真正的长期方向仍然是让 interfaces + 在 direct mode 下直接走 `await process_direct(...)`。 + """ + + try: + asyncio.get_running_loop() + except RuntimeError: + pass + else: + raise RuntimeError( + "AgentService.run_direct() cannot be used inside an active event loop; " + "use 'await AgentService.process_direct(...)' instead." + ) + return asyncio.run(self.process_direct(message, **kwargs)) diff --git a/app-instance/backend/beaver/services/memory_service.py b/app-instance/backend/beaver/services/memory_service.py new file mode 100644 index 0000000..e98e339 --- /dev/null +++ b/app-instance/backend/beaver/services/memory_service.py @@ -0,0 +1,65 @@ +"""Beaver memory 应用服务。 + +这层不是新的 memory 实现,而是对现有 `MemoryStore + MemorySnapshot` 的应用层包装。 + +目标只有三个: +1. 把“本轮运行前需要 refresh live state”这件事集中到一个地方 +2. 把“给 context builder 的只能是 frozen snapshot”这条规则写死 +3. 让 `AgentLoop` 不再直接操作 `MemoryStore` 细节 + +设计边界: +1. 记忆实际读写逻辑仍然在 `beaver.memory.curated.store.MemoryStore` +2. memory tool 仍然直接写 store +3. 本服务只负责 runtime 接入策略,不负责 CRUD 业务本身 +""" + +from __future__ import annotations + +from pathlib import Path + +from beaver.memory.curated.snapshot import MemorySnapshot, capture_memory_snapshot +from beaver.memory.curated.store import MemoryStore + + +class MemoryService: + """统一封装 runtime 对 curated memory 的访问方式。""" + + def __init__( + self, + root: str | Path, + *, + store: MemoryStore | None = None, + ) -> None: + self.root = Path(root) + self.store = store or MemoryStore(self.root) + self._snapshot: MemorySnapshot | None = None + + def initialize(self) -> None: + """启动时加载一次磁盘内容,建立首份 frozen snapshot 基线。""" + + self.store.load_from_disk() + self._snapshot = capture_memory_snapshot(self.store) + + def reload_for_new_run(self) -> None: + """每次新 run 开始前刷新 live state。 + + 这是 Hermes 风格 memory policy 的关键点: + - 上一次会话中通过 tool 写入的持久记忆,下一次运行应该能看到 + - 但同一次 run 中途写入的新记忆,不应反向修改当前 frozen snapshot + """ + + self.store.load_from_disk() + self._snapshot = capture_memory_snapshot(self.store) + + def get_snapshot(self) -> MemorySnapshot: + """获取当前 run 应注入 system prompt 的 frozen snapshot。""" + + if self._snapshot is None: + # 兜底场景:如果调用方绕过 initialize/reload,首次读取时仍建立一份快照。 + self._snapshot = capture_memory_snapshot(self.store) + return self._snapshot + + def get_store(self) -> MemoryStore: + """暴露底层 store 给需要直接调用 CRUD 的工具层。""" + + return self.store diff --git a/app-instance/backend/beaver/services/skill_service.py b/app-instance/backend/beaver/services/skill_service.py new file mode 100644 index 0000000..fdfb92e --- /dev/null +++ b/app-instance/backend/beaver/services/skill_service.py @@ -0,0 +1,2 @@ +"""Application service for skills.""" + diff --git a/app-instance/backend/beaver/services/team_service.py b/app-instance/backend/beaver/services/team_service.py new file mode 100644 index 0000000..deed230 --- /dev/null +++ b/app-instance/backend/beaver/services/team_service.py @@ -0,0 +1,10 @@ +"""Application service for coordinated team runs.""" + + +class TeamService: + """Placeholder service for multi-agent execution.""" + + def run(self, task: str) -> str: + """Return a placeholder summary until real backends are migrated.""" + return f"team run placeholder: {task}" + diff --git a/app-instance/backend/beaver/skills/__init__.py b/app-instance/backend/beaver/skills/__init__.py new file mode 100644 index 0000000..b90112c --- /dev/null +++ b/app-instance/backend/beaver/skills/__init__.py @@ -0,0 +1,12 @@ +"""Skill system for Beaver.""" + +from .assembler import SkillAssembler, SkillAssemblyResult, SkillEmbeddingRetriever +from .catalog import SkillRecord, SkillsLoader + +__all__ = [ + "SkillAssembler", + "SkillAssemblyResult", + "SkillEmbeddingRetriever", + "SkillRecord", + "SkillsLoader", +] diff --git a/app-instance/backend/beaver/skills/assembler/__init__.py b/app-instance/backend/beaver/skills/assembler/__init__.py new file mode 100644 index 0000000..c24b402 --- /dev/null +++ b/app-instance/backend/beaver/skills/assembler/__init__.py @@ -0,0 +1,6 @@ +"""Skill assembly for Beaver.""" + +from .embedding_retriever import SkillEmbeddingRetriever +from .task_assembler import SkillAssemblyResult, SkillAssembler + +__all__ = ["SkillAssemblyResult", "SkillAssembler", "SkillEmbeddingRetriever"] diff --git a/app-instance/backend/beaver/skills/assembler/embedding_retriever.py b/app-instance/backend/beaver/skills/assembler/embedding_retriever.py new file mode 100644 index 0000000..a7d74e7 --- /dev/null +++ b/app-instance/backend/beaver/skills/assembler/embedding_retriever.py @@ -0,0 +1,188 @@ +"""Embedding-based skill candidate retrieval. + +当前实现使用 OpenAI-compatible `/v1/embeddings` 接口调用 +阿里云百炼 `text-embedding-v4` 做最小语义召回: +1. 复用当前 provider 的 `api_key/api_base` +2. 先用 embedding 相似度召回一小批候选 +3. 再交给上层 LLM selector 做最终技能选择 +""" + +from __future__ import annotations + +import asyncio +import math +import os +import json +from urllib import request +from typing import Any + + +class SkillEmbeddingRetriever: + """用 OpenAI-compatible embeddings API 为 skill 选择做候选召回。""" + + def __init__( + self, + *, + api_key_env: str = "OPENAI_API_KEY", + api_base_env: str = "OPENAI_API_BASE", + model: str = "text-embedding-v4", + timeout_seconds: float = 20.0, + ) -> None: + self.api_key_env = api_key_env + self.api_base_env = api_base_env + self.model = model + self.timeout_seconds = timeout_seconds + + async def retrieve( + self, + *, + query: str, + candidates: list[dict[str, str]], + top_k: int = 12, + api_key: str | None = None, + api_base: str | None = None, + model: str | None = None, + ) -> list[dict[str, str]]: + """按 embedding 相似度召回 top-k 候选。 + + 如果没有可用的 API Key / base URL,或者 embedding 调用失败, + 当前阶段先退回到“全部候选交给 LLM selector”。 + """ + + if not candidates: + return [] + + resolved_api_key = api_key or os.getenv(self.api_key_env) + resolved_api_base = api_base or os.getenv(self.api_base_env) + if not resolved_api_key or not resolved_api_base: + return candidates + + try: + query_embedding = await self._embed_texts( + api_key=resolved_api_key, + api_base=resolved_api_base, + texts=[query], + model=model or self.model, + ) + candidate_texts = [self._candidate_text(item) for item in candidates] + candidate_embeddings = await self._embed_texts( + api_key=resolved_api_key, + api_base=resolved_api_base, + texts=candidate_texts, + model=model or self.model, + ) + except Exception: + return candidates + + if not query_embedding or not query_embedding[0] or len(candidate_embeddings) != len(candidates): + return candidates + + query_vector = query_embedding[0] + scored: list[tuple[float, dict[str, str]]] = [] + for candidate, vector in zip(candidates, candidate_embeddings, strict=False): + if not vector: + continue + scored.append((self._cosine_similarity(query_vector, vector), candidate)) + + scored.sort(key=lambda item: item[0], reverse=True) + return [item[1] for item in scored[:top_k]] + + async def _embed_texts( + self, + *, + api_key: str, + api_base: str, + texts: list[str], + model: str, + ) -> list[list[float]]: + """调用 OpenAI-compatible embeddings 接口。 + + 当前对齐的是你们实际在用的网关配置: + - `POST {api_base}/embeddings` + - `model=text-embedding-v4` + - `encoding_format=float` + """ + + all_vectors: list[list[float]] = [] + endpoint = self._normalize_embeddings_endpoint(api_base) + for start in range(0, len(texts), 10): + batch = texts[start:start + 10] + payload = await self._post_embeddings( + endpoint=endpoint, + api_key=api_key, + model=model, + texts=batch, + ) + embeddings = payload.get("data") or [] + embeddings = sorted(embeddings, key=lambda item: item.get("index", 0)) + all_vectors.extend([list(item.get("embedding") or []) for item in embeddings]) + return all_vectors + + async def _post_embeddings( + self, + *, + endpoint: str, + api_key: str, + model: str, + texts: list[str], + ) -> dict[str, Any]: + return await asyncio.to_thread( + self._post_embeddings_sync, + endpoint=endpoint, + api_key=api_key, + model=model, + texts=texts, + ) + + def _post_embeddings_sync( + self, + *, + endpoint: str, + api_key: str, + model: str, + texts: list[str], + ) -> dict[str, Any]: + body = json.dumps( + { + "model": model, + "input": texts if len(texts) > 1 else texts[0], + "encoding_format": "float", + } + ).encode("utf-8") + req = request.Request( + endpoint, + data=body, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + method="POST", + ) + with request.urlopen(req, timeout=self.timeout_seconds) as response: + return json.loads(response.read().decode("utf-8")) + + @staticmethod + def _candidate_text(candidate: dict[str, str]) -> str: + name = (candidate.get("name") or "").strip() + description = (candidate.get("description") or "").strip() + return f"{name}\n{description}".strip() + + @staticmethod + def _normalize_embeddings_endpoint(api_base: str) -> str: + base = api_base.rstrip("/") + if base.endswith("/embeddings"): + return base + if base.endswith("/v1"): + return f"{base}/embeddings" + return f"{base}/v1/embeddings" + + @staticmethod + def _cosine_similarity(left: list[float], right: list[float]) -> float: + if not left or not right or len(left) != len(right): + return -1.0 + dot = sum(a * b for a, b in zip(left, right, strict=False)) + left_norm = math.sqrt(sum(a * a for a in left)) + right_norm = math.sqrt(sum(b * b for b in right)) + if left_norm == 0 or right_norm == 0: + return -1.0 + return dot / (left_norm * right_norm) diff --git a/app-instance/backend/beaver/skills/assembler/task_assembler.py b/app-instance/backend/beaver/skills/assembler/task_assembler.py new file mode 100644 index 0000000..876f593 --- /dev/null +++ b/app-instance/backend/beaver/skills/assembler/task_assembler.py @@ -0,0 +1,168 @@ +"""LLM-driven skill assembler. + +这层现在不再自己做规则打分,而是直接把: +1. task description +2. embedding 召回后的候选 skill 摘要 + +交给一个模型来决定本轮要激活哪些 skill。 + +当前目标非常克制: +- 输入尽量简单 +- 输出只要 skill 名称 +- 没有命中就返回空 skills +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import json +from typing import Any + +from beaver.engine.context import SkillContext +from beaver.engine.providers.base import LLMProvider +from beaver.engine.providers.runtime import ProviderRuntime +from beaver.skills.catalog.loader import SkillsLoader +from beaver.skills.catalog.utils import strip_frontmatter +from .embedding_retriever import SkillEmbeddingRetriever + + +@dataclass(slots=True) +class SkillAssemblyResult: + """一次装配后真正要注入当前 run 的 skills。""" + + activated_skills: list[SkillContext] = field(default_factory=list) + + +class SkillAssembler: + """用 LLM 根据 task description 选择当前 run 的 skills。""" + + def __init__( + self, + loader: SkillsLoader, + retriever: SkillEmbeddingRetriever | None = None, + ) -> None: + self.loader = loader + self.retriever = retriever or SkillEmbeddingRetriever() + + async def assemble( + self, + *, + task_description: str, + provider: LLMProvider, + model: str, + embedding_runtime: ProviderRuntime | None = None, + top_k: int = 12, + ) -> SkillAssemblyResult: + candidates = self.loader.build_selection_candidates() + if not candidates: + return SkillAssemblyResult() + candidates = await self.retriever.retrieve( + query=task_description, + candidates=candidates, + top_k=top_k, + api_key=embedding_runtime.api_key if embedding_runtime is not None else None, + api_base=embedding_runtime.api_base if embedding_runtime is not None else None, + model=embedding_runtime.model if embedding_runtime is not None else None, + ) + if not candidates: + return SkillAssemblyResult() + + selected_names = await self._select_skill_names( + task_description=task_description, + candidates=candidates, + provider=provider, + model=model, + ) + if not selected_names: + return SkillAssemblyResult() + + activated_skills: list[SkillContext] = [] + for name in selected_names: + raw_content = self.loader.load_skill(name) + content = strip_frontmatter(raw_content).strip() if raw_content else "" + if not content: + continue + activated_skills.append(SkillContext(name=name, content=content)) + + return SkillAssemblyResult(activated_skills=activated_skills) + + async def _select_skill_names( + self, + *, + task_description: str, + candidates: list[dict[str, str]], + provider: LLMProvider, + model: str, + ) -> list[str]: + candidate_summary = self._render_candidates(candidates) + candidate_names = {item["name"] for item in candidates} + messages = [ + { + "role": "system", + "content": ( + "You select Beaver skills for a single run. " + "Given a task description and candidate skill summaries, " + "return only a JSON array of skill names to activate. " + "Do not invent names. If nothing matches, return []." + ), + }, + { + "role": "user", + "content": ( + f"Task description:\n{task_description}\n\n" + f"Candidate skills:\n{candidate_summary}\n\n" + "Return only JSON, for example: [\"skill-a\", \"skill-b\"]" + ), + }, + ] + response = await provider.chat( + messages=messages, + tools=None, + model=model, + max_tokens=512, + temperature=0, + ) + if response.finish_reason == "error" or not response.content: + return [] + + parsed = self._parse_selected_names(response.content) + if not parsed: + return [] + + # 只保留当前候选集中真实存在的 skill 名称,并维持模型输出顺序。 + filtered: list[str] = [] + for name in parsed: + if name in candidate_names and name not in filtered: + filtered.append(name) + return filtered + + @staticmethod + def _render_candidates(candidates: list[dict[str, str]]) -> str: + lines: list[str] = [] + for item in candidates: + lines.append(f"- {item['name']}: {item['description']}") + return "\n".join(lines) + + @staticmethod + def _parse_selected_names(content: str) -> list[str]: + cleaned = content.strip() + if cleaned.startswith("```"): + lines = cleaned.splitlines() + if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].startswith("```"): + cleaned = "\n".join(lines[1:-1]).strip() + + try: + payload: Any = json.loads(cleaned) + except json.JSONDecodeError: + return [] + + if isinstance(payload, dict): + for key in ("skills", "selected_skills", "activated_skills", "selected"): + value = payload.get(key) + if isinstance(value, list): + payload = value + break + + if not isinstance(payload, list): + return [] + return [item.strip() for item in payload if isinstance(item, str) and item.strip()] diff --git a/app-instance/backend/beaver/skills/builtin/__init__.py b/app-instance/backend/beaver/skills/builtin/__init__.py new file mode 100644 index 0000000..df2d6b2 --- /dev/null +++ b/app-instance/backend/beaver/skills/builtin/__init__.py @@ -0,0 +1,2 @@ +"""Built-in skill payloads.""" + diff --git a/app-instance/backend/beaver/skills/catalog/__init__.py b/app-instance/backend/beaver/skills/catalog/__init__.py new file mode 100644 index 0000000..5655994 --- /dev/null +++ b/app-instance/backend/beaver/skills/catalog/__init__.py @@ -0,0 +1,5 @@ +"""Skill catalog and indexing.""" + +from .loader import SkillRecord, SkillsLoader + +__all__ = ["SkillRecord", "SkillsLoader"] diff --git a/app-instance/backend/beaver/skills/catalog/loader.py b/app-instance/backend/beaver/skills/catalog/loader.py new file mode 100644 index 0000000..82516d8 --- /dev/null +++ b/app-instance/backend/beaver/skills/catalog/loader.py @@ -0,0 +1,281 @@ +"""Beaver skills catalog loader。 + +第一版目标非常明确: + +1. 扫描技能目录 +2. 读取 `SKILL.md` +3. 解析前置元数据 +4. 生成可注入上下文的正文与索引 + +这层不负责: +1. 动态选择本轮应该启用哪些 skill +2. skill review / publishing +3. skill 自动学习 + +这些决策属于 resolver 或更高层工作流。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .utils import ( + check_requirements, + escape_xml, + get_missing_requirements, + parse_frontmatter, + parse_skill_metadata_blob, + strip_frontmatter, +) + + +@dataclass(slots=True) +class SkillRecord: + """单个 skill 的目录级元数据。""" + + name: str + path: Path + source: str + + +class SkillsLoader: + """从 workspace/builtin 目录中发现并读取 skills。""" + + def __init__( + self, + workspace: str | Path, + *, + builtin_skills_dir: str | Path | None = None, + extra_dirs: list[str | Path] | None = None, + ) -> None: + self.workspace = Path(workspace) + self.workspace_skills = self.workspace / "skills" + self.builtin_skills = Path(builtin_skills_dir) if builtin_skills_dir is not None else Path(__file__).resolve().parent.parent / "builtin" + self.extra_dirs = [Path(item) for item in (extra_dirs or [])] + + def list_skills(self, *, filter_unavailable: bool = True) -> list[SkillRecord]: + """列出当前可见的 skills。 + + 优先级: + 1. workspace + 2. extra/plugin 目录 + 3. builtin + + 重名 skill 只保留优先级更高的那一个。 + """ + + ordered_roots: list[tuple[str, Path]] = [ + ("workspace", self.workspace_skills), + *[("plugin", path) for path in self.extra_dirs], + ("builtin", self.builtin_skills), + ] + found: dict[str, SkillRecord] = {} + + for source, root in ordered_roots: + if not root.exists(): + continue + for skill_dir in root.iterdir(): + skill_file = skill_dir / "SKILL.md" + if not skill_dir.is_dir() or not skill_file.exists(): + continue + name = skill_dir.name + if name in found: + continue + record = SkillRecord(name=name, path=skill_file, source=source) + if filter_unavailable and not self._record_available(record): + continue + found[name] = record + return list(found.values()) + + def load_skill(self, name: str) -> str | None: + """按名称加载 skill 原始内容。""" + + record = self._find_record(name) + if record is None: + return None + return record.path.read_text(encoding="utf-8") + + def get_skill_record(self, name: str) -> SkillRecord | None: + """按名称返回 skill record。""" + + return self._find_record(name) + + def get_skill_metadata(self, name: str) -> dict[str, Any] | None: + """读取 skill frontmatter 元数据。""" + + content = self.load_skill(name) + if content is None: + return None + metadata, _ = parse_frontmatter(content) + return metadata + + def load_skills_for_context(self, skill_names: list[str]) -> str: + """加载指定 skills 的正文,并整理成上下文块。""" + + sections: list[str] = [] + for name in skill_names: + content = self.load_skill(name) + if not content: + continue + body = strip_frontmatter(content).strip() + if not body: + continue + sections.append(f"## {name}\n\n{body}") + return "\n\n".join(sections) + + def build_skills_summary(self) -> str: + """构建可注入 system prompt 的 skills index。 + + 虽然函数名还沿用 `summary`,但当前语义已经更接近 Hermes 的 skills index: + - 这里只告诉模型“系统里有哪些 skill 可用” + - 不负责把 skill 正文塞进 system prompt + - 真正激活的 skill 正文由 resolver/builder 走显式消息注入 + """ + + skills = self.list_skills(filter_unavailable=False) + if not skills: + return "" + + lines = [""] + for record in skills: + frontmatter = self.get_skill_metadata(record.name) or {} + meta_blob = parse_skill_metadata_blob(frontmatter.get("metadata", "")) + available = check_requirements(meta_blob) + description = frontmatter.get("description") or record.name + load_hint = f'Use skill_view(name="{record.name}") to load the full skill.' + lines.append(f' ') + lines.append(f" {escape_xml(record.name)}") + lines.append(f" {escape_xml(description)}") + lines.append(f" {escape_xml(load_hint)}") + support_files = self.list_skill_supporting_files(record.name) + if support_files: + lines.append(" ") + for file_path in support_files[:12]: + lines.append(f" {escape_xml(file_path)}") + if len(support_files) > 12: + lines.append(" ...additional files omitted...") + lines.append(" ") + if not available: + missing = get_missing_requirements(meta_blob) + if missing: + lines.append(f" {escape_xml(missing)}") + lines.append(" ") + lines.append("") + return "\n".join(lines) + + def build_selection_candidates(self) -> list[dict[str, str]]: + """构建给 LLM selector 使用的候选 skill 摘要。 + + 这里刻意保持精简,只给: + - `name` + - `description` + + 选择器的任务只是“从候选里挑名字”,不是直接阅读完整 skill 正文。 + 真正激活后的 skill 正文仍然在后续阶段按需加载。 + """ + + candidates: list[dict[str, str]] = [] + for record in self.list_skills(filter_unavailable=True): + frontmatter = self.get_skill_metadata(record.name) or {} + description = str(frontmatter.get("description") or "").strip() + if not description: + raw_content = self.load_skill(record.name) or "" + body = strip_frontmatter(raw_content).strip() + if body: + description = " ".join(body.splitlines()[:3])[:240].strip() + candidates.append( + { + "name": record.name, + "description": description or record.name, + } + ) + return candidates + + def list_skill_supporting_files(self, name: str) -> list[str]: + """列出 skill 目录下可按需查看的支持文件相对路径。""" + + record = self._find_record(name) + if record is None: + return [] + skill_dir = record.path.parent + results: list[str] = [] + for subdir in ("references", "templates", "scripts", "assets"): + root = skill_dir / subdir + if not root.exists(): + continue + for file in sorted(root.rglob("*")): + if file.is_file() and not file.is_symlink(): + results.append(str(file.relative_to(skill_dir))) + return results + + def view_skill(self, name: str, file_path: str | None = None) -> tuple[str, str] | None: + """读取 skill 正文或其支持文件。 + + 返回 `(display_name, content)`: + - `display_name` 用于提示当前读取的是 skill 本体还是某个支持文件 + - `content` 为实际文本内容 + """ + + record = self._find_record(name) + if record is None: + return None + if not self._record_available(record): + frontmatter = self.get_skill_metadata(name) or {} + meta_blob = parse_skill_metadata_blob(frontmatter.get("metadata", "")) + missing = get_missing_requirements(meta_blob) + detail = f" Missing requirements: {missing}." if missing else "" + raise ValueError(f"Skill '{name}' is currently unavailable.{detail}") + + skill_dir = record.path.parent + if not file_path: + return ("SKILL.md", self._read_text_file(record.path, display_name="SKILL.md")) + + candidate = (skill_dir / file_path).resolve() + try: + candidate.relative_to(skill_dir.resolve()) + except ValueError as exc: + raise ValueError("Requested skill file must stay within the skill directory") from exc + if not candidate.exists() or not candidate.is_file(): + raise FileNotFoundError(f"Skill file '{file_path}' does not exist") + display_name = str(candidate.relative_to(skill_dir)) + return (display_name, self._read_text_file(candidate, display_name=display_name)) + + def get_always_skills(self) -> list[str]: + """返回标记为 always 的可用 skill 名称。""" + + result: list[str] = [] + for record in self.list_skills(filter_unavailable=True): + frontmatter = self.get_skill_metadata(record.name) or {} + meta_blob = parse_skill_metadata_blob(frontmatter.get("metadata", "")) + if meta_blob.get("always") or str(frontmatter.get("always", "")).lower() == "true": + result.append(record.name) + return result + + def _find_record(self, name: str) -> SkillRecord | None: + for record in self.list_skills(filter_unavailable=False): + if record.name == name: + return record + return None + + def _record_available(self, record: SkillRecord) -> bool: + content = record.path.read_text(encoding="utf-8") + frontmatter, _ = parse_frontmatter(content) + meta_blob = parse_skill_metadata_blob(frontmatter.get("metadata", "")) + return check_requirements(meta_blob) + + @staticmethod + def _read_text_file(path: Path, *, display_name: str) -> str: + try: + return path.read_text(encoding="utf-8") + except UnicodeDecodeError as exc: + raise ValueError( + f"Skill file '{display_name}' is not UTF-8 text and cannot be viewed with skill_view." + ) from exc + + def _skill_available(self, name: str) -> bool: + record = self._find_record(name) + if record is None: + return False + return self._record_available(record) diff --git a/app-instance/backend/beaver/skills/catalog/utils.py b/app-instance/backend/beaver/skills/catalog/utils.py new file mode 100644 index 0000000..14e8fcc --- /dev/null +++ b/app-instance/backend/beaver/skills/catalog/utils.py @@ -0,0 +1,122 @@ +"""Skills catalog 的公共辅助函数。 + +这里专门放“解析和校验 skill 文件”的纯函数,避免 `loader.py` 里同时承担: + +1. 目录扫描 +2. frontmatter 解析 +3. requirements 校验 +4. 文本裁剪/格式化 + +把这些细节拆出来之后,skills catalog 的边界会更清楚,后面无论是 reviews、publisher +还是 runtime resolver,都可以复用同一套元数据解析规则。 +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +from typing import Any + + +def parse_frontmatter(content: str) -> tuple[dict[str, str], str]: + """解析 Markdown 文件顶部的极简 frontmatter。 + + 当前先只支持最常见的: + + ```md + --- + key: value + key2: value2 + --- + body... + ``` + + 这样足够支撑第一版 skills runtime,不提前把 YAML 解析器引进来。 + """ + + if not content.startswith("---"): + return {}, content + + match = re.match(r"^---\n(.*?)\n---\n?", content, re.DOTALL) + if match is None: + return {}, content + + metadata: dict[str, str] = {} + for line in match.group(1).splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + metadata[key.strip()] = value.strip().strip('"\'') + body = content[match.end():].strip() + return metadata, body + + +def strip_frontmatter(content: str) -> str: + """去掉 frontmatter,只保留 skill 正文。""" + + _, body = parse_frontmatter(content) + return body + + +def parse_skill_metadata_blob(raw: str) -> dict[str, Any]: + """解析 metadata 字段里的 JSON 扩展配置。 + + 为了兼容旧 nanobot 习惯,这里同时支持: + - `nanobot` + - `openclaw` + + 第一版主要关心的字段有: + - `always` + - `requires` + """ + + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError): + return {} + + if not isinstance(data, dict): + return {} + nested = data.get("nanobot", data.get("openclaw", data)) + return nested if isinstance(nested, dict) else {} + + +def check_requirements(metadata: dict[str, Any]) -> bool: + """检查 skill 的最小 requirements 是否满足。""" + + requires = metadata.get("requires", {}) + if not isinstance(requires, dict): + return True + + for binary in requires.get("bins", []): + if not shutil.which(str(binary)): + return False + for env_name in requires.get("env", []): + if not os.environ.get(str(env_name)): + return False + return True + + +def get_missing_requirements(metadata: dict[str, Any]) -> str: + """返回缺失 requirements 的简短描述。""" + + requires = metadata.get("requires", {}) + if not isinstance(requires, dict): + return "" + + missing: list[str] = [] + for binary in requires.get("bins", []): + if not shutil.which(str(binary)): + missing.append(f"CLI: {binary}") + for env_name in requires.get("env", []): + if not os.environ.get(str(env_name)): + missing.append(f"ENV: {env_name}") + return ", ".join(missing) + + +def escape_xml(value: str) -> str: + """给 skills summary 做最小 XML 转义。""" + + return value.replace("&", "&").replace("<", "<").replace(">", ">") diff --git a/app-instance/backend/beaver/skills/drafts/__init__.py b/app-instance/backend/beaver/skills/drafts/__init__.py new file mode 100644 index 0000000..699c6a4 --- /dev/null +++ b/app-instance/backend/beaver/skills/drafts/__init__.py @@ -0,0 +1,2 @@ +"""Draft skills generated before review.""" + diff --git a/app-instance/backend/beaver/skills/publisher/__init__.py b/app-instance/backend/beaver/skills/publisher/__init__.py new file mode 100644 index 0000000..ff00858 --- /dev/null +++ b/app-instance/backend/beaver/skills/publisher/__init__.py @@ -0,0 +1,2 @@ +"""Skill publishing and version switching.""" + diff --git a/app-instance/backend/beaver/skills/resolver/__init__.py b/app-instance/backend/beaver/skills/resolver/__init__.py new file mode 100644 index 0000000..6b3a711 --- /dev/null +++ b/app-instance/backend/beaver/skills/resolver/__init__.py @@ -0,0 +1,5 @@ +"""Runtime skill resolution.""" + +from .runtime import ResolvedSkillSet, RuntimeSkillResolver + +__all__ = ["ResolvedSkillSet", "RuntimeSkillResolver"] diff --git a/app-instance/backend/beaver/skills/resolver/runtime.py b/app-instance/backend/beaver/skills/resolver/runtime.py new file mode 100644 index 0000000..c2bdfb4 --- /dev/null +++ b/app-instance/backend/beaver/skills/resolver/runtime.py @@ -0,0 +1,50 @@ +"""Runtime skill resolver。 + +这层负责回答一个运行时问题: +“这一次调用,哪些 skill 要被激活,并以什么形式注入上下文?” + +第一版保持保守,只综合三类来源: +1. `always` skills + +不在这里做复杂的语义匹配或自动推荐。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from beaver.engine.context import SkillContext +from beaver.skills.catalog.loader import SkillsLoader +from beaver.skills.catalog.utils import strip_frontmatter + + +@dataclass(slots=True) +class ResolvedSkillSet: + """一次运行最终解析出的 skills 结果。""" + + activated_skills: list[SkillContext] = field(default_factory=list) + + +class RuntimeSkillResolver: + """把 profile/request 转成当前轮次真正激活的 skill 集合。""" + + def __init__(self, loader: SkillsLoader) -> None: + self.loader = loader + + def resolve( + self, + ) -> ResolvedSkillSet: + selected: list[str] = [] + for name in self.loader.get_always_skills(): + if name not in selected: + selected.append(name) + + activated_skills: list[SkillContext] = [] + for name in selected: + raw_content = self.loader.load_skill(name) + content = strip_frontmatter(raw_content).strip() if raw_content else "" + if not content: + continue + activated_skills.append(SkillContext(name=name, content=content)) + + return ResolvedSkillSet(activated_skills=activated_skills) diff --git a/app-instance/backend/beaver/skills/reviews/__init__.py b/app-instance/backend/beaver/skills/reviews/__init__.py new file mode 100644 index 0000000..fed947c --- /dev/null +++ b/app-instance/backend/beaver/skills/reviews/__init__.py @@ -0,0 +1,2 @@ +"""Skill review workflow.""" + diff --git a/app-instance/backend/beaver/templates/__init__.py b/app-instance/backend/beaver/templates/__init__.py new file mode 100644 index 0000000..67c1e36 --- /dev/null +++ b/app-instance/backend/beaver/templates/__init__.py @@ -0,0 +1,2 @@ +"""Built-in Beaver templates.""" + diff --git a/app-instance/backend/beaver/tools/__init__.py b/app-instance/backend/beaver/tools/__init__.py new file mode 100644 index 0000000..df94b79 --- /dev/null +++ b/app-instance/backend/beaver/tools/__init__.py @@ -0,0 +1,15 @@ +"""Tool system for Beaver.""" + +from .base import BaseTool, ObjectBackedTool, ToolContext, ToolResult, ToolSpec +from .registry import ToolRegistry +from .runtime import ToolExecutor + +__all__ = [ + "BaseTool", + "ObjectBackedTool", + "ToolContext", + "ToolExecutor", + "ToolRegistry", + "ToolResult", + "ToolSpec", +] diff --git a/app-instance/backend/beaver/tools/base.py b/app-instance/backend/beaver/tools/base.py new file mode 100644 index 0000000..a19c990 --- /dev/null +++ b/app-instance/backend/beaver/tools/base.py @@ -0,0 +1,175 @@ +"""Beaver 工具系统的统一契约。 + +这一层的目标不是实现具体工具,而是把 runtime 真正依赖的最小接口定死。 + +我们需要统一回答 4 个问题: +1. 一个工具长什么样 +2. tool schema 怎么导出给 provider +3. 工具执行结果长什么样 +4. tool loop 执行时,可以把哪些运行时依赖传给工具 + +这层故意保持很薄: +- 不绑定 MCP +- 不绑定 memory/session +- 不绑定具体 provider + +这样内建工具、MCP 工具、未来插件工具都可以收敛到同一套契约上。 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +import json +from typing import Any + + +@dataclass(slots=True) +class ToolSpec: + """单个工具对外暴露的描述信息。 + + 这份信息主要服务两个场景: + 1. 导出给 provider 的 function schema + 2. 在 registry 中做列出、查找、调试 + """ + + name: str + description: str + input_schema: dict[str, Any] + + def to_provider_schema(self) -> dict[str, Any]: + """导出为 OpenAI-compatible function tool schema。""" + + return { + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": self.input_schema, + }, + } + + +@dataclass(slots=True) +class ToolContext: + """一次工具执行时可用的运行时上下文。 + + 这不是“所有系统对象的大杂烩”,而是当前工具执行阶段最常用的公共入口。 + 后面主链接进来时,可以把 session manager / memory store / workspace 等从这里传入。 + """ + + workspace: str | None = None + session_id: str | None = None + user_id: str | None = None + services: dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + + def get(self, key: str, default: Any = None) -> Any: + """优先从 services 中取依赖,方便工具侧少写样板代码。""" + + return self.services.get(key, default) + + +@dataclass(slots=True) +class ToolResult: + """标准化工具执行结果。 + + 统一返回结构的意义是: + 1. tool loop 更容易记录日志和失败信息 + 2. provider 回灌时可以稳定地拿到字符串内容 + 3. 后面要做工具审计时,数据结构已经固定 + """ + + success: bool + content: str + tool_name: str + error: str | None = None + raw_output: Any | None = None + + +class BaseTool(ABC): + """所有工具实现都应遵守的抽象基类。""" + + @property + @abstractmethod + def spec(self) -> ToolSpec: + """返回工具元数据。""" + + @abstractmethod + async def invoke(self, arguments: dict[str, Any], context: ToolContext) -> ToolResult: + """执行工具调用。""" + + +class ObjectBackedTool(BaseTool): + """把现有“轻量对象工具”适配到统一 BaseTool 契约。 + + 目前 `MemoryTool` / `SessionSearchTool` 已经存在,但它们还不是统一的 BaseTool。 + 这个适配器的作用就是避免重写业务逻辑,只做接口收口。 + """ + + def __init__(self, backend: Any) -> None: + self.backend = backend + self._spec = ToolSpec( + name=str(getattr(backend, "name")), + description=str(getattr(backend, "description", "")), + input_schema=dict(getattr(backend, "parameters", {"type": "object", "properties": {}})), + ) + + @property + def spec(self) -> ToolSpec: + return self._spec + + async def invoke(self, arguments: dict[str, Any], context: ToolContext) -> ToolResult: + try: + call_arguments = dict(arguments) + self._inject_runtime_context(call_arguments, context) + content = await self.backend.execute(**call_arguments) + result = self._normalize_output(content) + return ToolResult( + success=result["success"], + content=result["content"], + tool_name=self.spec.name, + error=result.get("error"), + raw_output=content, + ) + except Exception as exc: + return ToolResult( + success=False, + content=f"Tool {self.spec.name} failed: {exc}", + tool_name=self.spec.name, + error=str(exc), + ) + + def _inject_runtime_context(self, arguments: dict[str, Any], context: ToolContext) -> None: + """把少量 runtime 上下文注入到后端工具参数中。 + + 当前只做最小注入: + - 只有当 backend 明确暴露对应字段时才注入 + - 避免把 ToolContext 整个对象直接塞给现有 builtin 工具 + """ + + if "current_session_id" not in arguments and hasattr(self.backend, "current_session_id"): + arguments["current_session_id"] = context.session_id + + @staticmethod + def _normalize_output(content: Any) -> dict[str, Any]: + """把后端工具返回值转成统一 success/content/error 语义。 + + 对现有 builtin 工具最关键的是: + - 若返回的是 JSON 字符串,且包含 `success` 字段,就尊重它 + - 否则默认视为普通成功文本 + """ + + if isinstance(content, str): + try: + parsed = json.loads(content) + except json.JSONDecodeError: + return {"success": True, "content": content} + if isinstance(parsed, dict) and "success" in parsed: + return { + "success": bool(parsed.get("success")), + "content": content, + "error": parsed.get("error"), + } + return {"success": True, "content": content} + return {"success": True, "content": str(content)} diff --git a/app-instance/backend/beaver/tools/builtins/__init__.py b/app-instance/backend/beaver/tools/builtins/__init__.py new file mode 100644 index 0000000..4bfe1b1 --- /dev/null +++ b/app-instance/backend/beaver/tools/builtins/__init__.py @@ -0,0 +1,17 @@ +"""Built-in Beaver tools.""" + +from .echo import EchoTool, echo_tool +from .memory import MemoryTool, memory_tool +from .skill_view import SkillViewTool, skill_view +from .session_search import SessionSearchTool, session_search + +__all__ = [ + "EchoTool", + "MemoryTool", + "SkillViewTool", + "SessionSearchTool", + "echo_tool", + "memory_tool", + "skill_view", + "session_search", +] diff --git a/app-instance/backend/beaver/tools/builtins/echo.py b/app-instance/backend/beaver/tools/builtins/echo.py new file mode 100644 index 0000000..bea1358 --- /dev/null +++ b/app-instance/backend/beaver/tools/builtins/echo.py @@ -0,0 +1,43 @@ +"""最小调试工具:把输入原样回显。 + +它的价值不是业务能力,而是运行时验证: +当你只想确认 tool loop 是否能走通时,`echo` 是最便宜、最确定的测试工具。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +ECHO_TOOL_DESCRIPTION = "Echo the provided text back to the agent. Useful for verifying tool calling." + +ECHO_TOOL_PARAMETERS: dict[str, Any] = { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The text to echo back.", + } + }, + "required": ["text"], +} + + +def echo_tool(*, text: str) -> str: + return text + + +@dataclass(slots=True) +class EchoTool: + """面向 runtime 的最小内建工具。""" + + name: str = "echo" + description: str = ECHO_TOOL_DESCRIPTION + parameters: dict[str, Any] = field(default_factory=lambda: dict(ECHO_TOOL_PARAMETERS)) + + async def execute(self, **kwargs: Any) -> str: + text = kwargs.get("text") + if not isinstance(text, str): + raise ValueError("echo tool requires a string field 'text'") + return echo_tool(text=text) diff --git a/app-instance/backend/beaver/tools/builtins/memory.py b/app-instance/backend/beaver/tools/builtins/memory.py new file mode 100644 index 0000000..f510070 --- /dev/null +++ b/app-instance/backend/beaver/tools/builtins/memory.py @@ -0,0 +1,129 @@ +"""Beaver 内置 memory tool。 + +这个文件的职责很单纯:把 `MemoryStore` 暴露成一个 agent runtime 可以调用的统一工具。 + +设计边界: +1. `store.py` 负责底层数据与并发安全 +2. 本文件负责工具接口、参数校验分发、JSON 响应 +3. 更高层的 engine / loader 之后再决定如何把这个工具注册进 runtime + +换句话说,本文件是“memory 能力的工具化外壳”,不是记忆实现本身。 +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any + +from beaver.memory.curated.store import MemoryStore + +MEMORY_TOOL_DESCRIPTION = ( + "Save durable information to persistent memory that survives across sessions. " + "Use this proactively for user corrections, preferences, environment facts, " + "project conventions, and stable tool quirks. Do not store temporary task " + "progress or raw session logs here; use session search for historical detail." +) + +MEMORY_TOOL_PARAMETERS: dict[str, Any] = { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["add", "replace", "remove"], + "description": "The memory operation to perform.", + }, + "target": { + "type": "string", + "enum": ["memory", "user"], + "description": "Which curated store to update.", + }, + "content": { + "type": "string", + "description": "The new entry content. Required for add and replace.", + }, + "old_text": { + "type": "string", + "description": "A short unique substring identifying the entry to replace or remove.", + }, + }, + "required": ["action", "target"], +} + + +def memory_tool( + *, + action: str, + target: str = "memory", + content: str | None = None, + old_text: str | None = None, + store: MemoryStore | None = None, +) -> str: + """分发 Hermes 风格的 CRUD memory API,并返回 JSON 字符串。 + + 这里统一采用 JSON 返回,是为了兼容常见 tool-calling 场景: + - LLM 更容易消费结构化结果 + - Web/API/日志层也更容易透传和记录 + """ + + if store is None: + return json.dumps( + { + "success": False, + "error": "Memory store is not available for this runtime.", + }, + ensure_ascii=False, + ) + + if target not in {"memory", "user"}: + return json.dumps( + { + "success": False, + "error": f"Invalid target '{target}'. Use 'memory' or 'user'.", + }, + ensure_ascii=False, + ) + + if action == "add": + if not content: + result = {"success": False, "error": "content is required for add."} + else: + result = store.add(target, content) + elif action == "replace": + if not old_text: + result = {"success": False, "error": "old_text is required for replace."} + elif not content: + result = {"success": False, "error": "content is required for replace."} + else: + result = store.replace(target, old_text, content) + elif action == "remove": + if not old_text: + result = {"success": False, "error": "old_text is required for remove."} + else: + result = store.remove(target, old_text) + else: + result = { + "success": False, + "error": f"Unknown action '{action}'. Use add, replace, or remove.", + } + + return json.dumps(result, ensure_ascii=False) + + +@dataclass(slots=True) +class MemoryTool: + """面向 runtime 的轻量工具封装。 + + 这里故意保持很薄: + 1. 不重复实现业务逻辑 + 2. 不重复维护 schema + 3. 只做 `execute()` 到 `memory_tool()` 的桥接 + """ + + store: MemoryStore + name: str = "memory" + description: str = MEMORY_TOOL_DESCRIPTION + parameters: dict[str, Any] = field(default_factory=lambda: dict(MEMORY_TOOL_PARAMETERS)) + + async def execute(self, **kwargs: Any) -> str: + return memory_tool(store=self.store, **kwargs) diff --git a/app-instance/backend/beaver/tools/builtins/session_search.py b/app-instance/backend/beaver/tools/builtins/session_search.py new file mode 100644 index 0000000..7fec9cb --- /dev/null +++ b/app-instance/backend/beaver/tools/builtins/session_search.py @@ -0,0 +1,418 @@ +"""Beaver 内置 session_search tool。 + +这个工具对应 Hermes-agent 的跨会话检索能力,目标不是把所有历史内容塞回主上下文, +而是按需从过去的 session 中找回“之前发生过什么”。 + +当前实现保留了几个关键行为: +1. query 为空时进入 recent/browse 模式,只列最近会话,不走 LLM,总成本很低 +2. query 不为空时走 transcript DB 的搜索接口,预期底层是 FTS 风格检索 +3. 自动排除当前 session lineage,避免把当前上下文又搜出来一遍 +4. 对长会话做 match-centered truncation,而不是无脑截前 N 字符 +5. summarizer 是可选依赖;没有时降级返回 raw preview,而不是整条工具失败 +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Awaitable, Callable, Protocol + +MAX_SESSION_CHARS = 100_000 + + +class SessionSearchDB(Protocol): + """session_search 依赖的最小数据库契约。 + + 这里没有直接绑定某个具体 SQLite 实现,而是先定义行为接口。 + 这样后面无论你接的是 Hermes 风格 state DB、还是 Beaver 自己的 transcript store, + 只要满足这些方法就能工作。 + """ + + def list_sessions_rich( + self, + *, + limit: int, + exclude_sources: list[str] | None = None, + ) -> list[dict[str, Any]]: ... + + def get_session(self, session_id: str) -> dict[str, Any] | None: ... + + def get_messages_as_conversation(self, session_id: str) -> list[dict[str, Any]]: ... + + def search_messages( + self, + *, + query: str, + role_filter: list[str] | None = None, + exclude_sources: list[str] | None = None, + limit: int, + offset: int = 0, + ) -> list[dict[str, Any]]: ... + + +SessionSummarizer = Callable[[str, str, dict[str, Any]], Awaitable[str | None]] + +_HIDDEN_SESSION_SOURCES = ("tool",) + +SESSION_SEARCH_TOOL_DESCRIPTION = ( + "Search prior sessions for historical context, or browse recent sessions when " + "query is omitted. Use this when the user references past work, prior fixes, " + "or earlier decisions instead of asking them to repeat themselves." +) + +SESSION_SEARCH_TOOL_PARAMETERS: dict[str, Any] = { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Keyword, phrase, or boolean FTS query. Omit to browse recent sessions.", + }, + "role_filter": { + "type": "string", + "description": "Optional comma-separated roles to search, for example 'user,assistant'.", + }, + "limit": { + "type": "integer", + "default": 3, + "minimum": 1, + "maximum": 5, + "description": "Maximum number of sessions to return.", + }, + }, + "required": [], +} + + +def _format_timestamp(value: int | float | str | None) -> str: + """把时间戳或字符串格式化成更可读的展示文本。""" + if value is None: + return "unknown" + try: + if isinstance(value, (int, float)): + return datetime.fromtimestamp(value).strftime("%B %d, %Y at %I:%M %p") + if isinstance(value, str): + if value.replace(".", "").replace("-", "").isdigit(): + return datetime.fromtimestamp(float(value)).strftime("%B %d, %Y at %I:%M %p") + return value + except (OSError, OverflowError, ValueError): + pass + return str(value) + + +def _format_conversation(messages: list[dict[str, Any]]) -> str: + """把消息列表整理成适合摘要模型消费的 transcript 文本。 + + 这里会保留: + - role + - assistant 的 tool calls 名称 + - tool 输出的简短内容 + + 但不会原样塞入超长工具输出,否则摘要成本会被单个工具结果拉爆。 + """ + parts: list[str] = [] + for message in messages: + role = str(message.get("role", "unknown")).upper() + content = message.get("content") or "" + tool_name = message.get("tool_name") + + if role == "TOOL" and tool_name: + if len(content) > 500: + content = content[:250] + "\n...[truncated]...\n" + content[-250:] + parts.append(f"[TOOL:{tool_name}]: {content}") + continue + + if role == "ASSISTANT": + tool_calls = message.get("tool_calls") + if isinstance(tool_calls, list) and tool_calls: + names: list[str] = [] + for tool_call in tool_calls: + if isinstance(tool_call, dict): + names.append( + tool_call.get("name") + or tool_call.get("function", {}).get("name", "?") + ) + if names: + parts.append(f"[ASSISTANT]: [Called: {', '.join(names)}]") + parts.append(f"[ASSISTANT]: {content}") + continue + + parts.append(f"[{role}]: {content}") + + return "\n\n".join(parts) + + +def _truncate_around_matches(full_text: str, query: str, *, max_chars: int = MAX_SESSION_CHARS) -> str: + """围绕匹配位置截取上下文,而不是固定截头。 + + 优先级: + 1. 先找整句 query + 2. 找不到再找多词近邻共现 + 3. 再退化到逐词匹配 + + 这样做的目的,是尽量把与 query 最相关的对话片段保留下来,提高 summarizer 的命中率。 + """ + if len(full_text) <= max_chars: + return full_text + + text_lower = full_text.lower() + query_lower = query.lower().strip() + match_positions = [match.start() for match in re.finditer(re.escape(query_lower), text_lower)] + + if not match_positions: + terms = query_lower.split() + if len(terms) > 1: + positions: dict[str, list[int]] = { + term: [match.start() for match in re.finditer(re.escape(term), text_lower)] + for term in terms + } + rarest = min(terms, key=lambda term: len(positions.get(term, []))) + for position in positions.get(rarest, []): + if all( + any(abs(candidate - position) < 200 for candidate in positions.get(term, [])) + for term in terms + if term != rarest + ): + match_positions.append(position) + + if not match_positions: + for term in query_lower.split(): + match_positions.extend(match.start() for match in re.finditer(re.escape(term), text_lower)) + + if not match_positions: + head = full_text[:max_chars] + suffix = "\n\n...[later conversation truncated]..." if max_chars < len(full_text) else "" + return head + suffix + + best_start = 0 + best_count = 0 + for candidate in sorted(match_positions): + window_start = max(0, candidate - max_chars // 4) + window_end = window_start + max_chars + if window_end > len(full_text): + window_start = max(0, len(full_text) - max_chars) + window_end = len(full_text) + count = sum(1 for position in match_positions if window_start <= position < window_end) + if count > best_count: + best_count = count + best_start = window_start + + start = best_start + end = min(len(full_text), start + max_chars) + prefix = "...[earlier conversation truncated]...\n\n" if start > 0 else "" + suffix = "\n\n...[later conversation truncated]..." if end < len(full_text) else "" + return prefix + full_text[start:end] + suffix + + +def _resolve_to_parent(db: SessionSearchDB, session_id: str | None) -> str | None: + """沿 parent_session_id 向上追溯到 lineage root。 + + 这样可以把 delegation/compression 形成的子 session 归并回同一条主会话链, + 避免检索结果里出现多个其实属于同一轮上下文的碎片 session。 + """ + visited: set[str] = set() + current = session_id + while current and current not in visited: + visited.add(current) + session = db.get_session(current) + if not session: + break + parent = session.get("parent_session_id") + if not parent: + break + current = parent + return current + + +def _list_recent_sessions( + db: SessionSearchDB, + *, + limit: int, + current_session_id: str | None = None, +) -> str: + """recent mode:仅列出最近 session 的元数据,不做摘要调用。""" + sessions = db.list_sessions_rich( + limit=limit + 5, + exclude_sources=list(_HIDDEN_SESSION_SOURCES), + ) + current_root = _resolve_to_parent(db, current_session_id) if current_session_id else None + results: list[dict[str, Any]] = [] + for session in sessions: + session_id = session.get("id", "") + if current_root and session_id == current_root: + continue + if current_session_id and session_id == current_session_id: + continue + if session.get("parent_session_id"): + continue + results.append( + { + "session_id": session_id, + "title": session.get("title") or None, + "source": session.get("source", ""), + "started_at": session.get("started_at", ""), + "last_active": session.get("last_active", ""), + "message_count": session.get("message_count", 0), + "preview": session.get("preview", ""), + } + ) + if len(results) >= limit: + break + + return json.dumps( + { + "success": True, + "mode": "recent", + "results": results, + "count": len(results), + "message": f"Showing {len(results)} most recent sessions.", + }, + ensure_ascii=False, + ) + + +async def session_search( + *, + query: str = "", + role_filter: str | None = None, + limit: int = 3, + db: SessionSearchDB | None = None, + current_session_id: str | None = None, + summarizer: SessionSummarizer | None = None, +) -> str: + """搜索过去的会话并返回结构化 JSON 结果。 + + 运行流程: + 1. 空 query -> recent mode + 2. 有 query -> 调 transcript DB 搜索 + 3. 去掉当前会话链 + 4. 拉取命中的 session transcript + 5. 对 transcript 做 match-centered truncation + 6. 如果提供 summarizer,就并发摘要;否则回退 raw preview + """ + + if db is None: + return json.dumps({"success": False, "error": "Session database is not available."}, ensure_ascii=False) + + limit = max(1, min(limit, 5)) + if not query or not query.strip(): + return _list_recent_sessions(db, limit=limit, current_session_id=current_session_id) + + role_list = [item.strip() for item in (role_filter or "").split(",") if item.strip()] or None + try: + raw_results = db.search_messages( + query=query.strip(), + role_filter=role_list, + exclude_sources=list(_HIDDEN_SESSION_SOURCES), + limit=50, + offset=0, + ) + except Exception as exc: + logging.error("Session search failed during FTS lookup: %s", exc, exc_info=True) + return json.dumps({"success": False, "error": f"Search failed: {exc}"}, ensure_ascii=False) + + if not raw_results: + return json.dumps( + { + "success": True, + "query": query.strip(), + "results": [], + "count": 0, + "message": "No matching sessions found.", + }, + ensure_ascii=False, + ) + + current_root = _resolve_to_parent(db, current_session_id) if current_session_id else None + seen_sessions: dict[str, dict[str, Any]] = {} + for result in raw_results: + raw_session_id = result["session_id"] + resolved_session_id = _resolve_to_parent(db, raw_session_id) or raw_session_id + if current_root and resolved_session_id == current_root: + continue + if current_session_id and raw_session_id == current_session_id: + continue + if resolved_session_id not in seen_sessions: + entry = dict(result) + entry["session_id"] = resolved_session_id + seen_sessions[resolved_session_id] = entry + if len(seen_sessions) >= limit: + break + + prepared: list[tuple[str, dict[str, Any], str, dict[str, Any]]] = [] + for session_id, match_info in seen_sessions.items(): + try: + messages = db.get_messages_as_conversation(session_id) + if not messages: + continue + session_meta = db.get_session(session_id) or {} + transcript = _truncate_around_matches(_format_conversation(messages), query.strip()) + prepared.append((session_id, match_info, transcript, session_meta)) + except Exception as exc: + logging.warning("Failed to prepare session %s: %s", session_id, exc, exc_info=True) + + if summarizer is not None: + summaries = await asyncio.gather( + *(summarizer(transcript, query.strip(), session_meta) for _, _, transcript, session_meta in prepared), + return_exceptions=True, + ) + else: + summaries = [None] * len(prepared) + + results: list[dict[str, Any]] = [] + for (session_id, match_info, transcript, _), summary in zip(prepared, summaries): + resolved_summary: str | None + if isinstance(summary, Exception): + logging.warning("Failed to summarize session %s: %s", session_id, summary, exc_info=True) + resolved_summary = None + else: + resolved_summary = summary + + if not resolved_summary: + preview = transcript[:500] + ("\n…[truncated]" if len(transcript) > 500 else "") + resolved_summary = f"[Raw preview — summarization unavailable]\n{preview}" + + results.append( + { + "session_id": session_id, + "when": _format_timestamp(match_info.get("session_started")), + "source": match_info.get("source", "unknown"), + "model": match_info.get("model"), + "summary": resolved_summary, + } + ) + + return json.dumps( + { + "success": True, + "query": query.strip(), + "results": results, + "count": len(results), + "sessions_searched": len(seen_sessions), + }, + ensure_ascii=False, + ) + + +@dataclass(slots=True) +class SessionSearchTool: + """面向 runtime 的轻量 session_search 工具封装。""" + + db: SessionSearchDB + current_session_id: str | None = None + summarizer: SessionSummarizer | None = None + name: str = "session_search" + description: str = SESSION_SEARCH_TOOL_DESCRIPTION + parameters: dict[str, Any] = field(default_factory=lambda: dict(SESSION_SEARCH_TOOL_PARAMETERS)) + + async def execute(self, **kwargs: Any) -> str: + current_session_id = kwargs.pop("current_session_id", None) + return await session_search( + db=self.db, + current_session_id=current_session_id if current_session_id is not None else self.current_session_id, + summarizer=self.summarizer, + **kwargs, + ) diff --git a/app-instance/backend/beaver/tools/builtins/skill_view.py b/app-instance/backend/beaver/tools/builtins/skill_view.py new file mode 100644 index 0000000..cc650eb --- /dev/null +++ b/app-instance/backend/beaver/tools/builtins/skill_view.py @@ -0,0 +1,82 @@ +"""Beaver 内置 skill_view tool。 + +这个工具对应 Hermes 风格的显式 skill loading path: +1. skill 正文默认不会长期塞进 system prompt +2. 模型若想查看某个 skill 的完整正文或支持文件,必须显式调用 `skill_view` + +这样 skill 的按需展开路径会保持显式,而不是依赖 prompt 里长期堆目录信息。 +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any + +from beaver.skills.catalog.loader import SkillsLoader + +SKILL_VIEW_TOOL_DESCRIPTION = ( + "Load the full content of a skill or one of its supporting files. " + "Use this when you want to inspect a skill in detail." +) + +SKILL_VIEW_TOOL_PARAMETERS: dict[str, Any] = { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The skill name to inspect.", + }, + "file_path": { + "type": "string", + "description": ( + "Optional relative path to a supporting file inside the skill directory, " + "for example 'references/usage.md'. Omit to load SKILL.md itself." + ), + }, + }, + "required": ["name"], +} + + +def skill_view(*, name: str, file_path: str | None = None, loader: SkillsLoader | None = None) -> str: + """读取 skill 正文或支持文件,并返回结构化 JSON。""" + + if loader is None: + return json.dumps({"success": False, "error": "Skills loader is not available."}, ensure_ascii=False) + + try: + viewed = loader.view_skill(name, file_path=file_path) + except FileNotFoundError as exc: + return json.dumps({"success": False, "error": str(exc)}, ensure_ascii=False) + except ValueError as exc: + return json.dumps({"success": False, "error": str(exc)}, ensure_ascii=False) + + if viewed is None: + return json.dumps({"success": False, "error": f"Unknown skill '{name}'."}, ensure_ascii=False) + + display_name, content = viewed + support_files = loader.list_skill_supporting_files(name) + return json.dumps( + { + "success": True, + "name": name, + "file": display_name, + "content": content, + "supporting_files": support_files, + }, + ensure_ascii=False, + ) + + +@dataclass(slots=True) +class SkillViewTool: + """面向 runtime 的 skill_view 工具封装。""" + + loader: SkillsLoader + name: str = "skill_view" + description: str = SKILL_VIEW_TOOL_DESCRIPTION + parameters: dict[str, Any] = field(default_factory=lambda: dict(SKILL_VIEW_TOOL_PARAMETERS)) + + async def execute(self, **kwargs: Any) -> str: + return skill_view(loader=self.loader, **kwargs) diff --git a/app-instance/backend/beaver/tools/mcp/__init__.py b/app-instance/backend/beaver/tools/mcp/__init__.py new file mode 100644 index 0000000..0fd7c34 --- /dev/null +++ b/app-instance/backend/beaver/tools/mcp/__init__.py @@ -0,0 +1,2 @@ +"""MCP-backed tool integrations.""" + diff --git a/app-instance/backend/beaver/tools/policies/__init__.py b/app-instance/backend/beaver/tools/policies/__init__.py new file mode 100644 index 0000000..57d0c18 --- /dev/null +++ b/app-instance/backend/beaver/tools/policies/__init__.py @@ -0,0 +1,2 @@ +"""Tool policy guards.""" + diff --git a/app-instance/backend/beaver/tools/registry/__init__.py b/app-instance/backend/beaver/tools/registry/__init__.py new file mode 100644 index 0000000..c860354 --- /dev/null +++ b/app-instance/backend/beaver/tools/registry/__init__.py @@ -0,0 +1,5 @@ +"""Tool registration and discovery.""" + +from .tool_registry import ToolRegistry + +__all__ = ["ToolRegistry"] diff --git a/app-instance/backend/beaver/tools/registry/tool_registry.py b/app-instance/backend/beaver/tools/registry/tool_registry.py new file mode 100644 index 0000000..5799c65 --- /dev/null +++ b/app-instance/backend/beaver/tools/registry/tool_registry.py @@ -0,0 +1,55 @@ +"""Beaver 工具注册表。 + +这层只做三件事: +1. 注册工具 +2. 按名称查找工具 +3. 导出 provider 可消费的 tool schemas + +不要把执行逻辑塞进这里。 +执行属于 runtime/executor,那样边界更清晰。 +""" + +from __future__ import annotations + +from typing import Iterable + +from beaver.tools.base import BaseTool, ToolSpec + + +class ToolRegistry: + """统一维护当前 runtime 可用的工具集合。""" + + def __init__(self) -> None: + self._tools: dict[str, BaseTool] = {} + + def register(self, tool: BaseTool, *, replace: bool = False) -> None: + """注册一个工具。 + + 默认不允许重名覆盖,避免 loader/runtime 不小心把同名工具静默冲掉。 + """ + + name = tool.spec.name + if not replace and name in self._tools: + raise ValueError(f"Tool '{name}' is already registered") + self._tools[name] = tool + + def register_many(self, tools: Iterable[BaseTool], *, replace: bool = False) -> None: + for tool in tools: + self.register(tool, replace=replace) + + def get(self, name: str) -> BaseTool | None: + return self._tools.get(name) + + def require(self, name: str) -> BaseTool: + tool = self.get(name) + if tool is None: + raise KeyError(f"Unknown tool '{name}'") + return tool + + def list_specs(self) -> list[ToolSpec]: + return [tool.spec for tool in self._tools.values()] + + def export_provider_schemas(self) -> list[dict]: + """导出给 provider 的函数工具 schema 列表。""" + + return [spec.to_provider_schema() for spec in self.list_specs()] diff --git a/app-instance/backend/beaver/tools/runtime/__init__.py b/app-instance/backend/beaver/tools/runtime/__init__.py new file mode 100644 index 0000000..49acfcb --- /dev/null +++ b/app-instance/backend/beaver/tools/runtime/__init__.py @@ -0,0 +1,5 @@ +"""Tool execution runtime helpers.""" + +from .executor import ToolExecutor + +__all__ = ["ToolExecutor"] diff --git a/app-instance/backend/beaver/tools/runtime/executor.py b/app-instance/backend/beaver/tools/runtime/executor.py new file mode 100644 index 0000000..57a3366 --- /dev/null +++ b/app-instance/backend/beaver/tools/runtime/executor.py @@ -0,0 +1,114 @@ +"""Beaver 工具执行器。 + +这层专门负责把 provider 返回的 tool call 转成真正的工具执行。 +它不关心 provider 是 OpenAI、Anthropic 还是 Codex,只关心: + +1. 工具叫什么 +2. 参数是什么 +3. registry 能不能找到它 +4. 执行结果怎么标准化 +""" + +from __future__ import annotations + +import json +from typing import Any + +from beaver.engine.providers.base import ToolCallRequest +from beaver.tools.base import ToolContext, ToolResult +from beaver.tools.registry.tool_registry import ToolRegistry + + +class ToolExecutor: + """统一执行单个 tool call。""" + + def __init__(self, registry: ToolRegistry) -> None: + self.registry = registry + + async def execute( + self, + tool_name: str, + arguments: dict[str, Any] | None, + *, + context: ToolContext | None = None, + ) -> ToolResult: + """按工具名执行一次调用。""" + + tool = self.registry.get(tool_name) + if tool is None: + return ToolResult( + success=False, + content=f"Tool {tool_name} is not registered.", + tool_name=tool_name, + error="tool_not_found", + ) + return await tool.invoke(arguments or {}, context or ToolContext()) + + async def execute_tool_call( + self, + tool_call: ToolCallRequest | dict[str, Any], + *, + context: ToolContext | None = None, + ) -> ToolResult: + """执行 provider 返回的一次结构化 tool call。 + + 兼容两种输入: + - `ToolCallRequest` + - OpenAI 风格 dict + """ + + try: + tool_name, arguments = self._normalize_tool_call(tool_call) + except Exception as exc: + return ToolResult( + success=False, + content=f"Tool call could not be parsed: {exc}", + tool_name=self._extract_tool_name(tool_call), + error="tool_call_parse_error", + ) + + parse_error = arguments.pop("__beaver_tool_argument_parse_error__", None) + if parse_error is not None: + return ToolResult( + success=False, + content=f"Tool call arguments for {tool_name} could not be parsed: {parse_error}", + tool_name=tool_name, + error="tool_call_argument_parse_error", + raw_output=arguments.get("__raw_arguments__"), + ) + return await self.execute(tool_name, arguments, context=context) + + @staticmethod + def _normalize_tool_call(tool_call: ToolCallRequest | dict[str, Any]) -> tuple[str, dict[str, Any]]: + if isinstance(tool_call, ToolCallRequest): + return tool_call.name, dict(tool_call.arguments) + + function = tool_call.get("function") + if isinstance(function, dict): + name = function.get("name") + arguments = function.get("arguments", {}) + else: + name = tool_call.get("name") + arguments = tool_call.get("arguments", {}) + + if not name: + raise ValueError("Tool call is missing a tool name") + if isinstance(arguments, str): + try: + arguments = json.loads(arguments) + except json.JSONDecodeError as exc: + raise ValueError(f"Tool call arguments for {name!r} are not valid JSON") from exc + if not isinstance(arguments, dict): + raise ValueError(f"Tool call arguments for {name!r} must be a dict") + return str(name), arguments + + @staticmethod + def _extract_tool_name(tool_call: ToolCallRequest | dict[str, Any]) -> str: + if isinstance(tool_call, ToolCallRequest): + return str(tool_call.name or "unknown") + function = tool_call.get("function") + if isinstance(function, dict) and function.get("name"): + return str(function["name"]) + if tool_call.get("name"): + return str(tool_call["name"]) + return "unknown" diff --git a/app-instance/backend/change.md b/app-instance/backend/change.md new file mode 100644 index 0000000..d909f6d --- /dev/null +++ b/app-instance/backend/change.md @@ -0,0 +1,799 @@ +# Beaver Backend 重构蓝图 + +## 命名说明 + +当前项目正式名称已经不是 `nanobot`,而是 `beaver`。 + +这份文档里如果出现 `nanobot/...`,一律表示“当前仓库里还没迁走的历史代码路径 / 现状实现位置”,不代表目标命名。 + +后续重构目标应统一收敛到: + +1. 产品名、项目名、运行时内核名统一按 `beaver` 表达。 +2. `nanobot` 只作为迁移期遗留路径存在,最终应逐步退出目录、模块和文档命名。 +3. 新增目录、新增模块、新增文档都应优先使用 `beaver` 命名,而不是继续扩散 `nanobot`。 + +## 1. 这次重构到底要解决什么 + +当前后端已经不是“功能不够”,而是“能力已经长出来了,但结构还停留在早期阶段”。 + +现在项目里同时存在这些事实: + +1. `AgentLoop` 已经承担了太多职责,既管主 agent 对话,又管工具、委派、MCP、会话、事件、memory。 +2. `web/server.py` 已经变成超大文件,FastAPI app factory、chat API、session、文件、skills、cron、A2A、Outlook 都放在一起。 +3. `agent_team` 已经接上了 `swarms`,但目前更像“业务层直接借用第三方 runtime”,不是“我们自己的多智能体平台”。 +4. `skills` 已经有加载、安装、审核,但本质还是 Markdown 说明书,不是可学习、可演化、可评估的能力对象。 +5. 项目里已经隐约出现了三个方向,但还没有被统一成一个完整架构: + - `swarms` 提供多智能体架构能力 + - `hermes-agent` 提供 skill 生命周期与长期演进思路 + - `OpenHarness` 提供模块化的 harness 设计方法 + +所以这次重构不是简单“整理目录”,而是把项目从“围绕一个 CLI 主 agent 生长出来的系统”升级成“所有 agent 共享同一内核的自有 agent harness 平台”。 + +## 2. 我是怎么想的 + +我的核心判断是:我们不能继续把第三方库、业务流程、执行控制、UI/API 接口揉在一起,而是应该先定义我们自己的稳定边界,再让第三方能力挂进来。 + +换句话说,目标不是“把仓库改得更像 swarms / hermes / OpenHarness”,而是: + +1. 用 `swarms` 的强项来解决“团队编排”。 +2. 用 `hermes-agent` 的强项来解决“skills 怎么创建、维护、学习、沉淀”。 +3. 用 `OpenHarness` 的强项来解决“工程边界、模块职责、可维护性”。 +4. 最终收口成我们自己的抽象和目录,而不是长期让第三方结构反向塑造我们。 + +这意味着后续所有设计都应遵守四条原则: + +### 2.1 我们要有自己的抽象 + +不能让业务代码直接依赖: + +- `third_party/swarms` 的导入路径 +- `SwarmRouter` 的参数细节 +- 某个第三方 skill 文件格式 +- 某个第三方 runtime 的副作用 + +我们应该先定义自己的核心对象,例如: + +- `AgentDescriptor` +- `SkillSpec` +- `SkillVersion` +- `TeamSpec` +- `ExecutionPlan` +- `ProcedureRecord` +- `RunRecord` +- `BridgeResult` + +第三方库只能作为 adapter / backend 存在。 + +### 2.2 所有 agent 共享同一套运行内核 + +后面不应该再保留“CLI 单 agent”和“其他 agent 另一套执行方式”这种概念分叉。 + +正确做法应该是: + +1. 所有 agent 都复用同一个 `AgentLoop` / engine。 +2. 主 agent、subagent、team member、A2A local specialist 都只是不同的运行配置和上下文。 +3. tools、skills、memory、permissions、MCP、delegation 都在同一套内核里装载。 +4. CLI 只是一个 interface,作用是把用户输入送进内核,而不是代表一种单独的 agent 类型。 + +这样做的意义是: + +1. 所有 agent 的能力边界一致。 +2. 不会再出现“这个能力只在 CLI 主 agent 可用,子 agent 不一致”的问题。 +3. agent 的差异只存在于 profile / policy / prompt / runtime context,而不是存在于不同执行栈里。 + +### 2.3 Harness 和业务要分开 + +当前很多逻辑混在一起:既有“平台级能力”,也有“具体产品接入”。 + +后面应该分成两层: + +1. Harness 层 + - tool use + - skills + - memory + - delegation + - orchestration + - governance +2. Product / Interface 层 + - web API + - gateway + - channel adapters + - Outlook / WhatsApp / 外部服务接入 + +这样平台能力才能稳定,接入层才能随产品变化而变化。 + +### 2.4 多智能体是平台能力,不是工具技巧 + +现在 `spawn_agent_team` 已经存在,但在结构上还像“一个高级工具”。 + +后面应该把 multi-agent 当成正式 runtime 能力: + +- 有 plan 层 +- 有 strategy 层 +- 有 execution backend 层 +- 有 result normalization 层 +- 有 memory / procedure reuse 层 +- 有 governance / safety / skill constraints + +这里要特别说明 `2.4` 和 `2.5` 的关系: + +1. multi-agent 不是独立于 skills 的第二套指导系统。 +2. 我们仍然保留之前的群组讨论机制,也就是“探索式协作 + 流程化执行”两种能力都保留。 +3. 但无论是探索式 group discussion,还是流程化 sequential / rearrange / hierarchy,都必须受 skills 指引和约束。 +4. 也就是说,skills 决定“应该如何思考、遵守什么边界、优先采用什么方法”,而 multi-agent 负责“由几个人、以什么结构去执行”。 + +所以后续正确关系应是: + +`skills -> 约束与方法指导` +`multi-agent -> 在 skills 约束下进行探索、讨论、流程化执行` + +### 2.5 skills 必须变成生命周期系统 + +现在的 skills 更像可读文档包,适合“手工维护”,不适合“自动学习”。 + +如果以后要做到自动创建、自动修订、自动推荐、自动淘汰,skills 必须具备: + +- 结构化元数据 +- 版本号 +- 来源与 lineage +- 审核状态 +- 效果统计 +- 与 procedure 的映射关系 +- 可回滚、可禁用、可发布 + +并且这里的 `skills` 不应只服务于“工具使用技巧”,而应成为整个 agent 系统的统一指引层,包括: + +1. 主 agent 如何规划和执行 +2. subagent / team member 如何行动 +3. memory 如何参与判断 +4. procedure reuse 如何被触发和约束 +5. multi-agent 讨论时允许采用哪些方法、角色分工和输出习惯 + +换句话说: + +1. memory / procedure reuse 不是独立于 skills 的平行系统。 +2. 但 memory 的实现标准要以 `hermes-agent` 为准,而不是继续沿用当前偏自由发挥的记忆模型。 +3. skills 提供全局行为指引;memory 只保存跨会话仍然有价值的稳定事实;session_search 负责找回历史细节;procedure 只作为可选优化层。 + +这里要明确四者分工: + +1. `skills` + - 指导“怎么做” + - 约束工具使用、讨论方式、流程化执行方式 +2. `memory` + - 保存 durable facts + - 例如用户偏好、环境事实、项目约定、工具 quirks +3. `session_search` + - 检索历史会话细节 + - 不把大量过程细节直接塞进 memory +4. `procedure` + - 作为 coordinator 内部的复用优化 + - 不是主 memory 契约,也不是主要 prompt 注入来源 + +## 3. 现有项目现在是咋样的 + +### 3.1 当前的主结构 + +从代码上看,`app-instance/backend` 当前大致是这几块。 + +注意:下面这些路径仍写作 `nanobot/...`,是因为这里描述的是“现状代码位置”,不是目标命名。 + +1. 启动与装配 + - `nanobot/cli/commands.py` + - `nanobot/__main__.py` +2. agent 运行时 + - `nanobot/agent/loop.py` + - `nanobot/agent/context.py` + - `nanobot/agent/tools/*` + - `nanobot/session/*` + - `nanobot/providers/*` +3. 多 agent / 委派 + - `nanobot/agent/delegation.py` + - `nanobot/agent_team/*` + - `nanobot/a2a/*` +4. Web / Gateway / Channels + - `nanobot/web/server.py` + - `nanobot/channels/*` + - `bridge/` +5. 技能与插件 + - `nanobot/skills/*` + - `nanobot/agent/skills.py` + - `nanobot/agent/plugins.py` +6. 外部运行时耦合点 + - 当前主要是 vendored `swarms` + +### 3.2 当前已经有的优点 + +这套代码不是没基础,相反已经有几个很有价值的雏形: + +1. 已经有 `AgentRegistry`、`DelegationManager`、`agent_team`,说明“统一委派层”思路已经出现。 +2. 已经有 `ProcedureMemory` 和 `RunMemory`,说明“从执行中学习”的基础数据层已经出现。 +3. 已经有 `skills` 的加载、安装、审核,说明“受控扩展机制”已经存在。 +4. 已经有 `SwarmsBridge`、`SwarmsPolicy`、`SwarmsRunPlanner`,说明多智能体桥接已经不是空白。 + +所以这次重构不是推倒重来,而是把这些散落的雏形收敛成一个完整架构。 + +### 3.3 当前最主要的问题 + +#### 问题一:装配逻辑散落 + +同一个后端能力,在 CLI、Web、Gateway 中经常重复装配,甚至行为已经开始漂移。 + +这会导致: + +1. 同样的配置在不同入口行为不同。 +2. 改一个入口容易漏另一个入口。 +3. 测试覆盖变难。 + +#### 问题二:`AgentLoop` 太重,但又没有成为唯一内核 + +`AgentLoop` 已经不是纯 loop,而是“半个 runtime 内核”。 + +这会导致: + +1. 主 agent 与其他 agent 的边界不清。 +2. tool、memory、delegation、session、events 相互缠绕。 +3. 很多能力只能靠继续往 `AgentLoop` 里塞。 +4. 同时又没有真正做到“所有 agent 都统一复用它”。 + +#### 问题三:`swarms` 接入边界不干净,而且 `third_party` 目录本身会持续恶化维护成本 + +当前 `agent_team` 虽然有 bridge,但仍然直接依赖: + +1. `sys.path` 注入 vendored `swarms` +2. 顶层 `swarms` 包导入副作用 +3. `SwarmRouter` 的参数细节 +4. `AutoSwarmBuilder` 自己的 LLM 栈 + +这意味着现在不是“我们调度 swarms”,而是“我们的平台有一部分被 swarms runtime 反向定义了”。 + +另外,`third_party/` 这种目录在这个项目里不应该长期存在。它会带来两个问题: + +1. 仓库边界不清,到底哪些代码是我们的,哪些不是,很难维护。 +2. 一旦改动第三方源码,升级、回滚、排障都会变得更脆弱。 + +#### 问题四:skills 还是静态文档包 + +现在的 skill 系统适合: + +- 展示 +- 人工安装 +- prompt 注入 + +但不适合: + +- 自动学习 +- 自动合并 +- 自动评估 +- 版本回滚 +- 基于效果做选择 + +#### 问题五:接口层和核心层耦合过深 + +`web/server.py` 过大说明一个事实: + +平台内核与外部 API、外部接入、外部服务没有完成分层。 + +## 4. 后面应该怎么改 + +## 4.1 先把系统改成 OpenHarness 风格的能力分组 + +这里我建议明确参考 OpenHarness 那种“按能力分组、核心目录更扁平”的结构,而不是继续按历史演化路径堆目录。 + +核心思路是: + +1. 用 `engine` 作为唯一运行内核。 +2. 用 `coordinator` 负责委派和多 agent 编排。 +3. 用 `tools`、`skills`、`memory`、`permissions` 作为独立能力层。 +4. 用 `interfaces` 只放 CLI / Web / Gateway / Channels 这类入口。 +5. 用 `integrations` 放外部协议和外部系统适配。 + +这样拆完之后,模块关系应变成: + +`interfaces -> engine/coordinator/tools/skills/memory -> foundation` + +而不是像现在这样互相横穿。 + +## 4.2 彻底去掉 `third_party/`,把 `swarms` 改造成可替换 backend + +### 当前状态 + +现在的 `agent_team` 已经接通: + +- `GroupChat` +- `SequentialWorkflow` +- `ConcurrentWorkflow` +- `AgentRearrange` +- `MixtureOfAgents` +- `HierarchicalSwarm` + +但这些能力还不是“平台正式能力集合”,而是“当前 bridge 恰好能跑通的一部分 swarms 类型”。 + +更重要的是,当前它们依赖 `third_party/swarms` 这个 vendored 目录,这是后续必须去掉的。 + +### 目标状态 + +后续应该先定义我们自己的团队执行抽象: + +```text +TeamSpec + -> TeamPlanner + -> ExecutionPlan + -> StrategyBackend + -> NormalizedResult +``` + +然后: + +1. `SwarmsBackend` 只是 `StrategyBackend` 的一个实现。 +2. 平台对外暴露的是自己的策略名和能力矩阵。 +3. `swarms` 只负责执行,不再负责定义平台边界。 +4. 仓库内不再保留 `third_party/`。 +5. `swarms` 要么作为外部依赖安装,要么把真正需要的最小能力内聚到我们自己的 backend 模块中。 + +### 具体改法 + +1. 抽出 `coordinator/backends/base.py` + - 定义统一 backend 接口 +2. 抽出 `coordinator/backends/swarms/` + - 把 `swarms_adapter.py` + - `swarms_bridge.py` + - `swarms_policy.py` + - `swarms_planner.py` 中 swarms 相关逻辑收进去 +3. 在平台层定义正式支持的 strategy + - `group_chat` + - `sequential` + - `concurrent` + - `rearrange` + - `mixture` + - `hierarchical` + - 后续预留 `graph` + - 后续预留 `heavy` +4. 所有 strategy 的输入输出都转成我们的统一模型 + +### 结果 + +改完之后: + +1. `third_party/` 目录消失。 +2. 上层不再知道 `third_party/swarms` 这个路径。 +3. 对上层透明的是 `SwarmsBackend`,不是 vendored 源码目录。 + +## 4.3 把 `skills` 从静态文档升级成能力生命周期系统 + +### 当前状态 + +现在 skill 基本等于: + +- 一个目录 +- 一个 `SKILL.md` +- 一点 frontmatter +- 一点审核流程 + +### 目标状态 + +后续 skill 至少要分成三类对象: + +1. `SkillDraft` + - 自动生成或人工创建 + - 还没发布 +2. `SkillVersion` + - 某个稳定版本 + - 可启用/禁用/回滚 +3. `SkillRuntimeView` + - 当前对模型暴露的生效版本 + +同时 skill 应该带这些元信息: + +- `id` +- `name` +- `version` +- `summary` +- `usage_rules` +- `inputs` +- `outputs` +- `dependencies` +- `source` +- `derived_from_procedure` +- `review_status` +- `metrics` + +### 自动学习建议 + +不要直接让 agent 在线改 live skills。 + +正确链路应该是: + +`run result -> procedure candidate -> skill draft -> review -> publish -> runtime use` + +这比“自动改 `SKILL.md`”安全得多,也更适合生产环境。 + +### 结果 + +改完之后,skills 不再只是 prompt 资源,而是平台知识层的一等对象。 + +## 4.4 以 `hermes-agent` 的 memory 模型为基线重做 memory 层 + +这里要明确:新的 memory 设计不再以当前 `ProcedureMemory` 为中心,而是以 `hermes-agent` 的 memory 模型为准。 + +### 主 memory 契约 + +新的主 memory 契约应是: + +1. 一个统一的 `memory` tool +2. 三个核心动作: + - `add` + - `replace` + - `remove` +3. 两个目标存储: + - `memory`:agent 的环境事实、项目约定、工具经验 + - `user`:用户画像、偏好、习惯、纠正记录 + +它的行为应对齐 Hermes: + +1. `add` + - 追加新条目 + - 精确重复时跳过 + - 超限时返回当前条目和占用情况 +2. `replace` + - 用 `old_text` 的短语义片段匹配条目并整体替换 + - 多条匹配时要求更精确的 `old_text` +3. `remove` + - 也是通过 `old_text` 的语义片段删除 + - 多条匹配时同样要求更精确匹配 + +这里要采用“子串匹配”而不是 UUID,因为这更符合 LLM 的操作习惯。 + +### 写入安全与并发安全 + +新的 memory 层应保留 Hermes 这几个关键约束: + +1. 写入前扫描注入/渗透模式 +2. 在锁内重新从磁盘加载目标文件 +3. 做重复检测和字符上限检测 +4. 通过临时文件 + `os.replace()` 做原子写入 + +也就是说,并发安全的关键不是“先读后写”,而是: + +`scan -> lock -> reload -> validate -> atomic write` + +### 冻结快照模式 + +新的 memory 层必须采用 frozen snapshot,而不是“每次 memory 写入都改 system prompt”。 + +规则是: + +1. 会话开始时,从磁盘加载 `memory` 和 `user` +2. 立刻冻结成 system prompt snapshot +3. 会话中写入 memory 时,只更新磁盘上的 live state +4. 当前会话里的 system prompt 保持不变 +5. 下一个会话开始时,再重新加载最新 memory + +### session_search 取代“把所有过程细节塞进 memory” + +大量过程细节不应继续塞进 `memory`。 + +因此新后端应该明确区分: + +1. `memory` + - 保存小而精的、跨会话稳定有效的事实 +2. `session_search` + - 检索历史会话 + - 支持“无 query 浏览最近会话”和“有 query 的全文搜索 + 摘要” + +这个能力后续应在 Beaver 中落成: + +- `beaver/memory/curated/*` +- `beaver/memory/search/*` +- `beaver/tools/builtins/memory.py` +- `beaver/tools/builtins/session_search.py` + +### `ProcedureMemory` 的新定位 + +这不表示 `ProcedureMemory` 没价值,而是它的地位要下降: + +1. `ProcedureMemory` 不再是主 memory 契约 +2. 它不应该直接承担“跨会话记忆”职责 +3. 它更适合作为 coordinator 内部的流程复用与路由优化层 + +新的优先级应是: + +1. 用户偏好、纠正、环境事实 -> `memory` +2. 历史会话细节 -> `session_search` +3. 稳定方法论和工作法 -> `skills` +4. 团队/流程复用优化 -> `ProcedureMemory` + +## 4.5 CLI 不再代表单 agent 模式,只保留为薄入口 + +当前入口层太厚,后续应该改成: + +1. CLI 只做参数解析与 runtime 启动 +2. Web 只做 API 与 request/response 映射 +3. Gateway 只做渠道接入与消息转发 + +所有核心能力都由统一的 application services 提供,例如: + +- `ChatApplicationService` +- `DelegationApplicationService` +- `TeamRunApplicationService` +- `SkillApplicationService` +- `MemoryApplicationService` + +同时要明确一条原则: + +CLI 不是“单 agent 专用模式”。 + +它只是这些 interface 之一: + +- CLI +- Web +- Gateway +- Channel + +无论从哪个入口进来,最终都进入同一套 `AgentLoop` / engine。 + +这样就不会再出现“CLI 一套 agent,其他入口另一套 agent”的问题。 + +## 5. 具体改动后会是什么样 + +## 5.1 所有 agent 共用同一套 engine + +### 现在 + +`CLI/Web/Gateway -> 各自装配一套 AgentLoop 或相关依赖` + +### 之后 + +`CLI/Web/Gateway/Channel -> AgentEntryService -> AgentLoop(engine) -> tools/skills/memory/permissions/delegation` + +结果是: + +1. 主 agent、subagent、team member 复用同一套 engine。 +2. 装载逻辑只在 engine 内统一处理一次。 +3. 不再保留“CLI 单 agent 概念”。 +4. 测试可以直接测 engine 和 service,而不是分别测入口分支。 + +## 5.2 多 agent 场景 + +### 现在 + +`spawn_agent_team -> DelegationManager -> AgentTeamOrchestrator -> SwarmsPlanner/Bridge -> SwarmRouter` + +### 之后 + +`spawn_agent_team` +`-> DelegationService` +`-> TeamApplicationService` +`-> TeamPlanner` +`-> ExecutionPlan` +`-> StrategyBackendRegistry` +`-> SwarmsBackend` +`-> NormalizedTeamResult` + +结果是: + +1. 团队能力不再绑定某个第三方 runtime 结构。 +2. 可以逐步增加第二种 backend,而不推翻平台层。 +3. `swarms` 只是其中一个可插拔执行器。 + +## 5.3 skill 场景 + +### 现在 + +`SkillsLoader -> 读 SKILL.md -> 摘要注入 / 手动审核安装` + +### 之后 + +`SkillCatalog` +`-> SkillDraftStore` +`-> SkillReviewService` +`-> SkillPublisher` +`-> SkillRuntimeResolver` + +结果是: + +1. skill 可以有版本。 +2. skill 可以从 procedure 生成。 +3. skill 可以审核和回滚。 +4. skill 可以做效果分析和推荐。 + +## 5.4 运行学习场景 + +### 现在 + +`Run details 混在 session / memory / procedure 中` + +### 之后 + +`Run transcript` +`-> session_search index` + +`Durable fact` +`-> memory(add/replace/remove)` + +`Stable method / workaround / reusable workflow` +`-> SkillCandidateGenerator` +`-> SkillDraft` +`-> Review` +`-> Publish` + +`Repeated execution pattern` +`-> optional ProcedureMemory` + +结果是: + +1. durable facts、历史细节、稳定方法三类信息终于分层。 +2. 自动学习不会把临时过程污染到主 memory。 +3. skills 仍是最高层指导系统,而 memory 变成受控 CRUD 系统。 + +## 6. 分阶段落地建议 + +这次重构不应该一次性推翻,建议分四期做。 + +### 第一期:边界清理 + +目标: + +1. 把入口装配统一掉 +2. 把 `web/server.py` 开始拆分 +3. 把 swarms 相关代码聚到单独 backend 目录 + +交付物: + +- 统一 app factory / service wiring +- 初步拆分 web routes +- `orchestration/backends/swarms/` + +### 第二期:平台抽象固化 + +目标: + +1. 定义 team / skill / memory / session_search 的正式模型 +2. 让上层只依赖平台模型 + +交付物: + +- `TeamSpec` +- `SkillSpec` +- `ExecutionPlan` +- `MemoryEntry` +- `MemorySnapshot` +- `SessionSearchResult` +- `SkillDraft` +- `SkillVersion` + +### 第三期:skills 生命周期 + +目标: + +1. 从“文档技能”升级到“版本化能力” +2. 打通“稳定方法 -> SkillDraft” +3. 按 Hermes 基线完成 memory CRUD、frozen snapshot、session_search + +交付物: + +- skill catalog +- review/publish flow +- runtime resolver +- memory tool +- session search tool + +### 第四期:高级多智能体能力 + +目标: + +1. 放开更多正式支持的 strategy +2. 评估 `GraphWorkflow`、`HeavySwarm` +3. 增加 fallback / retry / policy routing + +交付物: + +- 完整 strategy registry +- 多 backend 能力矩阵 +- team execution fallback + +## 7. 重构后的推荐目录 + +下面这个目录我已经按你说的方向收紧了: + +1. 不保留 `third_party/` +2. 不保留“CLI 单 agent”这类结构暗示 +3. 尽量参考 OpenHarness 那种按能力分组、观感更规整的布局 +4. 每个目录后面都加中文说明 + +```text +app-instance/backend/ +├── change.md # 这份重构蓝图 +├── README.md # 后端总说明 +├── workflow.md # 运行链路说明 +├── docs/ # 架构文档和迁移文档 +│ ├── architecture/ # 核心架构说明 +│ └── migration/ # 分阶段迁移计划 +├── beaver/ +│ ├── foundation/ # 最底层公共设施:配置、模型、事件、错误、工具函数 +│ │ ├── config/ # 配置定义与加载 +│ │ ├── models/ # 全局共享数据模型 +│ │ ├── events/ # 统一事件模型与事件派发 +│ │ ├── errors/ # 统一错误类型 +│ │ └── utils/ # 通用工具函数 +│ ├── engine/ # 统一 agent 内核,所有 agent 都复用这里 +│ │ ├── loop.py # AgentLoop 主循环与执行入口 +│ │ ├── loader.py # tools、skills、memory、permissions 的统一装载 +│ │ ├── context/ # 上下文拼装 +│ │ ├── session/ # 会话状态与持久化 +│ │ ├── providers/ # LLM provider 适配 +│ │ └── runtime/ # 运行时辅助对象与执行上下文 +│ ├── tools/ # 工具系统 +│ │ ├── registry/ # 工具注册与发现 +│ │ ├── builtins/ # 内置工具 +│ │ ├── mcp/ # MCP 工具适配 +│ │ └── policies/ # 工具权限与调用约束 +│ ├── skills/ # 技能系统 +│ │ ├── builtin/ # 内置技能内容 +│ │ ├── catalog/ # 技能目录、索引与查询 +│ │ ├── drafts/ # 自动生成或待审核的 skill draft +│ │ ├── reviews/ # 技能审核流 +│ │ ├── publisher/ # 技能发布与版本切换 +│ │ └── resolver/ # 运行时技能解析与注入 +│ ├── memory/ # 记忆与经验沉淀系统 +│ │ ├── curated/ # Hermes 风格的 MEMORY / USER 持久记忆 +│ │ ├── search/ # session_search 与历史会话检索 +│ │ ├── runs/ # 单次执行记录 +│ │ ├── procedures/ # 可选的流程复用优化层 +│ │ └── stores/ # 底层存储与原子写实现 +│ ├── permissions/ # 权限、沙箱、治理规则 +│ │ ├── policies/ # 权限策略 +│ │ ├── guards/ # 执行前检查 +│ │ └── profiles/ # 不同 agent 运行权限画像 +│ ├── coordinator/ # 多 agent 协调层,参考 OpenHarness 的 coordinator 风格 +│ │ ├── delegation/ # 委派与任务分发 +│ │ ├── registry/ # agent registry 与 agent descriptor +│ │ ├── planner/ # 团队 planning 与 execution plan 生成 +│ │ ├── execution/ # 执行控制、fallback、聚合 +│ │ ├── backends/ # 可替换的多 agent backend +│ │ │ ├── base.py # backend 抽象接口 +│ │ │ └── swarms/ # swarms backend 封装,不再直接暴露第三方目录 +│ │ └── team/ # team 级模型与编排对象 +│ ├── services/ # application services,对外提供统一能力入口 +│ │ ├── agent_service.py # 统一 agent 运行入口 +│ │ ├── team_service.py # 多 agent 执行入口 +│ │ ├── skill_service.py # 技能管理入口 +│ │ ├── memory_service.py # memory 查询与写入入口 +│ │ └── admin_service.py # 平台管理入口 +│ ├── interfaces/ # 薄入口层,不承载核心业务 +│ │ ├── cli/ # CLI 入口,只负责把请求送进 services/engine +│ │ ├── web/ # FastAPI 接口层 +│ │ │ ├── app.py # Web app factory +│ │ │ ├── routes/ # 路由拆分 +│ │ │ ├── schemas/ # Web 请求/响应模型 +│ │ │ └── deps.py # Web 依赖装配 +│ │ ├── gateway/ # 常驻 worker / gateway 入口 +│ │ └── channels/ # Telegram/Slack/Email 等渠道入口 +│ ├── integrations/ # 外部系统与协议集成 +│ │ ├── a2a/ # A2A 协议与 client +│ │ ├── mcp/ # MCP 连接与管理 +│ │ ├── outlook/ # Outlook 集成 +│ │ ├── whatsapp/ # WhatsApp bridge 适配 +│ │ └── providers/ # 外部 provider 特定集成 +│ ├── plugins/ # 插件系统 +│ │ ├── loader.py # 插件发现与装载 +│ │ ├── registry.py # 插件注册表 +│ │ └── hooks.py # 插件 hooks +│ └── templates/ # 默认模板、system prompt 模板、内置文本资源 +├── tests/ # 测试 +│ ├── unit/ # 单元测试 +│ ├── integration/ # 集成测试 +│ ├── e2e/ # 端到端测试 +│ └── fixtures/ # 测试数据与夹具 +└── bridge/ # 独立 Node/bridge 代码,作为外部桥接层保留 +``` + +## 8. 最终结论 + +这次重构的本质不是“把代码拆小一点”,而是完成三件事: + +1. 把当前项目从“围绕 `AgentLoop` 生长的单体系统”升级成“所有 agent 共用一个 engine 的可维护 harness 平台”。 +2. 把 `swarms` 从“放在 `third_party/` 里的深耦合运行时”降级成“可替换的多智能体 backend”。 +3. 把 `skills` 从“静态 Markdown 包”升级成“可学习、可审核、可发布、可回滚的能力系统”。 + +如果这三件事做成了,后面再扩多智能体架构、自动学习、插件生态、外部接入,代码就不会继续失控。 diff --git a/app-instance/backend/docs/architecture/backend-overview.md b/app-instance/backend/docs/architecture/backend-overview.md new file mode 100644 index 0000000..b1a65a9 --- /dev/null +++ b/app-instance/backend/docs/architecture/backend-overview.md @@ -0,0 +1,11 @@ +# Beaver Backend Overview + +这是新 `Beaver` 后端的架构入口文档。 + +当前约束: + +1. 所有 agent 共用 `engine`。 +2. 多 agent 编排进入 `coordinator`。 +3. skills、memory、permissions 独立成能力层。 +4. `interfaces` 只做薄入口。 + diff --git a/app-instance/backend/docs/migration/phase-1.md b/app-instance/backend/docs/migration/phase-1.md new file mode 100644 index 0000000..d06452f --- /dev/null +++ b/app-instance/backend/docs/migration/phase-1.md @@ -0,0 +1,8 @@ +# Phase 1 + +第一阶段先完成结构搭建与边界清理: + +1. 创建新的 `beaver` 包结构。 +2. 保留旧实现于 `backend-old/`。 +3. 后续逐步把旧能力迁入新结构。 + diff --git a/app-instance/backend/flow.md b/app-instance/backend/flow.md new file mode 100644 index 0000000..28a30ea --- /dev/null +++ b/app-instance/backend/flow.md @@ -0,0 +1,610 @@ +# Beaver Backend Flow + +这份文档只记录两件事: + +1. 我们**为什么这么实现** +2. 当前代码里**真实已经实现了什么** + +它不是蓝图,也不是未来设计草稿。以后只要主链、装配逻辑、运行时边界发生变化,就必须同步更新它。 + +--- + +## 1. 参考项目各自借什么 + +当前 Beaver 的实现思路,主要借了三个参考项目,但借的点是分开的。 + +### 1.1 `OpenHarness` + +借的是**模块边界和 Harness 形态**: + +1. `Harness / Runtime` 应该和 Web、Gateway、产品接入分开 +2. `skills / memory / tools / session / orchestration` 都属于平台层 +3. 运行时最好是可装配的,而不是所有逻辑都塞进一个大 agent 类 + +所以 Beaver 现在一直在做的事情,是把: + +- `EngineLoader` +- `AgentLoop` +- `ContextBuilder` +- `Session` +- `Tools` +- `Skills` + +收成一个清晰的运行内核。 + +### 1.2 `hermes-agent` + +借的是**memory、skills、session 的运行时风格**: + +1. memory 用 curated CRUD + frozen snapshot +2. `session_search` 查历史细节,不把所有历史都塞进 memory +3. skills 用: + - 显式 skill loading path + - 激活后的 skill 正文显式注入 + +所以 Beaver 现在这些点都明显受 Hermes 影响: + +1. `MemoryService` + frozen snapshot +2. `session_search` +3. `skill_view` +4. activated skill messages + +### 1.3 `swarms` + +借的是**后面多智能体 orchestration 的方向**: + +1. team orchestration +2. swarm strategy +3. multi-agent execution backend + +但要注意:它现在**还不是当前主链的核心**。 +当前我们主要先把单 agent runtime 打稳,多智能体还没正式接回主链。 + +--- + +## 2. 当前我们到底做到哪了 + +当前已经不是“搭骨架”阶段了,而是: + +**最小单 agent runtime 已经跑通。** + +现在已经完成的核心段落是: + +1. `4.1 session` +2. `4.2 provider` +3. `4.3 context` +4. `4.4 tools` +5. `4.5 最小主链` +6. `5.1 memory 最小接入` +7. `5.2 skills 最小接入` +8. `6.1 session-first / event-source 第一阶段` + +更准确地说,当前 Beaver 已经有: + +1. 一个可运行的 `AgentService -> AgentLoop` 主链 +2. 一个外部化的 Session 子系统 +3. 一个可工作的 tool loop +4. Hermes 风格的 memory / skills 接入 +5. LLM-driven 的 `SkillAssembler` + +但还没有: + +1. 更完整的 shutdown hooks +2. Web / Gateway 的 bus / channels / realtime 全量接入 +3. delegation / swarm / team runtime +4. 权限系统 +5. MCP 全量工具接回 runtime + +--- + +## 3. 当前真实主链 + +当前主入口已经不是 CLI 逻辑,而是: + +```python +service = AgentService() +await service.process_direct("你好") +``` + +同时,第 6 阶段的最小运行循环已经有了: + +```python +service = AgentService() +await service.start() +result = await service.submit_direct("你好") +await service.stop() +service.close() +``` + +宿主层现在也已经开始接到这条 lifecycle 上: + +```python +app = create_app() # FastAPI lifespan 内部托管 AgentService.start()/shutdown() +await run_gateway() # Gateway 常驻进程托管 AgentService.start()/shutdown() +``` + +这套 lifecycle 当前明确是: + +1. `start()` 进入一个 `AgentLoop` 实例的运行模式 +2. 运行模式下,外部任务只能走 `submit_direct()` +3. 运行模式下,不允许再直接调用 `process_direct()` +4. `stop()` 是 **instance-scoped** + - 只针对当前这个 `AgentLoop` 实例 + - 不是 session-scoped + - 也不是 platform-scoped +5. `stop()` 调用后会拒绝新任务,已入队任务正常收尾 +6. `stop()` / `shutdown()` 支持 graceful timeout;必要时可 force cancel +7. `close()` 只能在该实例已停止后调用 + +### 3.1 Web / Gateway 当前怎么接 + +这一层现在已经不是纯占位了,而是最小宿主层: + +1. `beaver/interfaces/web/app.py` + - FastAPI lifespan 启动时: + - 创建或接收 `AgentService` + - 如果 app 自己创建 service,则 `await service.start()` + - Web 接口现在有最小正式 schema: + - `WebChatRequest` + - `WebChatResponse` + - `WebStatusResponse` + - `/api/chat` 请求: + - 用结构化 request schema 校验输入 + - `await service.submit_direct(...)` + - 把常见 runtime / config 错误收成 HTTP 错误 + - 外部注入但尚未进入 running mode 的 service,会返回 `503` + - `/api/ping`: + - 返回 `status/running/mode` + - 不会为了 health check 额外 boot runtime + - app 关闭时: + - 如果 app 自己创建 service,则 `await service.shutdown(timeout_seconds=5.0, force=True)` + - app 自己接管 lifecycle 时: + - 若 `start()` 失败,会立即 `close()` 做 startup cleanup + +2. `beaver/interfaces/gateway/main.py` + - `run_gateway()` 启动时: + - 如果 gateway 自己创建 service,则 `await service.start()` + - 持有最小 `MessageBus` + - 常驻消费 `bus.inbound` + - 调 `await service.submit_direct(...)` + - 把结果写回 `bus.outbound` + - 同时等待 `stop_event` + - 退出时: + - 先尝试 `await service.shutdown(timeout_seconds=5.0, force=True)` + - 再等待 bridge 协程收尾;必要时取消 bridge + - 如果 gateway 自己接管 lifecycle 且 `start()` 失败: + - 会立即 `close()` 做 startup cleanup + - 未处理完的 inbound: + - 不再静默丢下 + - 会被冲刷成结构化 outbound error + +3. `beaver/foundation/events/message_bus.py` + - 已有最小: + - `MessageBus` + - `InboundMessage` + - `OutboundMessage` + - 当前只做双队列桥接: + - `inbound` + - `outbound` + - 还没有 broker / topic routing / retry / persistence + +所以现在已经明确: + +1. Web / Gateway 属于宿主层 +2. 它们不直接 new `AgentLoop` 或绕过运行模式 +3. 它们复用: + - `start()` + - `submit_direct()` + - `stop()` + - `shutdown()` +4. ownership 语义: + - 自己创建的 `AgentService`:自己负责 lifecycle + - 外部注入的 `AgentService`:默认不自动 start/shutdown,除非显式要求接管 +5. gateway 已经从“只会常驻等待”推进到“最小消息桥接层” + - external inbound message + - `MessageBus.inbound` + - `service.submit_direct(...)` + - `MessageBus.outbound` + +### 3.2 总体链路 + +当前代码里的主链可以概括成: + +```text +AgentService + -> AgentLoop + -> Session + -> Memory + -> SkillAssembler + -> ContextBuilder + -> Provider + -> ToolExecutor + -> Session writeback +``` + +### 3.3 详细顺序 + +```text +用户输入 task +│ +├─ AgentService.create_loop() +│ ├─ 创建 AgentLoop(profile, loader) +│ └─ loop.boot() +│ +├─ AgentLoop.boot() +│ └─ EngineLoader.load() +│ ├─ SessionManager +│ ├─ MemoryStore +│ ├─ MemoryService +│ ├─ ToolRegistry +│ ├─ ToolExecutor +│ ├─ SkillsLoader +│ ├─ SkillAssembler +│ └─ ContextBuilder +│ +├─ AgentLoop.process_direct(task) +│ │ +│ ├─ 生成 `session_id` / `run_id` +│ │ +│ ├─ memory_service.reload_for_new_run() +│ │ └─ 建立本轮 frozen memory snapshot +│ │ +│ ├─ sessions.ensure_session(session_id) +│ ├─ sessions.append_message(event_type="run_started", hidden) +│ │ +│ ├─ make_provider_bundle() +│ │ ├─ main provider +│ │ ├─ fallback provider +│ │ ├─ auxiliary provider 可用于 skill 选择 +│ │ └─ embedding runtime 提供 embeddings 的 model/api_key/api_base +│ │ 说明:它是独立配置线,只支持 OpenAI-compatible embeddings endpoint +│ │ +│ ├─ skill_assembler.assemble(task_description=task, provider=selector_provider, embedding_runtime=..., ...) +│ │ ├─ 读取全量可用 skill 候选摘要 +│ │ ├─ 用 `text-embedding-v4` 对全量候选做相似度召回 +│ │ ├─ 把召回结果交给 LLM 做最终选择 +│ │ └─ 返回 activated_skills +│ │ +│ ├─ ContextBuilder.build_skill_activation_messages(...) +│ ├─ 如果 activated_skills 非空: +│ │ └─ sessions.append_message(event_type="skill_activation_snapshotted", hidden) +│ │ +│ ├─ ContextBuilder.build_messages() +│ │ ├─ system prompt 包含: +│ │ │ ├─ base system prompt +│ │ │ ├─ session metadata +│ │ │ ├─ execution context +│ │ │ └─ frozen memory snapshot +│ │ ├─ messages 里显式插入 activated skill messages +│ │ ├─ 再拼 visible history +│ │ └─ 最后追加当前 user input +│ │ +│ ├─ sessions.update_system_prompt() +│ ├─ sessions.append_message(event_type="system_prompt_snapshotted", hidden) +│ ├─ sessions.append_message(event_type="user_message_added") +│ │ +│ ├─ 进入最小 tool loop +│ │ ├─ provider.chat(messages, tools=schemas) +│ │ ├─ sessions.update_usage() +│ │ ├─ sessions.append_message(event_type="assistant_message_added") +│ │ ├─ ContextBuilder.add_assistant_message(...) +│ │ ├─ 如果没有 tool calls: +│ │ │ └─ 结束 +│ │ └─ 如果有 tool calls: +│ │ ├─ ToolExecutor.execute_tool_call(...) +│ │ ├─ sessions.append_message(event_type="tool_result_recorded") +│ │ ├─ ContextBuilder.add_tool_result(...) +│ │ └─ 再回 provider.chat(...) +│ │ +│ ├─ 成功结束: +│ │ └─ sessions.append_message(event_type="run_completed", hidden) +│ │ +│ ├─ 异常结束: +│ │ ├─ 补 assistant error message +│ │ └─ sessions.append_message(event_type="run_failed", hidden) +│ │ +│ └─ return AgentRunResult +│ ├─ session_id +│ ├─ run_id +│ ├─ output_text +│ ├─ finish_reason +│ ├─ tool_iterations +│ ├─ provider_name +│ ├─ model +│ └─ usage +``` + +--- + +## 4. 当前模块边界 + +### 4.1 `EngineLoader` + +职责:装配运行时依赖。 + +当前已经装配: + +1. `SessionManager` +2. `MemoryStore` +3. `MemoryService` +4. `ToolRegistry` +5. `ToolExecutor` +6. `SkillsLoader` +7. `SkillAssembler` +8. `ContextBuilder` + +### 4.2 `AgentLoop` + +职责:执行单次 run。 + +当前已经负责: + +1. direct run 主链 +2. provider 调用 +3. 最小 tool loop +4. session 事件写回 +5. usage 汇总 + +当前还没负责: + +1. 更复杂的 message bus mode +2. 多 worker / 并发调度 +3. 更完整的 runtime lifecycle +4. multi-agent orchestration + +### 4.3 `Session` + +职责:外部化的运行事实存储。 + +当前实现重点: + +1. `sessions` 表 + - projection / summary row +2. `messages` 表 + - 当前主事件流 +3. `run_id` + - 把同一个 session 里的多次 run 切开 + +当前主要读取接口: + +1. `get_event_records(session_id)` + - 整个 session 的完整事件流 +2. `get_run_event_records(session_id, run_id)` + - 某一次 run 的事件片段 +3. `list_run_ids(session_id)` + - 发现当前 session 中有哪些 run +4. `get_visible_history(session_id)` + - 给 ContextBuilder 用的可见历史切片 +5. `session_search` + - 只检索可见 transcript + - 不把 hidden prompt / skill snapshot 当成搜索候选 + +当前关键 hidden 事件: + +1. `run_started` +2. `skill_activation_snapshotted` +3. `system_prompt_snapshotted` +4. `run_completed` +5. `run_failed` + +### 4.4 `Memory` + +职责:durable facts,不是 transcript。 + +当前实现重点: + +1. curated CRUD +2. frozen snapshot +3. 每次新 run 开始时刷新 snapshot +4. 当前 run 中途写 memory 不反向污染本轮 prompt + +### 4.5 `Skills` + +职责:外置 skill 装配与按需查看。 + +当前实现重点: + +1. `SkillsLoader` + - 扫描 `workspace/skills/*/SKILL.md` + - 扫描 builtin skills +2. `SkillAssembler` + - 输入 task description + 候选 skill 摘要 + - 先用 embedding 做语义召回 + - 再调一次 LLM 直接选择 skills + - 没有匹配时返回空 skills +3. `skill_view` + - 显式加载 skill 正文或支持文件 +4. activated skills + - 按 Hermes 风格作为显式消息注入 + +当前 skill 语义已经定成: + +1. **run-scoped** + - skill 激活只对当前 run 生效 +2. **不是 session-scoped** + - 不默认跨 run 持久化为 session 状态 +3. **explicit loading path** + - `skill_view` +4. **no-match means no skill injection** + - 如果 assembler 没选出 skill + - 当前 run 不拼接 skill messages + - 也不会写 `skill_activation_snapshotted` + +### 4.6 `Tools` + +当前内建工具: + +1. `echo` +2. `memory` +3. `skill_view` +4. `session_search` + +当前工具基础设施: + +1. `ToolSpec` +2. `ObjectBackedTool` +3. `ToolRegistry` +4. `ToolExecutor` + +### 4.7 `Providers` + +当前已经实现: + +1. provider registry +2. runtime resolution +3. main provider +4. fallback provider + +当前状态: + +1. fallback 已经是“每次调用都先 main,再 fallback” +2. auxiliary provider 已经可用于 skill 选择 +3. auxiliary provider 还没有进入主对话 tool loop + +--- + +## 5. 当前最重要的设计决定 + +这几条是现在已经定下来的,不应该再反复漂: + +### 5.1 `Session-first` + +当前 Beaver 明确在往这个方向走: + +1. 运行事实优先写回 Session +2. Session 是 replay / audit / resume 的基础 +3. prompt 不是状态源,Session 才是 + +### 5.2 `Harness != Product Interface` + +当前主入口已经是: + +- `AgentService` +- `AgentLoop` + +而不是 CLI 本身。 +CLI、Web、Gateway 后面都应该只是接口层。 + +### 5.3 `Skill selection` 外置 + +已经不再让 `AgentLoop` 自己“决定该选哪个 skill”,而是: + +```text +task description + -> SkillAssembler + -> AgentLoop +``` + +### 5.4 `Skills` 采用 Hermes 风格 + +不是: + +- skill 正文长期塞进 system prompt +- summary 让模型自己猜怎么展开 + +而是: + +1. activated skill messages +2. `skill_view` + +--- + +## 6. 当前还没完成什么 + +这部分是接下来继续施工的重点。 + +### 6.1 运行时生命周期 + +已做第一步: + +1. `EngineLoadResult.close()` +2. `AgentLoop.close()` +3. `AgentService.close()` +4. `AgentService.shutdown()` + +已做第二步的最小版本: + +1. `AgentLoop.run()` +2. `AgentLoop.stop()` +3. `AgentLoop.submit_direct()` + +还没做: + +1. 统一 shutdown hooks +2. 更完整的 provider/client 资源释放协议 +3. 多 worker / bus / 调度策略 + +### 6.2 Web / Gateway 接主链 + +现在主链已经能跑,但还没正式变成: + +1. Web 真正调用 `AgentService.process_direct()` +2. Gateway 真正调用 `AgentService.process_direct()` + +### 6.3 Session 更完整的 event-source 能力 + +还没做: + +1. checkpoint +2. rewind +3. fork session +4. crash-resume protocol + +### 6.4 Multi-agent / swarms + +还没正式接回主链: + +1. delegation +2. team runtime +3. swarms orchestration backend + +但 lifecycle 关系已经先定下来了: + +1. team 不会共享一个大 `AgentLoop` 跑所有成员 +2. 每个 team member 都应有自己独立的 `AgentService / AgentLoop` +3. team coordinator 在上层调度多个 member 实例 +4. 因此当前这套 `start()/submit_direct()/stop()/close()` 首先是 member-level lifecycle +2. team runtime +3. swarms backend +4. group discussion / workflow orchestration + +### 6.5 权限与治理 + +还没做: + +1. permission gates +2. tool policy +3. MCP 工具治理 + +--- + +## 7. 下一步从哪开始最合理 + +如果现在继续施工,最合理的顺序是: + +1. 先把 `flow.md` 作为当前基线固定下来 +2. 再继续第 6 阶段: + - runtime lifecycle + - `boot / close / run / stop` +3. 然后再接: + - Web / Gateway +4. 最后才是: + - multi-agent / swarms + +一句话总结: + +**当前 Beaver 已经有一个可运行的单 agent runtime;接下来不是继续堆局部能力,而是把它升级成有完整生命周期的标准 harness。** + +--- + +## 8. 文档维护要求 + +以后只要发生以下任一变动,必须同步更新本文件: + +1. `EngineLoader` 装配项变化 +2. `AgentLoop` 主链变化 +3. `Session` 事件流结构变化 +4. `Memory` 接入方式变化 +5. `Skills` 装配方式变化 +6. `Tools` 默认集合变化 +7. Web / Gateway / multi-agent 真正接入主链 diff --git a/app-instance/backend/pyproject.toml b/app-instance/backend/pyproject.toml index 33facd4..3274382 100644 --- a/app-instance/backend/pyproject.toml +++ b/app-instance/backend/pyproject.toml @@ -1,122 +1,36 @@ [project] -name = "nanobot-ai" -version = "0.1.4.post1" -description = "A lightweight personal AI assistant framework" +name = "beaver-backend" +version = "0.1.0" +description = "Beaver backend skeleton" requires-python = ">=3.11" -license = {text = "MIT"} -authors = [ - {name = "nanobot contributors"} -] -keywords = ["ai", "agent", "chatbot"] -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", -] - dependencies = [ - "typer>=0.20.0,<1.0.0", - "litellm>=1.81.5,<2.0.0", - "pydantic>=2.12.0,<3.0.0", - "pydantic-settings>=2.12.0,<3.0.0", - "websockets>=16.0,<17.0", - "websocket-client>=1.9.0,<2.0.0", - "httpx>=0.28.0,<1.0.0", - "oauth-cli-kit>=0.1.3,<1.0.0", - "loguru>=0.7.3,<1.0.0", - "readability-lxml>=0.8.4,<1.0.0", - "rich>=14.0.0,<15.0.0", - "croniter>=6.0.0,<7.0.0", - "dingtalk-stream>=0.24.0,<1.0.0", - "python-telegram-bot[socks]>=22.0,<23.0", - "lark-oapi>=1.5.0,<2.0.0", - "socksio>=1.0.0,<2.0.0", - "python-socketio>=5.16.0,<6.0.0", - "msgpack>=1.1.0,<2.0.0", - "slack-sdk>=3.39.0,<4.0.0", - "slackify-markdown>=0.2.0,<1.0.0", - "qq-botpy>=1.2.0,<2.0.0", - "python-socks[asyncio]>=2.8.0,<3.0.0", - "prompt-toolkit>=3.0.50,<4.0.0", - "mcp>=1.26.0,<2.0.0", - "json-repair>=0.57.0,<1.0.0", + "anthropic>=0.51.0,<1.0.0", + "fastmcp>=3.0.0,<4.0.0", "fastapi>=0.115.0,<1.0.0", + "httpx>=0.28.0,<1.0.0", + "json-repair>=0.39.0,<1.0.0", + "litellm>=1.79.0,<2.0.0", + "openai>=1.79.0,<2.0.0", + "pydantic>=2.12.0,<3.0.0", + "typer>=0.20.0,<1.0.0", "uvicorn[standard]>=0.34.0,<1.0.0", - "psutil>=7.2.2", - "python-dotenv>=1.2.1", - "pyyaml>=6.0.3", - "toml>=0.10.2", - "pypdf==5.1.0", - "ratelimit>=2.2.1", - "tenacity>=9.1.4", - "networkx>=3.6.1", - "aiofiles>=24.1.0", - "requests>=2.32.5", - "aiohttp>=3.13.3", - "numpy>=2.4.4", - "schedule>=1.2.2", - "setuptools>=82.0.1", - "chardet<6", ] [project.optional-dependencies] -matrix = [ - "matrix-nio[e2e]>=0.25.2", - "mistune>=3.0.0,<4.0.0", - "nh3>=0.2.17,<1.0.0", -] dev = [ "pytest>=9.0.0,<10.0.0", - "pytest-asyncio>=1.3.0,<2.0.0", - "ruff>=0.1.0", - "matrix-nio[e2e]>=0.25.2", - "mistune>=3.0.0,<4.0.0", - "nh3>=0.2.17,<1.0.0", ] [project.scripts] -nanobot = "nanobot.cli.commands:app" +beaver = "beaver.interfaces.cli.main:main" +beaver-memory-mcp = "beaver.interfaces.mcp.memory_server:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["nanobot"] - -[tool.hatch.build.targets.wheel.sources] -"nanobot" = "nanobot" - -# Include non-Python files in skills and templates -[tool.hatch.build] -include = [ - "nanobot/**/*.py", - "nanobot/templates/**/*.md", - "nanobot/skills/**/*.md", - "nanobot/skills/**/*.sh", -] - -[tool.hatch.build.targets.sdist] -include = [ - "nanobot/", - "bridge/", - "README.md", - "LICENSE", -] - -[tool.hatch.build.targets.wheel.force-include] -"bridge" = "nanobot/bridge" - -[tool.ruff] -line-length = 100 -target-version = "py311" - -[tool.ruff.lint] -select = ["E", "F", "I", "N", "W"] -ignore = ["E501"] +packages = ["beaver"] [tool.pytest.ini_options] -asyncio_mode = "auto" testpaths = ["tests"] diff --git a/app-instance/backend/sessions/state.db b/app-instance/backend/sessions/state.db new file mode 100644 index 0000000..8c09169 Binary files /dev/null and b/app-instance/backend/sessions/state.db differ diff --git a/app-instance/backend/tests/unit/test_imports.py b/app-instance/backend/tests/unit/test_imports.py new file mode 100644 index 0000000..1b80785 --- /dev/null +++ b/app-instance/backend/tests/unit/test_imports.py @@ -0,0 +1,40 @@ +from beaver.engine import AgentLoop +from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage +from beaver.interfaces.gateway.main import run_gateway +from beaver.interfaces.web.app import create_app +from beaver.interfaces.web.schemas import WebChatRequest, WebChatResponse + + +def test_agent_loop_boots() -> None: + loop = AgentLoop() + loaded = loop.boot() + assert "echo" in loaded.tools + assert "memory" in loaded.tools + assert "session_search" in loaded.tools + + +def test_web_app_factory() -> None: + app = create_app() + assert app.title == "Beaver Backend" + + +def test_gateway_entry_imports() -> None: + assert callable(run_gateway) + + +def test_message_bus_imports() -> None: + bus = MessageBus() + assert isinstance(bus, MessageBus) + assert InboundMessage(channel="test", content="hello").channel == "test" + assert OutboundMessage(channel="test", content="ok", session_id=None, finish_reason="stop").content == "ok" + + +def test_web_schema_imports() -> None: + assert WebChatRequest(message="hello").message == "hello" + assert WebChatResponse( + session_id="s", + run_id="r", + output_text="ok", + finish_reason="stop", + tool_iterations=0, + ).output_text == "ok" diff --git a/app-instance/backend/施工指南.md b/app-instance/backend/施工指南.md new file mode 100644 index 0000000..3f60571 --- /dev/null +++ b/app-instance/backend/施工指南.md @@ -0,0 +1,1370 @@ +# Beaver Backend 施工指南 + +这份文档不是蓝图,也不是迁移映射,而是“真正开始施工时怎么下手”的执行指南。 + +目标是:**按运行时主链路,一步一步把 `backend-old` 的能力迁进新的 `beaver` 后端,并且始终保证我们先打通主链,再扩外围。** + +--- + +## 1. 施工总原则 + +先把几条原则定死,否则很容易又回到旧项目那种“边写边散”的状态。 + +### 1.1 先打通主链,再补外围 + +不要一上来拆 Web,不要先做页面,不要先做渠道接入。 + +施工顺序必须是: + +1. 运行时主链 +2. memory / skills / tools +3. delegation / team +4. CLI / Web / channels / gateway + +### 1.2 先做最小可运行链路 + +第一阶段目标不是“全部功能迁完”,而是先让新 `beaver` 后端具备一条最小闭环: + +`input -> session -> context -> provider -> tool loop -> save turn -> output` + +只要这条链没通,后面的多 agent、Web、MCP、cron 都不应该大规模开工。 + +### 1.3 一次只收口一层边界 + +每一阶段必须回答三个问题: + +1. 本阶段新增了什么文件 +2. 本阶段替换了旧项目哪几个函数 +3. 本阶段结束后,能跑通什么能力 + +### 1.4 统一以 `beaver.engine.AgentLoop` 为唯一运行内核实现 + +这里说的不是“系统里只会存在一个 loop 实例”,而是: + +1. 整个后端只维护一套 `AgentLoop` 实现代码 +2. 可以同时存在多个 agent 运行实例 +3. 但这些实例都必须复用同一个 `beaver.engine.AgentLoop` +4. 它们的差异只能来自 profile、context、toolset、skills、permissions,而不是来自不同执行栈 + +后续所有 agent 角色都必须围绕这同一套内核装配: + +- CLI agent instance +- delegated local agent instance +- team member agent instance +- future A2A local specialist instance + +不允许再出现“CLI 一套 loop、delegation 一套 loop、team 一套 loop”的情况。 + +--- + +## 2. 从运行时视角看,系统到底怎么工作 + +我们先把最终运行时主链画出来。后续所有施工都围绕这条链拆。 + +### 2.1 目标主链 + +一次标准请求应该按下面顺序流动: + +1. `interfaces/*` + - CLI / Web / Gateway 收到输入 +2. `services/agent_service.py` + - 创建或复用 `AgentLoop` +3. `engine/session/*` + - 恢复 session 与历史消息 +4. `memory/curated/*` + - 读取 frozen snapshot +5. `skills/resolver/runtime.py` + - 决定本轮注入哪些 skills +6. `engine/context/builder.py` + - 拼装 system prompt + messages +7. `engine/providers/*` + - 调用模型 +8. `engine/loop.py` + - 解析模型输出、执行 tool、迭代下一轮 +9. `tools/*` + - 执行文件、shell、web、memory、session_search、spawn 等工具 +10. `engine/loop.py` + - 汇总最终 assistant 输出 +11. `engine/session/*` + - 写回本轮消息 +12. `engine/session/store.py` + `engine/session/search.py` + - 记录 transcript,供 `session_search`、resume、history 使用 +13. `interfaces/*` + - 把结果回传给用户 + +### 2.2 第一条必须先打通的链 + +真正施工时,不要直接从 message bus + async background task + WebSocket 开始。 + +第一条应该先打通的是: + +`process_direct(task) -> build context -> call provider -> execute tools -> save session -> return text` + +也就是说: + +1. 先做 direct run +2. 再做 bus run +3. 再做 Web / gateway + +这样复杂度最低,也最容易验证。 + +--- + +## 3. 施工顺序总览 + +后续建议严格按下面顺序施工。 + +### 阶段 0:运行时前置件 + +目标:把主链运行所需的基础模型和公共组件先补齐。 + +先做这些文件: + +1. `beaver/foundation/config/schema.py` +2. `beaver/foundation/config/loader.py` +3. `beaver/foundation/config/paths.py` +4. `beaver/foundation/events/messages.py` +5. `beaver/foundation/events/message_bus.py` +6. `beaver/foundation/events/process.py` +7. `beaver/foundation/models/run_result.py` +8. `beaver/foundation/utils/helpers.py` +9. `beaver/foundation/utils/llm_audit.py` + +主要迁移来源: + +1. `backend-old/nanobot/config/schema.py` +2. `backend-old/nanobot/config/loader.py` +3. `backend-old/nanobot/config/paths.py` +4. `backend-old/nanobot/bus/events.py` +5. `backend-old/nanobot/bus/queue.py` +6. `backend-old/nanobot/agent/process_events.py` +7. `backend-old/nanobot/agent/run_result.py` +8. `backend-old/nanobot/utils/helpers.py` +9. `backend-old/nanobot/llm_audit.py` + +完成标准: + +1. 新后端已经有稳定的 config / bus / event / result 基础件 +2. 后续 engine、tools、services 不再依赖旧 `nanobot.*` + +--- + +## 4. 第一施工阶段:先把单 agent 主链做出来 + +这是最关键的一阶段,也是整个项目的起点。 + +### 4.1 先做 session 层 + +先实现: + +1. `beaver/engine/session/models.py` +2. `beaver/engine/session/manager.py` +3. `beaver/engine/session/store.py` +4. `beaver/engine/session/search.py` + +直接参考旧文件: + +- `backend-old/nanobot/session/manager.py` + +额外参考: + +- `hermes-agent` 的 `hermes_state.py` +- `OpenHarness` 的 harness 分层思路 + +这里的目标不是简单把旧 `SessionManager` 搬过来,而是把 session 拆成 4 层: + +1. `models.py` + - 放 `SessionRecord`、`MessageRecord`、`SessionUsage` + - 只放数据结构,不放数据库逻辑 +2. `store.py` + - 放 SQLite 实现 + - 负责 `sessions/messages` 表、WAL、FTS5、写入与查询 +3. `search.py` + - 放 `list_sessions_rich()`、`search_messages()`、`resolve_session_id()` 这类检索逻辑 + - 明确这是 session 子系统的一部分,不再挂到 memory 下面 +4. `manager.py` + - 作为运行时门面 + - `AgentLoop`、`services`、`interfaces` 只优先依赖它,而不是直接操作 SQLite + +这四层的职责必须分开: + +1. `session` 保存完整会话过程与历史恢复 +2. `memory` 只保存 durable facts +3. `session_search` 只是 session transcript 的检索能力 +4. `skills` 保存稳定方法论 + +现有的: + +- `beaver/memory/search/transcript_store.py` + +要明确视为**临时过渡实现**。它是为了先让 MCP `session_search` tool 可用而建的,不是最终归宿。 +真正开工 session 层时,应把它的能力并回: + +1. `beaver/engine/session/store.py` +2. `beaver/engine/session/search.py` + +然后让: + +- `beaver/tools/builtins/session_search.py` + +改为直接依赖 `engine/session` 的 store / manager,而不是继续单独维护一套“memory search store”。 + +优先迁移的类和函数: + +1. 旧 `Session` -> 拆成 `SessionRecord` + conversation replay 相关模型 +2. 旧 `SessionManager` -> 改成 `SessionManager` 门面层 +3. `get_or_create` +4. `_load` +5. `save` +6. `get_history` + +但新 session 层必须新增这些 Hermes 风格能力: + +1. `ensure_session(session_id, source, model, parent_session_id=None)` +2. `append_message(session_id, role, content, tool_name=None, tool_calls=None, ...)` +3. `get_messages_as_conversation(session_id)` +4. `update_system_prompt(session_id, system_prompt)` +5. `update_usage(session_id, input_tokens, output_tokens, ...)` +6. `end_session(session_id, end_reason)` +7. `reopen_session(session_id)` +8. `get_session(session_id)` +9. `list_sessions_rich(limit, include_children=False)` +10. `search_messages(query, role_filter=None, limit=...)` +11. `resolve_session_id(session_id_or_prefix)` + +session schema 也要从第一天就按 Hermes 思路建好,而不是后补: + +1. `sessions` + - `id` + - `source` + - `model` + - `system_prompt` + - `parent_session_id` + - `started_at` + - `ended_at` + - `message_count` + - `token / cost / usage` 相关字段 +2. `messages` + - `session_id` + - `role` + - `content` + - `tool_name` + - `tool_calls` + - `timestamp` +3. `messages_fts` + - 用 FTS5 做全文搜索 + +这一步要支持 lineage: + +1. 正常 direct run 可以只有一个 root session +2. 后续 compression / resume / delegation / team member session 都通过 `parent_session_id` 挂到同一条会话链 +3. `session_search` 和 session browser 都要按 lineage 理解,而不是只看单个碎片 session + +### 4.1.1 session 层第一批施工顺序 + +按下面顺序落文件,不要反过来: + +1. `models.py` +2. `store.py` +3. `search.py` +4. `manager.py` + +原因: + +1. 先把数据结构和 SQLite 能力定住 +2. 再把搜索能力挂上 +3. 最后才让 `manager` 做统一门面 + +### 4.1.2 session 层第一批必须先跑通的函数 + +第一批不要贪多,先跑通这 8 个: + +1. `ensure_session` +2. `append_message` +3. `get_session` +4. `get_messages_as_conversation` +5. `update_system_prompt` +6. `list_sessions_rich` +7. `search_messages` +8. `close` + +只要这 8 个通了,`process_direct()`、history replay、`session_search` 就都有基础了。 + +### 4.1.3 session 层与 runtime 的接点 + +后续 `AgentLoop` 必须按下面方式使用 session 层: + +1. 请求进入时: + - `ensure_session(...)` +2. context 组装完成后: + - `update_system_prompt(...)` +3. 读取历史消息时: + - `get_messages_as_conversation(...)` +4. 每次 user / assistant / tool 产出后: + - `append_message(...)` +5. 会话结束或取消时: + - `end_session(...)` + +### 4.1.4 第一批 session 层不要做的事 + +不要一上来就做这些: + +1. 复杂 session compaction +2. team transcript 合并策略 +3. Web session API +4. gateway 专属优化 +5. 跨数据库抽象层 + +先把单机 SQLite + WAL + FTS5 跑通即可。 + +### 4.1.5 session 层如何一步步做 code review + +每次完成 `session` 子系统的一轮改动,不要直接看“能不能跑”,而是按下面顺序 review。 + +#### 第一步:先看职责是否串层 + +按文件检查: + +1. `models.py` + - 只能有数据结构与序列化/反序列化辅助 + - 不能出现 SQL + - 不能出现 runtime orchestration +2. `store.py` + - 只能负责 SQLite schema、写入、读取、事务 + - 不能写 `session_search` 的上层业务流程 +3. `search.py` + - 只能负责 browse / FTS / resolve 逻辑 + - 不能负责写入 +4. `manager.py` + - 只能做 facade + - 不要把复杂 SQL 又塞回 manager + +只要出现“某层开始顺手做别层的事”,这轮 review 就先不过。 + +#### 第二步:看 schema 是否支撑后续 runtime + +逐项检查 `sessions` / `messages` 表字段是否够用: + +1. `sessions` 是否有: + - `id` + - `source` + - `model` + - `system_prompt` + - `parent_session_id` + - `started_at` + - `last_active` + - `ended_at` + - `message_count` +2. `messages` 是否有: + - `session_id` + - `role` + - `content` + - `tool_name` + - `tool_calls` + - `timestamp` +3. 是否已经有 FTS5 +4. 是否已经有支持 lineage 的 `parent_session_id` + +这一步的核心问题是: + +“等后面接 `AgentLoop`、resume、delegation、session_search` 时,会不会缺字段?” + +#### 第三步:看写路径是否完整 + +按运行时顺序 review: + +1. `ensure_session()` + - 新 session 能不能被创建 +2. `append_message()` + - user / assistant / tool message 能不能都写入 + - tool_calls 是否正确序列化 +3. `update_system_prompt()` + - assembled prompt snapshot 能不能落盘 +4. `update_usage()` + - usage 是增量还是绝对覆盖,语义是否清楚 +5. `end_session()` / `reopen_session()` + - 生命周期状态是否闭环 + +这里只问一件事: + +“如果一轮对话完整跑完,session 数据是否真的闭环写全了?” + +#### 第四步:看读路径是否能支撑 prompt 组装 + +重点检查: + +1. `get_session()` +2. `get_messages_as_conversation()` +3. `get_history()` + +要问: + +1. provider replay 需要的字段有没有漏 +2. tool_calls 有没有被正确还原 +3. leading non-user trimming 是否合理 +4. 会不会把本地存储字段脏带进 prompt + +#### 第五步:看 search 是否真的能服务 `session_search` + +重点检查: + +1. `list_sessions_rich()` +2. `search_messages()` +3. `resolve_session_id()` + +要问: + +1. FTS query sanitization 是否足够 +2. recent mode 返回的信息够不够 UI / tool 用 +3. prefix resolve 是否会误命中多个 session +4. exclude_sources / role_filter 是否真的生效 + +#### 第六步:看并发与持久化风险 + +这里不要只看“代码风格”,要看数据安全: + +1. SQLite 是否开启了 WAL +2. 写事务是否明确 +3. `BEGIN IMMEDIATE` 是否正确 +4. `check_same_thread=False` 后有没有线程锁保护 +5. schema 初始化是否幂等 + +这一步问的是: + +“两个入口同时写 session 时,会不会炸?” + +#### 第七步:看兼容路径是不是临时且可收敛 + +现在的: + +- `beaver/memory/search/transcript_store.py` + +是兼容层。 + +review 时要明确: + +1. 它有没有新长逻辑 +2. 它是不是只是薄封装 +3. 后续是否可以安全删掉 + +兼容层一旦开始长业务逻辑,就说明架构又开始回退了。 + +#### 第八步:最后才看命名、注释、可读性 + +这一步最后看: + +1. 命名是否统一用 `session` 而不是混成 `memory/transcript/history` +2. 中文注释是否解释了设计意图,而不是重复代码 +3. manager / store / search 的边界是否一眼能看懂 + +#### 第九步:做最小行为验证 + +每轮 review 最后至少手跑这几条: + +1. 创建 session +2. 写入 user message +3. 写入 assistant message +4. 更新 system prompt +5. 读取 history +6. FTS 搜索关键词 +7. recent mode 浏览 session + +如果这 7 条没过,这轮 review 不算通过。 + +为什么先做它: + +因为没有 session,就没有: + +1. 历史消息窗口 +2. transcript 持久化 +3. session_search 的真实后端 +4. resume / history / lineage + +loop 无法闭环。 + +完成标准: + +1. 能创建 session +2. 能读取历史 +3. 能写回消息 +4. 能记录 assembled system prompt snapshot +5. 能用 FTS5 搜历史消息 +6. `session_search` 可以直接复用 session store + +### 4.2 再做 provider 契约层 + +先实现: + +1. `beaver/engine/providers/base.py` +2. `beaver/engine/providers/registry.py` +3. `beaver/engine/providers/factory.py` +4. `beaver/engine/providers/runtime.py` + +然后再迁最常用 provider: + +5. `beaver/engine/providers/custom.py` +6. `beaver/engine/providers/codex.py` +7. `beaver/engine/providers/litellm.py` +8. `beaver/engine/providers/anthropic.py` + +对应旧来源: + +1. `backend-old/nanobot/providers/base.py` +2. `backend-old/nanobot/providers/registry.py` +3. `backend-old/nanobot/providers/openai_codex_provider.py` +4. `backend-old/nanobot/providers/litellm_provider.py` +5. `backend-old/nanobot/providers/custom_provider.py` + +额外参考: + +1. `Hermes-agent` 的 provider runtime resolution +2. `Hermes-agent` 的 auxiliary routing +3. `Hermes-agent` 的 fallback model/provider +4. `OpenHarness` 的模块化边界 + +provider 层不要再做成“一个厂商一个世界”,而是要拆成 4 层: + +1. `base.py` + - 统一 provider 契约 + - 统一 `LLMResponse` / `ToolCallRequest` +2. `registry.py` + - 只放 provider 元数据与匹配规则 + - 不放网络请求逻辑 +3. `runtime.py` + - 做 Hermes 风格 runtime resolution + - 决定最终 `provider / model / api_base / api_mode / auth path` +4. `factory.py` + - 作为对 engine 暴露的唯一装配入口 + - 统一产出 `main / fallback / auxiliary` provider 组合 + +### 4.2.1 provider 层的目标结构 + +最终 provider 子系统应该是: + +1. `beaver/engine/providers/base.py` +2. `beaver/engine/providers/registry.py` +3. `beaver/engine/providers/runtime.py` +4. `beaver/engine/providers/factory.py` +5. `beaver/engine/providers/chain.py` +6. `beaver/engine/providers/custom.py` +7. `beaver/engine/providers/codex.py` +8. `beaver/engine/providers/litellm.py` +9. `beaver/engine/providers/anthropic.py` + +### 4.2.2 provider 不按厂商数扩类,而按 API path 收敛 + +实现原则: + +1. 大部分 OpenAI-compatible / LiteLLM-compatible provider 走 `litellm.py` +2. Anthropic 走 `anthropic.py` 的 native messages path +3. OpenAI Codex 走 `codex.py` 的 Responses path +4. 自定义 OpenAI-compatible endpoint 走 `custom.py` +5. embedding runtime 作为独立配置线存在,不再默认继承主聊天 provider 的 provider 语义 +6. 当前 embedding 只支持 OpenAI-compatible `/v1/embeddings` + +也就是说: + +1. provider registry 可以很多 +2. 但真正的执行路径只有少数几条 + +### 4.2.3 第一批必须先支持的 provider + +第一批先把这些 provider 的 runtime path 定住: + +1. `openai` +2. `anthropic` +3. `openrouter` +4. `openai_codex` +5. `custom` +6. `github_copilot` +7. `deepseek` +8. `gemini` + +第二批再补这些旧后端已有 provider: + +1. `aihubmix` +2. `siliconflow` +3. `volcengine` +4. `dashscope` +5. `zhipu` +6. `moonshot` +7. `minimax` +8. `vllm` +9. `groq` + +### 4.2.4 provider 层必须照 Hermes 做的能力 + +这几个能力要从第一天就在设计里留位置: + +1. `api_mode` + - `chat_completions` + - `anthropic_messages` + - `codex_responses` +2. `fallback_model` + - 主 provider/model 失败后切换备用 + - 由 `FallbackProviderChain` 统一执行 failover +3. `auxiliary routing` + - 主对话与辅助任务可用不同 provider/model + - 由 `resolve_auxiliary_runtime()` 做独立解析 +4. `OpenRouter provider routing` + - `sort` + - `only` + - `ignore` + - `order` + - `require_parameters` + - `data_collection` + +### 4.2.5 第一批 provider 施工顺序 + +按下面顺序落代码: + +1. `base.py` +2. `registry.py` +3. `runtime.py` +4. `factory.py` +5. `chain.py` +6. `custom.py` +7. `codex.py` +8. `litellm.py` +9. `anthropic.py` + +原因: + +1. 不先定 runtime resolution,后面 provider 实现会继续散 +2. 不先定 registry,factory 就会出现 if/else 污染 +3. `chain` 先把 fallback 行为从 `AgentLoop` 里拿走 +4. `custom` 和 `codex` 最容易先单独落地 +5. `litellm` 收口大多数 provider +6. `anthropic` 作为 native path 独立出来 + +### 4.2.6 第一批 provider 层必须先跑通的函数 + +第一批先跑通这些: + +1. `find_by_name` +2. `find_by_model` +3. `find_gateway` +4. `resolve_provider_runtime` +5. `resolve_fallback_runtime` +6. `resolve_auxiliary_runtime` +7. `make_provider_from_runtime` +8. `make_main_provider` +9. `make_fallback_provider` +10. `make_aux_provider` +11. `make_provider_bundle` +12. `FallbackProviderChain.chat()` +13. 至少一个 provider 的 `chat()` + +第一阶段不要求把所有 provider 全迁完,只要求先有一个能跑通主链的 provider。 + +建议先选: + +1. `OpenAICodexProvider` +2. 或 `CustomProvider` + +完成标准: + +1. `AgentLoop` 能拿到 provider +2. 能发出一次最小模型请求 +3. provider 解析不再散落在 CLI / Web / gateway +4. registry / runtime / factory 三层边界清楚 + +### 4.3 再做 context builder + +实现: + +1. `beaver/engine/context/builder.py` + +参考旧文件: + +- `backend-old/nanobot/agent/context.py` + +优先实现的函数: + +1. `build_system_prompt` +2. `build_messages` +3. `add_tool_result` +4. `add_assistant_message` + +这一版必须改掉的地方: + +1. 不再直接读取 live memory +2. 只注入 frozen snapshot +3. 给 skills 预留注入点 +4. 给 current session / channel metadata 预留注入点 + +完成标准: + +1. 能从 session history + frozen memory + skills 拼出 prompt +2. 输出结构稳定,后续便于测试 + +### 4.4 再做 tools 基础设施 + +实现: + +1. `beaver/tools/base.py` +2. `beaver/tools/registry/tool_registry.py` + +参考旧文件: + +1. `backend-old/nanobot/agent/tools/base.py` +2. `backend-old/nanobot/agent/tools/registry.py` + +然后先迁最小工具集: + +1. `beaver/tools/builtins/filesystem.py` +2. `beaver/tools/builtins/shell.py` +3. `beaver/tools/builtins/web.py` +4. `beaver/tools/builtins/message.py` +5. `beaver/tools/builtins/memory.py` +6. `beaver/tools/builtins/session_search.py` + +第一阶段可以暂时不迁: + +1. `spawn` +2. `cron` +3. `mcp wrapper` + +因为先要保证单 agent tool loop 可运行。 + +完成标准: + +1. registry 可以注册工具 +2. provider 返回 tool call 时可以找到并执行工具 +3. memory / session_search 已纳入统一工具集合 + +### 4.5 最后实现第一版 `AgentLoop` + +这是第一施工阶段的收口点。 + +实现: + +1. `beaver/engine/loader.py` +2. `beaver/engine/loop.py` + +参考旧文件: + +- `backend-old/nanobot/agent/loop.py` + +第一版必须先实现这些函数: + +1. `AgentLoop.__init__` +2. `boot` +3. `_set_tool_context` +4. `_process_message` +5. `_run_agent_loop` +6. `_save_turn` +7. `process_direct` +8. `run` +9. `stop` + +第一版暂时不要迁的逻辑: + +1. `_connect_mcp` +2. `reload_mcp_servers` +3. 复杂 background consolidation +4. team delegation + +因为现在 memory 体系已经不是旧的 `consolidate_memory()` 了,这些旧逻辑不能硬搬。 + +第一阶段验收方式: + +1. 用 CLI 或脚本创建一个 loop +2. 调 `process_direct("hello")` +3. 能返回模型回复 +4. 工具调用能生效 +5. session 能写回 + +只要这一条通了,新的 `beaver` runtime 就算正式开工成功。 + +--- + +## 5. 第二施工阶段:把 memory / skills 接进主链 + +第一阶段打通的是“能跑”;第二阶段打通的是“跑得像 Beaver”。 + +### 5.1 memory 接入主链 + +当前已经有: + +1. `beaver/memory/curated/store.py` +2. `beaver/memory/curated/snapshot.py` +3. `beaver/memory/search/transcript_store.py`(临时过渡实现) +4. `beaver/tools/builtins/memory.py` +5. `beaver/tools/builtins/session_search.py` + +现在要做的是把它们真正装进运行时: + +需要改的地方: + +1. `beaver/engine/loader.py` + - 初始化 `MemoryStore` + - `load_from_disk()` + - capture snapshot +2. `beaver/engine/context/builder.py` + - 注入 frozen snapshot +3. `beaver/engine/loop.py` + - 在 `_save_turn` 后把消息写进 session store +4. `beaver/tools/builtins/session_search.py` + - 改为依赖 `beaver/engine/session/search.py` +5. `beaver/memory/search/transcript_store.py` + - 在 session 层稳定后删除或保留为兼容薄封装,不再作为主实现 + +完成标准: + +1. `memory` tool 真正能写持久记忆 +2. 新 session 能读到上次写入的 frozen snapshot +3. `session_search` 能直接搜 session transcript + +### 5.2 skills 接入主链 + +实现: + +1. `beaver/skills/catalog/loader.py` +2. `beaver/skills/catalog/utils.py` +3. `beaver/skills/resolver/runtime.py` + +参考旧文件: + +- `backend-old/nanobot/agent/skills.py` + +先迁这些函数: + +1. `list_skills` +2. `get_skill_metadata` +3. `load_skill` +4. `load_skills_for_context` +5. `build_skills_summary` +6. `get_always_skills` + +接入点: + +1. `engine/loader.py` +2. `engine/context/builder.py` +3. `engine/loop.py` + +第二阶段要求做到: + +1. 主 agent 能按上下文注入 skills +2. skills 不只是文档目录,而是运行时上下文的一部分 +3. 激活后的 skill 正文按 Hermes 风格走显式消息注入,而不是长期塞进 system prompt +4. skill 的选择不再由 AgentLoop 内部硬编码完成,而是交给外置 `SkillAssembler` +5. `SkillAssembler` 采用最直接的 LLM 选择器: + - 输入 task description + - 输入候选 skill 摘要 + - 先用 embedding 做语义召回 + - 输出应该激活的 skills +6. embedding 配置通过 provider bundle 的独立 `embedding runtime` 传入;若没有显式 embedding 配置,则只有主链本身是 OpenAI-compatible 时才允许继承 `api_base/api_key` + +--- + +## 6. 第三施工阶段:把 direct run 扩成标准 runtime + +当 direct run 已经稳定后,再把它扩成“完整的运行时内核”。 + +这一阶段的核心思想先明确为两句: + +1. `Session = Durable Memory + Event Source` +2. `Harness = Stateless Orchestrator` + +也就是说,后面的 Beaver 不应再把任务进度、运行中间态、恢复点藏在进程内对象里,而应尽量写回外部 Session。 + +### 6.0 这一阶段的目标架构 + +这一阶段要把当前的“最小单 agent 主链”推进成下面这种结构: + +1. `Session` + - 是外部持久化记忆 + - 是唯一事实来源 + - 本质上是 append-only event stream +2. `Harness` + - 只负责编排 + - 自身不持有不可恢复状态 + - 崩溃后可由新实例读取 Session 接管 +3. `ContextBuilder` + - 不再直接依赖进程内状态 + - 只从 Session / curated memory / skills 中提取当前需要的上下文 + +这一步不是要一口气做完 fork / rewind / checkpoint 全套系统,而是先把“Session-first, Stateless Harness”这条主线立住。 + +### 6.1 第一步:先把 Session 升级为事件源模型 + +这是第六阶段真正的第一步,也是后续 runtime 生命周期的基础。 + +目标: + +1. 让 `Session` 不只是聊天记录表,而是运行事件源 +2. 让 `AgentLoop` 不再依赖进程内隐式状态来判断“任务做到哪了” +3. 让新的 Harness 实例理论上可以只靠 Session 恢复运行现场 + +第一步先做这些文件: + +1. `beaver/engine/session/models.py` +2. `beaver/engine/session/store.py` +3. `beaver/engine/session/manager.py` +4. `beaver/engine/loop.py` +5. `beaver/engine/context/builder.py` +6. `beaver/engine/loader.py` + +#### 6.1.1 具体怎么做 + +先在 session 层引入“事件优先”的视角: + +1. 保留现有 `sessions` 表 + - 它继续承担 projection / summary row 的角色 + - 例如 `last_active`、`message_count`、`preview`、累计 usage +2. 强化 `messages` 表的事件语义 + - 它不只是聊天记录 + - 而是当前阶段的主事件流 +3. 给事件加清晰类型边界 + - `user` + - `assistant` + - `tool` + - 后续可扩 `system_event` / `checkpoint_event` / `run_event` + +然后在 `AgentLoop.process_direct()` 中,明确把运行过程拆成“事件追加”: + +1. `session_started/ensured` +2. `run_started` +3. `system_prompt_snapshotted` +4. `user_message_added` +5. `assistant_message_added` +6. `tool_call_requested` +7. `tool_result_recorded` +8. `run_failed` 或 `run_completed` + +并且每次 run 都要带独立 `run_id`,这样同一个 session 内的多次运行才能被切开。 + +注意: + +第一步不一定要真的新建一张 `session_events` 表,先把现有 `messages` 作为主事件流用起来也可以。 +关键不是表名,而是: + +1. 运行进度要能从外部事件重建 +2. 不依赖进程内变量才能知道“上一步发生了什么” + +#### 6.1.2 这一小步里具体要改哪些函数 + +优先改这些函数: + +1. `SessionStore.append_message()` + - 明确它承担事件追加语义 +2. `SessionManager.get_history()` + - 不只是“取最近聊天记录” + - 而是“从事件流里切一段 provider 需要的上下文” +3. `SessionManager.get_run_event_records()` + - 能按 `run_id` 读取某一次运行的事件片段 +4. `SessionManager.list_run_ids()` + - 能发现当前 session 内有哪些 run +5. `AgentLoop.process_direct()` + - 继续保留 direct run 入口 + - 但内部按事件阶段组织代码 +6. `ContextBuilder.build_messages()` + - 明确消费的是“上游裁剪后的事件片段” + - 而不是默认依赖进程内连续状态 + +#### 6.1.3 第一步完成后的结果 + +这一小步做完后,应达到: + +1. Session 已经是当前运行事实的主要来源 +2. AgentLoop 即使重建实例,也能读出: + - 当前 session 里有哪些 run + - 某一次 run 的起点和结束点 + - 当前历史消息 + - 上一次 system prompt snapshot + - 工具执行痕迹 + - 失败点 +3. 后续继续做: + - `run()` + - `stop()` + - `close()/shutdown()` + - fork / rewind / checkpoint + 才不会回到“全靠进程内状态续命” + +### 6.2 第二步:把 runtime 生命周期协议补齐 + +当前已完成的最小骨架: + +1. `EngineLoader` 返回的 `EngineLoadResult` 已经具备 runtime 容器语义 +2. `EngineLoadResult.close()` 已能统一关闭已登记的 closeables +3. `AgentLoop.boot()/close()` 已建立成对协议 +4. `AgentService.close()/shutdown()` 已可作为接口层统一释放入口 +5. `AgentLoop.run()/stop()/submit_direct()` 已形成最小运行循环 +6. `AgentService.start()/stop()/submit_direct()` 已可包装该运行循环 + +只有在 6.1 稳住后,才开始补统一生命周期: + +1. 扩 `closeables / shutdown hooks` +2. 明确 provider/client 等更多资源的释放协议 +3. 再补更复杂的 bus / worker / 调度语义 + +这一步的目标: + +1. 明确谁创建 runtime +2. 明确谁拥有 runtime +3. 明确谁负责释放 session/provider/client 等资源 + +这一阶段的 lifecycle 语义也已经定死: + +1. `start()`:让一个 `AgentLoop` 实例进入运行模式 +2. 运行模式下:所有外部任务只能走 `submit_direct()` +3. 运行模式下:不允许外部再直接调用 `process_direct()` +4. `stop()`:instance-scoped,只停止当前这个 `AgentLoop` 实例 +5. `stop()` 不是 session-scoped,也不是 platform-scoped +6. `stop()` 调用后拒绝新任务,已入队任务收尾退出 +7. `stop()` / `shutdown()` 应支持 graceful timeout,必要时允许 force cancel +8. `close()`:只有在实例已停止后才能释放 runtime 资源 + +### 6.2.1 Web / Gateway 现在如何接这套 lifecycle + +这一层现在已经开始落成真正的宿主层,而不是只停留在文档占位: + +1. `beaver/interfaces/web/app.py` + - FastAPI lifespan 启动时: + - 创建或接收 `AgentService` + - 如果 Web 自己创建 service,则 `await service.start()` + - Web 层现在已经有最小正式 schema: + - `WebChatRequest` + - `WebChatResponse` + - `WebStatusResponse` + - Web 请求处理时: + - 用结构化 schema 校验输入 + - 只允许走 `await service.submit_direct(...)` + - 将常见 runtime / config 错误收成明确的 HTTP 层错误 + - 外部注入但尚未进入 running mode 的 service,返回 `503` + - `/api/ping` + - 返回 `status/running/mode` + - 不会为了 health check 额外 boot runtime + - app 关闭时: + - 如果 Web 自己创建 service,则 `await service.shutdown(timeout_seconds=5.0, force=True)` + - 如果 Web 自己接管 lifecycle 且 `start()` 失败: + - 立即 `close()` 做 startup cleanup + +2. `beaver/interfaces/gateway/main.py` + - `run_gateway()` 启动时: + - 如果 gateway 自己创建 service,则 `await service.start()` + - 持有最小 `MessageBus` + - 常驻消费 `bus.inbound` + - 调 `await service.submit_direct(...)` + - 将结果写回 `bus.outbound` + - 同时等待 `stop_event` + - 停机时: + - 先尝试 `await service.shutdown(timeout_seconds=5.0, force=True)` + - 再等待 bridge 协程收尾;必要时取消 bridge + - 如果 gateway 自己接管 lifecycle 且 `start()` 失败: + - 立即 `close()` 做 startup cleanup + - 未处理完的 inbound: + - 不再静默丢弃 + - 会被冲刷成结构化 outbound error + +3. `beaver/foundation/events/message_bus.py` + - 已经补了最小: + - `MessageBus` + - `InboundMessage` + - `OutboundMessage` + - 当前只做双队列桥接: + - `inbound` + - `outbound` + - 还没有 broker / topic routing / retry / persistence + +所以现在已经明确: + +1. Web / Gateway 属于宿主层 +2. 它们不直接 new `AgentLoop` 或绕过运行模式 +3. 它们复用: + - `start()` + - `submit_direct()` + - `stop()` + - `shutdown()` +4. ownership 语义: + - 自己创建的 `AgentService`:自己负责 lifecycle + - 外部注入的 `AgentService`:默认不自动 start/shutdown,除非显式要求接管 +5. gateway 已经从“只会常驻等待”推进到“最小消息桥接层” + - external inbound message + - `MessageBus.inbound` + - `service.submit_direct(...)` + - `MessageBus.outbound` + +但这一阶段还没做: + +1. channels adapter +2. realtime streaming +3. platform-level supervisor +4. 更复杂的 bus 语义(retry / routing / persistence) + +### 6.3 第三步:回填 bus 模式 + +实现: + +1. `beaver/foundation/events/message_bus.py` +2. `beaver/engine/loop.py::run` + +参考旧逻辑: + +- `backend-old/nanobot/agent/loop.py` + +需要补的函数: + +1. 从 inbound 读取消息 +2. 调用 `_process_message` +3. 发布 outbound + +注意: + +只有在 `process_direct()` 稳定,并且 6.1 / 6.2 已经把 Session-first + lifecycle 骨架立住后,才做 `run()` 的长循环版本。 + +### 6.4 单 agent lifecycle 如何扩展到 team + +这里也先把关系写清楚,避免后面 team 层重走弯路: + +1. team 不会共用一个 `AgentLoop` 来跑所有成员 +2. 每个 team member 都应该是一个独立的 `AgentService / AgentLoop` 实例 +3. 每个 member 自己有: + - `start()` + - `submit_direct()` + - `stop()` + - `close()` +4. team coordinator 在上层管理这些 member 实例,而不是绕开它们的 lifecycle +5. 因此当前这套 lifecycle 首先是 member-level lifecycle,后面才能往 team-level / platform-level 扩 + +--- + +## 7. 第四施工阶段:加入 delegation 和单机 subagent + +现在才开始动 multi-agent。 + +### 7.1 先做 registry + +实现: + +1. `beaver/coordinator/registry/models.py` +2. `beaver/coordinator/registry/workspace_store.py` +3. `beaver/coordinator/registry/agent_registry.py` +4. `beaver/coordinator/registry/local_subagent_store.py` + +来源: + +1. `backend-old/nanobot/agent/agent_registry.py` +2. `backend-old/nanobot/agent/subagents.py` + +### 7.2 再做本地单机 delegation + +实现: + +1. `beaver/engine/runtime/local_runner.py` +2. `beaver/coordinator/delegation/manager.py` +3. `beaver/coordinator/delegation/events.py` +4. `beaver/coordinator/delegation/announcement.py` + +来源: + +1. `backend-old/nanobot/agent/subagent.py` +2. `backend-old/nanobot/agent/delegation.py` + +这一阶段的范围: + +1. 先支持 `spawn_subagent` +2. 先支持 local delegation +3. 暂不急着接 swarms team + +完成标准: + +1. 主 agent 可以调用子 agent +2. 子 agent 与主 agent 复用同一个 `AgentLoop` +3. 只是 profile / toolset / prompt context 不同 + +--- + +## 8. 第五施工阶段:接回群组讨论和流程化 team + +这阶段才开始回收旧 `agent_team` 和 `swarms bridge` 的成果。 + +### 8.1 先做 team types / planner / policy + +实现: + +1. `beaver/coordinator/team/types.py` +2. `beaver/coordinator/planner/swarms.py` +3. `beaver/coordinator/backends/swarms/policy.py` + +### 8.2 再做 bridge / adapter + +实现: + +1. `beaver/coordinator/backends/swarms/bridge.py` +2. `beaver/coordinator/backends/swarms/adapter.py` +3. `beaver/coordinator/backends/swarms/runtime.py` + +注意: + +1. 不再引入 `third_party/` +2. 不再允许旧式 `sys.path` 注入 +3. `swarms` 必须作为 adapter/backend,而不是平台内部结构 + +### 8.3 最后做 orchestrator + +实现: + +1. `beaver/coordinator/team/orchestrator.py` +2. `beaver/coordinator/team/target_resolver.py` +3. `beaver/coordinator/team/provisioning.py` + +这一阶段完成后,才算真正恢复: + +1. 群组讨论 +2. 流程化 team +3. skills 约束下的 multi-agent 执行 + +--- + +## 9. 第六施工阶段:最后才拆入口层 + +这时候再拆 CLI / Web,成本最低,也最稳。 + +### 9.1 CLI + +从: + +- `backend-old/nanobot/cli/commands.py` + +拆到: + +1. `beaver/interfaces/cli/main.py` +2. `beaver/interfaces/cli/commands/agent.py` +3. `beaver/interfaces/cli/commands/web.py` +4. `beaver/interfaces/cli/commands/cron.py` +5. `beaver/interfaces/cli/commands/providers.py` +6. `beaver/interfaces/cli/tty.py` + +### 9.2 Web + +从: + +- `backend-old/nanobot/web/server.py` + +拆到: + +1. `beaver/interfaces/web/app.py` +2. `beaver/interfaces/web/deps.py` +3. `beaver/interfaces/web/realtime.py` +4. `beaver/interfaces/web/auth.py` +5. `beaver/interfaces/web/routes/*.py` +6. `beaver/interfaces/web/schemas/*.py` + +只有在 engine / services 已稳定后,Web 才值得拆。 + +--- + +## 10. 第一批真正建议开工的文件 + +如果现在立刻开始干,建议按下面顺序提交,不要跳。 + +### 提交 1:foundation 前置件 + +文件: + +1. `beaver/foundation/config/schema.py` +2. `beaver/foundation/config/loader.py` +3. `beaver/foundation/config/paths.py` +4. `beaver/foundation/events/messages.py` +5. `beaver/foundation/events/message_bus.py` +6. `beaver/foundation/events/process.py` +7. `beaver/foundation/models/run_result.py` +8. `beaver/foundation/utils/helpers.py` + +### 提交 2:session + provider 基础 + +文件: + +1. `beaver/engine/session/models.py` +2. `beaver/engine/session/manager.py` +3. `beaver/engine/session/store.py` +4. `beaver/engine/session/search.py` +3. `beaver/engine/providers/base.py` +4. `beaver/engine/providers/registry.py` +5. `beaver/engine/providers/factory.py` +6. 至少一个真实 provider + +### 提交 3:context + tool registry + +文件: + +1. `beaver/engine/context/builder.py` +2. `beaver/tools/base.py` +3. `beaver/tools/registry/tool_registry.py` +4. 最小 builtins + +### 提交 4:第一版 AgentLoop + +文件: + +1. `beaver/engine/loader.py` +2. `beaver/engine/loop.py` +3. `beaver/services/agent_service.py` + +目标: + +1. 跑通 `process_direct` + +### 提交 5:memory / skills 正式接入 + +文件: + +1. `beaver/memory/*` +2. `beaver/skills/catalog/*` +3. `beaver/skills/resolver/runtime.py` +4. `engine` 接入改动 + +--- + +## 11. 第一阶段验收清单 + +在开始 Web / delegation 之前,必须满足以下条件: + +1. `beaver.interfaces.cli.main` 能启动一个最小 loop +2. `AgentLoop.process_direct()` 可用 +3. session 历史能读写 +4. provider 能完成一次普通回复 +5. provider 能触发工具调用 +6. `memory` tool 可写 +7. 新 session 能读到 frozen snapshot +8. `session_search` 能直接搜 session transcript +9. skills 能注入到 system prompt + +如果这 9 条没过,不要进入下一阶段。 + +--- + +## 12. 施工时要避免的错误 + +### 12.1 不要先拆 Web + +`web/server.py` 很大,但它不是第一施工点。 +先拆它,只会让你在 engine 还没稳的时候同时维护两套未完成装配。 + +### 12.2 不要先做 team orchestration + +multi-agent 很吸引人,但没有稳定的单 agent runtime,team 层只会把问题放大。 + +### 12.3 不要把旧 memory consolidation 直接搬过来 + +新 memory 基线已经确定是 Hermes 风格: + +1. CRUD memory tool +2. frozen snapshot +3. session_search + +所以旧的 `_consolidate_memory()` 路径不能原样迁。 + +### 12.4 不要在新 backend 中继续扩散 `nanobot` 命名 + +允许在迁移说明文档里引用旧路径,但新代码文件、类名、导出都必须收敛为 `beaver`。 + +--- + +## 13. 一句话施工结论 + +**从 `engine/session -> providers -> context -> tools -> AgentLoop.process_direct()` 这条最小运行时主链开始施工。** + +先把单 agent 运行内核打通,再把 memory / skills 接进去,再做 delegation / team,最后才拆 CLI / Web。 diff --git a/app-instance/backend/移植指南.md b/app-instance/backend/移植指南.md new file mode 100644 index 0000000..0629517 --- /dev/null +++ b/app-instance/backend/移植指南.md @@ -0,0 +1,350 @@ +# backend-old -> backend 可用移植指南 + +这份文档描述的是:从 `app-instance/backend-old` 迁到新的 `app-instance/backend` 时,哪些 Python 代码是可用的、应该放到新目录的哪里、哪些可以直接迁、哪些必须拆分后迁。 + +本文默认遵守以下前提: + +1. 新后端统一使用 `beaver` 命名。 +2. 所有 agent 最终都复用同一套 `beaver.engine.AgentLoop`。 +3. 新代码按当前新目录落点来放,不再向 `nanobot/` 回写。 +4. 不保留 `third_party/`。 +5. memory 设计以 `hermes-agent` 为基线:统一 CRUD memory tool + frozen snapshot + session_search。 + +## 1. 迁移范围 + +本文覆盖的是 `backend-old` 中的自有 Python 源码: + +- `backend-old/nanobot/**/*.py` +- `backend-old/nanobot/llm_audit.py` + +本文明确不覆盖: + +- `.venv/` +- `.pytest_cache/` +- `.ruff_cache/` +- `third_party/` +- `bridge/` 里的 Node 代码 + +## 2. 迁移判定说明 + +| 判定 | 含义 | +| --- | --- | +| `可直接迁移` | 改导入路径、命名后可基本原样落到新位置 | +| `小幅重构` | 主体逻辑可复用,但要改依赖注入、类型名或路径 | +| `拆分迁移` | 旧文件职责过大,必须拆成多个新文件 | +| `重写迁移` | 只保留行为目标,不建议原样搬代码 | +| `不迁移` | 不进入新后端 | + +## 3. 总体迁移顺序 + +建议按下面顺序迁: + +1. `foundation/config + foundation/events + foundation/models + foundation/utils` +2. `skills + plugins` +3. `memory(curated + session_search baseline)` +4. `engine/session + engine/providers + engine/context + engine/loop` +5. `tools` +6. `coordinator` +7. `interfaces/web + interfaces/cli + interfaces/channels + services` +8. `integrations/a2a + integrations/outlook + integrations/authz + integrations/mcp` + +原因: + +1. `engine`、`coordinator`、`interfaces` 都依赖 `config`、`events`、`models`。 +2. `skills` 和 `memory` 必须先定契约,因为 system prompt 注入、memory tool、session_search 都会反向约束 engine。 +3. `web/server.py`、`cli/commands.py` 只有在服务层和内核层稳定后才值得拆。 + +## 4. 包初始化文件如何处理 + +下面这些旧 `__init__.py` 不建议原样迁移,只保留“最小 re-export”或空包文件: + +- `nanobot/__init__.py` +- `nanobot/a2a/__init__.py` +- `nanobot/agent/__init__.py` +- `nanobot/agent_team/__init__.py` +- `nanobot/authz/__init__.py` +- `nanobot/bus/__init__.py` +- `nanobot/channels/__init__.py` +- `nanobot/cli/__init__.py` +- `nanobot/config/__init__.py` +- `nanobot/cron/__init__.py` +- `nanobot/heartbeat/__init__.py` +- `nanobot/providers/__init__.py` +- `nanobot/session/__init__.py` +- `nanobot/templates/__init__.py` +- `nanobot/templates/memory/__init__.py` +- `nanobot/utils/__init__.py` +- `nanobot/web/__init__.py` + +统一处理规则: + +1. 不复制旧的 lazy import / `__getattr__` 设计。 +2. 目标文件稳定后,再在对应 `beaver/*/__init__.py` 里做显式导出。 + +## 5. Foundation 层迁移映射 + +| 旧文件 | 关键类/函数 | 新位置 | 判定 | 说明 | +| --- | --- | --- | --- | --- | +| `nanobot/config/schema.py` | `Config`, `AgentDefaults`, `AgentsConfig`, `ProviderConfig`, `ProvidersConfig`, `ToolsConfig`, `ChannelsConfig`, `AuthzConfig`, `BackendIdentityConfig` | `beaver/foundation/config/schema.py` | `小幅重构` | 主体模型可迁;`Config._match_provider/get_provider*` 这类 provider 解析逻辑移到 `beaver/engine/providers/factory.py`。 | +| `nanobot/config/loader.py` | `get_config_path`, `get_data_dir`, `load_config`, `save_config`, `_migrate_config` | `beaver/foundation/config/loader.py` | `可直接迁移` | 迁移时把默认路径和 `beaver` 命名改掉。 | +| `nanobot/config/paths.py` | `get_data_dir`, `get_media_dir` | `beaver/foundation/config/paths.py` | `可直接迁移` | 纯 path helper。 | +| `nanobot/utils/helpers.py` | `ensure_dir`, `get_workspace_path`, `get_sessions_path`, `get_skills_path`, `timestamp`, `truncate_string`, `safe_filename`, `parse_session_key` | `beaver/foundation/utils/helpers.py` | `可直接迁移` | 纯工具函数,直接复用。 | +| `nanobot/bus/events.py` | `InboundMessage`, `OutboundMessage` | `beaver/foundation/events/messages.py` | `可直接迁移` | 建议作为全局消息模型。 | +| `nanobot/bus/queue.py` | `MessageBus` | `beaver/foundation/events/message_bus.py` | `小幅重构` | 类本身可迁,但后续由 `services` 和 `interfaces/gateway` 注入。 | +| `nanobot/agent/process_events.py` | `new_run_id`, `utc_now_iso`, `process_event_sink`, `process_run_context`, `current_process_run_id`, `has_process_event_sink`, `emit_process_event` | `beaver/foundation/events/process.py` | `可直接迁移` | 这是全局过程事件层,应该上提。 | +| `nanobot/agent/run_result.py` | `normalize_summary_text`, `contains_placeholder_summary`, `has_meaningful_summary`, `AgentRunResult` | `beaver/foundation/models/run_result.py` | `可直接迁移` | 给 engine、coordinator、services 共享。 | +| `nanobot/cron/types.py` | `CronSchedule`, `CronPayload`, `CronAction`, `CronExecutionResult`, `CronJobState`, `CronJob`, `CronStore` | `beaver/foundation/models/cron.py` | `可直接迁移` | 纯类型定义。 | +| `nanobot/llm_audit.py` | `write_llm_audit_event`, `redact_mapping`, `summarize_messages`, `summarize_tool_calls`, `summarize_tools`, `summarize_exception` | `beaver/foundation/utils/llm_audit.py` | `可直接迁移` | 审计逻辑不应挂在旧根路径。 | + +## 6. Engine 层迁移映射 + +| 旧文件 | 关键类/函数 | 新位置 | 判定 | 说明 | +| --- | --- | --- | --- | --- | +| `nanobot/session/manager.py` | `Session`, `SessionManager` | `beaver/engine/session/models.py`, `beaver/engine/session/manager.py` | `可直接迁移` | `Session` 和 `SessionManager` 建议拆开。 | +| `nanobot/agent/context.py` | `ContextBuilder.build_system_prompt`, `build_messages`, `add_tool_result`, `add_assistant_message` | `beaver/engine/context/builder.py` | `小幅重构` | 需要改成只注入 frozen memory snapshot,而不是直接读 live memory。 | +| `nanobot/agent/loop.py` | `AgentLoop` 全类 | `beaver/engine/loop.py` | `拆分迁移` | 旧文件过载,不能整块搬。 | +| `nanobot/agent/memory.py` | `MemoryStore.read_long_term`, `write_long_term`, `append_history`, `get_memory_context`, `consolidate` | `beaver/memory/curated/store.py`, `beaver/memory/curated/snapshot.py`, `beaver/memory/search/transcript_store.py` | `重写迁移` | 新标准不再沿用旧 `consolidate()` 模型;按 Hermes 改成 CRUD memory + frozen snapshot + session transcript search。 | +| `nanobot/providers/base.py` | `ToolCallRequest`, `LLMResponse`, `LLMProvider` | `beaver/engine/providers/base.py` | `可直接迁移` | provider 契约层。 | +| `nanobot/providers/registry.py` | `ProviderSpec`, `find_by_model`, `find_gateway`, `find_by_name` | `beaver/engine/providers/registry.py` | `可直接迁移` | registry 自包含度高。 | +| `nanobot/providers/litellm_provider.py` | `LiteLLMProvider` | `beaver/engine/providers/litellm.py` | `小幅重构` | 主要改导入路径和 `Config` 依赖。 | +| `nanobot/providers/openai_codex_provider.py` | `OpenAICodexProvider`, `_convert_messages`, `_consume_sse`, `_friendly_error` | `beaver/engine/providers/codex.py` | `小幅重构` | 主体可迁。 | +| `nanobot/providers/custom_provider.py` | `CustomProvider` | `beaver/engine/providers/custom.py` | `可直接迁移` | 文件小,直接迁。 | +| `nanobot/providers/transcription.py` | `GroqTranscriptionProvider` | `beaver/engine/providers/transcription.py` | `可直接迁移` | 辅助 provider,不阻塞主线。 | +| `nanobot/agent/subagent.py` | `SubagentManager.run_local_task`, `_build_local_tools`, `_build_subagent_prompt`, `_strip_think`, `_tool_hint` | `beaver/engine/runtime/local_runner.py` | `重写迁移` | 新架构下不应保留 `SubagentManager` 这个名字;只抽出本地 agent 运行能力。 | +| `nanobot/agent/subagents.py` | `normalize_subagent_id`, `SubagentSpec`, `LocalSubagentStore` | `beaver/coordinator/registry/local_subagent_store.py` | `小幅重构` | 数据结构可复用,但要切到 `beaver` 命名和新 registry。 | + +### 6.1 `agent/loop.py` 函数级拆分 + +旧 `nanobot/agent/loop.py` 应这样拆: + +| 旧函数 | 新位置 | +| --- | --- | +| `AgentLoop.__init__` | `beaver/engine/loop.py` | +| `apply_runtime_config` | `beaver/engine/loader.py` | +| `_register_default_tools` | `beaver/engine/loader.py` + `beaver/tools/registry/tool_registry.py` | +| `_connect_mcp`, `_clear_mcp_tools`, `reload_mcp_servers`, `get_mcp_servers_view` | `beaver/engine/runtime/mcp_runtime.py` | +| `_set_tool_context` | `beaver/engine/loop.py` | +| `_build_skills_loader` | `beaver/skills/resolver/runtime.py` | +| `_run_agent_loop`, `_process_message`, `_save_turn`, `process_system_announcement`, `process_direct` | `beaver/engine/loop.py` | +| `_get_consolidation_lock`, `_prune_consolidation_lock`, `_consolidate_memory` | `不直接迁移;改成 beaver/tools/builtins/memory.py + beaver/memory/curated/* + beaver/memory/search/*` | +| `run`, `stop`, `close_mcp` | `beaver/engine/loop.py` | + +## 7. Tools 层迁移映射 + +| 旧文件 | 关键类/函数 | 新位置 | 判定 | 说明 | +| --- | --- | --- | --- | --- | +| `nanobot/agent/tools/base.py` | `Tool`, `validate_params`, `to_schema` | `beaver/tools/base.py` | `可直接迁移` | 工具基类。 | +| `nanobot/agent/tools/registry.py` | `ToolRegistry` | `beaver/tools/registry/tool_registry.py` | `可直接迁移` | registry 逻辑自包含。 | +| `nanobot/agent/tools/filesystem.py` | `ReadFileTool`, `WriteFileTool`, `EditFileTool`, `ListDirTool` | `beaver/tools/builtins/filesystem.py` | `小幅重构` | 路径保护规则建议进一步抽到 `beaver/permissions/guards/filesystem.py`。 | +| `nanobot/agent/tools/shell.py` | `ExecTool`, `_guard_command`, `_guard_protected_paths` | `beaver/tools/builtins/shell.py` | `小幅重构` | 命令保护逻辑建议下沉到 `permissions/guards/shell.py`。 | +| `nanobot/agent/tools/web.py` | `WebSearchTool`, `WebFetchTool` | `beaver/tools/builtins/web.py` | `可直接迁移` | 改导入路径即可。 | +| `nanobot/agent/tools/message.py` | `MessageTool`, `set_context`, `set_send_callback`, `start_turn` | `beaver/tools/builtins/message.py` | `可直接迁移` | 保持 tool 形态。 | +| `nanobot/agent/tools/spawn.py` | `DelegationTool`, `SpawnSubagentTool`, `SpawnAgentTeamTool`, `NestedDelegateTool` | `beaver/tools/builtins/spawn.py` | `小幅重构` | tool 壳可留;执行全部接 `beaver/coordinator/delegation/manager.py`。 | +| `nanobot/agent/tools/cron.py` | `CronTool` | `beaver/tools/builtins/cron.py` | `可直接迁移` | 与 `CronService` 对接即可。 | +| `nanobot/agent/tools/cron_action.py` | `CronActionTool` | `beaver/tools/builtins/cron.py` | `小幅重构` | 合并成同一 cron 工具模块更合理。 | +| `nanobot/agent/tools/mcp.py` | `MCPToolWrapper`, `connect_mcp_servers`, `_describe_mcp_exception` | `beaver/tools/mcp/wrapper.py`, `beaver/tools/mcp/connect.py` | `小幅重构` | 连接逻辑和 wrapper 分开。 | +| `无旧文件一一对应(以 Hermes 为准新增)` | `memory_tool(action, target, content, old_text)` | `beaver/tools/builtins/memory.py` | `新增实现` | 统一 memory CRUD 工具,优先级高于旧 `MemoryStore.consolidate()` 逻辑。 | +| `无旧文件一一对应(以 Hermes 为准新增)` | `session_search(query, role_filter, limit)` | `beaver/tools/builtins/session_search.py` | `新增实现` | 历史会话检索不再靠把大量过程细节塞进 memory。 | + +## 8. Skills、Plugins、Memory 层迁移映射 + +| 旧文件 | 关键类/函数 | 新位置 | 判定 | 说明 | +| --- | --- | --- | --- | --- | +| `nanobot/agent/skills.py` | `SkillsLoader.list_skills`, `load_skill`, `load_skills_for_context`, `build_skills_summary`, `get_always_skills`, `get_skill_metadata`, `get_skill_agent_cards`, `list_skill_agent_cards` | `beaver/skills/catalog/loader.py`, `beaver/skills/resolver/runtime.py` | `拆分迁移` | catalog 负责扫描/索引/元数据;resolver 负责运行时注入。 | +| `nanobot/agent/skill_reviews.py` | `SkillReviewManager` | `beaver/skills/reviews/manager.py` | `小幅重构` | ZIP 解包、安全检查、review 状态管理都能复用。 | +| `nanobot/agent/plugins.py` | `PluginAgent`, `PluginCommand`, `Plugin`, `PluginLoader` | `beaver/plugins/models.py`, `beaver/plugins/loader.py`, `beaver/plugins/registry.py` | `小幅重构` | 这是最适合先迁的一批。 | +| `nanobot/agent/marketplace.py` | `MarketplaceEntry`, `MarketplacePluginInfo`, `MarketplaceManager` | `beaver/plugins/marketplace.py` | `可直接迁移` | 市场逻辑相对独立。 | +| `nanobot/agent_team/memory.py` | `ProcedureMemory`, `RunMemory`, `task_tokens`, `similarity_score`, `clip_confidence` | `beaver/memory/procedures/procedure_memory.py`, `beaver/memory/runs/run_memory.py` | `小幅重构` | 这些不再代表主 memory 契约,而是 coordinator/analytics 的可选优化层。 | +| `nanobot/agent/memory.py` | `MemoryStore` | `beaver/memory/curated/store.py`, `beaver/memory/curated/snapshot.py`, `beaver/memory/search/transcript_store.py` | `重写迁移` | 见第 6 节;memory 基线改成 Hermes 风格。 | +| `nanobot/skills/subagent-manager/scripts/subagentctl.py` | `cmd_list`, `cmd_show`, `cmd_create`, `cmd_delete`, `cmd_set_system_prompt`, `cmd_add_mcp_http`, `cmd_add_mcp_stdio`, `cmd_remove_mcp` | `beaver/interfaces/cli/subagentctl.py` | `小幅重构` | 只迁 CLI 管理能力,不再绑定 `nanobot` store。 | + +### 8.1 `agent/skills.py` 函数级拆分 + +| 旧函数/方法 | 新位置 | +| --- | --- | +| `list_skills`, `get_skill_metadata`, `get_skill_agent_cards`, `list_skill_agent_cards` | `beaver/skills/catalog/loader.py` | +| `load_skill`, `load_skills_for_context`, `build_skills_summary`, `get_always_skills` | `beaver/skills/resolver/runtime.py` | +| `_strip_frontmatter`, `_parse_nanobot_metadata`, `_check_requirements`, `_get_missing_requirements`, `_get_skill_description` | `beaver/skills/catalog/utils.py` 或 `loader.py` 内部私有函数 | + +### 8.2 Memory 迁移基线 + +新的 memory 迁移必须遵守下面这条,而不是直接复制旧 `MemoryStore + ProcedureMemory` 设计: + +1. 持久化记忆只保留两类: + - `memory` + - `user` +2. 写操作统一通过 `memory` tool: + - `add` + - `replace` + - `remove` +3. `replace/remove` 使用短语义片段匹配,不要求 UUID +4. 写入协议必须是: + - 注入扫描 + - 文件锁 + - 锁内 reload + - 重复/上限检测 + - 原子写入 +5. system prompt 只注入 frozen snapshot +6. 历史细节通过 `session_search` 检索,不扩大 memory +7. 稳定的方法论、工作法、可复用技巧进入 `skills` +8. `ProcedureMemory` 只保留为 coordinator 的复用优化 + +## 9. Coordinator 层迁移映射 + +| 旧文件 | 关键类/函数 | 新位置 | 判定 | 说明 | +| --- | --- | --- | --- | --- | +| `nanobot/agent/agent_registry.py` | `AgentDescriptor`, `WorkspaceAgentStore`, `AgentRegistry` | `beaver/coordinator/registry/models.py`, `workspace_store.py`, `agent_registry.py` | `拆分迁移` | descriptor、store、registry 三类职责应拆开。 | +| `nanobot/agent/delegation.py` | `DelegationRun`, `DelegationManager` | `beaver/coordinator/delegation/manager.py`, `beaver/coordinator/execution/delegation_run.py`, `beaver/coordinator/delegation/events.py` | `拆分迁移` | 旧文件职责最重,不能原样搬。 | +| `nanobot/a2a/client.py` | `A2AClient`, `A2AError`, `A2AUnsupportedMethodError`, `A2AStreamEvent` | `beaver/integrations/a2a/client.py` | `小幅重构` | A2A 是协议层,适合独立迁。 | +| `nanobot/agent_team/types.py` | `ExecutionMode`, `ResolvedTeamPlan`, `SwarmsRunSpec`, `SwarmsRunResult`, `ProcedureRecord`, `RunRecord`, `BridgeAttempt`, `BridgeResult` | `beaver/coordinator/team/types.py` | `可直接迁移` | 类型层稳定,但 `ProcedureRecord/RunRecord` 不再作为主 memory 契约。 | +| `nanobot/agent_team/orchestrator.py` | `AgentTeamOrchestrator.run_task` | `beaver/coordinator/team/orchestrator.py` | `小幅重构` | 是 team 主入口。 | +| `nanobot/agent_team/provisioning.py` | `ProvisioningManager`, `SpecialistProvisionResult` | `beaver/coordinator/team/provisioning.py` | `重写迁移` | 旧实现绑定 `LocalSubagentStore + Config + gateway port`,要改成新 registry 接口。 | +| `nanobot/agent_team/target_resolver.py` | `TargetResolver.resolve_team_targets`, `_select_existing_for_role_with_llm` | `beaver/coordinator/team/target_resolver.py` | `小幅重构` | 主要改 provider/registry/provisioning 注入。 | +| `nanobot/agent_team/swarms_policy.py` | `SwarmsPolicy` | `beaver/coordinator/backends/swarms/policy.py` | `可直接迁移` | 纯 guardrail,可先迁。 | +| `nanobot/agent_team/swarms_planner.py` | `SwarmsRunPlanner` | `beaver/coordinator/planner/swarms.py` | `小幅重构` | planner 逻辑稳定,但要切掉 `third_party` 假设。 | +| `nanobot/agent_team/swarms_bridge.py` | `SwarmsBridge` | `beaver/coordinator/backends/swarms/bridge.py` | `小幅重构` | 结果归一化和 backend 运行桥接分层很好。 | +| `nanobot/agent_team/swarms_adapter.py` | `ensure_swarms_importable`, `load_swarms_runtime`, `safe_swarms_name`, `NanobotAgentAdapter` | `beaver/coordinator/backends/swarms/runtime.py`, `adapter.py` | `重写迁移` | 不再允许 `third_party/` 路径探测;只保留 adapter 设计。 | + +### 9.1 `agent/delegation.py` 函数级拆分 + +| 旧函数/方法 | 新位置 | +| --- | --- | +| `dispatch_subagent`, `dispatch_agent_team`, `_dispatch`, `_run_dispatch` | `beaver/coordinator/delegation/manager.py` | +| `_emit_team_progress`, `_emit_agent_started`, `_emit_agent_finished`, `_emit_agent_cancelled`, `_emit_group_started`, `_emit_group_finished`, `_publish_prefixed_progress`, `_emit_direct_user_message` | `beaver/coordinator/delegation/events.py` | +| `_run_team_member_for_swarms`, `_execute_descriptor`, `_build_progress_callback`, `_build_task_callback` | `beaver/coordinator/execution/member_runner.py` | +| `_resolve_single`, `_resolve_nested_delegate`, `_normalize_skill_names`, `_build_skill_context`, `_augment_task_with_skills` | `beaver/coordinator/delegation/manager.py` | +| `cancel`, `cancel_all`, `_cancel_remote_tasks`, `_announce_cancelled` | `beaver/coordinator/execution/cancel.py` 或先保留在 `manager.py` | +| `_announce_single_result`, `_announce_orchestrator_result`, `_publish_announcement`, `_notify_direct_announcement` | `beaver/coordinator/delegation/announcement.py` | + +## 10. Services 层迁移映射 + +| 旧文件 | 关键类/函数 | 新位置 | 判定 | 说明 | +| --- | --- | --- | --- | --- | +| `nanobot/cron/service.py` | `CronService` | `beaver/services/cron_service.py` | `小幅重构` | 保持后台服务角色,不要再埋在 `nanobot/cron/` 目录。 | +| `nanobot/cron/runtime.py` | `run_cron_job`, `_build_execution_context`, `_resolve_session_key` | `beaver/services/cron_runtime.py` | `小幅重构` | 与 `AgentLoop`、Web、CLI 的对接点要更新。 | +| `nanobot/heartbeat/service.py` | `HeartbeatService` | `beaver/services/heartbeat_service.py` | `可直接迁移` | 后台服务类,自包含度高。 | + +## 11. Interfaces 层迁移映射 + +### 11.1 CLI + +| 旧文件 | 关键类/函数 | 新位置 | 判定 | 说明 | +| --- | --- | --- | --- | --- | +| `nanobot/__main__.py` | 模块入口 | `beaver/interfaces/cli/main.py` | `可直接迁移` | 只保留入口壳。 | +| `nanobot/cli/commands.py` | `main`, `version_callback`, `agent`, `gateway`, `web`, `onboard`, `_create_workspace_templates`, `status`, `channels_*`, `cron_*`, `provider_*` | `beaver/interfaces/cli/main.py`, `beaver/interfaces/cli/commands/*.py`, `beaver/services/*.py` | `拆分迁移` | 旧 CLI 同时做了命令声明、provider 装配、gateway/web 启动、cron 管理。 | + +### 11.2 `cli/commands.py` 函数级拆分 + +| 旧函数 | 新位置 | +| --- | --- | +| `main`, `version_callback` | `beaver/interfaces/cli/main.py` | +| `agent` | `beaver/interfaces/cli/commands/agent.py`,内部调用 `beaver/services/agent_service.py` | +| `gateway` | `beaver/interfaces/gateway/main.py` | +| `web` | `beaver/interfaces/cli/commands/web.py`,内部调用 `beaver/interfaces/web/app.py` | +| `_make_provider` | `beaver/engine/providers/factory.py` | +| `onboard`, `_create_workspace_templates`, `status` | `beaver/services/admin_service.py` + CLI 薄包装 | +| `channels_main`, `channels_status`, `channels_login` | `beaver/interfaces/cli/commands/channels.py` | +| `cron_main`, `cron_list`, `cron_add`, `cron_remove`, `cron_enable`, `cron_run` | `beaver/interfaces/cli/commands/cron.py` + `beaver/services/cron_service.py` | +| `provider_main`, `_register_login`, `provider_login`, `_login_openai_codex`, `_login_github_copilot` | `beaver/interfaces/cli/commands/providers.py` + `beaver/services/admin_service.py` | +| `_flush_pending_tty_input`, `_restore_terminal`, `_init_prompt_session`, `_print_agent_response`, `_is_exit_command`, `_read_interactive_input_async`, `_exit_after_group_help`, `_get_bridge_dir` | `beaver/interfaces/cli/tty.py` | + +### 11.3 Web + +| 旧文件 | 关键类/函数 | 新位置 | 判定 | 说明 | +| --- | --- | --- | --- | --- | +| `nanobot/web/server.py` | `create_app`, `WebSocketBroadcaster`, 所有 `*Request/*Response` 模型、所有 auth / handoff / route helper | `beaver/interfaces/web/app.py`, `deps.py`, `realtime.py`, `auth.py`, `routes/*.py`, `schemas/*.py` | `拆分迁移` | 这是第二个最大拆分热点。 | +| `nanobot/web/files.py` | `save_file`, `get_file_metadata`, `list_files`, `browse_workspace`, `save_to_workspace`, `delete_workspace_path`, `create_workspace_dir` | `beaver/interfaces/web/files.py`, `beaver/interfaces/web/routes/files.py` | `小幅重构` | 纯文件 API 逻辑,应该从 `server.py` 分离出去。 | +| `nanobot/web/outlook.py` | `connect_workspace`, `disconnect_workspace`, `outlook_status`, `get_overview`, `get_message_detail`, `list_messages`, `list_events`, `ensure_outlook_mcp_registration` | `beaver/integrations/outlook/service.py`,由 `beaver/interfaces/web/routes/outlook.py` 调用 | `小幅重构` | 核心逻辑应放 integration,不应继续留在 web 包下。 | + +### 11.4 `web/server.py` 函数级拆分 + +| 旧函数/类型 | 新位置 | +| --- | --- | +| `create_app` | `beaver/interfaces/web/app.py` | +| `ChatRequest`, `ChatResponse` | `beaver/interfaces/web/schemas/chat.py` | +| `AddCronJobRequest`, `ToggleCronJobRequest` | `beaver/interfaces/web/schemas/cron.py` | +| `AddMarketplaceRequest`, `ApproveSkillReviewRequest` | `beaver/interfaces/web/schemas/plugins.py`, `skills.py` | +| `AddAgentRequest`, `_discover_agent_payload`, `_manual_agent_payload`, `_should_auto_discover_agent` | `beaver/interfaces/web/routes/agents.py` + `beaver/interfaces/web/schemas/agents.py` | +| `MCPServerRequest` | `beaver/interfaces/web/schemas/mcp.py` | +| `SubagentRequest` | `beaver/interfaces/web/schemas/delegation.py` | +| `OutlookConnectionRequest` | `beaver/interfaces/web/schemas/outlook.py` | +| `LoginRequest`, `RegisterRequest`, `AuthzRegisterBackendRequest`, `LocalBackendIdentityRequest`, `HandoffConsumeRequest` | `beaver/interfaces/web/schemas/auth.py` | +| `WebSocketBroadcaster` | `beaver/interfaces/web/realtime.py` | +| `_issue_web_token`, `_require_web_user`, `_issue_handoff_code`, `_consume_handoff_code`, `_prune_handoff_codes`, `_handoff_*` | `beaver/interfaces/web/auth.py` | +| `_register_routes` | 删除;改为 `beaver/interfaces/web/routes/*.py` 各自注册 | +| `_make_provider` | 删除;使用 `beaver/engine/providers/factory.py` | +| `_serialize_job` | `beaver/interfaces/web/serializers/cron.py` 或 `schemas/cron.py` | + +### 11.5 Channels + +| 旧文件 | 关键类/函数 | 新位置 | 判定 | 说明 | +| --- | --- | --- | --- | --- | +| `nanobot/channels/base.py` | `BaseChannel` | `beaver/interfaces/channels/base.py` | `可直接迁移` | 通道抽象基类。 | +| `nanobot/channels/manager.py` | `ChannelManager` | `beaver/interfaces/channels/manager.py` | `小幅重构` | 初始化逻辑要改为 `beaver` config。 | +| `nanobot/channels/dingtalk.py` | `NanobotDingTalkHandler`, `DingTalkChannel` | `beaver/interfaces/channels/dingtalk.py` | `小幅重构` | 改命名和 config 依赖。 | +| `nanobot/channels/discord.py` | `_split_message`, `DiscordChannel` | `beaver/interfaces/channels/discord.py` | `可直接迁移` | 主要改导入路径。 | +| `nanobot/channels/email.py` | `EmailChannel` | `beaver/interfaces/channels/email.py` | `可直接迁移` | 通道逻辑自包含。 | +| `nanobot/channels/feishu.py` | `_extract_*`, `FeishuChannel` | `beaver/interfaces/channels/feishu.py` | `可直接迁移` | 保持通道粒度。 | +| `nanobot/channels/matrix.py` | `_filter_matrix_html_attribute`, `_render_markdown_html`, `_build_matrix_text_content`, `MatrixChannel` | `beaver/interfaces/channels/matrix.py` | `可直接迁移` | 主要改导入。 | +| `nanobot/channels/mochat.py` | `MochatBufferedEntry`, `DelayState`, `MochatTarget`, `MochatChannel` | `beaver/interfaces/channels/mochat.py` | `小幅重构` | 依赖 config 较多。 | +| `nanobot/channels/qq.py` | `_make_bot_class`, `QQChannel` | `beaver/interfaces/channels/qq.py` | `可直接迁移` | 主要改配置路径。 | +| `nanobot/channels/slack.py` | `SlackChannel` | `beaver/interfaces/channels/slack.py` | `可直接迁移` | 主要改导入路径。 | +| `nanobot/channels/telegram.py` | `_markdown_to_telegram_html`, `_split_message`, `TelegramChannel` | `beaver/interfaces/channels/telegram.py` | `可直接迁移` | 通道逻辑自包含。 | +| `nanobot/channels/whatsapp.py` | `WhatsAppChannel` | `beaver/interfaces/channels/whatsapp.py` | `小幅重构` | 作为通道入口保留;桥接细节可抽到 `beaver/integrations/whatsapp/bridge.py`。 | + +## 12. Integrations 层迁移映射 + +| 旧文件 | 关键类/函数 | 新位置 | 判定 | 说明 | +| --- | --- | --- | --- | --- | +| `nanobot/a2a/client.py` | `A2AClient` 全类 | `beaver/integrations/a2a/client.py` | `小幅重构` | 见第 9 节。 | +| `nanobot/web/outlook.py` | Outlook MCP 连接、状态、消息和日历方法 | `beaver/integrations/outlook/service.py` | `小幅重构` | 见第 11 节。 | +| `nanobot/authz/client.py` | `BackendRegistrationResult`, `AuthzClient` | `beaver/integrations/authz/client.py` | `可直接迁移` | 纯外部服务 client;新目录里需新增 `authz/`。 | +| `nanobot/providers/transcription.py` | `GroqTranscriptionProvider` | `beaver/integrations/providers/transcription.py` 或 `beaver/engine/providers/transcription.py` | `可直接迁移` | 二选一,取决于后续是否把 transcription 视为主 provider。 | +| `nanobot/agent/tools/mcp.py` | `connect_mcp_servers` | `beaver/integrations/mcp/connection.py` + `beaver/tools/mcp/wrapper.py` | `小幅重构` | 协议连接与工具包装分开。 | + +## 13. 明确不迁移的内容 + +| 路径 | 处理方式 | 原因 | +| --- | --- | --- | +| `backend-old/third_party/**` | `不迁移` | 新后端不保留 vendored 第三方目录。 | +| `backend-old/.venv/**` | `不迁移` | 环境文件。 | +| `backend-old/.pytest_cache/**` | `不迁移` | 缓存。 | +| `backend-old/.ruff_cache/**` | `不迁移` | 缓存。 | +| `backend-old/bridge/**` | `保留为外部桥接层,不按 Python 代码移植` | 这是独立 Node bridge。 | + +## 14. 第一批最值得迁的文件 + +如果要先挑“收益最高、风险最低”的一批,顺序建议是: + +1. `nanobot/config/loader.py` -> `beaver/foundation/config/loader.py` +2. `nanobot/config/paths.py` -> `beaver/foundation/config/paths.py` +3. `nanobot/config/schema.py` -> `beaver/foundation/config/schema.py` +4. `nanobot/utils/helpers.py` -> `beaver/foundation/utils/helpers.py` +5. `nanobot/agent/process_events.py` -> `beaver/foundation/events/process.py` +6. `nanobot/agent/run_result.py` -> `beaver/foundation/models/run_result.py` +7. `nanobot/session/manager.py` -> `beaver/engine/session/models.py` + `manager.py` +8. `nanobot/providers/base.py` / `registry.py` / `custom_provider.py` / `litellm_provider.py` / `openai_codex_provider.py` +9. `nanobot/agent/context.py` -> `beaver/engine/context/builder.py` +10. `nanobot/agent/tools/base.py` / `registry.py` / `filesystem.py` / `shell.py` / `web.py` / `message.py` +11. `nanobot/agent/plugins.py` -> `beaver/plugins/*` +12. `nanobot/agent/skills.py` -> `beaver/skills/catalog/loader.py` + `resolver/runtime.py` +13. `nanobot/agent_team/types.py` -> `beaver/coordinator/team/types.py` +14. `nanobot/agent_team/memory.py` -> `beaver/memory/procedures/*` + `beaver/memory/runs/*` +15. 以 Hermes 基线新增 `beaver/tools/builtins/memory.py` +16. 以 Hermes 基线新增 `beaver/tools/builtins/session_search.py` + +## 15. 最后一句话 + +从 `backend-old` 移到新的 `backend`,最重要的不是“先把文件复制过来”,而是始终按这个原则落: + +1. `foundation` 放公共模型、配置、事件、工具函数 +2. `engine` 放统一 agent 内核 +3. `tools` 放工具本身 +4. `skills` 放全系统指导层 +5. `memory` 放经验沉淀 +6. `coordinator` 放委派和多 agent 编排 +7. `services` 放应用服务 +8. `interfaces` 放 CLI / Web / Gateway / Channels 薄入口 +9. `integrations` 放 A2A / MCP / Outlook / Authz 这类外部系统 + +只要旧文件进入新目录时严格按这条边界落,新后端就不会再次长回 `backend-old` 那种结构。 diff --git a/app-instance/frontend/app/(app)/outlook/page.tsx b/app-instance/frontend/app/(app)/outlook/page.tsx index e459ef0..851d0da 100644 --- a/app-instance/frontend/app/(app)/outlook/page.tsx +++ b/app-instance/frontend/app/(app)/outlook/page.tsx @@ -338,7 +338,7 @@ function renderPlainText(content: string): React.ReactNode[] { export default function OutlookPage() { const { locale } = useAppI18n(); - const t = (zh: string, en: string) => pickAppText(locale, zh, en); + const t = useCallback((zh: string, en: string) => pickAppText(locale, zh, en), [locale]); const [status, setStatus] = useState(null); const [form, setForm] = useState(EMPTY_FORM); const [formDirty, setFormDirty] = useState(false); @@ -368,14 +368,19 @@ export default function OutlookPage() { sent: false, }); const [calendarLoading, setCalendarLoading] = useState(false); + const formDirtyRef = React.useRef(formDirty); + + useEffect(() => { + formDirtyRef.current = formDirty; + }, [formDirty]); const applyStatus = useCallback((nextStatus: OutlookStatus, forceFormSync = false) => { setStatus(nextStatus); - if (forceFormSync || !formDirty) { + if (forceFormSync || !formDirtyRef.current) { setForm(toFormState(nextStatus)); setFormDirty(false); } - }, [formDirty]); + }, []); const loadOverview = useCallback(async (preserveExisting = false) => { setOverviewLoading(true); @@ -456,9 +461,7 @@ export default function OutlookPage() { if (!background) { setStatusLoading(false); } - if (nextStatus.configured) { - await loadOverview(options?.preserveOverview ?? background); - } else { + if (!nextStatus.configured) { setOverview(null); setOverviewLoading(false); } @@ -668,7 +671,9 @@ export default function OutlookPage() { const refreshOverview = async () => { await loadStatus(true, { preserveOverview: true }); - if (activeView === 'inbox') { + if (activeView === 'settings' && isConfigured) { + await loadOverview(true); + } else if (activeView === 'inbox') { await loadMailboxPage('inbox', inboxPage?.page.skip ?? 0); } else if (activeView === 'sent') { await loadMailboxPage('sent', sentPage?.page.skip ?? 0); diff --git a/sessions/state.db b/sessions/state.db new file mode 100644 index 0000000..06b1fb1 Binary files /dev/null and b/sessions/state.db differ