修改了nanobot,往Hermes agent的风格走,进度1/3
This commit is contained in:
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,2 @@
|
||||
"""Channel interfaces."""
|
||||
|
||||
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."""
|
||||
|
||||
59
app-instance/backend/beaver/interfaces/cli/main.py
Normal file
59
app-instance/backend/beaver/interfaces/cli/main.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""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."),
|
||||
) -> None:
|
||||
"""Thin CLI wrapper around AgentService.
|
||||
|
||||
CLI 现在不再自己维护执行逻辑,只负责:
|
||||
1. 解析命令行参数
|
||||
2. 调 AgentService
|
||||
3. 打印结果
|
||||
"""
|
||||
|
||||
service = AgentService(workspace=workspace)
|
||||
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."""
|
||||
|
||||
189
app-instance/backend/beaver/interfaces/gateway/main.py
Normal file
189
app-instance/backend/beaver/interfaces/gateway/main.py
Normal file
@ -0,0 +1,189 @@
|
||||
"""Gateway entrypoint for Beaver.
|
||||
|
||||
当前阶段先不扩 bus / channels adapter,只做最小消息桥接:
|
||||
1. 启动时托管 `AgentService.start()`
|
||||
2. 常驻消费 `MessageBus.inbound`
|
||||
3. 调 `service.submit_direct(...)`
|
||||
4. 将结果写回 `MessageBus.outbound`
|
||||
5. 退出时走 `AgentService.shutdown()`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from beaver.foundation.events import InboundMessage, MessageBus, OutboundMessage
|
||||
from beaver.services.agent_service import AgentService
|
||||
|
||||
|
||||
async def _publish_bridge_error(
|
||||
bus: MessageBus,
|
||||
inbound: InboundMessage,
|
||||
*,
|
||||
detail: str,
|
||||
finish_reason: str = "error",
|
||||
) -> None:
|
||||
"""把 bridge 处理失败转换成结构化 outbound 错误消息。"""
|
||||
|
||||
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)},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
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 _publish_bridge_error(bus, pending, detail=reason, finish_reason="stopped")
|
||||
|
||||
|
||||
async def _await_bridge_shutdown(task: asyncio.Task[None], *, timeout_seconds: float = 1.0) -> None:
|
||||
"""等待 bridge 退出;超时则取消,避免 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:
|
||||
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,
|
||||
)
|
||||
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)},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def run_gateway(
|
||||
*,
|
||||
workspace: str | Path | None = None,
|
||||
service: AgentService | None = None,
|
||||
bus: MessageBus | 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
|
||||
"""
|
||||
|
||||
attached_service = service or AgentService(workspace=workspace)
|
||||
attached_bus = bus or MessageBus()
|
||||
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
|
||||
if owns_service:
|
||||
try:
|
||||
await attached_service.start()
|
||||
started = True
|
||||
except Exception:
|
||||
attached_service.close()
|
||||
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."
|
||||
)
|
||||
|
||||
bridge_task = asyncio.create_task(_bridge_inbound_to_runtime(attached_service, attached_bus, owned_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_bridge_shutdown(bridge_task)
|
||||
else:
|
||||
await _await_bridge_shutdown(bridge_task)
|
||||
|
||||
|
||||
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()
|
||||
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."""
|
||||
|
||||
198
app-instance/backend/beaver/interfaces/web/app.py
Normal file
198
app-instance/backend/beaver/interfaces/web/app.py
Normal file
@ -0,0 +1,198 @@
|
||||
"""FastAPI app factory for Beaver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from beaver.services.agent_service import AgentService
|
||||
|
||||
from .deps import get_agent_service
|
||||
from .schemas import WebChatRequest, WebChatResponse, WebErrorResponse, WebStatusResponse
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
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
|
||||
|
||||
class Request: # type: ignore[override]
|
||||
"""Fallback request shim used only for import-time compatibility."""
|
||||
|
||||
def __init__(self, app: Any) -> None:
|
||||
self.app = app
|
||||
|
||||
class FastAPI: # type: ignore[override]
|
||||
"""Small fallback shim so the package can import before dependencies are installed."""
|
||||
|
||||
def __init__(self, *, title: str, lifespan: Callable[..., Any] | None = None) -> None:
|
||||
self.title = title
|
||||
self.lifespan = lifespan
|
||||
self.state = SimpleNamespace()
|
||||
|
||||
def get(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
def post(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _app_lifespan(
|
||||
app: FastAPI,
|
||||
*,
|
||||
workspace: str | Path | None,
|
||||
service: AgentService | None,
|
||||
manage_service_lifecycle: bool | None,
|
||||
shutdown_timeout_seconds: float | None,
|
||||
shutdown_force: bool,
|
||||
) -> AsyncIterator[None]:
|
||||
"""把 Web app 接到 AgentService lifecycle 上。"""
|
||||
|
||||
attached_service = service or AgentService(workspace=workspace)
|
||||
owns_service = manage_service_lifecycle if manage_service_lifecycle is not None else service is None
|
||||
app.state.agent_service = attached_service
|
||||
started = False
|
||||
if owns_service:
|
||||
try:
|
||||
await attached_service.start()
|
||||
started = True
|
||||
except Exception:
|
||||
attached_service.close()
|
||||
raise
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if owns_service and started:
|
||||
await attached_service.shutdown(
|
||||
timeout_seconds=shutdown_timeout_seconds,
|
||||
force=shutdown_force,
|
||||
)
|
||||
|
||||
|
||||
def create_app(
|
||||
*,
|
||||
workspace: str | Path | None = None,
|
||||
service: AgentService | None = None,
|
||||
manage_service_lifecycle: bool | None = None,
|
||||
shutdown_timeout_seconds: float | None = 5.0,
|
||||
shutdown_force: bool = True,
|
||||
) -> FastAPI:
|
||||
"""Create a Beaver web app hosted by AgentService running mode.
|
||||
|
||||
默认 ownership 语义:
|
||||
- 未传 `service`:app 自己创建并接管其 lifecycle
|
||||
- 传入外部 `service`:默认只挂载,不自动 start/shutdown
|
||||
|
||||
如果确实需要覆盖默认行为,可以显式传 `manage_service_lifecycle=True/False`。
|
||||
"""
|
||||
|
||||
app = FastAPI(
|
||||
title="Beaver Backend",
|
||||
lifespan=lambda fastapi_app: _app_lifespan(
|
||||
fastapi_app,
|
||||
workspace=workspace,
|
||||
service=service,
|
||||
manage_service_lifecycle=manage_service_lifecycle,
|
||||
shutdown_timeout_seconds=shutdown_timeout_seconds,
|
||||
shutdown_force=shutdown_force,
|
||||
),
|
||||
)
|
||||
|
||||
@app.get("/api/ping", response_model=WebStatusResponse)
|
||||
async def ping(request: Request) -> WebStatusResponse:
|
||||
agent_service = get_agent_service(request)
|
||||
running = agent_service.is_running
|
||||
return WebStatusResponse(
|
||||
status="ok",
|
||||
running=running,
|
||||
mode="running" if running else ("direct" if agent_service.has_loop else "idle"),
|
||||
)
|
||||
|
||||
@app.post(
|
||||
"/api/chat",
|
||||
response_model=WebChatResponse,
|
||||
responses={
|
||||
400: {"model": WebErrorResponse},
|
||||
409: {"model": WebErrorResponse},
|
||||
503: {"model": WebErrorResponse},
|
||||
},
|
||||
)
|
||||
async def chat(request: Request, payload: WebChatRequest) -> WebChatResponse:
|
||||
agent_service = get_agent_service(request)
|
||||
message = payload.message.strip()
|
||||
if not message:
|
||||
raise HTTPException(status_code=400, detail="'message' is required")
|
||||
|
||||
fallback_target = _model_dump(payload.fallback_target)
|
||||
auxiliary_target = _model_dump(payload.auxiliary_target)
|
||||
embedding_target = _model_dump(payload.embedding_target)
|
||||
|
||||
try:
|
||||
result = await agent_service.submit_direct(
|
||||
message,
|
||||
session_id=payload.session_id,
|
||||
source="web",
|
||||
user_id=payload.user_id,
|
||||
title=payload.title,
|
||||
execution_context=payload.execution_context,
|
||||
model=payload.model,
|
||||
provider_name=payload.provider_name,
|
||||
embedding_model=payload.embedding_model,
|
||||
temperature=payload.temperature,
|
||||
max_tokens=payload.max_tokens,
|
||||
max_tool_iterations=payload.max_tool_iterations,
|
||||
fallback_target=fallback_target,
|
||||
auxiliary_target=auxiliary_target,
|
||||
embedding_target=embedding_target,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
detail = str(exc)
|
||||
if "requires an active run() loop" in detail or "not ready" in detail:
|
||||
status_code = 503
|
||||
elif "submit_direct" in detail or "running" in detail:
|
||||
status_code = 409
|
||||
else:
|
||||
status_code = 503
|
||||
raise HTTPException(status_code=status_code, detail=detail) from exc
|
||||
|
||||
return WebChatResponse(
|
||||
session_id=result.session_id,
|
||||
run_id=result.run_id,
|
||||
output_text=result.output_text,
|
||||
finish_reason=result.finish_reason,
|
||||
tool_iterations=result.tool_iterations,
|
||||
provider_name=result.provider_name,
|
||||
model=result.model,
|
||||
usage=result.usage,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _model_dump(value: Any) -> dict[str, Any] | None:
|
||||
"""兼容 Pydantic v1/v2 的最小导出辅助。"""
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
if hasattr(value, "model_dump"):
|
||||
return value.model_dump(exclude_none=True)
|
||||
if hasattr(value, "dict"):
|
||||
return value.dict(exclude_none=True)
|
||||
return dict(value)
|
||||
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
|
||||
@ -0,0 +1,2 @@
|
||||
"""Web routes."""
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
"""Web request and response schemas."""
|
||||
|
||||
from .chat import WebChatRequest, WebChatResponse, WebErrorResponse, WebProviderTarget, WebStatusResponse
|
||||
|
||||
__all__ = [
|
||||
"WebChatRequest",
|
||||
"WebChatResponse",
|
||||
"WebErrorResponse",
|
||||
"WebProviderTarget",
|
||||
"WebStatusResponse",
|
||||
]
|
||||
93
app-instance/backend/beaver/interfaces/web/schemas/chat.py
Normal file
93
app-instance/backend/beaver/interfaces/web/schemas/chat.py
Normal file
@ -0,0 +1,93 @@
|
||||
"""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
|
||||
max_tool_iterations: int | None = None
|
||||
fallback_target: WebProviderTarget | None = None
|
||||
auxiliary_target: WebProviderTarget | None = None
|
||||
embedding_target: WebProviderTarget | 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)
|
||||
|
||||
|
||||
class WebStatusResponse(BaseModel):
|
||||
"""Web 宿主层状态响应。"""
|
||||
|
||||
status: str
|
||||
running: bool
|
||||
mode: str
|
||||
|
||||
|
||||
class WebErrorResponse(BaseModel):
|
||||
"""统一错误响应结构。"""
|
||||
|
||||
detail: str
|
||||
Reference in New Issue
Block a user