Source code for agentsociety2.backend.routers.custom

"""
自定义模块 API 路由

提供扫描、清理、测试自定义模块的 API 端点。

关联文件:
- @extension/src/projectStructureProvider.ts - 前端项目结构视图(调用此API)
- @extension/src/apiClient.ts - API客户端

API端点:
- POST /api/v1/custom/scan - 扫描自定义模块并生成JSON配置
- POST /api/v1/custom/clean - 清理自定义模块配置
- POST /api/v1/custom/test - 测试自定义模块
- GET /api/v1/custom/list - 列出已注册的自定义模块
- GET /api/v1/custom/status - 获取自定义模块状态

内部服务:
- @packages/agentsociety2/agentsociety2/backend/services/custom/scanner.py - 模块扫描
- @packages/agentsociety2/agentsociety2/backend/services/custom/generator.py - JSON生成
- @packages/agentsociety2/agentsociety2/registry/ - 模块注册表
"""

from fastapi import APIRouter, HTTPException, Query

from agentsociety2.backend.path_security import (
    resolve_under_root,
    resolve_workspace_root,
)
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
import os
import json

# agentsociety2 是一个 Python 包,通过 import 使用
from agentsociety2.backend.services.custom.scanner import CustomModuleScanner
from agentsociety2.backend.services.custom.generator import CustomModuleJsonGenerator
from agentsociety2.backend.services.custom.script_generator import SafeModuleTester
from agentsociety2.registry import (
    get_registered_env_modules,
    get_registered_agent_modules,
    get_registry,
    register_scanned_custom_modules,
    scan_and_register_custom_modules,
)
from agentsociety2.logger import get_logger

logger = get_logger()

router = APIRouter(prefix="/api/v1/custom", tags=["custom"])


# ========== 请求/响应模型 ==========


[docs] class ScanRequest(BaseModel): """扫描请求""" workspace_path: Optional[str] = Field( None, description="工作区路径,不提供则使用环境变量" )
[docs] class ScanResponse(BaseModel): """扫描响应""" success: bool agents_found: int envs_found: int agents_generated: int envs_generated: int errors: List[str] = Field(default_factory=list) agent_diagnostics: List[Dict[str, Any]] = Field(default_factory=list) env_diagnostics: List[Dict[str, Any]] = Field(default_factory=list) message: Optional[str] = None
[docs] class CleanResponse(BaseModel): """清理响应""" success: bool removed_count: int message: str
[docs] class TestRequest(BaseModel): """测试请求""" workspace_path: Optional[str] = Field( None, description="工作区路径,不提供则使用环境变量" ) module_kind: Optional[str] = Field( None, description="模块类型: 'agent' 或 'env_module',不提供则测试所有" ) module_class_name: Optional[str] = Field( None, description="要测试的类名,与 module_kind 配合使用" )
[docs] class ModuleTestResult(BaseModel): """单个模块测试结果""" name: str module_kind: str = "env_module" success: bool output: str error: Optional[str] = None checks: List[Dict[str, Any]] = Field(default_factory=list) metadata: Dict[str, Any] = Field(default_factory=dict)
[docs] class TestResponse(BaseModel): """测试响应""" success: bool test_output: str error: Optional[str] = None returncode: Optional[int] = None results: List[ModuleTestResult] = Field(default_factory=list) total_tests: Optional[int] = None passed_tests: Optional[int] = None failed_tests: Optional[int] = None
[docs] class ListResponse(BaseModel): """列表响应""" success: bool agents: List[Dict[str, Any]] envs: List[Dict[str, Any]] total_agents: int total_envs: int
# ========== API 端点 ==========
[docs] @router.post("/scan", response_model=ScanResponse) async def scan_custom_modules(request: ScanRequest): """ 扫描自定义模块并注册到内存 扫描工作区的 custom/agents/ 和 custom/envs/ 目录(跳过 examples/ 子目录), 验证发现的模块并将其直接注册到内存中的 registry。 :param request: 扫描请求,包含: - workspace_path: 工作区路径(可选,不提供则使用环境变量) :returns: ScanResponse: 扫描结果,包含: - success: 是否成功 - agents_found: 发现的Agent数量 - envs_found: 发现的环境模块数量 - agents_generated: 成功注册的Agent数量 - envs_generated: 成功注册的环境模块数量 - errors: 错误信息列表 - message: 结果消息 :raises HTTPException: 400 - 未提供工作区路径 :raises HTTPException: 500 - 扫描失败 Note: 此接口不会生成JSON配置文件,模块仅注册到内存中。 如需持久化配置,请使用 /api/v1/custom/classes 端点。 """ workspace_path = request.workspace_path or os.getenv("WORKSPACE_PATH") if not workspace_path: raise HTTPException( status_code=400, detail="Workspace path not provided. Set WORKSPACE_PATH env var or pass in request.", ) try: logger.info(f"[Custom Modules] Starting scan of workspace: {workspace_path}") scanner = CustomModuleScanner(workspace_path) scan_result = scanner.scan_all() logger.info( f"[Custom Modules] Scan complete: {len(scan_result['agents'])} agents, " f"{len(scan_result['envs'])} envs found" ) registry = get_registry() registry.clear_custom_modules() scan_result = register_scanned_custom_modules(scan_result, registry) scan_result["errors"].extend(scan_result.get("registration_errors", [])) message_parts = [] agents_count = len(scan_result.get("agents", [])) envs_count = len(scan_result.get("envs", [])) if agents_count > 0: message_parts.append(f"发现 {agents_count} 个 Agent") if envs_count > 0: message_parts.append(f"发现 {envs_count} 个环境模块") if not message_parts: message = "未发现任何自定义模块" else: message = "、".join(message_parts) + ",已注册到内存" logger.info(f"[Custom Modules] Scan complete: {message}") return ScanResponse( success=True, agents_found=len(scan_result["agents"]), envs_found=len(scan_result["envs"]), agents_generated=agents_count, envs_generated=envs_count, errors=scan_result.get("errors", []), agent_diagnostics=scan_result.get("agent_diagnostics", []), env_diagnostics=scan_result.get("env_diagnostics", []), message=message, ) except HTTPException: raise except Exception as e: logger.error(f"[Custom Modules] Scan failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail="扫描失败") from None
[docs] @router.post("/clean", response_model=CleanResponse) async def clean_custom_modules(request: ScanRequest): """ 清理自定义模块的JSON配置 删除所有标记为 is_custom=true 的JSON配置文件。 :param request: 清理请求,包含: - workspace_path: 工作区路径(可选) :returns: CleanResponse: 清理结果,包含: - success: 是否成功 - removed_count: 删除的配置数量 - message: 结果消息 :raises HTTPException: 400 - 未提供工作区路径 :raises HTTPException: 500 - 清理失败 """ workspace_path = request.workspace_path or os.getenv("WORKSPACE_PATH") if not workspace_path: raise HTTPException( status_code=400, detail="Workspace path not provided. Set WORKSPACE_PATH env var or pass in request.", ) try: generator = CustomModuleJsonGenerator(workspace_path) count = generator.remove_custom_modules() return CleanResponse( success=True, removed_count=count, message=f"已清理 {count} 个自定义模块配置", ) except HTTPException: raise except Exception as e: logger.error(f"[Custom Modules] Clean failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail="清理失败") from None
[docs] @router.post("/test", response_model=TestResponse) async def test_custom_modules(request: TestRequest): """ 测试自定义模块 扫描并测试自定义模块,验证其能否正常工作。可以测试所有模块或指定特定模块。 :param request: 测试请求,包含: - workspace_path: 工作区路径(可选) - module_kind: 模块类型 ('agent' 或 'env_module',可选) - module_class_name: 要测试的类名(与module_kind配合使用,可选) :returns: TestResponse: 测试结果,包含: - success: 是否全部通过 - test_output: 测试输出内容 - error: 错误信息(如有) - returncode: 测试进程返回码 - results: 各模块测试结果列表 - total_tests: 总测试数 - passed_tests: 通过数 - failed_tests: 失败数 :raises HTTPException: 400 - 未提供工作区路径 :raises HTTPException: 500 - 测试失败 Note: 如果不指定 module_kind 和 module_class_name,则测试所有发现的模块。 """ workspace_path = request.workspace_path or os.getenv("WORKSPACE_PATH") if not workspace_path: raise HTTPException( status_code=400, detail="Workspace path not provided. Set WORKSPACE_PATH env var or pass in request.", ) module_kind = request.module_kind module_class_name = request.module_class_name try: # 记录测试请求 if module_kind and module_class_name: logger.info( f"[Custom Modules] Testing specific module: {module_kind}.{module_class_name}" ) else: logger.info( f"[Custom Modules] Starting test of workspace: {workspace_path}" ) builder = SafeModuleTester(workspace_path) if module_kind and module_class_name: scanner = CustomModuleScanner(workspace_path) scan_result = scanner.scan_all() target_modules = ( scan_result.get("agents", []) if module_kind == "agent" else scan_result.get("envs", []) ) module_info = next( ( item for item in target_modules if item.get("class_name") == module_class_name ), None, ) if module_info is None: diagnostics = ( scan_result.get("agent_diagnostics", []) if module_kind == "agent" else scan_result.get("env_diagnostics", []) ) module_info = next( ( item for item in diagnostics if item.get("class_name") == module_class_name ), None, ) if module_info is None: logger.warning( f"[Custom Modules] Module not found: {module_kind}.{module_class_name}" ) return TestResponse( success=False, test_output="", error=f"未找到指定的模块: {module_class_name}", results=[], total_tests=0, passed_tests=0, failed_tests=0, ) result = await builder.run_target_test( module_kind=module_kind, module_path=module_info.get("module_path", ""), class_name=module_class_name, ) else: scanner = CustomModuleScanner(workspace_path) scan_result = scanner.scan_all() agents = scan_result.get("agents", []) envs = scan_result.get("envs", []) logger.info( f"[Custom Modules] Test scan found: {len(agents)} agents, {len(envs)} envs" ) if not agents and not envs: logger.warning("[Custom Modules] No custom modules found for testing") return TestResponse( success=False, test_output="", error="未发现任何自定义模块,请先在 custom/ 目录下创建模块", results=[], total_tests=0, passed_tests=0, failed_tests=0, ) result = await builder.run_test(scan_result) # 记录每个模块的测试结果 for module_result in result.get("results", []): status = "PASSED" if module_result["success"] else "FAILED" logger.info(f"[Custom Modules] Test {status}: {module_result['name']}") if module_result.get("error"): logger.error( f"[Custom Modules] Test error for {module_result['name']}: {module_result['error']}" ) output = result.get("stdout", "") stderr = result.get("stderr", "") if stderr: output = output + "\n--- 错误输出 ---\n" + stderr if output else stderr # 记录总体测试结果 total = result.get("total_tests", 0) passed = result.get("passed_tests", 0) failed = result.get("failed_tests", 0) logger.info( f"[Custom Modules] Test complete: {passed}/{total} passed, {failed} failed" ) return TestResponse( success=result["success"], test_output=output, error=result.get("error"), returncode=result.get("returncode"), results=[ModuleTestResult(**r) for r in result.get("results", [])], total_tests=result.get("total_tests"), passed_tests=result.get("passed_tests"), failed_tests=result.get("failed_tests"), ) except HTTPException: raise except Exception as e: logger.error(f"[Custom Modules] Test failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail="测试失败") from None
[docs] @router.get("/list", response_model=ListResponse) async def list_custom_modules(): """ 列出当前已注册的自定义模块 从内存注册表中读取所有标记为 is_custom=true 的模块信息。 :returns: ListResponse: 模块列表,包含: - success: 是否成功 - agents: 自定义Agent列表 - envs: 自定义环境模块列表 - total_agents: Agent总数 - total_envs: 环境模块总数 :raises HTTPException: 500 - 获取列表失败 """ try: registry = get_registry() workspace_path = os.getenv("WORKSPACE_PATH") if workspace_path and not registry._custom_loaded: try: scan_and_register_custom_modules( resolve_workspace_root(workspace_path), registry ) except Exception as exc: logger.warning(f"[Custom Modules] Auto-load before list failed: {exc}") result = {"agents": [], "envs": []} # 从注册表获取自定义 Agent for agent_type, agent_class in get_registered_agent_modules(): if getattr(agent_class, "_is_custom", False): try: description = agent_class.description() except Exception: description = f"{agent_class.__name__}: {agent_class.__doc__ or 'No description available'}" try: init_description = agent_class.init_description() except Exception: init_description = "" result["agents"].append( { "type": agent_type, "class_name": agent_class.__name__, "description": description, "init_description": init_description, "is_custom": True, } ) # 从注册表获取自定义环境模块 for module_type, env_class in get_registered_env_modules(): if getattr(env_class, "_is_custom", False): try: description = env_class.description() except Exception: description = f"{env_class.__name__}: {env_class.__doc__ or 'No description available'}" try: init_description = env_class.init_description() except Exception: init_description = "" result["envs"].append( { "type": module_type, "class_name": env_class.__name__, "description": description, "init_description": init_description, "is_custom": True, } ) return ListResponse( success=True, agents=result["agents"], envs=result["envs"], total_agents=len(result["agents"]), total_envs=len(result["envs"]), ) except HTTPException: raise except Exception as e: logger.error(f"[Custom Modules] List failed: {e}", exc_info=True) raise HTTPException(status_code=500, detail="列表获取失败") from None
[docs] @router.get("/status") async def get_custom_modules_status(): """ 获取自定义模块状态概览 返回工作区自定义模块目录的状态信息。 :returns: Dict[str, Any]: 状态信息,包含: - custom_dir_exists: custom目录是否存在 - agents_dir_exists: agents子目录是否存在 - envs_dir_exists: envs子目录是否存在 - agent_files_count: Agent文件数量 - env_files_count: 环境模块文件数量 - registered_agents: 已注册的Agent数量 - registered_envs: 已注册的环境模块数量 :raises HTTPException: 400 - 未设置工作区路径 """ workspace_path = os.getenv("WORKSPACE_PATH") if not workspace_path: raise HTTPException(status_code=400, detail="Workspace path not set") workspace = resolve_workspace_root(workspace_path) custom_dir = resolve_under_root(workspace, "custom") status = { "custom_dir_exists": custom_dir.exists(), "agents_dir_exists": (custom_dir / "agents").exists(), "envs_dir_exists": (custom_dir / "envs").exists(), "agent_files_count": 0, "env_files_count": 0, "registered_agents": 0, "registered_envs": 0, } # 统计自定义代码文件 if status["agents_dir_exists"]: status["agent_files_count"] = len( [ f for f in (custom_dir / "agents").rglob("*.py") if not f.name.startswith("__") and "examples" not in f.parts ] ) if status["envs_dir_exists"]: status["env_files_count"] = len( [ f for f in (custom_dir / "envs").rglob("*.py") if not f.name.startswith("__") and "examples" not in f.parts ] ) # 统计已注册的模块(从内存注册表中读取) try: for _agent_type, agent_class in get_registered_agent_modules(): if getattr(agent_class, "_is_custom", False): status["registered_agents"] += 1 for _module_type, env_class in get_registered_env_modules(): if getattr(env_class, "_is_custom", False): status["registered_envs"] += 1 except Exception as e: logger.warning(f"[Custom Modules] Failed to count registered modules: {e}") return status
[docs] @router.get("/classes") async def list_available_classes( workspace_path: str = Query(..., description="工作区路径"), include_custom: bool = Query(True, description="是否包含自定义模块"), ) -> Dict[str, Any]: """ 列出所有可用的Agent类和环境模块类 返回所有可用的类,并标记哪些已配置预填充参数。 :param workspace_path: 工作区路径(必填) :param include_custom: 是否包含自定义模块,默认True :returns: Dict[str, Any]: 可用类列表,包含: - success: 是否成功 - env_modules: 环境模块字典,每个模块包含: - type, class_name, description, is_custom, has_prefill - agents: Agent字典,每个Agent包含: - type, class_name, description, is_custom, has_prefill - env_module_count: 环境模块数量 - agent_count: Agent数量 :raises HTTPException: 500 - 获取类列表失败 """ try: registry = get_registry() # 扫描自定义模块(如果请求) if include_custom: try: scan_and_register_custom_modules( resolve_workspace_root(workspace_path), registry ) except Exception as e: logger.warning(f"Failed to scan custom modules: {e}") # 获取所有已注册的Agent类 agents = {} for agent_type, agent_class in get_registered_agent_modules(): try: description = agent_class.description() except Exception: description = f"{agent_class.__name__}: {agent_class.__doc__ or 'No description available'}" try: init_description = agent_class.init_description() except Exception: init_description = "" agents[agent_type] = { "type": agent_type, "class_name": agent_class.__name__, "description": description, "init_description": init_description, "is_custom": getattr(agent_class, "_is_custom", False), } # 获取所有已注册的Env Module类 env_modules = {} for module_type, env_class in get_registered_env_modules(): try: description = env_class.description() except Exception: description = f"{env_class.__name__}: {env_class.__doc__ or 'No description available'}" try: init_description = env_class.init_description() except Exception: init_description = "" env_modules[module_type] = { "type": module_type, "class_name": env_class.__name__, "description": description, "init_description": init_description, "is_custom": getattr(env_class, "_is_custom", False), } # 加载预填充参数,标记哪些类已配置 workspace = resolve_workspace_root(workspace_path) prefill_file = resolve_under_root( workspace, ".agentsociety", "prefill_params.json" ) env_prefill = {} agent_prefill = {} if prefill_file.is_file(): try: prefill_params = json.loads(prefill_file.read_text(encoding="utf-8")) env_prefill = prefill_params.get("env_modules", {}) agent_prefill = prefill_params.get("agents", {}) except Exception as e: logger.warning(f"Failed to load prefill params: {e}") # 为每个类添加是否已配置的标记 for module_type in env_modules: env_modules[module_type]["has_prefill"] = ( module_type in env_prefill and bool(env_prefill[module_type]) ) for agent_type in agents: agents[agent_type]["has_prefill"] = agent_type in agent_prefill and bool( agent_prefill[agent_type] ) return { "success": True, "env_modules": env_modules, "agents": agents, "env_module_count": len(env_modules), "agent_count": len(agents), } except HTTPException: raise except Exception as e: logger.error(f"Failed to list available classes: {e}", exc_info=True) raise HTTPException( status_code=500, detail="Failed to list available classes" ) from None
[docs] @router.post("/rescan") async def rescan_custom_modules( workspace_path: str = Query(..., description="工作区路径"), ) -> Dict[str, Any]: """ 重新扫描自定义模块 清除内存中的旧模块并重新扫描工作区的自定义模块。 :param workspace_path: 工作区路径(必填) :returns: Dict[str, Any]: 扫描结果,包含: - success: 是否成功 - scan_result: 扫描详情 - message: 结果消息 :raises HTTPException: 500 - 重新扫描失败 """ try: registry = get_registry() # 清除旧的自定义模块 registry.clear_custom_modules() # 扫描新的自定义模块 scan_result = scan_and_register_custom_modules( resolve_workspace_root(workspace_path), registry ) return { "success": True, "scan_result": scan_result, "message": f"Scanned {len(scan_result.get('envs', []))} env modules and " f"{len(scan_result.get('agents', []))} agents", } except HTTPException: raise except Exception as e: logger.error(f"Failed to rescan custom modules: {e}", exc_info=True) raise HTTPException( status_code=500, detail="Failed to rescan custom modules" ) from None