mirror of
https://github.com/countbot-ai/CountBot.git
synced 2026-09-14 20:46:47 +08:00
399 lines
14 KiB
Python
399 lines
14 KiB
Python
"""Agent Teams API — CRUD for user-defined multi-agent workflow templates."""
|
|
|
|
import uuid
|
|
from typing import Any, List, Literal, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from loguru import logger
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.database import get_db
|
|
from backend.models.agent_team import AgentTeam
|
|
|
|
router = APIRouter(prefix="/api/agent-teams", tags=["agent-teams"])
|
|
|
|
|
|
def _normalize_api_mode(value: Any) -> str:
|
|
return "chat_completions"
|
|
|
|
|
|
# ============================================================================
|
|
# Pydantic schemas
|
|
# ============================================================================
|
|
|
|
|
|
class AgentDefinition(BaseModel):
|
|
"""One agent slot inside a workflow."""
|
|
id: str = Field(..., description="Unique identifier within the team")
|
|
role: str = Field(default="", description="Role / persona label")
|
|
system_prompt: Optional[str] = Field(
|
|
None,
|
|
description=(
|
|
"Persistent system-level instructions for this agent. "
|
|
"Injected as the system message so the LLM fully adopts this persona "
|
|
"before seeing the workflow goal or task."
|
|
),
|
|
)
|
|
task: str = Field(default="", description="What this agent should do (pipeline/graph only)")
|
|
perspective: Optional[str] = Field(None, description="Viewpoint label (council mode only)")
|
|
depends_on: List[str] = Field(default_factory=list, description="IDs this agent waits for (graph mode)")
|
|
condition: Optional[dict] = Field(
|
|
None,
|
|
description=(
|
|
"Optional execution condition (graph mode only). "
|
|
"Example: {'type': 'output_contains', 'node': 'test', 'text': '通过'}"
|
|
),
|
|
)
|
|
|
|
|
|
class AgentTeamCreate(BaseModel):
|
|
name: str = Field(..., min_length=1, max_length=200)
|
|
description: Optional[str] = Field(None, max_length=1000)
|
|
mode: str = Field("pipeline", pattern="^(pipeline|graph|council)$")
|
|
agents: List[AgentDefinition] = Field(default_factory=list)
|
|
is_active: bool = Field(True)
|
|
cross_review: bool = Field(True, description="Council mode only: enable cross-review between members")
|
|
enable_skills: bool = Field(False, description="Enable skills system for sub-agents")
|
|
|
|
|
|
class AgentTeamUpdate(BaseModel):
|
|
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
|
description: Optional[str] = None
|
|
mode: Optional[str] = Field(None, pattern="^(pipeline|graph|council)$")
|
|
agents: Optional[List[AgentDefinition]] = None
|
|
is_active: Optional[bool] = None
|
|
cross_review: Optional[bool] = Field(None, description="Council mode only: enable cross-review between members")
|
|
enable_skills: Optional[bool] = Field(None, description="Enable skills system for sub-agents")
|
|
|
|
|
|
class AgentTeamResponse(BaseModel):
|
|
id: str
|
|
name: str
|
|
description: Optional[str]
|
|
mode: str
|
|
agents: List[Any]
|
|
is_active: bool
|
|
cross_review: bool
|
|
enable_skills: bool
|
|
use_custom_model: bool
|
|
created_at: str
|
|
updated_at: str
|
|
|
|
|
|
class TeamModelConfigRequest(BaseModel):
|
|
"""团队模型配置请求"""
|
|
provider: Optional[str] = None
|
|
model: Optional[str] = None
|
|
api_mode: Optional[str] = None
|
|
temperature: Optional[float] = None
|
|
max_tokens: Optional[int] = None
|
|
max_iterations: Optional[int] = None
|
|
thinking_enabled: Optional[bool] = None
|
|
api_key: Optional[str] = None
|
|
api_base: Optional[str] = None
|
|
|
|
|
|
class TeamModelConfigResponse(BaseModel):
|
|
"""团队模型配置响应"""
|
|
team_id: str
|
|
use_custom_model: bool
|
|
model_settings: dict
|
|
global_defaults: dict
|
|
|
|
|
|
# ============================================================================
|
|
# Helpers
|
|
# ============================================================================
|
|
|
|
|
|
def _to_response(team: AgentTeam) -> AgentTeamResponse:
|
|
return AgentTeamResponse(**team.to_dict())
|
|
|
|
|
|
async def _get_or_404(team_id: str, db: AsyncSession) -> AgentTeam:
|
|
result = await db.execute(select(AgentTeam).where(AgentTeam.id == team_id))
|
|
team = result.scalar_one_or_none()
|
|
if not team:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Agent team '{team_id}' not found")
|
|
return team
|
|
|
|
|
|
# ============================================================================
|
|
# Endpoints
|
|
# ============================================================================
|
|
|
|
|
|
@router.get("/", response_model=List[AgentTeamResponse])
|
|
async def list_teams(db: AsyncSession = Depends(get_db)) -> List[AgentTeamResponse]:
|
|
"""Return all agent teams ordered by creation time (newest first)."""
|
|
try:
|
|
result = await db.execute(
|
|
select(AgentTeam).order_by(AgentTeam.created_at.desc())
|
|
)
|
|
teams = result.scalars().all()
|
|
return [_to_response(t) for t in teams]
|
|
except Exception as exc:
|
|
logger.exception(f"Failed to list agent teams: {exc}")
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
|
|
|
|
@router.get("/{team_id}", response_model=AgentTeamResponse)
|
|
async def get_team(team_id: str, db: AsyncSession = Depends(get_db)) -> AgentTeamResponse:
|
|
team = await _get_or_404(team_id, db)
|
|
return _to_response(team)
|
|
|
|
|
|
@router.post("/", response_model=AgentTeamResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_team(
|
|
payload: AgentTeamCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> AgentTeamResponse:
|
|
"""Create a new agent team template."""
|
|
try:
|
|
# 检查是否存在同名团队
|
|
existing = await db.execute(
|
|
select(AgentTeam).where(AgentTeam.name == payload.name)
|
|
)
|
|
if existing.scalar_one_or_none() is not None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"团队名称 '{payload.name}' 已存在,请使用其他名称"
|
|
)
|
|
|
|
team = AgentTeam(
|
|
id=str(uuid.uuid4()),
|
|
name=payload.name,
|
|
description=payload.description,
|
|
mode=payload.mode,
|
|
agents=[a.model_dump() for a in payload.agents],
|
|
is_active=payload.is_active,
|
|
cross_review=payload.cross_review,
|
|
enable_skills=payload.enable_skills,
|
|
)
|
|
db.add(team)
|
|
await db.commit()
|
|
await db.refresh(team)
|
|
return _to_response(team)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
await db.rollback()
|
|
logger.exception(f"Failed to create agent team: {exc}")
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
|
|
|
|
@router.put("/{team_id}", response_model=AgentTeamResponse)
|
|
async def update_team(
|
|
team_id: str,
|
|
payload: AgentTeamUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> AgentTeamResponse:
|
|
"""Update an existing agent team."""
|
|
team = await _get_or_404(team_id, db)
|
|
try:
|
|
# 如果要修改名称,检查新名称是否与其他团队重复
|
|
if payload.name is not None and payload.name != team.name:
|
|
existing = await db.execute(
|
|
select(AgentTeam).where(
|
|
AgentTeam.name == payload.name,
|
|
AgentTeam.id != team_id
|
|
)
|
|
)
|
|
if existing.scalar_one_or_none() is not None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"团队名称 '{payload.name}' 已存在,请使用其他名称"
|
|
)
|
|
team.name = payload.name
|
|
|
|
if payload.description is not None:
|
|
team.description = payload.description
|
|
if payload.mode is not None:
|
|
team.mode = payload.mode
|
|
if payload.agents is not None:
|
|
team.agents = [a.model_dump() for a in payload.agents]
|
|
if payload.is_active is not None:
|
|
team.is_active = payload.is_active
|
|
if payload.cross_review is not None:
|
|
team.cross_review = payload.cross_review
|
|
if payload.enable_skills is not None:
|
|
team.enable_skills = payload.enable_skills
|
|
await db.commit()
|
|
await db.refresh(team)
|
|
return _to_response(team)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
await db.rollback()
|
|
logger.exception(f"Failed to update agent team {team_id}: {exc}")
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
|
|
|
|
@router.delete("/{team_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_team(team_id: str, db: AsyncSession = Depends(get_db)) -> None:
|
|
"""Delete an agent team."""
|
|
team = await _get_or_404(team_id, db)
|
|
try:
|
|
await db.delete(team)
|
|
await db.commit()
|
|
except Exception as exc:
|
|
await db.rollback()
|
|
logger.exception(f"Failed to delete agent team {team_id}: {exc}")
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
|
|
|
|
# ============================================================================
|
|
# Team Model Configuration Endpoints
|
|
# ============================================================================
|
|
|
|
|
|
@router.get("/{team_id}/config", response_model=TeamModelConfigResponse)
|
|
async def get_team_config(
|
|
team_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> TeamModelConfigResponse:
|
|
"""获取团队模型配置
|
|
|
|
返回团队的有效配置(如果有自定义配置则返回,否则返回全局默认)
|
|
"""
|
|
import json
|
|
from backend.modules.config.loader import config_loader
|
|
|
|
team = await _get_or_404(team_id, db)
|
|
|
|
try:
|
|
config = config_loader.config
|
|
global_model = config.model.model_dump()
|
|
|
|
# 解析团队自定义配置
|
|
team_model_config = {}
|
|
if team.use_custom_model and team.team_model_config:
|
|
try:
|
|
team_model_config = json.loads(team.team_model_config)
|
|
except json.JSONDecodeError:
|
|
logger.warning(f"Failed to parse team model config for {team_id}")
|
|
|
|
# 合并配置(团队配置覆盖全局配置)
|
|
effective_config = global_model.copy()
|
|
for key, value in team_model_config.items():
|
|
if value is not None and value != "":
|
|
effective_config[key] = (
|
|
_normalize_api_mode(value) if key == "api_mode" else value
|
|
)
|
|
|
|
# 确保 api_key 和 api_base 字段存在
|
|
if "api_key" not in effective_config:
|
|
effective_config["api_key"] = ""
|
|
if "api_base" not in effective_config:
|
|
effective_config["api_base"] = ""
|
|
|
|
return TeamModelConfigResponse(
|
|
team_id=team_id,
|
|
use_custom_model=team.use_custom_model,
|
|
model_settings=effective_config,
|
|
global_defaults=global_model,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.exception(f"Failed to get team config: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to get team config: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.put("/{team_id}/config")
|
|
async def update_team_config(
|
|
team_id: str,
|
|
request: TeamModelConfigRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> dict:
|
|
"""更新团队模型配置"""
|
|
import json
|
|
from datetime import datetime, timezone
|
|
|
|
team = await _get_or_404(team_id, db)
|
|
|
|
try:
|
|
# 构建配置字典(只保存非空值)
|
|
config_dict = {}
|
|
if request.provider is not None and request.provider != "":
|
|
config_dict["provider"] = request.provider
|
|
if request.model is not None and request.model != "":
|
|
config_dict["model"] = request.model
|
|
if request.api_mode is not None and request.api_mode != "":
|
|
config_dict["api_mode"] = _normalize_api_mode(request.api_mode)
|
|
if request.temperature is not None:
|
|
config_dict["temperature"] = request.temperature
|
|
if request.max_tokens is not None:
|
|
config_dict["max_tokens"] = request.max_tokens
|
|
if request.max_iterations is not None:
|
|
config_dict["max_iterations"] = request.max_iterations
|
|
if request.thinking_enabled is not None:
|
|
config_dict["thinking_enabled"] = request.thinking_enabled
|
|
if request.api_key is not None and request.api_key != "":
|
|
config_dict["api_key"] = request.api_key
|
|
if request.api_base is not None and request.api_base != "":
|
|
config_dict["api_base"] = request.api_base
|
|
|
|
# 保存配置
|
|
team.team_model_config = json.dumps(config_dict)
|
|
team.use_custom_model = True
|
|
team.updated_at = datetime.now(timezone.utc)
|
|
|
|
await db.commit()
|
|
await db.refresh(team)
|
|
|
|
logger.info(f"Updated model config for team {team_id}: {config_dict}")
|
|
|
|
return {
|
|
"success": True,
|
|
"team_id": team_id,
|
|
"message": "Team model configuration updated"
|
|
}
|
|
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.exception(f"Failed to update team config: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to update team config: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.delete("/{team_id}/config")
|
|
async def reset_team_config(
|
|
team_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> dict:
|
|
"""重置团队模型配置为全局默认"""
|
|
from datetime import datetime, timezone
|
|
|
|
team = await _get_or_404(team_id, db)
|
|
|
|
try:
|
|
team.team_model_config = None
|
|
team.use_custom_model = False
|
|
team.updated_at = datetime.now(timezone.utc)
|
|
|
|
await db.commit()
|
|
|
|
logger.info(f"Reset model config for team {team_id}")
|
|
|
|
return {
|
|
"success": True,
|
|
"team_id": team_id,
|
|
"message": "Team model configuration reset to global defaults"
|
|
}
|
|
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.exception(f"Failed to reset team config: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to reset team config: {str(e)}"
|
|
)
|