merge agent team evidence validation work
This commit is contained in:
35
.env.example
35
.env.example
@ -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
25
.gitignore
vendored
@ -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
411
DESIGN.md
Normal 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
460
README.md
@ -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
145
agents/registry.json
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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` 后端
|
||||
- 实例创建
|
||||
- 实例删除
|
||||
- 实例列表
|
||||
|
||||
145
app-instance/agents/registry.json
Normal file
145
app-instance/agents/registry.json
Normal 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
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.egg-info
|
||||
dist/
|
||||
build/
|
||||
.git
|
||||
.env
|
||||
.assets
|
||||
node_modules/
|
||||
bridge/dist/
|
||||
workspace/
|
||||
201
app-instance/backend/.gitignore
vendored
201
app-instance/backend/.gitignore
vendored
@ -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
|
||||
@ -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. 所以实现时建议做兼容层,不要只押一种命名
|
||||
@ -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"/>
|
||||
@ -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"]
|
||||
@ -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.
|
||||
@ -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/team;team 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 bridge(Node 20)
|
||||
├── tests/ # 测试
|
||||
├── Dockerfile # 当前镜像构建文件
|
||||
├── docker-compose.yml # 当前自带 compose 示例(偏 gateway / CLI)
|
||||
└── workflow.md # 运行链路说明
|
||||
```
|
||||
|
||||
## 运行模式
|
||||
|
||||
| 命令 | 用途 | 默认端口 | 适合谁 |
|
||||
| --- | --- | --- | --- |
|
||||
| `nanobot agent` | 本地单轮 / 交互调试 | 无 | 开发排查 |
|
||||
| `nanobot web` | 启动 FastAPI 后端 | `18080` | 独立前端、接口调试、单用户使用 |
|
||||
| `nanobot gateway` | 启动常驻 worker | 无固定 HTTP 入口 | Telegram/Slack/Email/cron/heartbeat |
|
||||
| `nanobot status` | 查看配置和 provider 状态 | 无 | 开发、运维 |
|
||||
|
||||
注意:
|
||||
|
||||
- 如果你是给 Web 前端提供后端,请启动 `nanobot web`,不要误用 `gateway`
|
||||
- `gateway` 当前不是对外 Web API 服务
|
||||
- `web` 和 `gateway` 都会碰到同一份 workspace / cron / MCP 状态,通常不要在同一份数据目录上无脑同时跑两套
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Python `>=3.11`
|
||||
- 推荐使用 `uv`
|
||||
- 如果要构建 WhatsApp bridge 或使用仓库自带 Dockerfile,需要 Node.js `20`
|
||||
|
||||
本地开发最省事的方式:
|
||||
|
||||
```bash
|
||||
uv sync --extra dev
|
||||
```
|
||||
|
||||
如果你不用 `uv`,也可以:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## 本地快速启动
|
||||
|
||||
### 1. 初始化配置
|
||||
|
||||
```bash
|
||||
nanobot onboard
|
||||
```
|
||||
|
||||
初始化后默认会生成:
|
||||
|
||||
- 配置文件:`~/.nanobot/config.json`
|
||||
- 工作区:`~/.nanobot/workspace`
|
||||
|
||||
### 2. 填最小配置
|
||||
|
||||
下面是一份适合服务器环境的最小示例,重点是:
|
||||
|
||||
- 用绝对路径的 workspace
|
||||
- 建议打开 `restrictToWorkspace`
|
||||
- 先用 API Key provider,少踩 OAuth 交互坑
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "/root/.nanobot/workspace",
|
||||
"model": "openai/gpt-5"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openai": {
|
||||
"apiKey": "sk-xxxx"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"restrictToWorkspace": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如果你不是跑在容器里,把 `/root/.nanobot/workspace` 换成你自己的绝对路径。
|
||||
|
||||
### 3. 检查配置
|
||||
|
||||
```bash
|
||||
nanobot status
|
||||
```
|
||||
|
||||
### 4. 本地调试 agent
|
||||
|
||||
```bash
|
||||
nanobot agent -m "你好"
|
||||
```
|
||||
|
||||
### 5. 启动 Web 后端
|
||||
|
||||
```bash
|
||||
nanobot web --host 0.0.0.0 --port 18080
|
||||
```
|
||||
|
||||
启动后可直接访问:
|
||||
|
||||
- `http://127.0.0.1:18080/docs`
|
||||
- `http://127.0.0.1:18080/api/ping`
|
||||
|
||||
## Web API 能力概览
|
||||
|
||||
当前 `nanobot web` 提供的 API 大致包括:
|
||||
|
||||
- 聊天与流式输出
|
||||
- 会话管理
|
||||
- cron 任务管理
|
||||
- skills / plugins / agents 管理
|
||||
- 工作区文件浏览、上传、下载、删除
|
||||
- MCP server 管理与测试
|
||||
- Outlook 集成状态、连接测试、连接/断开、Overview、Message Detail
|
||||
|
||||
如果你有独立前端,这个后端就是给前端接的;如果没有前端,也可以直接走 `/docs` 调试。
|
||||
|
||||
## Outlook MCP 集成
|
||||
|
||||
这是当前仓库里最容易部署时踩坑的一块。
|
||||
|
||||
### 关系先说清楚
|
||||
|
||||
当前后端不会自己实现 Outlook 协议,它依赖外部仓库 `BW_Outlook_Mcp`:
|
||||
|
||||
- 后端代码位置:`nanobot/web/outlook.py`
|
||||
- 默认查找逻辑:
|
||||
1. 先看环境变量 `NANOBOT_OUTLOOK_MCP_ROOT`
|
||||
2. 再看与本仓库同级目录的 `../BW_Outlook_Mcp`
|
||||
3. 如果以上都没有,就尝试直接执行 PATH 里的 `bw-outlook-mcp`
|
||||
|
||||
也就是说,部署同事必须额外把 `BW_Outlook_Mcp` 这个仓库准备好,或者把它直接安装进镜像。
|
||||
|
||||
### 推荐的两种接法
|
||||
|
||||
#### 方案 A:把 `BW_Outlook_Mcp` 安装进同一个 Python 环境
|
||||
|
||||
这是生产环境更稳的方案。
|
||||
|
||||
部署同事需要:
|
||||
|
||||
```bash
|
||||
git clone <你们的 BW_Outlook_Mcp 仓库地址> /srv/BW_Outlook_Mcp
|
||||
cd /srv/BW_Outlook_Mcp
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
安装完成后,容器或宿主机里能直接执行:
|
||||
|
||||
```bash
|
||||
bw-outlook-mcp --help
|
||||
```
|
||||
|
||||
这样 Boardware Genius 就会直接用 PATH 里的 `bw-outlook-mcp`,不依赖额外挂载路径。
|
||||
|
||||
#### 方案 B:把 `BW_Outlook_Mcp` 作为外部目录挂进来
|
||||
|
||||
这是开发或临时部署更方便的方案。
|
||||
|
||||
部署同事需要至少做到两件事:
|
||||
|
||||
1. 把 `BW_Outlook_Mcp` 仓库拉到服务器
|
||||
2. 让这个目录里存在一个可执行的 `bw-outlook-mcp`
|
||||
|
||||
最简单的约定是:
|
||||
|
||||
```bash
|
||||
git clone <你们的 BW_Outlook_Mcp 仓库地址> /srv/BW_Outlook_Mcp
|
||||
cd /srv/BW_Outlook_Mcp
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
然后给 Boardware Genius 设置:
|
||||
|
||||
```bash
|
||||
export NANOBOT_OUTLOOK_MCP_ROOT=/srv/BW_Outlook_Mcp
|
||||
```
|
||||
|
||||
因为当前后端会优先寻找:
|
||||
|
||||
```text
|
||||
$NANOBOT_OUTLOOK_MCP_ROOT/.venv/bin/bw-outlook-mcp
|
||||
```
|
||||
|
||||
如果你挂了仓库目录但里面没有 `.venv/bin/bw-outlook-mcp`,那就必须确保 `bw-outlook-mcp` 已经在容器 PATH 里。
|
||||
|
||||
### Outlook 的认证和配置
|
||||
|
||||
`BW_Outlook_Mcp` 本身支持两套后端:
|
||||
|
||||
- `graph`:Microsoft 365 / Exchange Online
|
||||
- `ews`:本地或回迁后的 Exchange Server
|
||||
|
||||
#### Graph 登录
|
||||
|
||||
```bash
|
||||
bw-outlook-mcp auth login-graph \
|
||||
--workspace /root/.nanobot/workspace \
|
||||
--client-id YOUR_CLIENT_ID \
|
||||
--tenant-id YOUR_TENANT_ID
|
||||
```
|
||||
|
||||
#### EWS 配置
|
||||
|
||||
```bash
|
||||
bw-outlook-mcp auth setup-ews \
|
||||
--workspace /root/.nanobot/workspace \
|
||||
--email you@example.com \
|
||||
--username your_username \
|
||||
--domain example.com \
|
||||
--server mail.example.com
|
||||
```
|
||||
|
||||
如果你已经有固定 EWS URL,也可以改用:
|
||||
|
||||
```bash
|
||||
bw-outlook-mcp auth setup-ews \
|
||||
--workspace /root/.nanobot/workspace \
|
||||
--email you@example.com \
|
||||
--username your_username \
|
||||
--service-endpoint https://mail.example.com/EWS/Exchange.asmx
|
||||
```
|
||||
|
||||
#### 查看状态
|
||||
|
||||
```bash
|
||||
bw-outlook-mcp auth status --workspace /root/.nanobot/workspace
|
||||
```
|
||||
|
||||
### Outlook 状态文件会落在哪里
|
||||
|
||||
所有 Outlook 相关状态默认都落在 workspace 下:
|
||||
|
||||
```text
|
||||
<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` 表达。
|
||||
|
||||
@ -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.
|
||||
6
app-instance/backend/beaver/__init__.py
Normal file
6
app-instance/backend/beaver/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""Beaver backend package."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
34
app-instance/backend/beaver/coordinator/__init__.py
Normal file
34
app-instance/backend/beaver/coordinator/__init__.py
Normal 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",
|
||||
]
|
||||
@ -0,0 +1,2 @@
|
||||
"""Pluggable multi-agent backends."""
|
||||
|
||||
20
app-instance/backend/beaver/coordinator/backends/base.py
Normal file
20
app-instance/backend/beaver/coordinator/backends/base.py
Normal 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."""
|
||||
|
||||
@ -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.
|
||||
"""
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
"""Delegation orchestration."""
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
"""Execution control, retry, and aggregation."""
|
||||
|
||||
from .scheduler import TeamGraphScheduler
|
||||
|
||||
__all__ = ["TeamGraphScheduler"]
|
||||
270
app-instance/backend/beaver/coordinator/execution/scheduler.py
Normal file
270
app-instance/backend/beaver/coordinator/execution/scheduler.py
Normal 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,
|
||||
)
|
||||
151
app-instance/backend/beaver/coordinator/local.py
Normal file
151
app-instance/backend/beaver/coordinator/local.py
Normal 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)
|
||||
154
app-instance/backend/beaver/coordinator/models.py
Normal file
154
app-instance/backend/beaver/coordinator/models.py
Normal 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,
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
"""Team planning and execution-plan generation."""
|
||||
|
||||
14
app-instance/backend/beaver/coordinator/registry/__init__.py
Normal file
14
app-instance/backend/beaver/coordinator/registry/__init__.py
Normal 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",
|
||||
]
|
||||
184
app-instance/backend/beaver/coordinator/registry/models.py
Normal file
184
app-instance/backend/beaver/coordinator/registry/models.py
Normal 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"
|
||||
208
app-instance/backend/beaver/coordinator/registry/resolver.py
Normal file
208
app-instance/backend/beaver/coordinator/registry/resolver.py
Normal 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}
|
||||
196
app-instance/backend/beaver/coordinator/registry/store.py
Normal file
196
app-instance/backend/beaver/coordinator/registry/store.py
Normal 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",
|
||||
),
|
||||
]
|
||||
220
app-instance/backend/beaver/coordinator/subagents.py
Normal file
220
app-instance/backend/beaver/coordinator/subagents.py
Normal 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
|
||||
19
app-instance/backend/beaver/coordinator/team/__init__.py
Normal file
19
app-instance/backend/beaver/coordinator/team/__init__.py
Normal 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",
|
||||
]
|
||||
31
app-instance/backend/beaver/engine/__init__.py
Normal file
31
app-instance/backend/beaver/engine/__init__.py
Normal 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}")
|
||||
17
app-instance/backend/beaver/engine/context/__init__.py
Normal file
17
app-instance/backend/beaver/engine/context/__init__.py
Normal file
@ -0,0 +1,17 @@
|
||||
"""Context assembly for agent runs."""
|
||||
|
||||
from .builder import (
|
||||
ContextBuildInput,
|
||||
ContextBuildResult,
|
||||
ContextBuilder,
|
||||
SessionContext,
|
||||
SkillContext,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ContextBuildInput",
|
||||
"ContextBuildResult",
|
||||
"ContextBuilder",
|
||||
"SessionContext",
|
||||
"SkillContext",
|
||||
]
|
||||
375
app-instance/backend/beaver/engine/context/builder.py
Normal file
375
app-instance/backend/beaver/engine/context/builder.py
Normal 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。
|
||||
# 否则一旦当前会话中途写 memory,system prompt 语义就会和会话开头不一致。
|
||||
snapshot_sections = build_input.memory_snapshot.as_prompt_sections()
|
||||
if snapshot_sections:
|
||||
sections.extend(snapshot_sections)
|
||||
|
||||
for extra in build_input.extra_sections:
|
||||
cleaned = (extra or "").strip()
|
||||
if cleaned:
|
||||
sections.append(cleaned)
|
||||
|
||||
return "\n\n---\n\n".join(sections)
|
||||
|
||||
def build_messages(
|
||||
self,
|
||||
build_input: ContextBuildInput,
|
||||
) -> ContextBuildResult:
|
||||
"""构建一次模型调用的完整 messages。
|
||||
|
||||
这里做三件事:
|
||||
1. 先生成最终 system prompt
|
||||
2. 把已激活 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
|
||||
329
app-instance/backend/beaver/engine/loader.py
Normal file
329
app-instance/backend/beaver/engine/loader.py
Normal 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())
|
||||
1135
app-instance/backend/beaver/engine/loop.py
Normal file
1135
app-instance/backend/beaver/engine/loop.py
Normal file
File diff suppressed because it is too large
Load Diff
33
app-instance/backend/beaver/engine/providers/__init__.py
Normal file
33
app-instance/backend/beaver/engine/providers/__init__.py
Normal 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",
|
||||
]
|
||||
174
app-instance/backend/beaver/engine/providers/anthropic.py
Normal file
174
app-instance/backend/beaver/engine/providers/anthropic.py
Normal 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
|
||||
99
app-instance/backend/beaver/engine/providers/base.py
Normal file
99
app-instance/backend/beaver/engine/providers/base.py
Normal 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 的默认模型名。"""
|
||||
152
app-instance/backend/beaver/engine/providers/chain.py
Normal file
152
app-instance/backend/beaver/engine/providers/chain.py
Normal 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
|
||||
@ -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]}"
|
||||
107
app-instance/backend/beaver/engine/providers/custom.py
Normal file
107
app-instance/backend/beaver/engine/providers/custom.py
Normal 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
|
||||
235
app-instance/backend/beaver/engine/providers/factory.py
Normal file
235
app-instance/backend/beaver/engine/providers/factory.py
Normal 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",
|
||||
]
|
||||
277
app-instance/backend/beaver/engine/providers/litellm.py
Normal file
277
app-instance/backend/beaver/engine/providers/litellm.py
Normal 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
|
||||
249
app-instance/backend/beaver/engine/providers/registry.py
Normal file
249
app-instance/backend/beaver/engine/providers/registry.py
Normal 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
|
||||
408
app-instance/backend/beaver/engine/providers/runtime.py
Normal file
408
app-instance/backend/beaver/engine/providers/runtime.py
Normal 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}"
|
||||
2
app-instance/backend/beaver/engine/runtime/__init__.py
Normal file
2
app-instance/backend/beaver/engine/runtime/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Runtime helper objects and execution context."""
|
||||
|
||||
15
app-instance/backend/beaver/engine/session/__init__.py
Normal file
15
app-instance/backend/beaver/engine/session/__init__.py
Normal 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",
|
||||
]
|
||||
186
app-instance/backend/beaver/engine/session/manager.py
Normal file
186
app-instance/backend/beaver/engine/session/manager.py
Normal 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)
|
||||
235
app-instance/backend/beaver/engine/session/models.py
Normal file
235
app-instance/backend/beaver/engine/session/models.py
Normal 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"),
|
||||
),
|
||||
)
|
||||
156
app-instance/backend/beaver/engine/session/search.py
Normal file
156
app-instance/backend/beaver/engine/session/search.py
Normal 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
|
||||
559
app-instance/backend/beaver/engine/session/store.py
Normal file
559
app-instance/backend/beaver/engine/session/store.py
Normal 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)
|
||||
2
app-instance/backend/beaver/foundation/__init__.py
Normal file
2
app-instance/backend/beaver/foundation/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Foundation layer for shared Beaver primitives."""
|
||||
|
||||
26
app-instance/backend/beaver/foundation/config/__init__.py
Normal file
26
app-instance/backend/beaver/foundation/config/__init__.py
Normal 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",
|
||||
]
|
||||
227
app-instance/backend/beaver/foundation/config/loader.py
Normal file
227
app-instance/backend/beaver/foundation/config/loader.py
Normal 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)
|
||||
218
app-instance/backend/beaver/foundation/config/schema.py
Normal file
218
app-instance/backend/beaver/foundation/config/schema.py
Normal 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
|
||||
205
app-instance/backend/beaver/foundation/embedding.py
Normal file
205
app-instance/backend/beaver/foundation/embedding.py
Normal 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)
|
||||
@ -0,0 +1,2 @@
|
||||
"""Shared error types."""
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
"""Event contracts and dispatch helpers."""
|
||||
|
||||
from .message_bus import InboundMessage, MessageBus, OutboundMessage
|
||||
|
||||
__all__ = ["InboundMessage", "MessageBus", "OutboundMessage"]
|
||||
72
app-instance/backend/beaver/foundation/events/message_bus.py
Normal file
72
app-instance/backend/beaver/foundation/events/message_bus.py
Normal 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()
|
||||
11
app-instance/backend/beaver/foundation/models/__init__.py
Normal file
11
app-instance/backend/beaver/foundation/models/__init__.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""Shared Beaver data models."""
|
||||
|
||||
from .cron import CronExecutionResult, CronJob, CronPayload, CronRunRecord, CronSchedule
|
||||
|
||||
__all__ = [
|
||||
"CronExecutionResult",
|
||||
"CronJob",
|
||||
"CronPayload",
|
||||
"CronRunRecord",
|
||||
"CronSchedule",
|
||||
]
|
||||
265
app-instance/backend/beaver/foundation/models/cron.py
Normal file
265
app-instance/backend/beaver/foundation/models/cron.py
Normal 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
|
||||
2
app-instance/backend/beaver/foundation/utils/__init__.py
Normal file
2
app-instance/backend/beaver/foundation/utils/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Common utility helpers."""
|
||||
|
||||
2
app-instance/backend/beaver/integrations/__init__.py
Normal file
2
app-instance/backend/beaver/integrations/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""External integrations."""
|
||||
|
||||
2
app-instance/backend/beaver/integrations/a2a/__init__.py
Normal file
2
app-instance/backend/beaver/integrations/a2a/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""A2A integration."""
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
"""AuthZ service client integration."""
|
||||
|
||||
from .client import AuthzClient
|
||||
|
||||
__all__ = ["AuthzClient"]
|
||||
111
app-instance/backend/beaver/integrations/authz/client.py
Normal file
111
app-instance/backend/beaver/integrations/authz/client.py
Normal 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 {}
|
||||
5
app-instance/backend/beaver/integrations/mcp/__init__.py
Normal file
5
app-instance/backend/beaver/integrations/mcp/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""MCP integration."""
|
||||
|
||||
from .connection import MCPConnectionManager, test_mcp_server
|
||||
|
||||
__all__ = ["MCPConnectionManager", "test_mcp_server"]
|
||||
192
app-instance/backend/beaver/integrations/mcp/connection.py
Normal file
192
app-instance/backend/beaver/integrations/mcp/connection.py
Normal 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}"
|
||||
527
app-instance/backend/beaver/integrations/outlook/__init__.py
Normal file
527
app-instance/backend/beaver/integrations/outlook/__init__.py
Normal 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"],
|
||||
)
|
||||
@ -0,0 +1,2 @@
|
||||
"""Provider-specific integrations."""
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
"""WhatsApp integration."""
|
||||
|
||||
2
app-instance/backend/beaver/interfaces/__init__.py
Normal file
2
app-instance/backend/beaver/interfaces/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Thin interface layer for Beaver."""
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
"""Channel interfaces."""
|
||||
|
||||
from .base import ChannelAdapter
|
||||
from .manager import ChannelManager
|
||||
from .memory import MemoryChannelAdapter
|
||||
|
||||
__all__ = ["ChannelAdapter", "ChannelManager", "MemoryChannelAdapter"]
|
||||
24
app-instance/backend/beaver/interfaces/channels/base.py
Normal file
24
app-instance/backend/beaver/interfaces/channels/base.py
Normal 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."""
|
||||
|
||||
76
app-instance/backend/beaver/interfaces/channels/manager.py
Normal file
76
app-instance/backend/beaver/interfaces/channels/manager.py
Normal 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)
|
||||
91
app-instance/backend/beaver/interfaces/channels/memory.py
Normal file
91
app-instance/backend/beaver/interfaces/channels/memory.py
Normal 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,
|
||||
)
|
||||
2
app-instance/backend/beaver/interfaces/cli/__init__.py
Normal file
2
app-instance/backend/beaver/interfaces/cli/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""CLI interface."""
|
||||
|
||||
60
app-instance/backend/beaver/interfaces/cli/main.py
Normal file
60
app-instance/backend/beaver/interfaces/cli/main.py
Normal 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()
|
||||
@ -0,0 +1,2 @@
|
||||
"""Gateway interface."""
|
||||
|
||||
224
app-instance/backend/beaver/interfaces/gateway/main.py
Normal file
224
app-instance/backend/beaver/interfaces/gateway/main.py
Normal 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
|
||||
2
app-instance/backend/beaver/interfaces/mcp/__init__.py
Normal file
2
app-instance/backend/beaver/interfaces/mcp/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""MCP server entrypoints."""
|
||||
|
||||
210
app-instance/backend/beaver/interfaces/mcp/memory_server.py
Normal file
210
app-instance/backend/beaver/interfaces/mcp/memory_server.py
Normal 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()
|
||||
192
app-instance/backend/beaver/interfaces/mcp/tools_server.py
Normal file
192
app-instance/backend/beaver/interfaces/mcp/tools_server.py
Normal 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()
|
||||
2
app-instance/backend/beaver/interfaces/web/__init__.py
Normal file
2
app-instance/backend/beaver/interfaces/web/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Web interface."""
|
||||
|
||||
2997
app-instance/backend/beaver/interfaces/web/app.py
Normal file
2997
app-instance/backend/beaver/interfaces/web/app.py
Normal file
File diff suppressed because it is too large
Load Diff
27
app-instance/backend/beaver/interfaces/web/deps.py
Normal file
27
app-instance/backend/beaver/interfaces/web/deps.py
Normal 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
|
||||
@ -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)
|
||||
@ -0,0 +1,2 @@
|
||||
"""Web routes."""
|
||||
|
||||
@ -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",
|
||||
]
|
||||
137
app-instance/backend/beaver/interfaces/web/schemas/chat.py
Normal file
137
app-instance/backend/beaver/interfaces/web/schemas/chat.py
Normal 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
|
||||
2
app-instance/backend/beaver/memory/__init__.py
Normal file
2
app-instance/backend/beaver/memory/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Memory and experience stores."""
|
||||
|
||||
11
app-instance/backend/beaver/memory/curated/__init__.py
Normal file
11
app-instance/backend/beaver/memory/curated/__init__.py
Normal 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",
|
||||
]
|
||||
52
app-instance/backend/beaver/memory/curated/snapshot.py
Normal file
52
app-instance/backend/beaver/memory/curated/snapshot.py
Normal 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"),
|
||||
)
|
||||
463
app-instance/backend/beaver/memory/curated/store.py
Normal file
463
app-instance/backend/beaver/memory/curated/store.py
Normal 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
|
||||
@ -0,0 +1,2 @@
|
||||
"""Reusable procedures."""
|
||||
|
||||
6
app-instance/backend/beaver/memory/runs/__init__.py
Normal file
6
app-instance/backend/beaver/memory/runs/__init__.py
Normal 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
Reference in New Issue
Block a user