803 lines
29 KiB
Python
803 lines
29 KiB
Python
"""Global strategy-selection agent role management.
|
|
|
|
The strategy workbench owns three fixed roles (intent, judge and solution)
|
|
plus an administrator-managed set of angle roles. Role metadata is stored in
|
|
``strategy_agent_roles`` while model/SOUL/skills remain in the normal
|
|
folder-backed agent configuration, so the same configuration is shared by
|
|
every project and every user.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import weakref
|
|
from contextlib import asynccontextmanager
|
|
from datetime import datetime
|
|
from typing import Literal
|
|
from uuid import uuid4
|
|
|
|
import yaml
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.gateway.auth.disabled_mode import is_auth_disabled
|
|
from app.gateway.deps import (
|
|
get_agent_store,
|
|
get_config,
|
|
get_current_user_from_request,
|
|
get_skill_store,
|
|
get_strategy_agent_role_store,
|
|
)
|
|
from app.gateway.routers import agents as agents_router
|
|
from app.gateway.routers._strategy_selection_seed import (
|
|
STRATEGY_ANGLE_AGENT_PREFIX,
|
|
default_strategy_agent_roles,
|
|
ensure_strategy_selection_agents,
|
|
)
|
|
from deerflow.config.paths import get_paths
|
|
from deerflow.persistence.agents import AgentStore
|
|
from deerflow.persistence.strategy_agent_roles import (
|
|
MemoryStrategyAgentRoleStore,
|
|
StrategyAgentRoleStore,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/strategy-agents", tags=["strategy-agents"])
|
|
|
|
StrategyAgentCategory = Literal["intent", "angle", "judge", "solution"]
|
|
_DYNAMIC_ANGLE_TOOL_GROUPS = ["web", "file:read"]
|
|
_ROLE_SIDECAR = ".strategy-role.yaml"
|
|
_PROCESS_LOCKS: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
|
|
|
|
|
|
def _process_lock(agent_id: str) -> asyncio.Lock:
|
|
key = agent_id.casefold()
|
|
lock = _PROCESS_LOCKS.get(key)
|
|
if lock is None:
|
|
lock = asyncio.Lock()
|
|
_PROCESS_LOCKS[key] = lock
|
|
return lock
|
|
|
|
|
|
def _open_cross_process_lock(agent_id: str):
|
|
lock_dir = get_paths().agents_dir / ".strategy-locks"
|
|
lock_dir.mkdir(parents=True, exist_ok=True)
|
|
path = lock_dir / f"{agent_id}.lock"
|
|
handle = open(path, "a+b") # noqa: SIM115 - owned by async context below
|
|
if path.stat().st_size == 0:
|
|
handle.write(b"\0")
|
|
handle.flush()
|
|
return handle
|
|
|
|
|
|
def _try_cross_process_lock(handle) -> bool:
|
|
handle.seek(0)
|
|
try:
|
|
if os.name == "nt":
|
|
import msvcrt
|
|
|
|
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
else:
|
|
import fcntl
|
|
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _release_cross_process_lock(handle) -> None:
|
|
try:
|
|
handle.seek(0)
|
|
if os.name == "nt":
|
|
import msvcrt
|
|
|
|
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
else:
|
|
import fcntl
|
|
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
finally:
|
|
handle.close()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _strategy_mutation_guard(agent_id: str):
|
|
"""Serialize one strategy agent across coroutines and worker processes.
|
|
|
|
The zero-byte lock-file namespace is durable, but OS locks are tied to the
|
|
open handle and are automatically released if a worker dies. The local
|
|
lock table uses weak values so one-off dynamic ids do not leak forever.
|
|
"""
|
|
|
|
async with _process_lock(agent_id):
|
|
handle = _open_cross_process_lock(agent_id)
|
|
acquired = False
|
|
try:
|
|
while not acquired:
|
|
acquired = _try_cross_process_lock(handle)
|
|
if not acquired:
|
|
await asyncio.sleep(0.05)
|
|
yield
|
|
finally:
|
|
if acquired:
|
|
# Unlock synchronously: this is a tiny OS call and must not be
|
|
# skipped if the request is already being cancelled.
|
|
_release_cross_process_lock(handle)
|
|
else:
|
|
handle.close()
|
|
|
|
|
|
def _write_role_sidecar(agent_id: str, role: dict) -> None:
|
|
payload = {
|
|
"agent_id": agent_id,
|
|
"category": "angle",
|
|
"fixed": False,
|
|
"enabled": bool(role.get("enabled", True)),
|
|
"sort_order": int(role.get("sort_order", 0)),
|
|
}
|
|
agents_router._atomic_write_text(
|
|
get_paths().agent_dir(agent_id) / _ROLE_SIDECAR,
|
|
yaml.safe_dump(payload, allow_unicode=True, sort_keys=False),
|
|
)
|
|
|
|
|
|
def _read_optional_text(path) -> str | None:
|
|
return path.read_text(encoding="utf-8") if path.is_file() else None
|
|
|
|
|
|
def _restore_optional_text(path, content: str | None) -> None:
|
|
if content is None:
|
|
path.unlink(missing_ok=True)
|
|
return
|
|
agents_router._atomic_write_text(path, content)
|
|
|
|
|
|
async def _rollback_strategy_update(
|
|
*,
|
|
agent_id: str,
|
|
agent_store: AgentStore,
|
|
role_store: StrategyAgentRoleStore,
|
|
record: dict,
|
|
role: dict,
|
|
extras: dict,
|
|
file_snapshot: dict,
|
|
) -> None:
|
|
"""Best-effort compensation for the mixed DB/filesystem update."""
|
|
|
|
try:
|
|
agent_dir = get_paths().agent_dir(agent_id)
|
|
await asyncio.to_thread(
|
|
_restore_optional_text,
|
|
agent_dir / "config.yaml",
|
|
file_snapshot["config"],
|
|
)
|
|
await asyncio.to_thread(
|
|
_restore_optional_text,
|
|
agent_dir / "SOUL.md",
|
|
file_snapshot["soul"],
|
|
)
|
|
await asyncio.to_thread(
|
|
_restore_optional_text,
|
|
agent_dir / _ROLE_SIDECAR,
|
|
file_snapshot["sidecar"],
|
|
)
|
|
await agent_store.update_any(
|
|
agent_id,
|
|
{
|
|
"name": record["name"],
|
|
"description": record.get("description") or "",
|
|
},
|
|
)
|
|
await agent_store.replace_skills(agent_id, extras["skills"])
|
|
await agent_store.set_extras(
|
|
agent_id,
|
|
tool_groups=extras["tool_groups"],
|
|
model=extras["model"],
|
|
)
|
|
await role_store.update_role(
|
|
agent_id,
|
|
enabled=bool(role["enabled"]),
|
|
sort_order=int(role["sort_order"]),
|
|
)
|
|
except Exception:
|
|
logger.exception("Failed to compensate strategy-agent update for '%s'", agent_id)
|
|
|
|
|
|
async def _recover_memory_angle_roles(role_store: StrategyAgentRoleStore) -> None:
|
|
"""Recover dynamic role metadata after a memory-mode process restart."""
|
|
|
|
if not isinstance(role_store, MemoryStrategyAgentRoleStore):
|
|
return
|
|
agents_dir = get_paths().agents_dir
|
|
if not agents_dir.is_dir():
|
|
return
|
|
for agent_dir in agents_dir.iterdir():
|
|
sidecar = agent_dir / _ROLE_SIDECAR
|
|
if not sidecar.is_file():
|
|
continue
|
|
try:
|
|
raw = yaml.safe_load(sidecar.read_text(encoding="utf-8")) or {}
|
|
agent_id = str(raw.get("agent_id") or "")
|
|
if agent_id != agent_dir.name or not agent_id.startswith(STRATEGY_ANGLE_AGENT_PREFIX) or raw.get("category") != "angle" or bool(raw.get("fixed", False)):
|
|
raise ValueError("invalid dynamic angle sidecar")
|
|
existing = await role_store.get_role(agent_id)
|
|
if existing is None:
|
|
await role_store.create_role(
|
|
{
|
|
"agent_id": agent_id,
|
|
"category": "angle",
|
|
"fixed": False,
|
|
"enabled": bool(raw.get("enabled", True)),
|
|
"sort_order": int(raw.get("sort_order", 0)),
|
|
}
|
|
)
|
|
elif not bool(existing.get("fixed")) and existing.get("category") == "angle":
|
|
# Memory stores are rebuilt with code defaults first. The
|
|
# sidecar is the durable authority for every non-fixed angle,
|
|
# including the six shipped angles, so disabled defaults do
|
|
# not silently revive after a worker restart.
|
|
await role_store.update_role(
|
|
agent_id,
|
|
enabled=bool(raw.get("enabled", True)),
|
|
sort_order=int(raw.get("sort_order", 0)),
|
|
)
|
|
except Exception:
|
|
logger.exception("Ignoring invalid strategy role sidecar at %s", sidecar)
|
|
|
|
|
|
def _default_angle_soul(name: str, description: str) -> str:
|
|
focus = description.strip() or "围绕任务目标,从本角度给出独立、可执行的策略建议"
|
|
return (
|
|
f"# 角色\n\n你是“{name}”,负责{focus}。\n\n"
|
|
"# 工作要求\n\n"
|
|
"1. 先核对任务目标、约束、前置条件和时间范围;信息不足时明确假设。\n"
|
|
"2. 只从自己的专业角度分析,不替代其他角度,也不冒充评判或方案整理角色。\n"
|
|
"3. 输出具体策略、实施步骤、预期收益、主要风险、触发条件和退出条件。\n"
|
|
"4. 对时效性事实优先检索核验并标注依据;不得编造来源。\n"
|
|
"5. 遵守法律、安全与非升级边界,最终交付使用清晰的 Markdown。\n"
|
|
)
|
|
|
|
|
|
class StrategyAgentItem(BaseModel):
|
|
id: str
|
|
category: StrategyAgentCategory
|
|
fixed: bool
|
|
enabled: bool
|
|
sort_order: int
|
|
name: str
|
|
description: str = ""
|
|
model: str | None = None
|
|
soul: str | None = None
|
|
skills: list[str] | None = None
|
|
created_at: datetime | str | None = None
|
|
updated_at: datetime | str | None = None
|
|
|
|
|
|
class StrategyAgentListResponse(BaseModel):
|
|
agents: list[StrategyAgentItem]
|
|
|
|
|
|
class StrategyAngleCreateRequest(BaseModel):
|
|
name: str = Field(min_length=1, max_length=100)
|
|
description: str = Field(default="", max_length=2000)
|
|
soul: str = Field(default="", max_length=200_000)
|
|
model: str | None = Field(default=None, max_length=200)
|
|
skills: list[str] = Field(default_factory=list, max_length=100)
|
|
sort_order: int | None = Field(default=None, ge=-10_000, le=10_000)
|
|
|
|
|
|
class StrategyAgentUpdateRequest(BaseModel):
|
|
name: str | None = Field(default=None, max_length=100)
|
|
description: str | None = Field(default=None, max_length=2000)
|
|
soul: str | None = Field(default=None, max_length=200_000)
|
|
model: str | None = Field(default=None, max_length=200)
|
|
skills: list[str] | None = Field(default=None, max_length=100)
|
|
enabled: bool | None = None
|
|
sort_order: int | None = Field(default=None, ge=-10_000, le=10_000)
|
|
|
|
|
|
async def _require_admin(request: Request) -> None:
|
|
"""Require an administrator, except in explicit auth-disabled mode."""
|
|
|
|
user = getattr(request.state, "user", None)
|
|
if user is None:
|
|
if is_auth_disabled():
|
|
return
|
|
user = await get_current_user_from_request(request)
|
|
if getattr(user, "system_role", None) != "admin":
|
|
raise HTTPException(status_code=403, detail="策略智能体管理仅限管理员")
|
|
|
|
|
|
def _normalize_model(request: Request, raw_model: str | None) -> str | None:
|
|
return agents_router._validate_strategy_agent_model(
|
|
raw_model,
|
|
app_config=get_config(request),
|
|
)
|
|
|
|
|
|
async def _ensure_registry(
|
|
agent_store: AgentStore,
|
|
role_store: StrategyAgentRoleStore,
|
|
) -> None:
|
|
"""Seed missing role metadata and repair missing default agent rows."""
|
|
|
|
defaults = default_strategy_agent_roles()
|
|
await role_store.ensure_defaults(defaults)
|
|
await _recover_memory_angle_roles(role_store)
|
|
default_ids = [str(row["agent_id"]) for row in defaults]
|
|
for agent_id in default_ids:
|
|
existing = await agent_store.get_any(agent_id)
|
|
if existing is not None and existing.get("user_id") is not None:
|
|
# Never let legacy sync's ensure_builtin semantics silently treat a
|
|
# user-owned collision as one of the fixed global roles.
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Reserved strategy agent '{agent_id}' is owned by a user",
|
|
)
|
|
|
|
roles = await role_store.list_roles(include_disabled=True)
|
|
missing = [str(role["agent_id"]) for role in roles if await agent_store.get_any(str(role["agent_id"])) is None]
|
|
if missing:
|
|
# This path is normally handled by lifespan. It also rebuilds the
|
|
# memory-mode agent table from durable dynamic-angle folders.
|
|
await asyncio.to_thread(ensure_strategy_selection_agents)
|
|
await agents_router._sync_legacy_agents(agent_store)
|
|
|
|
for role in roles:
|
|
agent_id = str(role["agent_id"])
|
|
record = await agent_store.get_any(agent_id)
|
|
if record is None:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail=f"Strategy agent '{agent_id}' configuration is unavailable",
|
|
)
|
|
if str(record.get("id") or "") != agent_id or record.get("user_id") is not None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Strategy agent '{agent_id}' must be a canonical global agent",
|
|
)
|
|
|
|
|
|
async def ensure_strategy_agent_registry(app) -> None:
|
|
"""Synchronize one worker's registry, including memory-mode sidecars."""
|
|
|
|
agent_store = getattr(app.state, "agent_store", None)
|
|
role_store = getattr(app.state, "strategy_agent_role_store", None)
|
|
if agent_store is None or role_store is None:
|
|
raise RuntimeError("Strategy agent persistence is unavailable")
|
|
await _ensure_registry(agent_store, role_store)
|
|
|
|
|
|
async def _to_item(
|
|
role: dict,
|
|
record: dict,
|
|
*,
|
|
include_config: bool,
|
|
) -> StrategyAgentItem:
|
|
if include_config:
|
|
agent = await agents_router._record_to_response_async(
|
|
record,
|
|
include_soul=True,
|
|
include_extras=True,
|
|
)
|
|
return StrategyAgentItem(
|
|
id=agent.id,
|
|
category=role["category"],
|
|
fixed=bool(role["fixed"]),
|
|
enabled=bool(role["enabled"]),
|
|
sort_order=int(role["sort_order"]),
|
|
name=agent.name,
|
|
description=agent.description,
|
|
model=agent.model,
|
|
soul=agent.soul,
|
|
skills=agent.skills,
|
|
created_at=agent.created_at,
|
|
updated_at=agent.updated_at,
|
|
)
|
|
|
|
# Public consumers need only identity and lifecycle metadata. Avoid
|
|
# leaking prompts/model/skills and avoid synchronous filesystem reads.
|
|
return StrategyAgentItem(
|
|
id=str(record["id"]),
|
|
category=role["category"],
|
|
fixed=bool(role["fixed"]),
|
|
enabled=bool(role["enabled"]),
|
|
sort_order=int(role["sort_order"]),
|
|
name=str(record["name"]),
|
|
description=str(record.get("description") or ""),
|
|
created_at=record.get("created_at"),
|
|
updated_at=record.get("updated_at"),
|
|
)
|
|
|
|
|
|
async def _get_role_and_agent(
|
|
request: Request,
|
|
agent_id: str,
|
|
) -> tuple[dict, dict]:
|
|
role_store = get_strategy_agent_role_store(request)
|
|
agent_store = get_agent_store(request)
|
|
await _ensure_registry(agent_store, role_store)
|
|
role = await role_store.get_role(agent_id)
|
|
if role is None:
|
|
raise HTTPException(status_code=404, detail=f"Strategy agent '{agent_id}' not found")
|
|
record = await agent_store.get_any(agent_id)
|
|
if record is None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Strategy agent '{agent_id}' has no backing agent configuration",
|
|
)
|
|
if record.get("user_id") is not None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Strategy agent '{agent_id}' must be globally owned",
|
|
)
|
|
canonical_role_id = str(role.get("agent_id") or "")
|
|
if canonical_role_id != agent_id:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=(f"Strategy agent id casing mismatch; use canonical id '{canonical_role_id}'"),
|
|
)
|
|
agents_router._require_canonical_record_id(agent_id, record)
|
|
return role, record
|
|
|
|
|
|
@router.get("", response_model=StrategyAgentListResponse)
|
|
async def list_strategy_agents(
|
|
request: Request,
|
|
include_disabled: bool = False,
|
|
include_config: bool = False,
|
|
) -> StrategyAgentListResponse:
|
|
"""List the shared role catalog; sensitive configuration is admin-only."""
|
|
|
|
if include_disabled or include_config:
|
|
await _require_admin(request)
|
|
role_store = get_strategy_agent_role_store(request)
|
|
agent_store = get_agent_store(request)
|
|
await _ensure_registry(agent_store, role_store)
|
|
roles = await role_store.list_roles(include_disabled=include_disabled)
|
|
items: list[StrategyAgentItem] = []
|
|
for role in roles:
|
|
record = await agent_store.get_any(str(role["agent_id"]))
|
|
if record is None:
|
|
if bool(role["enabled"]):
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail=(f"Strategy agent '{role['agent_id']}' configuration is unavailable"),
|
|
)
|
|
continue
|
|
if str(record.get("id") or "") != str(role["agent_id"]) or record.get("user_id") is not None:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"Strategy agent '{role['agent_id']}' must be a canonical global agent",
|
|
)
|
|
items.append(await _to_item(role, record, include_config=include_config))
|
|
return StrategyAgentListResponse(agents=items)
|
|
|
|
|
|
@router.post(
|
|
"/angles",
|
|
response_model=StrategyAgentItem,
|
|
status_code=201,
|
|
)
|
|
async def create_strategy_angle(
|
|
payload: StrategyAngleCreateRequest,
|
|
request: Request,
|
|
) -> StrategyAgentItem:
|
|
"""Create one global angle agent. Fixed role categories are not creatable."""
|
|
|
|
await _require_admin(request)
|
|
name = agents_router._validate_display_name(payload.name)
|
|
model = _normalize_model(request, payload.model)
|
|
skills = await agents_router._validate_strategy_agent_skills(
|
|
payload.skills,
|
|
app_config=get_config(request),
|
|
skill_store=get_skill_store(request),
|
|
)
|
|
agent_store = get_agent_store(request)
|
|
role_store = get_strategy_agent_role_store(request)
|
|
await _ensure_registry(agent_store, role_store)
|
|
|
|
agent_id = f"{STRATEGY_ANGLE_AGENT_PREFIX}{uuid4().hex}"
|
|
# The generated id has already passed the fixed prefix/storage contract;
|
|
# all filesystem and persistence mutations happen under the cross-worker
|
|
# guard below.
|
|
agent_id = agents_router._validate_storage_id(agent_id)
|
|
async with _strategy_mutation_guard(agent_id):
|
|
return await _create_strategy_angle_locked(
|
|
agent_id=agent_id,
|
|
payload=payload,
|
|
name=name,
|
|
model=model,
|
|
skills=skills,
|
|
agent_store=agent_store,
|
|
role_store=role_store,
|
|
)
|
|
|
|
|
|
async def _cleanup_failed_create(
|
|
agent_id: str,
|
|
agent_store: AgentStore,
|
|
role_store: StrategyAgentRoleStore,
|
|
) -> None:
|
|
async def attempt(label: str, operation) -> None:
|
|
try:
|
|
result = operation()
|
|
if asyncio.iscoroutine(result):
|
|
await result
|
|
except BaseException: # cleanup must continue through cancellation/failures
|
|
logger.exception("Failed create cleanup step %s for '%s'", label, agent_id)
|
|
|
|
await attempt("role", lambda: role_store.delete_role(agent_id))
|
|
await attempt("agent", lambda: agent_store.delete_builtin(agent_id))
|
|
await attempt("extras", lambda: agent_store.delete_extras_for_agent(agent_id))
|
|
agent_dir = get_paths().agent_dir(agent_id)
|
|
if agent_dir.exists():
|
|
await attempt("folder", lambda: asyncio.to_thread(shutil.rmtree, agent_dir))
|
|
|
|
|
|
async def _create_strategy_angle_locked(
|
|
*,
|
|
agent_id: str,
|
|
payload: StrategyAngleCreateRequest,
|
|
name: str,
|
|
model: str | None,
|
|
skills: list[str] | None,
|
|
agent_store: AgentStore,
|
|
role_store: StrategyAgentRoleStore,
|
|
) -> StrategyAgentItem:
|
|
agent_dir = get_paths().agent_dir(agent_id)
|
|
if await agent_store.get_any(agent_id) is not None or agent_dir.exists():
|
|
# Cryptographically improbable, but never overwrite a folder if the
|
|
# deployment restored a row without its registry metadata.
|
|
raise HTTPException(status_code=409, detail="Generated strategy agent id already exists")
|
|
|
|
sort_order = payload.sort_order
|
|
if sort_order is None:
|
|
current = await role_store.list_roles(include_disabled=True)
|
|
angle_orders = [int(row["sort_order"]) for row in current if row.get("category") == "angle"]
|
|
sort_order = (max(angle_orders) + 1) if angle_orders else 100
|
|
soul = (
|
|
payload.soul
|
|
if payload.soul.strip()
|
|
else _default_angle_soul(
|
|
name,
|
|
payload.description,
|
|
)
|
|
)
|
|
|
|
completed = False
|
|
try:
|
|
await asyncio.to_thread(
|
|
agents_router._write_agent_config,
|
|
agent_id,
|
|
name=name,
|
|
description=payload.description,
|
|
skills=skills,
|
|
model=model,
|
|
tool_groups=_DYNAMIC_ANGLE_TOOL_GROUPS,
|
|
)
|
|
await asyncio.to_thread(agents_router._write_agent_soul, agent_id, soul)
|
|
record = await agent_store.ensure_builtin(
|
|
{
|
|
"id": agent_id,
|
|
"name": name,
|
|
"description": payload.description,
|
|
"published": True,
|
|
}
|
|
)
|
|
await agent_store.replace_skills(agent_id, skills)
|
|
await agent_store.set_extras(
|
|
agent_id,
|
|
tool_groups=_DYNAMIC_ANGLE_TOOL_GROUPS,
|
|
model=model,
|
|
)
|
|
role = await role_store.create_role(
|
|
{
|
|
"agent_id": agent_id,
|
|
"category": "angle",
|
|
"fixed": False,
|
|
"enabled": True,
|
|
"sort_order": sort_order,
|
|
}
|
|
)
|
|
await asyncio.to_thread(_write_role_sidecar, agent_id, role)
|
|
completed = True
|
|
return await _to_item(role, record, include_config=True)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
logger.exception("Failed to create strategy angle agent '%s'", agent_id)
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Failed to create strategy angle agent",
|
|
) from exc
|
|
finally:
|
|
if not completed:
|
|
cleanup = asyncio.create_task(_cleanup_failed_create(agent_id, agent_store, role_store))
|
|
try:
|
|
await asyncio.shield(cleanup)
|
|
except asyncio.CancelledError:
|
|
# A second cancellation must not leave a half-created global
|
|
# agent behind while the file lock is released.
|
|
await cleanup
|
|
raise
|
|
|
|
|
|
@router.put("/{agent_id}", response_model=StrategyAgentItem)
|
|
async def update_strategy_agent(
|
|
agent_id: str,
|
|
payload: StrategyAgentUpdateRequest,
|
|
request: Request,
|
|
) -> StrategyAgentItem:
|
|
"""Update configuration; fixed roles cannot change lifecycle or category."""
|
|
|
|
await _require_admin(request)
|
|
agent_id = agents_router._validate_storage_id(agent_id)
|
|
async with _strategy_mutation_guard(agent_id):
|
|
return await _update_strategy_agent_locked(agent_id, payload, request)
|
|
|
|
|
|
async def _update_strategy_agent_locked(
|
|
agent_id: str,
|
|
payload: StrategyAgentUpdateRequest,
|
|
request: Request,
|
|
) -> StrategyAgentItem:
|
|
role, record = await _get_role_and_agent(request, agent_id)
|
|
agent_store = get_agent_store(request)
|
|
fields = payload.model_fields_set
|
|
|
|
if bool(role["fixed"]):
|
|
if "enabled" in fields and payload.enabled is not True:
|
|
raise HTTPException(status_code=409, detail="Fixed strategy roles cannot be disabled")
|
|
if "sort_order" in fields and payload.sort_order != int(role["sort_order"]):
|
|
raise HTTPException(status_code=409, detail="Fixed strategy role order cannot be changed")
|
|
|
|
next_name = agents_router._validate_display_name(payload.name or "") if "name" in fields else str(record["name"])
|
|
next_description = payload.description or "" if "description" in fields else str(record.get("description") or "")
|
|
extras = await asyncio.to_thread(agents_router._load_config_extras, agent_id)
|
|
next_model = _normalize_model(request, payload.model) if "model" in fields else extras["model"]
|
|
next_skills = (
|
|
await agents_router._validate_strategy_agent_skills(
|
|
payload.skills,
|
|
app_config=get_config(request),
|
|
skill_store=get_skill_store(request),
|
|
)
|
|
if "skills" in fields
|
|
else extras["skills"]
|
|
)
|
|
|
|
agent_dir = get_paths().agent_dir(agent_id)
|
|
file_snapshot = await asyncio.to_thread(
|
|
lambda: {
|
|
"config": _read_optional_text(agent_dir / "config.yaml"),
|
|
"soul": _read_optional_text(agent_dir / "SOUL.md"),
|
|
"sidecar": _read_optional_text(agent_dir / _ROLE_SIDECAR),
|
|
}
|
|
)
|
|
original_record = dict(record)
|
|
original_role = dict(role)
|
|
role_store = get_strategy_agent_role_store(request)
|
|
try:
|
|
config_fields = {"name", "description", "model", "skills"}
|
|
if fields & config_fields:
|
|
await asyncio.to_thread(
|
|
agents_router._write_agent_config,
|
|
agent_id,
|
|
name=next_name,
|
|
description=next_description,
|
|
skills=next_skills,
|
|
model=next_model,
|
|
tool_groups=extras["tool_groups"],
|
|
common_questions=extras["common_questions"],
|
|
template_name=extras["template_name"],
|
|
seat_skill_directive=extras["seat_skill_directive"],
|
|
llmwiki_knowledge_base_ids=extras["llmwiki_knowledge_base_ids"],
|
|
)
|
|
await agent_store.replace_skills(agent_id, next_skills)
|
|
await agent_store.set_extras(
|
|
agent_id,
|
|
tool_groups=extras["tool_groups"],
|
|
model=next_model,
|
|
)
|
|
if "soul" in fields:
|
|
await asyncio.to_thread(
|
|
agents_router._write_agent_soul,
|
|
agent_id,
|
|
payload.soul or "",
|
|
)
|
|
|
|
if {"name", "description"} & fields:
|
|
updated = await agent_store.update_any(
|
|
agent_id,
|
|
{"name": next_name, "description": next_description},
|
|
)
|
|
if updated is None:
|
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
|
record = updated
|
|
|
|
enabled = payload.enabled if "enabled" in fields and not bool(role["fixed"]) else None
|
|
sort_order = payload.sort_order if "sort_order" in fields and not bool(role["fixed"]) else None
|
|
if enabled is not None or sort_order is not None:
|
|
updated_role = await role_store.update_role(
|
|
agent_id,
|
|
enabled=enabled,
|
|
sort_order=sort_order,
|
|
)
|
|
if updated_role is None:
|
|
raise HTTPException(status_code=404, detail=f"Strategy agent '{agent_id}' not found")
|
|
role = updated_role
|
|
|
|
if not bool(role["fixed"]):
|
|
await asyncio.to_thread(_write_role_sidecar, agent_id, role)
|
|
except Exception:
|
|
await _rollback_strategy_update(
|
|
agent_id=agent_id,
|
|
agent_store=agent_store,
|
|
role_store=role_store,
|
|
record=original_record,
|
|
role=original_role,
|
|
extras=extras,
|
|
file_snapshot=file_snapshot,
|
|
)
|
|
raise
|
|
|
|
return await _to_item(role, record, include_config=True)
|
|
|
|
|
|
@router.delete("/{agent_id}", response_model=StrategyAgentItem)
|
|
async def disable_strategy_angle(agent_id: str, request: Request) -> StrategyAgentItem:
|
|
"""Soft-disable an angle while retaining its agent and historical links."""
|
|
|
|
await _require_admin(request)
|
|
agent_id = agents_router._validate_storage_id(agent_id)
|
|
async with _strategy_mutation_guard(agent_id):
|
|
return await _disable_strategy_angle_locked(agent_id, request)
|
|
|
|
|
|
async def _disable_strategy_angle_locked(
|
|
agent_id: str,
|
|
request: Request,
|
|
) -> StrategyAgentItem:
|
|
role, record = await _get_role_and_agent(request, agent_id)
|
|
if bool(role["fixed"]):
|
|
raise HTTPException(status_code=409, detail="Fixed strategy roles cannot be deleted")
|
|
if role.get("category") != "angle":
|
|
raise HTTPException(status_code=409, detail="Only strategy angle roles can be disabled")
|
|
updated = await get_strategy_agent_role_store(request).update_role(
|
|
agent_id,
|
|
enabled=False,
|
|
)
|
|
if updated is None:
|
|
raise HTTPException(status_code=404, detail=f"Strategy agent '{agent_id}' not found")
|
|
try:
|
|
await asyncio.to_thread(_write_role_sidecar, agent_id, updated)
|
|
except Exception:
|
|
await get_strategy_agent_role_store(request).update_role(
|
|
agent_id,
|
|
enabled=bool(role["enabled"]),
|
|
sort_order=int(role["sort_order"]),
|
|
)
|
|
raise
|
|
return await _to_item(updated, record, include_config=True)
|
|
|
|
|
|
__all__ = [
|
|
"StrategyAgentItem",
|
|
"StrategyAgentListResponse",
|
|
"StrategyAgentUpdateRequest",
|
|
"StrategyAngleCreateRequest",
|
|
"create_strategy_angle",
|
|
"disable_strategy_angle",
|
|
"ensure_strategy_agent_registry",
|
|
"list_strategy_agents",
|
|
"router",
|
|
"update_strategy_agent",
|
|
]
|