feat(app-instance): 集成Beaver后端并更新配置管理

集成新的Beaver后端服务到应用实例中,替换原有的nanobot实现。

主要变更包括:
- 在Dockerfile和环境配置中添加Beaver相关路径和配置变量
- 更新工作目录结构从.nanobot到.beaver
- 实现Beaver引擎加载器,支持配置文件加载和工具组装
- 添加内置工具如ListDirectoryTool、ReadFileTool、SearchFilesTool
- 更新消息处理流程,支持通道适配器和网关模式
- 重构技能系统,支持显式工具提示和嵌入式检索
- 改进错误处理和生命周期管理

此变更使应用实例能够使用统一的Beaver后端进行AI代理运行时管理。
This commit is contained in:
2026-04-27 17:37:40 +08:00
parent 36882a7d7b
commit 5ba5c7e4c1
47 changed files with 2821 additions and 462 deletions

View File

@ -0,0 +1,107 @@
import json
from beaver.engine import AgentLoop, EngineLoader
from beaver.engine.providers import make_provider_bundle
from beaver.engine.providers.litellm import LiteLLMProvider
from beaver.foundation.config import load_config
def test_load_config_reads_current_instance_shape(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {
"defaults": {
"workspace": str(tmp_path / "workspace"),
"model": "qwen-plus",
}
},
"providers": {
"openai": {
"apiKey": "sk-test",
"apiBase": "https://oai.example.com/v1",
"extraHeaders": {"X-Test": "1"},
}
},
"embeddingModel": "text-embedding-v4",
}
),
encoding="utf-8",
)
config = load_config(config_path=config_path)
target = config.resolve_provider_target()
assert config.default_model == "qwen-plus"
assert config.default_embedding_model == "text-embedding-v4"
assert target["provider_name"] == "openai"
assert target["model"] == "qwen-plus"
assert target["api_key"] == "sk-test"
assert target["api_base"] == "https://oai.example.com/v1"
assert target["extra_headers"] == {"X-Test": "1"}
def test_engine_loader_uses_config_workspace(tmp_path) -> None:
workspace = tmp_path / "workspace"
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {
"defaults": {
"workspace": str(workspace),
"model": "qwen-plus",
}
},
"providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://oai.example.com/v1"}},
}
),
encoding="utf-8",
)
loader = EngineLoader(config_path=config_path)
assert loader.workspace == workspace
def test_agent_loop_config_drives_provider_bundle(tmp_path) -> None:
workspace = tmp_path / "workspace"
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {
"defaults": {
"workspace": str(workspace),
"model": "qwen-plus",
}
},
"providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://oai.example.com/v1"}},
}
),
encoding="utf-8",
)
loop = AgentLoop(loader=EngineLoader(config_path=config_path))
loaded = loop.boot()
target = loaded.config.resolve_provider_target()
assert target["provider_name"] == "openai"
assert target["model"] == "qwen-plus"
assert target["api_key"] == "sk-test"
assert target["api_base"] == "https://oai.example.com/v1"
loop.close()
def test_openai_compatible_qwen_config_keeps_openai_provider() -> None:
bundle = make_provider_bundle(
model="qwen-plus",
provider_name="openai",
api_key="sk-test",
api_base="https://oai.example.com/v1",
)
assert bundle.main_runtime.provider_name == "openai"
assert bundle.main_runtime.api_base == "https://oai.example.com/v1"
assert isinstance(bundle.main_provider, LiteLLMProvider)
assert bundle.main_provider._resolve_model("qwen-plus") == "openai/qwen-plus"

View File

@ -0,0 +1,129 @@
from __future__ import annotations
import asyncio
import json
import os
from pathlib import Path
from beaver.tools import ObjectBackedTool, ToolContext
from beaver.tools.builtins import ListDirectoryTool, ReadFileTool, SearchFilesTool
def _run_tool(tool, arguments: dict, workspace: Path):
return asyncio.run(
ObjectBackedTool(tool).invoke(arguments, ToolContext(workspace=str(workspace)))
)
def _payload(result):
return json.loads(result.content)
def test_list_directory_is_workspace_scoped(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
(workspace / "README.md").write_text("# Hello\n", encoding="utf-8")
(workspace / "src").mkdir()
result = _run_tool(ListDirectoryTool(), {"path": "."}, workspace)
payload = _payload(result)
assert result.success is True
assert payload["success"] is True
assert [entry["path"] for entry in payload["entries"]] == ["src", "README.md"]
def test_read_file_returns_limited_text(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
(workspace / "notes.txt").write_text("one\ntwo\nthree\n", encoding="utf-8")
result = _run_tool(ReadFileTool(), {"path": "notes.txt", "start_line": 2, "max_lines": 1}, workspace)
payload = _payload(result)
assert result.success is True
assert payload["success"] is True
assert payload["content"] == "two"
assert payload["start_line"] == 2
assert payload["end_line"] == 2
assert payload["truncated"] is True
def test_search_files_finds_paths_and_content(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
(workspace / "Dockerfile").write_text("FROM python:3.12\n", encoding="utf-8")
(workspace / "src").mkdir()
(workspace / "src" / "app.py").write_text("print('docker log')\n", encoding="utf-8")
result = _run_tool(SearchFilesTool(), {"query": "docker", "max_results": 10}, workspace)
payload = _payload(result)
assert result.success is True
assert payload["success"] is True
assert ("Dockerfile", "path") in {
(item["path"], item["match_type"]) for item in payload["results"]
}
assert ("src/app.py", "content") in {
(item["path"], item["match_type"]) for item in payload["results"]
}
def test_read_file_rejects_relative_path_escape(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
(tmp_path / "secret.txt").write_text("secret\n", encoding="utf-8")
result = _run_tool(ReadFileTool(), {"path": "../secret.txt"}, workspace)
payload = _payload(result)
assert result.success is False
assert payload["success"] is False
assert "escapes workspace" in payload["error"]
def test_read_file_rejects_absolute_path_escape(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
outside = tmp_path / "outside.txt"
outside.write_text("secret\n", encoding="utf-8")
result = _run_tool(ReadFileTool(), {"path": str(outside)}, workspace)
payload = _payload(result)
assert result.success is False
assert payload["success"] is False
assert "escapes workspace" in payload["error"]
def test_read_file_rejects_symlink_escape(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
outside = tmp_path / "outside.txt"
outside.write_text("secret\n", encoding="utf-8")
link = workspace / "outside-link.txt"
try:
os.symlink(outside, link)
except (OSError, NotImplementedError):
return
result = _run_tool(ReadFileTool(), {"path": "outside-link.txt"}, workspace)
payload = _payload(result)
assert result.success is False
assert payload["success"] is False
assert "escapes workspace" in payload["error"]
def test_read_file_rejects_binary_files(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
(workspace / "blob.bin").write_bytes(b"abc\x00def")
result = _run_tool(ReadFileTool(), {"path": "blob.bin"}, workspace)
payload = _payload(result)
assert result.success is False
assert payload["success"] is False
assert "binary" in payload["error"]

View File

@ -0,0 +1,201 @@
import asyncio
from dataclasses import dataclass, field
from typing import Any
from beaver.foundation.events import InboundMessage, MessageBus
from beaver.interfaces.channels import ChannelManager, MemoryChannelAdapter
from beaver.interfaces.gateway.main import run_gateway
from beaver.services.agent_service import AgentService
@dataclass(slots=True)
class FakeResult:
session_id: str
run_id: str = "run-1"
output_text: str = ""
finish_reason: str = "stop"
provider_name: str | None = "fake"
model: str | None = "fake-model"
usage: dict[str, Any] = field(default_factory=dict)
class FakeService:
is_running = True
async def submit_direct(self, message: str, **kwargs: Any) -> FakeResult:
return FakeResult(
session_id=kwargs.get("session_id") or "s1",
output_text=f"echo:{message}",
)
async def handle_inbound_message(self, inbound: InboundMessage):
result = await self.submit_direct(inbound.content, session_id=inbound.session_id)
return AgentService.build_outbound_message(inbound, result)
class SlowService:
is_running = True
async def submit_direct(self, message: str, **kwargs: Any) -> FakeResult:
await asyncio.sleep(10)
return FakeResult(session_id=kwargs.get("session_id") or "s1")
async def handle_inbound_message(self, inbound: InboundMessage):
result = await self.submit_direct(inbound.content, session_id=inbound.session_id)
return AgentService.build_outbound_message(inbound, result)
def test_gateway_routes_memory_channel_roundtrip() -> None:
async def run() -> None:
bus = MessageBus()
channel = MemoryChannelAdapter(bus)
stop_event = asyncio.Event()
task = asyncio.create_task(
run_gateway(
service=FakeService(),
manage_service_lifecycle=False,
bus=bus,
channels=[channel],
stop_event=stop_event,
)
)
await channel.publish_text("hello", session_id="s1")
for _ in range(40):
if channel.sent_messages:
break
await asyncio.sleep(0.05)
assert channel.sent_messages
message = channel.sent_messages[0]
assert message.content == "echo:hello"
assert message.session_id == "s1"
assert message.finish_reason == "stop"
stop_event.set()
await asyncio.wait_for(task, timeout=2)
asyncio.run(run())
def test_gateway_delivers_cancelled_outbound_to_channel() -> None:
async def run() -> None:
bus = MessageBus()
channel = MemoryChannelAdapter(bus)
stop_event = asyncio.Event()
task = asyncio.create_task(
run_gateway(
service=SlowService(),
manage_service_lifecycle=False,
bus=bus,
channels=[channel],
stop_event=stop_event,
)
)
await channel.publish_text("slow", session_id="s1")
await asyncio.sleep(0.05)
stop_event.set()
await asyncio.wait_for(task, timeout=3)
assert channel.sent_messages
assert channel.sent_messages[0].finish_reason == "cancelled"
asyncio.run(run())
def test_gateway_rejects_channel_manager_and_channels_together() -> None:
async def run() -> None:
bus = MessageBus()
try:
await run_gateway(
service=FakeService(),
manage_service_lifecycle=False,
bus=bus,
channel_manager=ChannelManager(bus),
channels=[MemoryChannelAdapter(bus)],
stop_event=asyncio.Event(),
)
except ValueError as exc:
assert "either channel_manager or channels" in str(exc)
else:
raise AssertionError("expected ValueError")
asyncio.run(run())
def test_agent_service_maps_inbound_error_to_structured_outbound() -> None:
async def run() -> None:
service = AgentService()
async def failing_submit_direct(message: str, **kwargs: Any) -> FakeResult:
raise RuntimeError("boom")
service.submit_direct = failing_submit_direct # type: ignore[method-assign]
outbound = await service.handle_inbound_message(
InboundMessage(channel="memory", content="hello", session_id="s1", metadata={"source": "test"})
)
assert outbound.finish_reason == "error"
assert outbound.session_id == "s1"
assert outbound.metadata["error"] == "boom"
assert outbound.metadata["inbound_metadata"] == {"source": "test"}
asyncio.run(run())
def test_channel_manager_start_cancellation_rolls_back_started_channels() -> None:
class StartedChannel:
name = "started"
def __init__(self, bus: MessageBus) -> None:
self.bus = bus
self.stopped = False
async def start(self) -> None:
pass
async def stop(self) -> None:
self.stopped = True
async def send(self, message: Any) -> None:
pass
class BlockingChannel:
name = "blocking"
def __init__(self, bus: MessageBus) -> None:
self.bus = bus
self.entered = asyncio.Event()
async def start(self) -> None:
self.entered.set()
await asyncio.sleep(10)
async def stop(self) -> None:
pass
async def send(self, message: Any) -> None:
pass
async def run() -> None:
bus = MessageBus()
started = StartedChannel(bus)
blocking = BlockingChannel(bus)
manager = ChannelManager(bus)
manager.register(started)
manager.register(blocking)
task = asyncio.create_task(manager.start())
await blocking.entered.wait()
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
else:
raise AssertionError("expected cancellation")
assert started.stopped
asyncio.run(run())

View File

@ -1,12 +1,13 @@
from beaver.engine import AgentLoop
from beaver.engine import AgentLoop, EngineLoader
from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage
from beaver.interfaces.channels import ChannelManager, MemoryChannelAdapter
from beaver.interfaces.gateway.main import run_gateway
from beaver.interfaces.web.app import create_app
from beaver.interfaces.web.schemas import WebChatRequest, WebChatResponse
def test_agent_loop_boots() -> None:
loop = AgentLoop()
def test_agent_loop_boots(tmp_path) -> None:
loop = AgentLoop(loader=EngineLoader(workspace=tmp_path))
loaded = loop.boot()
assert "echo" in loaded.tools
assert "memory" in loaded.tools
@ -29,6 +30,14 @@ def test_message_bus_imports() -> None:
assert OutboundMessage(channel="test", content="ok", session_id=None, finish_reason="stop").content == "ok"
def test_channel_imports() -> None:
bus = MessageBus()
channel = MemoryChannelAdapter(bus)
manager = ChannelManager(bus)
manager.register(channel)
assert manager.channels["memory"] is channel
def test_web_schema_imports() -> None:
assert WebChatRequest(message="hello").message == "hello"
assert WebChatResponse(

View File

@ -0,0 +1,149 @@
from __future__ import annotations
import asyncio
from pathlib import Path
import subprocess
import sys
from types import SimpleNamespace
from beaver.engine.context import SkillContext
from beaver.foundation.embedding import EmbeddingRetriever
from beaver.skills.catalog.loader import SkillsLoader
from beaver.tools import BaseTool, ToolAssembler, ToolContext, ToolExecutor, ToolRegistry, ToolResult, ToolSpec
class DummyTool(BaseTool):
def __init__(
self,
name: str,
*,
description: str | None = None,
toolset: str = "test",
always_available: bool = False,
) -> None:
self._spec = ToolSpec(
name=name,
description=description or name,
input_schema={"type": "object", "properties": {}},
toolset=toolset,
always_available=always_available,
)
@property
def spec(self) -> ToolSpec:
return self._spec
async def invoke(self, arguments: dict, context: ToolContext) -> ToolResult:
return ToolResult(success=True, content="ok", tool_name=self.spec.name)
class StaticRetriever:
async def retrieve(self, **kwargs):
candidates = kwargs["candidates"]
top_k = kwargs["top_k"]
preferred = ["search_files", "echo"]
ordered = sorted(
candidates,
key=lambda item: preferred.index(item["name"]) if item["name"] in preferred else len(preferred),
)
return ordered[:top_k]
def test_tool_spec_exports_mcp_and_provider_schema() -> None:
spec = ToolSpec(
name="read_file",
description="Read a file",
input_schema={"type": "object", "properties": {"path": {"type": "string"}}},
toolset="file",
)
assert spec.to_mcp_descriptor() == {
"name": "read_file",
"description": "Read a file",
"inputSchema": {"type": "object", "properties": {"path": {"type": "string"}}},
}
assert spec.to_provider_schema()["function"]["parameters"] == spec.input_schema
def test_tool_assembler_merges_always_skill_hints_and_embedding(tmp_path: Path) -> None:
skill_dir = tmp_path / "skills" / "docker-debug"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"""---
name: docker-debug
description: Debug Docker issues.
tools:
- terminal
---
# Docker Debug
""",
encoding="utf-8",
)
registry = ToolRegistry()
registry.register(DummyTool("memory", toolset="memory", always_available=True))
registry.register(DummyTool("skill_view", toolset="skills", always_available=True))
registry.register(DummyTool("terminal", toolset="shell"))
registry.register(DummyTool("search_files", toolset="file"))
registry.register(DummyTool("echo", toolset="debug"))
assembler = ToolAssembler(retriever=StaticRetriever())
loader = SkillsLoader(tmp_path)
selected = asyncio.run(
assembler.assemble(
task_description="排查 Docker 容器日志",
registry=registry,
skills_loader=loader,
activated_skills=[SkillContext(name="docker-debug", content="")],
top_k=1,
)
)
assert [spec.name for spec in selected] == ["memory", "skill_view", "terminal", "search_files"]
def test_embedding_fallback_can_return_all_or_top_k() -> None:
candidates = [{"name": f"tool_{index}", "description": "", "input_schema": "{}"} for index in range(3)]
retriever = EmbeddingRetriever(api_key_env="MISSING_EMBEDDING_KEY", api_base_env="MISSING_EMBEDDING_BASE")
all_candidates = asyncio.run(
retriever.retrieve(query="x", candidates=candidates, top_k=1, fallback_top_k=None)
)
top_candidate = asyncio.run(
retriever.retrieve(query="x", candidates=candidates, top_k=1, fallback_top_k=1)
)
assert [item["name"] for item in all_candidates] == ["tool_0", "tool_1", "tool_2"]
assert [item["name"] for item in top_candidate] == ["tool_0"]
def test_beaver_tools_import_does_not_load_provider_stack_with_socks_proxy() -> None:
code = (
"import beaver.tools\n"
"from beaver.skills.catalog.loader import SkillsLoader\n"
"print('ok')"
)
result = subprocess.run(
[sys.executable, "-c", code],
check=False,
capture_output=True,
text=True,
env={
"PYTHONPATH": str(Path(__file__).resolve().parents[2]),
"HTTP_PROXY": "socks://127.0.0.1:7897/",
"HTTPS_PROXY": "socks://127.0.0.1:7897/",
},
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "ok"
def test_tool_executor_parses_object_tool_call_string_arguments() -> None:
tool_call = SimpleNamespace(name="echo", arguments='{"text": "hello"}')
name, arguments = ToolExecutor._normalize_tool_call(tool_call)
assert name == "echo"
assert arguments == {"text": "hello"}