"""FastAPI app factory for Beaver.""" from __future__ import annotations import json import asyncio 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.services.agent_service import AgentService from beaver.skills.learning import SkillLearningWorker, SkillLearningWorkerConfig from .deps import get_agent_service from .schemas import ( WebChatFeedbackRequest, WebChatFeedbackResponse, WebChatRequest, WebChatResponse, WebErrorResponse, WebProviderConfigRequest, WebProviderConfigResponse, WebStatusResponse, ) try: from fastapi import FastAPI, HTTPException, Request except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only environments 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 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 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 @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 started = False if owns_service: try: await attached_service.start() started = True except BaseException: with suppress(BaseException): 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: 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 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.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) providers_status = [] default_provider = config.resolve_provider_target().get("provider_name") for spec in PROVIDERS: provider_cfg = config.providers.get(spec.name) enabled = provider_cfg is not None 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 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": {"enabled": False, "jobs": 0, "next_wake_at_ms": None}, } @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: 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[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"]) # 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.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}") 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]: loaded = get_agent_service(request).create_loop().boot() loaded.session_manager.end_session(session_id, "deleted") # type: ignore[union-attr] return {"ok": 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/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), "agent_cards": [], } for record in skills ] @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 HTTPException(status_code=404, detail=str(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 HTTPException(status_code=404, detail=str(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 HTTPException(status_code=404, detail=str(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.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") fallback_target = _model_dump(payload.fallback_target) auxiliary_target = _model_dump(payload.auxiliary_target) embedding_target = _model_dump(payload.embedding_target) try: result = await agent_service.submit_direct( message, 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, ) 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.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"), } ) 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 _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 _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 _clean_text(value: Any) -> str | None: if value is None: return None text = str(value).strip() return text or None 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: loaded.config = config