feat: 支持工作流代码节点 Python 执行引擎
- easyflow-module-ai: 新增 PythonRuntimeEngine、不可用引擎降级实现与引擎能力服务 - easyflow-module-ai: 在 TinyFlowConfigService 注册 python/py 引擎并增加启动探测与可用性日志 - easyflow-api: 新增 /api/v1/workflow/supportedCodeEngines 能力查询接口 - easyflow-starter: 增加 node.code-engine.python 配置项默认值 - Dockerfile: 安装 python3 运行时以支持容器内执行 - test: 增加 PythonRuntimeEngineTest 覆盖成功、语法错误、超时、输出限制、命令缺失场景 - chore(ui-admin): 更新 cspell 词典
This commit is contained in:
@@ -11,6 +11,9 @@ ENV EASYFLOW_LOG_FILE=/app/logs/app.log
|
||||
WORKDIR /app
|
||||
|
||||
RUN useradd --system --create-home easyflow && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
mkdir -p /app/logs && \
|
||||
chown -R easyflow:easyflow /app
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.easyagentsflow.service.CodeEngineCapabilityService;
|
||||
import tech.easyflow.ai.service.BotWorkflowService;
|
||||
import tech.easyflow.ai.service.ModelService;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
@@ -55,6 +56,8 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
||||
private ChainParser chainParser;
|
||||
@Resource
|
||||
private TinyFlowService tinyFlowService;
|
||||
@Resource
|
||||
private CodeEngineCapabilityService codeEngineCapabilityService;
|
||||
|
||||
public WorkflowController(WorkflowService service, ModelService modelService) {
|
||||
super(service);
|
||||
@@ -160,6 +163,12 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
||||
return Result.ok(res);
|
||||
}
|
||||
|
||||
@GetMapping("/supportedCodeEngines")
|
||||
@SaCheckPermission("/api/v1/workflow/query")
|
||||
public Result<?> supportedCodeEngines() {
|
||||
return Result.ok(codeEngineCapabilityService.listSupportedCodeEngines());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<Workflow> detail(String id) {
|
||||
Workflow workflow = service.getDetail(id);
|
||||
@@ -210,4 +219,4 @@ public class WorkflowController extends BaseCurdController<WorkflowService, Work
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,5 +92,12 @@
|
||||
<groupId>com.easyagents</groupId>
|
||||
<artifactId>easy-agents-mcp</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package tech.easyflow.ai.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@ConfigurationProperties(prefix = "node.code-engine.python")
|
||||
public class CodeEngineProps {
|
||||
|
||||
private boolean enabled = true;
|
||||
private String command = "python3";
|
||||
private long timeoutMs = 5000;
|
||||
private int maxOutputBytes = 262144;
|
||||
private String workingDir = System.getProperty("java.io.tmpdir");
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getCommand() {
|
||||
return command;
|
||||
}
|
||||
|
||||
public void setCommand(String command) {
|
||||
this.command = command;
|
||||
}
|
||||
|
||||
public long getTimeoutMs() {
|
||||
return timeoutMs;
|
||||
}
|
||||
|
||||
public void setTimeoutMs(long timeoutMs) {
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
public int getMaxOutputBytes() {
|
||||
return maxOutputBytes;
|
||||
}
|
||||
|
||||
public void setMaxOutputBytes(int maxOutputBytes) {
|
||||
this.maxOutputBytes = maxOutputBytes;
|
||||
}
|
||||
|
||||
public String getWorkingDir() {
|
||||
return workingDir;
|
||||
}
|
||||
|
||||
public void setWorkingDir(String workingDir) {
|
||||
this.workingDir = workingDir;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package tech.easyflow.ai.easyagentsflow.code;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.easyagents.flow.core.chain.Chain;
|
||||
import com.easyagents.flow.core.code.CodeRuntimeEngine;
|
||||
import com.easyagents.flow.core.node.CodeNode;
|
||||
import com.easyagents.flow.core.util.StringUtil;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class PythonRuntimeEngine implements CodeRuntimeEngine {
|
||||
|
||||
private static final String PYTHON_HELPER_SCRIPT = String.join("\n",
|
||||
"import contextlib",
|
||||
"import io",
|
||||
"import json",
|
||||
"import sys",
|
||||
"import traceback",
|
||||
"",
|
||||
"def _read_payload():",
|
||||
" raw = sys.stdin.read()",
|
||||
" if not raw:",
|
||||
" return {}",
|
||||
" return json.loads(raw)",
|
||||
"",
|
||||
"def _main():",
|
||||
" payload = _read_payload()",
|
||||
" code = payload.get('code') or ''",
|
||||
" context = payload.get('context')",
|
||||
" if not isinstance(context, dict):",
|
||||
" context = {}",
|
||||
"",
|
||||
" local_ctx = dict(context)",
|
||||
" local_ctx['_result'] = {}",
|
||||
"",
|
||||
" captured_stdout = io.StringIO()",
|
||||
" captured_stderr = io.StringIO()",
|
||||
"",
|
||||
" try:",
|
||||
" with contextlib.redirect_stdout(captured_stdout), contextlib.redirect_stderr(captured_stderr):",
|
||||
" exec(code, {'__builtins__': __builtins__}, local_ctx)",
|
||||
" except Exception:",
|
||||
" sys.stderr.write(traceback.format_exc())",
|
||||
" sys.exit(2)",
|
||||
"",
|
||||
" result = local_ctx.get('_result')",
|
||||
" if not isinstance(result, dict):",
|
||||
" sys.stderr.write('Python 脚本执行结束后,_result 必须是 dict\\n')",
|
||||
" sys.exit(3)",
|
||||
"",
|
||||
" if captured_stdout.getvalue():",
|
||||
" sys.stderr.write(captured_stdout.getvalue())",
|
||||
" if captured_stderr.getvalue():",
|
||||
" sys.stderr.write(captured_stderr.getvalue())",
|
||||
"",
|
||||
" try:",
|
||||
" sys.stdout.write(json.dumps(result, ensure_ascii=False))",
|
||||
" except Exception:",
|
||||
" sys.stderr.write(traceback.format_exc())",
|
||||
" sys.exit(4)",
|
||||
"",
|
||||
"if __name__ == '__main__':",
|
||||
" _main()"
|
||||
);
|
||||
|
||||
private final String command;
|
||||
private final long timeoutMs;
|
||||
private final int maxOutputBytes;
|
||||
private final String workingDir;
|
||||
|
||||
public PythonRuntimeEngine(String command, long timeoutMs, int maxOutputBytes, String workingDir) {
|
||||
this.command = command;
|
||||
this.timeoutMs = timeoutMs;
|
||||
this.maxOutputBytes = maxOutputBytes;
|
||||
this.workingDir = workingDir;
|
||||
}
|
||||
|
||||
public static ProbeResult probe(String command, long timeoutMs) {
|
||||
if (StringUtil.noText(command)) {
|
||||
return new ProbeResult(false, "未配置 python 命令");
|
||||
}
|
||||
|
||||
Process process = null;
|
||||
StreamCollector outputCollector = null;
|
||||
Thread outputThread = null;
|
||||
try {
|
||||
process = new ProcessBuilder(command, "--version")
|
||||
.redirectErrorStream(true)
|
||||
.start();
|
||||
|
||||
outputCollector = new StreamCollector(process.getInputStream(), 4096);
|
||||
outputThread = new Thread(outputCollector, "python-probe-output");
|
||||
outputThread.setDaemon(true);
|
||||
outputThread.start();
|
||||
|
||||
boolean finished = process.waitFor(Math.max(timeoutMs, 1000), TimeUnit.MILLISECONDS);
|
||||
waitCollector(outputThread);
|
||||
|
||||
String output = outputCollector == null ? "" : outputCollector.getText().trim();
|
||||
if (!finished) {
|
||||
process.destroyForcibly();
|
||||
return new ProbeResult(false, "执行 python --version 超时");
|
||||
}
|
||||
|
||||
if (process.exitValue() != 0) {
|
||||
return new ProbeResult(false, StringUtil.hasText(output) ? output : "python 命令返回非 0");
|
||||
}
|
||||
|
||||
if (outputCollector != null && outputCollector.isTruncated()) {
|
||||
return new ProbeResult(false, "python --version 输出过长");
|
||||
}
|
||||
|
||||
return new ProbeResult(true, StringUtil.hasText(output) ? output : "Python 可用");
|
||||
} catch (Exception e) {
|
||||
return new ProbeResult(false, e.getMessage());
|
||||
} finally {
|
||||
if (process != null) {
|
||||
process.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> execute(String code, CodeNode node, Chain chain) {
|
||||
if (StringUtil.noText(command)) {
|
||||
throw new RuntimeException("python 执行命令为空,请检查配置 node.code-engine.python.command");
|
||||
}
|
||||
|
||||
Process process = null;
|
||||
StreamCollector stdoutCollector = null;
|
||||
StreamCollector stderrCollector = null;
|
||||
Thread stdoutThread = null;
|
||||
Thread stderrThread = null;
|
||||
|
||||
try {
|
||||
ProcessBuilder builder = new ProcessBuilder(command, "-c", PYTHON_HELPER_SCRIPT);
|
||||
if (StringUtil.hasText(workingDir)) {
|
||||
builder.directory(new File(workingDir));
|
||||
}
|
||||
process = builder.start();
|
||||
|
||||
stdoutCollector = new StreamCollector(process.getInputStream(), maxOutputBytes);
|
||||
stderrCollector = new StreamCollector(process.getErrorStream(), maxOutputBytes);
|
||||
stdoutThread = new Thread(stdoutCollector, "python-runtime-stdout");
|
||||
stderrThread = new Thread(stderrCollector, "python-runtime-stderr");
|
||||
stdoutThread.setDaemon(true);
|
||||
stderrThread.setDaemon(true);
|
||||
stdoutThread.start();
|
||||
stderrThread.start();
|
||||
|
||||
Map<String, Object> context = buildContext(chain, node);
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("code", code);
|
||||
payload.put("context", context);
|
||||
|
||||
String payloadText = JSON.toJSONString(payload);
|
||||
try (OutputStream stdin = process.getOutputStream()) {
|
||||
stdin.write(payloadText.getBytes(StandardCharsets.UTF_8));
|
||||
stdin.flush();
|
||||
}
|
||||
|
||||
boolean finished = process.waitFor(Math.max(timeoutMs, 1), TimeUnit.MILLISECONDS);
|
||||
if (!finished) {
|
||||
process.destroyForcibly();
|
||||
process.waitFor(1, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
waitCollector(stdoutThread);
|
||||
waitCollector(stderrThread);
|
||||
|
||||
String stdout = stdoutCollector == null ? "" : stdoutCollector.getText().trim();
|
||||
String stderr = stderrCollector == null ? "" : stderrCollector.getText().trim();
|
||||
|
||||
if (stdoutCollector != null && stdoutCollector.isTruncated()) {
|
||||
throw new RuntimeException("Python 执行输出超出限制(" + maxOutputBytes + " bytes)");
|
||||
}
|
||||
if (stderrCollector != null && stderrCollector.isTruncated()) {
|
||||
stderr = stderr + "\n...(stderr too large, truncated)";
|
||||
}
|
||||
|
||||
if (!finished) {
|
||||
throw new RuntimeException("Python 脚本执行超时(" + timeoutMs + "ms)" + (StringUtil.hasText(stderr) ? ",stderr: " + stderr : ""));
|
||||
}
|
||||
|
||||
int exitCode = process.exitValue();
|
||||
if (exitCode != 0) {
|
||||
String errorMsg = StringUtil.hasText(stderr) ? stderr : (StringUtil.hasText(stdout) ? stdout : "无错误输出");
|
||||
throw new RuntimeException("Python 脚本执行失败(exit=" + exitCode + "): " + errorMsg);
|
||||
}
|
||||
|
||||
if (StringUtil.noText(stdout)) {
|
||||
throw new RuntimeException("Python 脚本执行失败:stdout 为空,未返回 _result JSON");
|
||||
}
|
||||
|
||||
Object parsed = JSON.parse(stdout);
|
||||
if (!(parsed instanceof JSONObject)) {
|
||||
throw new RuntimeException("Python 脚本执行失败:输出不是 JSON Object");
|
||||
}
|
||||
|
||||
return ((JSONObject) parsed);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Python 脚本执行失败: " + e.getMessage(), e);
|
||||
} finally {
|
||||
if (process != null) {
|
||||
process.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> buildContext(Chain chain, CodeNode node) {
|
||||
Map<String, Object> context = new HashMap<>();
|
||||
|
||||
Map<String, Object> all = chain.getState().getMemory();
|
||||
all.forEach((key, value) -> {
|
||||
if (!key.contains(".")) {
|
||||
context.put(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
Map<String, Object> parameterValues = chain.getState().resolveParameters(node);
|
||||
if (parameterValues != null && !parameterValues.isEmpty()) {
|
||||
context.putAll(parameterValues);
|
||||
}
|
||||
|
||||
context.put("_env", chain.getState().getEnvMap());
|
||||
return context;
|
||||
}
|
||||
|
||||
private static void waitCollector(Thread collectorThread) {
|
||||
if (collectorThread == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
collectorThread.join(2000L);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
public static class ProbeResult {
|
||||
private final boolean available;
|
||||
private final String message;
|
||||
|
||||
public ProbeResult(boolean available, String message) {
|
||||
this.available = available;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public boolean isAvailable() {
|
||||
return available;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
private static class StreamCollector implements Runnable {
|
||||
private final InputStream inputStream;
|
||||
private final int maxBytes;
|
||||
private final ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
private volatile boolean truncated;
|
||||
|
||||
private StreamCollector(InputStream inputStream, int maxBytes) {
|
||||
this.inputStream = inputStream;
|
||||
this.maxBytes = maxBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
byte[] buffer = new byte[4096];
|
||||
int n;
|
||||
int written = 0;
|
||||
try {
|
||||
while ((n = inputStream.read(buffer)) != -1) {
|
||||
if (written < maxBytes) {
|
||||
int canWrite = Math.min(maxBytes - written, n);
|
||||
output.write(buffer, 0, canWrite);
|
||||
written += canWrite;
|
||||
if (canWrite < n) {
|
||||
truncated = true;
|
||||
}
|
||||
} else {
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
// 忽略流读异常,最终由进程退出码和输出判断
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isTruncated() {
|
||||
return truncated;
|
||||
}
|
||||
|
||||
public String getText() {
|
||||
return output.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package tech.easyflow.ai.easyagentsflow.code;
|
||||
|
||||
import com.easyagents.flow.core.chain.Chain;
|
||||
import com.easyagents.flow.core.code.CodeRuntimeEngine;
|
||||
import com.easyagents.flow.core.node.CodeNode;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public class UnavailableCodeRuntimeEngine implements CodeRuntimeEngine {
|
||||
|
||||
private final String reason;
|
||||
|
||||
public UnavailableCodeRuntimeEngine(String reason) {
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> execute(String code, CodeNode node, Chain chain) {
|
||||
String engine = node == null ? "unknown" : node.getEngine();
|
||||
throw new RuntimeException("代码执行引擎不可用: " + engine + ",原因: " + reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package tech.easyflow.ai.easyagentsflow.service;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class CodeEngineCapabilityService {
|
||||
|
||||
private volatile boolean pythonAvailable = false;
|
||||
private volatile String pythonReason = "Python 引擎未初始化";
|
||||
|
||||
public void setPythonCapability(boolean available, String reason) {
|
||||
this.pythonAvailable = available;
|
||||
this.pythonReason = reason;
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> listSupportedCodeEngines() {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
result.add(toItem("js", "JavaScript", true, ""));
|
||||
result.add(toItem("python", "Python", pythonAvailable, pythonAvailable ? "" : pythonReason));
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<String, Object> toItem(String value, String label, boolean available, String reason) {
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("value", value);
|
||||
item.put("label", label);
|
||||
item.put("available", available);
|
||||
item.put("reason", reason);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package tech.easyflow.ai.easyagentsflow.service;
|
||||
|
||||
import com.easyagents.flow.core.code.CodeRuntimeEngineManager;
|
||||
import com.easyagents.flow.core.filestoreage.FileStorageManager;
|
||||
import com.easyagents.flow.core.filestoreage.FileStorageProvider;
|
||||
import com.easyagents.flow.core.knowledge.KnowledgeManager;
|
||||
@@ -11,15 +12,25 @@ import com.easyagents.flow.core.searchengine.SearchEngine;
|
||||
import com.easyagents.flow.core.searchengine.SearchEngineManager;
|
||||
import com.easyagents.flow.core.searchengine.SearchEngineProvider;
|
||||
import com.easyagents.flow.core.searchengine.impl.BochaaiSearchEngineImpl;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.config.BochaaiProps;
|
||||
import tech.easyflow.ai.config.CodeEngineProps;
|
||||
import tech.easyflow.ai.easyagentsflow.code.PythonRuntimeEngine;
|
||||
import tech.easyflow.ai.easyagentsflow.code.UnavailableCodeRuntimeEngine;
|
||||
import tech.easyflow.ai.node.*;
|
||||
import tech.easyflow.common.util.StringUtil;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class TinyFlowConfigService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TinyFlowConfigService.class);
|
||||
|
||||
@Resource
|
||||
private LlmProvider llmProvider;
|
||||
@Resource
|
||||
@@ -28,6 +39,10 @@ public class TinyFlowConfigService {
|
||||
private BochaaiProps bochaaiProps;
|
||||
@Resource
|
||||
private KnowledgeProvider knowledgeProvider;
|
||||
@Resource
|
||||
private CodeEngineProps codeEngineProps;
|
||||
@Resource
|
||||
private CodeEngineCapabilityService codeEngineCapabilityService;
|
||||
|
||||
public void initProvidersAndNodeParsers(ChainParser chainParser) {
|
||||
setExtraNodeParser(chainParser);
|
||||
@@ -35,6 +50,7 @@ public class TinyFlowConfigService {
|
||||
setFileStorage();
|
||||
setKnowledgeProvider();
|
||||
setSearchEngineProvider();
|
||||
setCodeRuntimeEngineProvider();
|
||||
}
|
||||
|
||||
private void setFileStorage() {
|
||||
@@ -88,4 +104,69 @@ public class TinyFlowConfigService {
|
||||
public void setKnowledgeProvider() {
|
||||
KnowledgeManager.getInstance().registerProvider(knowledgeProvider);
|
||||
}
|
||||
|
||||
public void setCodeRuntimeEngineProvider() {
|
||||
CodeRuntimeEngineManager manager = CodeRuntimeEngineManager.getInstance();
|
||||
|
||||
PythonRuntimeEngine.ProbeResult probeResult;
|
||||
String pythonCommand = codeEngineProps.getCommand();
|
||||
if (!codeEngineProps.isEnabled()) {
|
||||
probeResult = new PythonRuntimeEngine.ProbeResult(false, "配置已关闭");
|
||||
} else {
|
||||
probeResult = PythonRuntimeEngine.probe(pythonCommand, 2000L);
|
||||
}
|
||||
|
||||
boolean pythonAvailable = codeEngineProps.isEnabled() && probeResult.isAvailable();
|
||||
codeEngineCapabilityService.setPythonCapability(pythonAvailable, probeResult.getMessage());
|
||||
|
||||
if (pythonAvailable) {
|
||||
PythonRuntimeEngine pythonRuntimeEngine = new PythonRuntimeEngine(
|
||||
codeEngineProps.getCommand(),
|
||||
codeEngineProps.getTimeoutMs(),
|
||||
codeEngineProps.getMaxOutputBytes(),
|
||||
codeEngineProps.getWorkingDir()
|
||||
);
|
||||
manager.registerProvider(engineId -> {
|
||||
if ("python".equals(engineId) || "py".equals(engineId)) {
|
||||
return pythonRuntimeEngine;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
log.info("已启用 Python 代码执行引擎: command={}, timeoutMs={}, maxOutputBytes={}, workingDir={}",
|
||||
codeEngineProps.getCommand(), codeEngineProps.getTimeoutMs(), codeEngineProps.getMaxOutputBytes(), codeEngineProps.getWorkingDir());
|
||||
} else {
|
||||
final String reason = probeResult.getMessage();
|
||||
manager.registerProvider(engineId -> {
|
||||
if ("python".equals(engineId) || "py".equals(engineId)) {
|
||||
return new UnavailableCodeRuntimeEngine(reason);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
log.warn("Python 代码执行引擎不可用: enabled={}, command={}, reason={}",
|
||||
codeEngineProps.isEnabled(), codeEngineProps.getCommand(), reason);
|
||||
}
|
||||
|
||||
manager.registerProvider(engineId -> {
|
||||
if (!(engineId instanceof String)) {
|
||||
return new UnavailableCodeRuntimeEngine("未配置代码执行引擎");
|
||||
}
|
||||
String engine = ((String) engineId).trim();
|
||||
if (StringUtil.noText(engine)) {
|
||||
return new UnavailableCodeRuntimeEngine("未配置代码执行引擎");
|
||||
}
|
||||
return new UnavailableCodeRuntimeEngine("不支持的代码执行引擎: " + engine + ",当前支持: " + supportedEngineLabels());
|
||||
});
|
||||
}
|
||||
|
||||
private String supportedEngineLabels() {
|
||||
List<String> engines = codeEngineCapabilityService.listSupportedCodeEngines()
|
||||
.stream()
|
||||
.filter(it -> Boolean.TRUE.equals(it.get("available")))
|
||||
.map(it -> (String) it.get("value"))
|
||||
.toList();
|
||||
if (engines.isEmpty()) {
|
||||
engines = Arrays.asList("js");
|
||||
}
|
||||
return String.join(", ", engines);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package tech.easyflow.ai.easyagentsflow.code;
|
||||
|
||||
import com.easyagents.flow.core.chain.Chain;
|
||||
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||
import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository;
|
||||
import com.easyagents.flow.core.node.CodeNode;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Assume;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
public class PythonRuntimeEngineTest {
|
||||
|
||||
private static final String PYTHON_COMMAND = "python3";
|
||||
|
||||
@Test
|
||||
public void testExecuteSuccess() {
|
||||
Assume.assumeTrue(PythonRuntimeEngine.probe(PYTHON_COMMAND, 1500L).isAvailable());
|
||||
|
||||
Chain chain = createChain();
|
||||
CodeNode node = (CodeNode) chain.getDefinition().getNodeById("code-test");
|
||||
chain.getState().getMemory().put("a", 1);
|
||||
chain.getState().getMemory().put("b", 2);
|
||||
|
||||
PythonRuntimeEngine engine = new PythonRuntimeEngine(PYTHON_COMMAND, 3000L, 65536, System.getProperty("java.io.tmpdir"));
|
||||
Map<String, Object> result = engine.execute("_result['sum'] = a + b\n_result['env_type'] = type(_env).__name__", node, chain);
|
||||
|
||||
Assert.assertEquals(3, ((Number) result.get("sum")).intValue());
|
||||
Assert.assertEquals("dict", result.get("env_type"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSyntaxError() {
|
||||
Assume.assumeTrue(PythonRuntimeEngine.probe(PYTHON_COMMAND, 1500L).isAvailable());
|
||||
|
||||
Chain chain = createChain();
|
||||
CodeNode node = (CodeNode) chain.getDefinition().getNodeById("code-test");
|
||||
PythonRuntimeEngine engine = new PythonRuntimeEngine(PYTHON_COMMAND, 3000L, 65536, System.getProperty("java.io.tmpdir"));
|
||||
|
||||
assertExecuteFail(engine, node, chain, "if True print('broken')", "执行失败");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testTimeout() {
|
||||
Assume.assumeTrue(PythonRuntimeEngine.probe(PYTHON_COMMAND, 1500L).isAvailable());
|
||||
|
||||
Chain chain = createChain();
|
||||
CodeNode node = (CodeNode) chain.getDefinition().getNodeById("code-test");
|
||||
PythonRuntimeEngine engine = new PythonRuntimeEngine(PYTHON_COMMAND, 100L, 65536, System.getProperty("java.io.tmpdir"));
|
||||
|
||||
assertExecuteFail(engine, node, chain, "import time\ntime.sleep(1)\n_result['ok'] = 1", "超时");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testOutputTooLarge() {
|
||||
Assume.assumeTrue(PythonRuntimeEngine.probe(PYTHON_COMMAND, 1500L).isAvailable());
|
||||
|
||||
Chain chain = createChain();
|
||||
CodeNode node = (CodeNode) chain.getDefinition().getNodeById("code-test");
|
||||
PythonRuntimeEngine engine = new PythonRuntimeEngine(PYTHON_COMMAND, 3000L, 64, System.getProperty("java.io.tmpdir"));
|
||||
|
||||
assertExecuteFail(engine, node, chain, "_result['text'] = 'a' * 2048", "输出超出限制");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCommandNotFound() {
|
||||
Chain chain = createChain();
|
||||
CodeNode node = (CodeNode) chain.getDefinition().getNodeById("code-test");
|
||||
PythonRuntimeEngine engine = new PythonRuntimeEngine("python3_not_exists_for_test", 1000L, 65536, System.getProperty("java.io.tmpdir"));
|
||||
|
||||
assertExecuteFail(engine, node, chain, "_result['ok'] = 1", "执行失败");
|
||||
}
|
||||
|
||||
private Chain createChain() {
|
||||
ChainDefinition definition = new ChainDefinition();
|
||||
CodeNode node = new CodeNode();
|
||||
node.setId("code-test");
|
||||
node.setEngine("python");
|
||||
definition.addNode(node);
|
||||
|
||||
Chain chain = new Chain(definition, UUID.randomUUID().toString());
|
||||
chain.setChainStateRepository(new InMemoryChainStateRepository());
|
||||
return chain;
|
||||
}
|
||||
|
||||
private void assertExecuteFail(PythonRuntimeEngine engine, CodeNode node, Chain chain, String code, String messageContains) {
|
||||
try {
|
||||
engine.execute(code, node, chain);
|
||||
Assert.fail("expected execute fail");
|
||||
} catch (RuntimeException e) {
|
||||
Assert.assertTrue(e.getMessage().contains(messageContains));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,13 @@ node:
|
||||
# 搜索引擎节点 - 目前只支持博查搜索
|
||||
bochaai:
|
||||
apiKey: 'xxx'
|
||||
code-engine:
|
||||
python:
|
||||
enabled: true
|
||||
command: python3
|
||||
timeout-ms: 5000
|
||||
max-output-bytes: 262144
|
||||
working-dir: /tmp
|
||||
|
||||
jetcache:
|
||||
# 缓存类型,可选值:local/remote/both CacheConfig 类初始化
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
"sortablejs",
|
||||
"styl",
|
||||
"taze",
|
||||
"xyflow",
|
||||
"rerank",
|
||||
"reranker",
|
||||
"ui-kit",
|
||||
"uicons",
|
||||
"unplugin",
|
||||
@@ -64,6 +67,7 @@
|
||||
"**/*.log",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/__tests__/**"
|
||||
"**/__tests__/**",
|
||||
"packages/effects/common-ui/src/components/icon-picker/**"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user