feat(app-instance): 集成Beaver后端并更新配置管理
集成新的Beaver后端服务到应用实例中,替换原有的nanobot实现。 主要变更包括: - 在Dockerfile和环境配置中添加Beaver相关路径和配置变量 - 更新工作目录结构从.nanobot到.beaver - 实现Beaver引擎加载器,支持配置文件加载和工具组装 - 添加内置工具如ListDirectoryTool、ReadFileTool、SearchFilesTool - 更新消息处理流程,支持通道适配器和网关模式 - 重构技能系统,支持显式工具提示和嵌入式检索 - 改进错误处理和生命周期管理 此变更使应用实例能够使用统一的Beaver后端进行AI代理运行时管理。
This commit is contained in:
@ -1,2 +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)
|
||||
57
app-instance/backend/beaver/interfaces/channels/memory.py
Normal file
57
app-instance/backend/beaver/interfaces/channels/memory.py
Normal file
@ -0,0 +1,57 @@
|
||||
"""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
|
||||
|
||||
@ -35,6 +35,7 @@ app = typer.Typer(help="Beaver backend CLI") if hasattr(typer, "Typer") else typ
|
||||
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.
|
||||
|
||||
@ -44,7 +45,7 @@ def run(
|
||||
3. 打印结果
|
||||
"""
|
||||
|
||||
service = AgentService(workspace=workspace)
|
||||
service = AgentService(workspace=workspace, config_path=config)
|
||||
if not message:
|
||||
service.create_loop()
|
||||
typer.echo("Beaver engine booted.")
|
||||
|
||||
@ -1,41 +1,39 @@
|
||||
"""Gateway entrypoint for Beaver.
|
||||
|
||||
当前阶段先不扩 bus / channels adapter,只做最小消息桥接:
|
||||
当前阶段只做最小 gateway 宿主与 channel adapter 桥接:
|
||||
1. 启动时托管 `AgentService.start()`
|
||||
2. 常驻消费 `MessageBus.inbound`
|
||||
3. 调 `service.submit_direct(...)`
|
||||
3. 调 `service.handle_inbound_message(...)`
|
||||
4. 将结果写回 `MessageBus.outbound`
|
||||
5. 退出时走 `AgentService.shutdown()`
|
||||
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, OutboundMessage
|
||||
from beaver.foundation.events import InboundMessage, MessageBus
|
||||
from beaver.interfaces.channels import ChannelAdapter, ChannelManager
|
||||
from beaver.services.agent_service import AgentService
|
||||
|
||||
|
||||
async def _publish_bridge_error(
|
||||
bus: MessageBus,
|
||||
inbound: InboundMessage,
|
||||
async def _cleanup_owned_service(
|
||||
service: AgentService,
|
||||
*,
|
||||
detail: str,
|
||||
finish_reason: str = "error",
|
||||
timeout_seconds: float | None,
|
||||
force: bool,
|
||||
) -> None:
|
||||
"""把 bridge 处理失败转换成结构化 outbound 错误消息。"""
|
||||
"""Best-effort cleanup for service startup failures or cancellations."""
|
||||
|
||||
await bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
message_id=inbound.message_id,
|
||||
channel=inbound.channel,
|
||||
session_id=inbound.session_id,
|
||||
content=detail,
|
||||
finish_reason=finish_reason,
|
||||
metadata={"error": detail, "inbound_metadata": dict(inbound.metadata)},
|
||||
)
|
||||
)
|
||||
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:
|
||||
@ -46,11 +44,17 @@ async def _flush_pending_inbound(bus: MessageBus, *, reason: str) -> None:
|
||||
pending = bus.inbound.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
await _publish_bridge_error(bus, pending, detail=reason, finish_reason="stopped")
|
||||
await bus.publish_outbound(
|
||||
AgentService.build_outbound_error(
|
||||
pending,
|
||||
detail=reason,
|
||||
finish_reason="stopped",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _await_bridge_shutdown(task: asyncio.Task[None], *, timeout_seconds: float = 1.0) -> None:
|
||||
"""等待 bridge 退出;超时则取消,避免 shutdown 被桥接层反向卡死。"""
|
||||
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)
|
||||
@ -85,53 +89,28 @@ async def _bridge_inbound_to_runtime(
|
||||
continue
|
||||
|
||||
try:
|
||||
result = await service.submit_direct(
|
||||
inbound.content,
|
||||
session_id=inbound.session_id,
|
||||
source=f"gateway:{inbound.channel}",
|
||||
user_id=inbound.user_id,
|
||||
title=inbound.title,
|
||||
execution_context=inbound.execution_context,
|
||||
model=inbound.model,
|
||||
provider_name=inbound.provider_name,
|
||||
embedding_model=inbound.embedding_model,
|
||||
)
|
||||
outbound = await service.handle_inbound_message(inbound)
|
||||
except asyncio.CancelledError:
|
||||
await _publish_bridge_error(
|
||||
bus,
|
||||
inbound,
|
||||
detail="Gateway stopped before completing the inbound message",
|
||||
finish_reason="cancelled",
|
||||
)
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - defensive bridge path
|
||||
await _publish_bridge_error(
|
||||
bus,
|
||||
inbound,
|
||||
detail=str(exc),
|
||||
)
|
||||
else:
|
||||
await bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
message_id=inbound.message_id,
|
||||
channel=inbound.channel,
|
||||
session_id=result.session_id,
|
||||
run_id=result.run_id,
|
||||
content=result.output_text,
|
||||
finish_reason=result.finish_reason,
|
||||
provider_name=result.provider_name,
|
||||
model=result.model,
|
||||
usage=dict(result.usage),
|
||||
metadata={"inbound_metadata": dict(inbound.metadata)},
|
||||
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,
|
||||
@ -142,19 +121,41 @@ async def run_gateway(
|
||||
默认 ownership 语义:
|
||||
- 未传 `service`:gateway 自己创建并接管其 lifecycle
|
||||
- 传入外部 `service`:默认只使用,不自动 start/shutdown
|
||||
- `channel_manager` 和 `channels` 二选一,避免隐式修改外部 manager
|
||||
"""
|
||||
|
||||
attached_service = service or AgentService(workspace=workspace)
|
||||
attached_bus = bus or MessageBus()
|
||||
attached_service = service or AgentService(workspace=workspace, config_path=config_path)
|
||||
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 Exception:
|
||||
attached_service.close()
|
||||
except BaseException:
|
||||
await _cleanup_owned_service(
|
||||
attached_service,
|
||||
timeout_seconds=shutdown_timeout_seconds,
|
||||
force=shutdown_force,
|
||||
)
|
||||
raise
|
||||
|
||||
if not attached_service.is_running:
|
||||
@ -163,7 +164,25 @@ async def run_gateway(
|
||||
"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:
|
||||
@ -175,9 +194,14 @@ async def run_gateway(
|
||||
force=shutdown_force,
|
||||
)
|
||||
finally:
|
||||
await _await_bridge_shutdown(bridge_task)
|
||||
await _await_task_shutdown(bridge_task)
|
||||
else:
|
||||
await _await_bridge_shutdown(bridge_task)
|
||||
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:
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
@ -56,6 +56,7 @@ async def _app_lifespan(
|
||||
app: FastAPI,
|
||||
*,
|
||||
workspace: str | Path | None,
|
||||
config_path: str | Path | None,
|
||||
service: AgentService | None,
|
||||
manage_service_lifecycle: bool | None,
|
||||
shutdown_timeout_seconds: float | None,
|
||||
@ -63,7 +64,7 @@ async def _app_lifespan(
|
||||
) -> AsyncIterator[None]:
|
||||
"""把 Web app 接到 AgentService lifecycle 上。"""
|
||||
|
||||
attached_service = service or AgentService(workspace=workspace)
|
||||
attached_service = service or AgentService(workspace=workspace, config_path=config_path)
|
||||
owns_service = manage_service_lifecycle if manage_service_lifecycle is not None else service is None
|
||||
app.state.agent_service = attached_service
|
||||
started = False
|
||||
@ -71,8 +72,15 @@ async def _app_lifespan(
|
||||
try:
|
||||
await attached_service.start()
|
||||
started = True
|
||||
except Exception:
|
||||
attached_service.close()
|
||||
except BaseException:
|
||||
with suppress(BaseException):
|
||||
if attached_service.is_running:
|
||||
await attached_service.shutdown(
|
||||
timeout_seconds=shutdown_timeout_seconds,
|
||||
force=shutdown_force,
|
||||
)
|
||||
else:
|
||||
attached_service.close()
|
||||
raise
|
||||
try:
|
||||
yield
|
||||
@ -87,6 +95,7 @@ async def _app_lifespan(
|
||||
def create_app(
|
||||
*,
|
||||
workspace: str | Path | None = None,
|
||||
config_path: str | Path | None = None,
|
||||
service: AgentService | None = None,
|
||||
manage_service_lifecycle: bool | None = None,
|
||||
shutdown_timeout_seconds: float | None = 5.0,
|
||||
@ -106,6 +115,7 @@ def create_app(
|
||||
lifespan=lambda fastapi_app: _app_lifespan(
|
||||
fastapi_app,
|
||||
workspace=workspace,
|
||||
config_path=config_path,
|
||||
service=service,
|
||||
manage_service_lifecycle=manage_service_lifecycle,
|
||||
shutdown_timeout_seconds=shutdown_timeout_seconds,
|
||||
|
||||
Reference in New Issue
Block a user