|
|
|
|
@ -2,16 +2,30 @@
|
|
|
|
|
|
|
|
|
|
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 WebChatRequest, WebChatResponse, WebErrorResponse, WebStatusResponse
|
|
|
|
|
from .schemas import (
|
|
|
|
|
WebChatFeedbackRequest,
|
|
|
|
|
WebChatFeedbackResponse,
|
|
|
|
|
WebChatRequest,
|
|
|
|
|
WebChatResponse,
|
|
|
|
|
WebErrorResponse,
|
|
|
|
|
WebProviderConfigRequest,
|
|
|
|
|
WebProviderConfigResponse,
|
|
|
|
|
WebStatusResponse,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
|
|
|
@ -50,6 +64,24 @@ except ModuleNotFoundError: # pragma: no cover - fallback for skeleton-only env
|
|
|
|
|
|
|
|
|
|
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(
|
|
|
|
|
@ -82,9 +114,28 @@ async def _app_lifespan(
|
|
|
|
|
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,
|
|
|
|
|
@ -133,6 +184,412 @@ def create_app(
|
|
|
|
|
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,
|
|
|
|
|
@ -191,11 +648,132 @@ def create_app(
|
|
|
|
|
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 的最小导出辅助。"""
|
|
|
|
|
|
|
|
|
|
@ -206,3 +784,52 @@ def _model_dump(value: Any) -> dict[str, Any] | None:
|
|
|
|
|
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
|
|
|
|
|
|