feat: 默认省略代码节点显式 main 调用

- 新节点仅生成 main 与 return 并同步输入形参

- Python 运行时按配置顺序传入独立参数值

- 保留历史显式调用和单对象参数写法
This commit is contained in:
2026-08-03 16:58:20 +08:00
parent 46398d1365
commit a75ae9a843
7 changed files with 232 additions and 51 deletions

View File

@@ -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,10 +104,15 @@ 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",
" 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:",
@@ -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<String> resolveParameterNames(
CodeNode node, Map<String, Object> parameterValues) {
if (node != null
&& node.getParameters() != null
&& !node.getParameters().isEmpty()) {
List<String> 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 子进程的上下文。
*

View File

@@ -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<String, Object> 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<String, Object> 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());

View File

@@ -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;
});

View File

@@ -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');
}

View File

@@ -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',

View File

@@ -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) {

View File

@@ -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',
},
},
{