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