refactor(beaver): 移除Hermes相关引用和迁移代码,完善Beaver后端主线实现

移除了所有Hermes相关的命名引用,包括:
- 从.gitignore中清理相关构建缓存文件
- 将README中的beaver-home路径配置更新
- 完善backend/README.md文档说明Beaver后端主线实现
- 移除Hermes风格的相关注释和兼容性代码
- 清理nanobot环境变量兼容性处理
- 删除技能迁移和服务迁移相关功能代码
- 更新测试用例中相关命名和函数名

BREAKING CHANGE: 移除了Hermes迁移相关API和CLI命令,不再支持nanobot环境变量兼容性
This commit is contained in:
2026-05-14 17:20:32 +08:00
parent b59968167e
commit 3b0af173cc
57 changed files with 245 additions and 4109 deletions

View File

@ -16,7 +16,7 @@
1. 先服务单 agent 主链
2. 先支持 frozen curated memory而不是 live memory
3. skills 按 Hermes 风格支持“显式激活消息注入,不在这里做磁盘扫描
3. skills 通过显式激活消息注入,不在这里做磁盘扫描
4. 为后续 channel / gateway / team metadata 预留注入位,但不提前做复杂逻辑
"""
@ -45,7 +45,7 @@ class SkillContext:
- `name`:用于生成激活提示
- `content`skill 的完整正文
注意:按当前 Hermes 风格实现,skill 正文不再塞进 system prompt而是转成显式消息注入。
注意skill 正文不再塞进 system prompt而是转成显式消息注入。
"""
name: str
@ -151,7 +151,7 @@ class ContextBuilder:
- 身份与总规则要最靠前
- session/execution 是本轮运行语境,优先级高于长期记忆
- memory 必须是 frozen snapshot避免中途写 memory 后 prompt 失真
- activated skill 正文按 Hermes 风格放到显式消息里,避免 system prompt 持续膨胀
- activated skill 正文放到显式消息里,避免 system prompt 持续膨胀
"""
sections: list[str] = [BEAVER_USER_ASSISTANT_IDENTITY_PROMPT]
@ -190,7 +190,7 @@ class ContextBuilder:
这里做三件事:
1. 先生成最终 system prompt
2. 按 Hermes 风格,把已激活 skill 的完整正文作为显式消息注入
2. 把已激活 skill 的完整正文作为显式消息注入
3. 把历史消息按原顺序接到后面
4. 如果存在当前用户输入,则把本轮输入追加为最后一条 user message
@ -348,7 +348,7 @@ class ContextBuilder:
return "# Current Session\n\n" + "\n".join(rows)
def build_skill_activation_messages(self, activated_skills: list[SkillContext]) -> list[dict[str, str]]:
"""按 Hermes 风格把已激活 skill 转成显式消息。
"""把已激活 skill 转成显式消息。
关键区别:
- system prompt 只保留轻量 skills index

View File

@ -1,4 +1,4 @@
"""Hermes 风格的 provider runtime resolution"""
"""Provider runtime resolution for Beaver."""
from __future__ import annotations
@ -165,7 +165,7 @@ def resolve_fallback_runtime(
) -> ProviderRuntime | None:
"""把 fallback 配置解析成独立 runtime。
Hermes 的 fallback 是“主 provider 失败后切换到另一个 provider:model”。
fallback 的语义是“主 provider 失败后切换到另一个 provider:model”。
这里先把 fallback 解析独立出来,具体何时激活交给上层 chain/factory。
"""

View File

@ -1,6 +1,6 @@
"""Beaver session 子系统的 SQLite 存储实现。
设计来源主要参考 Hermes-agent
设计目标
1. SQLite 作为统一 session/transcript backend
2. WAL 模式支持多读单写
3. FTS5 支持跨 session 文本检索

View File

@ -35,14 +35,12 @@ def default_config_path(*, workspace: str | Path | None = None) -> Path:
Priority:
1. `BEAVER_CONFIG_PATH`
2. `NANOBOT_CONFIG_PATH` for compatibility during migration
3. `BEAVER_HOME/config.json`
4. `NANOBOT_HOME/config.json` for migration compatibility
5. `<workspace>/.beaver/config.json`
6. `./.beaver/config.json`
2. `BEAVER_HOME/config.json`
3. `<workspace>/.beaver/config.json`
4. `./.beaver/config.json`
"""
explicit = os.getenv("BEAVER_CONFIG_PATH") or os.getenv("NANOBOT_CONFIG_PATH")
explicit = os.getenv("BEAVER_CONFIG_PATH")
if explicit:
return Path(explicit).expanduser()
@ -50,10 +48,6 @@ def default_config_path(*, workspace: str | Path | None = None) -> Path:
if beaver_home:
return Path(beaver_home).expanduser() / "config.json"
nanobot_home = os.getenv("NANOBOT_HOME")
if nanobot_home:
return Path(nanobot_home).expanduser() / "config.json"
root = Path(workspace).expanduser() if workspace is not None else Path.cwd()
return root / ".beaver" / "config.json"

View File

@ -1,8 +1,7 @@
"""Scheduled task models for Beaver cron.
The scheduler borrows Hermes' durable JSON + explicit schedule parsing shape,
but the execution target is Beaver Task mode: every trigger creates a normal
Task run instead of a detached agent turn.
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

View File

@ -19,7 +19,7 @@ from beaver.foundation.config import BeaverConfig
from beaver.integrations.authz import AuthzClient
OUTLOOK_SERVER_ID = os.getenv("BEAVER_OUTLOOK_MCP_SERVER_ID") or os.getenv("NANOBOT_OUTLOOK_MCP_SERVER_ID", "outlook_mcp")
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
@ -31,11 +31,11 @@ class OutlookIntegrationError(RuntimeError):
@dataclass(frozen=True)
class OutlookDefaults:
domain: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_DOMAIN", "")
service_endpoint: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_EWS_URL", "")
server: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_EWS_SERVER", "")
default_timezone: str = os.getenv("NANOBOT_OUTLOOK_DEFAULT_TIMEZONE", "Asia/Shanghai")
autodiscover: bool = os.getenv("NANOBOT_OUTLOOK_DEFAULT_AUTODISCOVER", "0") == "1"
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)
@ -71,7 +71,7 @@ OUTLOOK_TOOL_NAMES = [
def _call_timeout_seconds() -> float:
raw = os.getenv("NANOBOT_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS", "").strip()
raw = os.getenv("BEAVER_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS", "").strip()
try:
return max(1.0, float(raw)) if raw else 10.0
except ValueError:
@ -108,8 +108,8 @@ def outlook_defaults() -> dict[str, Any]:
return {
"provider": "ews",
"server_id": OUTLOOK_SERVER_ID,
"mcp_command": os.getenv("NANOBOT_OUTLOOK_MCP_COMMAND", "bw-outlook-mcp"),
"mcp_extra_args": shlex.split(os.getenv("NANOBOT_OUTLOOK_MCP_EXTRA_ARGS", "").strip()),
"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()),
}

View File

@ -1,7 +1,5 @@
"""CLI entry for Beaver."""
from pathlib import Path
try:
import typer
except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments
@ -29,8 +27,6 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env
typer = _FallbackTyper() # type: ignore[assignment]
from beaver.services.agent_service import AgentService
from beaver.services.hermes_migration import HermesMigrationService
from beaver.skills.specs import SkillSpecStore
app = typer.Typer(help="Beaver backend CLI") if hasattr(typer, "Typer") else typer
@ -59,26 +55,6 @@ def run(
typer.echo(result.output_text)
@app.command("migrate-hermes")
def migrate_hermes(
repo: str = typer.Option(..., "--repo", help="Local checkout of https://github.com/NousResearch/hermes-agent."),
workspace: str | None = typer.Option(None, "--workspace", help="Workspace root to import skills into."),
manifest: str | None = typer.Option(None, "--manifest", help="Path for hermes_migration_manifest.json."),
dry_run: bool = typer.Option(False, "--dry-run", help="Only write the manifest without importing skills."),
) -> None:
"""Import no-credential Hermes Agent skills and write a manifest."""
service = AgentService(workspace=workspace)
loaded = service.create_loop().boot()
store = loaded.skill_spec_store or SkillSpecStore(loaded.workspace)
migration = HermesMigrationService(store, manifest_path=Path(manifest) if manifest else None)
result = migration.migrate(repo, dry_run=dry_run)
typer.echo(
f"Hermes migration complete: {len(result['included'])} included, "
f"{len(result['skipped'])} skipped."
)
def main() -> None:
"""Project script entrypoint."""
app()

View File

@ -58,7 +58,7 @@ LOCAL_TOOL_CATEGORIES = {
def _workspace_path(value: str | None = None) -> Path:
raw = value or os.getenv("BEAVER_WORKSPACE") or os.getenv("NANOBOT_WORKSPACE")
raw = value or os.getenv("BEAVER_WORKSPACE")
if raw:
return Path(raw).expanduser().resolve()
return Path.cwd()

View File

@ -23,7 +23,6 @@ from beaver.foundation.models import CronExecutionResult, CronRunRecord
from beaver.integrations.mcp import MCPConnectionManager
from beaver.services.agent_service import NOTIFICATION_SESSION_ID, AgentService
from beaver.services.cron_service import CronService, schedule_from_api
from beaver.services.skill_migration import SkillMigrationService
from beaver.services.skillhub_service import SkillHubService
from beaver.skills.learning import SkillLearningWorker, SkillLearningWorkerConfig
from beaver.skills.catalog.utils import parse_frontmatter
@ -305,7 +304,7 @@ def create_app(
)
app.state.auth_tokens = {}
app.state.handoff_codes = {}
app.state.auth_file = Path(os.getenv("BEAVER_AUTH_FILE") or os.getenv("NANOBOT_AUTH_FILE") or "")
app.state.auth_file = Path(os.getenv("BEAVER_AUTH_FILE") or "")
max_file_size = 50 * 1024 * 1024
@app.get("/api/ping", response_model=WebStatusResponse)
@ -427,7 +426,6 @@ def create_app(
_clean_text(payload.get("base_url"))
or config.backend_identity.public_base_url
or os.getenv("BEAVER_FRONTEND_PUBLIC_BASE_URL")
or os.getenv("NANOBOT_FRONTEND_PUBLIC_BASE_URL")
or str(request.base_url).rstrip("/")
)
frontend_base_url = _clean_text(payload.get("frontend_base_url")) or public_base_url
@ -526,7 +524,7 @@ def create_app(
return {
"id": username,
"username": username,
"email": os.getenv("BEAVER_BACKEND_IDENTITY__EMAIL") or os.getenv("NANOBOT_BACKEND_IDENTITY__EMAIL", ""),
"email": os.getenv("BEAVER_BACKEND_IDENTITY__EMAIL", ""),
"role": "owner",
"quota_tier": "single-user",
}
@ -1184,30 +1182,6 @@ def create_app(
)
return result
@app.get("/api/tools/servers")
async def list_tool_servers(request: Request) -> list[dict[str, Any]]:
return await list_mcp_servers(request)
@app.get("/api/tools")
async def list_tools(request: Request) -> dict[str, Any]:
servers = await list_mcp_servers(request)
tool_groups = await list_mcp_tools(request)
server_map = {server["id"]: server for server in servers}
grouped = {"local": [], "online": []}
for group in tool_groups:
server = server_map.get(group["server_id"], {})
kind = str(server.get("kind") or "online")
item = {
**group,
"server_name": server.get("name") or group["server_id"],
"transport": server.get("transport"),
"kind": kind,
"category": server.get("category") or kind,
"status": server.get("status"),
}
grouped["local" if kind == "local" else "online"].append(item)
return {"servers": servers, "groups": grouped}
@app.get("/api/skills")
async def list_skills(request: Request) -> list[dict[str, Any]]:
loaded = get_agent_service(request).create_loop().boot()
@ -1301,19 +1275,6 @@ def create_app(
raise HTTPException(status_code=400, detail=str(exc)) from exc
return draft
@app.post("/api/skills/migrate")
async def migrate_skills(request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
return SkillMigrationService(loaded.skill_spec_store).migrate_all() # type: ignore[arg-type]
@app.get("/api/skills/migration-manifest")
async def get_skill_migration_manifest(request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
path = loaded.workspace / "skill_migration_manifest.json"
if not path.exists():
return {"included": [], "skipped": []}
return json.loads(path.read_text(encoding="utf-8"))
@app.get("/api/marketplaces/skills/search")
async def search_skillhub(
request: Request,
@ -2482,7 +2443,7 @@ def _provider_enabled(provider_name: str, provider_cfg: Any) -> bool:
def _auth_file_path() -> Path:
raw = os.getenv("BEAVER_AUTH_FILE") or os.getenv("NANOBOT_AUTH_FILE")
raw = os.getenv("BEAVER_AUTH_FILE")
if raw:
return Path(raw)
return Path.home() / ".beaver" / "web_auth_users.json"
@ -2542,7 +2503,7 @@ def _issue_web_token(app: FastAPI, username: str) -> str:
def _handoff_ttl_seconds() -> int:
raw = os.getenv("NANOBOT_HANDOFF_CODE_TTL_SECONDS", "90").strip()
raw = os.getenv("BEAVER_HANDOFF_CODE_TTL_SECONDS", "90").strip()
try:
return max(15, int(raw))
except ValueError:
@ -2550,7 +2511,7 @@ def _handoff_ttl_seconds() -> int:
def _handoff_replay_window_seconds() -> int:
raw = os.getenv("NANOBOT_HANDOFF_REPLAY_WINDOW_SECONDS", "15").strip()
raw = os.getenv("BEAVER_HANDOFF_REPLAY_WINDOW_SECONDS", "15").strip()
try:
return max(1, int(raw))
except ValueError:
@ -2637,22 +2598,18 @@ def _require_web_user(app: FastAPI, authorization: str | None) -> str:
def _backend_connection_view(request: Request) -> dict[str, Any]:
public_base_url = (
os.getenv("BEAVER_BACKEND_IDENTITY__PUBLIC_BASE_URL")
or os.getenv("NANOBOT_BACKEND_IDENTITY__PUBLIC_BASE_URL")
or os.getenv("BEAVER_FRONTEND_PUBLIC_BASE_URL")
or os.getenv("NANOBOT_FRONTEND_PUBLIC_BASE_URL")
or str(request.base_url).rstrip("/")
)
backend_id = (
os.getenv("BEAVER_BACKEND_IDENTITY__BACKEND_ID")
or os.getenv("NANOBOT_BACKEND_IDENTITY__BACKEND_ID")
or os.getenv("BEAVER_BACKEND_IDENTITY__CLIENT_ID")
or os.getenv("NANOBOT_BACKEND_IDENTITY__CLIENT_ID")
)
client_id = os.getenv("BEAVER_BACKEND_IDENTITY__CLIENT_ID") or os.getenv("NANOBOT_BACKEND_IDENTITY__CLIENT_ID") or backend_id
client_id = os.getenv("BEAVER_BACKEND_IDENTITY__CLIENT_ID") or backend_id
return {
"backend_id": backend_id,
"client_id": client_id,
"name": os.getenv("BEAVER_BACKEND_IDENTITY__NAME") or os.getenv("NANOBOT_BACKEND_IDENTITY__NAME") or backend_id,
"name": os.getenv("BEAVER_BACKEND_IDENTITY__NAME") or backend_id,
"public_base_url": public_base_url,
"api_base_url": public_base_url,
"frontend_base_url": public_base_url,
@ -2663,16 +2620,14 @@ def _backend_connection_view(request: Request) -> dict[str, Any]:
def _local_backend_view() -> dict[str, Any]:
return {
"backend_id": os.getenv("BEAVER_BACKEND_IDENTITY__BACKEND_ID") or os.getenv("NANOBOT_BACKEND_IDENTITY__BACKEND_ID"),
"client_id": os.getenv("BEAVER_BACKEND_IDENTITY__CLIENT_ID") or os.getenv("NANOBOT_BACKEND_IDENTITY__CLIENT_ID"),
"name": os.getenv("BEAVER_BACKEND_IDENTITY__NAME") or os.getenv("NANOBOT_BACKEND_IDENTITY__NAME"),
"backend_id": os.getenv("BEAVER_BACKEND_IDENTITY__BACKEND_ID"),
"client_id": os.getenv("BEAVER_BACKEND_IDENTITY__CLIENT_ID"),
"name": os.getenv("BEAVER_BACKEND_IDENTITY__NAME"),
"public_base_url": os.getenv("BEAVER_BACKEND_IDENTITY__PUBLIC_BASE_URL")
or os.getenv("NANOBOT_BACKEND_IDENTITY__PUBLIC_BASE_URL")
or os.getenv("BEAVER_FRONTEND_PUBLIC_BASE_URL")
or os.getenv("NANOBOT_FRONTEND_PUBLIC_BASE_URL"),
or os.getenv("BEAVER_FRONTEND_PUBLIC_BASE_URL"),
"authz": {
"enabled": (os.getenv("BEAVER_AUTHZ__ENABLED") or os.getenv("NANOBOT_AUTHZ__ENABLED", "")).strip() in {"1", "true", "True"},
"base_url": os.getenv("BEAVER_AUTHZ__BASE_URL") or os.getenv("NANOBOT_AUTHZ__BASE_URL"),
"enabled": os.getenv("BEAVER_AUTHZ__ENABLED", "").strip() in {"1", "true", "True"},
"base_url": os.getenv("BEAVER_AUTHZ__BASE_URL"),
},
}

View File

@ -1,6 +1,6 @@
"""Beaver 的精炼长期记忆存储层。
这个文件实现的是以 Hermes-agent 为基线的 curated memory 模型,目标不是
这个文件实现的是 Beaver curated memory 模型,目标不是
“把所有历史都存下来”,而是只保存跨会话仍然值得保留的稳定事实。
核心设计:

View File

@ -38,10 +38,9 @@ _MAX_HISTORY = 20
class CronService:
"""Persistent single-timer scheduler.
Hermes' cron implementation stores jobs as JSON and ticks safely in the
background. Beaver keeps that shape, but the callback is required to route
agent work through Task mode so every scheduled trigger is visible as a
normal Task.
Jobs are stored as JSON and ticked safely in the background. The callback
routes agent work through Task mode so every scheduled trigger is visible as
a normal Task.
"""
def __init__(self, store_path: str | Path, *, on_job: CronCallback | None = None) -> None:

View File

@ -43,7 +43,7 @@ class MemoryService:
def reload_for_new_run(self) -> None:
"""每次新 run 开始前刷新 live state。
这是 Hermes 风格 memory policy 的关键点:
这是 Beaver memory policy 的关键点:
- 上一次会话中通过 tool 写入的持久记忆,下一次运行应该能看到
- 但同一次 run 中途写入的新记忆,不应反向修改当前 frozen snapshot
"""

View File

@ -237,7 +237,7 @@ class SkillsLoader:
def build_skills_summary(self) -> str:
"""构建可注入 system prompt 的 skills index。
虽然函数名还沿用 `summary`,但当前语义已经更接近 Hermes 的 skills index
虽然函数名还沿用 `summary`,但当前语义是轻量 skills index
- 这里只告诉模型“系统里有哪些 skill 可用”
- 不负责把 skill 正文塞进 system prompt
- 真正激活的 skill 正文由 resolver/builder 走显式消息注入

View File

@ -87,9 +87,7 @@ def strip_frontmatter(content: str) -> str:
def parse_skill_metadata_blob(raw: str) -> dict[str, Any]:
"""解析 metadata 字段里的 JSON 扩展配置。
为了兼容旧 nanobot 习惯,这里同时支持:
- `nanobot`
- `openclaw`
Supports plain metadata objects and the current `openclaw` namespace.
第一版主要关心的字段有:
- `always`
@ -103,7 +101,7 @@ def parse_skill_metadata_blob(raw: str) -> dict[str, Any]:
if not isinstance(data, dict):
return {}
nested = data.get("nanobot", data.get("openclaw", data))
nested = data.get("openclaw", data)
return nested if isinstance(nested, dict) else {}

View File

@ -20,6 +20,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
import inspect
import json
from typing import Any
@ -181,9 +182,21 @@ class ObjectBackedTool(BaseTool):
arguments["current_session_id"] = context.session_id
if "workspace" not in arguments and hasattr(self.backend, "workspace"):
arguments["workspace"] = context.workspace
if "metadata" not in arguments:
if "metadata" not in arguments and self._backend_accepts_argument("metadata"):
arguments["metadata"] = context.metadata
def _backend_accepts_argument(self, name: str) -> bool:
try:
signature = inspect.signature(self.backend.execute)
except (TypeError, ValueError):
return False
for parameter in signature.parameters.values():
if parameter.kind == inspect.Parameter.VAR_KEYWORD:
return True
if parameter.name == name:
return True
return False
@staticmethod
def _normalize_output(content: Any) -> dict[str, Any]:
"""把后端工具返回值转成统一 success/content/error 语义。

View File

@ -33,7 +33,7 @@ CRON_TOOL_PARAMETERS: dict[str, Any] = {
},
"schedule": {
"type": "string",
"description": "Hermes-style schedule, for example 'every 15m', '0 9 * * *', or an ISO datetime.",
"description": "Schedule expression, for example 'every 15m', '0 9 * * *', or an ISO datetime.",
},
"every_seconds": {
"type": "integer",

View File

@ -59,7 +59,7 @@ def memory_tool(
old_text: str | None = None,
store: MemoryStore | None = None,
) -> str:
"""分发 Hermes 风格的 CRUD memory API并返回 JSON 字符串。
"""分发 CRUD memory API并返回 JSON 字符串。
这里统一采用 JSON 返回,是为了兼容常见 tool-calling 场景:
- LLM 更容易消费结构化结果

View File

@ -1,6 +1,6 @@
"""Beaver 内置 session_search tool。
这个工具对应 Hermes-agent 的跨会话检索能力,目标不是把所有历史内容塞回主上下文,
这个工具提供跨会话检索能力,目标不是把所有历史内容塞回主上下文,
而是按需从过去的 session 中找回“之前发生过什么”。
当前实现保留了几个关键行为:
@ -28,7 +28,7 @@ class SessionSearchDB(Protocol):
"""session_search 依赖的最小数据库契约。
这里没有直接绑定某个具体 SQLite 实现,而是先定义行为接口。
这样后面无论你接的是 Hermes 风格 state DB、还是 Beaver 自己的 transcript store
这样后面无论你接的是当前 SQLite state DB、还是其他 transcript store
只要满足这些方法就能工作。
"""

View File

@ -1,6 +1,6 @@
"""Beaver 内置 skill_view tool。
这个工具对应 Hermes 风格的显式 skill loading path
这个工具对应显式 skill loading path
1. skill 正文默认不会长期塞进 system prompt
2. 模型若想查看某个 skill 的完整正文或支持文件,必须显式调用 `skill_view`