添加了 `revise_task` 路由动作类型,允许用户修改、纠正或重新执行最新活动任务结果。 实现了工具失败指导原则,防止相同类别工具重复失败。 为任务规划器添加了超时处理机制,避免长时间等待。 BREAKING CHANGE: 任务路由逻辑已更新,新增 `revise_task` 动作类型。 fix(api): 修复任务详情API返回完整流程投影 修复了任务详情API端点,现在会包含过滤后的流程运行、事件和工件信息, 并确保时间戳字段正确序列化。 refactor(engine): 优化任务技能解析器摘要节点处理 改进了任务技能解析器对摘要节点的处理逻辑,对于仅依赖文本生成功能的摘要节 点不再分配具体技能,直接使用依赖项输出进行汇总。 test: 增加任务修订和超时处理测试用例 添加了测试用例验证任务修订输入记录反馈、超时回退到单模式以及 摘要节点技能解析等新功能。
2998 lines
131 KiB
Python
2998 lines
131 KiB
Python
"""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 '<empty>'}",
|
||
}
|
||
)
|
||
continue
|
||
|
||
content = _clean_text(payload.get("content"))
|
||
if not content:
|
||
await websocket.send_json({"type": "error", "error": "'content' is required"})
|
||
continue
|
||
|
||
await websocket.send_json({"type": "status", "status": "thinking"})
|
||
try:
|
||
reply_to_scheduled_run_id = _clean_text(payload.get("reply_to_scheduled_run_id"))
|
||
if reply_to_scheduled_run_id:
|
||
cron_service = get_cron_service(websocket)
|
||
found = cron_service.get_run(reply_to_scheduled_run_id)
|
||
if found is None:
|
||
raise ValueError("Notification not found")
|
||
job, run = found
|
||
intent = _scheduled_reply_intent(payload.get("scheduled_reply_intent"))
|
||
reply_kwargs = {
|
||
"job": job,
|
||
"run": run,
|
||
"intent": intent,
|
||
}
|
||
websocket_thinking_enabled = _bool_or_none(payload.get("thinking_enabled"))
|
||
if websocket_thinking_enabled is not None:
|
||
reply_kwargs["thinking_enabled"] = websocket_thinking_enabled
|
||
result = await agent_service.submit_scheduled_reply(content, **reply_kwargs)
|
||
cron_service.mark_run_engaged(reply_to_scheduled_run_id, task_id=str(result.task_id or ""), intent=intent)
|
||
if intent == "update_future":
|
||
cron_service.update_job_message(job.id, _updated_scheduled_instruction(job.payload.message, content))
|
||
else:
|
||
direct_kwargs = {
|
||
"session_id": session_id,
|
||
"source": "websocket",
|
||
"user_id": _clean_text(payload.get("user_id")) or None,
|
||
"title": _clean_text(payload.get("title")) or None,
|
||
"execution_context": _clean_text(payload.get("execution_context")) or None,
|
||
"model": _clean_text(payload.get("model")) or None,
|
||
"provider_name": _clean_text(payload.get("provider_name")) or None,
|
||
"embedding_model": _clean_text(payload.get("embedding_model")) or None,
|
||
"max_tool_iterations": _int_or_none(payload.get("max_tool_iterations")),
|
||
}
|
||
websocket_thinking_enabled = _bool_or_none(payload.get("thinking_enabled"))
|
||
if websocket_thinking_enabled is not None:
|
||
direct_kwargs["thinking_enabled"] = websocket_thinking_enabled
|
||
result = await 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 = {}
|