"""FastAPI app factory for Beaver.""" from __future__ import annotations import json import asyncio import io import logging import mimetypes import os import re 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.events import ChannelIdentity, InboundMessage from beaver.memory.gateway import ( MemoryGatewayClient, MemoryGatewayClientError, MemoryGatewayCredentialStore, MemoryGatewayUserCredential, default_memory_gateway_users_path, ) from beaver.interfaces.channels.runtime import ChannelRuntime from beaver.interfaces.channels.connections import ( ChannelConnectionStore, ChannelConnectorRegistry, ConnectorSidecarClient, CredentialStore, FeishuConnector, MessageDedupeStore, TelegramConnector, WeixinConnector, ) 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.services.user_files import ( USER_FILE_ROOTS, UserFileError, UserFileNotFoundError, UserFilePathError, UserFileSizeError, UserFileService, ) from beaver.services.user_file_resolver import ( UserFileConfigurationError, UserFileStorageResolver, build_file_auth_context, ) from beaver.skills.authoring import canonical_skill_format_instructions, ensure_canonical_skill_body, normalize_skill_frontmatter from beaver.skills.authoring.format import parse_skill_rewrite_json from beaver.skills.learning import SkillLearningService, SkillLearningWorker, SkillLearningWorkerConfig from beaver.skills.learning.replay import ReplayRunner from beaver.skills.catalog.utils import extract_required_tool_names, 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, WebAgentConfigRequest, WebAgentConfigResponse, WebChannelConfigRequest, WebChannelConfigResponse, WebChannelConnectionCreateRequest, WebChannelConnectionResponse, WebChannelConnectionUpdateRequest, WebChannelValidationResponse, WebConnectorBridgeEventRequest, WebConnectorBridgeEventResponse, WebConnectorSessionCreateRequest, WebConnectorSessionResponse, WebProviderConfigRequest, WebProviderConfigResponse, WebStatusResponse, ) logger = logging.getLogger(__name__) try: from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, Response except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments CORSMiddleware = None # type: ignore[assignment] 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 JSONResponse(Response): # type: ignore[override] def __init__(self, content: Any, status_code: int = 200) -> None: super().__init__(json.dumps(content).encode("utf-8"), media_type="application/json") self.status_code = status_code 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 app.state.channel_runtime = None started = False channel_runtime: ChannelRuntime | None = None 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 try: loaded = attached_service.create_loop().boot() app.state.channel_connection_workspace = loaded.workspace connector_registry = _build_channel_connector_registry(loaded.workspace) app.state.channel_connector_registry = connector_registry connection_channels = await connector_registry.materialize_channel_configs() runtime_channels = dict(loaded.config.channels) runtime_channels.update(connection_channels) channel_runtime = ChannelRuntime( service=attached_service, workspace=loaded.workspace, channels=runtime_channels, ) app.state.channel_runtime = channel_runtime await channel_runtime.start() except BaseException: if owns_service and started: with suppress(BaseException): await attached_service.shutdown( timeout_seconds=shutdown_timeout_seconds, force=shutdown_force, ) 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 replay_runner_factory=lambda: ReplayRunner(agent_loop=attached_service.create_loop()), 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: runtime = getattr(app.state, "channel_runtime", None) if isinstance(runtime, ChannelRuntime): with suppress(BaseException): await runtime.stop() 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 get_channel_runtime(request: Request) -> ChannelRuntime: runtime = getattr(request.app.state, "channel_runtime", None) if not isinstance(runtime, ChannelRuntime): raise HTTPException(status_code=503, detail="Channel runtime is not running") return runtime def _connection_state_dir(workspace: Path) -> Path: return Path(workspace) / "state" / "channel_connections" def _channel_connection_workspace(request: Request) -> Path: workspace = getattr(request.app.state, "channel_connection_workspace", None) if workspace is not None: return Path(workspace) return Path(get_agent_service(request).loader.workspace) def _message_dedupe_store(workspace: Path) -> MessageDedupeStore: return MessageDedupeStore(_connection_state_dir(workspace) / "message_dedupe.json") def _bridge_token() -> str: return os.getenv("BEAVER_BRIDGE_TOKEN", "") def _build_channel_connector_registry(workspace: Path) -> ChannelConnectorRegistry: state_dir = _connection_state_dir(workspace) connection_store = ChannelConnectionStore(state_dir / "connections.json") credential_store = CredentialStore(state_dir / "credentials.json") registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store) registry.register( TelegramConnector( connection_store=connection_store, credential_store=credential_store, ) ) sidecar_base_url = os.getenv("EXTERNAL_CONNECTOR_BASE_URL", "http://external-connector:8787") sidecar_token = os.getenv("EXTERNAL_CONNECTOR_TOKEN", "") sidecar_client = ConnectorSidecarClient(base_url=sidecar_base_url, token=sidecar_token) registry.register( WeixinConnector( connection_store=connection_store, credential_store=credential_store, sidecar_client=sidecar_client, sidecar_base_url=sidecar_base_url, ) ) registry.register( FeishuConnector( connection_store=connection_store, credential_store=credential_store, sidecar_client=sidecar_client, sidecar_base_url=sidecar_base_url, ) ) return registry def get_channel_connector_registry(request: Request) -> ChannelConnectorRegistry: registry = getattr(request.app.state, "channel_connector_registry", None) if isinstance(registry, ChannelConnectorRegistry): return registry workspace = getattr(request.app.state, "channel_connection_workspace", None) if workspace is None: raise RuntimeError("Channel connector registry unavailable before service boot") registry = _build_channel_connector_registry(workspace) request.app.state.channel_connector_registry = registry return registry def _connection_response_view(connection: Any) -> dict[str, Any]: view = connection.to_dict() view.pop("credentials_ref", None) view.pop("connector_ref", None) view.pop("pairing_session_id", None) return view def _connector_session_response_view(view: dict[str, Any]) -> dict[str, Any]: result = dict(view) metadata = result.get("metadata") if isinstance(metadata, dict): result["metadata"] = { str(key): value for key, value in metadata.items() if not _is_sensitive_metadata_key(str(key)) } return result def _is_sensitive_metadata_key(key: str) -> bool: lowered = key.lower() return any(token in lowered for token in ("secret", "token", "password", "authorization", "credential")) async def _activate_connected_channel( request: Request, registry: ChannelConnectorRegistry, connection: Any, ) -> Any: if connection.status != "connected": return connection runtime = get_channel_runtime(request) config = (await registry.materialize_channel_configs()).get(connection.channel_id) if config is not None: await runtime.add_channel(connection.channel_id, config) return registry.connection_store.get(connection.connection_id) def _normalize_connection_config(config: dict[str, Any] | None) -> dict[str, Any]: if not isinstance(config, dict): return {} return { _camel_to_snake_text(str(key)): value for key, value in config.items() if str(key).strip() } def _connector_bridge_guard(connection: Any, payload: WebConnectorBridgeEventRequest) -> None: if connection.status == "revoked": raise HTTPException(status_code=404, detail="Channel connection not found") if connection.status not in {"connected", "running"}: raise HTTPException(status_code=409, detail="Channel connection is not connected") mismatches: list[str] = [] if payload.channel_id != connection.channel_id: mismatches.append("channelId") if payload.kind != connection.kind: mismatches.append("kind") if payload.account_id != connection.account_id: mismatches.append("accountId") if mismatches: raise HTTPException(status_code=403, detail=f"Bridge event does not match connection: {', '.join(mismatches)}") content = payload.content.strip() if not content: raise HTTPException(status_code=400, detail="Bridge event content is required") max_chars = _positive_int(connection.runtime_config.get("maxMessageChars"), default=20000) if len(content) > max_chars: raise HTTPException(status_code=413, detail=f"Bridge event content exceeds maxMessageChars ({max_chars})") def _positive_int(value: Any, *, default: int) -> int: try: number = int(value) except (TypeError, ValueError): return default return number if number > 0 else default def _camel_to_snake_text(value: str) -> str: result: list[str] = [] for char in value.strip(): if char.isupper() and result: result.append("_") result.append(char.lower()) return "".join(result) def _self_restart_enabled() -> bool: return os.getenv("BEAVER_ENABLE_SELF_RESTART", "1").strip() not in {"0", "false", "False"} def _cors_allow_origins() -> list[str]: raw = os.getenv("BEAVER_CORS_ALLOW_ORIGINS", "").strip() if raw: return [origin.strip().rstrip("/") for origin in raw.split(",") if origin.strip()] return [ "http://127.0.0.1:3000", "http://localhost:3000", "http://127.0.0.1:3080", "http://localhost:3080", "http://127.0.0.1:3081", "http://localhost:3081", ] def _schedule_self_restart(delay_seconds: float = 0.75) -> None: import threading def _exit_later() -> None: time.sleep(delay_seconds) os._exit(0) threading.Thread(target=_exit_later, daemon=True).start() 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, ), ) if CORSMiddleware is not None: app.add_middleware( CORSMiddleware, allow_origins=_cors_allow_origins(), allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.state.auth_tokens = {} app.state.handoff_codes = {} app.state.auth_file = Path(os.getenv("BEAVER_AUTH_FILE") or "") app.state.memory_gateway_credential_store = MemoryGatewayCredentialStore( default_memory_gateway_users_path() ) app.state.memory_gateway_client_factory = lambda config: MemoryGatewayClient(config) max_file_size = 50 * 1024 * 1024 max_user_file_upload_size = _int_env("BEAVER_USER_FILES_MAX_UPLOAD_BYTES", 5 * 1024 * 1024 * 1024) user_file_upload_part_size = _int_env("BEAVER_USER_FILES_UPLOAD_PART_SIZE", 10 * 1024 * 1024) def _user_file_resolver(request: Request, authorization: str | None) -> UserFileStorageResolver: username = _require_web_user(app, authorization) loaded = get_agent_service(request).create_loop().boot() auth_context = build_file_auth_context(username=username, config=loaded.config) return UserFileStorageResolver(config=loaded.config, workspace=loaded.workspace, auth_context=auth_context) async def _user_file_service(request: Request, authorization: str | None) -> UserFileService: return await _user_file_resolver(request, authorization).service() def _user_file_http_error(exc: UserFileError) -> HTTPException: if isinstance(exc, UserFileNotFoundError): return HTTPException(status_code=404, detail=str(exc) or "File not found") if isinstance(exc, UserFilePathError): return HTTPException(status_code=400, detail=str(exc) or "Invalid path") if isinstance(exc, UserFileSizeError): return HTTPException(status_code=413, detail=str(exc) or "File too large") if isinstance(exc, UserFileConfigurationError): return HTTPException(status_code=503, detail=str(exc) or "User file storage is not configured") return HTTPException(status_code=400, detail=str(exc) or "User file operation failed") @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": get_channel_runtime(request).statuses(), "runtime_controls": {"self_restart": _self_restart_enabled()}, "cron": cron_service.status(), } @app.get("/api/channels") async def list_channels(request: Request) -> list[dict[str, Any]]: return get_channel_runtime(request).statuses() @app.get("/api/channel-connectors") async def list_channel_connectors(request: Request) -> list[dict[str, str]]: return get_channel_connector_registry(request).connectors() @app.get("/api/channel-connections") async def list_channel_connections(request: Request) -> list[dict[str, Any]]: registry = get_channel_connector_registry(request) return [_connection_response_view(connection) for connection in registry.connection_store.list()] @app.post("/api/channel-connections", response_model=WebChannelConnectionResponse) async def create_channel_connection( request: Request, payload: WebChannelConnectionCreateRequest, ) -> WebChannelConnectionResponse: registry = get_channel_connector_registry(request) kind = _clean_text(payload.kind) mode = _clean_text(payload.mode) if not kind: raise HTTPException(status_code=400, detail="Connection kind is required") if not mode: raise HTTPException(status_code=400, detail="Connection mode is required") secrets_payload = payload.secrets or {} secrets = {key: value for key, value in secrets_payload.items() if value} credentials_ref = registry.credential_store.put(kind=kind, values=secrets) if secrets else None connection = registry.connection_store.create( kind=kind, mode=mode, display_name=_clean_text(payload.display_name) or kind, account_id=_clean_text(payload.account_id) or "", owner_user_id=_clean_text(payload.owner_user_id) or None, auth_type=_clean_text(payload.auth_type) or "token", credentials_ref=credentials_ref, runtime_config=_normalize_connection_config(payload.config), ) return WebChannelConnectionResponse( connection=_connection_response_view(connection), credentials=registry.credential_store.redacted(credentials_ref), ) @app.patch("/api/channel-connections/{connection_id}", response_model=WebChannelConnectionResponse) async def update_channel_connection( connection_id: str, request: Request, payload: WebChannelConnectionUpdateRequest, ) -> WebChannelConnectionResponse: registry = get_channel_connector_registry(request) try: connection = registry.connection_store.get(connection_id) except KeyError: raise HTTPException(status_code=404, detail="Channel connection not found") if payload.display_name is not None: connection.display_name = _clean_text(payload.display_name) or connection.display_name if payload.account_id is not None: connection.account_id = _clean_text(payload.account_id) or connection.account_id if payload.config is not None: connection.runtime_config = _normalize_connection_config(payload.config) if payload.secrets: secrets = {key: value for key, value in payload.secrets.items() if value} if secrets: # TODO: add credential GC when connection updates credentials. connection.credentials_ref = registry.credential_store.put(kind=connection.kind, values=secrets) connection = registry.connection_store.update(connection) return WebChannelConnectionResponse( connection=_connection_response_view(connection), credentials=registry.credential_store.redacted(connection.credentials_ref), ) @app.get("/api/channel-connections/{connection_id}", response_model=WebChannelConnectionResponse) async def get_channel_connection(connection_id: str, request: Request) -> WebChannelConnectionResponse: registry = get_channel_connector_registry(request) try: connection = registry.connection_store.get(connection_id) except KeyError: raise HTTPException(status_code=404, detail="Channel connection not found") return WebChannelConnectionResponse( connection=_connection_response_view(connection), credentials=registry.credential_store.redacted(connection.credentials_ref), ) @app.post("/api/channel-connections/{connection_id}/validate", response_model=WebChannelValidationResponse) async def validate_channel_connection(connection_id: str, request: Request) -> WebChannelValidationResponse: registry = get_channel_connector_registry(request) try: result = await registry.validate(connection_id) connection = registry.connection_store.get(connection_id) except KeyError: raise HTTPException(status_code=404, detail="Channel connection not found") return WebChannelValidationResponse( ok=result.ok, status=result.status, account_id=result.account_id, display_name=result.display_name, error=result.error, metadata=result.metadata, connection=_connection_response_view(connection), ) @app.post("/api/channel-connections/{connection_id}/revoke", response_model=WebChannelConnectionResponse) async def revoke_channel_connection(connection_id: str, request: Request) -> WebChannelConnectionResponse: registry = get_channel_connector_registry(request) try: await registry.revoke(connection_id) connection = registry.connection_store.get(connection_id) except KeyError: raise HTTPException(status_code=404, detail="Channel connection not found") return WebChannelConnectionResponse(connection=_connection_response_view(connection), credentials={}) @app.post("/api/channel-connector-sessions", response_model=WebConnectorSessionResponse) async def start_channel_connector_session( request: Request, payload: WebConnectorSessionCreateRequest, ) -> WebConnectorSessionResponse: registry = get_channel_connector_registry(request) kind = _clean_text(payload.kind) try: connector = registry.connector_for_kind(kind) except KeyError: raise HTTPException(status_code=404, detail="Connector not found") start_session = getattr(connector, "start_session", None) if start_session is None: raise HTTPException(status_code=400, detail="Connector does not support sessions") view = await start_session( display_name=_clean_text(payload.display_name) or kind, owner_user_id=_clean_text(payload.owner_user_id) or None, options=payload.options, ) connection_id = _clean_text(view.get("connectionId")) connection_view = None if connection_id: connection = registry.connection_store.get(connection_id) connection = await _activate_connected_channel(request, registry, connection) connection_view = _connection_response_view(connection) return WebConnectorSessionResponse(session=_connector_session_response_view(view), connection=connection_view) @app.get("/api/channel-connector-sessions/{session_id}", response_model=WebConnectorSessionResponse) async def get_channel_connector_session(session_id: str, request: Request) -> WebConnectorSessionResponse: registry = get_channel_connector_registry(request) connection = next( (item for item in registry.connection_store.list() if item.pairing_session_id == session_id), None, ) if connection is None: raise HTTPException(status_code=404, detail="Connector session not found") connector = registry.connector_for_kind(connection.kind) poll_session = getattr(connector, "poll_session", None) if poll_session is None: raise HTTPException(status_code=400, detail="Connector does not support sessions") view = await poll_session(session_id) connection = registry.connection_store.get(connection.connection_id) connection = await _activate_connected_channel(request, registry, connection) return WebConnectorSessionResponse( session=_connector_session_response_view(view), connection=_connection_response_view(connection), ) @app.post("/api/channel-connector-bridge/events", response_model=WebConnectorBridgeEventResponse) async def accept_connector_bridge_event( request: Request, payload: WebConnectorBridgeEventRequest, authorization: str | None = Header(default=None), ) -> Any: expected = _bridge_token() if not expected or authorization != f"Bearer {expected}": raise HTTPException(status_code=401, detail="Invalid connector bridge token") registry = get_channel_connector_registry(request) try: connection = registry.connection_store.get(payload.connection_id) except KeyError: raise HTTPException(status_code=404, detail="Channel connection not found") _connector_bridge_guard(connection, payload) store = _message_dedupe_store(_channel_connection_workspace(request)) begin = store.begin( connection_id=payload.connection_id, event_id=payload.event_id, delivery_attempt=payload.delivery_attempt, ) if not begin.should_process: body = WebConnectorBridgeEventResponse( accepted=begin.http_status == 200, duplicate=True, pending=begin.http_status == 409, retryAfterSeconds=begin.retry_after_seconds, ).model_dump(by_alias=True) return JSONResponse(status_code=begin.http_status, content=body) runtime = get_channel_runtime(request) identity = ChannelIdentity( channel_id=payload.channel_id, kind=payload.kind, account_id=payload.account_id, peer_id=payload.peer_id, thread_id=payload.thread_id, peer_type=payload.peer_type, user_id=payload.user_id, message_id=payload.message_id, ) inbound = InboundMessage( channel=payload.channel_id, content=payload.content, content_type=payload.message_type, channel_identity=identity, user_id=payload.user_id, message_id=payload.message_id, metadata=dict(payload.metadata), ) result = await runtime.accept_inbound(inbound) if result.accepted or result.duplicate: store.complete(begin.dedupe_key, message_id=payload.message_id) else: store.fail(begin.dedupe_key, error=result.error or "runtime rejected bridge event") return WebConnectorBridgeEventResponse( accepted=result.accepted, duplicate=result.duplicate, pending=result.pending, ) @app.get("/api/channels/{channel_id}/config") async def get_channel_config(channel_id: str, request: Request) -> dict[str, Any]: 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) channel = _ensure_dict(raw, "channels").get(channel_id) if not isinstance(channel, dict): raise HTTPException(status_code=404, detail="Channel not found") return _channel_config_view(channel_id, channel) @app.post("/api/channels/{channel_id}/config", response_model=WebChannelConfigResponse) async def update_channel_config( channel_id: str, request: Request, payload: WebChannelConfigRequest, ) -> WebChannelConfigResponse: if not _clean_text(channel_id): raise HTTPException(status_code=400, detail="Channel id is required") kind = _clean_text(payload.kind) mode = _clean_text(payload.mode) if not kind: raise HTTPException(status_code=400, detail="Channel kind is required") if not mode: raise HTTPException(status_code=400, detail="Channel mode is required") 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) channels = _ensure_dict(raw, "channels") current = channels.get(channel_id) if isinstance(channels.get(channel_id), dict) else {} current_secrets = current.get("secrets") if isinstance(current.get("secrets"), dict) else {} next_secrets = dict(current_secrets) for key, value in (payload.secrets or {}).items(): cleaned_key = _clean_text(key) cleaned_value = _clean_text(value) if not cleaned_key or not cleaned_value: continue next_secrets[cleaned_key] = cleaned_value channel_payload: dict[str, Any] = { "enabled": bool(payload.enabled), "kind": kind, "mode": mode, "accountId": _clean_text(payload.account_id) or "", "displayName": _clean_text(payload.display_name) or channel_id, "config": payload.config or {}, "secrets": next_secrets, } channels[channel_id] = channel_payload _write_config_json(config_path, raw) _reload_agent_config(agent_service, config_path) return WebChannelConfigResponse( ok=True, channel_id=channel_id, restart_required=True, channel=_channel_config_view(channel_id, channel_payload), ) @app.get("/api/channels/{channel_id}/events") async def list_channel_events(channel_id: str, request: Request, limit: int = 100) -> list[dict[str, Any]]: return get_channel_runtime(request).recent_events(channel_id, limit=limit) @app.post("/api/channels/{channel_id}/webhook") async def post_channel_webhook(channel_id: str, request: Request) -> JSONResponse: runtime = get_channel_runtime(request) adapter = runtime.adapters.get(channel_id) if adapter is None or not hasattr(adapter, "handle_webhook_payload"): raise HTTPException(status_code=404, detail="Webhook channel not found") payload = await request.json() if not isinstance(payload, dict): raise HTTPException(status_code=400, detail="Webhook payload must be a JSON object") result = await adapter.handle_webhook_payload(payload) # type: ignore[attr-defined] status_code = 202 if result.get("pending") else 200 return JSONResponse(result, status_code=status_code) @app.websocket("/api/channels/{channel_id}/ws") async def channel_websocket(websocket: WebSocket, channel_id: str) -> None: runtime = getattr(websocket.app.state, "channel_runtime", None) if not isinstance(runtime, ChannelRuntime): await websocket.accept() await websocket.send_json({"type": "error", "error": "Channel runtime is not running"}) await websocket.close(code=1011) return adapter = runtime.adapters.get(channel_id) if adapter is None or not hasattr(adapter, "handle_websocket"): await websocket.accept() await websocket.send_json({"type": "error", "error": "WebSocket channel not found"}) await websocket.close(code=1008) return await adapter.handle_websocket(websocket) # type: ignore[attr-defined] @app.post("/api/runtime/restart") async def restart_runtime() -> JSONResponse: if not _self_restart_enabled(): raise HTTPException(status_code=403, detail="Self restart is disabled") _schedule_self_restart() return JSONResponse({"ok": True, "restarting": True}, status_code=202) @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) if config.memory.mode == "hybrid" and config.memory.gateway.is_configured: try: gateway_client = app.state.memory_gateway_client_factory(config.memory.gateway) gateway_payload = await gateway_client.create_user(username) gateway_user_id = _clean_text(gateway_payload.get("user_id")) gateway_user_key = _clean_text(gateway_payload.get("user_key")) if not gateway_user_id or not gateway_user_key: raise MemoryGatewayClientError("create_user", "invalid_response") app.state.memory_gateway_credential_store.save( username, MemoryGatewayUserCredential( user_id=gateway_user_id, user_key=gateway_user_key, ), ) except MemoryGatewayClientError as exc: logger.warning( "Memory Gateway user provisioning failed for Beaver user %s: operation=%s category=%s status_code=%s", username, exc.operation, exc.category, exc.status_code, ) 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.post("/api/agent-config", response_model=WebAgentConfigResponse) async def update_agent_config( request: Request, payload: WebAgentConfigRequest, ) -> WebAgentConfigResponse: if payload.max_tokens is not None and payload.max_tokens <= 0: raise HTTPException(status_code=400, detail="max_tokens must be a positive integer or null") if payload.temperature < 0 or payload.temperature > 2: raise HTTPException(status_code=400, detail="temperature must be between 0 and 2") if payload.max_tool_iterations < 0: raise HTTPException(status_code=400, detail="max_tool_iterations must be zero or greater") 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) agents = _ensure_dict(raw, "agents") defaults = _ensure_dict(agents, "defaults") if payload.max_tokens is None: defaults.pop("maxTokens", None) defaults.pop("max_tokens", None) else: defaults["maxTokens"] = payload.max_tokens defaults.pop("max_tokens", None) defaults["temperature"] = payload.temperature defaults["maxToolIterations"] = payload.max_tool_iterations defaults.pop("max_tool_iterations", None) _write_config_json(config_path, raw) _reload_agent_config(agent_service, config_path) return WebAgentConfigResponse(ok=True) @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/user-files/status") async def user_files_status( request: Request, authorization: str | None = Header(default=None), ) -> dict[str, Any]: return (await _user_file_resolver(request, authorization).status()).to_dict() @app.get("/api/user-files/browse") async def browse_user_files( request: Request, path: str = "", authorization: str | None = Header(default=None), ) -> dict[str, Any]: try: return await (await _user_file_service(request, authorization)).browse(path) except UserFileError as exc: raise _user_file_http_error(exc) from exc @app.get("/api/user-files/download") async def download_user_file( path: str, request: Request, authorization: str | None = Header(default=None), ) -> Response: try: content = await (await _user_file_service(request, authorization)).download(path) except UserFileError as exc: raise _user_file_http_error(exc) from exc disposition = "inline" if content.content_type.startswith("image/") else "attachment" return Response( content=content.content, media_type=content.content_type, headers={"Content-Disposition": content_disposition(disposition, content.name)}, ) @app.get("/api/user-files/preview") async def preview_user_file( path: str, request: Request, authorization: str | None = Header(default=None), ) -> dict[str, Any]: try: return await (await _user_file_service(request, authorization)).preview(path) except UserFileError as exc: raise _user_file_http_error(exc) from exc @app.post("/api/user-files/upload") async def upload_user_file( request: Request, file: UploadFile = File(...), path: str = Form("uploads"), authorization: str | None = Header(default=None), ) -> dict[str, Any]: if not file.filename: raise HTTPException(status_code=400, detail="No filename provided") file_size = getattr(file, "size", None) if isinstance(file_size, int) and file_size > max_user_file_upload_size: raise HTTPException(status_code=413, detail=f"File too large (max {_human_upload_size(max_user_file_upload_size)})") try: return await (await _user_file_service(request, authorization)).upload_stream( path, file.filename, file.file, content_type=file.content_type or "application/octet-stream", max_bytes=max_user_file_upload_size, part_size=user_file_upload_part_size, ) except UserFileError as exc: raise _user_file_http_error(exc) from exc @app.delete("/api/user-files/delete") async def delete_user_file( path: str, request: Request, authorization: str | None = Header(default=None), ) -> dict[str, bool]: try: removed = await (await _user_file_service(request, authorization)).delete(path) except UserFileError as exc: raise _user_file_http_error(exc) from exc if removed: return {"ok": True} raise HTTPException(status_code=404, detail="Path not found") @app.post("/api/user-files/mkdir") async def create_user_file_directory( path: str, request: Request, authorization: str | None = Header(default=None), ) -> dict[str, Any]: try: return await (await _user_file_service(request, authorization)).mkdir(path) except UserFileError as exc: raise _user_file_http_error(exc) from exc @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") agent_service = get_agent_service(request) loaded = agent_service.create_loop().boot() try: content = await file.read() draft_payload = _create_skill_upload_draft(loaded, filename, content) draft = loaded.draft_service.get_draft(draft_payload["skill_name"], draft_payload["draft_id"]) if draft is not None: await _rewrite_uploaded_skill_draft_with_llm(agent_service, loaded, draft, filename=filename) draft = loaded.draft_service.get_draft(draft.skill_name, draft.draft_id) or draft draft_payload = draft.to_dict() except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return draft_payload @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 [ _skill_learning_candidate_payload(loaded, item) 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: candidate = loaded.skill_learning_pipeline.get_candidate(candidate_id) # type: ignore[union-attr] return _skill_learning_candidate_payload(loaded, candidate) 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) loop = agent_service.create_loop() loaded = 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: loaded.skill_learning_pipeline.get_draft(candidate.draft_skill_name, candidate.draft_id) # type: ignore[union-attr] except ValueError: pass else: return _skill_draft_payload(loaded, candidate.draft_skill_name, candidate.draft_id) 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, ) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return _skill_draft_payload(loaded, draft.skill_name, draft.draft_id) @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) loop = agent_service.create_loop() loaded = 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, ) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return _skill_draft_payload(loaded, draft.skill_name, draft.draft_id) @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]: agent_service = get_agent_service(request) loop = agent_service.create_loop() loaded = loop.boot() try: safety = loaded.skill_learning_pipeline.check_safety(skill_name, draft_id) # type: ignore[union-attr] if safety.passed and safety.risk_level != "critical": 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 ""), ) candidate_id = _skill_learning_candidate_id_for_draft(loaded, skill_name, draft_id) if candidate_id is not None: provider_bundle = agent_service._make_provider_bundle_for_task(loaded, {}) # noqa: SLF001 await loaded.skill_learning_pipeline.evaluate_draft( # type: ignore[union-attr] candidate_id, skill_name, draft_id, provider_bundle=provider_bundle, replay_runner=ReplayRunner(agent_loop=loop), ) except ValueError as exc: raise _skill_draft_http_error(exc) from exc return _skill_draft_payload(loaded, skill_name, draft_id) @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, authorization: str | None = Header(default=None), ) -> 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: gateway_user_id = _optional_web_user(app, authorization) direct_kwargs = { "session_id": payload.session_id, "source": "web", "user_id": payload.user_id, "gateway_user_id": gateway_user_id, "title": payload.title, "execution_context": payload.execution_context, "prompt_locale": payload.prompt_locale, "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 gateway_user_id = _web_user_from_token(app, websocket.query_params.get("token")) 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 ''}", } ) 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, "gateway_user_id": gateway_user_id, "title": _clean_text(payload.get("title")) or None, "execution_context": _clean_text(payload.get("execution_context")) or None, "prompt_locale": _clean_text(payload.get("prompt_locale")) 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 = [] safe_entries: list[tuple[Any, str, tuple[str, ...]]] = [] 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("/") or any(part in {"", ".", ".."} for part in parts): raise ValueError(f"Unsafe archive entry: {info.filename}") safe_entries.append((info, raw, tuple(parts))) if _is_skill_markdown_entry(parts[-1]): skill_entries.append(raw) if not skill_entries: raise ValueError("Zip must contain SKILL.md") if len(skill_entries) > 1: raise ValueError("Zip must contain exactly one SKILL.md") skill_entry = skill_entries[0] skill_root = tuple(Path(skill_entry).parts[:-1]) raw_skill = archive.read(skill_entry).decode("utf-8", errors="replace") frontmatter, body = parse_frontmatter(raw_skill) skill_name = str(frontmatter.get("name") or (skill_root[-1] if skill_root else "") 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") proposed_frontmatter = normalize_skill_frontmatter( { **dict(frontmatter), "name": skill_name, "description": frontmatter.get("description") or skill_name, }, skill_name=skill_name, ) proposed_frontmatter["tools"] = _merge_tool_names( proposed_frontmatter.get("tools"), extract_required_tool_names(body), _infer_uploaded_skill_tools( skill_name=skill_name, filename=filename, frontmatter=proposed_frontmatter, content=body, loaded=loaded, ), ) proposed_content = ensure_canonical_skill_body( body, title=skill_name, description=str(proposed_frontmatter.get("description") or ""), tools=list(proposed_frontmatter.get("tools") or []), ) files: list[tuple[str, bytes]] = [] for info, raw, parts in safe_entries: if raw == skill_entry: continue if skill_root: if parts[: len(skill_root)] != skill_root: continue rel_parts = parts[len(skill_root):] else: rel_parts = 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=proposed_content, proposed_frontmatter=proposed_frontmatter, 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 _is_skill_markdown_entry(filename: str) -> bool: return filename.strip().lower() in {"skill.md", "skills.md"} def _merge_tool_names(*groups: Any) -> list[str]: result: list[str] = [] for group in groups: if isinstance(group, str): raw_items = group.split(",") elif isinstance(group, (list, tuple, set)): raw_items = list(group) else: raw_items = [] for item in raw_items: cleaned = str(item).strip() if cleaned and cleaned not in result: result.append(cleaned) return result def _infer_uploaded_skill_tools( *, skill_name: str, filename: str, frontmatter: dict[str, Any], content: str, loaded: Any, ) -> list[str]: available = _available_runtime_tool_names(loaded) text = "\n".join( [ skill_name, filename, json.dumps(frontmatter, ensure_ascii=False, sort_keys=True), content, ] ).lower() inferred: list[str] = [] for tool_name in sorted(available or _COMMON_RUNTIME_TOOL_NAMES): if re.search(rf"(? None: for tool_name in tool_names: if available is not None and tool_name not in available: continue if tool_name not in inferred: inferred.append(tool_name) if re.search(r"\b(weather|forecast|temperature|precipitation|rain|snow|humidity|wind|air quality|aqi)\b", text): add_if_available("web_fetch", "web_search") if re.search(r"\b(latest|current|today|tomorrow|news|search|query|lookup|find online|web search)\b", text): add_if_available("web_search") if re.search(r"\b(url|http|https|website|webpage|page|fetch|crawl|browser|online source)\b", text): add_if_available("web_fetch") return inferred def _available_runtime_tool_names(loaded: Any) -> set[str] | None: registry = getattr(loaded, "tool_registry", None) if registry is None: return None try: return {spec.name for spec in registry.list_specs()} except Exception: return None _COMMON_RUNTIME_TOOL_NAMES = { "web_fetch", "web_search", "read_file", "write_file", "patch_file", "search_files", "list_directory", "memory", "terminal", "process", "execute_code", "skill_view", "skills_list", "skill_manage", "cron", } async def _rewrite_uploaded_skill_draft_with_llm(agent_service: Any, loaded: Any, draft: Any, *, filename: str) -> None: try: provider_bundle = agent_service._make_provider_bundle_for_task(loaded, {}) # noqa: SLF001 provider = getattr(provider_bundle, "auxiliary_provider", None) or getattr(provider_bundle, "main_provider", None) runtime = getattr(provider_bundle, "auxiliary_runtime", None) or getattr(provider_bundle, "main_runtime", None) if provider is None: return available_tool_names = sorted(_available_runtime_tool_names(loaded) or _COMMON_RUNTIME_TOOL_NAMES) response = await provider.chat( messages=[ { "role": "system", "content": ( "You rewrite uploaded Beaver skills into the required house style. " "Return only JSON with keys: frontmatter, content, change_reason. " "Do not include markdown fences." ), }, { "role": "user", "content": ( f"Uploaded filename: {filename}\n" f"Skill name: {draft.skill_name}\n" f"Current frontmatter:\n{json.dumps(draft.proposed_frontmatter, ensure_ascii=False, sort_keys=True)}\n\n" f"Current content:\n{draft.proposed_content}\n\n" f"Available runtime tool names:\n{json.dumps(available_tool_names, ensure_ascii=False)}\n\n" f"{canonical_skill_format_instructions()}\n\n" "Rewrite the skill so it is operational, concrete, and ready for review/publish. " "Infer exact required runtime tools from the uploaded content when the workflow depends on tools. " "Keep frontmatter.tools and the Required Tools section consistent." ), }, ], tools=None, model=getattr(runtime, "model", None), max_tokens=4096, temperature=0, ) payload = parse_skill_rewrite_json(response.content or "", skill_name=draft.skill_name) if payload is None: return payload["frontmatter"]["tools"] = _merge_tool_names( payload["frontmatter"].get("tools"), extract_required_tool_names(payload["content"]), _infer_uploaded_skill_tools( skill_name=draft.skill_name, filename=filename, frontmatter=payload["frontmatter"], content=payload["content"], loaded=loaded, ), ) payload["content"] = ensure_canonical_skill_body( payload["content"], title=str(payload["frontmatter"].get("name") or draft.skill_name), description=str(payload["frontmatter"].get("description") or ""), tools=list(payload["frontmatter"].get("tools") or []), ) draft.proposed_frontmatter = payload["frontmatter"] draft.proposed_content = payload["content"] if payload.get("change_reason"): draft.reason = f"{draft.reason}; LLM rewrite: {payload['change_reason']}" loaded.skill_spec_store.write_draft(draft) except Exception: return 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("") ) or ( lowered.startswith("") ) 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 _int_env(name: str, default: int) -> int: raw = os.getenv(name, "").strip() if not raw: return default try: value = int(raw) except ValueError: return default return value if value > 0 else default def _human_upload_size(size: int) -> str: units = ("B", "KB", "MB", "GB", "TB") value = float(size) for unit in units: if value < 1024 or unit == units[-1]: return f"{value:.0f}{unit}" if unit == "B" else f"{value:.1f}{unit}" value /= 1024 return f"{size}B" 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 _optional_web_user(app: FastAPI, authorization: str | None) -> str | None: if not authorization: return None prefix = "bearer " if not authorization.lower().startswith(prefix): return None return _web_user_from_token(app, authorization[len(prefix):].strip()) def _web_user_from_token(app: FastAPI, token: str | None) -> str | None: cleaned = _clean_text(token) if not cleaned: return None return app.state.auth_tokens.get(cleaned) 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_learning_candidate_payload(loaded: Any, candidate: Any) -> dict[str, Any]: payload = candidate.to_dict() evidence = dict(payload.get("evidence") or {}) task_text = _skill_learning_candidate_task_text(loaded, candidate) if task_text: evidence["task_text"] = task_text evidence["theme"] = SkillLearningService._task_theme(task_text) payload["evidence"] = evidence if candidate.kind == "new_skill": payload["evidence_summary"] = f"Theme: {evidence['theme']}" return payload def _skill_learning_candidate_task_text(loaded: Any, candidate: Any) -> str: evidence = candidate.evidence if isinstance(candidate.evidence, dict) else {} task_id = str(evidence.get("task_id") or "").strip() source_run_ids = set(candidate.source_run_ids or []) try: run_store = loaded.skill_learning_pipeline.learning_service.run_store runs = run_store.list_runs() except Exception: return str(evidence.get("task_text") or "").strip() if task_id: task_runs = [record for record in runs if record.task_id == task_id] if task_runs: return SkillLearningService._representative_task_text(task_runs) source_runs = [record for record in runs if record.run_id in source_run_ids] if source_runs: return SkillLearningService._representative_task_text(source_runs) return str(evidence.get("task_text") or "").strip() 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, "target_version": _skill_draft_target_version(loaded, draft.skill_name, draft.proposal_kind), "base_skill": _skill_draft_base_skill_payload(loaded, draft), } 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_draft_base_skill_payload(loaded: Any, draft: Any) -> dict[str, Any] | None: if draft.proposal_kind == "new_skill" or not draft.base_version: return None store = loaded.skill_learning_pipeline.publisher.store # type: ignore[union-attr] loaded_version = store.read_published_skill(draft.skill_name, draft.base_version) if loaded_version is None: return None version = loaded_version.version return { "skill_name": version.skill_name, "version": version.version, "frontmatter": dict(version.frontmatter), "content": loaded_version.content, "summary": version.summary, "tool_hints": list(version.tool_hints), } def _skill_draft_target_version(loaded: Any, skill_name: str, proposal_kind: str) -> str | None: if proposal_kind == "retire_skill": return None versions = [ item for item in loaded.skill_learning_pipeline.publisher.store.list_versions(skill_name) # type: ignore[union-attr] if isinstance(item, str) and item.startswith("v") and item[1:].isdigit() ] if not versions: return "v0001" latest = max(int(item[1:]) for item in versions) return f"v{latest + 1:04d}" def _skill_learning_candidate_id_for_draft(loaded: Any, skill_name: str, draft_id: str) -> str | None: for candidate in loaded.skill_learning_pipeline.list_candidates(): # type: ignore[union-attr] if candidate.draft_skill_name == skill_name and candidate.draft_id == draft_id: return candidate.candidate_id return None 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 _channel_config_view(channel_id: str, data: dict[str, Any]) -> dict[str, Any]: secrets_payload = data.get("secrets") if isinstance(data.get("secrets"), dict) else {} config_payload = data.get("config") if isinstance(data.get("config"), dict) else {} return { "channel_id": channel_id, "enabled": bool(data.get("enabled")), "kind": _clean_text(data.get("kind")) or "", "mode": _clean_text(data.get("mode")) or "webhook", "account_id": _clean_text(data.get("accountId") or data.get("account_id")) or "", "display_name": _clean_text(data.get("displayName") or data.get("display_name")) or channel_id, "config": dict(config_payload), "secrets": { str(key): _mask_secret(str(value) if value is not None else None) for key, value in secrets_payload.items() if str(key).strip() }, } 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: try: await old_manager.close() except Exception: # MCP transports may own anyio cancel scopes created by a # previous request task. Config reload must not leak that # cleanup failure as an unhandled background exception or # knock the app out of running mode. pass 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 = {}