feat(agent): complete EvoAgent integration for all 6 agent roles

Migrate all agent roles from Legacy to EvoAgent architecture:
- fundamentals_analyst, technical_analyst, sentiment_analyst, valuation_analyst
- risk_manager, portfolio_manager

Key changes:
- EvoAgent now supports Portfolio Manager compatibility methods (_make_decision,
  get_decisions, get_portfolio_state, load_portfolio_state, update_portfolio)
- Add UnifiedAgentFactory for centralized agent creation
- ToolGuard with batch approval API and WebSocket broadcast
- Legacy agents marked deprecated (AnalystAgent, RiskAgent, PMAgent)
- Remove backend/agents/compat.py migration shim
- Add run_id alongside workspace_id for semantic clarity
- Complete integration test coverage (13 tests)
- All smoke tests passing for 6 agent roles

Constraint: Must maintain backward compatibility with existing run configs
Constraint: Memory support must work with EvoAgent (no fallback to Legacy)
Rejected: Separate PM implementation for EvoAgent | unified approach cleaner
Confidence: high
Scope-risk: broad
Directive: EVO_AGENT_IDS env var still respected but defaults to all roles
Not-tested: Kubernetes sandbox mode for skill execution
This commit is contained in:
2026-04-02 00:55:08 +08:00
parent 0fa413380c
commit 16b54d5ccc
73 changed files with 9454 additions and 904 deletions

View File

@@ -0,0 +1,457 @@
# -*- coding: utf-8 -*-
"""
多模式技能沙盒执行器
支持三种模式:
- none: 直接执行(默认,开发环境)
- docker: Docker 容器隔离
- kubernetes: Kubernetes Pod 隔离
环境变量:
SKILL_SANDBOX_MODE: 沙盒模式 (none/docker/kubernetes),默认 none
SKILL_SANDBOX_IMAGE: Docker 镜像,默认 python:3.11-slim
SKILL_SANDBOX_MEMORY_LIMIT: 内存限制,默认 512m
SKILL_SANDBOX_CPU_LIMIT: CPU 限制,默认 1.0
SKILL_SANDBOX_NETWORK: 网络模式,默认 none
SKILL_SANDBOX_TIMEOUT: 超时时间(秒),默认 60
"""
import json
import logging
import os
import warnings
from abc import ABC, abstractmethod
from typing import Any
logger = logging.getLogger(__name__)
class SandboxBackend(ABC):
"""沙盒后端抽象基类"""
@abstractmethod
def execute(
self,
skill_name: str,
function_name: str,
function_args: dict,
) -> dict:
"""
执行技能函数
Args:
skill_name: 技能名称,如 "builtin/valuation_review"
function_name: 要执行的函数名,如 "build_dcf_report"
function_args: 函数参数字典
Returns:
执行结果字典
"""
pass
class NoSandboxBackend(SandboxBackend):
"""
无沙盒模式 - 直接执行(默认,仅用于开发环境)
特性:
- 直接导入并执行技能模块
- 零性能开销
- 无隔离,依赖代码审查保证安全
"""
# 函数名到脚本模块名的映射
FUNCTION_TO_SCRIPT_MAP = {
# valuation_review 技能
"build_dcf_report": "dcf_report",
"build_owner_earnings_report": "owner_earnings_report",
"build_ev_ebitda_report": "multiple_valuation_report",
"build_residual_income_report": "multiple_valuation_report",
}
def __init__(self):
self._module_cache = {}
self._warning_shown = False
def _get_script_name(self, function_name: str) -> str:
"""
根据函数名获取脚本模块名
优先使用预定义映射,否则尝试自动推断
"""
if function_name in self.FUNCTION_TO_SCRIPT_MAP:
return self.FUNCTION_TO_SCRIPT_MAP[function_name]
# 自动推断: build_X_report -> X_report
if function_name.startswith("build_") and function_name.endswith("_report"):
return function_name[6:] # 去掉 "build_" 前缀
return function_name
def execute(
self,
skill_name: str,
function_name: str,
function_args: dict,
) -> dict:
"""直接导入模块并执行函数"""
# 首次使用时显示安全警告
if not self._warning_shown:
warnings.warn(
"\n" + "=" * 60 + "\n"
"⚠️ [安全警告] 技能在无沙盒模式下运行 (SKILL_SANDBOX_MODE=none)\n"
" 技能脚本将直接在当前进程中执行,无隔离保护。\n"
" 建议:生产环境请设置 SKILL_SANDBOX_MODE=docker\n"
"=" * 60,
RuntimeWarning,
stacklevel=2,
)
self._warning_shown = True
logger.debug(f"[NoSandbox] 执行技能: {skill_name}.{function_name}")
try:
# 将技能路径转换为模块路径
# builtin/valuation_review -> backend.skills.builtin.valuation_review.scripts
module_path = f"backend.skills.{skill_name.replace('/', '.')}.scripts"
# 从 function_name 获取脚本模块名
script_name = self._get_script_name(function_name)
submodule_path = f"{module_path}.{script_name}"
logger.debug(f"[NoSandbox] 导入模块: {submodule_path}.{function_name}")
# 缓存已加载的模块
if submodule_path not in self._module_cache:
self._module_cache[submodule_path] = __import__(
submodule_path,
fromlist=[function_name],
)
module = self._module_cache[submodule_path]
func = getattr(module, function_name)
# 执行函数
result = func(**function_args)
return {
"status": "success",
"result": result,
}
except Exception as e:
logger.error(f"[NoSandbox] 执行失败: {e}")
return {
"status": "error",
"error": str(e),
"error_type": type(e).__name__,
}
class DockerSandboxBackend(SandboxBackend):
"""
Docker 沙盒模式 - 容器隔离
特性:
- 使用 Docker 容器隔离执行
- 支持资源限制CPU、内存
- 支持网络隔离
- 临时容器,执行后销毁
依赖:
pip install agentscope-runtime
Docker 守护进程运行中
"""
# 函数名到脚本模块名的映射
FUNCTION_TO_SCRIPT_MAP = {
# valuation_review 技能
"build_dcf_report": "dcf_report",
"build_owner_earnings_report": "owner_earnings_report",
"build_ev_ebitda_report": "multiple_valuation_report",
"build_residual_income_report": "multiple_valuation_report",
}
def __init__(self, config: dict):
self.config = config
self._available = None
def _get_script_name(self, function_name: str) -> str:
"""
根据函数名获取脚本模块名
优先使用预定义映射,否则尝试自动推断
"""
if function_name in self.FUNCTION_TO_SCRIPT_MAP:
return self.FUNCTION_TO_SCRIPT_MAP[function_name]
# 自动推断: build_X_report -> X_report
if function_name.startswith("build_") and function_name.endswith("_report"):
return function_name[6:] # 去掉 "build_" 前缀
return function_name
def _check_availability(self) -> bool:
"""检查 Docker 是否可用"""
if self._available is not None:
return self._available
try:
from agentscope_runtime.sandbox import BaseSandbox
self._available = True
except ImportError:
logger.error(
"AgentScope Runtime 未安装,无法使用 Docker 沙盒。"
"请运行: pip install agentscope-runtime"
)
self._available = False
return self._available
def execute(
self,
skill_name: str,
function_name: str,
function_args: dict,
) -> dict:
"""在 Docker 容器中执行"""
if not self._check_availability():
raise RuntimeError(
"Docker 沙盒不可用,请安装 agentscope-runtime "
"或切换到 SKILL_SANDBOX_MODE=none"
)
from agentscope_runtime.sandbox import BaseSandbox
logger.info(f"[DockerSandbox] 执行技能: {skill_name}.{function_name}")
# 获取脚本模块名
script_name = self._get_script_name(function_name)
# 构建执行代码
code = f"""
import sys
import json
# 挂载路径
sys.path.insert(0, '/skill/scripts')
# 导入函数
from {script_name} import {function_name}
# 执行
args = json.loads('{json.dumps(function_args)}')
result = {function_name}(**args)
# 输出结果
print(json.dumps({{"status": "success", "result": result}}))
"""
try:
with BaseSandbox(**self.config) as box:
# 挂载技能目录(只读)
host_skill_path = f"backend/skills/{skill_name}"
box.mount(
host_path=host_skill_path,
container_path="/skill",
read_only=True,
)
# 执行代码
exec_result = box.run_ipython_cell(code=code)
# 解析结果
if exec_result.get("exit_code") == 0:
output = exec_result.get("stdout", "")
return json.loads(output)
else:
return {
"status": "error",
"error": exec_result.get("stderr", "Unknown error"),
"exit_code": exec_result.get("exit_code"),
}
except Exception as e:
logger.error(f"[DockerSandbox] 执行失败: {e}")
return {
"status": "error",
"error": str(e),
"error_type": type(e).__name__,
}
class KubernetesSandboxBackend(SandboxBackend):
"""
Kubernetes 沙盒模式 - Pod 隔离(预留接口)
特性:
- 使用 Kubernetes Pod 隔离执行
- 企业级隔离和调度
- 支持资源配额和命名空间
TODO: 待实现
"""
def __init__(self, config: dict):
self.config = config
raise NotImplementedError(
"Kubernetes 沙盒模式尚未实现,"
"请使用 SKILL_SANDBOX_MODE=docker 或 none"
)
def execute(
self,
skill_name: str,
function_name: str,
function_args: dict,
) -> dict:
raise NotImplementedError()
class SkillSandbox:
"""
技能沙盒执行器
统一接口,根据配置自动选择后端。
默认使用 none 模式(无沙盒)。
示例:
>>> sandbox = SkillSandbox()
>>> result = sandbox.execute_skill(
... skill_name="builtin/valuation_review",
... function_name="build_dcf_report",
... function_args={"rows": [...], "current_date": "2024-01-01"}
... )
>>> print(result)
{"status": "success", "result": "..."}
"""
_instance = None
_mode = None
def __new__(cls):
"""单例模式"""
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self.mode = os.getenv("SKILL_SANDBOX_MODE", "none").lower()
self._backend = self._create_backend()
self._initialized = True
logger.info(f"SkillSandbox 初始化完成,模式: {self.mode}")
def _create_backend(self) -> SandboxBackend:
"""根据模式创建对应后端"""
if self.mode == "none":
logger.info("使用无沙盒模式(直接执行)")
return NoSandboxBackend()
elif self.mode == "docker":
config = {
"image": os.getenv(
"SKILL_SANDBOX_IMAGE", "python:3.11-slim"
),
"memory_limit": os.getenv(
"SKILL_SANDBOX_MEMORY_LIMIT", "512m"
),
"cpu_limit": float(
os.getenv("SKILL_SANDBOX_CPU_LIMIT", "1.0")
),
"network": os.getenv("SKILL_SANDBOX_NETWORK", "none"),
"timeout": int(os.getenv("SKILL_SANDBOX_TIMEOUT", "60")),
}
logger.info(f"使用 Docker 沙盒模式,配置: {config}")
return DockerSandboxBackend(config)
elif self.mode == "kubernetes":
config = {
"namespace": os.getenv(
"SKILL_SANDBOX_NAMESPACE", "agentscope"
),
"memory_limit": os.getenv(
"SKILL_SANDBOX_MEMORY_LIMIT", "512Mi"
),
"cpu_limit": os.getenv("SKILL_SANDBOX_CPU_LIMIT", "1000m"),
"timeout": int(os.getenv("SKILL_SANDBOX_TIMEOUT", "60")),
}
logger.info(f"使用 Kubernetes 沙盒模式,配置: {config}")
return KubernetesSandboxBackend(config)
else:
raise ValueError(
f"未知的沙盒模式: {self.mode}"
f"请设置 SKILL_SANDBOX_MODE=none/docker/kubernetes"
)
def execute_skill(
self,
skill_name: str,
function_name: str,
function_args: dict | None = None,
) -> Any:
"""
执行技能函数
Args:
skill_name: 技能名称,如 "builtin/valuation_review"
function_name: 函数名,如 "build_dcf_report"
function_args: 函数参数,默认 None
Returns:
函数执行结果(成功时返回 result 字段,失败时抛出异常)
Raises:
RuntimeError: 执行失败
"""
if function_args is None:
function_args = {}
logger.debug(
f"执行技能: {skill_name}.{function_name} "
f"(模式: {self.mode})"
)
result = self._backend.execute(
skill_name=skill_name,
function_name=function_name,
function_args=function_args,
)
if result.get("status") == "error":
error_msg = result.get("error", "Unknown error")
error_type = result.get("error_type", "Exception")
raise RuntimeError(f"[{error_type}] {error_msg}")
return result.get("result")
@property
def current_mode(self) -> str:
"""获取当前沙盒模式"""
return self.mode
def get_sandbox() -> SkillSandbox:
"""
获取 SkillSandbox 单例实例
Returns:
SkillSandbox 实例
"""
return SkillSandbox()
def reset_sandbox():
"""
重置沙盒实例(用于测试)
"""
SkillSandbox._instance = None
SkillSandbox._mode = None