mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-29 09:26:00 +00:00
* feat(agents): database-backed storage for custom agent definitions Add an agent_storage.backend switch (default file, behaviour-unchanged) with a db backend that stores each custom agent as a row in the shared SQL persistence layer, so a multi-instance deployment sees the same agents on every node (#4331, #4357). Introduces an AgentStore interface routing all read/write surfaces, an agents table + migration 0006, startup validation, and a file->db importer. Follows the thread_meta store / run_events backend-switch / 0003_scheduled_tasks migration patterns; no new dependency. * fix(agents): make db storage path production-ready (review round 1) Addresses review feedback on the db/sync agent-storage path: - sql.py: mirror the async engine's per-connection SQLite PRAGMAs on the sync engine (busy_timeout=30000, synchronous=NORMAL, foreign_keys=ON, WAL) so both engines behave identically against the shared DB; guard the engine cache with a lock (double-checked) so concurrent first-touch cannot build duplicate engines or register the connect listener twice. - routers/agents.py + routers/assistants_compat.py: offload the sync-store reads that ran on the event loop (list/get/check, update's pre-read + legacy guard + refresh, and assistants_compat's four list routes) via asyncio.to_thread — on db+postgres each was a network round trip stalling the loop. Writes were already offloaded. - file.py: translate the create() mkdir(exist_ok=False) race FileExistsError into AgentExistsError (router 409, matching SqlAgentStore's IntegrityError path); correct the _write docstring — per-file atomic replace, two commits sequential not transactional. Tests: sync-engine PRAGMA + engine-cache reuse assertions; file create-race -> AgentExistsError; strict Blockbuster anchor over the read endpoints so a regression back onto the loop fails CI. * fix(agents): address round-2 review on the db store path - update_agent tool: align the docstring/inline comment with FileAgentStore._write. Cross-field write atomicity is db-only; the file backend commits config then soul via two sequential os.replace (a crash between them can leave a fresh config.yaml beside a stale SOUL.md). The dropped partial-write *reporting* is an intentional tradeoff — the stage-then-replace safety is preserved (test_update_agent_soul_failure_does_not_replace_config still holds). - SqlAgentStore.update(): true upsert. Catch IntegrityError on the insert-on-missing branch, re-fetch and apply, so two concurrent first-time writes (e.g. two setup_agent handshakes) converge instead of surfacing a raw UNIQUE(user_id, name) violation as a 500. Symmetric with create(). - get_agent_store(): document the graph-subprocess config-resolution invariant (the except->file fallback is a genuine no-config path, not a mask for a misconfigured graph process) and pin it with two tests driving the real get_app_config() file resolution: db resolves from an on-disk config.yaml, file fallback when config is unresolvable. * test(agents): cover SqlAgentStore.update() write-race upsert recovery Mandatory-TDD test for the round-2 fix in 0680340a: two concurrent first-time update()s where the loser's insert hits UNIQUE(user_id, name). Deterministically forces the IntegrityError recovery path by making the first _row probe miss the committed winner, and asserts last-writer-wins instead of a surfaced 500.
575 lines
24 KiB
Python
575 lines
24 KiB
Python
"""CRUD API for custom agents."""
|
|
|
|
import asyncio
|
|
import logging
|
|
import re
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
|
|
from deerflow.config.agents_api_config import get_agents_api_config
|
|
from deerflow.config.agents_config import (
|
|
AgentConfig,
|
|
AgentModelSettings,
|
|
list_custom_agents,
|
|
load_agent_config,
|
|
load_agent_soul,
|
|
preserve_non_managed_fields,
|
|
)
|
|
from deerflow.config.app_config import get_app_config
|
|
from deerflow.config.paths import get_paths
|
|
from deerflow.persistence.agents import AgentExistsError, get_agent_store
|
|
from deerflow.runtime.user_context import get_effective_user_id
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api", tags=["agents"])
|
|
|
|
AGENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9-]+$")
|
|
|
|
ReasoningEffort = Literal["low", "medium", "high"]
|
|
|
|
# Fields carrying a custom agent's per-agent model behavior (issue #4336),
|
|
# shared by the create/update request bodies and the response so the three
|
|
# stay in lockstep. ``model`` picks the profile; the rest layer on top of it.
|
|
_MODEL_BEHAVIOR_FIELDS = ("model", "model_settings", "thinking_enabled", "reasoning_effort")
|
|
|
|
|
|
class AgentResponse(BaseModel):
|
|
"""Response model for a custom agent."""
|
|
|
|
name: str = Field(..., description="Agent name (hyphen-case)")
|
|
description: str = Field(default="", description="Agent description")
|
|
model: str | None = Field(default=None, description="Optional model override")
|
|
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
|
|
skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None=all, []=none)")
|
|
model_settings: AgentModelSettings | None = Field(default=None, description="Per-agent sampling overrides (temperature / max_tokens)")
|
|
thinking_enabled: bool | None = Field(default=None, description="Per-agent thinking-mode default (None = runtime default)")
|
|
reasoning_effort: ReasoningEffort | None = Field(default=None, description="Per-agent reasoning-effort default (None = runtime default)")
|
|
soul: str | None = Field(default=None, description="SOUL.md content")
|
|
|
|
|
|
class AgentsListResponse(BaseModel):
|
|
"""Response model for listing all custom agents."""
|
|
|
|
agents: list[AgentResponse]
|
|
|
|
|
|
class AgentCreateRequest(BaseModel):
|
|
"""Request body for creating a custom agent."""
|
|
|
|
name: str = Field(..., description="Agent name (must match ^[A-Za-z0-9-]+$, stored as lowercase)")
|
|
description: str = Field(default="", description="Agent description")
|
|
model: str | None = Field(default=None, description="Optional model override")
|
|
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist")
|
|
skills: list[str] | None = Field(default=None, description="Optional skill whitelist (None=all enabled, []=none)")
|
|
model_settings: AgentModelSettings | None = Field(default=None, description="Per-agent sampling overrides (temperature / max_tokens)")
|
|
thinking_enabled: bool | None = Field(default=None, description="Per-agent thinking-mode default (None = runtime default)")
|
|
reasoning_effort: ReasoningEffort | None = Field(default=None, description="Per-agent reasoning-effort default (None = runtime default)")
|
|
soul: str = Field(default="", description="SOUL.md content — agent personality and behavioral guardrails")
|
|
|
|
|
|
class AgentUpdateRequest(BaseModel):
|
|
"""Request body for updating a custom agent."""
|
|
|
|
description: str | None = Field(default=None, description="Updated description")
|
|
model: str | None = Field(default=None, description="Updated model override")
|
|
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist")
|
|
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (None=all, []=none)")
|
|
model_settings: AgentModelSettings | None = Field(default=None, description="Updated per-agent sampling overrides")
|
|
thinking_enabled: bool | None = Field(default=None, description="Updated per-agent thinking-mode default")
|
|
reasoning_effort: ReasoningEffort | None = Field(default=None, description="Updated per-agent reasoning-effort default")
|
|
soul: str | None = Field(default=None, description="Updated SOUL.md content")
|
|
|
|
|
|
def _validate_agent_name(name: str) -> None:
|
|
"""Validate agent name against allowed pattern.
|
|
|
|
Args:
|
|
name: The agent name to validate.
|
|
|
|
Raises:
|
|
HTTPException: 422 if the name is invalid.
|
|
"""
|
|
if not AGENT_NAME_PATTERN.match(name):
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"Invalid agent name '{name}'. Must match ^[A-Za-z0-9-]+$ (letters, digits, and hyphens only).",
|
|
)
|
|
|
|
|
|
def _normalize_agent_name(name: str) -> str:
|
|
"""Normalize agent name to lowercase for filesystem storage."""
|
|
return name.lower()
|
|
|
|
|
|
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 _validate_model_exists(model: str | None) -> None:
|
|
"""Reject an agent ``model`` that is not a configured profile.
|
|
|
|
Mirrors the ``update_agent`` harness tool: without this, an unknown model
|
|
silently falls back to the default at runtime and the user sees confusing
|
|
repeated warnings on every later turn instead of an actionable error here.
|
|
``None``/empty means "use the global default" and is always allowed.
|
|
|
|
Best-effort: if the app config cannot be loaded (e.g. no ``config.yaml`` on
|
|
disk in a bare/test deployment), skip the check rather than failing the
|
|
write — the runtime still falls back to the default for an unknown model.
|
|
"""
|
|
if not model:
|
|
return
|
|
try:
|
|
app_config = get_app_config()
|
|
except Exception:
|
|
logger.warning("Could not load app config to validate agent model %r; skipping model existence check.", model)
|
|
return
|
|
if app_config.get_model_config(model) is None:
|
|
raise HTTPException(status_code=422, detail=f"Unknown model '{model}'. Use a model name defined under `models:` in config.yaml.")
|
|
|
|
|
|
def _merge_model_settings_update(value: AgentModelSettings, existing: AgentModelSettings | None) -> dict:
|
|
"""Merge an explicit ``model_settings`` update with existing sub-fields.
|
|
|
|
The top-level ``model_settings`` key is optional in update requests:
|
|
omitted means "preserve the current block", while explicit ``null`` means
|
|
"clear the block". Inside the block, omitted sub-fields should behave the
|
|
same way. This lets API callers update only ``temperature`` without
|
|
accidentally clearing an existing ``max_tokens``.
|
|
"""
|
|
merged = existing.model_dump(exclude_none=True) if existing is not None else {}
|
|
for field in value.model_fields_set:
|
|
field_value = getattr(value, field)
|
|
if field_value is None:
|
|
merged.pop(field, None)
|
|
else:
|
|
merged[field] = field_value
|
|
return merged
|
|
|
|
|
|
def _apply_model_behavior(config_data: dict, source: BaseModel, existing: AgentConfig | None = None) -> None:
|
|
"""Write the model-behavior fields (issue #4336) onto ``config_data``.
|
|
|
|
Only fields explicitly set on ``source`` (``model_fields_set``) are taken
|
|
from it; the rest fall back to ``existing`` (on update) so an omitted field
|
|
is preserved rather than cleared. A resulting ``None`` is dropped so the
|
|
persisted YAML stays minimal and "unset" round-trips cleanly.
|
|
"""
|
|
for field in _MODEL_BEHAVIOR_FIELDS:
|
|
if field in source.model_fields_set:
|
|
value = getattr(source, field)
|
|
else:
|
|
value = getattr(existing, field, None) if existing is not None else None
|
|
if value is None:
|
|
continue
|
|
if field == "model_settings" and isinstance(value, AgentModelSettings):
|
|
dumped_settings = _merge_model_settings_update(value, existing.model_settings if existing is not None else None)
|
|
if dumped_settings:
|
|
config_data[field] = dumped_settings
|
|
continue
|
|
config_data[field] = value.model_dump(exclude_none=True) if isinstance(value, BaseModel) else value
|
|
|
|
|
|
def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False, *, user_id: str | None = None) -> AgentResponse:
|
|
"""Convert AgentConfig to AgentResponse."""
|
|
soul: str | None = None
|
|
if include_soul:
|
|
soul = load_agent_soul(agent_cfg.name, user_id=user_id) or ""
|
|
|
|
return AgentResponse(
|
|
name=agent_cfg.name,
|
|
description=agent_cfg.description,
|
|
model=agent_cfg.model,
|
|
tool_groups=agent_cfg.tool_groups,
|
|
skills=agent_cfg.skills,
|
|
model_settings=agent_cfg.model_settings,
|
|
thinking_enabled=agent_cfg.thinking_enabled,
|
|
reasoning_effort=agent_cfg.reasoning_effort,
|
|
soul=soul,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/agents",
|
|
response_model=AgentsListResponse,
|
|
summary="List Custom Agents",
|
|
description="List all custom agents available in the agents directory, including their soul content.",
|
|
)
|
|
async def list_agents() -> AgentsListResponse:
|
|
"""List all custom agents.
|
|
|
|
Returns:
|
|
List of all custom agents with their metadata and soul content.
|
|
"""
|
|
_require_agents_api_enabled()
|
|
|
|
user_id = get_effective_user_id()
|
|
|
|
def _list() -> AgentsListResponse:
|
|
# Worker thread: the store read plus the per-agent SOUL read inside
|
|
# _agent_config_to_response are filesystem IO (file backend) or DB round
|
|
# trips (db backend) and must stay off the event loop.
|
|
agents = list_custom_agents(user_id=user_id)
|
|
return AgentsListResponse(agents=[_agent_config_to_response(a, include_soul=True, user_id=user_id) for a in agents])
|
|
|
|
try:
|
|
return await asyncio.to_thread(_list)
|
|
except Exception as e:
|
|
logger.error(f"Failed to list agents: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to list agents: {str(e)}")
|
|
|
|
|
|
@router.get(
|
|
"/agents/check",
|
|
summary="Check Agent Name",
|
|
description="Validate an agent name and check if it is available (case-insensitive).",
|
|
)
|
|
async def check_agent_name(name: str) -> dict:
|
|
"""Check whether an agent name is valid and not yet taken.
|
|
|
|
Args:
|
|
name: The agent name to check.
|
|
|
|
Returns:
|
|
``{"available": true/false, "name": "<normalized>"}``
|
|
|
|
Raises:
|
|
HTTPException: 422 if the name is invalid.
|
|
"""
|
|
_require_agents_api_enabled()
|
|
_validate_agent_name(name)
|
|
normalized = _normalize_agent_name(name)
|
|
user_id = get_effective_user_id()
|
|
# Availability is defined by the active backend and stays consistent with
|
|
# create()'s conflict rule (file: per-user or legacy dir; db: a row). The
|
|
# exists() probe is filesystem IO / a DB round trip, so keep it off the loop.
|
|
exists = await asyncio.to_thread(get_agent_store().exists, normalized, user_id=user_id)
|
|
return {"available": not exists, "name": normalized}
|
|
|
|
|
|
@router.get(
|
|
"/agents/{name}",
|
|
response_model=AgentResponse,
|
|
summary="Get Custom Agent",
|
|
description="Retrieve details and SOUL.md content for a specific custom agent.",
|
|
)
|
|
async def get_agent(name: str) -> AgentResponse:
|
|
"""Get a specific custom agent by name.
|
|
|
|
Args:
|
|
name: The agent name.
|
|
|
|
Returns:
|
|
Agent details including SOUL.md content.
|
|
|
|
Raises:
|
|
HTTPException: 404 if agent not found.
|
|
"""
|
|
_require_agents_api_enabled()
|
|
_validate_agent_name(name)
|
|
name = _normalize_agent_name(name)
|
|
user_id = get_effective_user_id()
|
|
|
|
def _get() -> AgentResponse:
|
|
# Worker thread: config read + SOUL read must stay off the event loop.
|
|
agent_cfg = load_agent_config(name, user_id=user_id)
|
|
return _agent_config_to_response(agent_cfg, include_soul=True, user_id=user_id)
|
|
|
|
try:
|
|
return await asyncio.to_thread(_get)
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
|
except Exception as e:
|
|
logger.error(f"Failed to get agent '{name}': {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to get agent: {str(e)}")
|
|
|
|
|
|
@router.post(
|
|
"/agents",
|
|
response_model=AgentResponse,
|
|
status_code=201,
|
|
summary="Create Custom Agent",
|
|
description="Create a new custom agent with its config and SOUL.md.",
|
|
)
|
|
async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
|
|
"""Create a new custom agent.
|
|
|
|
Args:
|
|
request: The agent creation request.
|
|
|
|
Returns:
|
|
The created agent details.
|
|
|
|
Raises:
|
|
HTTPException: 409 if agent already exists, 422 if name is invalid.
|
|
"""
|
|
_require_agents_api_enabled()
|
|
_validate_agent_name(request.name)
|
|
_validate_model_exists(request.model)
|
|
normalized_name = _normalize_agent_name(request.name)
|
|
user_id = get_effective_user_id()
|
|
|
|
# Config document — only the fields the caller set, matching the historical
|
|
# writer (an omitted field stays absent rather than being materialized).
|
|
config_data: dict = {"name": normalized_name}
|
|
if request.description:
|
|
config_data["description"] = request.description
|
|
if request.tool_groups is not None:
|
|
config_data["tool_groups"] = request.tool_groups
|
|
if request.skills is not None:
|
|
config_data["skills"] = request.skills
|
|
# model / model_settings / thinking_enabled / reasoning_effort (issue #4336).
|
|
_apply_model_behavior(config_data, request)
|
|
|
|
store = get_agent_store()
|
|
|
|
def _create_agent() -> AgentResponse:
|
|
# Worker thread: existence checks + persistence (file IO or a DB round
|
|
# trip) must stay off the event loop.
|
|
store.create(normalized_name, config_data, request.soul, user_id=user_id)
|
|
logger.info("Created agent '%s'", normalized_name)
|
|
agent_cfg = load_agent_config(normalized_name, user_id=user_id)
|
|
return _agent_config_to_response(agent_cfg, include_soul=True, user_id=user_id)
|
|
|
|
try:
|
|
return await asyncio.to_thread(_create_agent)
|
|
except AgentExistsError:
|
|
raise HTTPException(status_code=409, detail=f"Agent '{normalized_name}' already exists")
|
|
except Exception as e:
|
|
logger.error(f"Failed to create agent '{request.name}': {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to create agent: {str(e)}")
|
|
|
|
|
|
@router.put(
|
|
"/agents/{name}",
|
|
response_model=AgentResponse,
|
|
summary="Update Custom Agent",
|
|
description="Update an existing custom agent's config and/or SOUL.md.",
|
|
)
|
|
async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
|
|
"""Update an existing custom agent.
|
|
|
|
Args:
|
|
name: The agent name.
|
|
request: The update request (all fields optional).
|
|
|
|
Returns:
|
|
The updated agent details.
|
|
|
|
Raises:
|
|
HTTPException: 404 if agent not found.
|
|
"""
|
|
_require_agents_api_enabled()
|
|
_validate_agent_name(name)
|
|
name = _normalize_agent_name(name)
|
|
user_id = get_effective_user_id()
|
|
|
|
try:
|
|
agent_cfg = await asyncio.to_thread(load_agent_config, name, user_id=user_id)
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
|
|
|
def _is_legacy_only_layout() -> bool:
|
|
# Require config.yaml, not bare directory existence — a per-user agent
|
|
# directory can exist containing only memory.json (written the first
|
|
# time this user chats with a legacy shared agent, before this route
|
|
# is ever called). Bare .exists() would miss that case and let this
|
|
# fall through to a silent fork of a brand-new config.yaml/SOUL.md
|
|
# into the memory-only directory instead of blocking (mirrors
|
|
# resolve_agent_dir's guard, see #3390). The db backend has no legacy
|
|
# shared layout, so this file-only guard is a no-op there. The .exists()
|
|
# probes are filesystem IO, so they run off the event loop.
|
|
paths = get_paths()
|
|
agent_dir = paths.user_agent_dir(user_id, name)
|
|
legacy_dir = paths.agent_dir(name)
|
|
return not (agent_dir / "config.yaml").exists() and (legacy_dir / "config.yaml").exists()
|
|
|
|
if await asyncio.to_thread(_is_legacy_only_layout):
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=(f"Agent '{name}' only exists in the legacy shared layout and is not scoped to a user. Run scripts/migrate_user_isolation.py to move legacy agents into the per-user layout before updating."),
|
|
)
|
|
|
|
if "model" in request.model_fields_set:
|
|
_validate_model_exists(request.model)
|
|
|
|
try:
|
|
# Update config if any config fields changed
|
|
# Use model_fields_set to distinguish "field omitted" from "explicitly set to null".
|
|
# This is critical for skills where None means "inherit all" (not "don't change").
|
|
fields_set = request.model_fields_set
|
|
config_changed = bool(fields_set & ({"description", "tool_groups", "skills"} | set(_MODEL_BEHAVIOR_FIELDS)))
|
|
|
|
updated: dict | None = None
|
|
if config_changed:
|
|
updated = {
|
|
"name": agent_cfg.name,
|
|
"description": request.description if "description" in fields_set else agent_cfg.description,
|
|
}
|
|
|
|
new_tool_groups = request.tool_groups if "tool_groups" in fields_set else agent_cfg.tool_groups
|
|
if new_tool_groups is not None:
|
|
updated["tool_groups"] = new_tool_groups
|
|
|
|
# skills: None = inherit all, [] = no skills, ["a","b"] = whitelist
|
|
if "skills" in fields_set:
|
|
new_skills = request.skills
|
|
else:
|
|
new_skills = agent_cfg.skills
|
|
if new_skills is not None:
|
|
updated["skills"] = new_skills
|
|
|
|
# model / model_settings / thinking_enabled / reasoning_effort:
|
|
# take explicitly-set request fields, else preserve the existing
|
|
# value (issue #4336).
|
|
_apply_model_behavior(updated, request, existing=agent_cfg)
|
|
|
|
# Carry forward every top-level AgentConfig field this route does
|
|
# not manage (currently ``github:``, plus any future field added
|
|
# to :class:`AgentConfig`). The harness ``update_agent`` tool uses
|
|
# the same helper, so an operator editing the agent description
|
|
# from the Web UI does not silently strip a hand-authored
|
|
# ``github:`` binding — which would otherwise leave the next
|
|
# webhook delivery unable to find the agent in the registry and
|
|
# silently no-op.
|
|
for key, value in preserve_non_managed_fields(agent_cfg).items():
|
|
updated.setdefault(key, value)
|
|
|
|
store = get_agent_store()
|
|
# Persist config (when changed) and/or soul (when provided) off the
|
|
# event loop. A no-change PATCH commits nothing and re-reads current state.
|
|
if updated is not None or request.soul is not None:
|
|
await asyncio.to_thread(store.update, name, updated, request.soul, user_id=user_id)
|
|
|
|
logger.info(f"Updated agent '{name}'")
|
|
|
|
def _refresh() -> AgentResponse:
|
|
# Worker thread: re-read config + SOUL off the event loop.
|
|
refreshed_cfg = load_agent_config(name, user_id=user_id)
|
|
return _agent_config_to_response(refreshed_cfg, include_soul=True, user_id=user_id)
|
|
|
|
return await asyncio.to_thread(_refresh)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Failed to update agent '{name}': {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(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:
|
|
"""Return the current USER.md content.
|
|
|
|
Returns:
|
|
UserProfileResponse with content=None if USER.md does not exist yet.
|
|
"""
|
|
_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(f"Failed to read user profile: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to read user profile: {str(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:
|
|
"""Create or overwrite the global USER.md.
|
|
|
|
Args:
|
|
request: The update request with the new USER.md content.
|
|
|
|
Returns:
|
|
UserProfileResponse with the saved content.
|
|
"""
|
|
_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(f"Updated USER.md at {paths.user_md_file}")
|
|
return UserProfileResponse(content=request.content or None)
|
|
except Exception as e:
|
|
logger.error(f"Failed to update user profile: {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to update user profile: {str(e)}")
|
|
|
|
|
|
@router.delete(
|
|
"/agents/{name}",
|
|
status_code=204,
|
|
summary="Delete Custom Agent",
|
|
description="Delete a custom agent and all its files (config, SOUL.md, memory).",
|
|
)
|
|
async def delete_agent(name: str) -> None:
|
|
"""Delete a custom agent.
|
|
|
|
Args:
|
|
name: The agent name.
|
|
|
|
Raises:
|
|
HTTPException: 404 if no per-user copy exists; 409 if only a legacy
|
|
shared copy exists (suggesting the migration script).
|
|
"""
|
|
_require_agents_api_enabled()
|
|
_validate_agent_name(name)
|
|
name = _normalize_agent_name(name)
|
|
user_id = get_effective_user_id()
|
|
store = get_agent_store()
|
|
|
|
try:
|
|
# Off the event loop: file rmtree or a DB delete plus memory cleanup.
|
|
outcome = await asyncio.to_thread(store.delete, name, user_id=user_id)
|
|
except Exception as e:
|
|
logger.error(f"Failed to delete agent '{name}': {e}", exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}")
|
|
|
|
if outcome == "legacy":
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=(f"Agent '{name}' only exists in the legacy shared layout and is not scoped to a user. Run scripts/migrate_user_isolation.py to move legacy agents into the per-user layout before deleting."),
|
|
)
|
|
if outcome == "missing":
|
|
raise HTTPException(status_code=404, detail=f"Agent '{name}' not found")
|
|
if outcome == "not-custom-agent":
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=(f"Directory for '{name}' contains memory data but is not a custom agent because config.yaml is missing; it was preserved."),
|
|
)
|
|
|
|
logger.info(f"Deleted agent '{name}'")
|