diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/code/PythonRuntimeEngine.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/code/PythonRuntimeEngine.java index 56f7dded..b0e0a655 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/code/PythonRuntimeEngine.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagentsflow/code/PythonRuntimeEngine.java @@ -14,10 +14,19 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.concurrent.TimeUnit; +/** + * 基于独立 Python 子进程的代码节点执行器。 + * + *

优先保留历史 {@code _result} 输出约定;当脚本未写入 + * {@code _result} 且声明了 {@code main} 函数时,自动传入节点参数并使用 + * {@code main} 返回的字典作为节点输出。

+ */ public class PythonRuntimeEngine implements CodeRuntimeEngine { private static final String PYTHON_HELPER_SCRIPT = String.join("\n", @@ -27,6 +36,40 @@ public class PythonRuntimeEngine implements CodeRuntimeEngine { "import sys", "import traceback", "", + "class _BoundedTextWriter(io.TextIOBase):", + " def __init__(self, max_bytes):", + " super().__init__()", + " self._max_bytes = max(0, max_bytes)", + " self._written_bytes = 0", + " self._buffer = io.StringIO()", + " self.truncated = False", + "", + " def write(self, text):", + " if not isinstance(text, str):", + " raise TypeError('write() argument must be str')", + " if not text:", + " return 0", + " remaining = self._max_bytes - self._written_bytes", + " if remaining <= 0:", + " self.truncated = True", + " return len(text)", + " candidate = text[:remaining]", + " encoded = candidate.encode('utf-8')", + " if len(encoded) > remaining:", + " candidate = encoded[:remaining].decode('utf-8', errors='ignore')", + " encoded = candidate.encode('utf-8')", + " self._buffer.write(candidate)", + " self._written_bytes += len(encoded)", + " if len(candidate) < len(text):", + " self.truncated = True", + " return len(text)", + "", + " def getvalue(self):", + " return self._buffer.getvalue()", + "", + " def writable(self):", + " return True", + "", "def _read_payload():", " raw = sys.stdin.read()", " if not raw:", @@ -39,23 +82,40 @@ public class PythonRuntimeEngine implements CodeRuntimeEngine { " context = payload.get('context')", " if not isinstance(context, dict):", " context = {}", + " parameter_names = payload.get('parameterNames')", + " if not isinstance(parameter_names, list):", + " parameter_names = []", + " max_output_bytes = payload.get('maxOutputBytes')", + " if not isinstance(max_output_bytes, int):", + " max_output_bytes = 0", "", " local_ctx = dict(context)", " local_ctx['_result'] = {}", + " local_ctx['__builtins__'] = __builtins__", "", - " captured_stdout = io.StringIO()", - " captured_stderr = io.StringIO()", + " captured_stdout = _BoundedTextWriter(max_output_bytes)", + " captured_stderr = _BoundedTextWriter(max_output_bytes)", + " main_invoked = False", "", " try:", " with contextlib.redirect_stdout(captured_stdout), contextlib.redirect_stderr(captured_stderr):", - " exec(code, {'__builtins__': __builtins__}, local_ctx)", + " exec(code, local_ctx, local_ctx)", + " result = local_ctx.get('_result')", + " if isinstance(result, dict) and not result:", + " main_func = local_ctx.get('main')", + " if callable(main_func):", + " main_invoked = True", + " main_input = {name: context.get(name) for name in parameter_names if isinstance(name, str)}", + " result = main_func(main_input)", " 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')", + " if main_invoked:", + " sys.stderr.write(\"Python main 函数必须返回 dict,例如:return {'result': value}\\n\")", + " else:", + " sys.stderr.write('Python 脚本执行结束后,_result 必须是 dict\\n')", " sys.exit(3)", "", " if captured_stdout.getvalue():", @@ -130,8 +190,37 @@ public class PythonRuntimeEngine implements CodeRuntimeEngine { } } + /** + * 执行 Python 脚本,兼容直接调用旧接口的场景。 + * + * @param code Python 脚本 + * @param node 当前代码节点 + * @param chain 当前工作流 + * @return 脚本输出结果 + */ @Override public Map execute(String code, CodeNode node, Chain chain) { + Map parameterValues = + chain.getExecutionState().resolveParameters(node); + return execute(code, node, chain, parameterValues); + } + + /** + * 使用调用方已解析的参数执行 Python 脚本。 + * + *

该方法作为普通重载保留,可同时兼容仅声明三参数接口的旧版 + * easy-agents;新版接口会按同签名方法直接分派,避免重复解析参数。

+ * + * @param code Python 脚本 + * @param node 当前代码节点 + * @param chain 当前工作流 + * @param parameterValues 已解析的节点参数 + * @return 脚本输出结果 + */ + public Map execute(String code, + CodeNode node, + Chain chain, + Map parameterValues) { if (StringUtil.noText(command)) { throw new RuntimeException("python 执行命令为空,请检查配置 node.code-engine.python.command"); } @@ -158,10 +247,16 @@ public class PythonRuntimeEngine implements CodeRuntimeEngine { stdoutThread.start(); stderrThread.start(); - Map context = buildContext(chain, node); + Map context = buildContext(chain, parameterValues); Map payload = new HashMap<>(); payload.put("code", code); payload.put("context", context); + payload.put( + "parameterNames", + parameterValues == null + ? Collections.emptyList() + : new ArrayList<>(parameterValues.keySet())); + payload.put("maxOutputBytes", maxOutputBytes); String payloadText = JSON.toJSONString(payload); try (OutputStream stdin = process.getOutputStream()) { @@ -217,7 +312,15 @@ public class PythonRuntimeEngine implements CodeRuntimeEngine { } } - private Map buildContext(Chain chain, CodeNode node) { + /** + * 构建传入 Python 子进程的上下文。 + * + * @param chain 当前工作流 + * @param parameterValues 已解析的节点参数 + * @return Python 脚本上下文 + */ + private Map buildContext( + Chain chain, Map parameterValues) { Map context = new HashMap<>(); ChainState chainState = chain.getExecutionState(); @@ -230,8 +333,6 @@ public class PythonRuntimeEngine implements CodeRuntimeEngine { } }); - Map parameterValues = - chainState.resolveParameters(node); if (parameterValues != null && !parameterValues.isEmpty()) { context.putAll(parameterValues); } diff --git a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/code/PythonRuntimeEngineTest.java b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/code/PythonRuntimeEngineTest.java index 4d38ddcb..980da743 100644 --- a/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/code/PythonRuntimeEngineTest.java +++ b/easyflow-modules/easyflow-module-ai/src/test/java/tech/easyflow/ai/easyagentsflow/code/PythonRuntimeEngineTest.java @@ -31,6 +31,106 @@ public class PythonRuntimeEngineTest { Assert.assertEquals("dict", result.get("env_type")); } + @Test + public void testExecuteMainSuccess() { + Assume.assumeTrue(PythonRuntimeEngine.probe(PYTHON_COMMAND, 1500L).isAvailable()); + + Chain chain = createChain(); + CodeNode node = (CodeNode) chain.getDefinition().getNodeById("code-test"); + chain.getState().getMemory().put("ambient", "memory-value"); + PythonRuntimeEngine engine = new PythonRuntimeEngine( + PYTHON_COMMAND, + 3000L, + 65536, + System.getProperty("java.io.tmpdir")); + + Map result = engine.execute( + String.join("\n", + "import math", + "def main(inputs):", + " return {", + " 'answer': inputs['input'],", + " 'score': 95,", + " 'sqrt': math.sqrt(16),", + " 'has_ambient': 'ambient' in inputs", + " }"), + node, + chain, + Map.of("input", "hello")); + + Assert.assertEquals("hello", result.get("answer")); + Assert.assertEquals(95, ((Number) result.get("score")).intValue()); + Assert.assertEquals(4D, ((Number) result.get("sqrt")).doubleValue(), 0D); + Assert.assertEquals(Boolean.FALSE, result.get("has_ambient")); + } + + @Test + public void testLegacyResultTakesPrecedenceOverMain() { + 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")); + + Map result = engine.execute( + String.join("\n", + "_result['source'] = 'legacy'", + "def main(inputs):", + " return {'source': 'main'}"), + node, + chain, + Map.of("input", "hello")); + + Assert.assertEquals("legacy", result.get("source")); + } + + @Test + public void testMainMustReturnDict() { + 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, + "def main(inputs):\n return 'invalid'", + "Python main 函数必须返回 dict"); + } + + @Test + public void testCapturedOutputIsBounded() { + 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")); + + Map result = engine.execute( + String.join("\n", + "import sys", + "print('a' * 2048)", + "_result['truncated'] = sys.stdout.truncated"), + node, + chain); + + Assert.assertEquals(Boolean.TRUE, result.get("truncated")); + } + @Test public void testSyntaxError() { Assume.assumeTrue(PythonRuntimeEngine.probe(PYTHON_COMMAND, 1500L).isAvailable()); diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/CodeNode.svelte b/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/CodeNode.svelte index 71822cc7..7b37e690 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/CodeNode.svelte +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/CodeNode.svelte @@ -22,8 +22,8 @@ let currentNode = useNodesData(currentNodeId); const { addParameter } = useAddParameter(); const { updateNodeData } = useSvelteFlow(); - const codeNodeHelp = `JavaScript 如何返回结果 -- 定义 main 函数并返回对象,系统会自动执行。 + const codeNodeHelp = `代码如何返回结果 +- JavaScript 定义 main 函数并返回对象;Python 定义 main 函数并返回 dict,系统会自动执行。 - main 接收由输入参数组成的对象。 输出参数如何配置 @@ -31,11 +31,15 @@ - 字段名与 main 返回对象的 key 保持一致。 - 下游节点可引用:代码节点ID.输出参数名。 -示例 -- 代码里写: +JavaScript 示例 function main({ input }) { return { answer: input, score: 95 }; } + +Python 示例 + def main(inputs): + return {'answer': inputs.get('input'), 'score': 95} + - 输出参数配置:answer(String)、score(Number) - 结束节点输出参数可引用:代码节点ID.answer、代码节点ID.score @@ -53,6 +57,13 @@ const firstAvailable = engines.find((item) => item.selectable !== false); return firstAvailable?.value || 'js'; }); + const codePlaceholder = $derived.by(() => { + const engine = String(data.engine || defaultEngine).trim().toLowerCase(); + if (engine === 'python' || engine === 'py') { + return "请输入代码,例如:def main(inputs):\n return {'output': inputs.get('input')}"; + } + return '请输入代码,例如:function main({ input }) { return { output: input }; }'; + }); onMount(async () => { const codeEngines = await options.provider?.codeEngine?.(); @@ -118,7 +129,7 @@ { ); }); + it('should prefer main return snippets for python output', () => { + const completions = createBusinessCompletions('python', []); + const mainCompletion = completions.find((item) => item.label === 'main'); + const resultCompletion = completions.find( + (item) => item.label === 'result-object', + ); + + expect(mainCompletion).toMatchObject({ + type: 'snippet', + detail: '代码节点入口', + boost: 1000, + }); + expect(String(mainCompletion?.apply)).toContain('def main(inputs):'); + expect(String(mainCompletion?.apply)).toContain('return {'); + expect(String(resultCompletion?.apply)).toContain('def main(inputs):'); + expect(String(resultCompletion?.apply)).toContain( + "return {'message': 'ok', 'data': inputs.get('data')}", + ); + }); + + it('should provide standalone python snippets', () => { + const completions = createBusinessCompletions('python', []); + const snippets = completions.filter((item) => item.type === 'snippet'); + + expect(snippets).toHaveLength(4); + for (const snippet of snippets) { + expect(String(snippet.apply)).toMatch(/^def main\(inputs\):\n /); + } + }); + it('should detect blocked nodes for comment/string/property contexts', () => { expect(shouldSkipBusinessCompletion('Comment')).toBe(true); expect(shouldSkipBusinessCompletion('LineComment')).toBe(true); diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeCompletion.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeCompletion.ts index fb03f4d8..7c6e9a4e 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeCompletion.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeCompletion.ts @@ -60,21 +60,29 @@ const PYTHON_SNIPPETS: Array<{ insert: string; detail: string; }> = [ + { + label: 'main', + detail: '代码节点入口', + insert: + "def main(inputs):\n return {\n 'output': inputs.get('input'),\n }", + }, { label: 'if-else', detail: '条件分支', insert: - "if condition:\n _result['value'] = value\nelse:\n _result['value'] = None", + "def main(inputs):\n condition = inputs.get('condition')\n value = inputs.get('value')\n if condition:\n return {'value': value}\n return {'value': None}", }, { label: 'for-loop', detail: '遍历数组', - insert: "for item in items:\n # TODO\n pass\n_result['done'] = True", + insert: + "def main(inputs):\n result = []\n for item in inputs.get('items', []):\n result.append(item)\n return {'result': result}", }, { label: 'result-object', detail: '返回对象', - insert: "_result['message'] = 'ok'\n_result['data'] = data", + insert: + "def main(inputs):\n return {'message': 'ok', 'data': inputs.get('data')}", }, ];