merge agent team evidence validation work

This commit is contained in:
2026-05-22 11:51:05 +08:00
466 changed files with 49325 additions and 41221 deletions

View File

@ -1,23 +1,30 @@
# Shared values used by the root deployment flow in README.md
PROJECT_ROOT=/home/ivan/xuan/nano_project
NANO_NET=nano-instance-edge
PROJECT_ROOT=/home/ivan/xuan/beaver_project
BEAVER_NET=beaver-instance-edge
BEAVER_PROXY_CONTAINER_NAME=beaver-router-proxy
NANO_DEPLOY_TOKEN=change-me
NANO_AUTHZ_INTERNAL_TOKEN=change-me
BEAVER_DEPLOY_TOKEN=change-me
BEAVER_AUTHZ_INTERNAL_TOKEN=change-me
NANO_SERVER_IP=203.0.113.10
NANO_BASE_DOMAIN=203.0.113.10.nip.io
BEAVER_SERVER_IP=203.0.113.10
BEAVER_BASE_DOMAIN=203.0.113.10.nip.io
NANO_PROVIDER=openai
NANO_MODEL=openai/gpt-5
NANO_API_KEY=sk-xxxxxxxx
NANO_API_BASE=
BEAVER_PROVIDER=openai
BEAVER_MODEL=openai/gpt-5
BEAVER_API_KEY=sk-xxxxxxxx
BEAVER_API_BASE=
# Per-instance Beaver backend config. In Docker app-instance this should point
# to the mounted single-user sandbox config, not to frontend env.
BEAVER_HOME=/root/.beaver
BEAVER_CONFIG_PATH=/root/.beaver/config.json
BEAVER_WORKSPACE=/root/.beaver/workspace
# Must be reachable from app-instance containers.
NANO_AUTHZ_URL=http://nano-authz-service:19090
NANO_OUTLOOK_MCP_URL=
NANO_OUTLOOK_MCP_SERVER_ID=outlook_mcp
BEAVER_AUTHZ_URL=http://beaver-authz-service:19090
BEAVER_OUTLOOK_MCP_URL=
BEAVER_OUTLOOK_MCP_SERVER_ID=outlook_mcp
# Must be reachable from auth-portal and authz-service containers.
NANO_DEPLOY_URL=http://nano-deploy-control:8090
BEAVER_DEPLOY_URL=http://beaver-deploy-control:8090

25
.gitignore vendored
View File

@ -1,12 +1,37 @@
# Runtime data generated by local Docker deployment
authz-service/runtime/data/
authz-service/src/data/
app-instance/runtime/instances/
app-instance/runtime/registry/
router-proxy/runtime/conf.d/
runtime/
!auth-portal/src/app/api/runtime/
!auth-portal/src/app/api/runtime/**
sessions/
**/sessions/state.db
**/runtime/**/*.lock
# Local build / cache artifacts
**/__pycache__/
**/.pytest_cache/
**/node_modules/
**/.next/
**/.next-dev/
**/.turbo/
**/.ruff_cache/
**/.mypy_cache/
**/.cache/
**/.venv/
**/dist/
**/build/
**/*.egg-info/
**/tsconfig.tsbuildinfo
*.log
*.tmp
*.py[cod]
# Local secrets / env files
.env
*.env
*.pem
app-instance/frontend/.env_prod

411
DESIGN.md Normal file
View File

@ -0,0 +1,411 @@
# DESIGN.md
## Brand
**Beaver — Taupe**
A calm editorial UI system focused on rhythm, hierarchy, and soft neutral contrast.
Designed for AI-native tools, dashboards, and minimalist productivity software.
The interface emphasizes:
- Spacious layouts
- Soft grayscale surfaces
- Typography-first hierarchy
- Rounded geometry
- Quiet shadows
- Dense information with low visual noise
The visual tone should feel:
- thoughtful
- mature
- calm
- premium
- architectural
- editorial
Avoid:
- saturated colors
- hard borders
- sharp corners
- excessive gradients
- loud shadows
- playful illustration-heavy UI
---
# Colors
## Core Palette
| Token | Hex | Usage |
|---|---|---|
| background | `#F5F3F1` | Main app background |
| foreground | `#0B0B0B` | Primary text |
| primary | `#1D1715` | Primary actions |
| secondary | `#E5E2DF` | Secondary surfaces |
| muted | `#DDD9D6` | Muted backgrounds |
| accent | `#CAC5C0` | Borders / subtle emphasis |
---
## Neutral Scale
| Token | Hex |
|---|---|
| zinc-50 | `#F7F5F4` |
| zinc-100 | `#ECE8E5` |
| zinc-200 | `#D8D2CE` |
| zinc-300 | `#B8AEA8` |
| zinc-400 | `#8B7E77` |
| zinc-500 | `#6A5E58` |
| zinc-600 | `#4F4642` |
| zinc-700 | `#342E2B` |
---
## Semantic Colors
### Taupe
| Step | Hex |
|---|---|
| taupe-100 | `#E7E2DE` |
| taupe-300 | `#B8AEA8` |
| taupe-500 | `#8B7E77` |
| taupe-700 | `#5F5550` |
### Sage
| Step | Hex |
|---|---|
| sage-100 | `#E3E8E2` |
| sage-300 | `#B7C2B5` |
| sage-500 | `#869683` |
| sage-700 | `#657162` |
### Slate
| Step | Hex |
|---|---|
| slate-100 | `#E4E7EB` |
| slate-300 | `#BCC4CE` |
| slate-500 | `#8C96A3` |
| slate-700 | `#697281` |
---
# Typography
## Philosophy
Typography drives hierarchy.
The system should feel like a modern editorial publication mixed with a productivity dashboard.
Large headings use elegant serif typography.
UI and body copy use neutral grotesk sans-serif typography.
---
## Font Stack
### Serif
```css
font-family: "Lora", Georgia, serif;
```
Used for:
- hero titles
- article headings
- marketing emphasis
- editorial sections
---
### Sans
```css
font-family: "Public Sans", Inter, sans-serif;
```
Used for:
- UI
- labels
- forms
- dashboards
- buttons
- navigation
---
## Type Scale
| Style | Size | Weight | Line Height |
|---|---|---|---|
| h1 | 48px | 600 | 1.1 |
| h2 | 36px | 600 | 1.15 |
| h3 | 28px | 500 | 1.2 |
| body-lg | 18px | 400 | 1.7 |
| body | 16px | 400 | 1.6 |
| small | 14px | 400 | 1.5 |
| mono | 13px | 500 | 1.4 |
---
# Radius
Rounded geometry should feel soft but architectural.
| Token | Radius |
|---|---|
| xs | 4px |
| sm | 8px |
| md | 12px |
| lg | 16px |
| xl | 24px |
| full | 999px |
Cards should primarily use:
```css
border-radius: 16px;
```
---
# Shadows
Shadows should be subtle and diffused.
Avoid strong elevation.
## Soft
```css
box-shadow:
0 1px 2px rgba(0,0,0,0.04),
0 6px 24px rgba(0,0,0,0.03);
```
## Floating
```css
box-shadow:
0 12px 40px rgba(0,0,0,0.06);
```
---
# Grid
## Layout
- 12-column grid
- Max width: `1280px`
- Horizontal padding: `32px`
- Large whitespace between sections
---
## Content Widths
| Type | Width |
|---|---|
| reading | 720px |
| dashboard | 1280px |
| modal | 480px |
| form | 560px |
---
# Spacing
Base unit:
```txt
4px
```
Spacing scale:
| Token | Value |
|---|---|
| 1 | 4px |
| 2 | 8px |
| 3 | 12px |
| 4 | 16px |
| 5 | 20px |
| 6 | 24px |
| 8 | 32px |
| 10 | 40px |
| 12 | 48px |
| 16 | 64px |
Use generous vertical rhythm.
Sections should breathe.
---
# Components
## Buttons
### Primary
- Dark background
- White text
- Pill radius
- Minimal shadow
```css
background: #1D1715;
color: white;
border-radius: 999px;
height: 40px;
padding: 0 16px;
```
### Secondary
```css
background: #ECE8E5;
color: #1D1715;
```
### Ghost
Transparent background with subtle hover fill.
---
## Cards
Cards are soft containers with quiet separation.
```css
background: rgba(255,255,255,0.7);
border: 1px solid rgba(0,0,0,0.04);
border-radius: 16px;
```
Avoid heavy borders.
---
## Inputs
Inputs should feel invisible until focused.
```css
background: #F7F5F4;
border: 1px solid transparent;
```
Focus:
```css
border-color: #8B7E77;
box-shadow: 0 0 0 3px rgba(139,126,119,0.12);
```
---
## Charts
Charts should use muted earthy tones.
Preferred palette:
- taupe
- sage
- slate
Avoid:
- neon colors
- bright blue dashboards
- rainbow charts
---
# Motion
Motion should be restrained and smooth.
Preferred easing:
```css
cubic-bezier(0.22, 1, 0.36, 1)
```
Preferred duration:
| Type | Duration |
|---|---|
| hover | 150ms |
| panel | 250ms |
| modal | 350ms |
---
# Layout Skeleton
Application layout:
- Left sidebar
- Large content canvas
- Floating top toolbar
- Soft dashboard cards
- Spacious internal padding
The UI should always feel:
- breathable
- editorial
- premium
- calm
Never dense or overly enterprise-looking.
---
# Design Keywords
Use these words when generating UI:
- editorial
- taupe
- soft neutral
- premium minimal
- typography-first
- architectural spacing
- calm dashboard
- quiet luxury
- modern serif
- subtle shadows
- muted grayscale
- sophisticated SaaS
---
# AI Agent Instructions
When generating UI:
1. Prioritize whitespace over density
2. Typography should create hierarchy
3. Use muted neutral palettes
4. Prefer soft cards over hard sections
5. Avoid excessive color usage
6. Keep interactions subtle
7. Use serif fonts sparingly for emphasis
8. Maintain premium visual restraint
9. Design should feel timeless rather than trendy
10. Every screen should feel breathable

460
README.md
View File

@ -1,21 +1,30 @@
# nano_project
# Beaver Project
单机部署版运行结构
`Beaver Project` 是一套单机 Docker 部署的多实例运行环境
- `auth-portal`
- 用户入口页,提供登录、注册、跳转
- `authz-service`
- AuthZ 服务
- 现在负责注册编排:`auth-portal -> authz-service -> deploy-control -> app-instance -> authz-service`
- `deploy-control`
- 部署机控制面。
- 负责创建实例、解析实例、刷新反向代理。
- `router-proxy`
- 统一入口代理。
- 按 Host 把 `<slug>.<base_domain>` 转发到对应实例容器。
- `app-instance`
- 真正的单用户实例。
- 一个容器里同时包含前端、后端和 Nginx。
- 用户先进入独立的 `auth-portal` 完成注册或登录。
- 注册会触发 `authz-service` 调用 `deploy-control`
- `deploy-control` 在同一台机器上创建一个独立的 `app-instance` 容器。
- `router-proxy` 按实例域名把流量转发到对应容器
当前推荐的最小部署方式是一台 Linux / WSL2 Ubuntu 机器加 Docker。生产域名和 HTTPS 可以放在项目外层的 Nginx、Caddy、Traefik 或云负载均衡上。
## 组件
| 目录 | 职责 | 默认端口 |
| --- | --- | --- |
| `auth-portal/` | 用户登录、注册、模型配置引导入口 | `3081` |
| `authz-service/` | AuthZ 服务,负责账号和 backend 身份编排 | `19090` |
| `deploy-control/` | 部署控制面,调用 Docker 创建和管理实例 | `8090` |
| `router-proxy/` | 统一实例入口代理,按 Host 分发到实例容器 | `8088` |
| `app-instance/` | 单用户运行实例,容器内包含前端、后端和 Nginx | 容器内 `8080` |
公网环境通常只暴露:
- `auth-portal`: `3081`,或外层代理后的 `https://portal.example.com`
- `router-proxy`: `8088`,或外层代理后的 `https://<slug>.apps.example.com`
不要直接把 `deploy-control:8090``authz-service:19090` 暴露到公网。
## 请求链路
@ -26,9 +35,11 @@ Browser
-> auth-portal
-> authz-service POST /portal/register
-> deploy-control POST /api/instances/register
-> create-instance.sh
-> app-instance/create-instance.sh
-> app-instance POST /api/auth/register
-> authz-service /oauth/register or /backends/register
-> auth-portal provider onboarding
-> deploy-control POST /api/instances/configure-provider
```
登录:
@ -38,378 +49,135 @@ Browser
-> auth-portal
-> deploy-control POST /api/instances/resolve
-> app-instance POST /api/auth/login
-> app-instance frontend URL
```
## 这份部署指南的前提
## 快速开始
这份 README 只覆盖一套基准方案
本机完整流程见
- 一台 Linux 服务器
- 用 Docker 运行 `auth-portal``authz-service``deploy-control``router-proxy`
- `deploy-control` 通过 Docker socket 在同一台机器上创建 `app-instance`
- 所有容器共用一个 Docker network`nano-instance-edge`
- 测试域名先用 `nip.io`
- [部署指南.md](./部署指南.md)
如果你后面要接 HTTPS、外部 LB、Kubernetes、真实 DNS这份流程仍然适合作为最小可运行基线。
域名、HTTPS、公网反向代理说明见
可直接参考这些模板文件:
- [域名配置指引.md](./域名配置指引.md)
- [`.env.example`](/home/ivan/xuan/nano_project/.env.example)
- [`auth-portal/src/.env.example`](/home/ivan/xuan/nano_project/auth-portal/src/.env.example)
- [`authz-service/.env.example`](/home/ivan/xuan/nano_project/authz-service/.env.example)
- [`deploy-control/.env.example`](/home/ivan/xuan/nano_project/deploy-control/.env.example)
- [`router-proxy/.env.example`](/home/ivan/xuan/nano_project/router-proxy/.env.example)
注意:
- 这些文件是模板,不会被现有脚本自动加载
- 你可以手动 `export`,或者在 `docker run` 时使用 `--env-file`
## 部署前必须先定好的值
先准备这些值:
- `PROJECT_ROOT`
- 仓库根目录。
- `NANO_NET`
- Docker network 名。
- 推荐固定成 `nano-instance-edge`
- `NANO_DEPLOY_TOKEN`
- `auth-portal` / `authz-service``deploy-control` 时用的 Bearer token。
- `NANO_AUTHZ_INTERNAL_TOKEN`
- AuthZ 内部接口 token。
- `NANO_SERVER_IP`
- 服务器公网 IP`nip.io` 测试使用。
- `NANO_BASE_DOMAIN`
- 实例基域名。
- 测试环境推荐 `<SERVER_IP>.nip.io`
- `NANO_PROVIDER`
- 默认 provider例如 `openai`
- `NANO_MODEL`
- 默认模型,例如 `openai/gpt-5`
- `NANO_API_KEY`
- 默认分发给新实例的 provider API key
- `NANO_API_BASE`
- 可空,自定义 provider base URL
- `NANO_AUTHZ_URL`
- 这个值必须是 `app-instance` 容器能访问到的 AuthZ 地址
- `NANO_OUTLOOK_MCP_URL`
- 可空。
- 如果配置了,所有新创建的 `app-instance` 都会默认带一个 Outlook MCP HTTP 工具配置。
- `NANO_OUTLOOK_MCP_SERVER_ID`
- Outlook MCP 默认 server id。
- 推荐固定成 `outlook_mcp`
- `NANO_DEPLOY_URL`
- `auth-portal``authz-service` 在容器网络里访问 deploy-control 的地址
直接导出一套最小配置:
最小配置变量:
```bash
export PROJECT_ROOT=/home/ivan/xuan/nano_project
export NANO_NET=nano-instance-edge
export PROJECT_ROOT=/home/ivan/xuan/beaver_project
export BEAVER_NET=beaver-instance-edge
export BEAVER_PROXY_CONTAINER_NAME=beaver-router-proxy
export NANO_DEPLOY_TOKEN="$(openssl rand -hex 32)"
export NANO_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)"
export BEAVER_DEPLOY_TOKEN="$(openssl rand -hex 32)"
export BEAVER_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)"
export NANO_SERVER_IP=203.0.113.10
export NANO_BASE_DOMAIN="${NANO_SERVER_IP}.nip.io"
export BEAVER_BASE_DOMAIN=127.0.0.1.nip.io
export BEAVER_AUTHZ_URL='http://beaver-authz-service:19090'
export BEAVER_DEPLOY_URL='http://beaver-deploy-control:8090'
export NANO_PROVIDER=openai
export NANO_MODEL=openai/gpt-5
export NANO_API_KEY='sk-xxxxxxxx'
export NANO_API_BASE=''
export NANO_AUTHZ_URL='http://nano-authz-service:19090'
export NANO_OUTLOOK_MCP_URL=''
export NANO_OUTLOOK_MCP_SERVER_ID='outlook_mcp'
export NANO_DEPLOY_URL='http://nano-deploy-control:8090'
export BEAVER_OUTLOOK_MCP_URL=''
export BEAVER_OUTLOOK_MCP_SERVER_ID='outlook_mcp'
```
## 变量到底是什么
启动顺序:
最容易混淆的是下面这几组:
1. 创建运行目录。
2. 构建四个镜像。
3. 创建共享 Docker network。
4. 启动 `router-proxy`
5. 启动 `authz-service`
6. 启动 `deploy-control`
7. 启动 `auth-portal`
8. 打开 `http://127.0.0.1:3081/register` 测试注册。
- `DEPLOY_API_TOKEN`
- 调用方带出去的 token。
- `auth-portal``authz-service` 会用它请求 `deploy-control`
- `DEPLOY_CONTROL_API_TOKEN`
- `deploy-control` 服务端校验的 token。
- 它必须和 `DEPLOY_API_TOKEN` 相等。
- `AUTHZ_ISSUER`
- 当前实现里它既是 AuthZ 的 issuer也是新实例要访问的 AuthZ base URL。
- 所以不要乱写 `127.0.0.1`,要写成实例容器能访问到的地址。
- `APP_INSTANCE_PROVIDER`
- 新实例默认 provider。
- `APP_INSTANCE_MODEL`
- 新实例默认模型。
- `APP_INSTANCE_API_KEY`
- 新实例默认 API key。
- `APP_INSTANCE_API_BASE`
- 新实例默认 API base。
- `DEFAULT_AUTHZ_BASE_URL`
- `deploy-control` 在没收到显式 `authz_base_url` 时,给新实例写入的兜底 AuthZ 地址。
## 关键配置关系
当前版本里provider 配置不是从 AuthZ setting 下发的。
它是在创建实例时由 `deploy-control` 写入 `app-instance``config.json`
`DEPLOY_API_TOKEN``DEPLOY_CONTROL_API_TOKEN` 必须相等:
## 目录持久化
- `auth-portal` / `authz-service``DEPLOY_API_TOKEN` 请求 `deploy-control`
- `deploy-control``DEPLOY_CONTROL_API_TOKEN` 校验请求。
至少保留这几个目录
- `authz-service/runtime/data`
- `app-instance/runtime`
- `router-proxy/runtime`
建议先创建:
```bash
mkdir -p \
"$PROJECT_ROOT/authz-service/runtime/data" \
"$PROJECT_ROOT/app-instance/runtime/instances" \
"$PROJECT_ROOT/app-instance/runtime/registry" \
"$PROJECT_ROOT/router-proxy/runtime/conf.d"
```
## 1. 构建镜像
先把四个镜像都构建好。虽然 `deploy-control` 在镜像缺失时也能触发构建,但上线前先显式构建更容易排错。
```bash
cd "$PROJECT_ROOT"
docker build -t nano/app-instance:latest app-instance
docker build -t nano/authz-service:latest authz-service
docker build -t nano/deploy-control:latest deploy-control
docker build -t nano/auth-portal:latest auth-portal/src
```
## 2. 创建共享网络
```bash
docker network inspect "$NANO_NET" >/dev/null 2>&1 || docker network create "$NANO_NET"
```
## 3. 启动 router-proxy
```bash
cd "$PROJECT_ROOT"
PROXY_NETWORK_NAME="$NANO_NET" \
PROXY_HTTP_PORT=8088 \
./router-proxy/start-proxy.sh --replace
```
默认对外入口:
- `router-proxy`: `http://<slug>.<base_domain>:8088`
## 4. 启动 authz-service
```bash
docker rm -f nano-authz-service >/dev/null 2>&1 || true
docker run -d \
--name nano-authz-service \
--restart unless-stopped \
--network "$NANO_NET" \
-p 19090:19090 \
-v "$PROJECT_ROOT/authz-service/runtime/data:/var/lib/authz-service/data" \
-e AUTHZ_ISSUER="$NANO_AUTHZ_URL" \
-e AUTHZ_INTERNAL_TOKEN="$NANO_AUTHZ_INTERNAL_TOKEN" \
-e DEPLOY_API_BASE_URL="$NANO_DEPLOY_URL" \
-e DEPLOY_API_TOKEN="$NANO_DEPLOY_TOKEN" \
nano/authz-service:latest
```
这里最重要的是:
- `AUTHZ_ISSUER` 现在不能只按“外部访问地址”理解
- 它必须是后续 `app-instance` 容器也能访问到的地址
- 这套单机 Docker 方案里直接用 `http://nano-authz-service:19090`
## 5. 启动 deploy-control
`deploy-control` 需要高权限:
- 读写 Docker socket
- 访问 `app-instance/`
- 访问 `router-proxy/`
```bash
docker rm -f nano-deploy-control >/dev/null 2>&1 || true
docker run -d \
--name nano-deploy-control \
--restart unless-stopped \
--network "$NANO_NET" \
-p 8090:8090 \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$PROJECT_ROOT/app-instance:$PROJECT_ROOT/app-instance" \
-v "$PROJECT_ROOT/router-proxy:$PROJECT_ROOT/router-proxy" \
-e APP_INSTANCE_DIR="$PROJECT_ROOT/app-instance" \
-e ROUTER_PROXY_DIR="$PROJECT_ROOT/router-proxy" \
-e DEPLOY_CONTROL_API_TOKEN="$NANO_DEPLOY_TOKEN" \
-e APP_INSTANCE_IMAGE="nano/app-instance:latest" \
-e APP_INSTANCE_NETWORK_NAME="$NANO_NET" \
-e APP_INSTANCE_PROVIDER="$NANO_PROVIDER" \
-e APP_INSTANCE_MODEL="$NANO_MODEL" \
-e APP_INSTANCE_API_KEY="$NANO_API_KEY" \
-e APP_INSTANCE_API_BASE="$NANO_API_BASE" \
-e DEFAULT_AUTHZ_BASE_URL="$NANO_AUTHZ_URL" \
-e DEFAULT_AUTHZ_OUTLOOK_MCP_URL="$NANO_OUTLOOK_MCP_URL" \
-e DEFAULT_OUTLOOK_MCP_SERVER_ID="$NANO_OUTLOOK_MCP_SERVER_ID" \
-e DEPLOY_PUBLIC_SCHEME="http" \
-e DEPLOY_PUBLIC_BASE_DOMAIN="$NANO_BASE_DOMAIN" \
-e DEPLOY_PUBLIC_PORT="8088" \
-e DEPLOY_AUTO_START_PROXY="1" \
nano/deploy-control:latest
```
这里不要把宿主机目录挂到容器内的另一个短路径,比如 `/app-instance`
原因是 `deploy-control` 会通过挂载进来的 Docker socket 再去创建 `app-instance` 容器;这时传给 Docker 的 bind mount 源路径必须是宿主机真实路径。如果你把宿主机目录映射成容器内短路径,`create-instance.sh` 生成的挂载源就会变成错误路径,最终表现为:
- 注册接口超时
- `app-instance` 容器反复重启
- 日志里出现 `Missing Boardware Genius config: /root/.nanobot/config.json`
当前版本里,新实例的默认大模型配置就是从这里分发的:
- `APP_INSTANCE_PROVIDER`
- `APP_INSTANCE_MODEL`
- `APP_INSTANCE_API_KEY`
- `APP_INSTANCE_API_BASE`
如果 `APP_INSTANCE_API_KEY` 没配,新用户注册时创建实例会直接失败。
## 6. 启动 auth-portal
```bash
docker rm -f nano-auth-portal >/dev/null 2>&1 || true
docker run -d \
--name nano-auth-portal \
--restart unless-stopped \
--network "$NANO_NET" \
-p 3081:3081 \
-e AUTHZ_API_BASE_URL="$NANO_AUTHZ_URL" \
-e DEPLOY_API_BASE_URL="$NANO_DEPLOY_URL" \
-e DEPLOY_API_TOKEN="$NANO_DEPLOY_TOKEN" \
nano/auth-portal:latest
```
当前页面入口:
- `http://<server-ip>:3081`
## 7. 上线前健康检查
先确认四个基础组件都起来了:
```bash
curl http://127.0.0.1:19090/healthz
curl http://127.0.0.1:8090/healthz
curl -I http://127.0.0.1:3081
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
```
再确认 `router-proxy` 在跑:
```bash
docker logs --tail=50 nano-router-proxy
```
## 8. 首次注册验收
打开:
`AUTHZ_ISSUER` 在这套单机部署里要写容器网络地址
```text
http://<server-ip>:3081/register
http://beaver-authz-service:19090
```
注册一个新用户后,预期结果是:
不要写成 `http://127.0.0.1:19090`,因为新创建的 `app-instance` 容器里的 `127.0.0.1` 指向它自己,不是 AuthZ 容器。
1. `auth-portal``authz-service /portal/register`
2. `authz-service``deploy-control /api/instances/register`
3. `deploy-control` 创建一个新的 `app-instance`
4. `app-instance` 回调 AuthZ 完成 backend 身份初始化
5. 浏览器被跳转到该实例自己的 URL
同时你应该能看到:
`DEPLOY_PUBLIC_*` 决定新实例展示给用户的 URL
```bash
cd "$PROJECT_ROOT"
./app-instance/list-instances.sh --json
DEPLOY_PUBLIC_SCHEME=http
DEPLOY_PUBLIC_BASE_DOMAIN=127.0.0.1.nip.io
DEPLOY_PUBLIC_PORT=8088
```
实例 URL 形如:
本机测试时实例 URL 形如:
```text
http://<instance-slug>.<base-domain>:8088
http://alice.127.0.0.1.nip.io:8088
```
如果你前面用的是
正式 HTTPS 域名通常改成
```bash
DEPLOY_PUBLIC_SCHEME=https
DEPLOY_PUBLIC_BASE_DOMAIN=apps.example.com
DEPLOY_PUBLIC_PORT=443
```
实例 URL 形如:
```text
NANO_BASE_DOMAIN=<server-ip>.nip.io
https://alice.apps.example.com
```
那么实例地址会像:
前提是你已经在项目外层把 `*.apps.example.com``80/443` 流量转发到 `router-proxy:8088`
## 模型配置方式
当前版本不会在注册创建实例时写入模型 provider、model 或 API key。
流程是:
1. 注册先创建一个不含模型凭证的实例。
2. `auth-portal` 进入模型配置引导页。
3. 用户确认后Portal 调用 `deploy-control /api/instances/configure-provider`
4. `deploy-control` 写入该实例的 `config.json` 并重启对应容器。
如果用户跳过引导,实例仍会创建成功,但后续需要在实例内补齐 provider 配置后才能正常调用模型。
## 持久化目录
至少保留:
```text
http://alice.203.0.113.10.nip.io:8088
authz-service/runtime/data
app-instance/runtime/instances
app-instance/runtime/registry
router-proxy/runtime/conf.d
```
## 9. 常见问题
不要在需要保留账号、实例或配置时删除这些目录。
### 1. 为什么不要把 `AUTHZ_ISSUER` 写成 `http://127.0.0.1:19090`
## 模板文件
因为 `app-instance` 容器里访问 `127.0.0.1` 只会打到它自己,不会打到 AuthZ 容器。
在当前实现里,`AUTHZ_ISSUER` 会被直接传给新实例当作 `authz_base_url`
可参考这些环境变量模板:
### 2. `DEPLOY_API_TOKEN` 和 `DEPLOY_CONTROL_API_TOKEN` 为什么要一样
- [`.env.example`](./.env.example)
- [`auth-portal/src/.env.example`](./auth-portal/src/.env.example)
- [`authz-service/.env.example`](./authz-service/.env.example)
- [`deploy-control/.env.example`](./deploy-control/.env.example)
- [`router-proxy/.env.example`](./router-proxy/.env.example)
因为一个是客户端发出去的 token一个是服务端拿来校验的 token
这些模板不会被脚本自动加载。你可以手动 `export`,也可以在 `docker run` 时使用 `--env-file`
### 3. 这些 provider 配置是写到哪里
## 子项目文档
写到每个实例自己的:
```text
app-instance/runtime/instances/<slug>/nanobot-home/config.json
```
不是写在 AuthZ 的某个 setting 里。
### 4. 现在支持每个用户注册时填自己的 API key 吗
后端请求模型已经支持 `provider/model/api_key/api_base` 字段,但当前 `auth-portal` 页面没有把这些字段暴露出来。
当前上线流程默认是:所有新实例先继承 `deploy-control` 上配置的默认 provider 凭证。
### 5. 现在内置 HTTPS 吗
没有。
当前内置 `router-proxy` 是 HTTP 入口。如果你要公网 HTTPS
- 在外面再放一层 Nginx / Caddy / LB 做 TLS 终止
- 再把流量转给 `router-proxy:8088``auth-portal:3081`
## 10. 仓库结构
```text
/home/ivan/xuan/nano_project
├── README.md
├── app-instance/
├── auth-portal/
├── authz-service/
├── deploy-control/
└── router-proxy/
```
各子目录更细的实现说明见:
- [`app-instance/README.md`](/home/ivan/xuan/nano_project/app-instance/README.md)
- [`auth-portal/src/README.md`](/home/ivan/xuan/nano_project/auth-portal/src/README.md)
- [`authz-service/README.md`](/home/ivan/xuan/nano_project/authz-service/README.md)
- [`deploy-control/README.md`](/home/ivan/xuan/nano_project/deploy-control/README.md)
- [`router-proxy/README.md`](/home/ivan/xuan/nano_project/router-proxy/README.md)
- [`app-instance/README.md`](./app-instance/README.md)
- [`auth-portal/src/README.md`](./auth-portal/src/README.md)
- [`authz-service/README.md`](./authz-service/README.md)
- [`deploy-control/README.md`](./deploy-control/README.md)
- [`router-proxy/README.md`](./router-proxy/README.md)

145
agents/registry.json Normal file
View File

@ -0,0 +1,145 @@
{
"agents": [
{
"agent_id": "researcher",
"capabilities": [
"research",
"analysis",
"source review",
"requirements"
],
"created_at": "2026-05-11T03:13:06.912240+00:00",
"description": "Finds facts, references, constraints, and implementation options.",
"display_name": "Researcher",
"metadata": {},
"model": null,
"name": "researcher",
"priority": 50,
"provider_name": null,
"role": "research",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a research specialist. Gather concise evidence and tradeoffs for the parent task.",
"tags": [
"planning",
"research"
],
"tool_hints": [],
"updated_at": "2026-05-11T03:13:06.912247+00:00"
},
{
"agent_id": "implementer",
"capabilities": [
"implementation",
"coding",
"refactor",
"integration"
],
"created_at": "2026-05-11T03:13:06.912250+00:00",
"description": "Builds scoped implementation slices and proposes concrete changes.",
"display_name": "Implementer",
"metadata": {},
"model": null,
"name": "implementer",
"priority": 45,
"provider_name": null,
"role": "implementation",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are an implementation specialist. Produce practical, scoped implementation output.",
"tags": [
"coding",
"build"
],
"tool_hints": [],
"updated_at": "2026-05-11T03:13:06.912251+00:00"
},
{
"agent_id": "reviewer",
"capabilities": [
"review",
"quality",
"risk",
"verification"
],
"created_at": "2026-05-11T03:13:06.912252+00:00",
"description": "Reviews plans, code, outputs, and risks before final synthesis.",
"display_name": "Reviewer",
"metadata": {},
"model": null,
"name": "reviewer",
"priority": 45,
"provider_name": null,
"role": "review",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a review specialist. Focus on defects, missing requirements, and risks.",
"tags": [
"review",
"quality"
],
"tool_hints": [],
"updated_at": "2026-05-11T03:13:06.912253+00:00"
},
{
"agent_id": "tester",
"capabilities": [
"testing",
"verification",
"regression",
"qa"
],
"created_at": "2026-05-11T03:13:06.912255+00:00",
"description": "Designs and executes verification checks for task outputs.",
"display_name": "Tester",
"metadata": {},
"model": null,
"name": "tester",
"priority": 40,
"provider_name": null,
"role": "testing",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a testing specialist. Identify focused checks and report pass/fail evidence.",
"tags": [
"test",
"quality"
],
"tool_hints": [],
"updated_at": "2026-05-11T03:13:06.912256+00:00"
},
{
"agent_id": "documenter",
"capabilities": [
"documentation",
"explanation",
"migration notes",
"release notes"
],
"created_at": "2026-05-11T03:13:06.912257+00:00",
"description": "Writes and reconciles user-facing and internal documentation updates.",
"display_name": "Documenter",
"metadata": {},
"model": null,
"name": "documenter",
"priority": 35,
"provider_name": null,
"role": "documentation",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a documentation specialist. Produce concise docs aligned with the implementation.",
"tags": [
"docs",
"communication"
],
"tool_hints": [],
"updated_at": "2026-05-11T03:13:06.912258+00:00"
}
],
"version": 1
}

View File

@ -36,7 +36,10 @@ ENV DEBIAN_FRONTEND=noninteractive \
APP_PUBLIC_PORT=8080 \
APP_FRONTEND_PORT=3000 \
APP_BACKEND_PORT=18080 \
NANOBOT_AUTH_FILE=/root/.nanobot/web_auth_users.json \
BEAVER_HOME=/root/.beaver \
BEAVER_CONFIG_PATH=/root/.beaver/config.json \
BEAVER_WORKSPACE=/root/.beaver/workspace \
BEAVER_AUTH_FILE=/root/.beaver/web_auth_users.json \
PORT=3000 \
HOSTNAME=127.0.0.1
@ -58,22 +61,10 @@ RUN apt-get update && \
WORKDIR /opt/app/backend
COPY backend/pyproject.toml backend/README.md backend/LICENSE ./
RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
uv pip install --system --no-cache .
COPY backend/nanobot/ ./nanobot/
COPY backend/bridge/ ./bridge/
COPY backend/pyproject.toml backend/README.md ./
COPY backend/beaver/ ./beaver/
RUN uv pip install --system --no-cache .
WORKDIR /opt/app/backend/bridge
RUN --mount=type=cache,target=/root/.npm \
npm config set registry "${NPM_REGISTRY}" && \
npm config set fetch-retries "${NPM_FETCH_RETRIES}" && \
npm config set fetch-retry-mintimeout "${NPM_FETCH_RETRY_MIN_TIMEOUT}" && \
npm config set fetch-retry-maxtimeout "${NPM_FETCH_RETRY_MAX_TIMEOUT}" && \
npm install && npm run build
WORKDIR /opt/app/frontend
COPY --from=frontend-builder /build/frontend/next.config.js ./
COPY --from=frontend-builder /build/frontend/public ./public
@ -86,7 +77,7 @@ COPY nginx.conf /opt/app/nginx.conf
COPY entrypoint.sh /opt/app/entrypoint.sh
RUN chmod +x /opt/app/entrypoint.sh && \
mkdir -p /var/lib/nginx/body /root/.nanobot/workspace
mkdir -p /var/lib/nginx/body /root/.beaver/workspace
EXPOSE 8080

View File

@ -45,14 +45,14 @@ runtime/registry/instances.json
### 1. 构建镜像
```bash
docker build -t nano/app-instance:latest .
docker build -t beaver/app-instance:latest .
```
### 2. 创建实例
```bash
./create-instance.sh \
--image nano/app-instance:latest \
--image beaver/app-instance:latest \
--instance-id demo-001 \
--auth-username admin \
--auth-password 123456 \
@ -106,17 +106,33 @@ runtime/instances/<instance-slug>/
```text
runtime/instances/<instance-slug>/
└── nanobot-home
└── beaver-home
├── config.json
├── web_auth_users.json
└── workspace/
```
这个目录是单用户 sandbox 的配置与数据边界。容器内会把它挂到:
```text
/root/.beaver/
```
并设置:
```text
BEAVER_CONFIG_PATH=/root/.beaver/config.json
BEAVER_WORKSPACE=/root/.beaver/workspace
```
所以模型 `provider/api_key/api_base/model` 配一次即可Web / channel 请求不需要、也不应该携带 API Key。
## 当前状态
这层已经支持:
- 统一镜像构建
- 镜像内安装并启动新的 `beaver` 后端
- 实例创建
- 实例删除
- 实例列表

View File

@ -0,0 +1,145 @@
{
"agents": [
{
"agent_id": "researcher",
"capabilities": [
"research",
"analysis",
"source review",
"requirements"
],
"created_at": "2026-05-11T03:13:06.921512+00:00",
"description": "Finds facts, references, constraints, and implementation options.",
"display_name": "Researcher",
"metadata": {},
"model": null,
"name": "researcher",
"priority": 50,
"provider_name": null,
"role": "research",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a research specialist. Gather concise evidence and tradeoffs for the parent task.",
"tags": [
"planning",
"research"
],
"tool_hints": [],
"updated_at": "2026-05-11T03:13:06.921520+00:00"
},
{
"agent_id": "implementer",
"capabilities": [
"implementation",
"coding",
"refactor",
"integration"
],
"created_at": "2026-05-11T03:13:06.921522+00:00",
"description": "Builds scoped implementation slices and proposes concrete changes.",
"display_name": "Implementer",
"metadata": {},
"model": null,
"name": "implementer",
"priority": 45,
"provider_name": null,
"role": "implementation",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are an implementation specialist. Produce practical, scoped implementation output.",
"tags": [
"coding",
"build"
],
"tool_hints": [],
"updated_at": "2026-05-11T03:13:06.921523+00:00"
},
{
"agent_id": "reviewer",
"capabilities": [
"review",
"quality",
"risk",
"verification"
],
"created_at": "2026-05-11T03:13:06.921527+00:00",
"description": "Reviews plans, code, outputs, and risks before final synthesis.",
"display_name": "Reviewer",
"metadata": {},
"model": null,
"name": "reviewer",
"priority": 45,
"provider_name": null,
"role": "review",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a review specialist. Focus on defects, missing requirements, and risks.",
"tags": [
"review",
"quality"
],
"tool_hints": [],
"updated_at": "2026-05-11T03:13:06.921528+00:00"
},
{
"agent_id": "tester",
"capabilities": [
"testing",
"verification",
"regression",
"qa"
],
"created_at": "2026-05-11T03:13:06.921529+00:00",
"description": "Designs and executes verification checks for task outputs.",
"display_name": "Tester",
"metadata": {},
"model": null,
"name": "tester",
"priority": 40,
"provider_name": null,
"role": "testing",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a testing specialist. Identify focused checks and report pass/fail evidence.",
"tags": [
"test",
"quality"
],
"tool_hints": [],
"updated_at": "2026-05-11T03:13:06.921530+00:00"
},
{
"agent_id": "documenter",
"capabilities": [
"documentation",
"explanation",
"migration notes",
"release notes"
],
"created_at": "2026-05-11T03:13:06.921533+00:00",
"description": "Writes and reconciles user-facing and internal documentation updates.",
"display_name": "Documenter",
"metadata": {},
"model": null,
"name": "documenter",
"priority": 35,
"provider_name": null,
"role": "documentation",
"skill_names": [],
"source": "builtin",
"status": "active",
"system_prompt": "You are a documentation specialist. Produce concise docs aligned with the implementation.",
"tags": [
"docs",
"communication"
],
"tool_hints": [],
"updated_at": "2026-05-11T03:13:06.921534+00:00"
}
],
"version": 1
}

View File

@ -1,13 +0,0 @@
__pycache__
*.pyc
*.pyo
*.pyd
*.egg-info
dist/
build/
.git
.env
.assets
node_modules/
bridge/dist/
workspace/

View File

@ -1,201 +0,0 @@
<<<<<<< HEAD
.assets
.env
*.pyc
dist/
build/
docs/
*.egg-info/
*.egg
*.pyc
*.pyo
*.pyd
*.pyw
*.pyz
*.pywz
*.pyzz
.venv/
venv/
__pycache__/
poetry.lock
.pytest_cache/
botpy.log
tests/
=======
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
>>>>>>> origin/main

View File

@ -1,753 +0,0 @@
# A2A Multi-Agent 改造方案
## 1. 需求目标
当前 `spawn`/`sub-agent` 只有一种执行方式: 创建一个本地后台 subagent 去完成任务。
这次需求要改成:
1. 调用 `sub-agent` 时,不一定新建本地 subagent。
2. 先从“已添加的 Agent”里找可用目标。
3. 再从 skills 中声明的 `agent cards` 里找可用目标。
4. 通过 A2A 协议把任务发给对应 agent。
5. 支持一个任务发给多个 agent形成 `agent group`,最后回到主 agent 汇总。
6. 保持现有 `spawn(task, label)` 兼容,不破坏已有行为。
结论先说:
- 最合适的做法不是继续把能力堆进 `SubagentManager`
- 应该把“本地 subagent 执行”升级为“统一委派层”。
- `spawn` 工具继续保留,但语义从“创建 subagent”扩展为“委派给合适的 agent / agent group”。
## 2. 当前代码现状
### 2.1 当前触发链路
现有链路很单一:
1. `AgentLoop` 初始化 `SubagentManager`
- 位置: `nanobot/agent/loop.py:88-114`
2. `AgentLoop._register_default_tools()` 注册 `SpawnTool`
- 位置: `nanobot/agent/loop.py:116-138`
3. LLM 调用 `spawn(task, label)`
4. `SpawnTool.execute()` 直接转发给 `SubagentManager.spawn()`
- 位置: `nanobot/agent/tools/spawn.py:67-76`
5. `SubagentManager.spawn()` 创建本地 asyncio 后台任务
- 位置: `nanobot/agent/subagent.py:64-93`
6. `_run_subagent()` 用一个受限工具集运行本地子代理
- 位置: `nanobot/agent/subagent.py:95-195`
7. `_announce_result()` 把结果包装成 `channel="system"` 的消息回投主消息总线
- 位置: `nanobot/agent/subagent.py:197-230`
8. `AgentLoop._process_message()` 接到 `system` 消息,再整理成用户可见回复
- 位置: `nanobot/agent/loop.py:331-347`
### 2.2 当前已经有但没接入调度链路的能力
仓库里已经有两类“候选 agent 信息”,但没有进入实际调度:
1. Plugin agents
- `PluginLoader.find_agent()` 已能找 agent
- 位置: `nanobot/agent/plugins.py:83-91`
- `build_agents_summary()` 也已能汇总 agent 信息
- 位置: `nanobot/agent/plugins.py:100-121`
- 但当前 `AgentLoop` / `ContextBuilder` 并没有用它做调度
2. Skills
- `SkillsLoader` 已能枚举 / 读取 skill
- 位置: `nanobot/agent/skills.py:32-249`
- 但 skill 目前只被当作 prompt 资源,不会暴露成“可路由 agent”
### 2.3 当前缺口
当前缺少这几层:
1. 统一的 `Agent Registry`
2. A2A `agent card` 发现与缓存
3. A2A client 调用层
4. 统一的委派器,负责在“本地 subagent / plugin agent / skill agent card / agent group”之间做路由
5. group 级别的状态管理和结果聚合
## 3. 推荐总方案
推荐采用“保留 `spawn` 工具名,重构内部执行层”的方案。
### 3.1 核心思路
把当前:
- `SpawnTool -> SubagentManager -> 本地 subagent`
改成:
- `SpawnTool -> DelegationManager -> AgentResolver -> Executor(local/plugin/a2a/group)`
也就是:
1. `spawn` 不再等价于“必须创建 subagent”。
2. `spawn` 变成“委派任务”。
3. 真正执行方式由委派层动态决定。
### 3.2 为什么这样最合适
如果直接继续扩 `SubagentManager`,很快会出现这些问题:
1. 一个类同时负责本地 LLM 运行、A2A 网络调用、agent card 发现、group 并发、结果聚合。
2. 后续要支持 plugin agent、本地 named agent、A2A streaming 时会越来越乱。
3. 当前 `SubagentManager` 的职责本来就已经比较明确: “本地后台 subagent 执行器”。
所以更合理的拆法是:
1. `SubagentManager` 保留或下沉为 `LocalSubagentExecutor`
2. 新增 `DelegationManager` 作为统一入口
3. 新增 `AgentRegistry` / `AgentResolver`
4. 新增 `A2AClient`
## 4. 推荐模块拆分
### 4.1 新增 `DelegationManager`
建议新文件:
- `nanobot/agent/delegation.py`
职责:
1. 接收 `spawn` 请求
2. 根据参数和任务内容选择目标 agent
3. 决定执行方式
4. 对 group 做并发调度
5. 统一把结果回投主消息总线
建议接口:
```python
class DelegationManager:
async def dispatch(
self,
task: str,
label: str | None = None,
target: str | None = None,
targets: list[str] | None = None,
strategy: str = "auto",
origin_channel: str = "cli",
origin_chat_id: str = "direct",
) -> str: ...
```
### 4.2 保留本地执行器
当前 `nanobot/agent/subagent.py``_run_subagent()` 逻辑可以保留,但角色改为:
- `LocalSubagentExecutor`
也可以第一版不重命名文件,只把里面逻辑拆成:
1. `spawn_local()`
2. `_run_local_subagent()`
3. `_announce_local_result()`
这样可以最小改动落地。
### 4.3 新增 `AgentRegistry`
建议新文件:
- `nanobot/agent/agent_registry.py`
职责:
1. 汇总所有可调度 agent
2. 统一输出规范化 descriptor
3. 维护优先级和去重逻辑
统一后的 agent 来源:
1. workspace 中“已添加的 agent”
2. plugin agents
3. skill frontmatter 里声明的 `agent_cards`
4. 必要时 fallback 到本地 `local-subagent`
建议统一 descriptor:
```python
@dataclass
class AgentDescriptor:
id: str
name: str
description: str
source: str # workspace | plugin | skill | builtin
kind: str # local_prompt | a2a_remote | local_fallback
protocol: str | None # a2a | None
plugin_name: str | None = None
skill_name: str | None = None
model: str | None = None
endpoint: str | None = None
card_url: str | None = None
tags: list[str] = field(default_factory=list)
capabilities: dict[str, Any] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
```
### 4.4 新增 A2A client 层
建议新目录:
- `nanobot/a2a/client.py`
- `nanobot/a2a/cards.py`
- `nanobot/a2a/models.py`
职责:
1. 获取 agent card
2. 解析 card 能力
3. 对远端 agent 发 JSON-RPC 请求
4. 处理同步返回 / task 轮询 / streaming 兼容
## 5. 代码插入点
## 5.1 `nanobot/agent/loop.py`
### 插入点 A: `__init__`
当前:
- `self.subagents = SubagentManager(...)`
- 位置: `nanobot/agent/loop.py:88-102`
建议改成:
1. 初始化 `PluginLoader`
2. 初始化 `AgentRegistry`
3. 初始化 `DelegationManager`
4. `DelegationManager` 内部持有 `LocalSubagentExecutor` / `A2AExecutor`
推荐形态:
```python
self.plugins = PluginLoader(workspace)
self.agent_registry = AgentRegistry(workspace, plugins=self.plugins, ...)
self.delegation = DelegationManager(
provider=provider,
workspace=workspace,
bus=bus,
registry=self.agent_registry,
...
)
```
### 插入点 B: `_register_default_tools`
当前:
- 注册 `SpawnTool(manager=self.subagents)`
- 位置: `nanobot/agent/loop.py:130-134`
建议改成:
```python
self.tools.register(SpawnTool(manager=self.delegation))
```
### 插入点 C: `_set_tool_context`
当前会给 `spawn` 工具写 origin context:
- 位置: `nanobot/agent/loop.py:165-192`
这里逻辑可以继续保留,不需要大改,因为 A2A / group 结果最终也要回到原会话。
## 5.2 `nanobot/agent/tools/spawn.py`
当前 `SpawnTool` 参数只有:
- `task`
- `label`
位置:
- schema: `nanobot/agent/tools/spawn.py:49-65`
- execute: `nanobot/agent/tools/spawn.py:67-76`
建议扩成:
```python
{
"task": "string",
"label": "string?",
"target": "string?",
"targets": "string[]?",
"strategy": "auto|local|plugin|a2a|group"
}
```
兼容规则:
1. 老调用只传 `task/label` 时,等价于 `strategy="auto"`
2. `target` 表示单目标
3. `targets` 表示 group
4. `strategy="local"` 强制走本地 subagent
5. `strategy="a2a"` 强制只找 A2A 目标
## 5.3 `nanobot/agent/context.py`
当前 prompt 中只注入:
1. bootstrap
2. memory
3. skills summary
位置:
- `build_system_prompt()`: `nanobot/agent/context.py:38-76`
建议新增一段:
- `# Available Agents`
`AgentRegistry.build_agents_summary()` 生成,内容只放:
1. agent id / name
2. 简短 description
3. source
4. protocol
5. 是否支持 group / streaming
目标是让主 agent 知道:
1. 当前有哪些现成 agent 可用
2. 什么时候应该 `spawn(target=...)`
3. 哪些是 skill 暴露出来的 A2A agent
## 5.4 `nanobot/agent/skills.py`
这是 skill agent cards 的关键入口。
当前 skill frontmatter 已支持 `metadata` 字段,并会解析其中的 JSON:
- `_parse_nanobot_metadata()`: `nanobot/agent/skills.py:190-196`
- `_get_skill_meta()`: `nanobot/agent/skills.py:209-212`
最推荐的做法不是去扫 `SKILL.md` 正文里的自由文本,而是约定 skill frontmatter 的 `metadata.nanobot.agent_cards`
建议新增:
```python
def list_skill_agent_cards(self) -> list[dict[str, Any]]: ...
```
推荐 skill 写法:
```md
---
name: github-research
description: GitHub research helper
metadata: '{"nanobot":{"agent_cards":[{"id":"repo-analyst","url":"https://example.com/.well-known/agent-card","tags":["github","research"],"auth_env":"REPO_AGENT_TOKEN"}]}}'
---
```
为什么推荐这样做:
1. 当前 frontmatter 解析已经存在
2. 不需要引入新的 skill 文件格式
3. 不需要解析自由文本
4. skill 打包/上传链路也不需要大改
## 5.5 `nanobot/agent/plugins.py`
当前 plugin agents 已能加载:
- `find_agent()`: `nanobot/agent/plugins.py:83-91`
- `_load_agents()`: `nanobot/agent/plugins.py:210-229`
建议:
1. `AgentRegistry` 直接复用 `PluginLoader`
2. plugin agent 作为“本地可执行 agent”来源之一
这里不建议把 plugin agent 强行转成 A2A。
更合理的处理是:
1. plugin agent 本地执行
2. skill agent cards 远程 A2A 调用
3. workspace 手动添加的 agent 也可走 A2A
## 5.6 `nanobot/config/schema.py`
当前 `ToolsConfig` 只有:
- `web`
- `exec`
- `restrict_to_workspace`
- `mcp_servers`
位置:
- `nanobot/config/schema.py:337-347`
建议新增:
```python
class A2AConfig(Base):
enabled: bool = True
timeout_seconds: int = 30
poll_interval_seconds: int = 2
card_cache_ttl_seconds: int = 300
max_parallel_agents: int = 4
allow_skill_cards: bool = True
allow_workspace_agents: bool = True
allowed_hosts: list[str] = Field(default_factory=list)
```
然后挂到:
```python
class ToolsConfig(Base):
...
a2a: A2AConfig = Field(default_factory=A2AConfig)
```
## 5.7 `nanobot/web/server.py`
当前 web API 有:
- `/api/skills`
- `/api/plugins`
位置:
- skills: `nanobot/web/server.py:702-843`
- plugins: `nanobot/web/server.py:1000-1037`
建议新增:
1. `GET /api/agents`
- 返回统一后的 agent registry
2. `POST /api/agents`
- 添加 workspace agent card
3. `DELETE /api/agents/{id}`
- 删除 workspace agent
4. `POST /api/agents/refresh`
- 刷新 card cache
这样“已添加的 Agent”才有明确的持久化来源。
## 6. 推荐的数据来源优先级
为了行为稳定,推荐 resolver 按以下优先级匹配:
1. workspace 手动添加的 agent
2. plugin agents
3. skill metadata 里的 agent cards
4. fallback 到本地 subagent
原因:
1. workspace 手动添加通常是用户明确希望接入的 agent
2. plugin agent 是本地稳定能力
3. skill card 往往是外部资源,可信度和可用性最弱
4. 本地 subagent 最后兜底,保证老行为不失效
## 7. A2A 协议接入建议
## 7.1 Agent Card 发现
建议支持 3 种入口:
1. 显式 `card_url`
2. `base_url + /.well-known/agent-card`
3. fallback `base_url + /.well-known/agent.json`
这样做的原因是:
1. 当前 A2A 文档和样例在 card 路径上存在新旧写法并存
2. 兼容性会更好
## 7.2 RPC 调用兼容层
建议客户端优先尝试:
1. `tasks/send`
2. 不支持时 fallback `message/send`
后续可选支持:
1. `tasks/sendSubscribe`
2. `message/sendStream`
3. `tasks/get`
4. `tasks/cancel`
推荐第一期先做:
1. 非流式发任务
2. 如果返回 `Task` 状态不是最终态,就轮询 `tasks/get`
这样能最小代价先打通。
## 7.3 发送给远端 agent 的上下文范围
不要把主会话完整 history 直接发给远端 agent。
建议第一版只发送:
1. 任务目标
2. 必要的结构化说明
3. 主 agent 整理好的最小上下文
原因:
1. 当前本地 subagent 也不共享主会话历史
2. 外部 A2A agent 不可信时,最小化数据泄漏面
3. 避免 token 膨胀
## 8. agent group 设计
## 8.1 什么时候触发 group
建议第一版只支持两种触发:
1. 用户明确指定多个 agent
2. LLM 在工具调用里显式传 `targets=[...]`
不建议第一版做“自动拆成多个 agent 并行”的强自动化。
原因:
1. 容易失控
2. 很难解释为什么调了这些 agent
3. 对成本和网络调用不可控
## 8.2 group 执行链路
推荐链路:
1. `SpawnTool.execute()` 收到 `targets`
2. `DelegationManager.dispatch()` 创建 `group_run_id`
3. `AgentRegistry` 解析出每个 target 的 descriptor
4. 按 executor 类型并发执行
5. `asyncio.gather(..., return_exceptions=True)` 收集结果
6. 统一做 group aggregation
7. `_announce_group_result()` 回投主消息总线
8. 主 agent 再生成最终用户回复
## 8.3 group 结果聚合
建议 group 执行器输出结构化结果:
```python
@dataclass
class AgentRunResult:
agent_id: str
status: str # ok | error | timeout | cancelled
summary: str
raw: dict[str, Any] | None = None
```
group 最终回投内容建议类似:
```text
[Agent group 'repo-check' completed]
Members:
- researcher: ok
- reviewer: ok
- planner: error
Results:
...
Summarize this naturally for the user. Mention disagreements if any.
```
这样能继续复用当前 `system -> main agent -> user` 的输出模式。
## 9. 推荐触发方式
## 9.1 用户显式触发
用户说法示例:
1. “把这个任务交给 `github-reviewer`
2. “让 `researcher``reviewer` 一起处理”
3. “如果有现成 agent 就不要新建 subagent”
这时主 agent 应调用:
```json
{
"task": "...",
"target": "github-reviewer"
}
```
或者:
```json
{
"task": "...",
"targets": ["researcher", "reviewer"],
"strategy": "group"
}
```
## 9.2 模型自主触发
当主 agent 判断:
1. 任务独立可并行
2. 已有 agent 专长明显更匹配
3. 任务耗时长,适合后台执行
则调用 `spawn`,但不再默认认为一定是“新建本地 subagent”。
## 9.3 自动回退
如果没有找到匹配 agent:
1. `strategy=auto` -> fallback 本地 subagent
2. `strategy=a2a` -> 直接返回未找到
3. `strategy=group` 且部分目标不存在 -> 明确报错或只跑已解析目标,建议第一版严格报错
## 10. workspace 中“已添加 agent”的建议存储
建议新增:
- `workspace/agents/registry.json`
示例:
```json
[
{
"id": "github-reviewer",
"name": "GitHub Reviewer",
"description": "Review GitHub repository changes",
"protocol": "a2a",
"base_url": "https://reviewer.example.com/a2a",
"card_url": "https://reviewer.example.com/.well-known/agent-card",
"auth_env": "GITHUB_REVIEWER_TOKEN",
"enabled": true,
"tags": ["github", "review"]
}
]
```
为什么不用直接塞进 `config.json`:
1. 这是 workspace 维度资源,不是全局运行参数
2. web API 做增删改查更方便
3. 不要求用户每次改 agent 都改配置再重启
## 11. 推荐实施顺序
### Phase 1: 打通单 agent 路由
目标:
1. 引入 `AgentRegistry`
2. `spawn` 支持 `target`
3. 支持 workspace agent 和 skill agent card
4. 支持 A2A 单点调用
5. 找不到时 fallback 本地 subagent
### Phase 2: 接入 plugin agent 本地执行
目标:
1. plugin agent 进入统一 registry
2. plugin agent 可作为 `target`
3. 本地 prompt-based agent 与 A2A remote agent 共存
### Phase 3: group 并发和聚合
目标:
1. `targets=[...]`
2. 并发执行
3. group 级状态跟踪
4. 聚合后回投主 agent
### Phase 4: web 管理接口
目标:
1. `/api/agents`
2. 添加 / 删除 / 刷新 agent
3. 前端展示 unified registry
## 12. 兼容性要求
这次改造一定要保留以下兼容性:
1. 旧的 `spawn(task, label)` 调用仍然可用
2. 没有 A2A agent 时,行为和现在一致
3. skill 没写 `agent_cards`skill 仍只是普通 skill
4. plugin agent 不参与调度时,现有 plugin 机制不受影响
## 13. 风险点
### 13.1 A2A 规范新旧写法并存
从当前公开文档和样例看,存在这些并行写法:
1. card 路径: `/.well-known/agent-card``/.well-known/agent.json`
2. RPC 方法: `tasks/send``message/send`
所以客户端必须做兼容适配,不能写死一种。
### 13.2 外部 agent 的安全边界
需要限制:
1. 白名单 host
2. 超时
3. card cache TTL
4. skill card 是否允许自动启用
### 13.3 远端 agent 无法直接访问本地 workspace
这意味着:
1. 不能把“去读本地文件然后处理”原样发给远端 A2A agent
2. 主 agent 需要先整理出必要上下文
3. 第一版最好只做文本级委派
## 14. 我建议的落地结论
如果要控制改动面,又要保证后续可扩展,推荐最终采用下面这个结构:
```text
AgentLoop
-> SpawnTool
-> DelegationManager
-> AgentRegistry / AgentResolver
-> LocalSubagentExecutor
-> PluginAgentExecutor
-> A2AExecutor
-> AgentGroupExecutor
-> announce_result() -> MessageBus(system) -> AgentLoop -> user
```
也就是说:
1. `spawn` 工具保留
2. `SubagentManager` 不再是唯一执行器
3. `DelegationManager` 成为真正总入口
4. skills 里的 `agent_cards` 用 frontmatter metadata 承载
5. workspace agent 单独持久化
6. group 通过并发 executor + 汇总消息实现
这是当前仓库里最稳妥、最符合现有架构的改法。
## 15. 外部参考
以下是我写这个方案时核对的 A2A 资料:
1. A2A Protocol Development Guide: https://a2aprotocol.ai/docs/guide/a2a-typescript-guide.html
2. Python A2A Tutorial: https://a2aprotocol.ai/docs/guide/python-a2a-tutorial-20250513
注意:
1. 当前公开文档里既能看到 `tasks/send`,也能看到 `message/send`
2. agent card 路径也能看到 `agent-card``agent.json` 两种写法
3. 所以实现时建议做兼容层,不要只押一种命名

View File

@ -1,5 +0,0 @@
We provide QR codes for joining the HKUDS discussion groups on **WeChat** and **Feishu**.
You can join by scanning the QR codes below:
<img src="https://github.com/HKUDS/.github/blob/main/profile/QR.png" alt="WeChat QR Code" width="400"/>

View File

@ -1,40 +0,0 @@
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
# Install Node.js 20 for the WhatsApp bridge
RUN apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates gnupg git && \
mkdir -p /etc/apt/keyrings && \
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" > /etc/apt/sources.list.d/nodesource.list && \
apt-get update && \
apt-get install -y --no-install-recommends nodejs && \
apt-get purge -y gnupg && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install Python dependencies first (cached layer)
COPY pyproject.toml README.md LICENSE ./
RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \
uv pip install --system --no-cache . && \
rm -rf nanobot bridge
# Copy the full source and install
COPY nanobot/ nanobot/
COPY bridge/ bridge/
RUN uv pip install --system --no-cache .
# Build the WhatsApp bridge
WORKDIR /app/bridge
RUN npm install && npm run build
WORKDIR /app
# Create config directory
RUN mkdir -p /root/.nanobot
# Gateway default port
EXPOSE 18790
ENTRYPOINT ["nanobot"]
CMD ["status"]

View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 nanobot contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,470 +1,29 @@
# 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
当前已经落地的主线:
如果你后续要把它打包成 Docker 丢到服务器,这份 README 就是给开发和部署同事看的执行文档
1. 以统一 `engine` 为核心,让主 agent 和 sub-agent 共享同一套运行内核
2. 聊天入口支持 Main Agent 自动 Task 化、验证、反馈门控。
3. skills 已有版本化、receipt/effect 记录、学习候选门控,以及后台 assisted learning pipeline。
4. Agent Team v1 已支持内部 `sequence / parallel / dag` coordinator。
5. Task mode 已能通过 `TaskExecutionPlanner` 按需调用 sub-agent/teamteam node 由 `TaskSkillResolver` 绑定 published skill缺失时生成 ephemeral guidance最终仍由主 Agent synthesis 生成用户回答。
6. Skill Learning 已支持后台 run-once/worker 自动生成 draft、safety report、eval report、人工审核发布和前端审核工作台worker 不会自动 approve/publish。
## 这套仓库现在是什么
## 当前结构
这不是一个自带前端静态页面的全栈仓库,而是后端仓库:
- `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`:外部系统与协议集成
- 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 bridgeNode 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
<workspace>/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
```
不是只挂了某一个文件。
后端已切到 Beaver 主线不再保留旧实现、vendored 第三方 runtime 或迁移期旧命名兼容入口。所有 agent 运行都复用 `beaver.engine`,多 agent 协调通过 Beaver 自有 coordinator 和 `ExecutionGraph` 表达

View File

@ -1,264 +0,0 @@
# Security Policy
## Reporting a Vulnerability
If you discover a security vulnerability in Boardware Genius, please report it by:
1. **DO NOT** open a public GitHub issue
2. Create a private security advisory on GitHub or contact the repository maintainers (xubinrencs@gmail.com)
3. Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
We aim to respond to security reports within 48 hours.
## Security Best Practices
### 1. API Key Management
**CRITICAL**: Never commit API keys to version control.
```bash
# ✅ Good: Store in config file with restricted permissions
chmod 600 ~/.nanobot/config.json
# ❌ Bad: Hardcoding keys in code or committing them
```
**Recommendations:**
- Store API keys in `~/.nanobot/config.json` with file permissions set to `0600`
- Consider using environment variables for sensitive keys
- Use OS keyring/credential manager for production deployments
- Rotate API keys regularly
- Use separate API keys for development and production
### 2. Channel Access Control
**IMPORTANT**: Always configure `allowFrom` lists for production use.
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allowFrom": ["123456789", "987654321"]
},
"whatsapp": {
"enabled": true,
"allowFrom": ["+1234567890"]
}
}
}
```
**Security Notes:**
- Empty `allowFrom` list will **ALLOW ALL** users (open by default for personal use)
- Get your Telegram user ID from `@userinfobot`
- Use full phone numbers with country code for WhatsApp
- Review access logs regularly for unauthorized access attempts
### 3. Shell Command Execution
The `exec` tool can execute shell commands. While dangerous command patterns are blocked, you should:
- ✅ Review all tool usage in agent logs
- ✅ Understand what commands the agent is running
- ✅ Use a dedicated user account with limited privileges
- ✅ Never run Boardware Genius as root
- ❌ Don't disable security checks
- ❌ Don't run on systems with sensitive data without careful review
**Blocked patterns:**
- `rm -rf /` - Root filesystem deletion
- Fork bombs
- Filesystem formatting (`mkfs.*`)
- Raw disk writes
- Other destructive operations
### 4. File System Access
File operations have path traversal protection, but:
- ✅ Run Boardware Genius with a dedicated user account
- ✅ Use filesystem permissions to protect sensitive directories
- ✅ Regularly audit file operations in logs
- ❌ Don't give unrestricted access to sensitive files
### 5. Network Security
**API Calls:**
- All external API calls use HTTPS by default
- Timeouts are configured to prevent hanging requests
- Consider using a firewall to restrict outbound connections if needed
**WhatsApp Bridge:**
- The bridge binds to `127.0.0.1:3001` (localhost only, not accessible from external network)
- Set `bridgeToken` in config to enable shared-secret authentication between Python and Node.js
- Keep authentication data in `~/.nanobot/whatsapp-auth` secure (mode 0700)
### 6. Dependency Security
**Critical**: Keep dependencies updated!
```bash
# Check for vulnerable dependencies
pip install pip-audit
pip-audit
# Update to latest secure versions
pip install --upgrade nanobot-ai
```
For Node.js dependencies (WhatsApp bridge):
```bash
cd bridge
npm audit
npm audit fix
```
**Important Notes:**
- Keep `litellm` updated to the latest version for security fixes
- We've updated `ws` to `>=8.17.1` to fix DoS vulnerability
- Run `pip-audit` or `npm audit` regularly
- Subscribe to security advisories for Boardware Genius and its dependencies
### 7. Production Deployment
For production use:
1. **Isolate the Environment**
```bash
# Run in a container or VM
docker run --rm -it python:3.11
pip install nanobot-ai
```
2. **Use a Dedicated User**
```bash
sudo useradd -m -s /bin/bash nanobot
sudo -u nanobot nanobot gateway
```
3. **Set Proper Permissions**
```bash
chmod 700 ~/.nanobot
chmod 600 ~/.nanobot/config.json
chmod 700 ~/.nanobot/whatsapp-auth
```
4. **Enable Logging**
```bash
# Configure log monitoring
tail -f ~/.nanobot/logs/nanobot.log
```
5. **Use Rate Limiting**
- Configure rate limits on your API providers
- Monitor usage for anomalies
- Set spending limits on LLM APIs
6. **Regular Updates**
```bash
# Check for updates weekly
pip install --upgrade nanobot-ai
```
### 8. Development vs Production
**Development:**
- Use separate API keys
- Test with non-sensitive data
- Enable verbose logging
- Use a test Telegram bot
**Production:**
- Use dedicated API keys with spending limits
- Restrict file system access
- Enable audit logging
- Regular security reviews
- Monitor for unusual activity
### 9. Data Privacy
- **Logs may contain sensitive information** - secure log files appropriately
- **LLM providers see your prompts** - review their privacy policies
- **Chat history is stored locally** - protect the `~/.nanobot` directory
- **API keys are in plain text** - use OS keyring for production
### 10. Incident Response
If you suspect a security breach:
1. **Immediately revoke compromised API keys**
2. **Review logs for unauthorized access**
```bash
grep "Access denied" ~/.nanobot/logs/nanobot.log
```
3. **Check for unexpected file modifications**
4. **Rotate all credentials**
5. **Update to latest version**
6. **Report the incident** to maintainers
## Security Features
### Built-in Security Controls
✅ **Input Validation**
- Path traversal protection on file operations
- Dangerous command pattern detection
- Input length limits on HTTP requests
✅ **Authentication**
- Allow-list based access control
- Failed authentication attempt logging
- Open by default (configure allowFrom for production use)
✅ **Resource Protection**
- Command execution timeouts (60s default)
- Output truncation (10KB limit)
- HTTP request timeouts (10-30s)
✅ **Secure Communication**
- HTTPS for all external API calls
- TLS for Telegram API
- WhatsApp bridge: localhost-only binding + optional token auth
## Known Limitations
⚠️ **Current Security Limitations:**
1. **No Rate Limiting** - Users can send unlimited messages (add your own if needed)
2. **Plain Text Config** - API keys stored in plain text (use keyring for production)
3. **No Session Management** - No automatic session expiry
4. **Limited Command Filtering** - Only blocks obvious dangerous patterns
5. **No Audit Trail** - Limited security event logging (enhance as needed)
## Security Checklist
Before deploying Boardware Genius:
- [ ] API keys stored securely (not in code)
- [ ] Config file permissions set to 0600
- [ ] `allowFrom` lists configured for all channels
- [ ] Running as non-root user
- [ ] File system permissions properly restricted
- [ ] Dependencies updated to latest secure versions
- [ ] Logs monitored for security events
- [ ] Rate limits configured on API providers
- [ ] Backup and disaster recovery plan in place
- [ ] Security review of custom skills/tools
## Updates
**Last Updated**: 2026-02-03
For the latest security updates and announcements, check:
- GitHub Security Advisories: https://github.com/HKUDS/nanobot/security/advisories
- Release Notes: https://github.com/HKUDS/nanobot/releases
## License
See LICENSE file for details.

View File

@ -0,0 +1,6 @@
"""Beaver backend package."""
__all__ = ["__version__"]
__version__ = "0.1.0"

View File

@ -0,0 +1,34 @@
"""Multi-agent coordination layer."""
from .models import (
AgentDescriptor,
DelegationEnvelope,
ExecutionGraph,
ExecutionNode,
NodeRunResult,
TeamRunResult,
)
def __getattr__(name: str):
if name == "LocalAgentRunner":
from .local import LocalAgentRunner
return LocalAgentRunner
if name == "TeamGraphScheduler":
from .execution import TeamGraphScheduler
return TeamGraphScheduler
raise AttributeError(name)
__all__ = [
"AgentDescriptor",
"DelegationEnvelope",
"ExecutionGraph",
"ExecutionNode",
"LocalAgentRunner",
"NodeRunResult",
"TeamGraphScheduler",
"TeamRunResult",
]

View File

@ -0,0 +1,2 @@
"""Pluggable multi-agent backends."""

View File

@ -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."""

View File

@ -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.
"""

View File

@ -0,0 +1,2 @@
"""Delegation orchestration."""

View File

@ -0,0 +1,5 @@
"""Execution control, retry, and aggregation."""
from .scheduler import TeamGraphScheduler
__all__ = ["TeamGraphScheduler"]

View File

@ -0,0 +1,270 @@
"""Minimal scheduler for Beaver-native team execution graphs."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from typing import TYPE_CHECKING
from beaver.engine.providers import ProviderBundle
from ..local import LocalAgentRunner
from ..models import DelegationEnvelope, ExecutionGraph, ExecutionNode, NodeRunResult, TeamRunResult
if TYPE_CHECKING:
from beaver.engine.context import SkillContext
class TeamGraphScheduler:
"""Execute sequence, parallel, and DAG team graphs."""
def __init__(self, runner: LocalAgentRunner, *, max_parallel_team_nodes: int = 3) -> None:
self.runner = runner
self.max_parallel_team_nodes = max(1, int(max_parallel_team_nodes))
async def run(
self,
graph: ExecutionGraph,
*,
parent_task_id: str | None,
parent_session_id: str,
parent_run_id: str | None = None,
provider_bundle: ProviderBundle | None = None,
provider_bundle_factory: Callable[[ExecutionNode], ProviderBundle | None] | None = None,
inherited_pinned_skills: list[str] | None = None,
inherited_pinned_skill_contexts: list["SkillContext"] | None = None,
allow_candidate_generation: bool = False,
) -> TeamRunResult:
graph.validate()
if provider_bundle is not None and len(graph.nodes) > 1:
raise ValueError("provider_bundle can only be used for single-node team graphs; use provider_bundle_factory")
inherited = list(inherited_pinned_skills or [])
inherited_contexts = list(inherited_pinned_skill_contexts or [])
if graph.strategy == "sequence":
results = await self._run_sequence(
graph.nodes,
parent_task_id=parent_task_id,
parent_session_id=parent_session_id,
parent_run_id=parent_run_id,
provider_bundle=provider_bundle,
provider_bundle_factory=provider_bundle_factory,
inherited_pinned_skills=inherited,
inherited_pinned_skill_contexts=inherited_contexts,
allow_candidate_generation=allow_candidate_generation,
)
elif graph.strategy == "parallel":
results = await self._run_parallel(
graph.nodes,
parent_task_id=parent_task_id,
parent_session_id=parent_session_id,
parent_run_id=parent_run_id,
provider_bundle=provider_bundle,
provider_bundle_factory=provider_bundle_factory,
inherited_pinned_skills=inherited,
inherited_pinned_skill_contexts=inherited_contexts,
allow_candidate_generation=allow_candidate_generation,
)
else:
results = await self._run_dag(
graph.nodes,
parent_task_id=parent_task_id,
parent_session_id=parent_session_id,
parent_run_id=parent_run_id,
provider_bundle=provider_bundle,
provider_bundle_factory=provider_bundle_factory,
inherited_pinned_skills=inherited,
inherited_pinned_skill_contexts=inherited_contexts,
allow_candidate_generation=allow_candidate_generation,
)
return self._summarize(results, task_id=parent_task_id)
async def _run_sequence(
self,
nodes: list[ExecutionNode],
**kwargs,
) -> list[NodeRunResult]:
results: list[NodeRunResult] = []
for node in nodes:
if any(not item.success for item in results):
results.append(self._blocked(node, results))
continue
dependency_outputs = {item.node_id: item.output_text for item in results if item.success}
results.append(await self._run_node(node, dependency_outputs=dependency_outputs, **kwargs))
return results
async def _run_parallel(
self,
nodes: list[ExecutionNode],
**kwargs,
) -> list[NodeRunResult]:
semaphore = asyncio.Semaphore(self.max_parallel_team_nodes)
async def run_one(node: ExecutionNode) -> NodeRunResult:
async with semaphore:
return await self._run_node(
node,
dependency_outputs={},
execution_mode="isolated_loop",
**kwargs,
)
return list(await asyncio.gather(*(run_one(node) for node in nodes)))
async def _run_dag(
self,
nodes: list[ExecutionNode],
**kwargs,
) -> list[NodeRunResult]:
pending = {node.node_id: node for node in nodes}
completed: dict[str, NodeRunResult] = {}
ordered: list[NodeRunResult] = []
while pending:
blocked_ids = {
node_id
for node_id, node in pending.items()
if any(dep in completed and not completed[dep].success for dep in node.depends_on)
}
for node_id in sorted(blocked_ids):
node = pending.pop(node_id)
result = self._blocked(node, list(completed.values()))
completed[node_id] = result
ordered.append(result)
ready = [
node
for node in pending.values()
if all(dep in completed and completed[dep].success for dep in node.depends_on)
]
if not ready:
if pending:
unresolved = ", ".join(sorted(pending))
raise ValueError(f"ExecutionGraph has cyclic or unresolved dependencies: {unresolved}")
break
batch = await asyncio.gather(
*(
self._run_node(
node,
dependency_outputs={
dep: completed[dep].output_text
for dep in node.depends_on
if dep in completed
},
**kwargs,
)
for node in ready
)
)
for result in batch:
pending.pop(result.node_id, None)
completed[result.node_id] = result
ordered.append(result)
return ordered
async def _run_node(
self,
node: ExecutionNode,
*,
parent_task_id: str | None,
parent_session_id: str,
parent_run_id: str | None,
provider_bundle: ProviderBundle | None,
provider_bundle_factory: Callable[[ExecutionNode], ProviderBundle | None] | None,
inherited_pinned_skills: list[str],
inherited_pinned_skill_contexts: list["SkillContext"],
allow_candidate_generation: bool,
dependency_outputs: dict[str, str],
execution_mode: str = "shared_loop",
) -> NodeRunResult:
try:
pinned = self._merge_pinned(inherited_pinned_skills, node.inherited_pinned_skills)
pinned_contexts = self._merge_skill_contexts(
inherited_pinned_skill_contexts,
node.inherited_pinned_skill_contexts,
)
envelope = DelegationEnvelope(
parent_task_id=parent_task_id,
parent_session_id=parent_session_id,
parent_run_id=parent_run_id,
agent=node.agent,
task=node.task,
inherited_pinned_skills=pinned,
inherited_pinned_skill_contexts=pinned_contexts,
constraints=list(node.constraints),
expected_output=node.expected_output,
node_id=node.node_id,
dependency_outputs=dict(dependency_outputs),
)
node_provider_bundle = provider_bundle_factory(node) if provider_bundle_factory is not None else provider_bundle
return await self.runner.run(
envelope,
provider_bundle=node_provider_bundle,
allow_candidate_generation=allow_candidate_generation,
execution_mode=execution_mode,
)
except asyncio.CancelledError:
raise
except Exception as exc:
return NodeRunResult(
node_id=node.node_id,
success=False,
output_text="",
finish_reason="error",
error=str(exc),
)
@staticmethod
def _merge_pinned(parent: list[str], local: list[str]) -> list[str]:
result: list[str] = []
for name in [*parent, *local]:
if name and name not in result:
result.append(name)
return result
@staticmethod
def _merge_skill_contexts(parent: list["SkillContext"], local: list["SkillContext"]) -> list["SkillContext"]:
result: list["SkillContext"] = []
seen: set[str] = set()
for skill in [*parent, *local]:
name = getattr(skill, "name", "")
if not name or name in seen:
continue
seen.add(name)
result.append(skill)
return result
@staticmethod
def _blocked(node: ExecutionNode, prior_results: list[NodeRunResult]) -> NodeRunResult:
failed = [item.node_id for item in prior_results if not item.success]
detail = ", ".join(failed) or "unknown dependency"
return NodeRunResult(
node_id=node.node_id,
success=False,
output_text="",
finish_reason="blocked",
error=f"Blocked by failed dependency: {detail}",
)
@staticmethod
def _summarize(results: list[NodeRunResult], *, task_id: str | None) -> TeamRunResult:
success = all(item.success for item in results)
successful_outputs = [item.output_text.strip() for item in results if item.success and item.output_text.strip()]
summary_parts = list(successful_outputs)
failed = [item for item in results if not item.success]
if failed:
failure_lines = [
f"- {item.node_id}: {item.error or item.finish_reason} evidence={'yes' if item.evidence else 'no'}"
for item in failed
]
summary_parts.append("Failed nodes:\n" + "\n".join(failure_lines))
summary = "\n\n".join(summary_parts)
return TeamRunResult(
success=success,
summary=summary,
node_results=results,
run_ids=[item.run_id for item in results if item.run_id],
session_ids=[item.session_id for item in results if item.session_id],
task_id=task_id,
)

View File

@ -0,0 +1,151 @@
"""Local delegated-agent runner built on the shared AgentLoop."""
from __future__ import annotations
from uuid import uuid4
from beaver.engine import AgentLoop
from beaver.engine.providers import ProviderBundle
from beaver.tasks.evidence import EvidenceBuilder
from .models import DelegationEnvelope, NodeRunResult
class LocalAgentRunner:
"""Run delegated agents through the same AgentLoop implementation."""
def __init__(self, loop: AgentLoop) -> None:
self.loop = loop
async def run(
self,
envelope: DelegationEnvelope,
*,
provider_bundle: ProviderBundle | None = None,
allow_candidate_generation: bool = False,
execution_mode: str = "shared_loop",
) -> NodeRunResult:
if provider_bundle is not None and (envelope.agent.model or envelope.agent.provider_name):
raise ValueError(
"provider_bundle cannot be combined with AgentDescriptor.model/provider_name; "
"build a node-specific provider bundle instead."
)
child_session_id = self._child_session_id(envelope)
target_loop = self.loop
if execution_mode == "isolated_loop":
target_loop = AgentLoop(profile=self.loop.profile, loader=self.loop.loader)
runner = (
target_loop.process_direct
if execution_mode == "isolated_loop"
else (self.loop.submit_direct if self.loop.is_running else self.loop.process_direct)
)
result = await runner(
envelope.task,
session_id=child_session_id,
parent_session_id=envelope.parent_session_id,
source=f"team:{envelope.agent.name}",
title=envelope.agent.role or envelope.agent.name,
execution_context=self._execution_context(envelope),
skill_selection_context=self._skill_selection_context(envelope),
model=envelope.agent.model,
provider_name=envelope.agent.provider_name,
provider_bundle=provider_bundle,
task_id=envelope.parent_task_id,
task_mode=bool(envelope.parent_task_id),
pinned_skill_names=envelope.inherited_pinned_skills,
pinned_skill_contexts=envelope.inherited_pinned_skill_contexts,
allow_candidate_generation=allow_candidate_generation,
)
loaded = target_loop.boot()
evidence = EvidenceBuilder(loaded.session_manager).build_run_evidence(
result.session_id,
result.run_id,
result.output_text,
result.finish_reason,
)
success = result.finish_reason == "stop"
return NodeRunResult(
node_id=envelope.node_id or envelope.agent.name,
success=success,
output_text=result.output_text,
run_id=result.run_id,
session_id=result.session_id,
finish_reason=result.finish_reason,
error=None if success else (result.output_text or result.finish_reason),
evidence=evidence,
)
@staticmethod
def _child_session_id(envelope: DelegationEnvelope) -> str:
node = envelope.node_id or envelope.agent.name or "node"
return f"{envelope.parent_session_id}:team:{node}:{uuid4().hex[:8]}"
@staticmethod
def _execution_context(envelope: DelegationEnvelope) -> str:
sections: list[str] = []
if envelope.parent_task_id:
sections.append(f"Parent task ID: {envelope.parent_task_id}")
if envelope.parent_run_id:
sections.append(f"Parent run ID: {envelope.parent_run_id}")
sections.append("Delegated worker: generic task sub-agent. Follow active pinned skills as the primary guidance.")
if envelope.agent.system_prompt:
sections.append(f"Additional delegated instructions:\n{envelope.agent.system_prompt}")
if envelope.constraints:
sections.append("Constraints:\n" + "\n".join(f"- {item}" for item in envelope.constraints))
if envelope.expected_output:
sections.append(f"Expected output:\n{envelope.expected_output}")
if envelope.dependency_outputs:
rendered = "\n\n".join(
f"Dependency {node_id} output:\n{output}"
for node_id, output in envelope.dependency_outputs.items()
)
sections.append("Dependency outputs:\n" + rendered)
if envelope.inherited_pinned_skills:
sections.append("Pinned inherited skills:\n" + "\n".join(f"- {item}" for item in envelope.inherited_pinned_skills))
if envelope.inherited_pinned_skill_contexts:
sections.append(
"Ephemeral pinned guidance:\n"
+ "\n".join(f"- {item.name} ({item.version})" for item in envelope.inherited_pinned_skill_contexts)
)
return "\n\n".join(sections)
@staticmethod
def _skill_selection_context(envelope: DelegationEnvelope) -> str:
sections: list[str] = []
if envelope.parent_task_id:
sections.append(f"Parent task ID:\n{envelope.parent_task_id}")
sections.append(f"Node task:\n{envelope.task}")
sections.append("Execution phase:\nteam_node")
if envelope.agent.role:
sections.append(f"Agent role:\n{envelope.agent.role}")
skill_query = envelope.agent.metadata.get("skill_query")
if skill_query:
sections.append(f"Skill query:\n{skill_query}")
required_capabilities = envelope.agent.metadata.get("required_capabilities")
if required_capabilities:
if isinstance(required_capabilities, list):
rendered = "\n".join(f"- {item}" for item in required_capabilities)
else:
rendered = str(required_capabilities)
sections.append(f"Required capabilities:\n{rendered}")
if envelope.constraints:
sections.append("Constraints:\n" + "\n".join(f"- {item}" for item in envelope.constraints))
if envelope.expected_output:
sections.append(f"Expected output:\n{envelope.expected_output}")
if envelope.inherited_pinned_skills:
sections.append(
"Pinned inherited skills (must be injected separately; use as strong context):\n"
+ "\n".join(f"- {item}" for item in envelope.inherited_pinned_skills)
)
if envelope.dependency_outputs:
rendered = "\n\n".join(
f"Dependency {node_id} output:\n{output[:800]}"
for node_id, output in envelope.dependency_outputs.items()
)
sections.append("Dependency outputs:\n" + rendered)
sections.append(
"Skill selection instruction:\n"
"Select published skills for this delegated node. "
"If no published skill matches, return [] and let the node continue without skills."
)
return "\n\n".join(sections)

View File

@ -0,0 +1,154 @@
"""Core models for Beaver team coordination."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Literal
if TYPE_CHECKING:
from beaver.engine.context import SkillContext
from beaver.tasks.evidence import RunEvidence
TeamStrategy = Literal[
"sequence",
"parallel",
"dag",
"moa",
"hierarchy",
"heavy",
"group_chat",
"forest",
"maker",
"router",
]
@dataclass(slots=True)
class AgentDescriptor:
"""Runtime identity for a delegated local agent."""
name: str
role: str = ""
system_prompt: str = ""
model: str | None = None
provider_name: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(slots=True)
class DelegationEnvelope:
"""All context passed from a parent agent run to one delegated run."""
parent_task_id: str | None
parent_session_id: str
parent_run_id: str | None
agent: AgentDescriptor
task: str
inherited_pinned_skills: list[str] = field(default_factory=list)
inherited_pinned_skill_contexts: list["SkillContext"] = field(default_factory=list)
constraints: list[str] = field(default_factory=list)
expected_output: str | None = None
node_id: str | None = None
dependency_outputs: dict[str, str] = field(default_factory=dict)
@dataclass(slots=True)
class ExecutionNode:
"""One node in a team execution graph."""
node_id: str
task: str
agent: AgentDescriptor
depends_on: list[str] = field(default_factory=list)
inherited_pinned_skills: list[str] = field(default_factory=list)
inherited_pinned_skill_contexts: list["SkillContext"] = field(default_factory=list)
constraints: list[str] = field(default_factory=list)
expected_output: str | None = None
@dataclass(slots=True)
class ExecutionGraph:
"""A lightweight team graph built from Beaver-native execution nodes."""
strategy: TeamStrategy
nodes: list[ExecutionNode]
def validate(self) -> None:
if self.strategy not in {"sequence", "parallel", "dag"}:
raise NotImplementedError(f"Team strategy {self.strategy!r} is reserved but not implemented in v1")
if not self.nodes:
raise ValueError("ExecutionGraph requires at least one node")
node_ids = [node.node_id for node in self.nodes]
if len(node_ids) != len(set(node_ids)):
raise ValueError("ExecutionGraph node_id values must be unique")
known = set(node_ids)
for node in self.nodes:
missing = [item for item in node.depends_on if item not in known]
if missing:
raise ValueError(f"ExecutionNode {node.node_id!r} depends on unknown node(s): {missing}")
visiting: set[str] = set()
visited: set[str] = set()
deps = {node.node_id: list(node.depends_on) for node in self.nodes}
def visit(node_id: str) -> None:
if node_id in visited:
return
if node_id in visiting:
raise ValueError(f"ExecutionGraph has cyclic or unresolved dependencies involving {node_id!r}")
visiting.add(node_id)
for dep in deps[node_id]:
visit(dep)
visiting.remove(node_id)
visited.add(node_id)
for node_id in node_ids:
visit(node_id)
@dataclass(slots=True)
class NodeRunResult:
"""Normalized result for one team node."""
node_id: str
success: bool
output_text: str
run_id: str | None = None
session_id: str | None = None
finish_reason: str = "stop"
error: str | None = None
evidence: "RunEvidence | None" = None
def to_dict(self) -> dict[str, Any]:
return {
"node_id": self.node_id,
"success": self.success,
"output_text": self.output_text,
"run_id": self.run_id,
"session_id": self.session_id,
"finish_reason": self.finish_reason,
"error": self.error,
"evidence": self.evidence.to_dict() if self.evidence is not None else None,
}
@dataclass(slots=True)
class TeamRunResult:
"""Normalized result returned by a Beaver team run."""
success: bool
summary: str
node_results: list[NodeRunResult] = field(default_factory=list)
run_ids: list[str] = field(default_factory=list)
session_ids: list[str] = field(default_factory=list)
task_id: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"success": self.success,
"summary": self.summary,
"node_results": [item.to_dict() for item in self.node_results],
"run_ids": list(self.run_ids),
"session_ids": list(self.session_ids),
"task_id": self.task_id,
}

View File

@ -0,0 +1,2 @@
"""Team planning and execution-plan generation."""

View File

@ -0,0 +1,14 @@
"""Agent registry and descriptors."""
"""Workspace specialist agent registry."""
from .models import AgentMatch, RegisteredAgent, TargetResolutionReport
from .resolver import TargetResolver
from .store import AgentRegistry
__all__ = [
"AgentMatch",
"AgentRegistry",
"RegisteredAgent",
"TargetResolutionReport",
"TargetResolver",
]

View File

@ -0,0 +1,184 @@
"""Workspace agent registry models."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Literal
from beaver.coordinator.models import AgentDescriptor
AgentRegistryStatus = Literal["active", "disabled"]
AgentRegistrySource = Literal["builtin", "workspace", "learned"]
@dataclass(slots=True)
class RegisteredAgent:
agent_id: str
name: str
display_name: str
role: str
description: str
system_prompt: str
capabilities: list[str] = field(default_factory=list)
skill_names: list[str] = field(default_factory=list)
tool_hints: list[str] = field(default_factory=list)
model: str | None = None
provider_name: str | None = None
tags: list[str] = field(default_factory=list)
priority: int = 0
status: AgentRegistryStatus = "active"
source: AgentRegistrySource = "workspace"
metadata: dict[str, Any] = field(default_factory=dict)
created_at: str = field(default_factory=lambda: _utc_now())
updated_at: str = field(default_factory=lambda: _utc_now())
def to_descriptor(self) -> AgentDescriptor:
return AgentDescriptor(
name=self.name,
role=self.role,
system_prompt=self.system_prompt,
model=self.model,
provider_name=self.provider_name,
metadata={
**self.metadata,
"agent_id": self.agent_id,
"display_name": self.display_name,
"description": self.description,
"capabilities": list(self.capabilities),
"skill_names": list(self.skill_names),
"tool_hints": list(self.tool_hints),
"tags": list(self.tags),
"source": self.source,
"resolution": "registered",
},
)
def to_dict(self) -> dict[str, Any]:
return {
"agent_id": self.agent_id,
"name": self.name,
"display_name": self.display_name,
"role": self.role,
"description": self.description,
"system_prompt": self.system_prompt,
"capabilities": list(self.capabilities),
"skill_names": list(self.skill_names),
"tool_hints": list(self.tool_hints),
"model": self.model,
"provider_name": self.provider_name,
"tags": list(self.tags),
"priority": self.priority,
"status": self.status,
"source": self.source,
"metadata": dict(self.metadata),
"created_at": self.created_at,
"updated_at": self.updated_at,
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "RegisteredAgent":
now = _utc_now()
agent_id = str(payload.get("agent_id") or payload.get("id") or payload.get("name") or "").strip()
if not agent_id:
raise ValueError("RegisteredAgent requires agent_id")
name = str(payload.get("name") or agent_id).strip()
return cls(
agent_id=agent_id,
name=name,
display_name=str(payload.get("display_name") or payload.get("displayName") or name).strip(),
role=str(payload.get("role") or "").strip(),
description=str(payload.get("description") or "").strip(),
system_prompt=str(payload.get("system_prompt") or payload.get("systemPrompt") or "").strip(),
capabilities=_string_list(payload.get("capabilities")),
skill_names=_string_list(payload.get("skill_names") or payload.get("skillNames")),
tool_hints=_string_list(payload.get("tool_hints") or payload.get("toolHints")),
model=_optional_str(payload.get("model")),
provider_name=_optional_str(payload.get("provider_name") or payload.get("providerName")),
tags=_string_list(payload.get("tags")),
priority=int(payload.get("priority", 0) or 0),
status="disabled" if str(payload.get("status") or "active") == "disabled" else "active",
source=_source(payload.get("source")),
metadata=dict(payload.get("metadata") or {}),
created_at=str(payload.get("created_at") or payload.get("createdAt") or now),
updated_at=str(payload.get("updated_at") or payload.get("updatedAt") or now),
)
@dataclass(slots=True)
class AgentMatch:
agent_id: str
score: float
reasons: list[str]
matched_capabilities: list[str]
resolved_descriptor: AgentDescriptor
def to_dict(self) -> dict[str, Any]:
return {
"agent_id": self.agent_id,
"score": self.score,
"reasons": list(self.reasons),
"matched_capabilities": list(self.matched_capabilities),
"resolved_descriptor": {
"name": self.resolved_descriptor.name,
"role": self.resolved_descriptor.role,
"model": self.resolved_descriptor.model,
"provider_name": self.resolved_descriptor.provider_name,
"metadata": dict(self.resolved_descriptor.metadata),
},
}
@dataclass(slots=True)
class TargetResolutionReport:
node_id: str
requested_role: str
requested_capabilities: list[str]
selected_agent_id: str | None
fallback_used: bool
score: float
reason: str
def to_dict(self) -> dict[str, Any]:
return {
"node_id": self.node_id,
"requested_role": self.requested_role,
"requested_capabilities": list(self.requested_capabilities),
"selected_agent_id": self.selected_agent_id,
"fallback_used": self.fallback_used,
"score": self.score,
"reason": self.reason,
}
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _optional_str(value: Any) -> str | None:
if value in (None, ""):
return None
text = str(value).strip()
return text or None
def _string_list(value: Any) -> list[str]:
if not isinstance(value, list):
if isinstance(value, str):
value = [item.strip() for item in value.split(",")]
else:
return []
result: list[str] = []
for item in value:
text = str(item).strip()
if text and text not in result:
result.append(text)
return result
def _source(value: Any) -> AgentRegistrySource:
text = str(value or "workspace").strip()
if text in {"builtin", "workspace", "learned"}:
return text # type: ignore[return-value]
return "workspace"

View File

@ -0,0 +1,208 @@
"""Resolve planner node requirements to registered specialist agents."""
from __future__ import annotations
from dataclasses import replace
from typing import Any, TYPE_CHECKING
from beaver.coordinator.models import AgentDescriptor, ExecutionGraph, ExecutionNode
from .models import AgentMatch, RegisteredAgent, TargetResolutionReport
from .store import AgentRegistry
if TYPE_CHECKING:
from beaver.tasks.models import TaskRecord
class TargetResolver:
def __init__(self, registry: AgentRegistry) -> None:
self.registry = registry
def resolve_graph(
self,
graph: ExecutionGraph,
*,
task: "TaskRecord",
user_message: str,
attempt_index: int,
) -> tuple[ExecutionGraph, list[TargetResolutionReport]]:
reports: list[TargetResolutionReport] = []
resolved_nodes: list[ExecutionNode] = []
for node in graph.nodes:
descriptor, report = self.resolve_node(
node,
task=task,
user_message=user_message,
attempt_index=attempt_index,
)
resolved_nodes.append(replace(node, agent=descriptor))
reports.append(report)
return ExecutionGraph(strategy=graph.strategy, nodes=resolved_nodes), reports
def resolve_node(
self,
node: ExecutionNode,
*,
task: "TaskRecord",
user_message: str,
attempt_index: int,
) -> tuple[AgentDescriptor, TargetResolutionReport]:
requested_role = (node.agent.role or node.agent.name or node.node_id).strip()
requested_capabilities = [
str(item).strip()
for item in node.agent.metadata.get("requested_capabilities", [])
if str(item).strip()
]
requested_tags = [
str(item).strip()
for item in node.agent.metadata.get("requested_tags", [])
if str(item).strip()
]
pinned_skills = list(node.inherited_pinned_skills)
match = self.best_match(
requested_role=requested_role,
requested_capabilities=requested_capabilities,
requested_tags=requested_tags,
pinned_skills=pinned_skills,
task_text=" ".join([task.goal, task.description, user_message, node.task]),
)
if match is not None and match.score > 0:
descriptor = match.resolved_descriptor
descriptor.metadata.update(
{
"node_id": node.node_id,
"attempt_index": attempt_index,
"requested_role": requested_role,
"requested_capabilities": requested_capabilities,
}
)
return descriptor, TargetResolutionReport(
node_id=node.node_id,
requested_role=requested_role,
requested_capabilities=requested_capabilities,
selected_agent_id=match.agent_id,
fallback_used=False,
score=match.score,
reason="; ".join(match.reasons),
)
fallback = AgentDescriptor(
name=node.agent.name or node.node_id,
role=node.agent.role,
system_prompt=node.agent.system_prompt,
model=node.agent.model,
provider_name=node.agent.provider_name,
metadata={
**node.agent.metadata,
"node_id": node.node_id,
"attempt_index": attempt_index,
"requested_role": requested_role,
"requested_capabilities": requested_capabilities,
"resolution": "fallback_ephemeral",
},
)
return fallback, TargetResolutionReport(
node_id=node.node_id,
requested_role=requested_role,
requested_capabilities=requested_capabilities,
selected_agent_id=None,
fallback_used=True,
score=0.0,
reason="no active registered specialist matched planner requirements",
)
def best_match(
self,
*,
requested_role: str,
requested_capabilities: list[str],
requested_tags: list[str],
pinned_skills: list[str],
task_text: str,
) -> AgentMatch | None:
matches = [
self._score_agent(
agent,
requested_role=requested_role,
requested_capabilities=requested_capabilities,
requested_tags=requested_tags,
pinned_skills=pinned_skills,
task_text=task_text,
)
for agent in self.registry.list_active_agents()
]
matches = [match for match in matches if match.score > 0]
if not matches:
return None
matches.sort(key=lambda item: (item.score, item.resolved_descriptor.metadata.get("priority", 0)), reverse=True)
return matches[0]
def _score_agent(
self,
agent: RegisteredAgent,
*,
requested_role: str,
requested_capabilities: list[str],
requested_tags: list[str],
pinned_skills: list[str],
task_text: str,
) -> AgentMatch:
score = 0.0
reasons: list[str] = []
requested_role_terms = _terms(requested_role)
capability_terms = _terms(" ".join(requested_capabilities))
tag_terms = _terms(" ".join(requested_tags))
skill_terms = _terms(" ".join(pinned_skills))
task_terms = _terms(task_text)
agent_role_terms = _terms(agent.role + " " + agent.name + " " + agent.display_name)
agent_capability_terms = _terms(" ".join(agent.capabilities))
agent_tag_terms = _terms(" ".join(agent.tags))
agent_skill_terms = _terms(" ".join(agent.skill_names))
agent_all_terms = (
agent_role_terms
| agent_capability_terms
| agent_tag_terms
| agent_skill_terms
| _terms(agent.description)
)
role_hits = requested_role_terms & agent_role_terms
if role_hits:
score += 60 + 5 * len(role_hits)
reasons.append(f"role matched: {', '.join(sorted(role_hits))}")
capability_hits = capability_terms & agent_capability_terms
if capability_hits:
score += 30 + 5 * len(capability_hits)
reasons.append(f"capabilities matched: {', '.join(sorted(capability_hits))}")
tag_hits = tag_terms & agent_tag_terms
if tag_hits:
score += 10 + 3 * len(tag_hits)
reasons.append(f"tags matched: {', '.join(sorted(tag_hits))}")
skill_hits = skill_terms & agent_skill_terms
if skill_hits:
score += 25 + 5 * len(skill_hits)
reasons.append(f"skills matched: {', '.join(sorted(skill_hits))}")
task_hits = task_terms & agent_all_terms
if task_hits:
score += min(20, len(task_hits) * 2)
reasons.append("task text matched registry profile")
score += agent.priority / 100.0
descriptor = agent.to_descriptor()
descriptor.metadata["priority"] = agent.priority
return AgentMatch(
agent_id=agent.agent_id,
score=round(score, 3),
reasons=reasons or ["priority fallback"],
matched_capabilities=sorted(capability_hits),
resolved_descriptor=descriptor,
)
def _terms(value: Any) -> set[str]:
text = str(value or "")
normalized = "".join(ch.lower() if ch.isalnum() else " " for ch in text)
return {part for part in normalized.split() if part}

View File

@ -0,0 +1,196 @@
"""File-backed workspace agent registry."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from .models import RegisteredAgent
class AgentRegistry:
def __init__(self, workspace: str | Path) -> None:
self.workspace = Path(workspace)
self.path = self.workspace / "agents" / "registry.json"
self.path.parent.mkdir(parents=True, exist_ok=True)
if not self.path.exists():
self._write_agents(_builtin_agents())
def list_agents(self, *, include_disabled: bool = True) -> list[RegisteredAgent]:
agents = self._read_agents()
if include_disabled:
return agents
return [agent for agent in agents if agent.status == "active"]
def list_active_agents(self) -> list[RegisteredAgent]:
return self.list_agents(include_disabled=False)
def get_agent(self, agent_id: str) -> RegisteredAgent | None:
needle = agent_id.strip()
for agent in self.list_agents():
if agent.agent_id == needle:
return agent
return None
def upsert_agent(self, payload: dict[str, Any] | RegisteredAgent) -> RegisteredAgent:
agent = payload if isinstance(payload, RegisteredAgent) else RegisteredAgent.from_dict(payload)
agents = self.list_agents()
for index, existing in enumerate(agents):
if existing.agent_id == agent.agent_id:
if existing.source == "builtin" and agent.source == "workspace":
agent.source = "builtin"
agent.created_at = existing.created_at
agents[index] = agent
self._write_agents(agents)
return agent
agents.append(agent)
self._write_agents(agents)
return agent
def disable_agent(self, agent_id: str) -> RegisteredAgent:
agents = self.list_agents()
for index, agent in enumerate(agents):
if agent.agent_id != agent_id:
continue
agent.status = "disabled"
agents[index] = agent
self._write_agents(agents)
return agent
raise ValueError(f"Unknown agent_id: {agent_id}")
def delete_agent(self, agent_id: str) -> bool:
target = agent_id.strip()
if not target:
return False
agents = self.list_agents()
kept = [agent for agent in agents if agent.agent_id != target]
if len(kept) == len(agents):
return False
self._write_agents(kept)
return True
def search(
self,
*,
role: str = "",
capabilities: list[str] | None = None,
tags: list[str] | None = None,
skills: list[str] | None = None,
) -> list[RegisteredAgent]:
role_terms = _terms(role)
capability_terms = set(_terms(" ".join(capabilities or [])))
tag_terms = set(_terms(" ".join(tags or [])))
skill_terms = set(_terms(" ".join(skills or [])))
matches: list[RegisteredAgent] = []
for agent in self.list_active_agents():
haystack = set(
_terms(
" ".join(
[
agent.agent_id,
agent.name,
agent.display_name,
agent.role,
agent.description,
" ".join(agent.capabilities),
" ".join(agent.tags),
" ".join(agent.skill_names),
]
)
)
)
if role_terms and not role_terms.intersection(haystack):
continue
if capability_terms and not capability_terms.intersection(haystack):
continue
if tag_terms and not tag_terms.intersection(haystack):
continue
if skill_terms and not skill_terms.intersection(haystack):
continue
matches.append(agent)
return matches
def _read_agents(self) -> list[RegisteredAgent]:
if not self.path.exists():
return []
payload = json.loads(self.path.read_text(encoding="utf-8"))
raw_agents = payload.get("agents") if isinstance(payload, dict) else payload
if not isinstance(raw_agents, list):
return []
return [RegisteredAgent.from_dict(item) for item in raw_agents if isinstance(item, dict)]
def _write_agents(self, agents: list[RegisteredAgent]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
payload = {"version": 1, "agents": [agent.to_dict() for agent in agents]}
self.path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def _terms(text: str) -> set[str]:
normalized = "".join(ch.lower() if ch.isalnum() else " " for ch in text)
return {part for part in normalized.split() if part}
def _builtin_agents() -> list[RegisteredAgent]:
return [
RegisteredAgent(
agent_id="researcher",
name="researcher",
display_name="Researcher",
role="research",
description="Finds facts, references, constraints, and implementation options.",
system_prompt="You are a research specialist. Gather concise evidence and tradeoffs for the parent task.",
capabilities=["research", "analysis", "source review", "requirements"],
tags=["planning", "research"],
priority=50,
source="builtin",
),
RegisteredAgent(
agent_id="implementer",
name="implementer",
display_name="Implementer",
role="implementation",
description="Builds scoped implementation slices and proposes concrete changes.",
system_prompt="You are an implementation specialist. Produce practical, scoped implementation output.",
capabilities=["implementation", "coding", "refactor", "integration"],
tags=["coding", "build"],
priority=45,
source="builtin",
),
RegisteredAgent(
agent_id="reviewer",
name="reviewer",
display_name="Reviewer",
role="review",
description="Reviews plans, code, outputs, and risks before final synthesis.",
system_prompt="You are a review specialist. Focus on defects, missing requirements, and risks.",
capabilities=["review", "quality", "risk", "verification"],
tags=["review", "quality"],
priority=45,
source="builtin",
),
RegisteredAgent(
agent_id="tester",
name="tester",
display_name="Tester",
role="testing",
description="Designs and executes verification checks for task outputs.",
system_prompt="You are a testing specialist. Identify focused checks and report pass/fail evidence.",
capabilities=["testing", "verification", "regression", "qa"],
tags=["test", "quality"],
priority=40,
source="builtin",
),
RegisteredAgent(
agent_id="documenter",
name="documenter",
display_name="Documenter",
role="documentation",
description="Writes and reconciles user-facing and internal documentation updates.",
system_prompt="You are a documentation specialist. Produce concise docs aligned with the implementation.",
capabilities=["documentation", "explanation", "migration notes", "release notes"],
tags=["docs", "communication"],
priority=35,
source="builtin",
),
]

View File

@ -0,0 +1,220 @@
"""Persistent local sub-agent storage for the web UI."""
from __future__ import annotations
import json
import re
import shutil
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
from beaver.coordinator.registry import AgentRegistry
_INVALID_ID_RE = re.compile(r"[^a-z0-9-]+")
def normalize_subagent_id(value: str) -> str:
normalized = _INVALID_ID_RE.sub("-", str(value or "").strip().lower()).strip("-")
normalized = re.sub(r"-{2,}", "-", normalized)
if not normalized:
raise ValueError("Sub-agent id is required")
return normalized
@dataclass(slots=True)
class SubagentSpec:
id: str
name: str
description: str
enabled: bool = True
workspace: str = ""
system_prompt: str = ""
model: str | None = None
delegation_mode: str = "remote_a2a_only"
allow_mcp: bool = True
tags: list[str] = field(default_factory=list)
aliases: list[str] = field(default_factory=list)
mcp_servers: dict[str, dict[str, Any]] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_dict(cls, payload: dict[str, Any], *, workspace_path: Path | None = None) -> "SubagentSpec":
agent_id = normalize_subagent_id(str(payload.get("id") or ""))
name = str(payload.get("name") or agent_id).strip() or agent_id
description = str(payload.get("description") or name).strip() or name
workspace = str(payload.get("workspace") or "").strip()
if not workspace and workspace_path is not None:
workspace = str(workspace_path)
mcp_servers = payload.get("mcp_servers", {})
metadata = payload.get("metadata", {})
return cls(
id=agent_id,
name=name,
description=description,
enabled=bool(payload.get("enabled", True)),
workspace=workspace,
system_prompt=str(payload.get("system_prompt") or "").strip(),
model=(str(payload.get("model") or "").strip() or None),
delegation_mode=str(payload.get("delegation_mode") or "remote_a2a_only").strip() or "remote_a2a_only",
allow_mcp=bool(payload.get("allow_mcp", True)),
tags=_string_list(payload.get("tags")),
aliases=_string_list(payload.get("aliases")),
mcp_servers=mcp_servers if isinstance(mcp_servers, dict) else {},
metadata=metadata if isinstance(metadata, dict) else {},
)
def to_dict(self) -> dict[str, Any]:
payload = asdict(self)
if not self.model:
payload["model"] = None
return payload
class LocalSubagentStore:
"""Persist sub-agent definitions under `<workspace>/agents/<id>_agent/`."""
def __init__(self, workspace: Path, *, public_base_url: str = "") -> None:
self.workspace = workspace.expanduser().resolve()
self.directory = self.workspace / "agents"
self.public_base_url = public_base_url.rstrip("/")
def list_subagents(self) -> list[SubagentSpec]:
if not self.directory.exists():
return []
result: list[SubagentSpec] = []
for child in sorted(self.directory.iterdir()):
agents_json = child / "AGENTS.json"
if not child.is_dir() or not agents_json.exists():
continue
try:
payload = json.loads(agents_json.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
continue
if isinstance(payload, dict):
result.append(SubagentSpec.from_dict(payload, workspace_path=child))
return result
def get_subagent(self, agent_id: str) -> SubagentSpec | None:
path = self.agents_json_path(agent_id)
if not path.exists():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
return None
if not isinstance(payload, dict):
return None
return SubagentSpec.from_dict(payload, workspace_path=self.subagent_dir(agent_id))
def upsert_subagent(self, payload: dict[str, Any]) -> SubagentSpec:
agent_id = normalize_subagent_id(str(payload.get("id") or ""))
workspace_path = self.subagent_dir(agent_id)
spec = SubagentSpec.from_dict(payload, workspace_path=workspace_path)
self._ensure_workspace(workspace_path)
spec.workspace = str(workspace_path)
self._sync_agents_md(workspace_path, spec)
self.agents_json_path(agent_id).write_text(
json.dumps(spec.to_dict(), indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
AgentRegistry(self.workspace).upsert_agent(self.build_registry_record(spec))
return spec
def delete_subagent(self, agent_id: str) -> bool:
agent_id = normalize_subagent_id(agent_id)
target = self.subagent_dir(agent_id)
if not target.exists():
return False
AgentRegistry(self.workspace).delete_agent(agent_id)
shutil.rmtree(target)
return True
def subagent_dir(self, agent_id: str) -> Path:
return self.directory / f"{normalize_subagent_id(agent_id)}_agent"
def agents_json_path(self, agent_id: str) -> Path:
return self.subagent_dir(agent_id) / "AGENTS.json"
def local_base_url(self, agent_id: str) -> str:
if self.public_base_url:
return f"{self.public_base_url}/subagents/{normalize_subagent_id(agent_id)}"
return f"/subagents/{normalize_subagent_id(agent_id)}"
def build_registry_record(self, spec: SubagentSpec) -> dict[str, Any]:
base_url = self.local_base_url(spec.id)
return {
"agent_id": spec.id,
"name": spec.id,
"display_name": spec.name,
"role": spec.description,
"description": spec.description,
"system_prompt": spec.system_prompt,
"model": spec.model,
"tags": sorted(set(["local-subagent", *spec.tags])),
"status": "active" if spec.enabled else "disabled",
"source": "workspace",
"metadata": {
**spec.metadata,
"workspace": spec.workspace,
"managed_by": "subagent-manager",
"local_subagent": True,
"kind": "local_subagent",
"protocol": "a2a",
"base_url": base_url,
"endpoint": f"{base_url}/rpc",
"card_url": f"{base_url}/.well-known/agent-card",
"aliases": sorted(set([spec.name, *spec.aliases])),
},
}
def serialize(self, spec: SubagentSpec) -> dict[str, Any]:
base_url = self.local_base_url(spec.id)
return {
**spec.to_dict(),
"base_url": base_url,
"endpoint": f"{base_url}/rpc",
"card_url": f"{base_url}/.well-known/agent-card",
}
def _ensure_workspace(self, workspace_path: Path) -> None:
workspace_path.mkdir(parents=True, exist_ok=True)
(workspace_path / "memory").mkdir(exist_ok=True)
(workspace_path / "skills").mkdir(exist_ok=True)
def _sync_agents_md(self, workspace_path: Path, spec: SubagentSpec) -> None:
(workspace_path / "AGENTS.md").write_text(self._render_agents_md(spec), encoding="utf-8")
@staticmethod
def _render_agents_md(spec: SubagentSpec) -> str:
prompt = spec.system_prompt.strip() or "Complete delegated tasks accurately and concisely."
return f"""# {spec.name}
You are {spec.name}, a persistent local sub-agent managed by Beaver.
## Role
{spec.description}
## System Prompt
{prompt}
## Constraints
- Work only inside this workspace.
- Respond only to delegated tasks.
- Do not create or manage local sub-agents.
- Do not message end users directly.
"""
def _string_list(value: Any) -> list[str]:
if isinstance(value, str):
value = [item.strip() for item in value.split(",")]
if not isinstance(value, list):
return []
result: list[str] = []
for item in value:
text = str(item).strip()
if text and text not in result:
result.append(text)
return result

View File

@ -0,0 +1,19 @@
"""Team models and orchestration objects."""
from ..models import (
AgentDescriptor,
DelegationEnvelope,
ExecutionGraph,
ExecutionNode,
NodeRunResult,
TeamRunResult,
)
__all__ = [
"AgentDescriptor",
"DelegationEnvelope",
"ExecutionGraph",
"ExecutionNode",
"NodeRunResult",
"TeamRunResult",
]

View File

@ -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}")

View File

@ -0,0 +1,17 @@
"""Context assembly for agent runs."""
from .builder import (
ContextBuildInput,
ContextBuildResult,
ContextBuilder,
SessionContext,
SkillContext,
)
__all__ = [
"ContextBuildInput",
"ContextBuildResult",
"ContextBuilder",
"SessionContext",
"SkillContext",
]

View File

@ -0,0 +1,375 @@
"""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 通过显式激活消息注入,不在这里做磁盘扫描
4. 为后续 channel / gateway / team metadata 预留注入位,但不提前做复杂逻辑
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any
from beaver.memory.curated.snapshot import MemorySnapshot
BEAVER_USER_ASSISTANT_IDENTITY_PROMPT = (
"You are 海狸 (Beaver), an AI assistant developed by 博维资讯系统有限公司. "
"When communicating with users, keep this identity consistent. "
"If users ask who you are, say that you are 海狸 (Beaver), 博维资讯系统有限公司研发的 AI 助手."
)
@dataclass(slots=True)
class SkillContext:
"""单个已激活 skill 的最小表示。
这里故意不把 skill 设计成复杂对象,只保留 builder 真正关心的两部分:
- `name`:用于生成激活提示
- `content`skill 的完整正文
注意skill 正文不再塞进 system prompt而是转成显式消息注入。
"""
name: str
content: str
version: str = "legacy"
content_hash: str = ""
activation_reason: str = "selected"
tool_hints: list[str] = field(default_factory=list)
@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. Beaver user-facing assistant identity
2. base system prompt
3. session metadata
4. execution context
5. frozen memory snapshot
6. extra sections
这样设计的原因:
- 身份与总规则要最靠前
- session/execution 是本轮运行语境,优先级高于长期记忆
- memory 必须是 frozen snapshot避免中途写 memory 后 prompt 失真
- activated skill 正文放到显式消息里,避免 system prompt 持续膨胀
"""
sections: list[str] = [BEAVER_USER_ASSISTANT_IDENTITY_PROMPT]
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。
# 否则一旦当前会话中途写 memorysystem 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. 把已激活 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(self._provider_history_message(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,
)
@staticmethod
def _provider_history_message(message: dict[str, Any]) -> dict[str, Any]:
"""Keep persisted UI/audit fields out of provider message payloads."""
allowed = {"role", "content", "tool_calls", "tool_call_id", "name"}
clean = {key: value for key, value in message.items() if key in allowed}
if "name" not in clean and message.get("tool_name"):
clean["name"] = message.get("tool_name")
if isinstance(clean.get("tool_calls"), list):
clean["tool_calls"] = ContextBuilder._provider_tool_calls(clean["tool_calls"])
return clean
@staticmethod
def _provider_tool_calls(tool_calls: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Normalize persisted tool calls to OpenAI-compatible provider payloads."""
normalized: list[dict[str, Any]] = []
for tool_call in tool_calls:
if not isinstance(tool_call, dict):
continue
clean = dict(tool_call)
function = clean.get("function")
if isinstance(function, dict):
clean_function = dict(function)
arguments = clean_function.get("arguments")
if not isinstance(arguments, str):
clean_function["arguments"] = json.dumps(arguments or {}, ensure_ascii=False, default=str)
clean["function"] = clean_function
normalized.append(clean)
return normalized
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"] = self._provider_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]]:
"""把已激活 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 (version {skill.version}) is active for this run. '
"Follow its instructions as active guidance unless the user overrides them.]\n\n"
f"{content}"
),
}
)
return messages

View File

@ -0,0 +1,329 @@
"""Centralized runtime loading for Beaver agents."""
from __future__ import annotations
import asyncio
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
from beaver.coordinator.registry import AgentRegistry
from beaver.engine.context import ContextBuilder
from beaver.engine.session import SessionManager
from beaver.foundation.config import BeaverConfig, load_config
from beaver.integrations.mcp import MCPConnectionManager
from beaver.memory.curated.store import MemoryStore
from beaver.memory.runs import RunMemoryStore
from beaver.memory.skills import SkillLearningStore
from beaver.services.memory_service import MemoryService
from beaver.skills.drafts import DraftService
from beaver.skills.learning import EvidenceSelector, SkillDraftSynthesizer, SkillLearningPipelineService, SkillLearningService
from beaver.skills.learning.safety import SkillDraftSafetyChecker
from beaver.skills.learning.eval import SkillDraftEvaluator
from beaver.skills.publisher import SkillPublisher
from beaver.skills.reviews import ReviewService
from beaver.skills.specs import SkillSpecStore
from beaver.tasks import TaskExecutionPlanner, TaskService, ValidationService
from beaver.tasks.skill_resolver import TaskSkillResolver
from beaver.skills import SkillAssembler, SkillsLoader
from beaver.tools import ObjectBackedTool, ToolAssembler, ToolExecutor, ToolRegistry
from beaver.tools.builtins import (
ClarifyTool,
CronTool,
DelegateTool,
EchoTool,
ExecuteCodeTool,
ListDirectoryTool,
MemoryTool,
PatchFileTool,
ProcessTool,
ReadFileTool,
SearchFilesTool,
SendMessageTool,
SpawnTool,
SessionSearchTool,
SkillManageTool,
SkillsListTool,
TerminalTool,
TodoTool,
WebFetchTool,
WebSearchTool,
WriteFileTool,
)
@dataclass(slots=True)
class EngineLoadResult:
"""描述当前 agent runtime 已经装好的依赖。
这里同时保留两类字段:
1. `tools/skills/memory_stores/permissions`
- 便于做状态展示、调试、轻量测试
2. `session_manager/tool_registry/...`
- 供真正的运行时主链直接使用
"""
workspace: Path
config: BeaverConfig = field(default_factory=BeaverConfig)
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
run_memory_store: RunMemoryStore | None = None
skill_learning_store: SkillLearningStore | None = None
tool_registry: ToolRegistry | None = None
tool_assembler: ToolAssembler | None = None
tool_executor: ToolExecutor | None = None
context_builder: ContextBuilder | None = None
skills_loader: SkillsLoader | None = None
skill_assembler: SkillAssembler | None = None
skill_spec_store: SkillSpecStore | None = None
draft_service: DraftService | None = None
review_service: ReviewService | None = None
skill_publisher: SkillPublisher | None = None
skill_learning_service: SkillLearningService | None = None
skill_learning_pipeline: SkillLearningPipelineService | None = None
agent_registry: AgentRegistry | None = None
task_skill_resolver: TaskSkillResolver | None = None
task_service: TaskService | None = None
task_execution_planner: TaskExecutionPlanner | None = None
validation_service: ValidationService | None = None
mcp_manager: MCPConnectionManager | None = None
mcp_report: dict[str, dict] = field(default_factory=dict)
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,
config_path: str | Path | None = None,
config: BeaverConfig | None = None,
session_manager: SessionManager | None = None,
curated_memory_store: MemoryStore | None = None,
memory_service: MemoryService | None = None,
run_memory_store: RunMemoryStore | None = None,
skill_learning_store: SkillLearningStore | None = None,
tool_registry: ToolRegistry | None = None,
tool_assembler: ToolAssembler | None = None,
context_builder: ContextBuilder | None = None,
skills_loader: SkillsLoader | None = None,
skill_assembler: SkillAssembler | None = None,
skill_spec_store: SkillSpecStore | None = None,
draft_service: DraftService | None = None,
review_service: ReviewService | None = None,
skill_publisher: SkillPublisher | None = None,
skill_learning_service: SkillLearningService | None = None,
skill_learning_pipeline: SkillLearningPipelineService | None = None,
agent_registry: AgentRegistry | None = None,
task_skill_resolver: TaskSkillResolver | None = None,
task_service: TaskService | None = None,
task_execution_planner: TaskExecutionPlanner | None = None,
validation_service: ValidationService | None = None,
) -> None:
self.config = config or load_config(workspace=workspace, config_path=config_path)
configured_workspace = self.config.agents_defaults.workspace
env_workspace = os.getenv("BEAVER_WORKSPACE")
self.workspace = Path(workspace or configured_workspace or env_workspace or Path.cwd())
self._session_manager = session_manager
self._curated_memory_store = curated_memory_store
self._memory_service = memory_service
self._run_memory_store = run_memory_store
self._skill_learning_store = skill_learning_store
self._tool_registry = tool_registry
self._tool_assembler = tool_assembler
self._context_builder = context_builder
self._skills_loader = skills_loader
self._skill_assembler = skill_assembler
self._skill_spec_store = skill_spec_store
self._draft_service = draft_service
self._review_service = review_service
self._skill_publisher = skill_publisher
self._skill_learning_service = skill_learning_service
self._skill_learning_pipeline = skill_learning_pipeline
self._agent_registry = agent_registry
self._task_skill_resolver = task_skill_resolver
self._task_service = task_service
self._task_execution_planner = task_execution_planner
self._validation_service = validation_service
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()
run_memory_store = self._run_memory_store or RunMemoryStore(workspace / "memory" / "runs")
skill_learning_store = self._skill_learning_store or SkillLearningStore(workspace / "memory" / "skills")
tool_registry = self._tool_registry or ToolRegistry()
skill_spec_store = self._skill_spec_store or SkillSpecStore(workspace)
skills_loader = self._skills_loader or SkillsLoader(workspace, skill_store=skill_spec_store)
if self._tool_registry is None:
# 这里先注册最小工具集,满足主链的 tool loop。
tool_registry.register_many(
[
ObjectBackedTool(EchoTool()),
ObjectBackedTool(MemoryTool(store=memory_service.get_store())),
ObjectBackedTool(SessionSearchTool(db=session_manager)),
ObjectBackedTool(ListDirectoryTool()),
ObjectBackedTool(ReadFileTool()),
ObjectBackedTool(SearchFilesTool()),
ObjectBackedTool(WriteFileTool()),
ObjectBackedTool(PatchFileTool()),
ObjectBackedTool(WebFetchTool()),
ObjectBackedTool(WebSearchTool()),
ObjectBackedTool(TerminalTool()),
ObjectBackedTool(ProcessTool()),
ObjectBackedTool(ExecuteCodeTool()),
ObjectBackedTool(TodoTool()),
ObjectBackedTool(ClarifyTool()),
ObjectBackedTool(SendMessageTool()),
ObjectBackedTool(DelegateTool()),
ObjectBackedTool(SpawnTool()),
SkillsListTool(),
SkillManageTool(),
CronTool(),
]
)
context_builder = self._context_builder or ContextBuilder()
tool_assembler = self._tool_assembler or ToolAssembler()
tool_executor = ToolExecutor(tool_registry)
skill_assembler = self._skill_assembler or SkillAssembler(skills_loader)
draft_service = self._draft_service or DraftService(skill_spec_store)
review_service = self._review_service or ReviewService(skill_spec_store)
skill_publisher = self._skill_publisher or SkillPublisher(skill_spec_store)
evidence_selector = EvidenceSelector(run_memory_store, session_manager=session_manager)
skill_learning_service = self._skill_learning_service or SkillLearningService(
run_store=run_memory_store,
learning_store=skill_learning_store,
draft_service=draft_service,
evidence_selector=evidence_selector,
synthesizer=SkillDraftSynthesizer(),
)
skill_learning_pipeline = self._skill_learning_pipeline or SkillLearningPipelineService(
learning_store=skill_learning_store,
learning_service=skill_learning_service,
draft_service=draft_service,
review_service=review_service,
publisher=skill_publisher,
safety_checker=SkillDraftSafetyChecker(
allowed_tool_names={spec.name for spec in tool_registry.list_specs()},
allowed_tool_prefixes={
f"mcp_{server_id}_"
for server_id in self.config.tools.mcp_servers
if str(server_id).strip()
},
),
evaluator=SkillDraftEvaluator(run_memory_store),
)
agent_registry = self._agent_registry or AgentRegistry(workspace)
task_skill_resolver = self._task_skill_resolver or TaskSkillResolver(
skills_loader=skills_loader,
draft_service=draft_service,
)
task_service = self._task_service or TaskService(workspace / "tasks")
task_execution_planner = self._task_execution_planner or TaskExecutionPlanner(task_skill_resolver=task_skill_resolver)
validation_service = self._validation_service or ValidationService()
mcp_manager = MCPConnectionManager(
self.config.tools.mcp_servers,
authz_config=self.config.authz,
backend_identity=self.config.backend_identity,
)
result = EngineLoadResult(
workspace=workspace,
config=self.config,
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,
run_memory_store=run_memory_store,
skill_learning_store=skill_learning_store,
tool_registry=tool_registry,
tool_assembler=tool_assembler,
tool_executor=tool_executor,
context_builder=context_builder,
skills_loader=skills_loader,
skill_assembler=skill_assembler,
skill_spec_store=skill_spec_store,
draft_service=draft_service,
review_service=review_service,
skill_publisher=skill_publisher,
skill_learning_service=skill_learning_service,
skill_learning_pipeline=skill_learning_pipeline,
agent_registry=agent_registry,
task_skill_resolver=task_skill_resolver,
task_service=task_service,
task_execution_planner=task_execution_planner,
validation_service=validation_service,
mcp_manager=mcp_manager,
)
if self._session_manager is None:
result.register_closeable("session_manager", session_manager.close)
result.register_closeable("mcp_manager", lambda: _close_mcp_manager(mcp_manager))
return result
def _close_mcp_manager(manager: MCPConnectionManager) -> None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
asyncio.run(manager.close())
return
loop.create_task(manager.close())

File diff suppressed because it is too large Load Diff

View File

@ -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",
]

View File

@ -0,0 +1,174 @@
"""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,
thinking_enabled: bool | None = None,
) -> 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

View File

@ -0,0 +1,99 @@
"""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,
thinking_enabled: bool | None = None,
) -> LLMResponse:
"""统一聊天接口。"""
@abstractmethod
def get_default_model(self) -> str:
"""返回 provider 的默认模型名。"""

View File

@ -0,0 +1,152 @@
"""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,
thinking_enabled: bool | None = None,
) -> 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,
thinking_enabled=thinking_enabled,
)
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,
thinking_enabled=thinking_enabled,
)
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,
thinking_enabled: bool | None,
) -> LLMResponse:
"""把 provider 抛出的异常也收敛成统一 error response。
这样 fallback 的触发条件就不依赖“每个 provider 都记得自己 catch 异常”。
"""
try:
kwargs = {
"messages": messages,
"tools": tools,
"model": model,
"max_tokens": max_tokens,
"temperature": temperature,
}
if thinking_enabled is not None:
kwargs["thinking_enabled"] = thinking_enabled
return await provider.chat(**kwargs)
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

View File

@ -1,4 +1,4 @@
"""OpenAI Codex Responses Provider."""
"""OpenAI Codex Responses provider."""
from __future__ import annotations
@ -7,21 +7,31 @@ import hashlib
import json
from typing import Any, AsyncGenerator
import httpx
from loguru import logger
from .base import LLMProvider, LLMResponse, ToolCallRequest
from oauth_cli_kit import get_token as get_codex_token
from nanobot.providers.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 = "nanobot"
DEFAULT_ORIGINATOR = "beaver"
class OpenAICodexProvider(LLMProvider):
"""Use Codex OAuth to call the Responses API."""
"""使用 Codex OAuth 调用 Responses API"""
def __init__(self, default_model: str = "openai-codex/gpt-5.1-codex"):
super().__init__(api_key=None, api_base=None)
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(
@ -31,15 +41,17 @@ class OpenAICodexProvider(LLMProvider):
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_enabled: bool | None = None,
) -> LLMResponse:
model = model or self.default_model
system_prompt, input_items = _convert_messages(messages)
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(model),
"model": _strip_model_prefix(resolved_model),
"store": False,
"stream": True,
"instructions": system_prompt,
@ -50,30 +62,27 @@ class OpenAICodexProvider(LLMProvider):
"tool_choice": "auto",
"parallel_tool_calls": True,
}
if tools:
body["tools"] = _convert_tools(tools)
url = DEFAULT_CODEX_URL
try:
try:
content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=True)
except Exception as e:
if "CERTIFICATE_VERIFY_FAILED" not in str(e):
raise
logger.warning("SSL certificate verification failed for Codex API; retrying with verify=False")
content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=False)
return LLMResponse(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
)
except Exception as e:
return LLMResponse(
content=f"Error calling Codex: {str(e)}",
finish_reason="error",
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
@ -91,7 +100,7 @@ def _build_headers(account_id: str, token: str) -> dict[str, str]:
"chatgpt-account-id": account_id,
"OpenAI-Beta": "responses=experimental",
"originator": DEFAULT_ORIGINATOR,
"User-Agent": "nanobot (python)",
"User-Agent": "beaver (python)",
"accept": "text/event-stream",
"content-type": "application/json",
}
@ -102,8 +111,9 @@ async def _request_codex(
headers: dict[str, str],
body: dict[str, Any],
verify: bool,
timeout_seconds: float,
) -> tuple[str, list[ToolCallRequest], str]:
async with httpx.AsyncClient(timeout=60.0, verify=verify) as client:
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()
@ -112,7 +122,6 @@ async def _request_codex(
def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Convert OpenAI function-calling schema to Codex flat format."""
converted: list[dict[str, Any]] = []
for tool in tools:
fn = (tool.get("function") or {}) if tool.get("type") == "function" else tool
@ -120,33 +129,30 @@ def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
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 {},
})
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 idx, msg in enumerate(messages):
role = msg.get("role")
content = msg.get("content")
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":
# Handle text first.
if isinstance(content, str) and content:
input_items.append(
{
@ -154,28 +160,24 @@ def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[st
"role": "assistant",
"content": [{"type": "output_text", "text": content}],
"status": "completed",
"id": f"msg_{idx}",
"id": f"msg_{index}",
}
)
# Then handle tool calls.
for tool_call in msg.get("tool_calls", []) or []:
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"))
call_id = call_id or f"call_{idx}"
item_id = item_id or f"fc_{idx}"
input_items.append(
{
"type": "function_call",
"id": item_id,
"call_id": call_id,
"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(msg.get("tool_call_id"))
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(
{
@ -184,8 +186,6 @@ def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[st
"output": output_text,
}
)
continue
return system_prompt, input_items
@ -222,12 +222,12 @@ def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
async def _iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]:
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 = [l[5:].strip() for l in buffer if l.startswith("data:")]
data_lines = [item[5:].strip() for item in buffer if item.startswith("data:")]
buffer = []
if not data_lines:
continue
@ -242,71 +242,34 @@ async def _iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any],
buffer.append(line)
async def _consume_sse(response: httpx.Response) -> tuple[str, list[ToolCallRequest], str]:
content = ""
async def _consume_sse(response: Any) -> tuple[str, list[ToolCallRequest], str]:
content_parts: list[str] = []
tool_calls: list[ToolCallRequest] = []
tool_call_buffers: dict[str, dict[str, Any]] = {}
finish_reason = "stop"
async for event in _iter_sse(response):
event_type = event.get("type")
if event_type == "response.output_item.added":
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":
call_id = item.get("call_id")
if not call_id:
continue
tool_call_buffers[call_id] = {
"id": item.get("id") or "fc_0",
"name": item.get("name"),
"arguments": item.get("arguments") or "",
}
elif event_type == "response.output_text.delta":
content += event.get("delta") or ""
elif event_type == "response.function_call_arguments.delta":
call_id = event.get("call_id")
if call_id and call_id in tool_call_buffers:
tool_call_buffers[call_id]["arguments"] += event.get("delta") or ""
elif event_type == "response.function_call_arguments.done":
call_id = event.get("call_id")
if call_id and call_id in tool_call_buffers:
tool_call_buffers[call_id]["arguments"] = event.get("arguments") or ""
elif event_type == "response.output_item.done":
item = event.get("item") or {}
if item.get("type") == "function_call":
call_id = item.get("call_id")
if not call_id:
continue
buf = tool_call_buffers.get(call_id) or {}
args_raw = buf.get("arguments") or item.get("arguments") or "{}"
raw_arguments = item.get("arguments") or "{}"
try:
args = json.loads(args_raw)
except Exception:
args = {"raw": args_raw}
arguments = json.loads(raw_arguments) if isinstance(raw_arguments, str) else raw_arguments
except json.JSONDecodeError:
arguments = {}
tool_calls.append(
ToolCallRequest(
id=f"{call_id}|{buf.get('id') or item.get('id') or 'fc_0'}",
name=buf.get("name") or item.get("name"),
arguments=args,
id=f"{item.get('call_id', 'call')}|{item.get('id', '')}",
name=item.get("name", ""),
arguments=arguments,
)
)
elif event_type == "response.completed":
status = (event.get("response") or {}).get("status")
finish_reason = _map_finish_reason(status)
elif event_type in {"error", "response.failed"}:
raise RuntimeError("Codex response failed")
return content, tool_calls, finish_reason
finish_reason = event.get("response", {}).get("status", "completed")
return "".join(content_parts) or None, tool_calls, finish_reason
_FINISH_REASON_MAP = {"completed": "stop", "incomplete": "length", "failed": "error", "cancelled": "error"}
def _map_finish_reason(status: str | None) -> str:
return _FINISH_REASON_MAP.get(status or "completed", "stop")
def _friendly_error(status_code: int, raw: str) -> str:
if status_code == 429:
return "ChatGPT usage quota exceeded or rate limit triggered. Please try again later."
return f"HTTP {status_code}: {raw}"
def _friendly_error(status_code: int, body: str) -> str:
return f"Codex API error ({status_code}): {body[:400]}"

View File

@ -0,0 +1,107 @@
"""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,
thinking_enabled: bool | None = None,
) -> 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

View File

@ -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",
]

View File

@ -0,0 +1,277 @@
"""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_by_name, 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
if spec is None and self.provider_name:
spec = find_by_name(self.provider_name)
if spec is None:
spec = 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
if self.provider_name:
spec = find_by_name(self.provider_name)
if spec is not None and not spec.is_gateway and not spec.is_local:
resolved = model
if spec.litellm_prefix and not any(resolved.startswith(prefix) for prefix in spec.skip_prefixes):
resolved = f"{spec.litellm_prefix}/{resolved}"
elif spec.name == "openai" and "/" not in resolved:
resolved = f"openai/{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
if isinstance(clean.get("tool_calls"), list):
clean["tool_calls"] = LiteLLMProvider._sanitize_tool_calls(clean["tool_calls"])
sanitized.append(clean)
return sanitized
@staticmethod
def _sanitize_tool_calls(tool_calls: list[Any]) -> list[dict[str, Any]]:
sanitized: list[dict[str, Any]] = []
for tool_call in tool_calls:
if not isinstance(tool_call, dict):
continue
clean = dict(tool_call)
function = clean.get("function")
if isinstance(function, dict):
clean_function = dict(function)
arguments = clean_function.get("arguments")
if not isinstance(arguments, str):
clean_function["arguments"] = json.dumps(arguments or {}, ensure_ascii=False, default=str)
clean["function"] = clean_function
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
def _apply_thinking_mode(self, original_model: str, resolved_model: str, kwargs: dict[str, Any], enabled: bool | None) -> None:
if enabled is None:
return
model_key = f"{original_model} {resolved_model}".lower()
if "qwen" not in model_key:
return
extra_body = dict(kwargs.get("extra_body") or {})
chat_template_kwargs = dict(extra_body.get("chat_template_kwargs") or {})
chat_template_kwargs["enable_thinking"] = bool(enabled)
extra_body["chat_template_kwargs"] = chat_template_kwargs
kwargs["extra_body"] = extra_body
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,
thinking_enabled: bool | None = None,
) -> 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,
"timeout": self.request_timeout_seconds or 45.0,
}
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)
self._apply_thinking_mode(original_model, resolved_model, kwargs, thinking_enabled)
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

View File

@ -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

View File

@ -0,0 +1,408 @@
"""Provider runtime resolution for Beaver."""
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。
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}"

View File

@ -0,0 +1,2 @@
"""Runtime helper objects and execution context."""

View File

@ -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",
]

View File

@ -0,0 +1,186 @@
"""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 update_latest_assistant_event_payload(
self,
session_id: str,
run_id: str,
updates: dict[str, Any],
) -> None:
"""把 run 级 UI 状态投影回最新 assistant 可见消息。"""
self.store.update_latest_assistant_event_payload(session_id, run_id, updates)
def set_run_context_visible(self, session_id: str, run_id: str, visible: bool) -> None:
self.store.set_run_context_visible(session_id, run_id, visible)
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` 明确消费的是“上游裁剪后的可见片段”
"""
records = self.get_event_records(session_id)
completed_run_ids = {
record.run_id
for record in records
if record.run_id and record.event_type == "run_completed"
}
failed_run_ids = {
record.run_id
for record in records
if record.run_id
and record.event_type == "run_completed"
and (
record.finish_reason == "error"
or (record.event_payload or {}).get("finish_reason") == "error"
)
}
history = []
for record in records:
if not record.context_visible or record.role == "system":
continue
if record.role == "tool":
continue
if record.role == "assistant" and record.tool_calls:
continue
if record.run_id and record.run_id not in completed_run_ids:
continue
if record.run_id and record.run_id in failed_run_ids:
continue
if record.role == "assistant" and record.finish_reason == "error":
continue
history.append(record.to_conversation_message())
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)

View File

@ -0,0 +1,235 @@
"""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.timestamp is not None:
payload["timestamp"] = self.timestamp
if self.run_id:
payload["run_id"] = self.run_id
if self.event_payload:
if self.event_payload.get("task_id"):
payload["task_id"] = self.event_payload.get("task_id")
if self.event_payload.get("task_status"):
payload["task_status"] = self.event_payload.get("task_status")
if self.event_payload.get("validation_status"):
payload["validation_status"] = self.event_payload.get("validation_status")
if self.event_payload.get("feedback_state"):
payload["feedback_state"] = self.event_payload.get("feedback_state")
if self.event_payload.get("feedback_error"):
payload["feedback_error"] = self.event_payload.get("feedback_error")
for key in (
"message_type",
"scheduled_job_id",
"scheduled_run_id",
"cron_job_name",
"mode",
):
if self.event_payload.get(key):
payload[key] = self.event_payload.get(key)
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"),
),
)

View File

@ -0,0 +1,156 @@
"""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,
exclude_end_reasons: 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)
if exclude_end_reasons:
placeholders = ",".join("?" for _ in exclude_end_reasons)
clauses.append(f"(end_reason IS NULL OR end_reason NOT IN ({placeholders}))")
params.extend(exclude_end_reasons)
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

View File

@ -0,0 +1,559 @@
"""Beaver session 子系统的 SQLite 存储实现。
设计目标:
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")
self._conn.executescript(FTS_TRIGGER_SQL)
except sqlite3.Error:
self._rebuild_fts_index()
return
# 旧版本可能把 hidden 事件也写进了 FTS初始化时顺手清掉这些噪声项。
try:
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()
except sqlite3.Error:
self._rebuild_fts_index()
def _rebuild_fts_index(self) -> None:
"""Recreate the derived FTS index without touching canonical session rows."""
self._conn.executescript(
"""
DROP TRIGGER IF EXISTS messages_fts_insert;
DROP TRIGGER IF EXISTS messages_fts_delete;
DROP TRIGGER IF EXISTS messages_fts_update;
DROP TABLE IF EXISTS messages_fts;
"""
)
self._conn.executescript(FTS_TABLE_SQL)
self._conn.executescript(FTS_TRIGGER_SQL)
self._conn.execute(
"""
INSERT INTO messages_fts(rowid, content)
SELECT id, content
FROM messages
WHERE context_visible = 1 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 update_latest_assistant_event_payload(
self,
session_id: str,
run_id: str,
updates: dict[str, Any],
) -> None:
"""Merge payload fields into the latest visible assistant message for a run."""
if not updates:
return
def _do(conn: sqlite3.Connection) -> None:
row = conn.execute(
"""
SELECT id, event_payload
FROM messages
WHERE session_id = ?
AND run_id = ?
AND role = 'assistant'
AND event_type = 'assistant_message_added'
AND context_visible = 1
ORDER BY timestamp DESC, id DESC
LIMIT 1
""",
(session_id, run_id),
).fetchone()
if row is None:
return
payload: dict[str, Any] = {}
if row["event_payload"]:
try:
parsed = json.loads(row["event_payload"])
if isinstance(parsed, dict):
payload = parsed
except json.JSONDecodeError:
payload = {}
payload.update(updates)
conn.execute(
"""
UPDATE messages
SET event_payload = ?
WHERE id = ?
""",
(json.dumps(payload, ensure_ascii=False, sort_keys=True), row["id"]),
)
self._execute_write(_do)
def set_run_context_visible(self, session_id: str, run_id: str, visible: bool) -> None:
"""Set context visibility for all currently visible events in one run."""
def _do(conn: sqlite3.Connection) -> None:
conn.execute(
"""
UPDATE messages
SET context_visible = ?
WHERE session_id = ?
AND run_id = ?
AND context_visible != ?
""",
(1 if visible else 0, session_id, run_id, 1 if visible else 0),
)
self._execute_write(_do)
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)

View File

@ -0,0 +1,2 @@
"""Foundation layer for shared Beaver primitives."""

View File

@ -0,0 +1,26 @@
"""Configuration models and loaders."""
from .loader import default_config_path, load_config
from .schema import (
AgentDefaultsConfig,
AuthzConfig,
BackendIdentityConfig,
BeaverConfig,
EmbeddingConfig,
MCPServerConfig,
ProviderConfig,
ToolsConfig,
)
__all__ = [
"AgentDefaultsConfig",
"AuthzConfig",
"BackendIdentityConfig",
"BeaverConfig",
"EmbeddingConfig",
"MCPServerConfig",
"ProviderConfig",
"ToolsConfig",
"default_config_path",
"load_config",
]

View File

@ -0,0 +1,227 @@
"""Config loader for per-sandbox Beaver runtime settings."""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from typing import Any
from .schema import (
AgentDefaultsConfig,
AuthzConfig,
BackendIdentityConfig,
BeaverConfig,
EmbeddingConfig,
MCPServerConfig,
ProviderConfig,
ToolsConfig,
)
LOCAL_MCP_CATEGORIES: dict[str, dict[str, str]] = {
"local_filesystem_mcp": {"category": "filesystem", "display_name": "本地文件工具"},
"local_runtime_mcp": {"category": "runtime", "display_name": "本地运行工具"},
"local_memory_mcp": {"category": "memory", "display_name": "本地记忆工具"},
"local_skills_mcp": {"category": "skills", "display_name": "本地技能工具"},
"local_coordination_mcp": {"category": "coordination", "display_name": "本地协作工具"},
"local_scheduler_mcp": {"category": "scheduler", "display_name": "本地定时工具"},
"local_web_mcp": {"category": "web", "display_name": "本地联网工具"},
}
def default_config_path(*, workspace: str | Path | None = None) -> Path:
"""Resolve the default config path for a single-user sandbox instance.
Priority:
1. `BEAVER_CONFIG_PATH`
2. `BEAVER_HOME/config.json`
3. `<workspace>/.beaver/config.json`
4. `./.beaver/config.json`
"""
explicit = os.getenv("BEAVER_CONFIG_PATH")
if explicit:
return Path(explicit).expanduser()
beaver_home = os.getenv("BEAVER_HOME")
if beaver_home:
return Path(beaver_home).expanduser() / "config.json"
root = Path(workspace).expanduser() if workspace is not None else Path.cwd()
return root / ".beaver" / "config.json"
def load_config(
*,
workspace: str | Path | None = None,
config_path: str | Path | None = None,
) -> BeaverConfig:
"""Load backend config; missing config is treated as an empty config."""
path = Path(config_path).expanduser() if config_path is not None else default_config_path(workspace=workspace)
if not path.exists():
return BeaverConfig(config_path=path)
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(f"Beaver config must be a JSON object: {path}")
return BeaverConfig(
agents_defaults=_parse_agent_defaults(data),
providers=_parse_providers(data.get("providers")),
embedding=_parse_embedding(data),
tools=_parse_tools(data.get("tools")),
authz=_parse_authz(data.get("authz")),
backend_identity=_parse_backend_identity(data.get("backend_identity") or data.get("backendIdentity")),
config_path=path,
)
def _parse_agent_defaults(data: dict[str, Any]) -> AgentDefaultsConfig:
agents = _as_dict(data.get("agents"))
defaults = _as_dict(agents.get("defaults"))
return AgentDefaultsConfig(
workspace=_string(defaults.get("workspace") or data.get("workspace")),
model=_string(defaults.get("model") or data.get("model")),
provider=_string(defaults.get("provider") or data.get("provider")),
embedding_model=_string(defaults.get("embeddingModel") or defaults.get("embedding_model") or data.get("embeddingModel")),
)
def _parse_providers(raw: Any) -> dict[str, ProviderConfig]:
providers: dict[str, ProviderConfig] = {}
for name, payload in _as_dict(raw).items():
if not isinstance(payload, dict):
continue
providers[str(name)] = ProviderConfig(
api_key=_string(payload.get("apiKey") or payload.get("api_key")),
api_base=_string(payload.get("apiBase") or payload.get("api_base") or payload.get("baseUrl") or payload.get("base_url")),
extra_headers=_string_dict(payload.get("extraHeaders") or payload.get("extra_headers") or payload.get("headers")),
request_timeout_seconds=_float(
payload.get("requestTimeoutSeconds")
or payload.get("request_timeout_seconds")
or payload.get("timeout")
),
)
return providers
def _parse_embedding(data: dict[str, Any]) -> EmbeddingConfig:
raw = _as_dict(data.get("embedding") or data.get("embeddings"))
return EmbeddingConfig(
provider=_string(raw.get("provider") or raw.get("provider_name")),
model=_string(raw.get("model") or data.get("embeddingModel") or data.get("embedding_model")),
api_key=_string(raw.get("apiKey") or raw.get("api_key")),
api_base=_string(raw.get("apiBase") or raw.get("api_base") or raw.get("baseUrl") or raw.get("base_url")),
extra_headers=_string_dict(raw.get("extraHeaders") or raw.get("extra_headers") or raw.get("headers")),
request_timeout_seconds=_float(
raw.get("requestTimeoutSeconds") or raw.get("request_timeout_seconds") or raw.get("timeout")
),
)
def _parse_tools(raw: Any) -> ToolsConfig:
data = _as_dict(raw)
mcp_servers: dict[str, MCPServerConfig] = {}
for server_id, payload in _as_dict(data.get("mcpServers") or data.get("mcp_servers")).items():
if not isinstance(payload, dict):
continue
mcp_servers[str(server_id)] = MCPServerConfig(
command=_string(payload.get("command")) or "",
args=_string_list(payload.get("args")),
env=_string_dict(payload.get("env")),
url=_string(payload.get("url")) or "",
headers=_string_dict(payload.get("headers")),
auth_mode=(_string(payload.get("authMode") or payload.get("auth_mode")) or "none").lower(),
auth_audience=_string(payload.get("authAudience") or payload.get("auth_audience")) or "",
auth_scopes=_string_list(payload.get("authScopes") or payload.get("auth_scopes")),
tool_timeout=int(_float(payload.get("toolTimeout") or payload.get("tool_timeout")) or 30),
sensitive=_bool(payload.get("sensitive"), default=False),
kind=(_string(payload.get("kind")) or ("local" if payload.get("command") else "online")).lower(),
category=_string(payload.get("category")) or ("local" if payload.get("command") else "online"),
managed=_bool(payload.get("managed"), default=False),
display_name=_string(payload.get("displayName") or payload.get("display_name")) or "",
source=_string(payload.get("source")) or "config",
)
for server_id, meta in LOCAL_MCP_CATEGORIES.items():
if server_id in mcp_servers:
continue
mcp_servers[server_id] = MCPServerConfig(
command=sys.executable or "python",
args=["-m", "beaver.interfaces.mcp.tools_server", "--category", meta["category"]],
env={},
kind="local",
category=meta["category"],
managed=True,
display_name=meta["display_name"],
source="beaver-default",
tool_timeout=60,
)
return ToolsConfig(
restrict_to_workspace=_bool(
data.get("restrictToWorkspace") if "restrictToWorkspace" in data else data.get("restrict_to_workspace"),
default=True,
),
mcp_servers=mcp_servers,
)
def _parse_authz(raw: Any) -> AuthzConfig:
data = _as_dict(raw)
return AuthzConfig(
enabled=_bool(data.get("enabled"), default=False),
base_url=_string(data.get("baseUrl") or data.get("base_url")) or "",
request_timeout_seconds=int(_float(data.get("requestTimeoutSeconds") or data.get("request_timeout_seconds")) or 10),
outlook_mcp_url=_string(data.get("outlookMcpUrl") or data.get("outlook_mcp_url")) or "",
)
def _parse_backend_identity(raw: Any) -> BackendIdentityConfig:
data = _as_dict(raw)
return BackendIdentityConfig(
backend_id=_string(data.get("backendId") or data.get("backend_id")) or "",
client_id=_string(data.get("clientId") or data.get("client_id")) or "",
client_secret=_string(data.get("clientSecret") or data.get("client_secret")) or "",
name=_string(data.get("name")) or "",
public_base_url=_string(data.get("publicBaseUrl") or data.get("public_base_url")) or "",
)
def _as_dict(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _string(value: Any) -> str | None:
if value is None:
return None
value = str(value).strip()
return value or None
def _string_dict(value: Any) -> dict[str, str]:
if not isinstance(value, dict):
return {}
return {str(key): str(item) for key, item in value.items() if item is not None}
def _string_list(value: Any) -> list[str]:
if not isinstance(value, list):
return []
return [str(item) for item in value if str(item).strip()]
def _float(value: Any) -> float | None:
if value in (None, ""):
return None
return float(value)
def _bool(value: Any, *, default: bool) -> bool:
if isinstance(value, bool):
return value
if value in (None, ""):
return default
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)

View File

@ -0,0 +1,218 @@
"""Runtime configuration schema for Beaver sandbox instances."""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass(slots=True)
class ProviderConfig:
"""One configured LLM provider profile."""
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
@dataclass(slots=True)
class AgentDefaultsConfig:
"""Default agent settings for this sandbox instance."""
workspace: str | None = None
model: str | None = None
provider: str | None = None
embedding_model: str | None = None
@dataclass(slots=True)
class EmbeddingConfig:
"""Optional dedicated embedding model settings."""
provider: 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
@dataclass(slots=True)
class MCPServerConfig:
"""One configured MCP server.
Transport is inferred from fields:
- command => local stdio MCP server
- url => remote streamable HTTP MCP server
"""
command: str = ""
args: list[str] = field(default_factory=list)
env: dict[str, str] = field(default_factory=dict)
url: str = ""
headers: dict[str, str] = field(default_factory=dict)
auth_mode: str = "none"
auth_audience: str = ""
auth_scopes: list[str] = field(default_factory=list)
tool_timeout: int = 30
sensitive: bool = False
kind: str = "online"
category: str = "online"
managed: bool = False
display_name: str = ""
source: str = "config"
@property
def transport(self) -> str:
return "stdio" if _clean(self.command) else "http"
@dataclass(slots=True)
class ToolsConfig:
"""Runtime tool configuration."""
restrict_to_workspace: bool = True
mcp_servers: dict[str, MCPServerConfig] = field(default_factory=dict)
@dataclass(slots=True)
class AuthzConfig:
"""External AuthZ service configuration."""
enabled: bool = False
base_url: str = ""
request_timeout_seconds: int = 10
outlook_mcp_url: str = ""
@dataclass(slots=True)
class BackendIdentityConfig:
"""This backend's AuthZ client identity."""
backend_id: str = ""
client_id: str = ""
client_secret: str = ""
name: str = ""
public_base_url: str = ""
@dataclass(slots=True)
class BeaverConfig:
"""Config loaded once per backend sandbox instance."""
agents_defaults: AgentDefaultsConfig = field(default_factory=AgentDefaultsConfig)
providers: dict[str, ProviderConfig] = field(default_factory=dict)
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
tools: ToolsConfig = field(default_factory=ToolsConfig)
authz: AuthzConfig = field(default_factory=AuthzConfig)
backend_identity: BackendIdentityConfig = field(default_factory=BackendIdentityConfig)
config_path: Path | None = None
@property
def default_model(self) -> str | None:
return _clean(self.agents_defaults.model)
@property
def default_embedding_model(self) -> str:
return _clean(self.embedding.model) or _clean(self.agents_defaults.embedding_model) or "text-embedding-v4"
def resolve_provider_target(
self,
*,
model: str | None = None,
provider_name: str | None = None,
) -> dict[str, Any]:
"""Resolve model/provider credentials from instance config.
Request-level model/provider overrides are allowed, but credentials are still
read from backend config, not from Web/channel payloads.
"""
resolved_model = _clean(model) or self.default_model
requested_provider = _clean(provider_name)
enabled_providers = self._enabled_provider_names()
resolved_provider = (
requested_provider
if requested_provider and requested_provider in enabled_providers
else self._infer_provider(resolved_model)
)
provider_cfg = self.providers.get(resolved_provider or "") if resolved_provider else None
payload: dict[str, Any] = {
"model": resolved_model,
"provider_name": resolved_provider,
}
if provider_cfg is not None:
payload.update(
{
"api_key": provider_cfg.api_key,
"api_base": provider_cfg.api_base,
"extra_headers": dict(provider_cfg.extra_headers),
"request_timeout_seconds": provider_cfg.request_timeout_seconds,
}
)
return {key: value for key, value in payload.items() if value not in (None, "", {})}
def resolve_embedding_target(self) -> dict[str, Any] | None:
"""Return an explicit embedding target when configured."""
has_explicit_embedding = any(
[
_clean(self.embedding.provider),
_clean(self.embedding.api_key),
_clean(self.embedding.api_base),
self.embedding.extra_headers,
self.embedding.request_timeout_seconds is not None,
]
)
if not has_explicit_embedding:
return None
provider_cfg = self.providers.get(_clean(self.embedding.provider) or "")
payload: dict[str, Any] = {
"provider": _clean(self.embedding.provider),
"model": self.default_embedding_model,
"api_key": _clean(self.embedding.api_key) or (provider_cfg.api_key if provider_cfg else None),
"api_base": _clean(self.embedding.api_base) or (provider_cfg.api_base if provider_cfg else None),
"extra_headers": self.embedding.extra_headers or (dict(provider_cfg.extra_headers) if provider_cfg else {}),
"request_timeout_seconds": self.embedding.request_timeout_seconds
or (provider_cfg.request_timeout_seconds if provider_cfg else None),
}
return {key: value for key, value in payload.items() if value not in (None, "", {})}
def _infer_provider(self, model: str | None) -> str | None:
configured_provider = _clean(self.agents_defaults.provider)
if configured_provider and configured_provider != "custom":
return configured_provider
if model and "/" in model:
prefix = model.split("/", 1)[0]
if prefix in self._enabled_provider_names():
return prefix
enabled_providers = self._enabled_provider_names()
if len(enabled_providers) == 1:
return enabled_providers[0]
return None
def _enabled_provider_names(self) -> list[str]:
return [
name
for name, provider in self.providers.items()
if name != "custom"
and any(
[
_clean(provider.api_key),
_clean(provider.api_base),
provider.extra_headers,
]
)
]
def _clean(value: str | None) -> str | None:
if value is None:
return None
value = str(value).strip()
return value or None

View File

@ -0,0 +1,205 @@
"""Shared embedding-based semantic retrieval utilities."""
from __future__ import annotations
import asyncio
import json
import math
import os
from typing import Any
from urllib import request
class EmbeddingRetriever:
"""Use an OpenAI-compatible embeddings API to rank lightweight candidates."""
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 = 3.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,
api_key: str | None = None,
api_base: str | None = None,
model: str | None = None,
extra_headers: dict[str, str] | None = None,
timeout_seconds: float | None = None,
fallback_top_k: int | None = None,
) -> list[dict[str, str]]:
"""Return candidates ordered by embedding similarity.
If embedding config is missing or the request fails, return the original
candidate order. This keeps retrieval non-blocking for the main run.
"""
if not candidates or top_k <= 0:
return []
fallback = self._fallback_candidates(candidates, fallback_top_k=fallback_top_k)
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 fallback
try:
query_embedding = await self._embed_texts(
api_key=resolved_api_key,
api_base=resolved_api_base,
texts=[query],
model=model or self.model,
extra_headers=extra_headers,
timeout_seconds=timeout_seconds,
)
candidate_embeddings = await self._embed_texts(
api_key=resolved_api_key,
api_base=resolved_api_base,
texts=[self._candidate_text(item) for item in candidates],
model=model or self.model,
extra_headers=extra_headers,
timeout_seconds=timeout_seconds,
)
except Exception:
return fallback
if not query_embedding or not query_embedding[0] or len(candidate_embeddings) != len(candidates):
return fallback
query_vector = query_embedding[0]
scored: list[tuple[float, dict[str, str]]] = []
for candidate, vector in zip(candidates, candidate_embeddings, strict=False):
if vector:
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,
extra_headers: dict[str, str] | None = None,
timeout_seconds: float | None = None,
) -> list[list[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,
extra_headers=extra_headers,
timeout_seconds=timeout_seconds,
)
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],
extra_headers: dict[str, str] | None = None,
timeout_seconds: float | None = None,
) -> dict[str, Any]:
return await asyncio.to_thread(
self._post_embeddings_sync,
endpoint=endpoint,
api_key=api_key,
model=model,
texts=texts,
extra_headers=extra_headers,
timeout_seconds=timeout_seconds,
)
def _post_embeddings_sync(
self,
*,
endpoint: str,
api_key: str,
model: str,
texts: list[str],
extra_headers: dict[str, str] | None = None,
timeout_seconds: float | None = None,
) -> 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",
**(extra_headers or {}),
},
method="POST",
)
with request.urlopen(req, timeout=timeout_seconds or self.timeout_seconds) as response:
return json.loads(response.read().decode("utf-8"))
@staticmethod
def _fallback_candidates(
candidates: list[dict[str, str]],
*,
fallback_top_k: int | None,
) -> list[dict[str, str]]:
if fallback_top_k is None:
return list(candidates)
if fallback_top_k <= 0:
return []
return candidates[:fallback_top_k]
@staticmethod
def _candidate_text(candidate: dict[str, str]) -> str:
parts = [
(candidate.get("name") or "").strip(),
(candidate.get("description") or "").strip(),
(candidate.get("input_schema") or "").strip(),
]
return "\n".join(part for part in parts if part)
@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)

View File

@ -0,0 +1,2 @@
"""Shared error types."""

View File

@ -0,0 +1,5 @@
"""Event contracts and dispatch helpers."""
from .message_bus import InboundMessage, MessageBus, OutboundMessage
__all__ = ["InboundMessage", "MessageBus", "OutboundMessage"]

View File

@ -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()

View File

@ -0,0 +1,11 @@
"""Shared Beaver data models."""
from .cron import CronExecutionResult, CronJob, CronPayload, CronRunRecord, CronSchedule
__all__ = [
"CronExecutionResult",
"CronJob",
"CronPayload",
"CronRunRecord",
"CronSchedule",
]

View File

@ -0,0 +1,265 @@
"""Scheduled task models for Beaver cron.
Every trigger targets Beaver Task mode so scheduled work remains visible as a
normal Task instead of a detached agent turn.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
from uuid import uuid4
CronScheduleKind = Literal["at", "every", "cron"]
CronPayloadKind = Literal["agent_turn", "system_event"]
CronPayloadMode = Literal["notification", "task"]
@dataclass(slots=True)
class CronSchedule:
kind: CronScheduleKind
at_ms: int | None = None
every_ms: int | None = None
expr: str | None = None
tz: str | None = None
display: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"kind": self.kind,
"at_ms": self.at_ms,
"every_ms": self.every_ms,
"expr": self.expr,
"tz": self.tz,
"display": self.display,
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "CronSchedule":
return cls(
kind=str(payload.get("kind") or "every"), # type: ignore[arg-type]
at_ms=_optional_int(payload.get("at_ms") or payload.get("atMs")),
every_ms=_optional_int(payload.get("every_ms") or payload.get("everyMs")),
expr=_optional_str(payload.get("expr")),
tz=_optional_str(payload.get("tz")),
display=_optional_str(payload.get("display")),
)
@dataclass(slots=True)
class CronPayload:
kind: CronPayloadKind = "agent_turn"
mode: CronPayloadMode = "notification"
message: str = ""
session_key: str | None = None
requires_followup: bool = False
deliver: bool = False
channel: str | None = None
to: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"kind": self.kind,
"mode": self.mode,
"message": self.message,
"session_key": self.session_key,
"requires_followup": self.requires_followup,
"deliver": self.deliver,
"channel": self.channel,
"to": self.to,
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "CronPayload":
return cls(
kind=str(payload.get("kind") or "agent_turn"), # type: ignore[arg-type]
mode=_payload_mode(payload.get("mode"), default="task"),
message=str(payload.get("message") or ""),
session_key=_optional_str(payload.get("session_key") or payload.get("sessionKey")),
requires_followup=bool(payload.get("requires_followup") or payload.get("requiresFollowup") or False),
deliver=bool(payload.get("deliver", False)),
channel=_optional_str(payload.get("channel")),
to=_optional_str(payload.get("to")),
)
@dataclass(slots=True)
class CronRunRecord:
started_at_ms: int
scheduled_run_id: str = field(default_factory=lambda: uuid4().hex)
finished_at_ms: int | None = None
status: Literal["running", "ok", "error", "skipped"] = "running"
mode: CronPayloadMode = "notification"
notification_session_id: str | None = None
output: str | None = None
task_id: str | None = None
run_id: str | None = None
error: str | None = None
engaged: bool = False
engaged_at_ms: int | None = None
engage_intent: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"scheduled_run_id": self.scheduled_run_id,
"started_at_ms": self.started_at_ms,
"finished_at_ms": self.finished_at_ms,
"status": self.status,
"mode": self.mode,
"notification_session_id": self.notification_session_id,
"output": self.output,
"task_id": self.task_id,
"run_id": self.run_id,
"error": self.error,
"engaged": self.engaged,
"engaged_at_ms": self.engaged_at_ms,
"engage_intent": self.engage_intent,
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "CronRunRecord":
return cls(
scheduled_run_id=str(payload.get("scheduled_run_id") or payload.get("scheduledRunId") or uuid4().hex),
started_at_ms=int(payload.get("started_at_ms") or payload.get("startedAtMs") or 0),
finished_at_ms=_optional_int(payload.get("finished_at_ms") or payload.get("finishedAtMs")),
status=str(payload.get("status") or "running"), # type: ignore[arg-type]
mode=_payload_mode(payload.get("mode"), default="notification"),
notification_session_id=_optional_str(payload.get("notification_session_id") or payload.get("notificationSessionId")),
output=_optional_str(payload.get("output")),
task_id=_optional_str(payload.get("task_id") or payload.get("taskId")),
run_id=_optional_str(payload.get("run_id") or payload.get("runId")),
error=_optional_str(payload.get("error")),
engaged=bool(payload.get("engaged", False)),
engaged_at_ms=_optional_int(payload.get("engaged_at_ms") or payload.get("engagedAtMs")),
engage_intent=_optional_str(payload.get("engage_intent") or payload.get("engageIntent")),
)
@dataclass(slots=True)
class CronJob:
id: str
name: str
enabled: bool
schedule: CronSchedule
payload: CronPayload
created_at_ms: int
updated_at_ms: int
next_run_at_ms: int | None = None
last_run_at_ms: int | None = None
last_status: Literal["ok", "error", "skipped"] | None = None
last_error: str | None = None
delete_after_run: bool = False
history: list[CronRunRecord] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"enabled": self.enabled,
"schedule": self.schedule.to_dict(),
"payload": self.payload.to_dict(),
"created_at_ms": self.created_at_ms,
"updated_at_ms": self.updated_at_ms,
"next_run_at_ms": self.next_run_at_ms,
"last_run_at_ms": self.last_run_at_ms,
"last_status": self.last_status,
"last_error": self.last_error,
"delete_after_run": self.delete_after_run,
"history": [item.to_dict() for item in self.history],
}
def to_api_dict(self) -> dict[str, Any]:
latest = self.history[-1] if self.history else None
return {
"id": self.id,
"name": self.name,
"enabled": self.enabled,
"schedule_kind": self.schedule.kind,
"schedule_display": self.schedule.display or _schedule_display(self.schedule),
"schedule_expr": self.schedule.expr,
"schedule_every_ms": self.schedule.every_ms,
"message": self.payload.message,
"mode": self.payload.mode,
"requires_followup": self.payload.requires_followup,
"deliver": self.payload.deliver,
"channel": self.payload.channel,
"to": self.payload.to,
"session_key": self.payload.session_key,
"next_run_at_ms": self.next_run_at_ms,
"last_run_at_ms": self.last_run_at_ms,
"last_status": self.last_status,
"last_error": self.last_error,
"last_scheduled_run_id": latest.scheduled_run_id if latest else None,
"last_task_id": latest.task_id if latest else None,
"last_run_id": latest.run_id if latest else None,
"history": [item.to_dict() for item in self.history],
"created_at_ms": self.created_at_ms,
"updated_at_ms": self.updated_at_ms,
}
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "CronJob":
schedule_payload = payload.get("schedule") if isinstance(payload.get("schedule"), dict) else {}
payload_payload = payload.get("payload") if isinstance(payload.get("payload"), dict) else {}
return cls(
id=str(payload["id"]),
name=str(payload.get("name") or payload["id"]),
enabled=bool(payload.get("enabled", True)),
schedule=CronSchedule.from_dict(schedule_payload),
payload=CronPayload.from_dict(payload_payload),
created_at_ms=int(payload.get("created_at_ms") or payload.get("createdAtMs") or 0),
updated_at_ms=int(payload.get("updated_at_ms") or payload.get("updatedAtMs") or 0),
next_run_at_ms=_optional_int(payload.get("next_run_at_ms") or payload.get("nextRunAtMs")),
last_run_at_ms=_optional_int(payload.get("last_run_at_ms") or payload.get("lastRunAtMs")),
last_status=_optional_str(payload.get("last_status") or payload.get("lastStatus")), # type: ignore[arg-type]
last_error=_optional_str(payload.get("last_error") or payload.get("lastError")),
delete_after_run=bool(payload.get("delete_after_run") or payload.get("deleteAfterRun") or False),
history=[
CronRunRecord.from_dict(item)
for item in payload.get("history") or []
if isinstance(item, dict)
],
)
@dataclass(slots=True)
class CronExecutionResult:
response: str | None = None
task_id: str | None = None
run_id: str | None = None
notification_session_id: str | None = None
mode: CronPayloadMode = "notification"
def _schedule_display(schedule: CronSchedule) -> str:
if schedule.kind == "every":
seconds = int((schedule.every_ms or 0) / 1000)
return f"every {seconds}s"
if schedule.kind == "cron":
return schedule.expr or "cron"
return "one-time"
def _optional_str(value: Any) -> str | None:
if value in (None, ""):
return None
return str(value)
def _optional_int(value: Any) -> int | None:
if value in (None, ""):
return None
def _payload_mode(value: Any, *, default: CronPayloadMode = "notification") -> CronPayloadMode:
if value in (None, ""):
return default
cleaned = str(value or "").strip().lower()
if cleaned == "task":
return "task"
return "notification"
try:
return int(value)
except (TypeError, ValueError):
return None

View File

@ -0,0 +1,2 @@
"""Common utility helpers."""

View File

@ -0,0 +1,2 @@
"""External integrations."""

View File

@ -0,0 +1,2 @@
"""A2A integration."""

View File

@ -0,0 +1,5 @@
"""AuthZ service client integration."""
from .client import AuthzClient
__all__ = ["AuthzClient"]

View File

@ -0,0 +1,111 @@
"""Small async client for the internal AuthZ service."""
from __future__ import annotations
from typing import Any
import httpx
class AuthzClient:
def __init__(self, base_url: str, timeout_seconds: int = 10) -> None:
self.base_url = base_url.rstrip("/")
self.timeout_seconds = timeout_seconds
async def _request(self, method: str, path: str, *, json_body: dict[str, Any] | None = None) -> Any:
async with httpx.AsyncClient(
timeout=self.timeout_seconds,
follow_redirects=True,
trust_env=False,
) as client:
response = await client.request(method, f"{self.base_url}{path}", json=json_body)
response.raise_for_status()
if not response.content:
return None
return response.json()
async def issue_token(
self,
*,
client_id: str,
client_secret: str,
audience: str,
scopes: list[str],
) -> dict[str, Any]:
data = await self._request(
"POST",
"/oauth/token",
json_body={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"aud": audience,
"scopes": list(scopes),
},
)
return data if isinstance(data, dict) else {}
async def get_permissions(self, backend_id: str) -> dict[str, Any]:
data = await self._request("GET", f"/backends/{backend_id}/permissions")
return data if isinstance(data, dict) else {}
async def set_permissions(self, backend_id: str, payload: dict[str, Any]) -> dict[str, Any]:
data = await self._request("POST", f"/backends/{backend_id}/permissions", json_body=payload)
return data if isinstance(data, dict) else {}
async def register_user(
self,
*,
username: str,
password: str,
email: str | None = None,
backend_name: str | None = None,
backend_id: str | None = None,
base_url: str | None = None,
frontend_base_url: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"username": username,
"password": password,
}
optional = {
"email": email,
"backend_name": backend_name,
"backend_id": backend_id,
"base_url": base_url,
"frontend_base_url": frontend_base_url,
}
payload.update({key: value for key, value in optional.items() if value})
data = await self._request("POST", "/oauth/register", json_body=payload)
return data if isinstance(data, dict) else {}
async def register_backend(
self,
*,
name: str,
base_url: str,
frontend_base_url: str | None = None,
backend_id: str | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"name": name,
"base_url": base_url,
}
if frontend_base_url:
payload["frontend_base_url"] = frontend_base_url
if backend_id:
payload["backend_id"] = backend_id
data = await self._request("POST", "/backends/register", json_body=payload)
return data if isinstance(data, dict) else {}
async def get_outlook_settings(self, backend_id: str) -> dict[str, Any]:
data = await self._request("GET", f"/backends/{backend_id}/settings/outlook")
return data if isinstance(data, dict) else {}
async def set_outlook_settings(self, backend_id: str, payload: dict[str, Any]) -> dict[str, Any]:
data = await self._request("POST", f"/backends/{backend_id}/settings/outlook", json_body=payload)
return data if isinstance(data, dict) else {}
async def delete_outlook_settings(self, backend_id: str) -> dict[str, Any]:
data = await self._request("DELETE", f"/backends/{backend_id}/settings/outlook")
return data if isinstance(data, dict) else {}

View File

@ -0,0 +1,5 @@
"""MCP integration."""
from .connection import MCPConnectionManager, test_mcp_server
__all__ = ["MCPConnectionManager", "test_mcp_server"]

View File

@ -0,0 +1,192 @@
"""MCP connection manager."""
from __future__ import annotations
import asyncio
from contextlib import AsyncExitStack
from dataclasses import dataclass, field
from typing import Any
import httpx
from beaver.foundation.config import AuthzConfig, BackendIdentityConfig, MCPServerConfig
from beaver.integrations.authz import AuthzClient
from beaver.tools.mcp.wrapper import MCPToolWrapper
from beaver.tools.registry import ToolRegistry
@dataclass(slots=True)
class MCPConnectionReport:
status: str = "disconnected"
last_error: str | None = None
tool_names: list[str] = field(default_factory=list)
tool_count: int = 0
transport: str = "http"
def to_dict(self) -> dict[str, Any]:
return {
"status": self.status,
"last_error": self.last_error,
"tool_names": list(self.tool_names),
"tool_count": self.tool_count,
"transport": self.transport,
}
class MCPConnectionManager:
def __init__(
self,
servers: dict[str, MCPServerConfig],
*,
authz_config: AuthzConfig | None = None,
backend_identity: BackendIdentityConfig | None = None,
) -> None:
self.servers = servers
self.authz_config = authz_config
self.backend_identity = backend_identity
self.stack = AsyncExitStack()
self.connected = False
self._connect_lock = asyncio.Lock()
self.report: dict[str, MCPConnectionReport] = {}
async def connect_all(self, registry: ToolRegistry) -> dict[str, dict[str, Any]]:
async with self._connect_lock:
if self.connected:
return {key: value.to_dict() for key, value in self.report.items()}
self.report = {}
for server_id, cfg in self.servers.items():
self.report[server_id] = MCPConnectionReport(transport=cfg.transport)
try:
if cfg.command:
await self._connect_stdio(server_id, cfg, registry)
elif cfg.url:
await self._connect_http(server_id, cfg, registry)
else:
raise ValueError("MCP server requires command or url")
self.report[server_id].status = "connected"
self.report[server_id].tool_count = len(self.report[server_id].tool_names)
except Exception as exc:
self.report[server_id].status = "error"
self.report[server_id].last_error = _describe_exception(exc, server_id=server_id, url=cfg.url or None)
self.connected = True
return {key: value.to_dict() for key, value in self.report.items()}
async def close(self) -> None:
await self.stack.aclose()
self.connected = False
async def _headers(self, server_id: str, cfg: MCPServerConfig) -> dict[str, str]:
headers = dict(cfg.headers or {})
if cfg.auth_mode != "oauth_backend_token":
return headers
if not (
self.authz_config
and self.authz_config.enabled
and self.authz_config.base_url
and self.backend_identity
and self.backend_identity.client_id
and self.backend_identity.client_secret
):
raise RuntimeError("oauth_backend_token requires AuthZ and backend identity")
audience = cfg.auth_audience or f"mcp:{server_id}"
client = AuthzClient(self.authz_config.base_url, timeout_seconds=self.authz_config.request_timeout_seconds)
token = await client.issue_token(
client_id=self.backend_identity.client_id,
client_secret=self.backend_identity.client_secret,
audience=audience,
scopes=list(cfg.auth_scopes),
)
access_token = str(token.get("access_token") or "").strip()
if not access_token:
raise RuntimeError("AuthZ did not return an access token")
headers["Authorization"] = f"Bearer {access_token}"
return headers
async def _open_http_session(self, cfg: MCPServerConfig, headers: dict[str, str]):
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
http_client = await self.stack.enter_async_context(
httpx.AsyncClient(headers=headers or None, follow_redirects=True, trust_env=False)
)
read, write, _ = await self.stack.enter_async_context(streamable_http_client(cfg.url, http_client=http_client))
session = await self.stack.enter_async_context(ClientSession(read, write))
await session.initialize()
return session
async def _connect_http(self, server_id: str, cfg: MCPServerConfig, registry: ToolRegistry) -> None:
headers = await self._headers(server_id, cfg)
session = await self._open_http_session(cfg, headers)
tools = await session.list_tools()
for tool_def in tools.tools:
async def call_tool(tool_name: str, args: dict[str, Any], *, _session=session) -> Any:
return await _session.call_tool(tool_name, arguments=args)
wrapper = MCPToolWrapper(
server_id,
tool_def,
call_tool,
cfg.tool_timeout,
cfg.sensitive,
cfg.kind,
cfg.category,
cfg.display_name,
)
registry.register(wrapper, replace=True)
if wrapper.spec.name not in self.report[server_id].tool_names:
self.report[server_id].tool_names.append(wrapper.spec.name)
async def _connect_stdio(self, server_id: str, cfg: MCPServerConfig, registry: ToolRegistry) -> None:
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
params = StdioServerParameters(command=cfg.command, args=list(cfg.args), env=dict(cfg.env) or None)
read, write = await self.stack.enter_async_context(stdio_client(params))
session = await self.stack.enter_async_context(ClientSession(read, write))
await session.initialize()
tools = await session.list_tools()
for tool_def in tools.tools:
async def call_tool(tool_name: str, args: dict[str, Any], *, _session=session) -> Any:
return await _session.call_tool(tool_name, arguments=args)
wrapper = MCPToolWrapper(
server_id,
tool_def,
call_tool,
cfg.tool_timeout,
cfg.sensitive,
cfg.kind,
cfg.category,
cfg.display_name,
)
registry.register(wrapper, replace=True)
if wrapper.spec.name not in self.report[server_id].tool_names:
self.report[server_id].tool_names.append(wrapper.spec.name)
async def test_mcp_server(
server_id: str,
cfg: MCPServerConfig,
*,
authz_config: AuthzConfig | None = None,
backend_identity: BackendIdentityConfig | None = None,
) -> dict[str, Any]:
registry = ToolRegistry()
manager = MCPConnectionManager({server_id: cfg}, authz_config=authz_config, backend_identity=backend_identity)
try:
report = await manager.connect_all(registry)
return {"ok": report.get(server_id, {}).get("status") == "connected", "server": server_id, **report.get(server_id, {})}
finally:
await manager.close()
def _describe_exception(exc: BaseException, *, server_id: str, url: str | None = None) -> str:
target = f" ({url})" if url else ""
if isinstance(exc, httpx.TimeoutException):
return f"MCP server '{server_id}' timed out{target}"
if isinstance(exc, httpx.ConnectError):
return f"MCP server '{server_id}' is unreachable{target}"
if isinstance(exc, httpx.HTTPStatusError):
return f"MCP server '{server_id}' returned HTTP {exc.response.status_code}{target}"
detail = str(exc).strip() or exc.__class__.__name__
return f"MCP server '{server_id}' failed{target}: {detail}"

View File

@ -0,0 +1,527 @@
"""Workspace-scoped Outlook helpers for the web UI."""
from __future__ import annotations
import asyncio
import json
import os
import shlex
from contextlib import AsyncExitStack
from dataclasses import asdict, dataclass
from datetime import datetime, time, timedelta
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
import httpx
from beaver.foundation.config import BeaverConfig
from beaver.integrations.authz import AuthzClient
OUTLOOK_SERVER_ID = os.getenv("BEAVER_OUTLOOK_MCP_SERVER_ID", "outlook_mcp")
OUTLOOK_OVERVIEW_MESSAGE_LIMIT = 8
OUTLOOK_OVERVIEW_EVENT_LIMIT = 20
OUTLOOK_MAX_PAGE_SIZE = 100
class OutlookIntegrationError(RuntimeError):
"""Raised when the Outlook integration backend is unavailable or misconfigured."""
@dataclass(frozen=True)
class OutlookDefaults:
domain: str = os.getenv("BEAVER_OUTLOOK_DEFAULT_DOMAIN", "")
service_endpoint: str = os.getenv("BEAVER_OUTLOOK_DEFAULT_EWS_URL", "")
server: str = os.getenv("BEAVER_OUTLOOK_DEFAULT_EWS_SERVER", "")
default_timezone: str = os.getenv("BEAVER_OUTLOOK_DEFAULT_TIMEZONE", "Asia/Shanghai")
autodiscover: bool = os.getenv("BEAVER_OUTLOOK_DEFAULT_AUTODISCOVER", "0") == "1"
@dataclass(frozen=True)
class OutlookConnectionInput:
email: str
password: str
username: str | None = None
domain: str | None = None
service_endpoint: str | None = None
server: str | None = None
autodiscover: bool = False
default_timezone: str = "Asia/Shanghai"
OUTLOOK_TOOL_NAMES = [
"auth_status",
"mail_list_folders",
"mail_list_messages",
"mail_search_messages",
"mail_get_message",
"mail_send_email",
"mail_reply_to_message",
"mail_forward_message",
"mail_move_message",
"mail_delta_sync",
"calendar_list_events",
"calendar_create_event",
"calendar_update_event",
"calendar_get_schedule",
"calendar_find_meeting_times",
"calendar_delta_sync",
]
def _call_timeout_seconds() -> float:
raw = os.getenv("BEAVER_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS", "").strip()
try:
return max(1.0, float(raw)) if raw else 10.0
except ValueError:
return 10.0
def _use_authz_mode(config: BeaverConfig) -> bool:
return bool(config.authz.enabled and config.authz.base_url.strip())
def _authz_client(config: BeaverConfig) -> AuthzClient:
if not _use_authz_mode(config):
raise OutlookIntegrationError("AuthZ mode is not enabled.")
return AuthzClient(config.authz.base_url, timeout_seconds=int(config.authz.request_timeout_seconds))
def _require_backend_identity(config: BeaverConfig) -> str:
backend_id = config.backend_identity.backend_id.strip()
client_id = config.backend_identity.client_id.strip()
client_secret = config.backend_identity.client_secret.strip()
if not (backend_id and client_id and client_secret):
raise OutlookIntegrationError("Backend is not registered with AuthZ yet.")
return backend_id
def _outlook_mcp_url(config: BeaverConfig) -> str:
url = config.authz.outlook_mcp_url.strip()
if not url:
raise OutlookIntegrationError("AuthZ mode requires authz.outlook_mcp_url to be configured.")
return url
def outlook_defaults() -> dict[str, Any]:
return {
"provider": "ews",
"server_id": OUTLOOK_SERVER_ID,
"mcp_command": os.getenv("BEAVER_OUTLOOK_MCP_COMMAND", "bw-outlook-mcp"),
"mcp_extra_args": shlex.split(os.getenv("BEAVER_OUTLOOK_MCP_EXTRA_ARGS", "").strip()),
"fields": asdict(OutlookDefaults()),
}
def outlook_mcp_config_payload(config: BeaverConfig) -> dict[str, Any]:
url = _outlook_mcp_url(config)
return {
"url": url,
"authMode": "oauth_backend_token",
"authAudience": f"mcp:{OUTLOOK_SERVER_ID}",
"authScopes": ["list_tools", *[f"tool:{name}" for name in OUTLOOK_TOOL_NAMES]],
"sensitive": True,
"toolTimeout": 60,
"kind": "online",
"category": "outlook",
"managed": True,
"displayName": "Outlook MCP",
"source": "beaver-managed",
}
def _meta_file(workspace: Path) -> Path:
return workspace.expanduser().resolve() / "state" / "bw_outlook_mcp" / "ui_meta.json"
def _load_meta(workspace: Path) -> dict[str, Any]:
path = _meta_file(workspace)
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def _update_meta(workspace: Path, **fields: Any) -> dict[str, Any]:
payload = _load_meta(workspace)
payload.update(fields)
payload["updated_at"] = datetime.now().isoformat()
path = _meta_file(workspace)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return payload
def _normalize_input(data: OutlookConnectionInput) -> OutlookConnectionInput:
email = data.email.strip()
password = data.password
username = (data.username or "").strip() or email.partition("@")[0].strip()
domain = (data.domain or "").strip() or None
service_endpoint = (data.service_endpoint or "").strip() or None
server = (data.server or "").strip() or None
default_timezone = (data.default_timezone or "").strip() or OutlookDefaults.default_timezone
if service_endpoint:
server = None
if not email:
raise OutlookIntegrationError("Email is required.")
if not password:
raise OutlookIntegrationError("Password is required.")
if not username:
raise OutlookIntegrationError("Username is required.")
if not data.autodiscover and not service_endpoint and not server:
raise OutlookIntegrationError("Provide an EWS URL, a server host, or enable autodiscover.")
return OutlookConnectionInput(
email=email,
password=password,
username=username,
domain=domain,
service_endpoint=service_endpoint,
server=server,
autodiscover=bool(data.autodiscover),
default_timezone=default_timezone,
)
def _default_outlook_permissions() -> dict[str, Any]:
return {
"mcp": {
OUTLOOK_SERVER_ID: {
"enabled": True,
"tools": list(OUTLOOK_TOOL_NAMES),
}
},
"a2a": {"enabled": False, "agents": []},
}
async def ensure_outlook_authz_permissions(config: BeaverConfig) -> None:
backend_id = _require_backend_identity(config)
client = _authz_client(config)
existing = await client.get_permissions(backend_id)
mcp_settings = existing.get("mcp", {}).get(OUTLOOK_SERVER_ID, {}) if isinstance(existing, dict) else {}
if isinstance(mcp_settings, dict) and mcp_settings.get("enabled"):
return
await client.set_permissions(backend_id, _default_outlook_permissions())
async def _call_outlook_mcp_tool(
config: BeaverConfig,
tool_name: str,
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
url = _outlook_mcp_url(config)
client = _authz_client(config)
try:
token_response = await client.issue_token(
client_id=config.backend_identity.client_id,
client_secret=config.backend_identity.client_secret,
audience=f"mcp:{OUTLOOK_SERVER_ID}",
scopes=scopes or ["list_tools", f"tool:{tool_name}"],
)
except httpx.TimeoutException as exc:
raise OutlookIntegrationError("AuthZ token 请求超时。") from exc
except httpx.HTTPError as exc:
detail = str(exc).strip() or exc.__class__.__name__
raise OutlookIntegrationError(f"AuthZ token 获取失败:{detail}") from exc
access_token = str(token_response.get("access_token") or "").strip()
if not access_token:
raise OutlookIntegrationError("Failed to obtain an Outlook MCP access token.")
async def _invoke() -> dict[str, Any]:
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 _call_timeout_seconds(),
)
)
read, write, _ = await stack.enter_async_context(streamable_http_client(url, http_client=http_client))
session = await stack.enter_async_context(ClientSession(read, write))
await session.initialize()
result = await session.call_tool(tool_name, arguments=arguments)
parts: list[str] = []
for block in result.content:
parts.append(block.text if isinstance(block, types.TextContent) else str(block))
output = "\n".join(parts).strip()
if not output:
return {}
try:
parsed = json.loads(output)
except json.JSONDecodeError:
return {"text": output}
return parsed if isinstance(parsed, dict) else {"value": parsed}
timeout_value = timeout_seconds or _call_timeout_seconds()
try:
return await asyncio.wait_for(_invoke(), timeout=timeout_value)
except TimeoutError as exc:
raise OutlookIntegrationError(f"Outlook MCP 请求超时:{tool_name} 超过 {int(timeout_value)}s") from exc
except OutlookIntegrationError:
raise
except Exception as exc:
detail = str(exc).strip() or exc.__class__.__name__
raise OutlookIntegrationError(f"Outlook MCP 调用失败:{detail}") from exc
async def test_connection(data: OutlookConnectionInput, config: BeaverConfig) -> dict[str, Any]:
if not _use_authz_mode(config):
raise OutlookIntegrationError("Outlook setup requires AuthZ mode in this Beaver instance.")
normalized = _normalize_input(data)
return {
"ok": True,
"provider": "ews",
"mailbox": normalized.email,
"resolved_username": normalized.username or "",
"resolved_domain": normalized.domain,
"sample": {"folders": [], "inbox": [], "events": []},
"warnings": [
"AuthZ mode skips local EWS validation. Credentials will be validated by the Outlook MCP service after save."
],
}
async def connect_workspace(config: BeaverConfig, workspace: Path, data: OutlookConnectionInput) -> dict[str, Any]:
probe = await test_connection(data, config)
normalized = _normalize_input(data)
backend_id = _require_backend_identity(config)
client = _authz_client(config)
await client.set_outlook_settings(
backend_id,
{
"configured": True,
"email": normalized.email,
"username": normalized.username,
"domain": normalized.domain,
"service_endpoint": normalized.service_endpoint,
"server": normalized.server,
"autodiscover": normalized.autodiscover,
"default_timezone": normalized.default_timezone,
"password": normalized.password,
},
)
await ensure_outlook_authz_permissions(config)
meta = _update_meta(
workspace,
provider="ews",
mailbox=normalized.email,
last_verified_at=datetime.now().isoformat(),
last_connected_at=datetime.now().isoformat(),
)
return {
"ok": True,
"probe": probe["sample"],
"saved": {"backend_id": backend_id, "configured": True},
"mcp": {"id": OUTLOOK_SERVER_ID, **outlook_mcp_config_payload(config)},
"meta": meta,
}
async def disconnect_workspace(config: BeaverConfig) -> dict[str, Any]:
backend_id = _require_backend_identity(config)
removed = False
try:
result = await _authz_client(config).delete_outlook_settings(backend_id)
removed = bool(result.get("ok"))
except Exception:
removed = False
return {"ok": True, "removed_state": removed, "removed_mcp": False, "server_id": OUTLOOK_SERVER_ID}
async def outlook_status(config: BeaverConfig, workspace: Path) -> dict[str, Any]:
meta = _load_meta(workspace)
if not _use_authz_mode(config):
return {
"configured": False,
"connected": False,
"provider": None,
"storage_mode": "workspace",
"saved": None,
"auth_status": None,
"mcp_registered": OUTLOOK_SERVER_ID in config.tools.mcp_servers,
"mcp_server_id": OUTLOOK_SERVER_ID,
"defaults": outlook_defaults(),
"meta": meta,
"error": "Outlook setup requires AuthZ mode in this Beaver instance.",
}
client = _authz_client(config)
backend_id = _require_backend_identity(config)
saved = await client.get_outlook_settings(backend_id)
configured = bool(saved.get("configured"))
connected = False
auth_status: dict[str, Any] | None = None
error: str | None = None
if configured:
try:
auth_status = await _call_outlook_mcp_tool(config, "auth_status", {}, scopes=["list_tools", "tool:auth_status"])
connected = bool(auth_status.get("authenticated"))
except Exception as exc:
error = str(exc)
return {
"configured": configured,
"connected": connected,
"provider": "ews" if configured else None,
"storage_mode": "authz",
"saved": saved if configured else None,
"auth_status": auth_status,
"mcp_registered": bool(OUTLOOK_SERVER_ID in config.tools.mcp_servers or config.authz.outlook_mcp_url.strip()),
"mcp_server_id": OUTLOOK_SERVER_ID,
"defaults": outlook_defaults(),
"meta": meta,
"error": error,
}
async def get_overview(config: BeaverConfig, workspace: Path) -> 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.")
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] = []
async def _load_section(label: str, coro: Any) -> dict[str, Any]:
try:
payload = await coro
return payload if isinstance(payload, dict) else {"value": []}
except Exception as exc:
warnings.append(f"{label} unavailable: {exc}")
return {"value": []}
inbox, sent, calendar = await asyncio.gather(
_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"],
),
),
_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"],
),
),
_load_section(
"calendar",
_call_outlook_mcp_tool(
config,
"calendar_list_events",
{
"start_time": start_of_day.isoformat(),
"end_time": end_of_day.isoformat(),
"top": OUTLOOK_OVERVIEW_EVENT_LIMIT,
"skip": 0,
},
scopes=["list_tools", "tool:calendar_list_events"],
),
),
)
meta = _update_meta(workspace, last_overview_refresh_at=datetime.now().isoformat())
return {
"mailbox": saved.get("email") or "",
"timezone": timezone_name,
"today": now.date().isoformat(),
"connection": await outlook_status(config, workspace),
"recentInbox": inbox.get("value", []),
"recentSent": sent.get("value", []),
"todayEvents": calendar.get("value", []),
"warnings": warnings,
"meta": meta,
}
def _normalize_page_args(*, top: int, skip: int) -> tuple[int, int]:
return max(1, min(int(top), OUTLOOK_MAX_PAGE_SIZE)), max(0, int(skip))
def _normalize_page_payload(payload: dict[str, Any], *, top: int, skip: int) -> dict[str, Any]:
items = payload.get("value", []) if isinstance(payload, dict) else []
returned = len(items) if isinstance(items, list) else 0
page = payload.get("page") if isinstance(payload, dict) else None
if isinstance(page, dict):
return {
**payload,
"page": {
"top": int(page.get("top", top)),
"skip": int(page.get("skip", skip)),
"returned": int(page.get("returned", returned)),
"has_more": bool(page.get("has_more", False)),
"next_skip": page.get("next_skip"),
},
}
return {
**payload,
"page": {
"top": top,
"skip": skip,
"returned": returned,
"has_more": returned >= top,
"next_skip": skip + returned if returned >= top else None,
},
}
async def list_messages(
config: BeaverConfig,
*,
folder: str,
top: int,
skip: int = 0,
unread_only: bool = False,
) -> dict[str, Any]:
safe_top, safe_skip = _normalize_page_args(top=top, skip=skip)
payload = await _call_outlook_mcp_tool(
config,
"mail_list_messages",
{"folder": folder, "top": safe_top, "skip": safe_skip, "unread_only": unread_only},
scopes=["list_tools", "tool:mail_list_messages"],
)
return {"folder": folder, "unread_only": unread_only, **_normalize_page_payload(payload, top=safe_top, skip=safe_skip)}
async def list_events(
config: BeaverConfig,
*,
start_time: str,
end_time: str,
top: int,
skip: int = 0,
) -> dict[str, Any]:
safe_top, safe_skip = _normalize_page_args(top=top, skip=skip)
payload = await _call_outlook_mcp_tool(
config,
"calendar_list_events",
{"start_time": start_time, "end_time": end_time, "top": safe_top, "skip": safe_skip},
scopes=["list_tools", "tool:calendar_list_events"],
)
return {"start_time": start_time, "end_time": end_time, **_normalize_page_payload(payload, top=safe_top, skip=safe_skip)}
async def get_message_detail(config: BeaverConfig, message_id: str, *, changekey: str | None = None) -> dict[str, Any]:
return await _call_outlook_mcp_tool(
config,
"mail_get_message",
{"message_id": message_id, "changekey": changekey},
scopes=["list_tools", "tool:mail_get_message"],
)

View File

@ -0,0 +1,2 @@
"""Provider-specific integrations."""

View File

@ -0,0 +1,2 @@
"""WhatsApp integration."""

View File

@ -0,0 +1,2 @@
"""Thin interface layer for Beaver."""

View File

@ -0,0 +1,7 @@
"""Channel interfaces."""
from .base import ChannelAdapter
from .manager import ChannelManager
from .memory import MemoryChannelAdapter
__all__ = ["ChannelAdapter", "ChannelManager", "MemoryChannelAdapter"]

View File

@ -0,0 +1,24 @@
"""Channel adapter contracts for gateway-facing integrations."""
from __future__ import annotations
from typing import Protocol
from beaver.foundation.events import MessageBus, OutboundMessage
class ChannelAdapter(Protocol):
"""Minimal contract every gateway channel must implement."""
name: str
bus: MessageBus
async def start(self) -> None:
"""Prepare the channel before messages are routed."""
async def stop(self) -> None:
"""Stop accepting/routing channel messages."""
async def send(self, message: OutboundMessage) -> None:
"""Deliver an outbound message to the concrete channel."""

View File

@ -0,0 +1,76 @@
"""Channel manager for routing gateway outbound messages."""
from __future__ import annotations
import asyncio
from contextlib import suppress
from beaver.foundation.events import MessageBus, OutboundMessage
from .base import ChannelAdapter
class ChannelManager:
"""Start/stop channel adapters and dispatch outbound messages to them."""
def __init__(self, bus: MessageBus) -> None:
self.bus = bus
self.channels: dict[str, ChannelAdapter] = {}
self.undeliverable: list[OutboundMessage] = []
self.started = False
def register(self, channel: ChannelAdapter) -> None:
if self.started:
raise RuntimeError("Cannot register channels after ChannelManager.start()")
if channel.name in self.channels:
raise ValueError(f"Channel already registered: {channel.name}")
if channel.bus is not self.bus:
raise ValueError("Channel must share the same MessageBus as ChannelManager")
self.channels[channel.name] = channel
async def start(self) -> None:
started: list[ChannelAdapter] = []
try:
for channel in self.channels.values():
await channel.start()
started.append(channel)
except BaseException:
for channel in reversed(started):
with suppress(BaseException):
await channel.stop()
raise
else:
self.started = True
async def stop(self) -> None:
errors: list[BaseException] = []
for channel in reversed(tuple(self.channels.values())):
try:
await channel.stop()
except Exception as exc: # pragma: no cover - defensive cleanup path
errors.append(exc)
self.started = False
if errors:
raise RuntimeError(f"Failed to stop {len(errors)} channel(s)") from errors[0]
async def dispatch_outbound(self, stop_event: asyncio.Event) -> None:
"""Route bus outbound messages until stopped and the queue is drained."""
while True:
if stop_event.is_set() and self.bus.outbound_size == 0:
break
try:
message = await asyncio.wait_for(self.bus.consume_outbound(), timeout=0.25)
except asyncio.TimeoutError:
continue
channel = self.channels.get(message.channel)
if channel is None:
self.undeliverable.append(message)
continue
try:
await channel.send(message)
except Exception: # pragma: no cover - defensive channel isolation
self.undeliverable.append(message)

View File

@ -0,0 +1,91 @@
"""In-memory channel adapter for tests and local gateway embedding."""
from __future__ import annotations
from typing import Any
from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage
class MemoryChannelAdapter:
"""A local channel that stores outbound messages in memory."""
def __init__(self, bus: MessageBus, *, name: str = "memory") -> None:
self.name = name
self.bus = bus
self.started = False
self.sent_messages: list[OutboundMessage] = []
async def start(self) -> None:
self.started = True
async def stop(self) -> None:
self.started = False
async def send(self, message: OutboundMessage) -> None:
self.sent_messages.append(message)
async def publish_text(
self,
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,
metadata: dict[str, Any] | None = None,
) -> InboundMessage:
"""Publish a text message from this channel into the shared bus."""
message = InboundMessage(
channel=self.name,
content=content,
session_id=session_id,
user_id=user_id,
title=title,
execution_context=execution_context,
model=model,
provider_name=provider_name,
embedding_model=embedding_model,
metadata=metadata or {},
)
await self.bus.publish_inbound(message)
return message
async def publish_external_text(
self,
content: str,
*,
chat_id: str,
message_id: str | None = None,
thread_id: str | None = None,
raw_payload: dict[str, Any] | None = None,
user_id: str | None = None,
title: str | None = None,
) -> InboundMessage:
"""Publish an old-style channel payload through the new adapter contract.
Real platform adapters should keep platform-specific fields here, build
a stable Beaver session_id, and pass the normalized InboundMessage to
the shared gateway bus.
"""
session_parts = [self.name, chat_id]
if thread_id:
session_parts.append(thread_id)
metadata = {
"chat_id": chat_id,
"message_id": message_id,
"thread_id": thread_id,
"raw_channel_payload": raw_payload or {},
}
return await self.publish_text(
content,
session_id=":".join(str(part) for part in session_parts if str(part)),
user_id=user_id,
title=title,
metadata=metadata,
)

View File

@ -0,0 +1,2 @@
"""CLI interface."""

View File

@ -0,0 +1,60 @@
"""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."),
config: str | None = typer.Option(None, "--config", help="Backend config path for this run."),
) -> None:
"""Thin CLI wrapper around AgentService.
CLI 现在不再自己维护执行逻辑,只负责:
1. 解析命令行参数
2. 调 AgentService
3. 打印结果
"""
service = AgentService(workspace=workspace, config_path=config)
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()

View File

@ -0,0 +1,2 @@
"""Gateway interface."""

View File

@ -0,0 +1,224 @@
"""Gateway entrypoint for Beaver.
当前阶段只做最小 gateway 宿主与 channel adapter 桥接:
1. 启动时托管 `AgentService.start()`
2. 常驻消费 `MessageBus.inbound`
3. 调 `service.handle_inbound_message(...)`
4. 将结果写回 `MessageBus.outbound`
5. 如果配置了 channel adapters则由 `ChannelManager` 分发 outbound
6. 退出时走 `AgentService.shutdown()`
"""
from __future__ import annotations
import asyncio
from collections.abc import Sequence
from contextlib import suppress
from pathlib import Path
from beaver.foundation.events import InboundMessage, MessageBus
from beaver.interfaces.channels import ChannelAdapter, ChannelManager
from beaver.services.agent_service import AgentService
def _validate_gateway_service(service: AgentService) -> None:
"""Fail fast on injected service objects that do not satisfy gateway needs."""
handler = getattr(service, "handle_inbound_message", None)
if not callable(handler):
raise TypeError(
"Gateway requires a service with an async 'handle_inbound_message(inbound)' method"
)
async def _cleanup_owned_service(
service: AgentService,
*,
timeout_seconds: float | None,
force: bool,
) -> None:
"""Best-effort cleanup for service startup failures or cancellations."""
with suppress(BaseException):
if service.is_running:
await service.shutdown(timeout_seconds=timeout_seconds, force=force)
else:
service.close()
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 bus.publish_outbound(
AgentService.build_outbound_error(
pending,
detail=reason,
finish_reason="stopped",
)
)
async def _await_task_shutdown(task: asyncio.Task[None], *, timeout_seconds: float = 1.0) -> None:
"""等待后台任务退出;超时则取消,避免 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:
outbound = await service.handle_inbound_message(inbound)
except asyncio.CancelledError:
await bus.publish_outbound(
AgentService.build_outbound_error(
inbound,
detail="Gateway stopped before completing the inbound message",
finish_reason="cancelled",
)
)
raise
else:
await bus.publish_outbound(outbound)
async def run_gateway(
*,
workspace: str | Path | None = None,
config_path: str | Path | None = None,
service: AgentService | None = None,
bus: MessageBus | None = None,
channels: Sequence[ChannelAdapter] | None = None,
channel_manager: ChannelManager | 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
- `channel_manager` 和 `channels` 二选一,避免隐式修改外部 manager
"""
attached_service = service or AgentService(workspace=workspace, config_path=config_path)
_validate_gateway_service(attached_service)
if channel_manager is not None and channels is not None:
raise ValueError("Pass either channel_manager or channels, not both")
if bus is not None:
attached_bus = bus
elif channel_manager is not None:
attached_bus = channel_manager.bus
else:
attached_bus = MessageBus()
attached_channel_manager = channel_manager
if attached_channel_manager is not None and attached_channel_manager.bus is not attached_bus:
raise ValueError("Injected channel_manager must share the gateway MessageBus")
if attached_channel_manager is None and channels is not None:
attached_channel_manager = ChannelManager(attached_bus)
if attached_channel_manager is not None and channels is not None:
for channel in channels:
attached_channel_manager.register(channel)
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
channels_started = False
if owns_service:
try:
await attached_service.start()
started = True
except BaseException:
await _cleanup_owned_service(
attached_service,
timeout_seconds=shutdown_timeout_seconds,
force=shutdown_force,
)
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."
)
if attached_channel_manager is not None:
try:
await attached_channel_manager.start()
channels_started = True
except BaseException:
if owns_service and started:
await _cleanup_owned_service(
attached_service,
timeout_seconds=shutdown_timeout_seconds,
force=shutdown_force,
)
raise
bridge_task = asyncio.create_task(_bridge_inbound_to_runtime(attached_service, attached_bus, owned_stop_event))
dispatch_task: asyncio.Task[None] | None = None
dispatch_stop_event = asyncio.Event()
if attached_channel_manager is not None:
dispatch_task = asyncio.create_task(attached_channel_manager.dispatch_outbound(dispatch_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_task_shutdown(bridge_task)
else:
await _await_task_shutdown(bridge_task)
if dispatch_task is not None:
dispatch_stop_event.set()
await _await_task_shutdown(dispatch_task)
if attached_channel_manager is not None and channels_started:
await attached_channel_manager.stop()
def main() -> None:
"""同步 gateway 入口。"""
try:
asyncio.run(run_gateway())
except KeyboardInterrupt:
pass

View File

@ -0,0 +1,2 @@
"""MCP server entrypoints."""

View File

@ -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()

View File

@ -0,0 +1,192 @@
"""Beaver local tools as real stdio MCP servers."""
from __future__ import annotations
import argparse
import asyncio
import json
import os
from pathlib import Path
from typing import Any
import mcp.types as types
from mcp.server.lowlevel import Server
from mcp.server.lowlevel.server import NotificationOptions
from mcp.server.models import InitializationOptions
from mcp.server.stdio import stdio_server
from beaver.engine.session import SessionManager
from beaver.memory.curated.store import MemoryStore
from beaver.services.cron_service import CronService
from beaver.skills import SkillsLoader
from beaver.skills.drafts import DraftService
from beaver.skills.specs import SkillSpecStore
from beaver.tools.base import BaseTool, ObjectBackedTool, ToolContext
from beaver.tools.builtins import (
ClarifyTool,
CronTool,
DelegateTool,
ExecuteCodeTool,
ListDirectoryTool,
MemoryTool,
PatchFileTool,
ProcessTool,
ReadFileTool,
SearchFilesTool,
SendMessageTool,
SkillManageTool,
SkillViewTool,
SkillsListTool,
SpawnTool,
TerminalTool,
TodoTool,
WebFetchTool,
WebSearchTool,
WriteFileTool,
)
LOCAL_TOOL_CATEGORIES = {
"filesystem": "Beaver Local Filesystem Tools",
"runtime": "Beaver Local Runtime Tools",
"memory": "Beaver Local Memory Tools",
"skills": "Beaver Local Skills Tools",
"coordination": "Beaver Local Coordination Tools",
"scheduler": "Beaver Local Scheduler Tools",
"web": "Beaver Local Web Tools",
}
def _workspace_path(value: str | None = None) -> Path:
raw = value or os.getenv("BEAVER_WORKSPACE")
if raw:
return Path(raw).expanduser().resolve()
return Path.cwd()
def _json_content(value: str) -> dict[str, Any]:
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {"success": True, "result": parsed}
except json.JSONDecodeError:
return {"success": True, "content": value}
def _category_tools(category: str, workspace: Path) -> tuple[list[BaseTool], ToolContext]:
skill_store = SkillSpecStore(workspace)
skills_loader = SkillsLoader(workspace, skill_store=skill_store)
draft_service = DraftService(skill_store)
services = {
"skills_loader": skills_loader,
"draft_service": draft_service,
}
context = ToolContext(workspace=str(workspace), services=services)
if category == "filesystem":
tools: list[BaseTool] = [
ObjectBackedTool(ListDirectoryTool()),
ObjectBackedTool(ReadFileTool()),
ObjectBackedTool(SearchFilesTool()),
ObjectBackedTool(WriteFileTool()),
ObjectBackedTool(PatchFileTool()),
]
elif category == "runtime":
tools = [
ObjectBackedTool(TerminalTool()),
ObjectBackedTool(ProcessTool()),
ObjectBackedTool(ExecuteCodeTool()),
]
elif category == "memory":
session_manager = SessionManager(workspace)
memory_store = MemoryStore(workspace / "memory" / "curated")
memory_store.load_from_disk()
tools = [
ObjectBackedTool(MemoryTool(store=memory_store)),
ObjectBackedTool(__import__("beaver.tools.builtins.session_search", fromlist=["SessionSearchTool"]).SessionSearchTool(db=session_manager)),
]
elif category == "skills":
tools = [
ObjectBackedTool(SkillViewTool(loader=skills_loader)),
SkillsListTool(),
SkillManageTool(),
]
elif category == "coordination":
tools = [
ObjectBackedTool(TodoTool()),
ObjectBackedTool(ClarifyTool()),
ObjectBackedTool(DelegateTool()),
ObjectBackedTool(SpawnTool()),
ObjectBackedTool(SendMessageTool()),
]
elif category == "scheduler":
services["cron_service"] = CronService(workspace / "cron" / "jobs.json")
tools = [CronTool()]
elif category == "web":
tools = [
ObjectBackedTool(WebFetchTool()),
ObjectBackedTool(WebSearchTool()),
]
else:
raise ValueError(f"Unknown local tool category: {category}")
return tools, context
def create_tools_server(*, category: str, workspace: str | None = None) -> Server:
workspace_path = _workspace_path(workspace)
tools, context = _category_tools(category, workspace_path)
tool_map = {tool.spec.name: tool for tool in tools}
server = Server(LOCAL_TOOL_CATEGORIES.get(category, f"Beaver Local {category} Tools"))
@server.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name=tool.spec.name,
description=tool.spec.description,
inputSchema=tool.spec.input_schema,
)
for tool in tools
]
@server.call_tool(validate_input=True)
async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
tool = tool_map.get(name)
if tool is None:
return {"success": False, "error": f"Unknown tool: {name}"}
result = await tool.invoke(arguments or {}, context)
if result.raw_output is not None and isinstance(result.raw_output, dict):
return result.raw_output
payload = _json_content(result.content)
if "success" not in payload:
payload["success"] = bool(result.success)
if result.error and "error" not in payload:
payload["error"] = result.error
return payload
return server
async def _run_stdio(category: str, workspace: str | None) -> None:
server = create_tools_server(category=category, workspace=workspace)
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
InitializationOptions(
server_name=LOCAL_TOOL_CATEGORIES.get(category, f"beaver-{category}"),
server_version="0.1.0",
capabilities=server.get_capabilities(notification_options=NotificationOptions(), experimental_capabilities={}),
),
)
def main() -> None:
parser = argparse.ArgumentParser(description="Run a Beaver local tool category as a stdio MCP server.")
parser.add_argument("--category", choices=sorted(LOCAL_TOOL_CATEGORIES), required=True)
parser.add_argument("--workspace", default=None)
args = parser.parse_args()
asyncio.run(_run_stdio(args.category, args.workspace))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,2 @@
"""Web interface."""

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -1,8 +1,9 @@
"""File storage helpers for the web API."""
"""File storage and workspace browsing helpers for the web API."""
from __future__ import annotations
import json
import mimetypes
import shutil
import uuid
from datetime import datetime, timezone
@ -12,7 +13,8 @@ from urllib.parse import quote
def content_disposition(disposition: str, filename: str) -> str:
"""Build Content-Disposition header value, RFC 5987 encoding for non-ASCII."""
"""Build a Content-Disposition header, including RFC 5987 for non-ASCII names."""
try:
filename.encode("ascii")
return f'{disposition}; filename="{filename}"'
@ -20,28 +22,10 @@ def content_disposition(disposition: str, filename: str) -> str:
utf8_quoted = quote(filename)
return f"{disposition}; filename*=UTF-8''{utf8_quoted}"
from loguru import logger
def _is_safe_filename(filename: str) -> bool:
"""Check if filename is safe (no path separators or dot-prefixed)."""
return bool(filename) and "/" not in filename and "\\" not in filename and not filename.startswith(".")
def _is_safe_file_id(file_id: str) -> bool:
"""Ensure file_id contains only hex characters."""
return bool(file_id) and all(c in '0123456789abcdef' for c in file_id)
def _files_dir(workspace: Path) -> Path:
"""Return the files storage directory, creating it if needed."""
d = workspace / "files"
d.mkdir(parents=True, exist_ok=True)
return d
def generate_file_id() -> str:
"""Generate a short unique file ID (12 hex chars)."""
"""Generate a short unique file id."""
return uuid.uuid4().hex[:12]
@ -53,12 +37,13 @@ def save_file(
content_type: str,
session_id: str = "web:default",
) -> dict[str, Any]:
"""Save a file to workspace/files/<file_id>/ and write metadata.json."""
"""Save an uploaded attachment under workspace/files/<file_id>/."""
if not _is_safe_filename(filename):
raise ValueError(f"Invalid filename: {filename}")
file_dir = _files_dir(workspace) / file_id
file_dir.mkdir(parents=True, exist_ok=True)
file_path = file_dir / filename
file_path.write_bytes(content)
@ -71,42 +56,46 @@ def save_file(
"session_id": session_id,
}
(file_dir / "metadata.json").write_text(json.dumps(metadata, ensure_ascii=False), encoding="utf-8")
return metadata
def get_file_metadata(workspace: Path, file_id: str) -> dict[str, Any] | None:
"""Load metadata for a file. Returns None if not found or invalid."""
"""Load attachment metadata."""
if not _is_safe_file_id(file_id):
return None
meta_path = _files_dir(workspace) / file_id / "metadata.json"
if not meta_path.exists():
return None
try:
return json.loads(meta_path.read_text(encoding="utf-8"))
data = json.loads(meta_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, ValueError):
logger.warning(f"Corrupted metadata file: {meta_path}")
return None
return data if isinstance(data, dict) else None
def get_file_path(workspace: Path, file_id: str) -> Path | None:
"""Get the actual file path for a file_id. Returns None if not found."""
"""Resolve the stored attachment path."""
meta = get_file_metadata(workspace, file_id)
if meta is None:
return None
file_path = _files_dir(workspace) / file_id / meta["name"]
# Ensure resolved path is within files directory
file_path = _files_dir(workspace) / file_id / str(meta.get("name") or "")
try:
file_path.resolve().relative_to(_files_dir(workspace).resolve())
except ValueError:
return None
return file_path if file_path.exists() else None
return file_path if file_path.exists() and file_path.is_file() else None
def list_files(workspace: Path, session_id: str | None = None) -> list[dict[str, Any]]:
"""List all file metadata, optionally filtered by session_id."""
"""List uploaded attachments, optionally filtered by session."""
files_dir = _files_dir(workspace)
result = []
result: list[dict[str, Any]] = []
for entry in sorted(files_dir.iterdir()):
if not entry.is_dir():
continue
@ -117,6 +106,8 @@ def list_files(workspace: Path, session_id: str | None = None) -> list[dict[str,
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, ValueError):
continue
if not isinstance(meta, dict):
continue
if session_id and meta.get("session_id") != session_id:
continue
result.append(meta)
@ -124,9 +115,11 @@ def list_files(workspace: Path, session_id: str | None = None) -> list[dict[str,
def delete_file(workspace: Path, file_id: str) -> bool:
"""Delete a file and its metadata. Returns True if deleted."""
"""Delete a stored attachment by id."""
if not _is_safe_file_id(file_id):
return False
file_dir = _files_dir(workspace) / file_id
if not file_dir.exists():
return False
@ -134,61 +127,48 @@ def delete_file(workspace: Path, file_id: str) -> bool:
return True
# ---------------------------------------------------------------------------
# Workspace browser helpers (browse the entire workspace directory)
# ---------------------------------------------------------------------------
import mimetypes
def _resolve_workspace_path(workspace: Path, rel_path: str) -> Path | None:
"""Resolve a relative path within workspace, rejecting traversal."""
workspace = workspace.resolve()
target = (workspace / rel_path).resolve()
try:
target.relative_to(workspace)
except ValueError:
return None
return target
def browse_workspace(workspace: Path, rel_path: str = "") -> dict[str, Any]:
"""List contents of a directory within the workspace."""
workspace = workspace.resolve()
"""List files and directories below the workspace root."""
workspace = _ensure_workspace(workspace)
target = _resolve_workspace_path(workspace, rel_path)
if target is None or not target.is_dir():
raise ValueError("Invalid directory path")
items: list[dict[str, Any]] = []
try:
entries = sorted(target.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower()))
except PermissionError:
raise ValueError("Permission denied")
entries = sorted(target.iterdir(), key=lambda entry: (not entry.is_dir(), entry.name.lower()))
except PermissionError as exc:
raise ValueError("Permission denied") from exc
items: list[dict[str, Any]] = []
for entry in entries:
# Skip hidden files/dirs
if entry.name.startswith("."):
continue
rel = str(entry.relative_to(workspace))
if entry.is_dir():
items.append({
"name": entry.name,
"path": rel,
"type": "directory",
"size": None,
"modified": datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc).isoformat(),
})
items.append(
{
"name": entry.name,
"path": rel,
"type": "directory",
"size": None,
"modified": datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc).isoformat(),
}
)
elif entry.is_file():
stat = entry.stat()
ct, _ = mimetypes.guess_type(entry.name)
items.append({
"name": entry.name,
"path": rel,
"type": "file",
"size": stat.st_size,
"content_type": ct or "application/octet-stream",
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
})
content_type, _ = mimetypes.guess_type(entry.name)
items.append(
{
"name": entry.name,
"path": rel,
"type": "file",
"size": stat.st_size,
"content_type": content_type or "application/octet-stream",
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
}
)
return {
"path": str(target.relative_to(workspace)) if target != workspace else "",
"items": items,
@ -196,16 +176,47 @@ def browse_workspace(workspace: Path, rel_path: str = "") -> dict[str, Any]:
def workspace_file_path(workspace: Path, rel_path: str) -> Path | None:
"""Resolve a file path within workspace for download."""
"""Resolve a workspace file path for download."""
workspace = _ensure_workspace(workspace)
target = _resolve_workspace_path(workspace, rel_path)
if target is None or not target.is_file():
return None
return target
def workspace_file_preview(workspace: Path, rel_path: str, *, max_bytes: int = 1024 * 1024) -> dict[str, Any]:
"""Return a bounded preview payload for a workspace file."""
file_path = workspace_file_path(workspace, rel_path)
if file_path is None:
raise ValueError("File not found")
stat = file_path.stat()
content_type, _ = mimetypes.guess_type(file_path.name)
content_type = content_type or "application/octet-stream"
raw = file_path.read_bytes() if stat.st_size <= max_bytes else file_path.read_bytes()[:max_bytes]
is_binary = _is_probably_binary(raw, content_type)
content = None if is_binary else raw.decode("utf-8", errors="replace")
return {
"name": file_path.name,
"path": str(file_path.relative_to(_ensure_workspace(workspace))),
"size": stat.st_size,
"content_type": content_type,
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"is_binary": is_binary,
"is_truncated": stat.st_size > max_bytes,
"content": content,
}
def save_to_workspace(workspace: Path, rel_dir: str, filename: str, content: bytes) -> dict[str, Any]:
"""Save uploaded file to a specific directory in the workspace."""
workspace = workspace.resolve()
"""Save an uploaded file to a workspace directory."""
if not filename:
raise ValueError("Invalid filename")
workspace = _ensure_workspace(workspace)
target_dir = _resolve_workspace_path(workspace, rel_dir)
if target_dir is None:
raise ValueError("Invalid directory path")
@ -214,29 +225,28 @@ def save_to_workspace(workspace: Path, rel_dir: str, filename: str, content: byt
file_path = (target_dir / filename).resolve()
try:
file_path.relative_to(workspace)
except ValueError:
raise ValueError("Invalid filename")
except ValueError as exc:
raise ValueError("Invalid filename") from exc
file_path.write_bytes(content)
stat = file_path.stat()
ct, _ = mimetypes.guess_type(filename)
content_type, _ = mimetypes.guess_type(filename)
return {
"name": filename,
"path": str(file_path.relative_to(workspace)),
"type": "file",
"size": stat.st_size,
"content_type": ct or "application/octet-stream",
"content_type": content_type or "application/octet-stream",
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
}
def delete_workspace_path(workspace: Path, rel_path: str) -> bool:
"""Delete a file or directory from the workspace."""
"""Delete a file or directory below workspace root."""
workspace = _ensure_workspace(workspace)
target = _resolve_workspace_path(workspace, rel_path)
if target is None or not target.exists():
return False
# Don't allow deleting the workspace root
if target == workspace.resolve():
if target is None or not target.exists() or target == workspace:
return False
if target.is_dir():
shutil.rmtree(target)
@ -246,14 +256,67 @@ def delete_workspace_path(workspace: Path, rel_path: str) -> bool:
def create_workspace_dir(workspace: Path, rel_path: str) -> dict[str, Any]:
"""Create a directory in the workspace."""
workspace = workspace.resolve()
"""Create a directory below workspace root."""
workspace = _ensure_workspace(workspace)
target = _resolve_workspace_path(workspace, rel_path)
if target is None:
if target is None or target == workspace:
raise ValueError("Invalid directory path")
target.mkdir(parents=True, exist_ok=True)
stat = target.stat()
return {
"name": target.name,
"path": str(target.relative_to(workspace)),
"type": "directory",
"size": None,
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
}
def _files_dir(workspace: Path) -> Path:
directory = _ensure_workspace(workspace) / "files"
directory.mkdir(parents=True, exist_ok=True)
return directory
def _ensure_workspace(workspace: Path) -> Path:
root = Path(workspace).expanduser()
root.mkdir(parents=True, exist_ok=True)
return root.resolve()
def _resolve_workspace_path(workspace: Path, rel_path: str) -> Path | None:
root = _ensure_workspace(workspace)
target = (root / rel_path).resolve()
try:
target.relative_to(root)
except ValueError:
return None
return target
def _is_probably_binary(raw: bytes, content_type: str) -> bool:
if content_type.startswith("text/") or content_type in {
"application/json",
"application/javascript",
"application/xml",
"application/x-yaml",
}:
return False
if not raw:
return False
if b"\x00" in raw[:4096]:
return True
try:
raw[:4096].decode("utf-8")
except UnicodeDecodeError:
return True
return False
def _is_safe_filename(filename: str) -> bool:
return bool(filename) and "/" not in filename and "\\" not in filename and not filename.startswith(".")
def _is_safe_file_id(file_id: str) -> bool:
return bool(file_id) and all(char in "0123456789abcdef" for char in file_id)

View File

@ -0,0 +1,2 @@
"""Web routes."""

View File

@ -0,0 +1,25 @@
"""Web request and response schemas."""
from .chat import (
WebChatFeedbackRequest,
WebChatFeedbackResponse,
WebChatRequest,
WebChatResponse,
WebErrorResponse,
WebProviderConfigRequest,
WebProviderConfigResponse,
WebProviderTarget,
WebStatusResponse,
)
__all__ = [
"WebChatFeedbackRequest",
"WebChatFeedbackResponse",
"WebChatRequest",
"WebChatResponse",
"WebErrorResponse",
"WebProviderConfigRequest",
"WebProviderConfigResponse",
"WebProviderTarget",
"WebStatusResponse",
]

View File

@ -0,0 +1,137 @@
"""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
thinking_enabled: bool | None = None
max_tool_iterations: int | None = None
fallback_target: WebProviderTarget | None = None
auxiliary_target: WebProviderTarget | None = None
embedding_target: WebProviderTarget | None = None
reply_to_scheduled_run_id: str | None = None
scheduled_reply_intent: str | 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)
task_id: str | None = None
task_status: str | None = None
validation_result: dict[str, Any] | None = None
class WebChatFeedbackRequest(BaseModel):
"""Feedback on the latest assistant result in chat."""
session_id: str
run_id: str
feedback_type: str
comment: str | None = None
class WebChatFeedbackResponse(BaseModel):
"""Feedback recording result."""
session_id: str
run_id: str
task_id: str
task_status: str
feedback_type: str
learning_candidates: list[dict[str, Any]] = Field(default_factory=list)
class WebProviderConfigRequest(BaseModel):
"""Provider config update from the status page."""
enabled: bool = True
model: str | None = None
api_key: str | None = None
api_base: str | None = None
request_timeout_seconds: float | None = None
class WebProviderConfigResponse(BaseModel):
"""Provider config update result."""
ok: bool
provider: str
enabled: bool
class WebStatusResponse(BaseModel):
"""Web 宿主层状态响应。"""
status: str
running: bool
mode: str
class WebErrorResponse(BaseModel):
"""统一错误响应结构。"""
detail: str

View File

@ -0,0 +1,2 @@
"""Memory and experience stores."""

View File

@ -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",
]

View File

@ -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"),
)

View File

@ -0,0 +1,463 @@
"""Beaver 的精炼长期记忆存储层。
这个文件实现的是 Beaver 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

View File

@ -0,0 +1,2 @@
"""Reusable procedures."""

View File

@ -0,0 +1,6 @@
"""Run records."""
from .models import RunOutcome, RunRecord, SkillEffectRecord
from .store import RunMemoryStore
__all__ = ["RunMemoryStore", "RunOutcome", "RunRecord", "SkillEffectRecord"]

Some files were not shown because too many files have changed in this diff Show More