From a75ae9a843c4ddd895eddeb77e0dedbf2f7825d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Mon, 3 Aug 2026 16:58:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=BB=98=E8=AE=A4=E7=9C=81=E7=95=A5?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E8=8A=82=E7=82=B9=E6=98=BE=E5=BC=8F=20main?= =?UTF-8?q?=20=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新节点仅生成 main 与 return 并同步输入形参 - Python 运行时按配置顺序传入独立参数值 - 保留历史显式调用和单对象参数写法 --- .../code/PythonRuntimeEngine.java | 43 ++++++++-- .../code/PythonRuntimeEngineTest.java | 63 +++++++++++++++ .../src/components/nodes/CodeNode.svelte | 28 +++++-- .../src/components/utils/codeNodeScaffold.ts | 4 - .../src/components/utils/codeNodeSync.test.ts | 80 ++++++++++++++----- .../src/components/utils/codeNodeSync.ts | 62 ++++++++++---- .../src/components/utils/nodePalette.ts | 3 +- 7 files changed, 232 insertions(+), 51 deletions(-) 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 b0e0a655..4b5a593c 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 @@ -4,6 +4,7 @@ import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import com.easyagents.flow.core.chain.Chain; import com.easyagents.flow.core.chain.ChainState; +import com.easyagents.flow.core.chain.Parameter; import com.easyagents.flow.core.code.CodeRuntimeEngine; import com.easyagents.flow.core.node.CodeNode; import com.easyagents.flow.core.util.StringUtil; @@ -17,6 +18,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -91,6 +93,7 @@ public class PythonRuntimeEngine implements CodeRuntimeEngine { "", " local_ctx = dict(context)", " local_ctx['_result'] = {}", + " initial_result = local_ctx['_result']", " local_ctx['__builtins__'] = __builtins__", "", " captured_stdout = _BoundedTextWriter(max_output_bytes)", @@ -101,12 +104,17 @@ public class PythonRuntimeEngine implements CodeRuntimeEngine { " with contextlib.redirect_stdout(captured_stdout), contextlib.redirect_stderr(captured_stderr):", " exec(code, local_ctx, local_ctx)", " result = local_ctx.get('_result')", - " if isinstance(result, dict) and not result:", + " legacy_result_assigned = result is not initial_result", + " if isinstance(result, dict) and not result and not legacy_result_assigned:", " 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)", + " if payload.get('mainArgsMode') == 'named':", + " main_args = [context.get(name) for name in parameter_names]", + " result = main_func(*main_args)", + " else:", + " 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)", @@ -253,9 +261,8 @@ public class PythonRuntimeEngine implements CodeRuntimeEngine { payload.put("context", context); payload.put( "parameterNames", - parameterValues == null - ? Collections.emptyList() - : new ArrayList<>(parameterValues.keySet())); + resolveParameterNames(node, parameterValues)); + payload.put("mainArgsMode", node.getMainArgsMode()); payload.put("maxOutputBytes", maxOutputBytes); String payloadText = JSON.toJSONString(payload); @@ -312,6 +319,30 @@ public class PythonRuntimeEngine implements CodeRuntimeEngine { } } + /** + * 按节点配置顺序解析 main 自动调用参数名。 + * + * @param node 当前代码节点 + * @param parameterValues 已解析的节点参数 + * @return 有序参数名 + */ + private List resolveParameterNames( + CodeNode node, Map parameterValues) { + if (node != null + && node.getParameters() != null + && !node.getParameters().isEmpty()) { + List names = + new ArrayList<>(node.getParameters().size()); + for (Parameter parameter : node.getParameters()) { + names.add(parameter.getName()); + } + return names; + } + return parameterValues == null + ? Collections.emptyList() + : new ArrayList<>(parameterValues.keySet()); + } + /** * 构建传入 Python 子进程的上下文。 * 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 980da743..5ee7f970 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 @@ -2,12 +2,14 @@ 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.Parameter; 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.Arrays; import java.util.Map; import java.util.UUID; @@ -64,6 +66,38 @@ public class PythonRuntimeEngineTest { Assert.assertEquals(Boolean.FALSE, result.get("has_ambient")); } + /** + * 验证新节点按配置顺序把输入值传给 main 的独立形参。 + */ + @Test + public void testExecuteMainWithNamedArguments() { + Assume.assumeTrue(PythonRuntimeEngine.probe(PYTHON_COMMAND, 1500L).isAvailable()); + + Chain chain = createChain(); + CodeNode node = (CodeNode) chain.getDefinition().getNodeById("code-test"); + node.setParameters(Arrays.asList( + new Parameter("data"), + new Parameter("suffix"))); + node.setMainArgsMode(CodeNode.MAIN_ARGS_MODE_NAMED); + PythonRuntimeEngine engine = new PythonRuntimeEngine( + PYTHON_COMMAND, + 3000L, + 65536, + System.getProperty("java.io.tmpdir")); + + Map result = engine.execute( + String.join("\n", + "def main(data, suffix):", + " return {'answer': data + suffix}"), + node, + chain, + Map.of( + "suffix", "111", + "data", "hello")); + + Assert.assertEquals("hello111", result.get("answer")); + } + @Test public void testLegacyResultTakesPrecedenceOverMain() { Assume.assumeTrue(PythonRuntimeEngine.probe(PYTHON_COMMAND, 1500L).isAvailable()); @@ -88,6 +122,35 @@ public class PythonRuntimeEngineTest { Assert.assertEquals("legacy", result.get("source")); } + /** + * 验证显式调用返回空字典时不会再次自动调用 main。 + */ + @Test + public void testEmptyExplicitResultDoesNotInvokeMainAgain() { + 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", + "def main(data):", + " if not isinstance(data, str):", + " raise RuntimeError('main invoked twice')", + " return {}", + "_result = main(data)"), + node, + chain, + Map.of("data", "hello")); + + Assert.assertTrue(result.isEmpty()); + } + @Test public void testMainMustReturnDict() { 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 dcd8701e..d085d762 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 @@ -19,6 +19,7 @@ import {genShortId} from '../utils/IdGen'; import {createCodeNodeScaffold} from '../utils/codeNodeScaffold'; import { + analyzeCodeNodeMainArgsMode, analyzeCodeNodeOutputs, reconcileInferredCodeNodeOutputs, syncCodeNodeInputParameters, @@ -39,7 +40,7 @@ const codeNodeHelp = `代码如何返回结果 - 输入参数会按名称传入 main,例如输入参数 data 对应 main(data)。 - JavaScript 的 main 返回对象;Python 的 main 返回 dict。 -- 保留 _result = main(...),运行时会读取 _result 作为节点结果。 +- 历史代码中的 _result = main(...) 写法仍然兼容。 输出参数如何配置 - return 中的静态字段会自动补全到“输出参数”,并推断 String、Number、Boolean、File、Object、Array。 @@ -50,12 +51,10 @@ JavaScript 示例 function main(data) { return { answer: data, score: 95 }; } - _result = main(data); Python 示例 def main(data): return {'answer': data, 'score': 95} - _result = main(data) - 输出参数配置:answer(String)、score(Number) - 结束节点输出参数可引用:代码节点ID.answer、代码节点ID.score`; @@ -77,9 +76,9 @@ Python 示例 const codePlaceholder = $derived.by(() => { const engine = String(nodeData.engine || defaultEngine).trim().toLowerCase(); if (engine === 'python' || engine === 'py') { - return "请输入代码,例如:def main(data):\n return {'output': data}\n\n_result = main(data)"; + return "请输入代码,例如:def main(data):\n return {'output': data}"; } - return '请输入代码,例如:function main(data) { return { output: data }; }\n_result = main(data);'; + return '请输入代码,例如:function main(data) { return { output: data }; }'; }); const reconcileOutputs = ( @@ -113,6 +112,9 @@ Python 示例 syncHint = syncResult.synced ? '' : (syncResult.reason || ''); return { code: syncResult.code, + mainArgsMode: syncResult.synced + ? 'named' + : currentData.mainArgsMode, parameters: syncResult.parameters, outputDefs: reconcileOutputs( syncResult.code, @@ -157,13 +159,23 @@ Python 示例 (node.data.parameters as Parameter[]) || [], (node.data.outputDefs as Parameter[]) || [], ); + const mainArgsMode = analyzeCodeNodeMainArgsMode( + code, + String(node.data.engine || defaultEngine), + (node.data.parameters as Parameter[]) || [], + ); if ( JSON.stringify(outputDefs) === - JSON.stringify(node.data.outputDefs || []) + JSON.stringify(node.data.outputDefs || []) && + (!mainArgsMode || + mainArgsMode === node.data.mainArgsMode) ) { return {}; } - return { outputDefs }; + return { + outputDefs, + ...(mainArgsMode ? { mainArgsMode } : {}), + }; }); }, 300); }; @@ -178,6 +190,8 @@ Python 示例 (node.data.parameters as Parameter[]) || [], (node.data.outputDefs as Parameter[]) || [], ); + patch.codeScaffoldVersion = 2; + patch.mainArgsMode = 'named'; } return patch; }); diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeScaffold.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeScaffold.ts index f158a104..370269ec 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeScaffold.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeScaffold.ts @@ -146,8 +146,6 @@ export function createCodeNodeScaffold( ' return {', ...outputLines, ' }', - '', - `_result = main(${argumentsText})`, ].join('\n'); } @@ -160,8 +158,6 @@ export function createCodeNodeScaffold( ...outputLines, ' };', '}', - '', - `_result = main(${argumentsText});`, ].join('\n'); } diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeSync.test.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeSync.test.ts index 80eccf6f..7479c83d 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeSync.test.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeSync.test.ts @@ -5,6 +5,7 @@ import type { Parameter } from '#types'; import { createCodeNodeScaffold } from './codeNodeScaffold'; import { getAvailableNodes } from './nodePalette'; import { + analyzeCodeNodeMainArgsMode, analyzeCodeNodeOutputs, reconcileInferredCodeNodeOutputs, syncCodeNodeInputParameters, @@ -20,12 +21,13 @@ describe('code node scaffold', () => { expect(codeNode?.extra).toMatchObject({ code: createCodeNodeScaffold('js'), codeScaffoldManaged: true, - codeScaffoldVersion: 1, + codeScaffoldVersion: 2, engine: 'js', + mainArgsMode: 'named', }); }); - it('creates an explicit javascript main call', () => { + it('creates a javascript main scaffold without an explicit call', () => { expect( createCodeNodeScaffold('js', [{ name: 'data' }], [{ name: 'result' }]), ).toBe( @@ -35,13 +37,11 @@ describe('code node scaffold', () => { ' "result": null,', ' };', '}', - '', - '_result = main(data);', ].join('\n'), ); }); - it('creates an explicit python main call', () => { + it('creates a python main scaffold without an explicit call', () => { expect( createCodeNodeScaffold( 'python', @@ -54,8 +54,6 @@ describe('code node scaffold', () => { ' return {', ' "result": None,', ' }', - '', - '_result = main(data)', ].join('\n'), ); }); @@ -68,7 +66,7 @@ describe('code node scaffold', () => { ]); expect(code).toContain('def main(data):'); - expect(code).toContain('_result = main(data)'); + expect(code).not.toContain('_result'); }); }); @@ -77,20 +75,20 @@ describe.each([ engine: 'js', emptyCode: createCodeNodeScaffold('js'), mainWithData: 'function main(data)', - callWithData: '_result = main(data);', mainWithQuery: 'function main(query)', - callWithQuery: '_result = main(query);', + legacyCallWithData: '_result = main(data);', + legacyCallWithQuery: '_result = main(query);', }, { engine: 'python', emptyCode: createCodeNodeScaffold('python'), mainWithData: 'def main(data):', - callWithData: '_result = main(data)', mainWithQuery: 'def main(query):', - callWithQuery: '_result = main(query)', + legacyCallWithData: '_result = main(data)', + legacyCallWithQuery: '_result = main(query)', }, ])('code node input sync for $engine', (fixture) => { - it('syncs a newly configured input name to main and its call', () => { + it('syncs a newly configured input name to main', () => { const blankParameter: Parameter = { id: 'input-1', name: '', @@ -115,7 +113,7 @@ describe.each([ expect(namedResult.synced).toBe(true); expect(namedResult.code).toContain(fixture.mainWithData); - expect(namedResult.code).toContain(fixture.callWithData); + expect(namedResult.code).not.toContain('_result'); expect(namedResult.parameters[0].codeSyncName).toBe('data'); }); @@ -143,7 +141,7 @@ describe.each([ expect(cleared.parameters[0].codeSyncName).toBe('data'); expect(renamed.synced).toBe(true); expect(renamed.code).toContain(fixture.mainWithQuery); - expect(renamed.code).toContain(fixture.callWithQuery); + expect(renamed.code).not.toContain('_result'); }); it('does not overwrite a user-managed main signature', () => { @@ -170,6 +168,26 @@ describe.each([ expect(result.code).toBe(manualCode); }); + it('keeps a compatible legacy explicit main call synchronized', () => { + const initial = syncCodeNodeInputParameters( + fixture.emptyCode, + fixture.engine, + [], + [{ id: 'input-1', name: 'data' }], + ); + const legacyCode = [initial.code, fixture.legacyCallWithData].join('\n'); + const result = syncCodeNodeInputParameters( + legacyCode, + fixture.engine, + initial.parameters, + [{ ...initial.parameters[0], name: 'query' }], + ); + + expect(result.synced).toBe(true); + expect(result.code).toContain(fixture.mainWithQuery); + expect(result.code).toContain(fixture.legacyCallWithQuery); + }); + it('does not partially update multiple explicit main calls', () => { const initial = syncCodeNodeInputParameters( fixture.emptyCode, @@ -179,9 +197,8 @@ describe.each([ ); const duplicatedCall = [ initial.code, - fixture.engine === 'python' - ? '_result = main(data)' - : '_result = main(data);', + fixture.legacyCallWithData, + fixture.legacyCallWithData, ].join('\n'); const result = syncCodeNodeInputParameters( duplicatedCall, @@ -223,6 +240,33 @@ describe.each([ }); }); +describe('code node main argument mode', () => { + it.each([ + { + engine: 'js', + namedCode: 'function main(data) { return { data }; }', + objectCode: 'function main(inputs) { return { data: inputs.data }; }', + }, + { + engine: 'python', + namedCode: 'def main(data):\n return {"data": data}', + objectCode: 'def main(inputs):\n return {"data": inputs.get("data")}', + }, + ])( + 'recognizes named and legacy object contracts for $engine', + ({ engine, namedCode, objectCode }) => { + const parameters = [{ name: 'data', dataType: 'String' }]; + + expect(analyzeCodeNodeMainArgsMode(namedCode, engine, parameters)).toBe( + 'named', + ); + expect(analyzeCodeNodeMainArgsMode(objectCode, engine, parameters)).toBe( + 'object', + ); + }, + ); +}); + describe.each([ { engine: 'js', diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeSync.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeSync.ts index 030aa583..dd4f3cbf 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeSync.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeSync.ts @@ -27,6 +27,8 @@ export type CodeNodeSyncResult = { synced: boolean; }; +export type CodeNodeMainArgsMode = 'named' | 'object'; + export type InferredCodeNodeOutput = { dataType: CodeNodeDataType; name: string; @@ -192,7 +194,7 @@ function readSimpleNames( return children.map((child) => nodeText(child, source)); } -function findExplicitMainCall(document: ParsedDocument) { +function findExplicitMainCalls(document: ParsedDocument) { const matches: SyntaxNode[] = []; for (const statement of childNodes(document.topNode)) { if (statement.name === 'FunctionDefinition') { @@ -235,7 +237,7 @@ function findExplicitMainCall(document: ParsedDocument) { matches.push(argList); } } - return matches.length === 1 ? matches[0] : null; + return matches; } function arraysEqual(left: string[], right: string[]) { @@ -313,7 +315,7 @@ function withPreviousSyncNames(previous: Parameter[], next: Parameter[]) { } /** - * 在 main 签名和显式调用仍受控时,同步代码节点输入参数。 + * 在 main 签名和可选显式调用仍受控时,同步代码节点输入参数。 */ export function syncCodeNodeInputParameters( source: string, @@ -345,12 +347,12 @@ export function syncCodeNodeInputParameters( ); const document = parseDocument(source, engine); const mainFunction = document && findMainFunction(document); - const explicitCall = document && findExplicitMainCall(document); - if (!document || !mainFunction || !explicitCall) { + const explicitCalls = document ? findExplicitMainCalls(document) : []; + if (!document || !mainFunction || explicitCalls.length > 1) { return { code: source, parameters: nextWithSyncNames, - reason: '未找到可安全同步的 main 函数和显式调用', + reason: '未找到可安全同步的唯一 main 函数', synced: false, }; } @@ -360,11 +362,10 @@ export function syncCodeNodeInputParameters( source, new Set(['VariableDefinition', 'VariableName']), ); - const currentArguments = readSimpleNames( - explicitCall, - source, - new Set(['VariableName']), - ); + const explicitCall = explicitCalls[0]; + const currentArguments = explicitCall + ? readSimpleNames(explicitCall, source, new Set(['VariableName'])) + : previousNames; if ( !currentParams || !currentArguments || @@ -381,18 +382,21 @@ export function syncCodeNodeInputParameters( } const replacement = nextNames.join(', '); - const code = applySourceEdits(source, [ + const edits: SourceEdit[] = [ { from: mainFunction.paramList.from + 1, to: mainFunction.paramList.to - 1, insert: replacement, }, - { + ]; + if (explicitCall) { + edits.push({ from: explicitCall.from + 1, to: explicitCall.to - 1, insert: replacement, - }, - ]); + }); + } + const code = applySourceEdits(source, edits); return { code, parameters: nextWithSyncNames.map((parameter) => ({ @@ -403,6 +407,34 @@ export function syncCodeNodeInputParameters( }; } +/** + * 根据 main 简单形参与节点输入名称判断运行时调用方式。 + */ +export function analyzeCodeNodeMainArgsMode( + source: string, + rawEngine: string | undefined, + parameters: Parameter[], +): CodeNodeMainArgsMode | null { + const engine = normalizeCodeNodeEngine(rawEngine); + const document = parseDocument(source, engine); + const mainFunction = document && findMainFunction(document); + if (!document || !mainFunction) { + return null; + } + + const configuredNames = parameterNames(parameters, engine, 'name'); + const currentParams = readSimpleNames( + mainFunction.paramList, + source, + new Set(['VariableDefinition', 'VariableName']), + ); + return configuredNames && + currentParams && + arraysEqual(currentParams, configuredNames) + ? 'named' + : 'object'; +} + function parseQuotedString(rawValue: string) { const raw = rawValue.trim(); if (raw.length < 2) { diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/nodePalette.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/nodePalette.ts index 398eaa56..fbd89c54 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/nodePalette.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/nodePalette.ts @@ -81,8 +81,9 @@ const BUILT_IN_NODES: NodePaletteItem[] = [ extra: { code: DEFAULT_CODE_NODE_JAVASCRIPT, codeScaffoldManaged: true, - codeScaffoldVersion: 1, + codeScaffoldVersion: 2, engine: 'js', + mainArgsMode: 'named', }, }, {