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:
@@ -22,16 +22,6 @@ from agentscope.message import TextBlock
|
||||
from agentscope.tool import ToolResponse
|
||||
|
||||
from backend.data.provider_utils import normalize_symbol
|
||||
from backend.skills.builtin.valuation_review.scripts.dcf_report import (
|
||||
build_dcf_report,
|
||||
)
|
||||
from backend.skills.builtin.valuation_review.scripts.multiple_valuation_report import (
|
||||
build_ev_ebitda_report,
|
||||
build_residual_income_report,
|
||||
)
|
||||
from backend.skills.builtin.valuation_review.scripts.owner_earnings_report import (
|
||||
build_owner_earnings_report,
|
||||
)
|
||||
from backend.tools.data_tools import (
|
||||
get_company_news,
|
||||
get_financial_metrics,
|
||||
@@ -41,10 +31,12 @@ from backend.tools.data_tools import (
|
||||
prices_to_df,
|
||||
search_line_items,
|
||||
)
|
||||
from backend.tools.sandboxed_executor import get_sandbox
|
||||
from backend.tools.technical_signals import StockTechnicalAnalyzer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_technical_analyzer = StockTechnicalAnalyzer()
|
||||
_sandbox = get_sandbox()
|
||||
|
||||
|
||||
def _to_text_response(text: str) -> ToolResponse:
|
||||
@@ -869,7 +861,13 @@ def dcf_valuation_analysis(
|
||||
},
|
||||
)
|
||||
|
||||
return _to_text_response(build_dcf_report(rows, current_date))
|
||||
return _to_text_response(
|
||||
_sandbox.execute_skill(
|
||||
skill_name="builtin/valuation_review",
|
||||
function_name="build_dcf_report",
|
||||
function_args={"rows": rows, "current_date": current_date},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@safe
|
||||
@@ -958,7 +956,13 @@ def owner_earnings_valuation_analysis(
|
||||
},
|
||||
)
|
||||
|
||||
return _to_text_response(build_owner_earnings_report(rows, current_date))
|
||||
return _to_text_response(
|
||||
_sandbox.execute_skill(
|
||||
skill_name="builtin/valuation_review",
|
||||
function_name="build_owner_earnings_report",
|
||||
function_args={"rows": rows, "current_date": current_date},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@safe
|
||||
@@ -1033,7 +1037,13 @@ def ev_ebitda_valuation_analysis(
|
||||
},
|
||||
)
|
||||
|
||||
return _to_text_response(build_ev_ebitda_report(rows, current_date))
|
||||
return _to_text_response(
|
||||
_sandbox.execute_skill(
|
||||
skill_name="builtin/valuation_review",
|
||||
function_name="build_ev_ebitda_report",
|
||||
function_args={"rows": rows, "current_date": current_date},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@safe
|
||||
@@ -1114,7 +1124,13 @@ def residual_income_valuation_analysis(
|
||||
},
|
||||
)
|
||||
|
||||
return _to_text_response(build_residual_income_report(rows, current_date))
|
||||
return _to_text_response(
|
||||
_sandbox.execute_skill(
|
||||
skill_name="builtin/valuation_review",
|
||||
function_name="build_residual_income_report",
|
||||
function_args={"rows": rows, "current_date": current_date},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Tool Registry for dynamic toolkit creation
|
||||
|
||||
457
backend/tools/sandboxed_executor.py
Normal file
457
backend/tools/sandboxed_executor.py
Normal 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
|
||||
Reference in New Issue
Block a user