feat: Add agent workspace system and runtime management

- Add agent core modules (agent_core, factory, registry, skill_loader)
- Add runtime system for agent execution management
- Add REST API for agents, workspaces, and runtime control
- Add process supervisor for agent lifecycle management
- Add workspace template system with agent profiles
- Add frontend RuntimeView and runtime API integration
- Add per-agent skill workspaces for smoke_fullstack run
- Refactor skill system with active/installed separation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 16:43:29 +08:00
parent 2daf5717ba
commit 59b44545d0
121 changed files with 8384 additions and 358 deletions

405
backend/api/agents.py Normal file
View File

@@ -0,0 +1,405 @@
# -*- coding: utf-8 -*-
"""
Agent API Routes
Provides REST API endpoints for agent management within workspaces.
"""
from typing import Any, Dict, List, Optional
from pathlib import Path
from fastapi import APIRouter, HTTPException, Depends, Body
from pydantic import BaseModel, Field
from backend.agents import AgentFactory, WorkspaceManager, get_registry
from backend.agents.skills_manager import SkillsManager
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 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
# Dependencies
def get_agent_factory():
"""Get AgentFactory instance."""
return AgentFactory()
def get_workspace_manager():
"""Get WorkspaceManager instance."""
return WorkspaceManager()
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.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.get("/{agent_id}/files/{filename}", response_model=AgentFileResponse)
async def get_agent_file(
workspace_id: str,
agent_id: str,
filename: str,
workspace_manager: WorkspaceManager = 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, ROLE.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: WorkspaceManager = 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))