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