Files
evotraders/backend/tools/sandboxed_executor.py

442 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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
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 = {}
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:
"""直接导入模块并执行函数"""
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 隔离执行
- 企业级隔离和调度
- 支持资源配额和命名空间
"""
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.debug(f"SkillSandbox 初始化完成,模式: {self.mode}")
def _create_backend(self) -> SandboxBackend:
"""根据模式创建对应后端"""
if self.mode == "none":
logger.debug("使用无沙盒模式(直接执行)")
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