Files
beaver_project/app-instance/backend/beaver/interfaces/web/app.py
steven_li 6e9e74d1ee feat(engine): 添加运行时上下文支持并重构工具迭代限制
添加 RuntimeContext 类用于捕获模型运行时的日期时间信息,
包括UTC时间、本地时间和时区信息,并在系统提示中显示这些信息。

同时增加最大上下文消息数和工具迭代次数的配置选项,
将验证服务从引擎加载器中移除,并更新相关的数据结构和接口。

BREAKING CHANGE: 移除了验证服务,相关字段被替换为证据状态和接受状态。

- 添加 RuntimeContext 类和相关渲染方法
- 增加 max_context_messages 和 max_tool_iterations 配置
- 移除 ValidationService 相关代码
- 更新消息记录中的验证状态字段
- 添加原始工具调用检测和回退处理
2026-05-26 11:18:35 +08:00

3066 lines
133 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""FastAPI app factory for Beaver."""
from __future__ import annotations
import json
import asyncio
import io
import mimetypes
import os
import secrets
import shutil
import time
import zipfile
from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager, suppress
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from beaver.engine.providers.registry import PROVIDERS, find_by_name
from beaver.foundation.config import default_config_path, load_config
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.skillhub_service import SkillHubService
from beaver.skills.learning import SkillLearningWorker, SkillLearningWorkerConfig
from beaver.skills.catalog.utils import parse_frontmatter
from .deps import get_agent_service
from .files import (
browse_workspace,
content_disposition,
create_workspace_dir,
delete_file,
delete_workspace_path,
generate_file_id,
get_file_metadata,
get_file_path,
list_files,
save_file,
save_to_workspace,
workspace_file_preview,
workspace_file_path,
)
from .schemas import (
WebChatAcceptanceRequest,
WebChatAcceptanceResponse,
WebChatFeedbackRequest,
WebChatFeedbackResponse,
WebChatRequest,
WebChatResponse,
WebErrorResponse,
WebProviderConfigRequest,
WebProviderConfigResponse,
WebStatusResponse,
)
try:
from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import Response
except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments
def File(default: Any = None) -> Any: # type: ignore[override]
return default
def Form(default: Any = None) -> Any: # type: ignore[override]
return default
def Header(default: Any = None) -> Any: # type: ignore[override]
return default
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 UploadFile: # type: ignore[override]
filename: str | None
class Response: # type: ignore[override]
def __init__(self, content: bytes, media_type: str | None = None, headers: dict[str, str] | None = None) -> None:
self.content = content
self.media_type = media_type
self.headers = headers or {}
class WebSocketDisconnect(Exception):
"""Fallback websocket disconnect exception."""
class WebSocket: # type: ignore[override]
"""Fallback websocket shim used only so annotations import."""
app: Any
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
def put(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
return func
return decorator
def delete(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
return func
return decorator
def put(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
return func
return decorator
def patch(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
return func
return decorator
def delete(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
return func
return decorator
def websocket(self, _path: str, **_kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
return func
return decorator
RAW_TOOL_CALL_DISPLAY_FALLBACK = (
"The run reached the configured tool-call limit before producing a reliable final answer. "
"The model attempted another tool call instead of answering, so the raw tool call was suppressed. "
"Please request a revision to continue the task."
)
@asynccontextmanager
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,
shutdown_force: bool,
) -> AsyncIterator[None]:
"""把 Web app 接到 AgentService lifecycle 上。"""
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
app.state.cron_service = _build_cron_service(attached_service) if owns_service else None
started = False
if owns_service:
try:
await attached_service.start()
await app.state.cron_service.start()
started = True
except BaseException:
with suppress(BaseException):
app.state.cron_service.stop()
if attached_service.is_running:
await attached_service.shutdown(
timeout_seconds=shutdown_timeout_seconds,
force=shutdown_force,
)
else:
attached_service.close()
raise
worker: SkillLearningWorker | None = None
worker_task = None
worker_config = SkillLearningWorkerConfig.from_env()
if owns_service and worker_config.enabled:
loaded = attached_service.create_loop().boot()
worker = SkillLearningWorker(
pipeline=loaded.skill_learning_pipeline, # type: ignore[arg-type]
provider_bundle_factory=lambda: attached_service._make_provider_bundle_for_task(loaded, {}), # noqa: SLF001
config=worker_config,
)
worker_task = asyncio.create_task(worker.run_forever())
app.state.skill_learning_worker = worker
app.state.skill_learning_worker_task = worker_task
try:
yield
finally:
cron_service = getattr(app.state, "cron_service", None)
if isinstance(cron_service, CronService):
cron_service.stop()
if worker is not None:
worker.stop()
if worker_task is not None:
worker_task.cancel()
with suppress(BaseException):
await worker_task
if owns_service and started:
await attached_service.shutdown(
timeout_seconds=shutdown_timeout_seconds,
force=shutdown_force,
)
def _build_cron_service(agent_service: AgentService) -> CronService:
loaded = agent_service.create_loop().boot()
async def on_job(job: Any, run_record: CronRunRecord) -> CronExecutionResult:
if getattr(job.payload, "mode", "notification") == "notification":
result = await agent_service.run_scheduled_notification(
job.payload.message,
session_id=NOTIFICATION_SESSION_ID,
cron_job_id=job.id,
cron_job_name=job.name,
scheduled_run_id=run_record.scheduled_run_id,
)
return CronExecutionResult(
response=result.output_text,
run_id=result.run_id,
notification_session_id=result.session_id,
mode="notification",
)
session_id = job.payload.session_key or f"cron:{job.id}"
result = await agent_service.run_scheduled_task(
job.payload.message,
session_id=session_id,
cron_job_id=job.id,
cron_job_name=job.name,
scheduled_run_id=run_record.scheduled_run_id,
requires_followup=bool(getattr(job.payload, "requires_followup", False)),
)
return CronExecutionResult(
response=result.output_text,
task_id=result.task_id,
run_id=result.run_id,
notification_session_id=session_id,
mode="task",
)
service = CronService(loaded.workspace / "cron" / "jobs.json", on_job=on_job)
agent_service.register_runtime_service("cron_service", service)
return service
def get_cron_service(request: Request) -> CronService:
service = getattr(request.app.state, "cron_service", None)
if isinstance(service, CronService):
return service
agent_service = get_agent_service(request)
service = _build_cron_service(agent_service)
request.app.state.cron_service = service
return service
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,
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,
config_path=config_path,
service=service,
manage_service_lifecycle=manage_service_lifecycle,
shutdown_timeout_seconds=shutdown_timeout_seconds,
shutdown_force=shutdown_force,
),
)
app.state.auth_tokens = {}
app.state.handoff_codes = {}
app.state.auth_file = Path(os.getenv("BEAVER_AUTH_FILE") or "")
max_file_size = 50 * 1024 * 1024
@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.get("/api/status")
async def status(request: Request) -> dict[str, Any]:
agent_service = get_agent_service(request)
loaded = agent_service.create_loop().boot()
config = loaded.config
config_path = config.config_path or default_config_path(workspace=loaded.workspace)
cron_service = get_cron_service(request)
providers_status = []
default_provider = config.resolve_provider_target().get("provider_name")
for spec in PROVIDERS:
if spec.name == "custom":
continue
provider_cfg = config.providers.get(spec.name)
api_key = provider_cfg.api_key if provider_cfg is not None else None
api_base = provider_cfg.api_base if provider_cfg is not None else None
enabled = _provider_enabled(spec.name, provider_cfg)
if spec.is_oauth:
has_key = enabled
elif spec.is_local or spec.is_direct:
has_key = bool(api_base)
else:
has_key = bool(api_key)
providers_status.append(
{
"id": spec.name,
"name": spec.label,
"label": spec.label,
"enabled": enabled,
"active": default_provider == spec.name,
"has_key": has_key,
"api_key_masked": _mask_secret(api_key),
"api_base": api_base or "",
"default_api_base": spec.default_api_base,
"detail": api_base or spec.default_api_base or "",
"requires_api_key": not (spec.is_oauth or spec.is_local or spec.is_direct),
"is_oauth": spec.is_oauth,
"is_local": spec.is_local,
}
)
return {
"config_path": str(config_path),
"config_exists": config_path.exists(),
"workspace": str(loaded.workspace),
"workspace_exists": loaded.workspace.exists(),
"model": config.default_model or agent_service.profile.default_model,
"max_tokens": agent_service.profile.max_tokens,
"max_context_messages": agent_service.profile.max_context_messages,
"temperature": agent_service.profile.temperature,
"max_tool_iterations": agent_service.profile.max_tool_iterations,
"providers": providers_status,
"channels": [{"name": "web", "enabled": True}],
"cron": cron_service.status(),
}
@app.post("/api/auth/login")
async def auth_login(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
username = _clean_text(payload.get("username"))
password = str(payload.get("password") or "")
if not username or not password:
raise HTTPException(status_code=400, detail="Username and password are required")
users = _load_auth_users(_auth_file_path())
expected = users.get(username)
if expected is None or not secrets.compare_digest(expected, password):
raise HTTPException(status_code=401, detail="Invalid username or password")
token = _issue_web_token(app, username)
handoff_code, handoff_expires_at = _issue_handoff_code(app, username, token)
return {
"access_token": token,
"refresh_token": "",
"token_type": "bearer",
"user_id": username,
"username": username,
"role": "owner",
"handoff_code": handoff_code,
"handoff_expires_at": handoff_expires_at,
"backend_connection": _backend_connection_view(request),
"local_backend": _local_backend_view(),
}
@app.post("/api/auth/register")
async def auth_register(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
username = _clean_text(payload.get("username"))
password = str(payload.get("password") or "")
email = _clean_text(payload.get("email")) or ""
if not username or not password:
raise HTTPException(status_code=400, detail="Username and password are required")
auth_file = _auth_file_path()
users = _load_auth_users_if_present(auth_file)
user_exists = username in users
if user_exists and not secrets.compare_digest(users[username], password):
raise HTTPException(
status_code=409,
detail="Username already exists. Use the existing password to finish setup or log in.",
)
agent_service = get_agent_service(request)
loaded = agent_service.create_loop().boot()
config = loaded.config
authz_base_url = _clean_text(payload.get("authz_base_url")) or (config.authz.base_url if config.authz.enabled else "")
backend_name = _clean_text(payload.get("backend_name")) or config.backend_identity.name or username
requested_backend_id = _clean_text(payload.get("backend_id")) or config.backend_identity.backend_id or None
public_base_url = (
_clean_text(payload.get("base_url"))
or config.backend_identity.public_base_url
or os.getenv("BEAVER_FRONTEND_PUBLIC_BASE_URL")
or str(request.base_url).rstrip("/")
)
frontend_base_url = _clean_text(payload.get("frontend_base_url")) or public_base_url
authz_user_registered = False
authz_backend_registered = False
local_backend: dict[str, Any] | None = None
if authz_base_url:
from beaver.integrations.authz import AuthzClient
try:
authz_payload = await AuthzClient(
authz_base_url,
timeout_seconds=config.authz.request_timeout_seconds,
).register_user(
username=username,
password=password,
email=email or None,
backend_name=backend_name,
backend_id=requested_backend_id,
base_url=public_base_url,
frontend_base_url=frontend_base_url,
)
except Exception as exc: # noqa: BLE001 - expose upstream setup failures to portal
raise HTTPException(status_code=502, detail=f"AuthZ registration failed: {exc}") from exc
backend = authz_payload.get("backend") if isinstance(authz_payload, dict) else {}
if isinstance(backend, dict):
backend_id = _clean_text(backend.get("backend_id")) or requested_backend_id
client_id = _clean_text(backend.get("client_id")) or backend_id
client_secret = _clean_text(backend.get("client_secret")) or config.backend_identity.client_secret
if backend_id and client_id and client_secret:
local_backend = _save_backend_identity(
agent_service,
config_path=config.config_path or default_config_path(workspace=loaded.workspace),
backend_id=backend_id,
client_id=client_id,
client_secret=client_secret,
name=_clean_text(backend.get("name")) or backend_name,
public_base_url=public_base_url,
authz_base_url=authz_base_url,
)
authz_backend_registered = True
authz_user_registered = bool(authz_payload)
if not user_exists:
users[username] = password
_save_auth_users(auth_file, users)
token = _issue_web_token(app, username)
handoff_code, handoff_expires_at = _issue_handoff_code(app, username, token)
backend_connection = {
**_backend_connection_view(request),
"public_base_url": public_base_url,
"api_base_url": public_base_url,
"frontend_base_url": frontend_base_url,
"registered": bool(local_backend),
}
if local_backend is not None:
backend_connection.update(
{
"backend_id": local_backend.get("backend_id"),
"client_id": local_backend.get("client_id"),
"name": local_backend.get("name"),
}
)
return {
"access_token": token,
"refresh_token": "",
"token_type": "bearer",
"user_id": username,
"username": username,
"email": email,
"role": "owner",
"handoff_code": handoff_code,
"handoff_expires_at": handoff_expires_at,
"existing_user": user_exists,
"authz": {
"enabled": bool(authz_base_url),
"base_url": authz_base_url or None,
"user_registered": authz_user_registered,
"backend_registered": authz_backend_registered,
},
"backend_connection": backend_connection,
"local_backend": local_backend or _local_backend_view(),
}
@app.post("/api/auth/handoff/consume")
async def auth_handoff_consume(payload: dict[str, Any]) -> dict[str, Any]:
return _consume_handoff_code(app, str(payload.get("code") or ""))
@app.get("/api/auth/me")
async def auth_me(authorization: str | None = Header(default=None)) -> dict[str, Any]:
username = _require_web_user(app, authorization)
return {
"id": username,
"username": username,
"email": os.getenv("BEAVER_BACKEND_IDENTITY__EMAIL", ""),
"role": "owner",
"quota_tier": "single-user",
}
@app.post("/api/auth/logout")
async def auth_logout(authorization: str | None = Header(default=None)) -> dict[str, Any]:
if authorization and authorization.lower().startswith("bearer "):
token = authorization[7:].strip()
app.state.auth_tokens.pop(token, None)
return {"ok": True}
@app.post("/api/providers/{provider_name}/config", response_model=WebProviderConfigResponse)
async def update_provider_config(
provider_name: str,
request: Request,
payload: WebProviderConfigRequest,
) -> WebProviderConfigResponse:
spec = find_by_name(provider_name)
if spec is None or spec.name == "custom":
raise HTTPException(status_code=404, detail=f"Unknown provider: {provider_name}")
agent_service = get_agent_service(request)
config_path = agent_service.loader.config.config_path or default_config_path(workspace=agent_service.loader.workspace)
raw = _read_config_json(config_path)
providers = _ensure_dict(raw, "providers")
agents = _ensure_dict(raw, "agents")
defaults = _ensure_dict(agents, "defaults")
if not payload.enabled:
providers.pop(spec.name, None)
if _clean_text(defaults.get("provider")) == spec.name:
defaults.pop("provider", None)
else:
current = providers.get(spec.name) if isinstance(providers.get(spec.name), dict) else {}
provider_payload = dict(current)
api_key = _clean_text(payload.api_key)
api_base = _clean_text(payload.api_base)
if api_key:
provider_payload["apiKey"] = api_key
elif "apiKey" not in provider_payload and "api_key" not in provider_payload:
provider_payload.pop("apiKey", None)
if api_base:
provider_payload["apiBase"] = api_base
elif spec.default_api_base and not provider_payload.get("apiBase") and not provider_payload.get("api_base"):
provider_payload["apiBase"] = spec.default_api_base
elif not api_base and not spec.default_api_base:
provider_payload.pop("apiBase", None)
if payload.request_timeout_seconds is not None:
provider_payload["requestTimeoutSeconds"] = payload.request_timeout_seconds
providers.clear()
providers[spec.name] = provider_payload
defaults["provider"] = spec.name
model = _clean_text(payload.model)
if model:
defaults["model"] = model
_write_config_json(config_path, raw)
_reload_agent_config(agent_service, config_path)
return WebProviderConfigResponse(ok=True, provider=spec.name, enabled=payload.enabled)
@app.get("/api/sessions")
async def list_sessions(request: Request) -> list[dict[str, Any]]:
loaded = get_agent_service(request).create_loop().boot()
session_manager = loaded.session_manager
rows = session_manager.list_sessions_rich(
limit=100,
exclude_sources=["subagent", "notification"],
exclude_end_reasons=["archived", "deleted"],
) # type: ignore[union-attr]
return [
{
"key": str(row.get("id")),
"created_at": _iso_from_timestamp(row.get("started_at")),
"updated_at": _iso_from_timestamp(row.get("last_active")),
"path": str(row.get("id")),
}
for row in rows
]
@app.get("/api/debug/chat-logs")
async def get_chat_logs(request: Request, limit: int = 50) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
session_manager = loaded.session_manager
bounded_limit = max(1, min(int(limit or 50), 200))
rows = session_manager.list_sessions_rich(
limit=bounded_limit,
exclude_end_reasons=["archived", "deleted"],
) # type: ignore[union-attr]
sessions = []
for row in rows:
session_id = str(row.get("id"))
runs = _debug_runs_for_session(session_manager, session_id)
if not runs:
continue
sessions.append(
{
"session_id": session_id,
"source": row.get("source"),
"title": row.get("title"),
"created_at": _iso_from_timestamp(row.get("started_at")),
"updated_at": _iso_from_timestamp(row.get("last_active")),
"runs": runs,
}
)
return {"sessions": sessions}
@app.post("/api/sessions/{session_id:path}/archive")
async def archive_session(session_id: str, request: Request) -> dict[str, Any]:
if session_id.startswith("notify:"):
raise HTTPException(status_code=400, detail="Notification sessions cannot be archived")
loaded = get_agent_service(request).create_loop().boot()
loaded.session_manager.end_session(session_id, "archived") # type: ignore[union-attr]
return {"ok": True, "archived": True}
@app.post("/api/sessions/{session_id:path}")
async def create_session(session_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
session_manager = loaded.session_manager
session = session_manager.get_or_create(session_id, source="web") # type: ignore[union-attr]
return _session_detail(session_manager, session_id, session) # type: ignore[arg-type]
@app.get("/api/sessions/{session_id:path}/process")
async def get_session_process(session_id: str, request: Request) -> dict[str, Any]:
from beaver.services.process_service import SessionProcessProjector
loaded = get_agent_service(request).create_loop().boot()
projector = SessionProcessProjector(
loaded.session_manager,
loaded.run_memory_store,
)
return projector.project(session_id)
@app.get("/api/sessions/{session_id:path}/active-task")
async def get_session_active_task(session_id: str, request: Request) -> dict[str, Any] | None:
loaded = get_agent_service(request).create_loop().boot()
task_service = loaded.task_service
if task_service is None:
return None
view = task_service.active_task_view(session_id)
if view is None:
return None
return {
"task_id": view["task_id"],
"status": view["status"],
"short_title": view["short_title"],
"description": view["description"],
"updated_at": view["updated_at"],
}
@app.get("/api/sessions/{session_id:path}")
async def get_session(session_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
session_manager = loaded.session_manager
session = session_manager.get_or_create(session_id, source="web") # type: ignore[union-attr]
return _session_detail(session_manager, session_id, session) # type: ignore[arg-type]
@app.delete("/api/sessions/{session_id:path}")
async def delete_session(session_id: str, request: Request) -> dict[str, Any]:
if session_id.startswith("notify:"):
raise HTTPException(status_code=400, detail="Notification sessions cannot be archived")
loaded = get_agent_service(request).create_loop().boot()
loaded.session_manager.end_session(session_id, "archived") # type: ignore[union-attr]
return {"ok": True, "archived": True}
@app.post("/api/files/upload")
async def upload_file(
request: Request,
file: UploadFile = File(...),
session_id: str = Form("web:default"),
) -> dict[str, Any]:
if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided")
content = await file.read()
if len(content) > max_file_size:
raise HTTPException(status_code=413, detail="File too large (max 50MB)")
loaded = get_agent_service(request).create_loop().boot()
file_id = generate_file_id()
metadata = save_file(
workspace=loaded.workspace,
file_id=file_id,
filename=file.filename,
content=content,
content_type=file.content_type or "application/octet-stream",
session_id=session_id,
)
metadata["url"] = f"/api/files/{file_id}"
return metadata
@app.get("/api/files")
async def list_uploaded_files(request: Request, session_id: str | None = None) -> list[dict[str, Any]]:
loaded = get_agent_service(request).create_loop().boot()
return list_files(loaded.workspace, session_id=session_id)
@app.get("/api/files/{file_id}")
async def download_file(file_id: str, request: Request) -> Response:
loaded = get_agent_service(request).create_loop().boot()
metadata = get_file_metadata(loaded.workspace, file_id)
if metadata is None:
raise HTTPException(status_code=404, detail="File not found")
file_path = get_file_path(loaded.workspace, file_id)
if file_path is None:
raise HTTPException(status_code=404, detail="File data missing")
content_type = str(metadata.get("content_type") or "application/octet-stream")
disposition = "inline" if content_type.startswith("image/") else "attachment"
filename = str(metadata.get("name") or file_path.name)
return Response(
content=file_path.read_bytes(),
media_type=content_type,
headers={"Content-Disposition": content_disposition(disposition, filename)},
)
@app.delete("/api/files/{file_id}")
async def remove_file(file_id: str, request: Request) -> dict[str, bool]:
loaded = get_agent_service(request).create_loop().boot()
if delete_file(loaded.workspace, file_id):
return {"ok": True}
raise HTTPException(status_code=404, detail="File not found")
@app.get("/api/workspace/browse")
async def browse_workspace_dir(request: Request, path: str = "") -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
try:
return browse_workspace(loaded.workspace, path)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/workspace/download")
async def download_workspace_file(path: str, request: Request) -> Response:
loaded = get_agent_service(request).create_loop().boot()
file_path = workspace_file_path(loaded.workspace, path)
if file_path is None:
raise HTTPException(status_code=404, detail="File not found")
content_type, _ = mimetypes.guess_type(file_path.name)
content_type = content_type or "application/octet-stream"
disposition = "inline" if content_type.startswith("image/") else "attachment"
return Response(
content=file_path.read_bytes(),
media_type=content_type,
headers={"Content-Disposition": content_disposition(disposition, file_path.name)},
)
@app.get("/api/workspace/file")
async def preview_workspace_file(path: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
try:
return workspace_file_preview(loaded.workspace, path)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.post("/api/workspace/upload")
async def upload_to_workspace(
request: Request,
file: UploadFile = File(...),
path: str = Form(""),
) -> dict[str, Any]:
if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided")
content = await file.read()
if len(content) > max_file_size:
raise HTTPException(status_code=413, detail="File too large (max 50MB)")
loaded = get_agent_service(request).create_loop().boot()
try:
return save_to_workspace(loaded.workspace, path, file.filename, content)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.delete("/api/workspace/delete")
async def delete_workspace_item(path: str, request: Request) -> dict[str, bool]:
loaded = get_agent_service(request).create_loop().boot()
if delete_workspace_path(loaded.workspace, path):
return {"ok": True}
raise HTTPException(status_code=404, detail="Path not found")
@app.post("/api/workspace/mkdir")
async def create_workspace_directory(path: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
try:
return create_workspace_dir(loaded.workspace, path)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/agents")
async def list_agents(request: Request) -> list[dict[str, Any]]:
loaded = get_agent_service(request).create_loop().boot()
return [_registered_agent_to_ui(agent) for agent in loaded.agent_registry.list_agents()] # type: ignore[union-attr]
@app.post("/api/agents")
async def upsert_agent(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
agent = loaded.agent_registry.upsert_agent(_agent_payload_from_ui(payload)) # type: ignore[union-attr]
return _registered_agent_to_ui(agent)
@app.patch("/api/agents/{agent_id}")
async def patch_agent(agent_id: str, request: Request, payload: dict[str, Any]) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
registry = loaded.agent_registry
current = registry.get_agent(agent_id) # type: ignore[union-attr]
if current is None:
raise HTTPException(status_code=404, detail=f"Unknown agent: {agent_id}")
merged = current.to_dict()
merged.update(_agent_payload_from_ui(payload))
merged["agent_id"] = agent_id
agent = registry.upsert_agent(merged) # type: ignore[union-attr]
return _registered_agent_to_ui(agent)
@app.post("/api/agents/{agent_id}/disable")
async def disable_agent(agent_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
try:
agent = loaded.agent_registry.disable_agent(agent_id) # type: ignore[union-attr]
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return _registered_agent_to_ui(agent)
@app.delete("/api/agents/{agent_id}")
async def delete_agent(agent_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
deleted = loaded.agent_registry.delete_agent(agent_id) # type: ignore[union-attr]
if not deleted:
raise HTTPException(status_code=404, detail="Agent not found")
return {"ok": True, "id": agent_id}
@app.post("/api/agents/refresh")
async def refresh_agents(request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
return {"agents": [_registered_agent_to_ui(agent) for agent in loaded.agent_registry.list_agents()]} # type: ignore[union-attr]
@app.get("/api/subagents")
async def list_subagents(request: Request) -> list[dict[str, Any]]:
from beaver.coordinator.subagents import LocalSubagentStore
loaded = get_agent_service(request).create_loop().boot()
store = LocalSubagentStore(loaded.workspace, public_base_url=loaded.config.backend_identity.public_base_url)
return [store.serialize(spec) for spec in store.list_subagents()]
@app.get("/api/subagents/{agent_id}")
async def get_subagent(agent_id: str, request: Request) -> dict[str, Any]:
from beaver.coordinator.subagents import LocalSubagentStore
loaded = get_agent_service(request).create_loop().boot()
store = LocalSubagentStore(loaded.workspace, public_base_url=loaded.config.backend_identity.public_base_url)
spec = store.get_subagent(agent_id)
if spec is None:
raise HTTPException(status_code=404, detail="Sub-agent not found")
return store.serialize(spec)
@app.post("/api/subagents")
async def create_subagent(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
from beaver.coordinator.subagents import LocalSubagentStore
loaded = get_agent_service(request).create_loop().boot()
store = LocalSubagentStore(loaded.workspace, public_base_url=loaded.config.backend_identity.public_base_url)
try:
spec = store.upsert_subagent(payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return store.serialize(spec)
@app.put("/api/subagents/{agent_id}")
async def update_subagent(agent_id: str, request: Request, payload: dict[str, Any]) -> dict[str, Any]:
if _clean_text(payload.get("id")) != agent_id:
raise HTTPException(status_code=400, detail="Path id must match body id")
return await create_subagent(request, payload)
@app.delete("/api/subagents/{agent_id}")
async def delete_subagent(agent_id: str, request: Request) -> dict[str, Any]:
from beaver.coordinator.subagents import LocalSubagentStore
loaded = get_agent_service(request).create_loop().boot()
store = LocalSubagentStore(loaded.workspace, public_base_url=loaded.config.backend_identity.public_base_url)
if not store.delete_subagent(agent_id):
raise HTTPException(status_code=404, detail="Sub-agent not found")
return {"ok": True, "id": agent_id}
@app.get("/api/authz/status")
async def get_authz_status(request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
config = loaded.config
registered = bool(config.backend_identity.backend_id and config.backend_identity.client_id and config.backend_identity.client_secret)
permissions: dict[str, Any] = {}
error = None
if config.authz.enabled and config.authz.base_url and config.backend_identity.backend_id:
try:
from beaver.integrations.authz import AuthzClient
permissions = await AuthzClient(
config.authz.base_url,
timeout_seconds=config.authz.request_timeout_seconds,
).get_permissions(config.backend_identity.backend_id)
except Exception as exc: # noqa: BLE001 - status endpoint reports dependency errors
error = str(exc)
return {
"enabled": config.authz.enabled,
"base_url": config.authz.base_url,
"outlook_mcp_url": config.authz.outlook_mcp_url,
"local_backend": {
"backend_id": config.backend_identity.backend_id or None,
"client_id": config.backend_identity.client_id or None,
"name": config.backend_identity.name or None,
"public_base_url": config.backend_identity.public_base_url or None,
"registered": registered,
},
"permissions": permissions,
"error": error,
}
@app.get("/api/mcp/servers")
async def list_mcp_servers(request: Request) -> list[dict[str, Any]]:
loaded = get_agent_service(request).create_loop().boot()
return [_mcp_server_view(server_id, cfg, loaded.mcp_report.get(server_id, {})) for server_id, cfg in loaded.config.tools.mcp_servers.items()]
@app.post("/api/mcp/servers")
async def add_mcp_server(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
server_id = _clean_text(payload.get("id"))
if not server_id:
raise HTTPException(status_code=400, detail="Server id is required")
config_path = loaded.config.config_path or default_config_path(workspace=loaded.workspace)
raw = _read_config_json(config_path)
tools = _ensure_dict(raw, "tools")
servers = _ensure_dict(tools, "mcpServers")
servers[server_id] = _mcp_config_payload(payload, server_id)
_write_config_json(config_path, raw)
_reload_agent_config(get_agent_service(request), config_path)
loaded = get_agent_service(request).create_loop().boot()
cfg = loaded.config.tools.mcp_servers[server_id]
return _mcp_server_view(server_id, cfg, {})
@app.put("/api/mcp/servers/{server_id}")
async def update_mcp_server(server_id: str, request: Request, payload: dict[str, Any]) -> dict[str, Any]:
if _clean_text(payload.get("id")) and _clean_text(payload.get("id")) != server_id:
raise HTTPException(status_code=400, detail="Path id must match body id")
payload = {**payload, "id": server_id}
return await add_mcp_server(request, payload)
@app.delete("/api/mcp/servers/{server_id}")
async def delete_mcp_server(server_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
config_path = loaded.config.config_path or default_config_path(workspace=loaded.workspace)
raw = _read_config_json(config_path)
servers = _ensure_dict(_ensure_dict(raw, "tools"), "mcpServers")
if server_id not in servers:
raise HTTPException(status_code=404, detail="MCP server not found")
servers.pop(server_id, None)
_write_config_json(config_path, raw)
_reload_agent_config(get_agent_service(request), config_path)
return {"ok": True, "id": server_id}
@app.post("/api/mcp/servers/{server_id}/test")
async def test_mcp(server_id: str, request: Request) -> dict[str, Any]:
from beaver.integrations.mcp import test_mcp_server
loaded = get_agent_service(request).create_loop().boot()
cfg = loaded.config.tools.mcp_servers.get(server_id)
if cfg is None:
raise HTTPException(status_code=404, detail="MCP server not found")
return await test_mcp_server(
server_id,
cfg,
authz_config=loaded.config.authz,
backend_identity=loaded.config.backend_identity,
)
@app.get("/api/integrations/outlook/status")
async def get_outlook_status(request: Request) -> dict[str, Any]:
from beaver.integrations.outlook import OutlookIntegrationError, outlook_status
loaded = get_agent_service(request).create_loop().boot()
try:
return await outlook_status(loaded.config, loaded.workspace)
except OutlookIntegrationError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.post("/api/integrations/outlook/test-connection")
async def test_outlook_connection(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
from beaver.integrations.outlook import OutlookConnectionInput, OutlookIntegrationError, test_connection
loaded = get_agent_service(request).create_loop().boot()
try:
return await test_connection(OutlookConnectionInput(**payload), loaded.config)
except OutlookIntegrationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except TypeError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/integrations/outlook/connect")
async def connect_outlook(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
from beaver.integrations.outlook import (
OUTLOOK_SERVER_ID,
OutlookConnectionInput,
OutlookIntegrationError,
connect_workspace,
outlook_mcp_config_payload,
)
agent_service = get_agent_service(request)
loaded = agent_service.create_loop().boot()
try:
result = await connect_workspace(loaded.config, loaded.workspace, OutlookConnectionInput(**payload))
except OutlookIntegrationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except TypeError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
config_path = loaded.config.config_path or default_config_path(workspace=loaded.workspace)
raw = _read_config_json(config_path)
servers = _ensure_dict(_ensure_dict(raw, "tools"), "mcpServers")
servers[OUTLOOK_SERVER_ID] = outlook_mcp_config_payload(loaded.config)
_write_config_json(config_path, raw)
_reload_agent_config(agent_service, config_path)
return result
@app.post("/api/integrations/outlook/disconnect")
async def disconnect_outlook(request: Request) -> dict[str, Any]:
from beaver.integrations.outlook import OutlookIntegrationError, disconnect_workspace
loaded = get_agent_service(request).create_loop().boot()
try:
return await disconnect_workspace(loaded.config)
except OutlookIntegrationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/integrations/outlook/overview")
async def get_outlook_overview(request: Request) -> dict[str, Any]:
from beaver.integrations.outlook import OutlookIntegrationError, get_overview
loaded = get_agent_service(request).create_loop().boot()
try:
return await get_overview(loaded.config, loaded.workspace)
except OutlookIntegrationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/integrations/outlook/messages")
async def get_outlook_messages(
request: Request,
folder: str = "inbox",
top: int = 20,
skip: int = 0,
unread_only: bool = False,
) -> dict[str, Any]:
from beaver.integrations.outlook import OutlookIntegrationError, list_messages
if not folder.strip():
raise HTTPException(status_code=400, detail="folder is required")
loaded = get_agent_service(request).create_loop().boot()
try:
return await list_messages(
loaded.config,
folder=folder.strip(),
top=top,
skip=skip,
unread_only=unread_only,
)
except OutlookIntegrationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/integrations/outlook/events")
async def get_outlook_events(
request: Request,
start_time: str,
end_time: str,
top: int = 20,
skip: int = 0,
) -> dict[str, Any]:
from beaver.integrations.outlook import OutlookIntegrationError, list_events
if not start_time.strip() or not end_time.strip():
raise HTTPException(status_code=400, detail="start_time and end_time are required")
loaded = get_agent_service(request).create_loop().boot()
try:
return await list_events(
loaded.config,
start_time=start_time.strip(),
end_time=end_time.strip(),
top=top,
skip=skip,
)
except OutlookIntegrationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/integrations/outlook/message-detail")
async def get_outlook_message_detail(
request: Request,
message_id: str,
changekey: str | None = None,
) -> dict[str, Any]:
from beaver.integrations.outlook import OutlookIntegrationError, get_message_detail
if not message_id.strip():
raise HTTPException(status_code=400, detail="message_id is required")
loaded = get_agent_service(request).create_loop().boot()
try:
return await get_message_detail(
loaded.config,
message_id.strip(),
changekey=changekey.strip() if changekey else None,
)
except OutlookIntegrationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/mcp/tools")
async def list_mcp_tools(request: Request) -> list[dict[str, Any]]:
loaded = get_agent_service(request).create_loop().boot()
registry = loaded.tool_registry
report: dict[str, Any] = {}
if getattr(loaded, "mcp_manager", None) is not None:
loaded.mcp_report = await loaded.mcp_manager.connect_all(registry)
report = dict(loaded.mcp_report or {})
groups: dict[str, list[dict[str, Any]]] = {}
for spec in registry.list_specs():
if not spec.name.startswith("mcp_"):
continue
metadata = dict(getattr(spec, "metadata", {}) or {})
server_id = str(metadata.get("server_id") or "")
if not server_id:
remainder = spec.name[len("mcp_"):]
server_id, _, _public_name = remainder.partition("_")
public_name = str(metadata.get("original_tool_name") or spec.name)
groups.setdefault(server_id, []).append(
{
"server_id": server_id,
"tool_name": public_name,
"name": spec.name,
"description": spec.description,
"parameters": spec.input_schema,
"kind": metadata.get("kind") or "online",
"category": metadata.get("category") or "online",
}
)
result: list[dict[str, Any]] = []
for key, value in sorted(groups.items()):
cfg = loaded.config.tools.mcp_servers.get(key)
server_report = report.get(key, {})
kind = cfg.kind if cfg is not None else (value[0].get("kind") if value else "online")
category = cfg.category if cfg is not None else (value[0].get("category") if value else kind)
result.append(
{
"server_id": key,
"server_name": cfg.display_name if cfg and cfg.display_name else key,
"transport": cfg.transport if cfg is not None else "mcp",
"kind": kind,
"category": category,
"status": server_report.get("status"),
"last_error": server_report.get("last_error"),
"tool_count": len(value),
"tools": sorted(value, key=lambda item: item["tool_name"]),
}
)
return result
@app.get("/api/skills")
async def list_skills(request: Request) -> list[dict[str, Any]]:
loaded = get_agent_service(request).create_loop().boot()
skills = loaded.skills_loader.list_skills(filter_unavailable=False) # type: ignore[union-attr]
return [
{
"name": record.name,
"description": record.description,
"source": "builtin" if record.source == "builtin" else "workspace",
"available": loaded.skills_loader._record_available(record), # type: ignore[union-attr]
"path": str(record.path),
"version": record.version,
"status": record.status,
"source_kind": record.source_kind,
"tool_hints": list(record.tool_hints),
"provenance": (
loaded.skill_spec_store.read_published_skill(record.name).version.provenance # type: ignore[union-attr]
if loaded.skill_spec_store.read_published_skill(record.name) is not None # type: ignore[union-attr]
else {}
),
"agent_cards": [],
}
for record in skills
]
@app.get("/api/skills/{name}/detail")
async def get_skill_detail(name: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
record = loaded.skills_loader.get_skill_record(name) # type: ignore[union-attr]
if record is None:
raise HTTPException(status_code=404, detail="Skill not found")
return _skill_detail_payload(loaded, name, record.version)
@app.get("/api/skills/{name}/versions/{version}")
async def get_skill_version(name: str, version: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
record = loaded.skills_loader.get_skill_record(name) # type: ignore[union-attr]
if record is None:
raise HTTPException(status_code=404, detail="Skill not found")
return _skill_detail_payload(loaded, name, version)
@app.get("/api/skills/{name}/versions/{version}/file")
async def get_skill_file(name: str, version: str, request: Request, path: str) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
record = loaded.skills_loader.get_skill_record(name) # type: ignore[union-attr]
if record is None:
raise HTTPException(status_code=404, detail="Skill not found")
base_dir = _skill_version_base_dir(loaded, record, version)
file_path = _safe_child_path(base_dir, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="Skill file not found")
return _skill_file_content_payload(base_dir, file_path)
@app.get("/api/skills/{name}/download")
async def download_skill(name: str, request: Request) -> Response:
loaded = get_agent_service(request).create_loop().boot()
record = loaded.skills_loader.get_skill_record(name) # type: ignore[union-attr]
if record is None:
raise HTTPException(status_code=404, detail="Skill not found")
skill_dir = record.path.parent
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
for file_path in sorted(skill_dir.rglob("*")):
if file_path.is_file() and not file_path.is_symlink():
archive.write(file_path, f"{name}/{file_path.relative_to(skill_dir)}")
return Response(
content=buffer.getvalue(),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{name}.zip"'},
)
@app.delete("/api/skills/{name}")
async def delete_skill(name: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
target = loaded.workspace / "skills" / name
if not target.exists() or not target.is_dir():
raise HTTPException(status_code=404, detail="Skill not found")
shutil.rmtree(target)
return {"ok": True, "name": name}
@app.post("/api/skills/upload")
async def upload_skill(request: Request, file: UploadFile = File(...)) -> dict[str, Any]:
filename = file.filename or ""
if not filename.endswith(".zip"):
raise HTTPException(status_code=400, detail="File must be a .zip archive")
loaded = get_agent_service(request).create_loop().boot()
try:
content = await file.read()
draft = _create_skill_upload_draft(loaded, filename, content)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return draft
@app.get("/api/marketplaces/skills/search")
async def search_skillhub(
request: Request,
q: str = "",
sort: str = "relevance",
page: int = 0,
size: int = 12,
namespace: str | None = None,
) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
service = SkillHubService(loaded.skill_spec_store) # type: ignore[arg-type]
try:
return await service.search(q=q, sort=sort, page=page, size=size, namespace=namespace)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@app.get("/api/marketplaces/skills/{namespace}/{slug}")
async def get_skillhub_detail(namespace: str, slug: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
service = SkillHubService(loaded.skill_spec_store) # type: ignore[arg-type]
try:
return await service.detail(namespace, slug)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@app.get("/api/marketplaces/skills/{namespace}/{slug}/versions/{version}")
async def get_skillhub_version(namespace: str, slug: str, version: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
service = SkillHubService(loaded.skill_spec_store) # type: ignore[arg-type]
try:
return await service.version(namespace, slug, version)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@app.get("/api/marketplaces/skills/{namespace}/{slug}/versions")
async def list_skillhub_versions(namespace: str, slug: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
service = SkillHubService(loaded.skill_spec_store) # type: ignore[arg-type]
try:
return await service.versions(namespace, slug)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@app.get("/api/marketplaces/skills/{namespace}/{slug}/versions/{version}/file")
async def get_skillhub_file(namespace: str, slug: str, version: str, request: Request, path: str) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
service = SkillHubService(loaded.skill_spec_store) # type: ignore[arg-type]
try:
return await service.file_content(namespace, slug, version, path)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@app.post("/api/marketplaces/skills/{namespace}/{slug}/install")
async def install_skillhub_skill(namespace: str, slug: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
service = SkillHubService(loaded.skill_spec_store) # type: ignore[arg-type]
try:
return await service.install(namespace, slug, version=_clean_text((payload or {}).get("version")))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
@app.get("/api/skills/candidates")
async def list_skill_candidates(request: Request, status: str | None = None) -> list[dict[str, Any]]:
loaded = get_agent_service(request).create_loop().boot()
return [item.to_dict() for item in loaded.skill_learning_pipeline.list_candidates(status=status)] # type: ignore[union-attr]
@app.get("/api/skills/candidates/{candidate_id}")
async def get_skill_candidate(candidate_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
try:
return loaded.skill_learning_pipeline.get_candidate(candidate_id).to_dict() # type: ignore[union-attr]
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.post("/api/skills/candidates/{candidate_id}/draft")
async def synthesize_skill_draft(candidate_id: str, request: Request) -> dict[str, Any]:
agent_service = get_agent_service(request)
loaded = agent_service.create_loop().boot()
try:
candidate = loaded.skill_learning_pipeline.get_candidate(candidate_id) # type: ignore[union-attr]
if candidate.draft_skill_name and candidate.draft_id:
try:
return _skill_draft_payload(loaded, candidate.draft_skill_name, candidate.draft_id)
except ValueError:
pass
provider_bundle = agent_service._make_provider_bundle_for_task(loaded, {}) # noqa: SLF001
draft = await loaded.skill_learning_pipeline.synthesize_draft( # type: ignore[union-attr]
candidate_id,
provider_bundle=provider_bundle,
)
loaded.skill_learning_pipeline.check_safety(draft.skill_name, draft.draft_id) # type: ignore[union-attr]
await loaded.skill_learning_pipeline.evaluate_draft( # type: ignore[union-attr]
candidate_id,
draft.skill_name,
draft.draft_id,
provider_bundle=provider_bundle,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return draft.to_dict()
@app.post("/api/skills/candidates/{candidate_id}/regenerate")
async def regenerate_skill_draft(candidate_id: str, request: Request) -> dict[str, Any]:
agent_service = get_agent_service(request)
loaded = agent_service.create_loop().boot()
provider_bundle = agent_service._make_provider_bundle_for_task(loaded, {}) # noqa: SLF001
try:
draft = await loaded.skill_learning_pipeline.regenerate_draft( # type: ignore[union-attr]
candidate_id,
provider_bundle=provider_bundle,
)
loaded.skill_learning_pipeline.check_safety(draft.skill_name, draft.draft_id) # type: ignore[union-attr]
await loaded.skill_learning_pipeline.evaluate_draft( # type: ignore[union-attr]
candidate_id,
draft.skill_name,
draft.draft_id,
provider_bundle=provider_bundle,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return draft.to_dict()
@app.post("/api/skills/learning/run-once")
async def run_skill_learning_once(request: Request) -> dict[str, Any]:
agent_service = get_agent_service(request)
loaded = agent_service.create_loop().boot()
worker = SkillLearningWorker(
pipeline=loaded.skill_learning_pipeline, # type: ignore[arg-type]
provider_bundle_factory=lambda: agent_service._make_provider_bundle_for_task(loaded, {}), # noqa: SLF001
config=SkillLearningWorkerConfig.from_env(),
)
result = await worker.run_once()
return result.to_dict()
@app.get("/api/skills/drafts")
async def list_skill_drafts(request: Request) -> list[dict[str, Any]]:
loaded = get_agent_service(request).create_loop().boot()
return [
_skill_draft_payload(loaded, item.skill_name, item.draft_id)
for item in loaded.skill_learning_pipeline.list_drafts() # type: ignore[union-attr]
]
@app.get("/api/skills/{skill_name}/drafts/{draft_id}")
async def get_skill_draft(skill_name: str, draft_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
try:
return _skill_draft_payload(loaded, skill_name, draft_id, include_reviews=True)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.get("/api/skills/{skill_name}/drafts/{draft_id}/safety")
async def get_skill_draft_safety(skill_name: str, draft_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
report = loaded.skill_learning_pipeline.get_safety_report(skill_name, draft_id) # type: ignore[union-attr]
if report is None:
raise HTTPException(status_code=404, detail="Safety report not found")
return report.to_dict()
@app.post("/api/skills/{skill_name}/drafts/{draft_id}/safety")
async def recheck_skill_draft_safety(skill_name: str, draft_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
try:
report = loaded.skill_learning_pipeline.check_safety(skill_name, draft_id) # type: ignore[union-attr]
except ValueError as exc:
raise _skill_draft_http_error(exc) from exc
return report.to_dict()
@app.get("/api/skills/{skill_name}/drafts/{draft_id}/eval")
async def get_skill_draft_eval(skill_name: str, draft_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
report = loaded.skill_learning_pipeline.get_eval_report(skill_name, draft_id) # type: ignore[union-attr]
if report is None:
raise HTTPException(status_code=404, detail="Eval report not found")
return report.to_dict()
@app.post("/api/skills/{skill_name}/drafts/{draft_id}/submit")
async def submit_skill_draft(skill_name: str, draft_id: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
try:
review = loaded.skill_learning_pipeline.submit_review( # type: ignore[union-attr]
skill_name,
draft_id,
requested_by=str((payload or {}).get("requested_by") or "web"),
notes=str((payload or {}).get("notes") or ""),
)
except ValueError as exc:
raise _skill_draft_http_error(exc) from exc
return review.to_dict()
@app.post("/api/skills/{skill_name}/drafts/{draft_id}/approve")
async def approve_skill_draft(skill_name: str, draft_id: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
try:
review = loaded.skill_learning_pipeline.approve( # type: ignore[union-attr]
skill_name,
draft_id,
reviewer=str((payload or {}).get("reviewer") or "web"),
notes=str((payload or {}).get("notes") or ""),
)
except ValueError as exc:
raise _skill_draft_http_error(exc) from exc
return review.to_dict()
@app.post("/api/skills/{skill_name}/drafts/{draft_id}/reject")
async def reject_skill_draft(skill_name: str, draft_id: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
try:
review = loaded.skill_learning_pipeline.reject( # type: ignore[union-attr]
skill_name,
draft_id,
reviewer=str((payload or {}).get("reviewer") or "web"),
notes=str((payload or {}).get("notes") or ""),
)
except ValueError as exc:
raise _skill_draft_http_error(exc) from exc
return review.to_dict()
@app.post("/api/skills/{skill_name}/drafts/{draft_id}/publish")
async def publish_skill_draft(skill_name: str, draft_id: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
try:
result = loaded.skill_learning_pipeline.publish( # type: ignore[union-attr]
skill_name,
draft_id,
publisher=str((payload or {}).get("publisher") or "web"),
notes=str((payload or {}).get("notes") or ""),
confirm_high_risk=bool((payload or {}).get("confirm_high_risk")),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return result.to_dict()
@app.post("/api/skills/{skill_name}/disable")
async def disable_skill(skill_name: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
try:
spec = loaded.skill_learning_pipeline.disable( # type: ignore[union-attr]
skill_name,
actor=str((payload or {}).get("actor") or "web"),
reason=str((payload or {}).get("reason") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return spec.to_dict()
@app.post("/api/skills/{skill_name}/rollback")
async def rollback_skill(skill_name: str, request: Request, payload: dict[str, Any]) -> dict[str, Any]:
target_version = str(payload.get("target_version") or "").strip()
if not target_version:
raise HTTPException(status_code=400, detail="target_version is required")
loaded = get_agent_service(request).create_loop().boot()
try:
spec = loaded.skill_learning_pipeline.rollback( # type: ignore[union-attr]
skill_name,
target_version,
actor=str(payload.get("actor") or "web"),
reason=str(payload.get("reason") or ""),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return spec.to_dict()
@app.get("/api/notifications")
async def list_notifications(request: Request) -> list[dict[str, Any]]:
cron_service = get_cron_service(request)
return [
_notification_summary(job, run)
for job, run in cron_service.list_runs()
if run.mode == "notification" or run.notification_session_id == NOTIFICATION_SESSION_ID
]
@app.get("/api/notifications/{scheduled_run_id}")
async def get_notification(scheduled_run_id: str, request: Request) -> dict[str, Any]:
cron_service = get_cron_service(request)
found = cron_service.get_run(scheduled_run_id)
if found is None:
raise HTTPException(status_code=404, detail="Notification not found")
job, run = found
loaded = get_agent_service(request).create_loop().boot()
session_id = run.notification_session_id or NOTIFICATION_SESSION_ID
session = loaded.session_manager.get_or_create(session_id, source="notification", title="通知") # type: ignore[union-attr]
return {
**_notification_summary(job, run),
"detail": _session_detail(loaded.session_manager, session_id, session), # type: ignore[arg-type]
}
@app.post("/api/notifications/{scheduled_run_id}/engage")
async def engage_notification(scheduled_run_id: str, request: Request, payload: dict[str, Any] | None = None) -> dict[str, Any]:
cron_service = get_cron_service(request)
found = cron_service.get_run(scheduled_run_id)
if found is None:
raise HTTPException(status_code=404, detail="Notification not found")
job, run = found
intent = _scheduled_reply_intent((payload or {}).get("intent"))
task = get_agent_service(request).engage_scheduled_run(job=job, run=run, intent=intent)
cron_service.mark_run_engaged(scheduled_run_id, task_id=task.task_id, intent=intent)
return {"ok": True, "task_id": task.task_id, "intent": intent}
@app.get("/api/cron/jobs")
async def list_cron_jobs(request: Request, include_disabled: bool = True) -> list[dict[str, Any]]:
cron_service = get_cron_service(request)
return [job.to_api_dict() for job in cron_service.list_jobs(include_disabled=include_disabled)]
@app.post("/api/cron/jobs")
async def add_cron_job(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
cron_service = get_cron_service(request)
try:
schedule = schedule_from_api(payload)
job = cron_service.add_job(
name=str(payload.get("name") or "").strip(),
message=str(payload.get("message") or "").strip(),
schedule=schedule,
session_key=str(payload.get("session_key") or "").strip() or None,
payload_kind=str(payload.get("payload_kind") or "agent_turn"),
mode=str(payload.get("mode") or "notification").strip().lower(),
requires_followup=bool(payload.get("requires_followup", False)),
deliver=bool(payload.get("deliver", False)),
channel=str(payload.get("channel") or "").strip() or None,
to=str(payload.get("to") or "").strip() or None,
delete_after_run=bool(payload.get("delete_after_run", False)),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return job.to_api_dict()
@app.delete("/api/cron/jobs/{job_id}")
async def delete_cron_job(job_id: str, request: Request) -> dict[str, Any]:
if not get_cron_service(request).remove_job(job_id):
raise HTTPException(status_code=404, detail="Cron job not found")
return {"ok": True, "id": job_id}
@app.put("/api/cron/jobs/{job_id}/toggle")
async def toggle_cron_job(job_id: str, request: Request, payload: dict[str, Any]) -> dict[str, Any]:
job = get_cron_service(request).update_enabled(job_id, bool(payload.get("enabled", True)))
if job is None:
raise HTTPException(status_code=404, detail="Cron job not found")
return job.to_api_dict()
@app.post("/api/cron/jobs/{job_id}/run")
async def run_cron_job(job_id: str, request: Request) -> dict[str, Any]:
cron_service = get_cron_service(request)
if not await cron_service.run_job(job_id, force=True):
raise HTTPException(status_code=404, detail="Cron job not found")
job = cron_service.get_job(job_id)
return {"ok": True, "id": job_id, "job": job.to_api_dict() if job is not None else None}
@app.get("/api/tasks")
async def list_tasks(request: Request) -> list[dict[str, Any]]:
loaded = get_agent_service(request).create_loop().boot()
task_service = loaded.task_service
if task_service is None:
return []
return [task_service.to_api_dict(task) for task in task_service.list_tasks()]
@app.get("/api/tasks/{task_id}")
async def get_task(task_id: str, request: Request) -> dict[str, Any]:
from beaver.services.process_service import SessionProcessProjector
loaded = get_agent_service(request).create_loop().boot()
task_service = loaded.task_service
if task_service is None:
raise HTTPException(status_code=404, detail="Task service is unavailable")
task = task_service.get_task(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
process_projection = SessionProcessProjector(
loaded.session_manager,
loaded.run_memory_store,
).project(task.session_id)
filtered_process = _filter_task_process_projection(process_projection, task_id)
return {
**task_service.to_api_dict(task),
"events": [event.to_dict() for event in task_service.list_events(task_id)],
"runs": _task_run_views(task, task_service.list_events(task_id), loaded.session_manager, loaded.run_memory_store), # type: ignore[arg-type]
"process_runs": filtered_process["runs"],
"process_events": filtered_process["events"],
"process_artifacts": filtered_process["artifacts"],
}
@app.delete("/api/tasks/{task_id}")
async def delete_task(task_id: str, request: Request) -> dict[str, Any]:
loaded = get_agent_service(request).create_loop().boot()
task_service = loaded.task_service
if task_service is None:
raise HTTPException(status_code=404, detail="Task service is unavailable")
if not task_service.delete_task(task_id):
raise HTTPException(status_code=404, detail="Task not found")
return {"ok": True, "task_id": task_id}
@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")
reply_to_scheduled_run_id = _clean_text(payload.reply_to_scheduled_run_id)
if reply_to_scheduled_run_id:
cron_service = get_cron_service(request)
found = cron_service.get_run(reply_to_scheduled_run_id)
if found is None:
raise HTTPException(status_code=404, detail="Notification not found")
job, run = found
intent = _scheduled_reply_intent(payload.scheduled_reply_intent)
try:
reply_kwargs = {
"job": job,
"run": run,
"intent": intent,
}
if payload.thinking_enabled is not None:
reply_kwargs["thinking_enabled"] = payload.thinking_enabled
result = await agent_service.submit_scheduled_reply(message, **reply_kwargs)
cron_service.mark_run_engaged(reply_to_scheduled_run_id, task_id=str(result.task_id or ""), intent=intent)
if intent == "update_future":
cron_service.update_job_message(job.id, _updated_scheduled_instruction(job.payload.message, message))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) 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,
task_id=result.task_id,
task_status=result.task_status,
evidence_status="recorded" if result.task_id else None,
validation_result=None,
)
fallback_target = _model_dump(payload.fallback_target)
auxiliary_target = _model_dump(payload.auxiliary_target)
embedding_target = _model_dump(payload.embedding_target)
try:
direct_kwargs = {
"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,
}
if payload.thinking_enabled is not None:
direct_kwargs["thinking_enabled"] = payload.thinking_enabled
result = await _run_web_direct(agent_service, message, **direct_kwargs)
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,
task_id=result.task_id,
task_status=result.task_status,
evidence_status="recorded" if result.task_id else None,
validation_result=None,
)
@app.websocket("/ws/{session_id:path}")
async def chat_websocket(websocket: WebSocket, session_id: str) -> None:
"""WebSocket chat adapter.
This is intentionally a thin Web entrypoint: it delegates to
AgentService.submit_direct() and returns the same run/task metadata as
the REST chat endpoint.
"""
await websocket.accept()
agent_service = getattr(websocket.app.state, "agent_service", None)
if not isinstance(agent_service, AgentService):
await websocket.send_json({"type": "error", "error": "AgentService is not ready"})
await websocket.close(code=1011)
return
while True:
try:
payload = await websocket.receive_json()
except WebSocketDisconnect:
break
except ValueError:
await websocket.send_json({"type": "error", "error": "Invalid websocket JSON payload"})
continue
if not isinstance(payload, dict):
await websocket.send_json({"type": "error", "error": "Websocket payload must be a JSON object"})
continue
message_type = (_clean_text(payload.get("type")) or "").lower()
if message_type == "ping":
await websocket.send_json({"type": "pong"})
continue
if message_type != "message":
await websocket.send_json(
{
"type": "error",
"error": f"Unsupported websocket message type: {message_type or '<empty>'}",
}
)
continue
content = _clean_text(payload.get("content"))
if not content:
await websocket.send_json({"type": "error", "error": "'content' is required"})
continue
await websocket.send_json({"type": "status", "status": "thinking"})
try:
reply_to_scheduled_run_id = _clean_text(payload.get("reply_to_scheduled_run_id"))
if reply_to_scheduled_run_id:
cron_service = get_cron_service(websocket)
found = cron_service.get_run(reply_to_scheduled_run_id)
if found is None:
raise ValueError("Notification not found")
job, run = found
intent = _scheduled_reply_intent(payload.get("scheduled_reply_intent"))
reply_kwargs = {
"job": job,
"run": run,
"intent": intent,
}
websocket_thinking_enabled = _bool_or_none(payload.get("thinking_enabled"))
if websocket_thinking_enabled is not None:
reply_kwargs["thinking_enabled"] = websocket_thinking_enabled
result = await agent_service.submit_scheduled_reply(content, **reply_kwargs)
cron_service.mark_run_engaged(reply_to_scheduled_run_id, task_id=str(result.task_id or ""), intent=intent)
if intent == "update_future":
cron_service.update_job_message(job.id, _updated_scheduled_instruction(job.payload.message, content))
else:
direct_kwargs = {
"session_id": session_id,
"source": "websocket",
"user_id": _clean_text(payload.get("user_id")) or None,
"title": _clean_text(payload.get("title")) or None,
"execution_context": _clean_text(payload.get("execution_context")) or None,
"model": _clean_text(payload.get("model")) or None,
"provider_name": _clean_text(payload.get("provider_name")) or None,
"embedding_model": _clean_text(payload.get("embedding_model")) or None,
"max_tool_iterations": _int_or_none(payload.get("max_tool_iterations")),
}
websocket_thinking_enabled = _bool_or_none(payload.get("thinking_enabled"))
if websocket_thinking_enabled is not None:
direct_kwargs["thinking_enabled"] = websocket_thinking_enabled
result = await _run_web_direct(agent_service, content, **direct_kwargs)
except Exception as exc:
await websocket.send_json(
{
"type": "message",
"role": "assistant",
"content": f"Run failed before completion: {exc}",
"session_id": session_id,
"finish_reason": "error",
"tool_iterations": 0,
"metadata": {
"error": str(exc),
"input_metadata": _websocket_input_metadata(payload),
},
}
)
continue
await websocket.send_json(_websocket_message_payload(result, input_payload=payload))
await websocket.send_json(
{
"type": "session_updated",
"session_id": result.session_id,
"source": "websocket",
}
)
@app.post(
"/api/chat/acceptance",
response_model=WebChatAcceptanceResponse,
responses={
400: {"model": WebErrorResponse},
404: {"model": WebErrorResponse},
},
)
async def chat_acceptance(request: Request, payload: WebChatAcceptanceRequest) -> WebChatAcceptanceResponse:
agent_service = get_agent_service(request)
try:
result = await agent_service.submit_acceptance(
session_id=payload.session_id,
run_id=payload.run_id,
acceptance_type=payload.acceptance_type,
comment=payload.comment,
)
except ValueError as exc:
detail = str(exc)
status_code = 404 if "No internal task" in detail else 400
raise HTTPException(status_code=status_code, detail=detail) from exc
return WebChatAcceptanceResponse(**result)
@app.post(
"/api/chat/feedback",
response_model=WebChatFeedbackResponse,
responses={
400: {"model": WebErrorResponse},
404: {"model": WebErrorResponse},
},
)
async def chat_feedback(request: Request, payload: WebChatFeedbackRequest) -> WebChatFeedbackResponse:
agent_service = get_agent_service(request)
try:
result = await agent_service.submit_acceptance(
session_id=payload.session_id,
run_id=payload.run_id,
acceptance_type=payload.feedback_type,
comment=payload.comment,
)
except ValueError as exc:
detail = str(exc)
status_code = 404 if "No internal task" in detail else 400
raise HTTPException(status_code=status_code, detail=detail) from exc
return WebChatFeedbackResponse(**result)
return app
def _session_detail(session_manager: Any, session_id: str, session: dict[str, Any]) -> dict[str, Any]:
messages = []
for event in session_manager.get_messages_as_conversation(session_id):
role = event.get("role")
if role not in {"user", "assistant"}:
continue
content = event.get("content") or ""
comparable_content = str(content).replace("\u200b", "").replace("\u200c", "").replace("\u200d", "").replace("\ufeff", "")
if role == "assistant" and not comparable_content.strip():
continue
content = _sanitize_user_visible_assistant_content(role=role, content=content)
messages.append(
{
"role": role,
"content": content,
"timestamp": _iso_from_timestamp(event.get("timestamp")),
"run_id": event.get("run_id"),
"task_id": event.get("task_id"),
"task_status": event.get("task_status"),
"evidence_status": event.get("evidence_status"),
"acceptance_state": event.get("acceptance_state"),
"feedback_state": event.get("feedback_state"),
"feedback_error": event.get("feedback_error"),
"message_type": event.get("message_type"),
"scheduled_job_id": event.get("scheduled_job_id"),
"scheduled_run_id": event.get("scheduled_run_id"),
"cron_job_name": event.get("cron_job_name"),
}
)
return {
"key": session_id,
"messages": messages,
"created_at": _iso_from_timestamp(session.get("started_at")),
"updated_at": _iso_from_timestamp(session.get("last_active")),
}
async def _run_web_direct(agent_service: AgentService, message: str, **kwargs: Any) -> Any:
if agent_service.is_running:
return await agent_service.submit_direct(message, **kwargs)
return await agent_service.process_direct(message, **kwargs)
def _create_skill_upload_draft(loaded: Any, filename: str, content: bytes) -> dict[str, Any]:
try:
archive = zipfile.ZipFile(io.BytesIO(content), "r")
except zipfile.BadZipFile as exc:
raise ValueError("Invalid zip archive") from exc
with archive:
file_infos = [info for info in archive.infolist() if not info.is_dir()]
if not file_infos:
raise ValueError("Zip archive is empty")
skill_entries = []
for info in file_infos:
parts = Path(info.filename.replace("\\", "/")).parts
if "__MACOSX" in parts or Path(info.filename).name == ".DS_Store":
continue
if info.filename.replace("\\", "/").startswith("/") or any(part in {"", ".", ".."} for part in parts):
raise ValueError(f"Unsafe archive entry: {info.filename}")
if parts[-1] == "SKILL.md":
if len(parts) not in (1, 2):
raise ValueError("SKILL.md must be at root or inside one top-level directory")
skill_entries.append(info.filename)
if not skill_entries:
raise ValueError("Zip must contain SKILL.md")
skill_entry = skill_entries[0]
top = Path(skill_entry).parts[0] if len(Path(skill_entry).parts) == 2 else ""
raw_skill = archive.read(skill_entry).decode("utf-8", errors="replace")
frontmatter, body = parse_frontmatter(raw_skill)
skill_name = str(frontmatter.get("name") or top or Path(filename).stem).strip().replace(" ", "-")
if not skill_name or "/" in skill_name or "\\" in skill_name or skill_name in {".", ".."}:
raise ValueError("Could not determine a safe skill name")
files: list[tuple[str, bytes]] = []
for info in file_infos:
raw = info.filename.replace("\\", "/")
parts = Path(raw).parts
if "__MACOSX" in parts or Path(raw).name == ".DS_Store":
continue
if raw.startswith("/"):
raise ValueError(f"Unsafe archive entry: {info.filename}")
if top and parts and parts[0] != top:
raise ValueError("Zip archive must contain a single top-level skill directory")
rel_parts = parts[1:] if top and parts and parts[0] == top else parts
if not rel_parts or any(part in {"", ".", ".."} for part in rel_parts):
raise ValueError(f"Unsafe archive entry: {info.filename}")
files.append(("/".join(rel_parts), archive.read(info)))
draft = loaded.draft_service.create_new_skill_draft(
skill_name=skill_name,
proposed_content=body,
proposed_frontmatter={
**dict(frontmatter),
"name": skill_name,
"description": frontmatter.get("description") or skill_name,
},
created_by="web-upload",
reason=f"Uploaded {filename}",
evidence_refs=[{"kind": "upload", "filename": filename, "files": sorted(path for path, _ in files)}],
)
upload_dir = loaded.skill_spec_store.root / skill_name / "draft_uploads" / draft.draft_id
upload_dir.mkdir(parents=True, exist_ok=True)
for rel_path, file_bytes in files:
if rel_path == "SKILL.md":
continue
target = upload_dir / rel_path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(file_bytes)
draft.evidence_refs = [
{
"kind": "upload",
"filename": filename,
"files": sorted(path for path, _ in files),
"supporting_upload_dir": str(upload_dir),
}
]
loaded.skill_spec_store.write_draft(draft)
return draft.to_dict()
def _debug_runs_for_session(session_manager: Any, session_id: str) -> list[dict[str, Any]]:
grouped: dict[str, list[Any]] = {}
run_order: list[str] = []
for record in session_manager.get_event_records(session_id):
if not record.run_id:
continue
if record.run_id not in grouped:
grouped[record.run_id] = []
run_order.append(record.run_id)
grouped[record.run_id].append(record)
runs: list[dict[str, Any]] = []
for run_id in run_order:
records = grouped[run_id]
started = next((item for item in records if item.event_type == "run_started"), None)
completed = next(
(item for item in reversed(records) if item.event_type in {"run_completed", "run_failed"}),
None,
)
user_event = next((item for item in records if item.event_type == "user_message_added"), None)
intent_event = next((item for item in records if item.event_type == "intent_agent_decision_snapshotted"), None)
task_id = None
attempt_index = None
task_mode = None
intent_decision = intent_event.event_payload if intent_event is not None else None
source = None
title = None
if started is not None and isinstance(started.event_payload, dict):
task_id = started.event_payload.get("task_id")
attempt_index = started.event_payload.get("attempt_index")
task_mode = started.event_payload.get("task_mode")
source = started.event_payload.get("source")
if intent_decision is None:
intent_decision = started.event_payload.get("intent_agent_decision")
if started is not None:
title = getattr(started, "title", None)
if title is None:
title = source or "run"
runs.append(
{
"run_id": run_id,
"session_id": session_id,
"title": title,
"source": source,
"task_id": task_id,
"attempt_index": attempt_index,
"task_mode": task_mode,
"intent_agent_choice": intent_decision.get("choice") if isinstance(intent_decision, dict) else None,
"intent_agent_decision": intent_decision if isinstance(intent_decision, dict) else None,
"user_input": user_event.content if user_event is not None else "",
"started_at": _iso_from_timestamp(started.timestamp if started is not None else None),
"ended_at": _iso_from_timestamp(completed.timestamp) if completed is not None else None,
"finish_reason": completed.finish_reason if completed is not None else None,
"events": [_debug_event_to_dict(item) for item in records],
}
)
return runs
def _debug_event_to_dict(record: Any) -> dict[str, Any]:
return {
"message_id": record.message_id,
"run_id": record.run_id,
"role": record.role,
"event_type": record.event_type,
"content": record.content,
"timestamp": _iso_from_timestamp(record.timestamp),
"context_visible": record.context_visible,
"tool_name": record.tool_name,
"tool_call_id": record.tool_call_id,
"tool_calls": record.tool_calls,
"finish_reason": record.finish_reason,
"reasoning": record.reasoning,
"reasoning_details": record.reasoning_details,
"codex_reasoning_items": record.codex_reasoning_items,
"event_payload": record.event_payload,
}
def _notification_summary(job: Any, run: CronRunRecord) -> dict[str, Any]:
return {
"scheduled_run_id": run.scheduled_run_id,
"job_id": job.id,
"job_name": job.name,
"title": job.name,
"message": job.payload.message,
"status": run.status,
"mode": run.mode,
"started_at_ms": run.started_at_ms,
"finished_at_ms": run.finished_at_ms,
"started_at": _iso_from_ms(run.started_at_ms),
"finished_at": _iso_from_ms(run.finished_at_ms),
"output": run.output,
"error": run.error,
"notification_session_id": run.notification_session_id or NOTIFICATION_SESSION_ID,
"task_id": run.task_id,
"run_id": run.run_id,
"engaged": run.engaged,
"engaged_at_ms": run.engaged_at_ms,
"engage_intent": run.engage_intent,
}
def _task_run_views(task: Any, events: list[Any], session_manager: Any, run_memory_store: Any) -> list[dict[str, Any]]:
run_records = {record.run_id: record for record in run_memory_store.list_runs()}
labels = _agent_labels_for_task_events(events)
views: list[dict[str, Any]] = []
seen: set[str] = set()
for index, run_id in enumerate(task.run_ids):
if run_id in seen:
continue
seen.add(run_id)
run_record = run_records.get(run_id)
session_id = run_record.session_id if run_record is not None else task.session_id
messages = []
for record in session_manager.get_run_event_records(session_id, run_id):
if record.role not in {"user", "assistant", "tool"}:
continue
content = (record.content or "").strip()
if not content:
continue
content = _sanitize_user_visible_assistant_content(role=record.role, content=content)
messages.append(
{
"role": record.role,
"content": content,
"created_at": _iso_from_timestamp(record.timestamp),
"tool_name": record.tool_name,
}
)
views.append(
{
"run_id": run_id,
"title": labels.get(run_id) or ("主 Agent" if index == len(task.run_ids) - 1 else f"Agent {index + 1}"),
"session_id": session_id,
"started_at": run_record.started_at if run_record is not None else None,
"ended_at": run_record.ended_at if run_record is not None else None,
"success": run_record.success if run_record is not None else None,
"finish_reason": run_record.finish_reason if run_record is not None else None,
"attempt_index": run_record.attempt_index if run_record is not None else None,
"task_text": run_record.task_text if run_record is not None else "",
"messages": messages,
"evidence_status": "recorded",
"validation_result": None,
}
)
return views
def _filter_task_process_projection(projection: dict[str, Any], task_id: str) -> dict[str, list[dict[str, Any]]]:
def belongs_to_task(item: dict[str, Any]) -> bool:
metadata = item.get("metadata")
return isinstance(metadata, dict) and metadata.get("task_id") == task_id
def with_task_metadata(item: dict[str, Any]) -> dict[str, Any]:
copied = dict(item)
metadata = dict(copied.get("metadata") or {})
metadata.setdefault("task_id", task_id)
copied["metadata"] = metadata
return copied
runs = [with_task_metadata(item) for item in projection.get("runs", []) if isinstance(item, dict) and belongs_to_task(item)]
run_ids = {str(item.get("run_id")) for item in runs if item.get("run_id")}
events = [
with_task_metadata(item)
for item in projection.get("events", [])
if isinstance(item, dict) and (belongs_to_task(item) or str(item.get("run_id")) in run_ids)
]
artifacts = [
with_task_metadata(item)
for item in projection.get("artifacts", [])
if isinstance(item, dict) and (belongs_to_task(item) or str(item.get("run_id")) in run_ids)
]
return {"runs": runs, "events": events, "artifacts": artifacts}
def _agent_labels_for_task_events(events: list[Any]) -> dict[str, str]:
labels: dict[str, str] = {}
for event in events:
payload = dict(getattr(event, "payload", {}) or {})
for item in payload.get("node_results") or []:
if not isinstance(item, dict):
continue
run_id = str(item.get("run_id") or "")
node_id = str(item.get("node_id") or "").strip()
if run_id and node_id:
labels[run_id] = node_id
main_run_id = str(payload.get("main_run_id") or "")
if main_run_id:
labels[main_run_id] = "主 Agent"
return labels
def _scheduled_reply_intent(value: Any) -> str:
cleaned = str(value or "").strip().lower()
if cleaned == "update_future":
return "update_future"
if cleaned == "continue_task":
return "continue_task"
return "revise_once"
def _updated_scheduled_instruction(current: str, request: str) -> str:
cleaned_current = " ".join((current or "").strip().split())
cleaned_request = " ".join((request or "").strip().split())
if not cleaned_request:
return cleaned_current
return f"{cleaned_current}\n\n以后执行时请遵循:{cleaned_request}"
def _iso_from_ms(value: Any) -> str | None:
if value in (None, ""):
return None
try:
return _iso_from_timestamp(float(value) / 1000)
except (TypeError, ValueError):
return None
def _iso_from_timestamp(value: Any) -> str:
from datetime import datetime, timezone
if value in (None, ""):
return datetime.now(timezone.utc).isoformat()
try:
return datetime.fromtimestamp(float(value), tz=timezone.utc).isoformat()
except (TypeError, ValueError):
return str(value)
def _registered_agent_to_ui(agent: Any) -> dict[str, Any]:
metadata = dict(agent.metadata or {})
source = agent.source if agent.source in {"workspace", "skill", "builtin"} else "workspace"
aliases = metadata.get("aliases")
return {
"id": agent.agent_id,
"name": agent.display_name or agent.name,
"description": agent.description,
"source": source,
"kind": metadata.get("kind") or ("local_subagent" if metadata.get("local_subagent") else "specialist"),
"protocol": metadata.get("protocol"),
"endpoint": metadata.get("endpoint"),
"base_url": metadata.get("base_url"),
"card_url": metadata.get("card_url"),
"auth_env": metadata.get("auth_env"),
"auth_mode": metadata.get("auth_mode") or "none",
"auth_audience": metadata.get("auth_audience"),
"auth_scopes": _coerce_str_list(metadata.get("auth_scopes")),
"tags": list(agent.tags),
"aliases": _coerce_str_list(aliases) or [agent.name],
"metadata": {
**metadata,
"role": agent.role,
"capabilities": list(agent.capabilities),
"skill_names": list(agent.skill_names),
"tool_hints": list(agent.tool_hints),
"priority": agent.priority,
"status": agent.status,
},
"support_streaming": bool(metadata.get("support_streaming", False)),
}
def _agent_payload_from_ui(payload: dict[str, Any]) -> dict[str, Any]:
metadata = dict(payload.get("metadata") or {})
for key in (
"protocol",
"endpoint",
"base_url",
"card_url",
"auth_env",
"auth_mode",
"auth_audience",
"auth_scopes",
"aliases",
"support_streaming",
):
if key in payload:
metadata[key] = payload.get(key)
if metadata.get("protocol") == "a2a" or metadata.get("base_url") or metadata.get("endpoint") or metadata.get("card_url"):
metadata.setdefault("kind", "a2a_remote")
capabilities = payload.get("capabilities")
if capabilities is None and isinstance(metadata.get("capabilities"), list):
capabilities = metadata.get("capabilities")
role = payload.get("role") or metadata.get("role") or payload.get("kind") or ""
agent_id = payload.get("agent_id") or payload.get("id") or payload.get("name") or _derive_agent_id_from_metadata(metadata)
return {
"agent_id": agent_id,
"name": payload.get("name") or payload.get("id") or agent_id,
"display_name": payload.get("display_name") or payload.get("name") or payload.get("id") or agent_id,
"role": role,
"description": payload.get("description") or "",
"system_prompt": payload.get("system_prompt") or metadata.get("system_prompt") or "",
"capabilities": capabilities or [],
"skill_names": payload.get("skill_names") or metadata.get("skill_names") or [],
"tool_hints": payload.get("tool_hints") or metadata.get("tool_hints") or [],
"model": payload.get("model") or metadata.get("model"),
"provider_name": payload.get("provider_name") or metadata.get("provider_name"),
"tags": payload.get("tags") or [],
"priority": payload.get("priority") or metadata.get("priority") or 0,
"status": payload.get("status") or ("active" if payload.get("enabled", True) else "disabled"),
"source": payload.get("source") or "workspace",
"metadata": metadata,
}
def _derive_agent_id_from_metadata(metadata: dict[str, Any]) -> str:
raw = metadata.get("base_url") or metadata.get("endpoint") or metadata.get("card_url") or "workspace-agent"
text = str(raw).strip().lower()
for prefix in ("https://", "http://"):
if text.startswith(prefix):
text = text[len(prefix):]
text = text.split("/", 1)[0] or text
normalized = "".join(ch if ch.isalnum() else "-" for ch in text).strip("-")
while "--" in normalized:
normalized = normalized.replace("--", "-")
return normalized or "workspace-agent"
def _mcp_server_view(server_id: str, cfg: Any, report: dict[str, Any]) -> dict[str, Any]:
transport = "stdio" if getattr(cfg, "command", "") else "http"
tool_names = list(report.get("tool_names") or [])
return {
"id": server_id,
"name": getattr(cfg, "display_name", "") or server_id,
"transport": transport,
"kind": getattr(cfg, "kind", "local" if transport == "stdio" else "online"),
"category": getattr(cfg, "category", "local" if transport == "stdio" else "online"),
"managed": bool(getattr(cfg, "managed", False)),
"source": getattr(cfg, "source", "config"),
"url": getattr(cfg, "url", "") or None,
"command": getattr(cfg, "command", "") or None,
"args": list(getattr(cfg, "args", []) or []),
"env": _redact_mapping(dict(getattr(cfg, "env", {}) or {})),
"headers": _redact_mapping(dict(getattr(cfg, "headers", {}) or {})),
"auth_mode": getattr(cfg, "auth_mode", "none") or "none",
"auth_audience": getattr(cfg, "auth_audience", "") or None,
"auth_scopes": list(getattr(cfg, "auth_scopes", []) or []),
"enabled": True,
"tool_timeout": getattr(cfg, "tool_timeout", 30),
"tool_count": int(report.get("tool_count") or len(tool_names)),
"tool_names": tool_names,
"status": report.get("status") or "disconnected",
"last_error": report.get("last_error"),
"sensitive": bool(getattr(cfg, "sensitive", False)),
}
def _mcp_config_payload(payload: dict[str, Any], server_id: str) -> dict[str, Any]:
command = _clean_text(payload.get("command")) or ""
url = _clean_text(payload.get("url")) or ""
auth_mode = (_clean_text(payload.get("auth_mode") or payload.get("authMode")) or "none").lower()
auth_audience = _clean_text(payload.get("auth_audience") or payload.get("authAudience")) or ""
if auth_mode == "oauth_backend_token" and not auth_audience:
auth_audience = f"mcp:{server_id}"
return {
"command": command,
"args": _coerce_str_list(payload.get("args")),
"env": _coerce_str_dict(payload.get("env")),
"url": url,
"headers": _coerce_str_dict(payload.get("headers")),
"authMode": auth_mode,
"authAudience": auth_audience,
"authScopes": _coerce_str_list(payload.get("auth_scopes") or payload.get("authScopes")),
"toolTimeout": int(payload.get("tool_timeout") or payload.get("toolTimeout") or 30),
"sensitive": bool(payload.get("sensitive", False)),
"kind": _clean_text(payload.get("kind")) or ("local" if command else "online"),
"category": _clean_text(payload.get("category")) or ("local" if command else "online"),
"managed": bool(payload.get("managed", False)),
"displayName": _clean_text(payload.get("display_name") or payload.get("displayName")) or server_id,
"source": _clean_text(payload.get("source")) or "config",
}
def _coerce_str_list(value: Any) -> list[str]:
if not isinstance(value, list):
return []
return [str(item) for item in value if str(item).strip()]
def _coerce_str_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 _redact_mapping(value: dict[str, str]) -> dict[str, str]:
redacted = {}
for key, item in value.items():
if any(token in key.lower() for token in ("key", "token", "secret", "authorization")):
redacted[key] = _mask_secret(item)
else:
redacted[key] = item
return redacted
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)
def _websocket_input_metadata(payload: dict[str, Any]) -> dict[str, Any]:
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
result: dict[str, Any] = dict(metadata)
attachments = payload.get("attachments")
if isinstance(attachments, list):
result["attachments"] = attachments
return result
def _bool_or_none(value: Any) -> bool | None:
if isinstance(value, bool):
return value
if value is None:
return None
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
return None
def _int_or_none(value: Any) -> int | None:
if value in (None, ""):
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _websocket_message_payload(result: Any, *, input_payload: dict[str, Any]) -> dict[str, Any]:
task_id = getattr(result, "task_id", None)
task_status = getattr(result, "task_status", None)
return {
"type": "message",
"role": "assistant",
"content": _sanitize_user_visible_assistant_content(
role="assistant",
content=getattr(result, "output_text", "") or "",
),
"session_id": getattr(result, "session_id", None),
"run_id": getattr(result, "run_id", None),
"finish_reason": getattr(result, "finish_reason", None),
"tool_iterations": getattr(result, "tool_iterations", 0),
"provider_name": getattr(result, "provider_name", None),
"model": getattr(result, "model", None),
"usage": dict(getattr(result, "usage", {}) or {}),
"task_id": task_id,
"task_status": task_status,
"evidence_status": "recorded" if task_id else None,
"validation_result": None,
"metadata": {
"task_id": task_id,
"task_status": task_status,
"evidence_status": "recorded" if task_id else None,
"input_metadata": _websocket_input_metadata(input_payload),
},
}
def _sanitize_user_visible_assistant_content(*, role: str, content: str) -> str:
if role != "assistant":
return content
if _looks_like_raw_tool_call(content):
return RAW_TOOL_CALL_DISPLAY_FALLBACK
return content
def _looks_like_raw_tool_call(content: str | None) -> bool:
if not content:
return False
stripped = content.strip()
lowered = stripped.lower()
return (
lowered.startswith("<tool_call")
and lowered.endswith("</tool_call>")
) or (
lowered.startswith("<function=")
and lowered.endswith("</function>")
)
def _provider_enabled(provider_name: str, provider_cfg: Any) -> bool:
if provider_cfg is None or provider_name == "custom":
return False
return any(
[
_clean_text(getattr(provider_cfg, "api_key", None)),
_clean_text(getattr(provider_cfg, "api_base", None)),
bool(getattr(provider_cfg, "extra_headers", None)),
]
)
def _auth_file_path() -> Path:
raw = os.getenv("BEAVER_AUTH_FILE")
if raw:
return Path(raw)
return Path.home() / ".beaver" / "web_auth_users.json"
def _load_auth_users(path: Path) -> dict[str, str]:
if not path.exists():
raise HTTPException(status_code=500, detail=f"Auth file not found: {path}")
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise HTTPException(status_code=500, detail=f"Invalid auth file: {path}") from exc
users: dict[str, str] = {}
if isinstance(raw, dict):
entries = raw.get("users") or raw.get("accounts")
if isinstance(entries, list):
for entry in entries:
if not isinstance(entry, dict):
continue
username = _clean_text(entry.get("username"))
password = entry.get("password")
if username and isinstance(password, str):
users[username] = password
for key, value in raw.items():
if key in {"users", "accounts"}:
continue
username = _clean_text(key)
if username and isinstance(value, str):
users[username] = value
if not users:
raise HTTPException(status_code=500, detail=f"No valid users found in auth file: {path}")
return users
def _load_auth_users_if_present(path: Path) -> dict[str, str]:
if not path.exists():
return {}
return _load_auth_users(path)
def _save_auth_users(path: Path, users: dict[str, str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"users": [
{"username": username, "password": password}
for username, password in sorted(users.items())
]
}
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def _issue_web_token(app: FastAPI, username: str) -> str:
token = secrets.token_urlsafe(32)
app.state.auth_tokens[token] = username
return token
def _handoff_ttl_seconds() -> int:
raw = os.getenv("BEAVER_HANDOFF_CODE_TTL_SECONDS", "90").strip()
try:
return max(15, int(raw))
except ValueError:
return 90
def _handoff_replay_window_seconds() -> int:
raw = os.getenv("BEAVER_HANDOFF_REPLAY_WINDOW_SECONDS", "15").strip()
try:
return max(1, int(raw))
except ValueError:
return 15
def _prune_handoff_codes(app: FastAPI) -> None:
now = time.time()
replay_window = _handoff_replay_window_seconds()
expired = []
for code, payload in list(app.state.handoff_codes.items()):
expires_at = float(payload.get("expires_at") or 0)
consumed_at = payload.get("consumed_at")
if expires_at <= now:
expired.append(code)
elif consumed_at is not None and now - float(consumed_at) > replay_window:
expired.append(code)
for code in expired:
app.state.handoff_codes.pop(code, None)
def _issue_handoff_code(app: FastAPI, username: str, access_token: str, refresh_token: str = "") -> tuple[str, int]:
_prune_handoff_codes(app)
code = secrets.token_urlsafe(24)
expires_at = int(time.time()) + _handoff_ttl_seconds()
app.state.handoff_codes[code] = {
"username": username,
"access_token": access_token,
"refresh_token": refresh_token,
"expires_at": expires_at,
"consumed_at": None,
}
return code, expires_at
def _consume_handoff_code(app: FastAPI, code: str) -> dict[str, Any]:
if not code.strip():
raise HTTPException(status_code=400, detail="Handoff code is required")
_prune_handoff_codes(app)
payload = app.state.handoff_codes.get(code)
if payload is None:
raise HTTPException(status_code=401, detail="Invalid or expired handoff code")
now = time.time()
expires_at = float(payload.get("expires_at") or 0)
if expires_at <= now:
app.state.handoff_codes.pop(code, None)
raise HTTPException(status_code=410, detail="Handoff code expired")
consumed_at = payload.get("consumed_at")
if consumed_at is None:
payload["consumed_at"] = now
elif now - float(consumed_at) > _handoff_replay_window_seconds():
app.state.handoff_codes.pop(code, None)
raise HTTPException(status_code=410, detail="Handoff code already used")
username = str(payload.get("username") or "").strip()
access_token = str(payload.get("access_token") or "").strip()
if not username or not access_token:
app.state.handoff_codes.pop(code, None)
raise HTTPException(status_code=401, detail="Invalid handoff payload")
return {
"access_token": access_token,
"refresh_token": str(payload.get("refresh_token") or ""),
"token_type": "bearer",
"user_id": username,
"username": username,
"role": "owner",
}
def _require_web_user(app: FastAPI, authorization: str | None) -> str:
if not authorization:
raise HTTPException(status_code=401, detail="Missing Authorization header")
prefix = "bearer "
if not authorization.lower().startswith(prefix):
raise HTTPException(status_code=401, detail="Invalid Authorization header")
token = authorization[len(prefix):].strip()
if not token:
raise HTTPException(status_code=401, detail="Invalid token")
username = app.state.auth_tokens.get(token)
if not username:
raise HTTPException(status_code=401, detail="Invalid or expired token")
return username
def _backend_connection_view(request: Request) -> dict[str, Any]:
public_base_url = (
os.getenv("BEAVER_BACKEND_IDENTITY__PUBLIC_BASE_URL")
or os.getenv("BEAVER_FRONTEND_PUBLIC_BASE_URL")
or str(request.base_url).rstrip("/")
)
backend_id = (
os.getenv("BEAVER_BACKEND_IDENTITY__BACKEND_ID")
or os.getenv("BEAVER_BACKEND_IDENTITY__CLIENT_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 backend_id,
"public_base_url": public_base_url,
"api_base_url": public_base_url,
"frontend_base_url": public_base_url,
"ws_base_url": public_base_url.replace("http://", "ws://").replace("https://", "wss://", 1),
"registered": bool(backend_id),
}
def _local_backend_view() -> dict[str, Any]:
return {
"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("BEAVER_FRONTEND_PUBLIC_BASE_URL"),
"authz": {
"enabled": os.getenv("BEAVER_AUTHZ__ENABLED", "").strip() in {"1", "true", "True"},
"base_url": os.getenv("BEAVER_AUTHZ__BASE_URL"),
},
}
def _clean_text(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _skill_detail_payload(loaded: Any, name: str, version: str | None) -> dict[str, Any]:
record = loaded.skills_loader.get_skill_record(name) # type: ignore[union-attr]
if record is None:
raise HTTPException(status_code=404, detail="Skill not found")
selected_version = version or record.version or "legacy"
loaded_version = loaded.skill_spec_store.read_published_skill(name, selected_version) # type: ignore[union-attr]
if loaded_version is not None:
content = loaded_version.content
frontmatter = dict(loaded_version.version.frontmatter)
version_detail = loaded_version.version.to_dict()
else:
if record.source == "workspace" and selected_version != record.version:
raise HTTPException(status_code=404, detail="Skill version not found")
content = record.path.read_text(encoding="utf-8")
frontmatter, _ = parse_frontmatter(content)
version_detail = {
"skill_name": name,
"version": record.version or selected_version,
"review_state": record.status,
"frontmatter": dict(frontmatter),
"summary": record.description,
"tool_hints": list(record.tool_hints),
"provenance": {"source": record.source},
}
spec = loaded.skill_spec_store.get_skill_spec(name) # type: ignore[union-attr]
base_dir = _skill_version_base_dir(loaded, record, selected_version)
files = _list_skill_files(base_dir)
versions = _skill_versions_payload(loaded, record)
return {
"skill": {
"name": record.name,
"description": record.description,
"source": "builtin" if record.source == "builtin" else "workspace",
"available": loaded.skills_loader._record_available(record), # type: ignore[union-attr]
"path": str(record.path),
"version": record.version,
"status": record.status,
"source_kind": record.source_kind,
"tool_hints": list(record.tool_hints),
"provenance": version_detail.get("provenance") or {},
"agent_cards": [],
},
"spec": spec.to_dict() if spec is not None else None,
"currentVersion": selected_version,
"versions": versions,
"versionDetail": version_detail,
"files": files,
"content": content,
"frontmatter": frontmatter,
}
def _skill_draft_payload(loaded: Any, skill_name: str, draft_id: str, *, include_reviews: bool = False) -> dict[str, Any]:
draft = loaded.skill_learning_pipeline.get_draft(skill_name, draft_id) # type: ignore[union-attr]
safety = loaded.skill_learning_pipeline.get_safety_report(skill_name, draft_id) # type: ignore[union-attr]
eval_report = loaded.skill_learning_pipeline.get_eval_report(skill_name, draft_id) # type: ignore[union-attr]
payload = {
**draft.to_dict(),
"safety_report": safety.to_dict() if safety is not None else None,
"eval_report": eval_report.to_dict() if eval_report is not None else None,
}
if include_reviews:
payload["reviews"] = [
item.to_dict()
for item in loaded.skill_learning_pipeline.reviews_for_draft(skill_name, draft_id) # type: ignore[union-attr]
]
return payload
def _skill_versions_payload(loaded: Any, record: Any) -> list[dict[str, Any]]:
if record.source != "workspace":
return [
{
"version": record.version or "legacy",
"status": record.status,
"createdAt": None,
"publishedAt": None,
}
]
result: list[dict[str, Any]] = []
for version in loaded.skill_spec_store.list_versions(record.name): # type: ignore[union-attr]
loaded_version = loaded.skill_spec_store.read_published_skill(record.name, version) # type: ignore[union-attr]
if loaded_version is None:
continue
result.append(
{
"version": loaded_version.version.version,
"status": loaded_version.version.review_state,
"createdAt": loaded_version.version.created_at,
"publishedAt": loaded_version.version.created_at,
"changeReason": loaded_version.version.change_reason,
"parentVersion": loaded_version.version.parent_version,
"contentHash": loaded_version.version.content_hash,
}
)
if not result:
result.append(
{
"version": record.version or "legacy",
"status": record.status,
"createdAt": None,
"publishedAt": None,
}
)
return result
def _skill_version_base_dir(loaded: Any, record: Any, version: str) -> Path:
if record.source != "workspace" or version == "legacy":
if record.source == "workspace":
legacy_dir = loaded.skill_spec_store.root / record.name # type: ignore[union-attr]
if (legacy_dir / "SKILL.md").exists():
return legacy_dir
return record.path.parent
loaded_version = loaded.skill_spec_store.read_published_skill(record.name, version) # type: ignore[union-attr]
if loaded_version is None:
raise HTTPException(status_code=404, detail="Skill version not found")
return loaded.skill_spec_store.root / record.name / "versions" / version # type: ignore[union-attr]
def _list_skill_files(base_dir: Path) -> list[dict[str, Any]]:
if not base_dir.exists():
return []
ignored_dirs = {"drafts", "reviews", "archive", "versions", "__pycache__"}
ignored_files = {"version.json", "skill.json", "current.json"}
files: list[dict[str, Any]] = []
for file_path in sorted(base_dir.rglob("*")):
if not file_path.is_file() or file_path.is_symlink():
continue
rel_path = file_path.relative_to(base_dir).as_posix()
parts = set(file_path.relative_to(base_dir).parts)
if parts & ignored_dirs or file_path.name in ignored_files:
continue
stat = file_path.stat()
files.append(
{
"filePath": rel_path,
"fileSize": stat.st_size,
"contentType": _content_type_for_path(rel_path),
"sha256": None,
}
)
return files
def _skill_file_content_payload(base_dir: Path, file_path: Path) -> dict[str, Any]:
rel_path = file_path.relative_to(base_dir).as_posix()
raw = file_path.read_bytes()
is_binary = _is_probably_binary(raw)
content = None if is_binary else raw.decode("utf-8", errors="replace")
return {
"filePath": rel_path,
"fileSize": len(raw),
"contentType": _content_type_for_path(rel_path),
"isBinary": is_binary,
"content": content,
}
def _safe_child_path(base_dir: Path, rel_path: str) -> Path:
cleaned = rel_path.replace("\\", "/").lstrip("/")
if not cleaned or cleaned in {".", ".."}:
raise HTTPException(status_code=400, detail="Invalid file path")
base_resolved = base_dir.resolve()
target = (base_dir / cleaned).resolve()
if target != base_resolved and base_resolved not in target.parents:
raise HTTPException(status_code=400, detail="Invalid file path")
return target
def _content_type_for_path(path: str) -> str:
lower = path.lower()
if lower.endswith(".md"):
return "text/markdown"
if lower.endswith(".json"):
return "application/json"
if lower.endswith((".yaml", ".yml", ".toml", ".txt", ".csv", ".log")):
return "text/plain"
if lower.endswith((".py", ".ts", ".tsx", ".js", ".jsx", ".css", ".html", ".sh")):
return "text/plain"
if lower.endswith((".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg")):
return f"image/{lower.rsplit('.', 1)[-1].replace('jpg', 'jpeg')}"
return "application/octet-stream"
def _is_probably_binary(raw: bytes) -> bool:
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 _skill_draft_http_error(exc: ValueError) -> HTTPException:
detail = str(exc)
status_code = 404 if detail.startswith("Draft not found:") else 400
return HTTPException(status_code=status_code, detail=detail)
def _mask_secret(value: str | None) -> str:
secret = _clean_text(value)
if not secret:
return ""
if len(secret) <= 8:
return "••••"
return f"{secret[:4]}••••{secret[-4:]}"
def _read_config_json(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(f"Config must be a JSON object: {path}")
return data
def _ensure_dict(parent: dict[str, Any], key: str) -> dict[str, Any]:
value = parent.get(key)
if not isinstance(value, dict):
value = {}
parent[key] = value
return value
def _save_backend_identity(
agent_service: AgentService,
*,
config_path: Path,
backend_id: str,
client_id: str,
client_secret: str,
name: str,
public_base_url: str,
authz_base_url: str,
) -> dict[str, Any]:
raw = _read_config_json(config_path)
authz = _ensure_dict(raw, "authz")
authz["enabled"] = True
authz["baseUrl"] = authz_base_url
identity = _ensure_dict(raw, "backend_identity")
identity["backendId"] = backend_id
identity["clientId"] = client_id
identity["clientSecret"] = client_secret
identity["name"] = name
identity["publicBaseUrl"] = public_base_url
_write_config_json(config_path, raw)
_reload_agent_config(agent_service, config_path)
return {
"backend_id": backend_id,
"client_id": client_id,
"name": name,
"public_base_url": public_base_url,
"authz": {
"enabled": True,
"base_url": authz_base_url,
},
}
def _write_config_json(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_name(f"{path.name}.tmp")
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(path)
def _reload_agent_config(agent_service: AgentService, config_path: Path) -> None:
config = load_config(config_path=config_path)
agent_service.loader.config = config
agent_service._apply_configured_profile_defaults() # noqa: SLF001
loop = getattr(agent_service, "_loop", None)
loaded = getattr(loop, "loaded", None) if loop is not None else None
if loaded is not None:
old_manager = getattr(loaded, "mcp_manager", None)
if old_manager is not None:
async def _close_old_manager() -> None:
await old_manager.close()
try:
running_loop = asyncio.get_running_loop()
except RuntimeError:
asyncio.run(_close_old_manager())
else:
running_loop.create_task(_close_old_manager())
loaded.config = config
loaded.mcp_manager = MCPConnectionManager(
config.tools.mcp_servers,
authz_config=config.authz,
backend_identity=config.backend_identity,
)
loaded.mcp_report = {}