新增内部Task系统,包括验证、反馈门控机制,实现自动质量验证 (通过率>=0.75)和用户反馈闭环(satisfied/revise/abandon)。 实现Agent Team v1协调器,支持sequence/parallel/dag执行策略, sub-agent复用主AgentLoop,每个run使用独立memory snapshot。 建立Skill学习pipeline,包含draft/审核/发布/回滚完整生命周期, 通过Task验证通过且用户满意才生成学习候选。 重构目录结构,移除third_party依赖,建立统一engine内核, 所有agent共享运行时基础组件。 更新ContextBuilder清理provider消息字段,增强SkillContext版本管理, 集成TaskExecutionPlanner和TaskSkillResolver实现技能解析机制。
836 lines
36 KiB
Python
836 lines
36 KiB
Python
"""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
|