1644 lines
72 KiB
Python
1644 lines
72 KiB
Python
"""CRUD API for custom agents."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import time
|
|
from datetime import datetime
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
import yaml
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.gateway.auth.password import verify_password_async
|
|
from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_tag_store
|
|
from app.gateway.routers._strategy_selection_seed import (
|
|
STRATEGY_AGENT_ID_PREFIX,
|
|
)
|
|
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
|
from deerflow.config.agents_api_config import get_agents_api_config
|
|
from deerflow.config.agents_config import CommonQuestion, list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.config.paths import get_paths
|
|
from deerflow.config.system_settings import load_system_settings, normalize_square_id, resolve_default_square_id
|
|
from deerflow.persistence.agents import AgentStore
|
|
from deerflow.persistence.skills import SkillStore
|
|
from deerflow.persistence.tags import TagStore
|
|
from deerflow.runtime.user_context import get_effective_user_id
|
|
from deerflow.skills.cascade import sync_agent_skills_to_available
|
|
from deerflow.skills.storage import get_or_new_skill_storage
|
|
from deerflow.skills.types import SkillCategory
|
|
from deerflow.skills.usage import all_entries as get_skill_usage_entries
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api", tags=["agents"])
|
|
|
|
|
|
def _is_strategy_agent_id(agent_id: str) -> bool:
|
|
return agent_id.casefold().startswith(STRATEGY_AGENT_ID_PREFIX.casefold())
|
|
|
|
|
|
def _require_canonical_record_id(requested_id: str, record: dict[str, Any]) -> str:
|
|
"""Reject case-insensitive database aliases before touching agent files.
|
|
|
|
MySQL deployments may use a case-insensitive collation while Windows agent
|
|
directories are also case-insensitive. A lookup for a differently-cased
|
|
id must never be allowed to select one row and mutate another path.
|
|
"""
|
|
|
|
canonical_id = str(record.get("id") or "")
|
|
if canonical_id != requested_id:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Agent id casing mismatch; use canonical id '{canonical_id}'",
|
|
)
|
|
return canonical_id
|
|
|
|
|
|
class AgentResponse(BaseModel):
|
|
"""Response model for an agent."""
|
|
|
|
id: str = Field(..., description="Agent id used for routing and filesystem storage")
|
|
name: str = Field(..., description="Display name. Chinese and duplicate names are allowed.")
|
|
description: str = Field(default="", description="Agent description")
|
|
user_id: str | None = Field(default=None, description="Owner user id. Null means built-in.")
|
|
owner_username: str | None = Field(default=None, description="Publisher username (email local part). Null for built-in agents.")
|
|
published: bool = Field(default=False, description="Whether a user-created agent is public")
|
|
is_private: bool = Field(default=False, description="Whether the agent lives in the password-gated private square instead of the public one")
|
|
square_id: str = Field(default="", description="Publish square (发布广场) the agent is filed under. Empty string = the default square. Independent of is_private.")
|
|
builtin: bool = Field(default=False, description="True when user_id is null")
|
|
created_at: datetime | str | None = None
|
|
updated_at: datetime | str | None = None
|
|
soul: str | None = Field(default=None, description="SOUL.md content")
|
|
|
|
# Deprecated compatibility fields. Agent runtime configuration is no longer
|
|
# stored in the agents table, but older frontends may still read these keys.
|
|
model: str | None = None
|
|
tool_groups: list[str] | None = None
|
|
skills: list[str] | None = None
|
|
|
|
common_questions: list[CommonQuestion] | None = Field(
|
|
default=None,
|
|
description="Recommended questions shown on the agent chat home / sidebar (常用问题). Each item is {title, prompt}; legacy string lists are coerced.",
|
|
)
|
|
|
|
template_name: str | None = Field(
|
|
default=None,
|
|
description="入库模板名 (external knowledge-ingest templateName). When set, markdown artifacts produced in this agent's chats can be published to the external knowledge-ingest API.",
|
|
)
|
|
|
|
seat_skill_directive: bool | None = Field(
|
|
default=None,
|
|
description="会商席位「技能识别强化」开关 (config.yaml). True = 本智能体作为会商席位时强化技能识别 (与业务链条同名开关取 OR). None/False = 关 (默认).",
|
|
)
|
|
|
|
llmwiki_knowledge_base_ids: list[str] | None = Field(
|
|
default=None,
|
|
description="DeerFlow LLMWiki mapping ids this agent may offer for conversation retrieval.",
|
|
)
|
|
|
|
tags: list[dict] = Field(default_factory=list, description="Tags assigned to this agent")
|
|
|
|
featured_order: int | None = Field(
|
|
default=None,
|
|
description="Admin-curated homepage rank. Null = not featured. Lower sorts first.",
|
|
)
|
|
|
|
is_favorite: bool = Field(
|
|
default=False,
|
|
description="Whether the requesting user has favorited (收藏) this agent.",
|
|
)
|
|
|
|
is_pinned: bool = Field(
|
|
default=False,
|
|
description="Whether the requesting user has pinned this agent.",
|
|
)
|
|
|
|
|
|
class AgentsListResponse(BaseModel):
|
|
"""Response model for listing agents.
|
|
|
|
``total`` / ``page`` / ``page_size`` describe server-side pagination. When
|
|
a caller does not opt into pagination they default to "everything on one
|
|
page" (``total == len(agents)``, ``page == 1``), so existing unpaginated
|
|
callers keep working unchanged.
|
|
"""
|
|
|
|
agents: list[AgentResponse]
|
|
total: int = Field(default=0, description="Total agents matching the query across all pages")
|
|
page: int = Field(default=1, description="1-based page index of this response")
|
|
page_size: int = Field(default=0, description="Page size; 0 means the result was not paginated")
|
|
|
|
|
|
class AgentCreateRequest(BaseModel):
|
|
"""Request body for creating a custom agent."""
|
|
|
|
id: str | None = Field(
|
|
default=None,
|
|
description="Optional explicit storage id. Must match the storage id pattern. If omitted, a UUID is generated.",
|
|
)
|
|
name: str = Field(..., description="Display name; Chinese and duplicate names are allowed")
|
|
description: str = Field(default="", description="Agent description")
|
|
soul: str = Field(default="", description="SOUL.md content - agent personality and behavioral guardrails")
|
|
published: bool = Field(default=False, description="Whether the new agent should be public")
|
|
is_private: bool = Field(default=False, description="When True, the new agent goes into the password-gated private square instead of the public one")
|
|
square_id: str | None = Field(default=None, description="Publish square (发布广场) to file the agent under. Omit / empty = the default square.")
|
|
skills: list[str] | None = Field(default=None, description="Skill whitelist persisted to the agent's config.yaml")
|
|
model: str | None = Field(default=None, description="Optional model override persisted to config.yaml")
|
|
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
|
|
common_questions: list[CommonQuestion] | None = Field(default=None, description="Recommended questions (常用问题) persisted to config.yaml. Each item is {title, prompt}; legacy strings are coerced.")
|
|
template_name: str | None = Field(default=None, description="入库模板名 persisted to config.yaml. Enables publishing markdown artifacts to the external knowledge-ingest API.")
|
|
seat_skill_directive: bool | None = Field(default=None, description="会商席位「技能识别强化」开关 persisted to config.yaml. None/False = off (默认).")
|
|
llmwiki_knowledge_base_ids: list[str] | None = Field(
|
|
default=None,
|
|
max_length=100,
|
|
description="Allowed DeerFlow LLMWiki mapping ids persisted to config.yaml.",
|
|
)
|
|
|
|
|
|
class AgentUpdateRequest(BaseModel):
|
|
"""Request body for updating a custom agent."""
|
|
|
|
name: str | None = Field(default=None, description="Updated display name")
|
|
description: str | None = Field(default=None, description="Updated description")
|
|
soul: str | None = Field(default=None, description="Updated SOUL.md content")
|
|
published: bool | None = Field(default=None, description="Updated publication state")
|
|
is_private: bool | None = Field(default=None, description="Move the agent into / out of the private square (true / false)")
|
|
square_id: str | None = Field(default=None, description="Publish square (发布广场) to file the agent under. Empty string = the default square.")
|
|
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (config.yaml)")
|
|
model: str | None = Field(default=None, description="Updated model override (config.yaml)")
|
|
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
|
|
common_questions: list[CommonQuestion] | None = Field(default=None, description="Updated recommended questions (常用问题, config.yaml). Each item is {title, prompt}; legacy strings are coerced.")
|
|
template_name: str | None = Field(default=None, description="Updated 入库模板名 (config.yaml). Empty string clears it.")
|
|
seat_skill_directive: bool | None = Field(default=None, description="Updated 会商席位「技能识别强化」开关 (config.yaml).")
|
|
llmwiki_knowledge_base_ids: list[str] | None = Field(
|
|
default=None,
|
|
max_length=100,
|
|
description="Updated allowed DeerFlow LLMWiki mapping ids (config.yaml).",
|
|
)
|
|
|
|
|
|
class AgentSelectorResponse(BaseModel):
|
|
"""Response for the homepage agent selector dropdown.
|
|
|
|
The list is fully curated by admins (``featured_order`` on each row).
|
|
Non-admin users get a read-only view of the same list.
|
|
"""
|
|
|
|
agents: list[AgentResponse] = Field(
|
|
default_factory=list,
|
|
description="Admin-curated agents, sorted by featured_order ascending.",
|
|
)
|
|
|
|
|
|
class AgentFeaturedRequest(BaseModel):
|
|
"""Request body for setting/clearing an agent's homepage featured rank."""
|
|
|
|
order: int | None = Field(
|
|
default=None,
|
|
description="Featured rank. Lower sorts first. Pass null to remove the agent from the homepage selector.",
|
|
)
|
|
|
|
|
|
def _require_agents_api_enabled() -> None:
|
|
"""Reject access unless the custom-agent management API is explicitly enabled."""
|
|
if not get_agents_api_config().enabled:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=("Custom-agent management API is disabled. Set agents_api.enabled=true to expose agent and user-profile routes over HTTP."),
|
|
)
|
|
|
|
|
|
def _current_user_id(request: Request) -> str:
|
|
user = getattr(request.state, "user", None)
|
|
if user is not None:
|
|
return str(user.id)
|
|
return get_effective_user_id()
|
|
|
|
|
|
def _is_admin_user(request: Request) -> bool:
|
|
"""Return True when the request is authenticated as a system admin."""
|
|
user = getattr(request.state, "user", None)
|
|
return getattr(user, "system_role", None) == "admin"
|
|
|
|
|
|
async def _validate_llmwiki_bindings(
|
|
request: Request,
|
|
mapping_ids: list[str] | None,
|
|
) -> list[str] | None:
|
|
"""Normalize bindings and require read access for the editing actor."""
|
|
|
|
if mapping_ids is None:
|
|
return None
|
|
normalized = list(dict.fromkeys(item.strip() for item in mapping_ids if item.strip()))
|
|
if not normalized:
|
|
return []
|
|
store = getattr(request.app.state, "llmwiki_store", None)
|
|
if store is None:
|
|
raise HTTPException(status_code=503, detail="LLMWiki metadata store is unavailable")
|
|
user_id = _current_user_id(request)
|
|
is_admin = _is_admin_user(request)
|
|
for mapping_id in normalized:
|
|
row = await store.get_authorized(
|
|
mapping_id,
|
|
user_id,
|
|
write=False,
|
|
is_admin=is_admin,
|
|
)
|
|
if row is None:
|
|
raise HTTPException(status_code=422, detail=f"LLMWiki knowledge base '{mapping_id}' is unavailable")
|
|
return normalized
|
|
|
|
|
|
def _email_local_part(email: str | None) -> str | None:
|
|
"""Return the username portion (before ``@``) of an email address."""
|
|
if not email:
|
|
return None
|
|
at = email.find("@")
|
|
return email[:at] if at > 0 else email
|
|
|
|
|
|
async def _resolve_usernames(user_ids: set[str]) -> dict[str, str]:
|
|
"""Batch-resolve owner user ids to friendly usernames (email local part).
|
|
|
|
One lookup per distinct id; tolerates missing accounts (deleted users) and
|
|
an uninitialised auth provider by simply omitting those ids from the map.
|
|
"""
|
|
user_ids = {uid for uid in user_ids if uid}
|
|
if not user_ids:
|
|
return {}
|
|
try:
|
|
from app.gateway.deps import get_local_provider
|
|
|
|
provider = get_local_provider()
|
|
except Exception:
|
|
return {}
|
|
names: dict[str, str] = {}
|
|
for uid in user_ids:
|
|
try:
|
|
user = await provider.get_user(uid)
|
|
except Exception:
|
|
user = None
|
|
local = _email_local_part(getattr(user, "email", None)) if user is not None else None
|
|
if local:
|
|
names[uid] = local
|
|
return names
|
|
|
|
|
|
def _validate_display_name(name: str) -> str:
|
|
normalized = name.strip()
|
|
if not normalized:
|
|
raise HTTPException(status_code=422, detail="Agent name cannot be empty")
|
|
return normalized
|
|
|
|
|
|
def _validate_storage_id(agent_id: str) -> str:
|
|
try:
|
|
return validate_agent_id(agent_id) or agent_id
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
|
|
|
|
def _validate_strategy_agent_model(
|
|
raw_model: str | None,
|
|
*,
|
|
app_config: AppConfig,
|
|
) -> str | None:
|
|
"""Normalize a shared strategy role's model and reject stale names."""
|
|
|
|
model = (raw_model or "").strip() or None
|
|
if model is None:
|
|
return None
|
|
if model not in {entry.name for entry in app_config.models}:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"Unknown strategy agent model: {model}",
|
|
)
|
|
return model
|
|
|
|
|
|
async def _validate_strategy_agent_skills(
|
|
requested_skills: list[str] | None,
|
|
*,
|
|
app_config: AppConfig,
|
|
skill_store: SkillStore,
|
|
) -> list[str] | None:
|
|
"""Validate a global strategy role's skill allowlist.
|
|
|
|
Strategy-selection agents are built-in roles shared by every project and
|
|
every user. Their allowlists may therefore reference only canonical skill
|
|
names that exist on disk, are enabled/active, and are globally visible:
|
|
deployment-owned public skills, explicit built-in rows, or custom skills
|
|
currently published by their owner. In particular, an administrator must
|
|
not be able to attach a different user's unpublished skill merely because
|
|
``?admin=true`` exposes it in the management catalog.
|
|
"""
|
|
|
|
if requested_skills is None:
|
|
return None
|
|
|
|
normalized: list[str] = []
|
|
for raw_name in requested_skills:
|
|
name = raw_name.strip()
|
|
if not name:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail="Strategy agent skill names cannot be blank",
|
|
)
|
|
if name not in normalized:
|
|
normalized.append(name)
|
|
|
|
try:
|
|
skills = get_or_new_skill_storage(app_config=app_config).load_skills(enabled_only=False)
|
|
ownership = {str(record.get("name") or ""): record for record in await skill_store.list_all() if record.get("name")}
|
|
usage_entries = get_skill_usage_entries()
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc: # noqa: BLE001 - stable API response
|
|
logger.exception("Failed to validate strategy-agent skill allowlist")
|
|
raise HTTPException(status_code=503, detail="Skill catalog is unavailable") from exc
|
|
|
|
by_name = {skill.name: skill for skill in skills}
|
|
unknown = [name for name in normalized if name not in by_name]
|
|
if unknown:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"Unknown strategy agent skills: {', '.join(unknown)}",
|
|
)
|
|
|
|
unavailable = [name for name in normalized if not by_name[name].enabled or str((usage_entries.get(name) or {}).get("state") or "active") != "active"]
|
|
if unavailable:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"Disabled or archived strategy agent skills: {', '.join(unavailable)}",
|
|
)
|
|
|
|
not_global: list[str] = []
|
|
for name in normalized:
|
|
skill = by_name[name]
|
|
if skill.category != SkillCategory.CUSTOM:
|
|
continue
|
|
record = ownership.get(name)
|
|
if record is not None and (record.get("owner_user_id") is None or bool(record.get("published"))):
|
|
continue
|
|
not_global.append(name)
|
|
if not_global:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=(f"Strategy agent skills must be globally available; unpublished or unregistered custom skills: {', '.join(not_global)}"),
|
|
)
|
|
|
|
return normalized
|
|
|
|
|
|
def _ensure_strategy_agent_deletable(agent_id: str) -> None:
|
|
"""Keep fixed strategy role ids alive; their SOUL/skills remain editable."""
|
|
|
|
if _is_strategy_agent_id(agent_id):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=(f"Strategy role agent '{agent_id}' cannot be deleted; edit its SOUL and skills instead"),
|
|
)
|
|
|
|
|
|
def _write_agent_config(
|
|
agent_id: str,
|
|
*,
|
|
name: str,
|
|
description: str,
|
|
skills: list[str] | None = None,
|
|
model: str | None = None,
|
|
tool_groups: list[str] | None = None,
|
|
common_questions: list[CommonQuestion] | None = None,
|
|
template_name: str | None = None,
|
|
seat_skill_directive: bool | None = None,
|
|
llmwiki_knowledge_base_ids: list[str] | None = None,
|
|
) -> None:
|
|
agent_dir = get_paths().agent_dir(agent_id)
|
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
|
config_file = agent_dir / "config.yaml"
|
|
config_data: dict[str, Any] = {
|
|
"id": agent_id,
|
|
"name": name,
|
|
"description": description or "",
|
|
}
|
|
if skills is not None:
|
|
config_data["skills"] = skills
|
|
if model is not None:
|
|
config_data["model"] = model
|
|
if tool_groups is not None:
|
|
config_data["tool_groups"] = tool_groups
|
|
if common_questions is not None:
|
|
# Persist as plain dicts (dropping blank rows) so config.yaml stays a
|
|
# clean mapping rather than a pydantic repr. Accepts already-coerced
|
|
# CommonQuestion models or raw dicts/strings.
|
|
normalized = [q if isinstance(q, CommonQuestion) else CommonQuestion.model_validate(q) for q in common_questions]
|
|
config_data["common_questions"] = [q.model_dump() for q in normalized if q.prompt.strip()]
|
|
if template_name is not None and template_name.strip():
|
|
config_data["template_name"] = template_name.strip()
|
|
# 会商席位技能识别强化开关:仅当显式设置时写入(None = 不写, 由 AgentConfig 默认为关)。
|
|
if seat_skill_directive is not None:
|
|
config_data["seat_skill_directive"] = bool(seat_skill_directive)
|
|
if llmwiki_knowledge_base_ids is not None:
|
|
config_data["llmwiki_knowledge_base_ids"] = list(dict.fromkeys(item.strip() for item in llmwiki_knowledge_base_ids if item.strip()))
|
|
_atomic_write_text(
|
|
config_file,
|
|
yaml.safe_dump(config_data, default_flow_style=False, allow_unicode=True),
|
|
)
|
|
|
|
|
|
def _load_config_extras(agent_id: str) -> dict[str, Any]:
|
|
"""Read skills/model/tool_groups/common_questions from the agent's config.yaml, tolerant of missing files."""
|
|
empty = {
|
|
"skills": None,
|
|
"model": None,
|
|
"tool_groups": None,
|
|
"common_questions": None,
|
|
"template_name": None,
|
|
"seat_skill_directive": None,
|
|
"llmwiki_knowledge_base_ids": None,
|
|
}
|
|
try:
|
|
cfg = load_agent_config(agent_id)
|
|
except (FileNotFoundError, ValueError):
|
|
return dict(empty)
|
|
if cfg is None:
|
|
return dict(empty)
|
|
return {
|
|
"skills": cfg.skills,
|
|
"model": cfg.model,
|
|
"tool_groups": cfg.tool_groups,
|
|
"common_questions": cfg.common_questions,
|
|
"template_name": cfg.template_name,
|
|
"seat_skill_directive": cfg.seat_skill_directive,
|
|
"llmwiki_knowledge_base_ids": cfg.llmwiki_knowledge_base_ids,
|
|
}
|
|
|
|
|
|
def _write_agent_soul(agent_id: str, soul: str) -> None:
|
|
agent_dir = get_paths().agent_dir(agent_id)
|
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
|
_atomic_write_text(agent_dir / "SOUL.md", soul)
|
|
|
|
|
|
def _atomic_write_text(path, content: str) -> None:
|
|
"""Durably replace one small agent config file on the same filesystem."""
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temp_name: str | None = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w",
|
|
encoding="utf-8",
|
|
dir=path.parent,
|
|
prefix=f".{path.name}.",
|
|
suffix=".tmp",
|
|
delete=False,
|
|
) as handle:
|
|
temp_name = handle.name
|
|
handle.write(content)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(temp_name, path)
|
|
temp_name = None
|
|
finally:
|
|
if temp_name is not None:
|
|
try:
|
|
os.unlink(temp_name)
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
|
|
async def _sync_legacy_agents(store: AgentStore) -> None:
|
|
"""Import existing folder-backed agents as built-in DB records.
|
|
|
|
This preserves old deployments where agents already existed under
|
|
``.deer-flow/agents/{name}`` before the agents table was introduced.
|
|
|
|
Heavy: scans the agents directory + parses each ``config.yaml`` + issues a
|
|
SELECT-then-INSERT/UPDATE per agent. Call once at startup via the lifespan,
|
|
and rely on :func:`_maybe_sync_legacy_agents` (TTL-gated) inside request
|
|
handlers as a safety net for environments that bypassed the lifespan hook.
|
|
"""
|
|
try:
|
|
pairs: list[tuple[str, Any]] = []
|
|
for agent_cfg in list_custom_agents():
|
|
agent_id = agent_cfg.id or agent_cfg.name
|
|
if not agent_id:
|
|
continue
|
|
await store.ensure_builtin(
|
|
{
|
|
"id": agent_id,
|
|
"name": agent_cfg.name,
|
|
"description": agent_cfg.description or "",
|
|
"published": True,
|
|
}
|
|
)
|
|
pairs.append((agent_id, agent_cfg))
|
|
await _backfill_listing_cache(store, pairs)
|
|
except Exception:
|
|
logger.exception("Failed to sync legacy folder-backed agents")
|
|
|
|
|
|
async def _backfill_listing_cache(store: AgentStore, pairs: list[tuple[str, Any]]) -> None:
|
|
"""Mirror each agent's config.yaml skills/tool_groups/model into the DB cache.
|
|
|
|
The list endpoint reads skills/tool_groups from these cache rows instead of
|
|
stat+parsing every ``config.yaml`` per request. We fetch the current cache
|
|
in one batch and only re-write rows whose values actually changed, so a
|
|
steady-state sync (every ``_LEGACY_SYNC_TTL_SECONDS``) is cheap and does not
|
|
churn DELETE/INSERTs. For the cache, ``None`` and ``[]`` skills are
|
|
equivalent ("no skills") — the tri-state only matters to the agent runtime,
|
|
which reads config.yaml directly.
|
|
"""
|
|
if not pairs:
|
|
return
|
|
cached = await store.get_extras_for([aid for aid, _ in pairs])
|
|
for agent_id, cfg in pairs:
|
|
want_skills = [s for s in (cfg.skills or []) if isinstance(s, str)]
|
|
want_tool_groups = cfg.tool_groups
|
|
want_model = cfg.model
|
|
existing = cached.get(agent_id)
|
|
if existing is None or existing.get("skills") != want_skills:
|
|
await store.replace_skills(agent_id, want_skills)
|
|
if existing is None or existing.get("tool_groups") != want_tool_groups or existing.get("model") != want_model:
|
|
await store.set_extras(agent_id, tool_groups=want_tool_groups, model=want_model)
|
|
|
|
|
|
# Throttle the per-request legacy-sync fallback. The real sync runs once at
|
|
# startup (lifespan); request handlers used to call ``_sync_legacy_agents`` on
|
|
# every hit, which scanned the filesystem + did N DB upserts per agent each
|
|
# time. After the first successful sync we skip the work for ``_LEGACY_SYNC_TTL_SECONDS``
|
|
# — long enough to be effectively "startup-only" in steady state, short enough
|
|
# that a freshly dropped agent directory gets picked up on the next bucket.
|
|
_LEGACY_SYNC_TTL_SECONDS = 3600.0
|
|
_legacy_sync_state: dict[str, Any] = {"last_at": 0.0, "lock": None}
|
|
|
|
|
|
async def _maybe_sync_legacy_agents(store: AgentStore) -> None:
|
|
"""TTL-gated wrapper around :func:`_sync_legacy_agents`.
|
|
|
|
Becomes a no-op in steady state — the heavy lifting happens in the lifespan
|
|
hook. Inside the TTL window we don't even take the lock; outside it we
|
|
serialize callers so a thundering herd doesn't fan out into N filesystem
|
|
scans + N*M DB queries.
|
|
"""
|
|
now = time.monotonic()
|
|
if now - float(_legacy_sync_state["last_at"]) < _LEGACY_SYNC_TTL_SECONDS:
|
|
return
|
|
lock = _legacy_sync_state["lock"]
|
|
if lock is None:
|
|
lock = asyncio.Lock()
|
|
_legacy_sync_state["lock"] = lock
|
|
async with lock:
|
|
# Re-check under the lock — a sibling coroutine may have just refreshed.
|
|
if time.monotonic() - float(_legacy_sync_state["last_at"]) < _LEGACY_SYNC_TTL_SECONDS:
|
|
return
|
|
await _sync_legacy_agents(store)
|
|
_legacy_sync_state["last_at"] = time.monotonic()
|
|
|
|
|
|
def _mark_legacy_sync_fresh() -> None:
|
|
"""Called by the lifespan hook after running :func:`_sync_legacy_agents`."""
|
|
_legacy_sync_state["last_at"] = time.monotonic()
|
|
|
|
|
|
def _ensure_agent_dir_from_record(record: dict[str, Any]) -> None:
|
|
"""Lazily restore an agent's on-disk artifacts from its DB record.
|
|
|
|
The DB row is authoritative for the agent's existence. If the matching
|
|
``{base_dir}/agents/{id}/`` directory is missing (data dir moved between
|
|
machines, filesystem wiped, etc.) we recreate it with a minimal
|
|
``config.yaml`` + empty ``SOUL.md`` synthesized from the row. This restores
|
|
a usable agent without requiring the admin to manually delete + recreate.
|
|
"""
|
|
agent_id = record.get("id")
|
|
if not agent_id:
|
|
return
|
|
try:
|
|
agent_dir = get_paths().agent_dir(agent_id)
|
|
if agent_dir.exists():
|
|
return
|
|
logger.warning(
|
|
"Agent dir missing for '%s' — rebuilding minimal config.yaml + SOUL.md from DB",
|
|
agent_id,
|
|
)
|
|
_write_agent_config(
|
|
agent_id,
|
|
name=record.get("name") or agent_id,
|
|
description=record.get("description") or "",
|
|
)
|
|
_write_agent_soul(agent_id, "")
|
|
except Exception:
|
|
# Self-heal is best-effort. ``load_agent_config`` already tolerates a
|
|
# missing dir, so the worst case is the agent runs with default
|
|
# settings until someone re-edits it.
|
|
logger.exception("Failed to self-heal agent dir for '%s'", agent_id)
|
|
|
|
|
|
def _record_to_response(
|
|
record: dict[str, Any],
|
|
*,
|
|
include_soul: bool = False,
|
|
include_extras: bool = True,
|
|
) -> AgentResponse:
|
|
"""Convert a DB row to an API response.
|
|
|
|
Args:
|
|
include_soul: Read SOUL.md from disk and embed in ``soul``. Set False
|
|
in list endpoints — the frontend (gallery / recommend / selector)
|
|
never reads ``soul``, only the edit dialog does. Skipping this
|
|
avoids one synchronous file read per agent.
|
|
include_extras: Self-heal a missing agent dir and read deprecated
|
|
``model`` / ``tool_groups`` / ``skills`` from ``config.yaml``. Set
|
|
False in list endpoints to skip a stat + yaml parse per agent —
|
|
those fields are unused by current frontends and the dir restore
|
|
can happen lazily when the agent is actually opened.
|
|
"""
|
|
soul: str | None = None
|
|
extras: dict[str, Any] = {
|
|
"skills": None,
|
|
"model": None,
|
|
"tool_groups": None,
|
|
"common_questions": None,
|
|
"template_name": None,
|
|
"seat_skill_directive": None,
|
|
"llmwiki_knowledge_base_ids": None,
|
|
}
|
|
if include_extras:
|
|
_ensure_agent_dir_from_record(record)
|
|
extras = _load_config_extras(record["id"])
|
|
if include_soul:
|
|
soul = load_agent_soul(record["id"]) or ""
|
|
|
|
return AgentResponse(
|
|
id=record["id"],
|
|
name=record["name"],
|
|
description=record.get("description") or "",
|
|
user_id=record.get("user_id"),
|
|
published=bool(record.get("published", False)),
|
|
is_private=bool(record.get("is_private", False)),
|
|
square_id=str(record.get("square_id") or ""),
|
|
builtin=record.get("user_id") is None,
|
|
created_at=record.get("created_at"),
|
|
updated_at=record.get("updated_at"),
|
|
soul=soul,
|
|
model=extras["model"],
|
|
tool_groups=extras["tool_groups"],
|
|
skills=extras["skills"],
|
|
common_questions=extras.get("common_questions"),
|
|
template_name=extras.get("template_name"),
|
|
seat_skill_directive=extras.get("seat_skill_directive"),
|
|
llmwiki_knowledge_base_ids=extras.get("llmwiki_knowledge_base_ids"),
|
|
featured_order=record.get("featured_order"),
|
|
)
|
|
|
|
|
|
def _sort_agent_responses(responses: list[AgentResponse], sort: str) -> list[AgentResponse]:
|
|
reverse = sort not in {"time_asc", "updated_asc", "name_asc"}
|
|
if sort in {"name_asc", "name_desc"}:
|
|
ordered = sorted(responses, key=lambda r: (r.name or "").lower(), reverse=reverse)
|
|
elif sort in {"updated_desc", "updated_asc"}:
|
|
ordered = sorted(responses, key=lambda r: str(r.updated_at or ""), reverse=reverse)
|
|
else:
|
|
ordered = sorted(responses, key=lambda r: str(r.created_at or ""), reverse=reverse)
|
|
return sorted(ordered, key=lambda r: 0 if r.is_pinned else 1)
|
|
|
|
|
|
async def _record_to_response_async(
|
|
record: dict[str, Any],
|
|
*,
|
|
include_soul: bool = False,
|
|
include_extras: bool = True,
|
|
) -> AgentResponse:
|
|
"""Async wrapper: when we actually touch disk, hop to a worker thread so
|
|
we don't block the asyncio loop. List endpoints (no soul, no extras) skip
|
|
this overhead and stay on the calling task."""
|
|
if not include_soul and not include_extras:
|
|
return _record_to_response(record, include_soul=False, include_extras=False)
|
|
return await asyncio.to_thread(
|
|
_record_to_response,
|
|
record,
|
|
include_soul=include_soul,
|
|
include_extras=include_extras,
|
|
)
|
|
|
|
|
|
async def _get_owned_or_raise(store: AgentStore, agent_id: str, user_id: str) -> dict[str, Any]:
|
|
owned = await store.get_owned(agent_id, user_id)
|
|
if owned is not None:
|
|
return owned
|
|
visible = await store.get_visible(agent_id, user_id)
|
|
if visible is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
raise HTTPException(status_code=403, detail="You can only modify agents that you created")
|
|
|
|
|
|
async def _get_editable_or_raise(
|
|
store: AgentStore,
|
|
agent_id: str,
|
|
user_id: str,
|
|
*,
|
|
is_admin: bool,
|
|
) -> tuple[dict[str, Any], str]:
|
|
"""Return (record, edit_mode) where edit_mode is 'owner' or 'admin'.
|
|
|
|
Admins may edit any agent — built-in (``user_id IS NULL``) or owned by
|
|
another user — so the admin console can manage the full agent catalog.
|
|
Non-admin callers are still restricted to agents they own.
|
|
"""
|
|
owned = await store.get_owned(agent_id, user_id)
|
|
if owned is not None:
|
|
return owned, "owner"
|
|
if is_admin:
|
|
record = await store.get_any(agent_id)
|
|
if record is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
return record, "admin"
|
|
visible = await store.get_visible(agent_id, user_id)
|
|
if visible is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
raise HTTPException(status_code=403, detail="You can only modify agents that you created")
|
|
|
|
|
|
async def _build_list_responses(
|
|
store: AgentStore,
|
|
tag_store: TagStore,
|
|
user_id: str,
|
|
records: list[dict[str, Any]],
|
|
) -> list[AgentResponse]:
|
|
"""Build gallery responses for a set of agent rows WITHOUT touching disk.
|
|
|
|
skills / tool_groups / model come from the denormalized DB cache
|
|
(``get_extras_for`` — one batched query) instead of stat+parsing every
|
|
agent's ``config.yaml`` per request, which was the list-endpoint hot spot.
|
|
SOUL.md is skipped (only the edit dialog reads it). An agent absent from
|
|
the cache (never mirrored) keeps its ``None`` extras until the next sync.
|
|
"""
|
|
responses = [_record_to_response(a, include_soul=False, include_extras=False) for a in records]
|
|
ids = [r.id for r in responses]
|
|
extras = await store.get_extras_for(ids)
|
|
assignments = await tag_store.list_assignments("agent", ids)
|
|
favorite_ids = await store.list_favorite_ids(user_id)
|
|
pin_ids = await store.list_pin_ids(user_id)
|
|
usernames = await _resolve_usernames({r.user_id for r in responses if r.user_id})
|
|
for resp in responses:
|
|
cached = extras.get(resp.id)
|
|
if cached is not None:
|
|
resp.skills = cached.get("skills")
|
|
resp.tool_groups = cached.get("tool_groups")
|
|
resp.model = cached.get("model")
|
|
resp.tags = assignments.get(resp.id, [])
|
|
resp.is_favorite = resp.id in favorite_ids
|
|
resp.is_pinned = resp.id in pin_ids
|
|
if resp.user_id:
|
|
resp.owner_username = usernames.get(resp.user_id)
|
|
return responses
|
|
|
|
|
|
@router.get(
|
|
"/agents",
|
|
response_model=AgentsListResponse,
|
|
summary="List Agents",
|
|
description="List built-in agents, the current user's agents, and published agents.",
|
|
)
|
|
async def list_agents(
|
|
request: Request,
|
|
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on agent name (raw or desensitized display form)"),
|
|
tag_ids: list[str] | None = Query(default=None, description="Keep agents carrying any of these tags (OR)"),
|
|
scope: str | None = Query(
|
|
default=None,
|
|
pattern="^(mine|square|builtin)$",
|
|
description="Pagination scope. 'mine' = owned + favorited; 'square' = built-in/published public; 'builtin' = 内置智能体 (user_id IS NULL, **admin only**). Omit for the default combined visibility list.",
|
|
),
|
|
page: int | None = Query(default=None, ge=1, description="1-based page index. Providing this (or 'scope') enables server-side pagination."),
|
|
page_size: int = Query(default=20, ge=1, le=200, description="Items per page when paginating."),
|
|
sort: str = Query(
|
|
default="time_desc",
|
|
pattern="^(time_desc|time_asc|updated_desc|updated_asc|name_asc|name_desc)$",
|
|
description="Sort order. Default is newest created first.",
|
|
),
|
|
owner: str | None = Query(
|
|
default=None,
|
|
description="Keep only agents published by this user id (the card's publisher). Built-in agents never match.",
|
|
),
|
|
square: str | None = Query(
|
|
default=None,
|
|
description="Keep only agents filed under this publish square (发布广场). Omit for all squares.",
|
|
),
|
|
admin: bool = Query(
|
|
default=False,
|
|
description=("Admin-only override. When ``true`` and the caller is a system admin, every agent is returned (built-in + every user's agents + private-square entries) regardless of the usual ownership/visibility filter."),
|
|
),
|
|
) -> AgentsListResponse:
|
|
_require_agents_api_enabled()
|
|
|
|
try:
|
|
store = get_agent_store(request)
|
|
tag_store: TagStore = get_tag_store(request)
|
|
await _maybe_sync_legacy_agents(store)
|
|
user_id = _current_user_id(request)
|
|
if admin and not _is_admin_user(request):
|
|
raise HTTPException(status_code=403, detail="Only system admins can list all agents")
|
|
# 「内置智能体」筛选仅限管理员(普通用户不应看到全部内置/功能型 agent 目录)。
|
|
if scope == "builtin" and not _is_admin_user(request):
|
|
raise HTTPException(status_code=403, detail="Only system admins can list built-in agents")
|
|
|
|
paginate = scope is not None or page is not None
|
|
square_id = square.strip() if square and square.strip() else None
|
|
square_is_default = False
|
|
if square_id:
|
|
sq = load_system_settings().publish_squares
|
|
square_is_default = square_id == resolve_default_square_id(sq)
|
|
if paginate:
|
|
eff_page = page or 1
|
|
rows, total = await store.list_paginated(
|
|
user_id=user_id,
|
|
scope=scope,
|
|
search=search,
|
|
tag_ids=tag_ids,
|
|
page=eff_page,
|
|
page_size=page_size,
|
|
admin=admin,
|
|
sort=sort,
|
|
owner=owner,
|
|
square_id=square_id,
|
|
square_is_default=square_is_default,
|
|
)
|
|
responses = await _build_list_responses(store, tag_store, user_id, rows)
|
|
return AgentsListResponse(agents=responses, total=total, page=eff_page, page_size=page_size)
|
|
|
|
# Legacy unpaginated path — returns everything (admin console, callers
|
|
# that have not migrated to pagination). Extras now come from the DB
|
|
# cache too, so this path no longer reads config.yaml per agent.
|
|
agents = await store.list_all() if admin else await store.list_visible(user_id)
|
|
responses = await _build_list_responses(store, tag_store, user_id, agents)
|
|
if search:
|
|
needle = search.strip().lower()
|
|
responses = [r for r in responses if needle in r.name.lower()]
|
|
if tag_ids:
|
|
wanted = set(tag_ids)
|
|
responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)]
|
|
if owner and owner.strip():
|
|
responses = [r for r in responses if r.user_id == owner.strip()]
|
|
if square_id:
|
|
responses = [r for r in responses if (r.square_id or "") == square_id or (square_is_default and not (r.square_id or ""))]
|
|
responses = _sort_agent_responses(responses, sort)
|
|
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to list agents: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}") from e
|
|
|
|
|
|
@router.get(
|
|
"/agents/check",
|
|
summary="Check Agent Name",
|
|
description="Validate an agent display name. Duplicate names are allowed.",
|
|
)
|
|
async def check_agent_name(name: str) -> dict[str, Any]:
|
|
_require_agents_api_enabled()
|
|
normalized = _validate_display_name(name)
|
|
return {"available": True, "name": normalized}
|
|
|
|
|
|
@router.get(
|
|
"/agents/selector",
|
|
response_model=AgentSelectorResponse,
|
|
summary="Agent Homepage Selector",
|
|
description=("Return the admin-curated agent list shown on the new-chat homepage. Sorted by ``featured_order`` ascending. Visibility-filtered, so an agent the caller can't see (author un-published) is silently omitted."),
|
|
)
|
|
async def get_agent_selector(request: Request) -> AgentSelectorResponse:
|
|
_require_agents_api_enabled()
|
|
try:
|
|
store = get_agent_store(request)
|
|
await _maybe_sync_legacy_agents(store)
|
|
|
|
user_id = _current_user_id(request)
|
|
records = await store.list_featured(user_id)
|
|
# Selector dropdown only renders name/description/featured_order — same
|
|
# rationale as list_agents: skip soul + extras + self-heal.
|
|
return AgentSelectorResponse(agents=[_record_to_response(r, include_soul=False, include_extras=False) for r in records])
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to load agent selector: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to load agent selector: {str(e)}") from e
|
|
|
|
|
|
@router.get(
|
|
"/agents/{agent_id}",
|
|
response_model=AgentResponse,
|
|
summary="Get Agent",
|
|
description="Retrieve details and SOUL.md content for a visible agent.",
|
|
)
|
|
async def get_agent(agent_id: str, request: Request) -> AgentResponse:
|
|
_require_agents_api_enabled()
|
|
agent_id = _validate_storage_id(agent_id)
|
|
|
|
try:
|
|
store = get_agent_store(request)
|
|
await _maybe_sync_legacy_agents(store)
|
|
user_id = _current_user_id(request)
|
|
record = await store.get_visible(agent_id, user_id)
|
|
if record is None and _is_admin_user(request):
|
|
# The admin console lists every agent (?admin=true), so an admin
|
|
# must be able to open any of them — mirror that here.
|
|
record = await store.get_any(agent_id)
|
|
if record is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
# Detail view legitimately reads SOUL.md + config.yaml — hop to a
|
|
# worker thread so the asyncio loop stays responsive under concurrent
|
|
# detail fetches.
|
|
response = await _record_to_response_async(record, include_soul=True, include_extras=True)
|
|
favorite_ids = await store.list_favorite_ids(user_id)
|
|
pin_ids = await store.list_pin_ids(user_id)
|
|
response.is_favorite = agent_id in favorite_ids
|
|
response.is_pinned = agent_id in pin_ids
|
|
return response
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to get agent '%s': %s", agent_id, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to get agent: {str(e)}") from e
|
|
|
|
|
|
@router.post(
|
|
"/agents",
|
|
response_model=AgentResponse,
|
|
status_code=201,
|
|
summary="Create Agent",
|
|
description="Create a new user-owned agent. The display name can be Chinese and may duplicate existing names.",
|
|
)
|
|
async def create_agent_endpoint(request: AgentCreateRequest, http_request: Request) -> AgentResponse:
|
|
_require_agents_api_enabled()
|
|
name = _validate_display_name(request.name)
|
|
store = get_agent_store(http_request)
|
|
|
|
if request.id:
|
|
agent_id = _validate_storage_id(request.id)
|
|
if _is_strategy_agent_id(agent_id):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=("The 'strategy-selection-' id namespace is reserved; create strategy angle agents through /api/strategy-agents/angles"),
|
|
)
|
|
if await store.get_any(agent_id) is not None:
|
|
raise HTTPException(status_code=409, detail=f"Agent id '{agent_id}' already exists")
|
|
else:
|
|
agent_id = uuid4().hex
|
|
agent_dir = get_paths().agent_dir(agent_id)
|
|
llmwiki_ids = await _validate_llmwiki_bindings(
|
|
http_request,
|
|
request.llmwiki_knowledge_base_ids,
|
|
)
|
|
|
|
try:
|
|
if agent_dir.exists():
|
|
raise HTTPException(status_code=409, detail=f"Agent id '{agent_id}' already exists")
|
|
|
|
_write_agent_config(
|
|
agent_id,
|
|
name=name,
|
|
description=request.description or "",
|
|
skills=request.skills,
|
|
model=request.model,
|
|
tool_groups=request.tool_groups,
|
|
common_questions=request.common_questions,
|
|
template_name=request.template_name,
|
|
seat_skill_directive=request.seat_skill_directive,
|
|
llmwiki_knowledge_base_ids=llmwiki_ids,
|
|
)
|
|
_write_agent_soul(agent_id, request.soul)
|
|
|
|
record = await store.create(
|
|
{
|
|
"id": agent_id,
|
|
"name": name,
|
|
"description": request.description or "",
|
|
"user_id": _current_user_id(http_request),
|
|
"published": request.published,
|
|
"is_private": request.is_private,
|
|
# Normalize to a valid configured square id (default when unset).
|
|
"square_id": normalize_square_id(request.square_id, load_system_settings().publish_squares),
|
|
}
|
|
)
|
|
# Mirror skills/tool_groups/model into the listing cache so the gallery
|
|
# can render this agent's badges without reading its config.yaml.
|
|
await store.replace_skills(agent_id, request.skills)
|
|
await store.set_extras(agent_id, tool_groups=request.tool_groups, model=request.model)
|
|
logger.info("Created agent '%s' (%s) at %s", name, agent_id, agent_dir)
|
|
return await _record_to_response_async(record, include_soul=True, include_extras=True)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
if agent_dir.exists():
|
|
shutil.rmtree(agent_dir)
|
|
logger.error("Failed to create agent '%s': %s", request.name, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to create agent: {str(e)}") from e
|
|
|
|
|
|
@router.put(
|
|
"/agents/{agent_id}",
|
|
response_model=AgentResponse,
|
|
summary="Update Agent",
|
|
description="Update an existing user-owned agent.",
|
|
)
|
|
async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request: Request) -> AgentResponse:
|
|
_require_agents_api_enabled()
|
|
agent_id = _validate_storage_id(agent_id)
|
|
if _is_strategy_agent_id(agent_id):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=("Strategy agent configuration must be changed through /api/strategy-agents/{agent_id}"),
|
|
)
|
|
|
|
try:
|
|
store = get_agent_store(http_request)
|
|
user_id = _current_user_id(http_request)
|
|
is_admin = _is_admin_user(http_request)
|
|
current, edit_mode = await _get_editable_or_raise(store, agent_id, user_id, is_admin=is_admin)
|
|
_require_canonical_record_id(agent_id, current)
|
|
|
|
fields_set = request.model_fields_set
|
|
update_data: dict[str, Any] = {}
|
|
if "name" in fields_set:
|
|
update_data["name"] = _validate_display_name(request.name or "")
|
|
if "description" in fields_set:
|
|
update_data["description"] = request.description or ""
|
|
if "published" in fields_set:
|
|
update_data["published"] = bool(request.published)
|
|
if "is_private" in fields_set:
|
|
update_data["is_private"] = bool(request.is_private)
|
|
if "square_id" in fields_set:
|
|
# Normalize to a valid configured square id (default when unset /
|
|
# unknown), so the row never holds a dangling square reference.
|
|
update_data["square_id"] = normalize_square_id(request.square_id, load_system_settings().publish_squares)
|
|
# Mutual exclusion: an agent moved into the private square cannot
|
|
# also be publicly published. Force ``published=false`` whenever the
|
|
# same request sets ``is_private=true``, even if the client forgot
|
|
# to clear it. (When the request only sets one of the two, the
|
|
# other side is left untouched.)
|
|
if update_data.get("is_private") is True:
|
|
update_data["published"] = False
|
|
|
|
next_name = update_data.get("name", current["name"])
|
|
next_description = update_data.get("description", current.get("description") or "")
|
|
|
|
existing_extras = _load_config_extras(agent_id)
|
|
if "skills" in fields_set and _is_strategy_agent_id(agent_id):
|
|
next_skills = await _validate_strategy_agent_skills(
|
|
request.skills,
|
|
app_config=get_config(http_request),
|
|
skill_store=get_skill_store(http_request),
|
|
)
|
|
else:
|
|
next_skills = request.skills if "skills" in fields_set else existing_extras["skills"]
|
|
if "model" in fields_set and _is_strategy_agent_id(agent_id):
|
|
next_model = _validate_strategy_agent_model(
|
|
request.model,
|
|
app_config=get_config(http_request),
|
|
)
|
|
else:
|
|
next_model = request.model if "model" in fields_set else existing_extras["model"]
|
|
next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"]
|
|
next_common_questions = request.common_questions if "common_questions" in fields_set else existing_extras["common_questions"]
|
|
next_template_name = request.template_name if "template_name" in fields_set else existing_extras["template_name"]
|
|
next_seat_skill_directive = request.seat_skill_directive if "seat_skill_directive" in fields_set else existing_extras["seat_skill_directive"]
|
|
next_llmwiki_ids = await _validate_llmwiki_bindings(http_request, request.llmwiki_knowledge_base_ids) if "llmwiki_knowledge_base_ids" in fields_set else existing_extras["llmwiki_knowledge_base_ids"]
|
|
|
|
config_dirty = bool(update_data) or any(key in fields_set for key in ("skills", "model", "tool_groups", "common_questions", "template_name", "seat_skill_directive", "llmwiki_knowledge_base_ids"))
|
|
if config_dirty:
|
|
_write_agent_config(
|
|
agent_id,
|
|
name=next_name,
|
|
description=next_description,
|
|
skills=next_skills,
|
|
model=next_model,
|
|
tool_groups=next_tool_groups,
|
|
common_questions=next_common_questions,
|
|
template_name=next_template_name,
|
|
seat_skill_directive=next_seat_skill_directive,
|
|
llmwiki_knowledge_base_ids=next_llmwiki_ids,
|
|
)
|
|
# Keep the listing cache in lock-step with config.yaml, but only
|
|
# when the cached fields were actually touched — a partial update
|
|
# (e.g. 常用问题 only) skips the DELETE/INSERT churn.
|
|
if any(key in fields_set for key in ("skills", "model", "tool_groups")):
|
|
await store.replace_skills(agent_id, next_skills)
|
|
await store.set_extras(agent_id, tool_groups=next_tool_groups, model=next_model)
|
|
if request.soul is not None:
|
|
_write_agent_soul(agent_id, request.soul)
|
|
|
|
if not update_data:
|
|
record = current
|
|
elif edit_mode == "admin":
|
|
# Admins may edit built-in (``user_id IS NULL``) and any user-owned
|
|
# agent — ``update_any`` skips the ownership check that ``update``
|
|
# and ``update_builtin`` enforce.
|
|
record = await store.update_any(agent_id, update_data)
|
|
else:
|
|
record = await store.update(agent_id, user_id, update_data)
|
|
if record is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
|
|
logger.info("Updated agent '%s' (%s)", record["name"], agent_id)
|
|
return await _record_to_response_async(record, include_soul=True, include_extras=True)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to update agent '%s': %s", agent_id, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(e)}") from e
|
|
|
|
|
|
class UserProfileResponse(BaseModel):
|
|
"""Response model for the global user profile (USER.md)."""
|
|
|
|
content: str | None = Field(default=None, description="USER.md content, or null if not yet created")
|
|
|
|
|
|
class UserProfileUpdateRequest(BaseModel):
|
|
"""Request body for setting the global user profile."""
|
|
|
|
content: str = Field(default="", description="USER.md content - describes the user's background and preferences")
|
|
|
|
|
|
@router.get(
|
|
"/user-profile",
|
|
response_model=UserProfileResponse,
|
|
summary="Get User Profile",
|
|
description="Read the global USER.md file that is injected into all custom agents.",
|
|
)
|
|
async def get_user_profile() -> UserProfileResponse:
|
|
_require_agents_api_enabled()
|
|
|
|
try:
|
|
user_md_path = get_paths().user_md_file
|
|
if not user_md_path.exists():
|
|
return UserProfileResponse(content=None)
|
|
raw = user_md_path.read_text(encoding="utf-8").strip()
|
|
return UserProfileResponse(content=raw or None)
|
|
except Exception as e:
|
|
logger.error("Failed to read user profile: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to read user profile: {str(e)}") from e
|
|
|
|
|
|
@router.put(
|
|
"/user-profile",
|
|
response_model=UserProfileResponse,
|
|
summary="Update User Profile",
|
|
description="Write the global USER.md file that is injected into all custom agents.",
|
|
)
|
|
async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileResponse:
|
|
_require_agents_api_enabled()
|
|
|
|
try:
|
|
paths = get_paths()
|
|
paths.base_dir.mkdir(parents=True, exist_ok=True)
|
|
paths.user_md_file.write_text(request.content, encoding="utf-8")
|
|
logger.info("Updated USER.md at %s", paths.user_md_file)
|
|
return UserProfileResponse(content=request.content or None)
|
|
except Exception as e:
|
|
logger.error("Failed to update user profile: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update user profile: {str(e)}") from e
|
|
|
|
|
|
@router.delete(
|
|
"/agents/{agent_id}",
|
|
status_code=204,
|
|
summary="Delete Agent",
|
|
description="Delete a user-owned agent and all its files.",
|
|
)
|
|
async def delete_agent(agent_id: str, request: Request) -> None:
|
|
_require_agents_api_enabled()
|
|
agent_id = _validate_storage_id(agent_id)
|
|
if _is_strategy_agent_id(agent_id):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=("Strategy agents cannot be deleted through the generic API; use /api/strategy-agents/{agent_id} to disable an angle"),
|
|
)
|
|
|
|
try:
|
|
store = get_agent_store(request)
|
|
user_id = _current_user_id(request)
|
|
is_admin = _is_admin_user(request)
|
|
record, edit_mode = await _get_editable_or_raise(store, agent_id, user_id, is_admin=is_admin)
|
|
_require_canonical_record_id(agent_id, record)
|
|
_ensure_strategy_agent_deletable(agent_id)
|
|
|
|
if edit_mode == "admin":
|
|
deleted = await store.delete_any(agent_id)
|
|
else:
|
|
deleted = await store.delete(agent_id, user_id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
|
|
agent_dir = get_paths().agent_dir(agent_id)
|
|
if agent_dir.exists():
|
|
shutil.rmtree(agent_dir)
|
|
try:
|
|
await get_tag_store(request).unassign_all("agent", agent_id)
|
|
except Exception: # noqa: BLE001 — tag cleanup must not block deletion
|
|
pass
|
|
try:
|
|
await store.delete_favorites_for_agent(agent_id)
|
|
except Exception: # noqa: BLE001 — favorite cleanup must not block deletion
|
|
pass
|
|
try:
|
|
await store.delete_pins_for_agent(agent_id)
|
|
except Exception: # noqa: BLE001 - pin cleanup must not block deletion
|
|
pass
|
|
try:
|
|
await store.delete_extras_for_agent(agent_id)
|
|
except Exception: # noqa: BLE001 — cache cleanup must not block deletion
|
|
pass
|
|
logger.info("Deleted agent '%s' from %s", agent_id, agent_dir)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to delete agent '%s': %s", agent_id, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}") from e
|
|
|
|
|
|
@router.post(
|
|
"/agents/{agent_id}/favorite",
|
|
status_code=204,
|
|
summary="Favorite Agent",
|
|
description="Bookmark a visible agent into the current user's 我的 tab. Idempotent.",
|
|
)
|
|
async def favorite_agent(agent_id: str, request: Request) -> None:
|
|
_require_agents_api_enabled()
|
|
agent_id = _validate_storage_id(agent_id)
|
|
|
|
try:
|
|
store = get_agent_store(request)
|
|
user_id = _current_user_id(request)
|
|
record = await store.get_visible(agent_id, user_id)
|
|
if record is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
await store.add_favorite(user_id, agent_id)
|
|
logger.info("User '%s' favorited agent '%s'", user_id, agent_id)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to favorite agent '%s': %s", agent_id, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to favorite agent: {str(e)}") from e
|
|
|
|
|
|
@router.delete(
|
|
"/agents/{agent_id}/favorite",
|
|
status_code=204,
|
|
summary="Unfavorite Agent",
|
|
description="Remove an agent from the current user's favorites. No-op when not favorited.",
|
|
)
|
|
async def unfavorite_agent(agent_id: str, request: Request) -> None:
|
|
_require_agents_api_enabled()
|
|
agent_id = _validate_storage_id(agent_id)
|
|
|
|
try:
|
|
store = get_agent_store(request)
|
|
user_id = _current_user_id(request)
|
|
await store.remove_favorite(user_id, agent_id)
|
|
logger.info("User '%s' unfavorited agent '%s'", user_id, agent_id)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to unfavorite agent '%s': %s", agent_id, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to unfavorite agent: {str(e)}") from e
|
|
|
|
|
|
@router.post(
|
|
"/agents/{agent_id}/pin",
|
|
status_code=204,
|
|
summary="Pin Agent",
|
|
description="Pin a visible agent to the top of the current user's lists. Idempotent.",
|
|
)
|
|
async def pin_agent(agent_id: str, request: Request) -> None:
|
|
_require_agents_api_enabled()
|
|
agent_id = _validate_storage_id(agent_id)
|
|
|
|
try:
|
|
store = get_agent_store(request)
|
|
user_id = _current_user_id(request)
|
|
record = await store.get_visible(agent_id, user_id)
|
|
if record is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
await store.add_pin(user_id, agent_id)
|
|
logger.info("User '%s' pinned agent '%s'", user_id, agent_id)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to pin agent '%s': %s", agent_id, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to pin agent: {str(e)}") from e
|
|
|
|
|
|
@router.delete(
|
|
"/agents/{agent_id}/pin",
|
|
status_code=204,
|
|
summary="Unpin Agent",
|
|
description="Remove the current user's pin from an agent. No-op when not pinned.",
|
|
)
|
|
async def unpin_agent(agent_id: str, request: Request) -> None:
|
|
_require_agents_api_enabled()
|
|
agent_id = _validate_storage_id(agent_id)
|
|
|
|
try:
|
|
store = get_agent_store(request)
|
|
user_id = _current_user_id(request)
|
|
await store.remove_pin(user_id, agent_id)
|
|
logger.info("User '%s' unpinned agent '%s'", user_id, agent_id)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to unpin agent '%s': %s", agent_id, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to unpin agent: {str(e)}") from e
|
|
|
|
|
|
@router.put(
|
|
"/agents/{agent_id}/featured",
|
|
response_model=AgentResponse,
|
|
summary="Set Agent Featured Rank (admin)",
|
|
description=(
|
|
"Admin-only. Set or clear the homepage-selector featured rank for any agent. "
|
|
"Requires the agent to be ``published=true`` when a non-null rank is supplied; "
|
|
"passing ``order: null`` removes the agent from the selector and is always allowed."
|
|
),
|
|
)
|
|
async def set_agent_featured(
|
|
agent_id: str,
|
|
body: AgentFeaturedRequest,
|
|
request: Request,
|
|
) -> AgentResponse:
|
|
_require_agents_api_enabled()
|
|
agent_id = _validate_storage_id(agent_id)
|
|
|
|
if not _is_admin_user(request):
|
|
raise HTTPException(status_code=403, detail="Only system admins can set the homepage featured rank")
|
|
|
|
try:
|
|
store = get_agent_store(request)
|
|
await _maybe_sync_legacy_agents(store)
|
|
record = await store.get_any(agent_id)
|
|
if record is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
|
|
if body.order is not None and not record.get("published"):
|
|
raise HTTPException(status_code=409, detail="Only published agents can be featured on the homepage")
|
|
|
|
updated = await store.set_featured(agent_id, body.order)
|
|
if updated is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
logger.info("Set agent '%s' featured_order=%s by admin", agent_id, body.order)
|
|
return await _record_to_response_async(updated, include_soul=True, include_extras=True)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to set featured for agent '%s': %s", agent_id, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to set featured: {str(e)}") from e
|
|
|
|
|
|
class PrivateSquareListRequest(BaseModel):
|
|
"""Request body for listing all private-square agents."""
|
|
|
|
password: str = Field(..., description="Plain-text private-square password. Verified against the admin-configured hash before the listing is returned.")
|
|
search: str | None = Field(default=None, description="Case-insensitive fuzzy match on agent name (raw or desensitized).")
|
|
tag_ids: list[str] | None = Field(default=None, description="Keep agents carrying any of these tags (OR).")
|
|
page: int | None = Field(default=None, ge=1, description="1-based page index. Providing this enables server-side pagination.")
|
|
page_size: int = Field(default=20, ge=1, le=200, description="Items per page when paginating.")
|
|
sort: str = Field(
|
|
default="time_desc",
|
|
pattern="^(time_desc|time_asc|updated_desc|updated_asc|name_asc|name_desc)$",
|
|
description="Sort order. Default is newest created first.",
|
|
)
|
|
owner: str | None = Field(default=None, description="Keep only agents published by this user id (the card's publisher).")
|
|
|
|
|
|
class AgentPrivateUpdateRequest(BaseModel):
|
|
"""Admin-only request body for moving an agent into / out of the private square."""
|
|
|
|
is_private: bool = Field(..., description="Set true to move the agent into the private square, false to move it back into the public square.")
|
|
|
|
|
|
@router.post(
|
|
"/agents/private-square/list",
|
|
response_model=AgentsListResponse,
|
|
summary="List Private-Square Agents",
|
|
description=(
|
|
"Verify the admin-configured private-square password and return every "
|
|
"agent flagged ``is_private=true``. Returns 404 when no password is set, "
|
|
"403 when the supplied password does not match, and the full agent list "
|
|
"otherwise — regardless of ownership, since the password is the gate."
|
|
),
|
|
)
|
|
async def list_private_square(body: PrivateSquareListRequest, request: Request) -> AgentsListResponse:
|
|
_require_agents_api_enabled()
|
|
|
|
settings = load_system_settings().private_square
|
|
if not settings.password_hash:
|
|
raise HTTPException(status_code=404, detail="Private square password is not configured")
|
|
if not body.password or not await verify_password_async(body.password, settings.password_hash):
|
|
raise HTTPException(status_code=403, detail="Incorrect private square password")
|
|
|
|
try:
|
|
store = get_agent_store(request)
|
|
tag_store: TagStore = get_tag_store(request)
|
|
await _maybe_sync_legacy_agents(store)
|
|
# user_id is unused by the "private" scope filter (the password is the
|
|
# gate), but list_paginated requires it for its signature.
|
|
user_id = _current_user_id(request)
|
|
|
|
if body.page is not None:
|
|
rows, total = await store.list_paginated(
|
|
user_id=user_id,
|
|
scope="private",
|
|
search=body.search,
|
|
tag_ids=body.tag_ids,
|
|
page=body.page,
|
|
page_size=body.page_size,
|
|
sort=body.sort,
|
|
owner=body.owner,
|
|
)
|
|
responses = await _build_list_responses(store, tag_store, user_id, rows)
|
|
return AgentsListResponse(agents=responses, total=total, page=body.page, page_size=body.page_size)
|
|
|
|
records = await store.list_private()
|
|
responses = await _build_list_responses(store, tag_store, user_id, records)
|
|
responses = _sort_agent_responses(responses, body.sort)
|
|
return AgentsListResponse(agents=responses, total=len(responses), page=1, page_size=len(responses))
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to list private agents: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to list private agents: {str(e)}") from e
|
|
|
|
|
|
@router.put(
|
|
"/agents/{agent_id}/private",
|
|
response_model=AgentResponse,
|
|
summary="Move Agent Between Squares (admin)",
|
|
description=(
|
|
"Admin-only. Move any agent (built-in or user-owned) into or out of the "
|
|
"password-gated private square. Owners can already toggle the flag on "
|
|
"their own agents via the regular ``PUT /agents/{id}`` endpoint; this "
|
|
"endpoint exists so admins can re-categorize other users' agents too."
|
|
),
|
|
)
|
|
async def set_agent_private(
|
|
agent_id: str,
|
|
body: AgentPrivateUpdateRequest,
|
|
request: Request,
|
|
) -> AgentResponse:
|
|
_require_agents_api_enabled()
|
|
agent_id = _validate_storage_id(agent_id)
|
|
|
|
if not _is_admin_user(request):
|
|
raise HTTPException(status_code=403, detail="Only system admins can re-categorize other users' agents")
|
|
|
|
try:
|
|
store = get_agent_store(request)
|
|
await _sync_legacy_agents(store)
|
|
record = await store.get_any(agent_id)
|
|
if record is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
|
|
update: dict[str, Any] = {"is_private": bool(body.is_private)}
|
|
# Mutual exclusion — see ``update_agent`` above. Moving an agent into
|
|
# the private square also drops the public ``published`` flag so we
|
|
# never end up with an agent that's "in both squares".
|
|
if update["is_private"]:
|
|
update["published"] = False
|
|
if record.get("user_id") is None:
|
|
updated = await store.update_builtin(agent_id, update)
|
|
else:
|
|
updated = await store.update(agent_id, str(record["user_id"]), update)
|
|
if updated is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
|
|
logger.info("Admin set agent '%s' is_private=%s", agent_id, body.is_private)
|
|
return _record_to_response(updated, include_soul=True)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to set is_private for agent '%s': %s", agent_id, e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(e)}") from e
|
|
|
|
|
|
class AgentFeaturedReorderRequest(BaseModel):
|
|
"""Request body for admin to bulk-reorder the homepage featured list."""
|
|
|
|
agent_ids: list[str] = Field(
|
|
default_factory=list,
|
|
description=("Featured agent ids in the desired top→bottom display order. Ids not currently featured are silently skipped, so the client can send the current visible order without first reconciling with the server."),
|
|
)
|
|
|
|
|
|
@router.put(
|
|
"/agents/featured/order",
|
|
status_code=204,
|
|
summary="Reorder Featured Agents (admin)",
|
|
description=("Admin-only. Bulk-assign ``featured_order`` to each id in the order received (``0`` is topmost). After this, the homepage selector renders agents in exactly the supplied order. Ids not already featured are ignored."),
|
|
)
|
|
async def reorder_featured_agents(
|
|
body: AgentFeaturedReorderRequest,
|
|
request: Request,
|
|
) -> None:
|
|
_require_agents_api_enabled()
|
|
if not _is_admin_user(request):
|
|
raise HTTPException(status_code=403, detail="Only system admins can reorder the homepage selector")
|
|
|
|
try:
|
|
store = get_agent_store(request)
|
|
for index, agent_id in enumerate(body.agent_ids):
|
|
# ``set_featured`` is a no-op for unknown ids — it returns None and
|
|
# we move on without raising, matching the docstring.
|
|
await store.set_featured(agent_id, index)
|
|
logger.info("Admin reordered featured agents (%d ids)", len(body.agent_ids))
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to reorder featured agents: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to reorder featured agents: {str(e)}") from e
|
|
|
|
|
|
class AgentSkillRemovalEntry(BaseModel):
|
|
agent_id: str = Field(..., description="Agent storage id whose config.yaml was rewritten")
|
|
agent_name: str | None = Field(default=None, description="Display name from the DB, if any")
|
|
user_id: str | None = Field(default=None, description="Owner user id (null = built-in)")
|
|
removed_skills: list[str] = Field(..., description="Skill names that no longer exist on disk and were stripped")
|
|
|
|
|
|
class AgentSkillsSyncResponse(BaseModel):
|
|
checked: int = Field(..., description="Number of agent config.yaml files scanned")
|
|
modified: int = Field(..., description="Number of agents whose skills list was rewritten")
|
|
removals: list[AgentSkillRemovalEntry] = Field(default_factory=list, description="Per-agent removal details")
|
|
|
|
|
|
@router.post(
|
|
"/agents/admin/sync-skills",
|
|
response_model=AgentSkillsSyncResponse,
|
|
summary="Sync Agent Skills (admin)",
|
|
description=(
|
|
"Admin-only. Scan every agent's ``config.yaml`` and strip skill names that "
|
|
"no longer exist on disk. Repairs historical drift where a skill was "
|
|
"removed without the delete/unpublish cascade firing (e.g. deleted from "
|
|
"the filesystem manually, or removed in an older version of the code)."
|
|
),
|
|
)
|
|
async def admin_sync_agent_skills(
|
|
request: Request,
|
|
config: AppConfig = Depends(get_config),
|
|
) -> AgentSkillsSyncResponse:
|
|
_require_agents_api_enabled()
|
|
if not _is_admin_user(request):
|
|
raise HTTPException(status_code=403, detail="Only system admins can run agent-skills sync")
|
|
|
|
try:
|
|
storage = get_or_new_skill_storage(app_config=config)
|
|
all_skills = storage.load_skills(enabled_only=False)
|
|
available = {skill.name for skill in all_skills}
|
|
|
|
result = await asyncio.to_thread(sync_agent_skills_to_available, available)
|
|
|
|
# Enrich removals with display name + owner from the DB so the admin
|
|
# UI can show "我的智能体 X 移除了技能 Y" instead of just the id.
|
|
store = get_agent_store(request)
|
|
enriched: list[AgentSkillRemovalEntry] = []
|
|
for entry in result.get("removals", []):
|
|
agent_id = entry["agent_id"]
|
|
record = await store.get_any(agent_id)
|
|
enriched.append(
|
|
AgentSkillRemovalEntry(
|
|
agent_id=agent_id,
|
|
agent_name=(record.get("name") if record else None),
|
|
user_id=(record.get("user_id") if record else None),
|
|
removed_skills=entry["removed_skills"],
|
|
)
|
|
)
|
|
|
|
if result.get("modified"):
|
|
await refresh_skills_system_prompt_cache_async()
|
|
|
|
logger.info(
|
|
"Admin agent-skills sync: checked=%d modified=%d",
|
|
result["checked"],
|
|
result["modified"],
|
|
)
|
|
return AgentSkillsSyncResponse(
|
|
checked=result["checked"],
|
|
modified=result["modified"],
|
|
removals=enriched,
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("Failed to sync agent skills: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to sync agent skills: {str(e)}") from e
|