# -*- coding: utf-8 -*- """ Run-scoped Agent API Routes Provides REST API endpoints for runtime agent asset access under `runs//`. This module separates runtime concerns from design-time workspace management: - `/api/runs/{run_id}/agents/*` - Runtime agent assets and configuration - design-time workspace registry CRUD lives under `/api/workspaces/{workspace_id}/...` """ 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.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/runs/{run_id}/agents", tags=["runs"]) # Request/Response Models 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 AgentFileResponse(BaseModel): """Agent file content response.""" filename: str content: str scope_type: str = "runtime_run" scope_note: Optional[str] = None class AgentProfileResponse(BaseModel): agent_id: str run_id: str profile: Dict[str, Any] scope_type: str = "runtime_run" scope_note: Optional[str] = None class AgentSkillsResponse(BaseModel): agent_id: str run_id: str skills: List[Dict[str, Any]] scope_type: str = "runtime_run" scope_note: Optional[str] = None class SkillDetailResponse(BaseModel): agent_id: str run_id: str skill: Dict[str, Any] scope_type: str = "runtime_run" scope_note: Optional[str] = None # Dependencies def get_workspace_manager(): """Get run-scoped asset manager for one runtime workspace/run id.""" return RunWorkspaceManager() def get_skills_manager(): """Get SkillsManager instance.""" return SkillsManager() # Runtime Routes @router.get("/{agent_id}/profile", response_model=AgentProfileResponse) async def get_agent_profile( run_id: str, agent_id: str, skills_manager: SkillsManager = Depends(get_skills_manager), ): """ Get agent profile from runtime assets under `runs//`. Args: run_id: Run identifier (e.g., "smoke_fullstack") agent_id: Agent identifier Returns: Agent profile with model config, skills, and tool groups """ asset_dir = skills_manager.get_agent_asset_dir(run_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, run_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=run_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, run_id=run_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( run_id: str, agent_id: str, skills_manager: SkillsManager = Depends(get_skills_manager), ): """ Get agent skills from runtime assets under `runs//`. Args: run_id: Run identifier agent_id: Agent identifier Returns: List of skills with their status (active/enabled/disabled/available) """ agent_asset_dir = skills_manager.get_agent_asset_dir(run_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=run_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(run_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, run_id=run_id, skills=payload, ) @router.get("/{agent_id}/skills/{skill_name}", response_model=SkillDetailResponse) async def get_agent_skill_detail( run_id: str, agent_id: str, skill_name: str, skills_manager: SkillsManager = Depends(get_skills_manager), ): """ Get detailed skill information from runtime assets. Args: run_id: Run identifier agent_id: Agent identifier skill_name: Skill name Returns: Skill detail information """ try: detail = skills_manager.load_agent_skill_document( config_name=run_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, run_id=run_id, skill=detail, ) @router.post("/{agent_id}/skills/{skill_name}/enable") async def enable_skill( run_id: str, agent_id: str, skill_name: str, skills_manager: SkillsManager = Depends(get_skills_manager), ): """ Enable a skill for an agent in runtime assets. Args: run_id: Run identifier agent_id: Agent identifier skill_name: Skill name to enable Returns: Success message with updated enabled skills list """ result = skills_manager.update_agent_skill_overrides( config_name=run_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( run_id: str, agent_id: str, skill_name: str, skills_manager: SkillsManager = Depends(get_skills_manager), ): """ Disable a skill for an agent in runtime assets. Args: run_id: Run identifier agent_id: Agent identifier skill_name: Skill name to disable Returns: Success message with updated disabled skills list """ result = skills_manager.update_agent_skill_overrides( config_name=run_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( run_id: str, agent_id: str, request: InstallExternalSkillRequest, skills_manager: SkillsManager = Depends(get_skills_manager), ): """ Install an external skill into one agent's local skills under `runs//`. Args: run_id: Run identifier agent_id: Agent identifier request: Installation parameters Returns: Success message with installed skill details """ try: result = skills_manager.install_external_skill_for_agent( config_name=run_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( run_id: str, agent_id: str, request: LocalSkillRequest, skills_manager: SkillsManager = Depends(get_skills_manager), ): """ Create a new local skill for an agent under `runs//`. Args: run_id: Run identifier agent_id: Agent identifier request: Local skill creation parameters Returns: Success message """ try: skills_manager.create_agent_local_skill( config_name=run_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( run_id: str, agent_id: str, skill_name: str, request: LocalSkillContentRequest, skills_manager: SkillsManager = Depends(get_skills_manager), ): """ Update a local skill's SKILL.md content under `runs//`. Args: run_id: Run identifier agent_id: Agent identifier skill_name: Skill name request: Updated content Returns: Success message """ try: skills_manager.update_agent_local_skill( config_name=run_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( run_id: str, agent_id: str, skill_name: str, skills_manager: SkillsManager = Depends(get_skills_manager), ): """ Delete a local skill under `runs//`. Args: run_id: Run identifier agent_id: Agent identifier skill_name: Skill name to delete Returns: Success message """ try: skills_manager.delete_agent_local_skill( config_name=run_id, agent_id=agent_id, skill_name=skill_name, ) skills_manager.forget_agent_skill_overrides( config_name=run_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( run_id: str, agent_id: str, file: UploadFile = File(...), name: Optional[str] = Form(None), activate: bool = Form(True), skills_manager: SkillsManager = Depends(get_skills_manager), ): """ Upload a zip skill package and install for one agent under `runs//`. Args: run_id: Run identifier agent_id: Agent identifier file: Zip file to upload name: Optional skill name override activate: Whether to enable skill immediately Returns: Success message with installed skill details """ 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) result = skills_manager.install_external_skill_for_agent( config_name=run_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( run_id: str, agent_id: str, filename: str, workspace_manager: RunWorkspaceManager = Depends(get_workspace_manager), ): """ Read an agent file from the run-scoped asset tree under `runs//`. Args: run_id: Run 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=run_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( run_id: str, agent_id: str, filename: str, content: str = Body(..., media_type="text/plain"), workspace_manager: RunWorkspaceManager = Depends(get_workspace_manager), ): """ Update an agent file in the run-scoped asset tree under `runs//`. Args: run_id: Run 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=run_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))