# -*- coding: utf-8 -*- """ Agent API Routes Provides REST API endpoints for agent management within workspaces. """ import logging import os import tempfile from pathlib import Path from typing import Any, Dict, List, Optional from fastapi import APIRouter, HTTPException, Depends, Body, UploadFile, File, Form from pydantic import BaseModel, Field from backend.agents import AgentFactory, get_registry from backend.agents.workspace_manager import RunWorkspaceManager from backend.agents.agent_workspace import load_agent_workspace_config from backend.agents.skills_manager import SkillsManager from backend.agents.toolkit_factory import load_agent_profiles from backend.config.bootstrap_config import get_bootstrap_config_for_run from backend.llm.models import get_agent_model_info logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/workspaces/{workspace_id}/agents", tags=["agents"]) # Request/Response Models class CreateAgentRequest(BaseModel): """Request to create a new agent.""" agent_id: str = Field(..., description="Unique agent identifier") agent_type: str = Field(..., description="Type of agent (e.g., technical_analyst)") name: Optional[str] = Field(None, description="Display name") description: Optional[str] = Field(None, description="Agent description") clone_from: Optional[str] = Field(None, description="Agent ID to clone from") llm_model_config: Optional[Dict[str, Any]] = Field(None, description="LLM model configuration") class UpdateAgentRequest(BaseModel): """Request to update an agent.""" name: Optional[str] = None description: Optional[str] = None enabled_skills: Optional[List[str]] = None disabled_skills: Optional[List[str]] = None class InstallExternalSkillRequest(BaseModel): """Request to install an external skill for one agent.""" source: str = Field(..., description="Directory path, zip path, or http(s) zip URL") name: Optional[str] = Field(None, description="Optional override skill name") activate: bool = Field(True, description="Whether to enable skill immediately") class LocalSkillRequest(BaseModel): skill_name: str = Field(..., description="Local skill name") class LocalSkillContentRequest(BaseModel): content: str = Field(..., description="Updated SKILL.md content") class AgentResponse(BaseModel): """Agent information response.""" agent_id: str agent_type: str workspace_id: str config_path: str agent_dir: str status: str = "inactive" class AgentFileResponse(BaseModel): """Agent file content response.""" filename: str content: str class AgentProfileResponse(BaseModel): agent_id: str workspace_id: str profile: Dict[str, Any] class AgentSkillsResponse(BaseModel): agent_id: str workspace_id: str skills: List[Dict[str, Any]] class SkillDetailResponse(BaseModel): agent_id: str workspace_id: str skill: Dict[str, Any] # Dependencies def get_agent_factory(): """Get AgentFactory instance.""" return AgentFactory() def get_workspace_manager(): """Get run-scoped workspace manager instance.""" return RunWorkspaceManager() def get_skills_manager(): """Get SkillsManager instance.""" return SkillsManager() # Routes @router.post("", response_model=AgentResponse) async def create_agent( workspace_id: str, request: CreateAgentRequest, factory: AgentFactory = Depends(get_agent_factory), registry = Depends(get_registry), ): """ Create a new agent in a workspace. Args: workspace_id: Workspace identifier request: Agent creation parameters Returns: Created agent information """ # Check workspace exists if not factory.workspaces_root.exists(): raise HTTPException(status_code=404, detail="Workspaces root not found") workspace_dir = factory.workspaces_root / workspace_id if not workspace_dir.exists(): raise HTTPException(status_code=404, detail=f"Workspace '{workspace_id}' not found") try: # Create agent agent = factory.create_agent( agent_id=request.agent_id, agent_type=request.agent_type, workspace_id=workspace_id, clone_from=request.clone_from, ) # Register in registry registry.register( agent_id=request.agent_id, agent_type=request.agent_type, workspace_id=workspace_id, config_path=str(agent.config_path), agent_dir=str(agent.agent_dir), status="inactive", ) return AgentResponse( agent_id=agent.agent_id, agent_type=agent.agent_type, workspace_id=agent.workspace_id, config_path=str(agent.config_path), agent_dir=str(agent.agent_dir), status="inactive", ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @router.get("", response_model=List[AgentResponse]) async def list_agents( workspace_id: str, factory: AgentFactory = Depends(get_agent_factory), ): """ List all agents in a workspace. Args: workspace_id: Workspace identifier Returns: List of agents """ try: agents_data = factory.list_agents(workspace_id=workspace_id) return [ AgentResponse( agent_id=agent["agent_id"], agent_type=agent["agent_type"], workspace_id=workspace_id, config_path=agent["config_path"], agent_dir=str(Path(agent["config_path"]).parent), status="inactive", ) for agent in agents_data ] except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) @router.get("/{agent_id}", response_model=AgentResponse) async def get_agent( workspace_id: str, agent_id: str, registry = Depends(get_registry), ): """ Get agent details. Args: workspace_id: Workspace identifier agent_id: Agent identifier Returns: Agent information """ agent_info = registry.get(agent_id) if not agent_info or agent_info.workspace_id != workspace_id: raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") return AgentResponse( agent_id=agent_info.agent_id, agent_type=agent_info.agent_type, workspace_id=agent_info.workspace_id, config_path=agent_info.config_path, agent_dir=agent_info.agent_dir, status=agent_info.status, ) @router.get("/{agent_id}/profile", response_model=AgentProfileResponse) async def get_agent_profile( workspace_id: str, agent_id: str, skills_manager: SkillsManager = Depends(get_skills_manager), ): asset_dir = skills_manager.get_agent_asset_dir(workspace_id, agent_id) agent_config = load_agent_workspace_config(asset_dir / "agent.yaml") profiles = load_agent_profiles() profile = profiles.get(agent_id, {}) bootstrap = get_bootstrap_config_for_run(skills_manager.project_root, workspace_id) override = bootstrap.agent_override(agent_id) active_tool_groups = override.get("active_tool_groups", agent_config.active_tool_groups or profile.get("active_tool_groups", [])) if not isinstance(active_tool_groups, list): active_tool_groups = [] disabled_tool_groups = agent_config.disabled_tool_groups if disabled_tool_groups: disabled_set = set(disabled_tool_groups) active_tool_groups = [group_name for group_name in active_tool_groups if group_name not in disabled_set] default_skills = profile.get("skills", []) if not isinstance(default_skills, list): default_skills = [] resolved_skills = skills_manager.resolve_agent_skill_names( config_name=workspace_id, agent_id=agent_id, default_skills=default_skills, ) prompt_files = agent_config.prompt_files or ["SOUL.md", "PROFILE.md", "AGENTS.md", "POLICY.md", "MEMORY.md"] model_name, model_provider = get_agent_model_info(agent_id) return AgentProfileResponse( agent_id=agent_id, workspace_id=workspace_id, profile={ "model_name": model_name, "model_provider": model_provider, "prompt_files": prompt_files, "default_skills": default_skills, "resolved_skills": resolved_skills, "active_tool_groups": active_tool_groups, "disabled_tool_groups": disabled_tool_groups, "enabled_skills": agent_config.enabled_skills, "disabled_skills": agent_config.disabled_skills, }, ) @router.get("/{agent_id}/skills", response_model=AgentSkillsResponse) async def get_agent_skills( workspace_id: str, agent_id: str, skills_manager: SkillsManager = Depends(get_skills_manager), ): agent_asset_dir = skills_manager.get_agent_asset_dir(workspace_id, agent_id) agent_config = load_agent_workspace_config(agent_asset_dir / "agent.yaml") resolved_skills = set(skills_manager.resolve_agent_skill_names(config_name=workspace_id, agent_id=agent_id, default_skills=[])) enabled = set(agent_config.enabled_skills) disabled = set(agent_config.disabled_skills) payload = [] for item in skills_manager.list_agent_skill_catalog(workspace_id, agent_id): if item.skill_name in disabled: status = "disabled" elif item.skill_name in enabled: status = "enabled" elif item.skill_name in resolved_skills: status = "active" else: status = "available" payload.append({ "skill_name": item.skill_name, "name": item.name, "description": item.description, "version": item.version, "source": item.source, "tools": item.tools, "status": status, }) return AgentSkillsResponse(agent_id=agent_id, workspace_id=workspace_id, skills=payload) @router.get("/{agent_id}/skills/{skill_name}", response_model=SkillDetailResponse) async def get_agent_skill_detail( workspace_id: str, agent_id: str, skill_name: str, skills_manager: SkillsManager = Depends(get_skills_manager), ): try: detail = skills_manager.load_agent_skill_document( config_name=workspace_id, agent_id=agent_id, skill_name=skill_name, ) except FileNotFoundError: raise HTTPException(status_code=404, detail=f"Unknown skill: {skill_name}") return SkillDetailResponse(agent_id=agent_id, workspace_id=workspace_id, skill=detail) @router.delete("/{agent_id}") async def delete_agent( workspace_id: str, agent_id: str, factory: AgentFactory = Depends(get_agent_factory), registry = Depends(get_registry), ): """ Delete an agent. Args: workspace_id: Workspace identifier agent_id: Agent identifier Returns: Success message """ # Check agent exists in registry agent_info = registry.get(agent_id) if not agent_info or agent_info.workspace_id != workspace_id: raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") # Delete from factory success = factory.delete_agent(agent_id, workspace_id) if not success: raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") # Unregister registry.unregister(agent_id) return {"message": f"Agent '{agent_id}' deleted successfully"} @router.patch("/{agent_id}", response_model=AgentResponse) async def update_agent( workspace_id: str, agent_id: str, request: UpdateAgentRequest, registry = Depends(get_registry), ): """ Update agent configuration. Args: workspace_id: Workspace identifier agent_id: Agent identifier request: Update parameters Returns: Updated agent information """ agent_info = registry.get(agent_id) if not agent_info or agent_info.workspace_id != workspace_id: raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") # Update metadata in registry metadata_updates = {} if request.name: metadata_updates["name"] = request.name if request.description: metadata_updates["description"] = request.description if metadata_updates: registry.update_metadata(agent_id, metadata_updates) # Update skills if provided if request.enabled_skills or request.disabled_skills: skills_manager = SkillsManager() skills_manager.update_agent_skill_overrides( config_name=workspace_id, agent_id=agent_id, enable=request.enabled_skills or [], disable=request.disabled_skills or [], ) # Get updated info agent_info = registry.get(agent_id) return AgentResponse( agent_id=agent_info.agent_id, agent_type=agent_info.agent_type, workspace_id=agent_info.workspace_id, config_path=agent_info.config_path, agent_dir=agent_info.agent_dir, status=agent_info.status, ) @router.post("/{agent_id}/skills/{skill_name}/enable") async def enable_skill( workspace_id: str, agent_id: str, skill_name: str, registry = Depends(get_registry), ): """ Enable a skill for an agent. Args: workspace_id: Workspace identifier agent_id: Agent identifier skill_name: Skill name to enable Returns: Success message """ agent_info = registry.get(agent_id) if not agent_info or agent_info.workspace_id != workspace_id: raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") skills_manager = SkillsManager() result = skills_manager.update_agent_skill_overrides( config_name=workspace_id, agent_id=agent_id, enable=[skill_name], ) return { "message": f"Skill '{skill_name}' enabled for agent '{agent_id}'", "enabled_skills": result["enabled_skills"], } @router.post("/{agent_id}/skills/{skill_name}/disable") async def disable_skill( workspace_id: str, agent_id: str, skill_name: str, registry = Depends(get_registry), ): """ Disable a skill for an agent. Args: workspace_id: Workspace identifier agent_id: Agent identifier skill_name: Skill name to disable Returns: Success message """ agent_info = registry.get(agent_id) if not agent_info or agent_info.workspace_id != workspace_id: raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") skills_manager = SkillsManager() result = skills_manager.update_agent_skill_overrides( config_name=workspace_id, agent_id=agent_id, disable=[skill_name], ) return { "message": f"Skill '{skill_name}' disabled for agent '{agent_id}'", "disabled_skills": result["disabled_skills"], } @router.post("/{agent_id}/skills/install") async def install_external_skill( workspace_id: str, agent_id: str, request: InstallExternalSkillRequest, registry=Depends(get_registry), ): """Install an external skill into one agent's local skills.""" agent_info = registry.get(agent_id) if not agent_info or agent_info.workspace_id != workspace_id: raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") skills_manager = SkillsManager() try: result = skills_manager.install_external_skill_for_agent( config_name=workspace_id, agent_id=agent_id, source=request.source, skill_name=request.name, activate=request.activate, ) except (FileNotFoundError, ValueError) as exc: raise HTTPException(status_code=400, detail=str(exc)) return { "message": f"Installed external skill '{result['skill_name']}' for '{agent_id}'", **result, } @router.post("/{agent_id}/skills/local") async def create_local_skill( workspace_id: str, agent_id: str, request: LocalSkillRequest, registry=Depends(get_registry), ): agent_info = registry.get(agent_id) if not agent_info or agent_info.workspace_id != workspace_id: raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") skills_manager = SkillsManager() try: skills_manager.create_agent_local_skill( config_name=workspace_id, agent_id=agent_id, skill_name=request.skill_name, ) except (ValueError, FileExistsError) as exc: raise HTTPException(status_code=400, detail=str(exc)) return {"message": f"Created local skill '{request.skill_name}' for '{agent_id}'"} @router.put("/{agent_id}/skills/local/{skill_name}") async def update_local_skill( workspace_id: str, agent_id: str, skill_name: str, request: LocalSkillContentRequest, registry=Depends(get_registry), ): agent_info = registry.get(agent_id) if not agent_info or agent_info.workspace_id != workspace_id: raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") skills_manager = SkillsManager() try: skills_manager.update_agent_local_skill( config_name=workspace_id, agent_id=agent_id, skill_name=skill_name, content=request.content, ) except (ValueError, FileNotFoundError) as exc: raise HTTPException(status_code=400, detail=str(exc)) return {"message": f"Updated local skill '{skill_name}' for '{agent_id}'"} @router.delete("/{agent_id}/skills/local/{skill_name}") async def delete_local_skill( workspace_id: str, agent_id: str, skill_name: str, registry=Depends(get_registry), ): agent_info = registry.get(agent_id) if not agent_info or agent_info.workspace_id != workspace_id: raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") skills_manager = SkillsManager() try: skills_manager.delete_agent_local_skill( config_name=workspace_id, agent_id=agent_id, skill_name=skill_name, ) skills_manager.forget_agent_skill_overrides( config_name=workspace_id, agent_id=agent_id, skill_names=[skill_name], ) except (ValueError, FileNotFoundError) as exc: raise HTTPException(status_code=400, detail=str(exc)) return {"message": f"Deleted local skill '{skill_name}' for '{agent_id}'"} @router.post("/{agent_id}/skills/upload") async def upload_external_skill( workspace_id: str, agent_id: str, file: UploadFile = File(...), name: Optional[str] = Form(None), activate: bool = Form(True), registry=Depends(get_registry), ): """Upload a zip skill package from frontend and install for one agent.""" agent_info = registry.get(agent_id) if not agent_info or agent_info.workspace_id != workspace_id: raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found") original_name = (file.filename or "").strip() if not original_name.lower().endswith(".zip"): raise HTTPException(status_code=400, detail="Uploaded file must be a .zip archive") suffix = Path(original_name).suffix or ".zip" temp_path: Optional[str] = None try: with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: temp_path = tmp.name content = await file.read() tmp.write(content) skills_manager = SkillsManager() result = skills_manager.install_external_skill_for_agent( config_name=workspace_id, agent_id=agent_id, source=temp_path, skill_name=name, activate=activate, ) except (FileNotFoundError, ValueError) as exc: raise HTTPException(status_code=400, detail=str(exc)) finally: try: await file.close() except Exception as e: logger.warning(f"Failed to close uploaded file: {e}") if temp_path and os.path.exists(temp_path): os.remove(temp_path) return { "message": f"Uploaded and installed external skill '{result['skill_name']}' for '{agent_id}'", **result, } @router.get("/{agent_id}/files/{filename}", response_model=AgentFileResponse) async def get_agent_file( workspace_id: str, agent_id: str, filename: str, workspace_manager: RunWorkspaceManager = Depends(get_workspace_manager), ): """ Read an agent's workspace file. Args: workspace_id: Workspace identifier agent_id: Agent identifier filename: File to read (e.g., SOUL.md, PROFILE.md) Returns: File content """ try: content = workspace_manager.load_agent_file( config_name=workspace_id, agent_id=agent_id, filename=filename, ) return AgentFileResponse(filename=filename, content=content) except FileNotFoundError: raise HTTPException(status_code=404, detail=f"File '{filename}' not found") @router.put("/{agent_id}/files/{filename}", response_model=AgentFileResponse) async def update_agent_file( workspace_id: str, agent_id: str, filename: str, content: str = Body(..., media_type="text/plain"), workspace_manager: RunWorkspaceManager = Depends(get_workspace_manager), ): """ Update an agent's workspace file. Args: workspace_id: Workspace identifier agent_id: Agent identifier filename: File to update content: New file content Returns: Updated file information """ try: workspace_manager.update_agent_file( config_name=workspace_id, agent_id=agent_id, filename=filename, content=content, ) return AgentFileResponse(filename=filename, content=content) except Exception as e: raise HTTPException(status_code=500, detail=str(e))