Skip to content

Commit cb42d78

Browse files
Zie619claude
andcommitted
fix(n8n): extract $fromAI params from raw node config for tool schemas
n8n sub-node tools have empty schemas when parent is custom agent because $fromAI() doesn't resolve in our context. Fix: use n8n-workflow's extractFromAICalls() to scan raw node parameters for $fromAI() calls, extract param names/descriptions, and build proper OpenAI function definitions. Also pass args as JSON string to tools with empty schemas. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent db0bf37 commit cb42d78

File tree

1 file changed

+120
-63
lines changed

1 file changed

+120
-63
lines changed

n8n-node/nodes/TruseraAgent/TruseraAgent.node.ts

Lines changed: 120 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -187,92 +187,142 @@ export class TruseraAgent implements INodeType {
187187
}
188188

189189
// Get connected tools
190-
const rawTools = ((await this.getInputConnectionData(
191-
NodeConnectionTypes.AiTool,
192-
0,
193-
)) ?? []) as any[];
194-
195-
const connectedTools = Array.isArray(rawTools) ? rawTools : [rawTools].filter(Boolean);
196-
197-
// n8n's N8nTool objects often have empty schemas (schema.shape = {})
198-
// because the $fromAI() expressions resolve dynamically. When the schema
199-
// is empty, bindTools sends a function with zero parameters to OpenAI,
200-
// and the LLM returns empty args {}.
201-
//
202-
// Fix: for tools with empty schemas, convert to DynamicTool via asDynamicTool()
203-
// which embeds parameter info in the description string. Then build OpenAI
204-
// function definitions manually with a single 'input' string parameter.
190+
let rawToolData: any;
191+
try {
192+
rawToolData = await this.getInputConnectionData(NodeConnectionTypes.AiTool, 0);
193+
} catch {
194+
rawToolData = [];
195+
}
196+
const connectedTools = Array.isArray(rawToolData) ? rawToolData : [rawToolData].filter(Boolean);
197+
198+
// ── Fix empty tool schemas ──
199+
// n8n's N8nTool objects from $fromAI() tools have empty schemas when
200+
// called from custom agent nodes. The $fromAI() expression doesn't
201+
// resolve in our context. Fix: extract $fromAI params from the raw
202+
// node config of connected tool sub-nodes, and rebuild schemas.
203+
let extractFromAICalls: ((str: string) => any[]) | null = null;
204+
try {
205+
extractFromAICalls = require('n8n-workflow').extractFromAICalls;
206+
} catch { /* not available */ }
207+
208+
// Get parent tool node configs to extract $fromAI params
209+
const parentNodes = ('getParentNodes' in this)
210+
? (this as any).getParentNodes(this.getNode().name, {
211+
connectionType: NodeConnectionTypes.AiTool,
212+
depth: 1,
213+
})
214+
: [];
215+
205216
const processedTools: any[] = [];
206217
const openAiFunctions: any[] = [];
207218

208-
for (const tool of connectedTools) {
219+
for (let idx = 0; idx < connectedTools.length; idx++) {
220+
const tool = connectedTools[idx];
209221
const schemaKeys = tool.schema?.shape ? Object.keys(tool.schema.shape) : [];
222+
const parentNode = parentNodes[idx];
210223

211-
if (schemaKeys.length === 0 && typeof tool.asDynamicTool === 'function') {
212-
// N8nTool with empty schema → convert to DynamicTool
213-
const dynamicTool = tool.asDynamicTool();
214-
processedTools.push(dynamicTool);
224+
if (schemaKeys.length === 0 && extractFromAICalls && parentNode) {
225+
// Schema is empty — extract $fromAI params from the raw node config
226+
const nodeParams = parentNode.parameters ?? {};
227+
const fromAiParams: Array<{ key: string; description: string; type: string }> = [];
228+
229+
// Scan all parameter values for $fromAI() calls
230+
const scanForFromAI = (obj: any) => {
231+
if (typeof obj === 'string' && obj.includes('$fromAI')) {
232+
try {
233+
const calls = extractFromAICalls!(obj);
234+
for (const call of calls) {
235+
fromAiParams.push({
236+
key: call.key ?? call[0] ?? 'input',
237+
description: call.description ?? call[1] ?? '',
238+
type: call.type ?? call[2] ?? 'string',
239+
});
240+
}
241+
} catch { /* parse error */ }
242+
} else if (typeof obj === 'object' && obj !== null) {
243+
for (const val of Object.values(obj)) {
244+
scanForFromAI(val);
245+
}
246+
}
247+
};
248+
scanForFromAI(nodeParams);
215249

216-
// Build OpenAI function definition with a single 'input' param
250+
// Build OpenAI function definition from extracted params
251+
const properties: any = {};
252+
const required: string[] = [];
253+
const toolDesc = (nodeParams.description ?? nodeParams.toolDescription ?? tool.description ?? tool.name ?? 'A tool') as string;
254+
255+
if (fromAiParams.length > 0) {
256+
for (const param of fromAiParams) {
257+
properties[param.key] = {
258+
type: param.type === 'number' ? 'number' : param.type === 'boolean' ? 'boolean' : 'string',
259+
description: param.description,
260+
};
261+
required.push(param.key);
262+
}
263+
} else {
264+
// Fallback: single input parameter
265+
properties.input = {
266+
type: 'string',
267+
description: 'The input to pass to the tool as a JSON string',
268+
};
269+
required.push('input');
270+
}
271+
272+
processedTools.push(tool);
217273
openAiFunctions.push({
218274
type: 'function',
219275
function: {
220-
name: dynamicTool.name,
221-
description: dynamicTool.description || tool.description || 'A tool',
222-
parameters: {
223-
type: 'object',
224-
properties: {
225-
input: {
226-
type: 'string',
227-
description: 'The input to pass to the tool. Must be a valid JSON string with the required parameters.',
228-
},
229-
},
230-
required: ['input'],
231-
},
276+
name: tool.name,
277+
description: toolDesc,
278+
parameters: { type: 'object', properties, required },
232279
},
233280
});
234-
} else {
235-
// Tool with proper schema → use as-is
281+
} else if (schemaKeys.length > 0) {
282+
// Tool has proper schema — build function def from it
236283
processedTools.push(tool);
237-
238-
// Build function def from zod schema
239284
const props: any = {};
240-
const required: string[] = [];
241-
if (tool.schema?.shape) {
242-
for (const [key, zodField] of Object.entries(tool.schema.shape)) {
243-
props[key] = {
244-
type: 'string',
245-
description: (zodField as any)?._def?.description ?? '',
246-
};
247-
if (!(zodField as any)?.isOptional?.()) {
248-
required.push(key);
249-
}
250-
}
285+
const req: string[] = [];
286+
for (const [key, zodField] of Object.entries(tool.schema.shape)) {
287+
props[key] = {
288+
type: 'string',
289+
description: (zodField as any)?._def?.description ?? '',
290+
};
291+
if (!(zodField as any)?.isOptional?.()) req.push(key);
251292
}
252-
253293
openAiFunctions.push({
254294
type: 'function',
255295
function: {
256296
name: tool.name,
257297
description: tool.description || 'A tool',
298+
parameters: { type: 'object', properties: props, required: req },
299+
},
300+
});
301+
} else {
302+
// Fallback for unknown tools
303+
processedTools.push(tool);
304+
openAiFunctions.push({
305+
type: 'function',
306+
function: {
307+
name: tool.name,
308+
description: tool.description || tool.name || 'A tool',
258309
parameters: {
259310
type: 'object',
260-
properties: props,
261-
required,
311+
properties: { input: { type: 'string', description: 'Tool input' } },
312+
required: ['input'],
262313
},
263314
},
264315
});
265316
}
266317
}
267318

268-
const toolDebug = processedTools.map((tool: any) => ({
269-
name: tool.name,
270-
type: tool.constructor?.name,
271-
description: (tool.description ?? '').slice(0, 200),
319+
const toolDebug = openAiFunctions.map((f: any) => ({
320+
name: f.function.name,
321+
description: f.function.description?.slice(0, 150),
322+
paramKeys: Object.keys(f.function.parameters?.properties ?? {}),
272323
}));
273324

274-
// Bind tools using OpenAI function format (not raw tool objects)
275-
// This ensures proper schema is sent to the LLM regardless of tool type.
325+
// Bind tools using OpenAI function format
276326
const modelWithTools = model.bind
277327
? model.bind({ tools: openAiFunctions })
278328
: model;
@@ -385,12 +435,19 @@ export class TruseraAgent implements INodeType {
385435

386436
if (tool) {
387437
try {
388-
// DynamicTool expects a string input; DynamicStructuredTool expects an object.
389-
// If args has a single 'input' key (from our OpenAI function def), extract it.
390-
let invokeArg: any = toolArgs;
391-
if (toolArgs.input && Object.keys(toolArgs).length === 1) {
392-
// Single 'input' param → pass the string directly (DynamicTool format)
393-
invokeArg = toolArgs.input;
438+
// n8n tools (N8nTool/DynamicStructuredTool) expect the invoke arg
439+
// to match their schema. For tools with empty schemas (from $fromAI),
440+
// pass args as a JSON string — the tool's func/asDynamicTool wrapper
441+
// handles parsing internally.
442+
let invokeArg: any;
443+
const schemaKeys = tool.schema?.shape ? Object.keys(tool.schema.shape) : [];
444+
445+
if (schemaKeys.length > 0) {
446+
// Structured tool — pass object matching schema
447+
invokeArg = toolArgs;
448+
} else {
449+
// Empty schema or DynamicTool — pass JSON string
450+
invokeArg = JSON.stringify(toolArgs);
394451
}
395452

396453
const result = await tool.invoke(invokeArg);

0 commit comments

Comments
 (0)