- 集成MCP连接管理器,支持MCP服务器连接 - 添加多种内置工具:ClarifyTool、CronTool、DelegateTool、ExecuteCodeTool、 PatchFileTool、ProcessTool、SendMessageTool、SpawnTool、TerminalTool、 TodoTool、WebFetchTool、WebSearchTool、WriteFileTool等 - 实现工具注册和装配功能 - 添加技能选择上下文参数 - 支持思考模式控制参数thinking_enabled feat(coordinator): 重构任务执行计划器参数命名 - 将learning_candidate_enabled重命名为allow_candidate_generation - 更新TeamGraphScheduler中的参数传递 - 修改LocalAgentRunner中的相关参数处理 - 更新README文档中的相应描述 refactor(context): 标准化工具调用参数格式 - 添加_json导入用于参数序列化 - 实现_provider_tool_calls方法标准化OpenAI兼容的工具调用载荷 - 修复工具调用中参数非字符串类型的序列化问题 refactor(session): 优化消息历史记录过滤逻辑 - 修改get_messages_as_conversation为基于运行状态过滤消息 - 排除未完成、失败或错误结束的运行记录 - 改进对话历史的可见性控制机制 fix(store): 修复FTS索引重建逻辑 - 添加异常处理防止FTS索引创建失败 - 实现_rebuild_fts_index方法重新构建全文搜索索引 - 优化索引触发器和表的维护流程
2199 lines
96 KiB
Python
2199 lines
96 KiB
Python
"""FastAPI app factory for Beaver."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import asyncio
|
||
import io
|
||
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.skill_migration import SkillMigrationService
|
||
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 .schemas import (
|
||
WebChatFeedbackRequest,
|
||
WebChatFeedbackResponse,
|
||
WebChatRequest,
|
||
WebChatResponse,
|
||
WebErrorResponse,
|
||
WebProviderConfigRequest,
|
||
WebProviderConfigResponse,
|
||
WebStatusResponse,
|
||
)
|
||
|
||
try:
|
||
from fastapi import FastAPI, File, 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 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("NANOBOT_AUTH_FILE") or os.getenv("BEAVER_AUTH_FILE") or "")
|
||
|
||
@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/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("NANOBOT_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.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.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/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/tools/servers")
|
||
async def list_tool_servers(request: Request) -> list[dict[str, Any]]:
|
||
return await list_mcp_servers(request)
|
||
|
||
@app.get("/api/tools")
|
||
async def list_tools(request: Request) -> dict[str, Any]:
|
||
servers = await list_mcp_servers(request)
|
||
tool_groups = await list_mcp_tools(request)
|
||
server_map = {server["id"]: server for server in servers}
|
||
grouped = {"local": [], "online": []}
|
||
for group in tool_groups:
|
||
server = server_map.get(group["server_id"], {})
|
||
kind = str(server.get("kind") or "online")
|
||
item = {
|
||
**group,
|
||
"server_name": server.get("name") or group["server_id"],
|
||
"transport": server.get("transport"),
|
||
"kind": kind,
|
||
"category": server.get("category") or kind,
|
||
"status": server.get("status"),
|
||
}
|
||
grouped["local" if kind == "local" else "online"].append(item)
|
||
return {"servers": servers, "groups": grouped}
|
||
|
||
@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}/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.post("/api/skills/migrate")
|
||
async def migrate_skills(request: Request) -> dict[str, Any]:
|
||
loaded = get_agent_service(request).create_loop().boot()
|
||
return SkillMigrationService(loaded.skill_spec_store).migrate_all() # type: ignore[arg-type]
|
||
|
||
@app.get("/api/skills/migration-manifest")
|
||
async def get_skill_migration_manifest(request: Request) -> dict[str, Any]:
|
||
loaded = get_agent_service(request).create_loop().boot()
|
||
path = loaded.workspace / "skill_migration_manifest.json"
|
||
if not path.exists():
|
||
return {"included": [], "skipped": []}
|
||
return json.loads(path.read_text(encoding="utf-8"))
|
||
|
||
@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.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()
|
||
provider_bundle = agent_service._make_provider_bundle_for_task(loaded, {}) # noqa: SLF001
|
||
try:
|
||
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()
|
||
results = []
|
||
for item in loaded.skill_learning_pipeline.list_drafts(): # type: ignore[union-attr]
|
||
safety = loaded.skill_learning_pipeline.get_safety_report(item.skill_name, item.draft_id) # type: ignore[union-attr]
|
||
eval_report = loaded.skill_learning_pipeline.get_eval_report(item.skill_name, item.draft_id) # type: ignore[union-attr]
|
||
results.append(
|
||
{
|
||
**item.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,
|
||
}
|
||
)
|
||
return results
|
||
|
||
@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:
|
||
draft = loaded.skill_learning_pipeline.get_draft(skill_name, draft_id) # type: ignore[union-attr]
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
return {
|
||
**draft.to_dict(),
|
||
"reviews": [
|
||
item.to_dict()
|
||
for item in loaded.skill_learning_pipeline.reviews_for_draft(skill_name, draft_id) # type: ignore[union-attr]
|
||
],
|
||
"safety_report": (
|
||
loaded.skill_learning_pipeline.get_safety_report(skill_name, draft_id).to_dict() # type: ignore[union-attr]
|
||
if loaded.skill_learning_pipeline.get_safety_report(skill_name, draft_id) is not None # type: ignore[union-attr]
|
||
else None
|
||
),
|
||
"eval_report": (
|
||
loaded.skill_learning_pipeline.get_eval_report(skill_name, draft_id).to_dict() # type: ignore[union-attr]
|
||
if loaded.skill_learning_pipeline.get_eval_report(skill_name, draft_id) is not None # type: ignore[union-attr]
|
||
else None
|
||
),
|
||
}
|
||
|
||
@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.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]:
|
||
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")
|
||
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]
|
||
}
|
||
|
||
@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,
|
||
}
|
||
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",
|
||
"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)
|
||
task_id = None
|
||
attempt_index = None
|
||
task_mode = 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 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,
|
||
"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 _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]:
|
||
return {
|
||
"id": agent.agent_id,
|
||
"name": agent.display_name or agent.name,
|
||
"description": agent.description,
|
||
"source": agent.source if agent.source in {"workspace", "skill", "builtin"} else "workspace",
|
||
"kind": "specialist",
|
||
"protocol": None,
|
||
"endpoint": None,
|
||
"base_url": None,
|
||
"card_url": None,
|
||
"auth_env": None,
|
||
"auth_mode": "none",
|
||
"auth_audience": None,
|
||
"auth_scopes": [],
|
||
"tags": list(agent.tags),
|
||
"aliases": [agent.name],
|
||
"metadata": {
|
||
**dict(agent.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": False,
|
||
}
|
||
|
||
|
||
def _agent_payload_from_ui(payload: dict[str, Any]) -> dict[str, Any]:
|
||
metadata = dict(payload.get("metadata") or {})
|
||
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 ""
|
||
return {
|
||
"agent_id": payload.get("agent_id") or payload.get("id") or payload.get("name"),
|
||
"name": payload.get("name") or payload.get("id"),
|
||
"display_name": payload.get("display_name") or payload.get("name") or payload.get("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 _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 _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),
|
||
"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("NANOBOT_AUTH_FILE") or 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 _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("NANOBOT_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("NANOBOT_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("NANOBOT_BACKEND_IDENTITY__PUBLIC_BASE_URL")
|
||
or os.getenv("NANOBOT_FRONTEND_PUBLIC_BASE_URL")
|
||
or str(request.base_url).rstrip("/")
|
||
)
|
||
backend_id = os.getenv("NANOBOT_BACKEND_IDENTITY__BACKEND_ID") or os.getenv("NANOBOT_BACKEND_IDENTITY__CLIENT_ID")
|
||
client_id = os.getenv("NANOBOT_BACKEND_IDENTITY__CLIENT_ID") or backend_id
|
||
return {
|
||
"backend_id": backend_id,
|
||
"client_id": client_id,
|
||
"name": os.getenv("NANOBOT_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("NANOBOT_BACKEND_IDENTITY__BACKEND_ID"),
|
||
"client_id": os.getenv("NANOBOT_BACKEND_IDENTITY__CLIENT_ID"),
|
||
"name": os.getenv("NANOBOT_BACKEND_IDENTITY__NAME"),
|
||
"public_base_url": os.getenv("NANOBOT_BACKEND_IDENTITY__PUBLIC_BASE_URL")
|
||
or os.getenv("NANOBOT_FRONTEND_PUBLIC_BASE_URL"),
|
||
"authz": {
|
||
"enabled": os.getenv("NANOBOT_AUTHZ__ENABLED", "").strip() in {"1", "true", "True"},
|
||
"base_url": os.getenv("NANOBOT_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_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 _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 = {}
|