feat: add signature tool calling for non-native tool support#298
Conversation
- Add signatureToolCalling flag to enable tool execution via signature fields - Implement tool schema injection as optional signature fields - Add signatureToolCalling manager for tool execution coordination - Include tool schema converter for LLM-friendly field definitions - Add comprehensive test suite with 12 passing tests - Provide working example with search and calculator tools - Zero breaking changes, opt-in via signatureToolCalling: true This enables models without native tool calling support to leverage Ax's signature architecture for tool execution directly from LLM responses.
Add comprehensive dot notation support for nested tool parameters in signature tool calling, enabling intuitive parameter access using tool_name.parameter_name syntax. Includes recursive handling of nested objects and arrays, smart field name sanitization, and robust type inference. - Add jsonSchemaToSignature utility for JSON Schema to signature field conversion - Enhance AxSignature.injectToolFields with dot notation parameter generation - Support deep nesting with arbitrary depth and complex array structures - Implement smart fallback logic for tools without parameters - Add comprehensive test suite covering all edge cases and scenarios - Update examples to demonstrate new dot notation capabilities - Ensure full TypeScript type safety and backward compatibility This enhancement enables advanced signature tool calling with complex nested APIs while maintaining the existing interface and adding powerful new parameter injection capabilities.
|
I did have to add some sort of 1. Syntax Design// Current (flat structure)
const sig = AxSignature.create('userName:string, userEmail:string ->
response:string')// Proposed (nested structure)
const sig = AxSignature.create('user.name:string, user.email:string ->
response:string')2. Implementation ApproachA. Nested Object Fields// Input: Flat signature string with dot notation
const signature = AxSignature.create('user.name:string, user.email:string ->
response:string')
// Output: Structured field objects
// user: {
// name: { type: 'string', isArray: false },
// email: { type: 'string', isArray: false }
// }B. Array Support// Arrays with dot notation
const signature = AxSignature.create('items[].name:string, items[].
price:number -> total:number')
// Output: Array of objects
// items: [{ name: string, price: number }]C. Deep Nesting// Deep nested structures
const signature = AxSignature.create('config.api.timeout:number, config.api.
retryCount:number -> result:string')
// Output: Deep nested object
// config: {
// api: {
// timeout: number,
// retryCount: number
// }
// }3. Implementation StrategyA. Signature Parser Enhancement// Enhanced parser in sig.ts
class AxSignature {
public static create(signature: string): AxSignature {
// New: Parse dot notation for nested structures
const parsed = parseDotNotationSignature(signature);
// ... existing logic
}
}
function parseDotNotationSignature(signature: string) {
// Parse nested field structures
// Handle arrays with []
// Build hierarchical field objects
}B. Field Structureinterface AxField {
name: string; // "user.name" or "user"
path: string[]; // ["user", "name"]
type: { name: string, isArray: boolean }
parent?: string; // "user" for nested fields
isNested: boolean; // true for dot notation fields
}4. Usage ExamplesA. Simple Nested Objects// Input
const sig = AxSignature.create('user.name:string, user.age:number ->
greeting:string')
// Usage
const result = await generator.forward(llm, {
user: {
name: "Alice",
age: 30
}
})B. Arrays of Objects// Input
const sig = AxSignature.create('products[].name:string, products[].
price:number -> summary:string')
// Usage
const result = await generator.forward(llm, {
products: [
{ name: "Widget", price: 10.99 },
{ name: "Gadget", price: 25.50 }
]
})C. Mixed Flat and Nested// Input
const sig = AxSignature.create('user.name:string, user.email:string,
count:number -> response:string')
// Usage
const result = await generator.forward(llm, {
user: { name: "Bob", email: "bob@example.com" },
count: 5
})5. Validation & Error HandlingA. Field Name Validation// Ensure consistent naming
'user.name' → valid
'user..name' → invalid (consecutive dots)
'user.name.' → invalid (trailing dot)
'user.123name' → valid (numbers allowed)B. Type Safety// TypeScript integration
interface User {
name: string;
email: string;
}
interface Input {
user: User;
count: number;
}6. Backward CompatibilityA. Flat Fields Still Work// Existing flat signatures continue to work
const sig = AxSignature.create('name:string, email:string -> response:string')B. Migration Path// Gradual adoption
const sig1 = AxSignature.create('name:string') // Flat
const sig2 = AxSignature.create('user.name:string') // Nested7. Implementation ComplexityA. Parser Changes• Low: Extend existing signature parser B. Runtime Changes• Field access: fields['user.name'] vs fields.user.name 8. Potential API Design// Option 1: Direct dot notation in signature
const sig = AxSignature.create('user.name:string, user.email:string ->
response:string')
// Option 2: Builder pattern for complex structures
const sig = AxSignature.create('user:object -> response:string')
.addNestedField('user.name', 'string')
.addNestedField('user.email', 'string')
// Option 3: Object literal style
const sig = AxSignature.create({
user: {
name: 'string',
email: 'string'
}
} -> 'response:string')9. Performance Considerations• Parsing: O(n) complexity for signature parsing |
|
I understand this is about adding tool calling when the api does not support it. And you're also proposing exposing this at the signature level. Lets keep these things separate else it'll complicate things. Can you enable debug and post a trace here of what the user -> llm exchange it with this kind of tool calling so I can quickly grok it. And separately these questions related to exposing it in the signature.
|
|
Oh sure, the magic here is using the LLM's output as input for the tool calling. As such, there needs to be a manager to detect whether the optional fields are filled in and pass them as tool call inputs in their respective handlers:
// before
const basicAgent = agent('question:string -> answer:string', {
name: 'basicAgent',
definition: 'You are a helpful assistant.',
ai: llm
functions: [searchTool, calculateTool],
// no signatureToolCalling
});
// LLM sees:
// Input: { question: string }
// Output: { answer: string }// after
const smartAgent = agent('question:string -> answer:string', {
name: 'smartAgent',
definition: 'You are a helpful assistant that can search and calculate.',
functions: [searchTool, calculateTool],
signatureToolCalling: true, // 🔑 This enables dot notation that gets sanitized to snake_case
});
// LLM sees:
// Input: { question: string }
// Output: {
// answer: string,
// search_web_query?: string, // Dot notation field
// search_web_limit?: number, // Dot notation field
// calculate_expression?: string // Dot notation field
// }
// Original flat signature
const flatSig = s('name:string, age:number -> greeting:string')
// Type: { name: string, age: number } -> { greeting: string }
// New dot notation signature (tool calling)
const toolSig = s('query:string -> response:string')
// Type: { query: string } -> { response: string, get_weather_location?:
string, ... }
// The dot notation fields are automatically injected as optional
// TypeScript infers the complete type including tool fields
|
|
Here's a simple debug script to help visualize it a bit better: // debug-signature-tool-calling.ts
import { ai, agent } from './src/ax/index.js';
import type { AxFunction } from './src/ax/ai/types.js';
import { AxSignature } from './src/ax/dsp/sig.js';
// Debug-enabled tool calling example
const searchTool: AxFunction = {
name: 'searchWeb',
description: 'Search the web for information',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
} as any,
required: ['query'] as any,
},
func: async (args: { query: string }) => {
console.log(`🔧 [TOOL EXECUTED] searchWeb("${args.query}")`);
return `Found results for "${args.query}" - Top 3: Wikipedia, official site, news articles`;
},
};
const calculateTool: AxFunction = {
name: 'calculate',
description: 'Perform mathematical calculations',
parameters: {
type: 'object',
properties: {
expression: { type: 'string', description: 'Mathematical expression' },
} as any,
required: ['expression'] as any,
},
func: async (args: { expression: string }) => {
console.log(`🔧 [TOOL EXECUTED] calculate("${args.expression}")`);
// biome-ignore lint/security/noGlobalEval: Safe for demo purposes
return `Result: ${eval(args.expression)}`;
},
};
async function debugSignatureToolCalling() {
console.log('🚀 === DEBUG: Signature Tool Calling Exchange ===\n');
// Check if API key is available
if (!process.env.OPENAI_API_KEY) {
console.log('❌ Please set OPENAI_API_KEY environment variable');
console.log(' Example: export OPENAI_API_KEY=your_key_here');
return;
}
// Set up AI
const llm = ai({
name: 'openai',
apiKey: process.env.OPENAI_API_KEY,
model: 'gpt-4o-mini'
});
console.log('📋 STEP 1: Original Signature');
console.log(' Input: { question: string }');
console.log(' Output: { answer: string }');
console.log('\n📋 STEP 2: After Tool Injection');
const originalSignature = AxSignature.create('question:string -> answer:string');
const injectedSignature = originalSignature.injectToolFields([searchTool, calculateTool]);
const fields = injectedSignature.getOutputFields();
console.log(' Output: { ');
fields.forEach((field: any) => {
console.log(` ${field.name}: ${field.type.name}${field.isOptional ? '?' : ''}`);
});
console.log(' }');
console.log('\n📋 STEP 3: User → LLM Exchange');
console.log(' User Question: "What is 15 * 23 and search for the tallest building?"');
// Create agent with signature tool calling
const debugAgent = agent('question:string -> answer:string', {
name: 'debugAgent',
description: 'Debug agent showing signature tool calling flow',
definition: 'You are a helpful assistant that can search the web and perform calculations. Use the available tools when needed.',
functions: [searchTool, calculateTool],
signatureToolCalling: true,
});
console.log('\n📋 STEP 4: LLM Response Detection');
try {
const result = await debugAgent.forward(llm, {
question: 'What is 15 * 23 and search for the tallest building?'
});
console.log('\n📋 STEP 5: Final Result');
console.log(' Result:', result);
} catch (error) {
console.log('❌ Error:', error);
}
console.log('\n✅ === DEBUG COMPLETE ===');
}
// Run the debug script
if (import.meta.url === `file://${process.argv[1]}`) {
debugSignatureToolCalling().catch(console.error);
}(although ideally like this ^ let me fix up the part manager part) |
- Add mutual exclusion logic to disable normal tool calling when signatureToolCalling is enabled - Update createFunctionConfig to return empty functions when signatureToolCalling is true - Integrate SignatureToolCallingManager into AxGen class for signature and result processing - Add comprehensive test suite to verify mutual exclusion behavior - Ensure only one tool calling method can be active at a time
|
Couple things I'm thinking about:
I want to explore exposing the nested signature fields stuff to users for structured extraction. but I want to keep that separate since it's a bit more complex. |
|
Will get a release out today I'm calling it functionCallMode : "auto" | "native" | "prompt" |
|
It's out can you test and if needed more any fixes |
|
awesome, works great so far 🚀 |
|
I'll look into your structured output idea more this weekend inferring the input / output types from the signature is key to if we do it. Also what model do you use this type of function calling with? |
) * feat: add signature tool calling for non-native tool support - Add signatureToolCalling flag to enable tool execution via signature fields - Implement tool schema injection as optional signature fields - Add signatureToolCalling manager for tool execution coordination - Include tool schema converter for LLM-friendly field definitions - Add comprehensive test suite with 12 passing tests - Provide working example with search and calculator tools - Zero breaking changes, opt-in via signatureToolCalling: true This enables models without native tool calling support to leverage Ax's signature architecture for tool execution directly from LLM responses. * feat: implement jq-like dot notation for tool parameter injection Add comprehensive dot notation support for nested tool parameters in signature tool calling, enabling intuitive parameter access using tool_name.parameter_name syntax. Includes recursive handling of nested objects and arrays, smart field name sanitization, and robust type inference. - Add jsonSchemaToSignature utility for JSON Schema to signature field conversion - Enhance AxSignature.injectToolFields with dot notation parameter generation - Support deep nesting with arbitrary depth and complex array structures - Implement smart fallback logic for tools without parameters - Add comprehensive test suite covering all edge cases and scenarios - Update examples to demonstrate new dot notation capabilities - Ensure full TypeScript type safety and backward compatibility This enhancement enables advanced signature tool calling with complex nested APIs while maintaining the existing interface and adding powerful new parameter injection capabilities. * feat: integrate signature tool calling with mutual exclusion - Add mutual exclusion logic to disable normal tool calling when signatureToolCalling is enabled - Update createFunctionConfig to return empty functions when signatureToolCalling is true - Integrate SignatureToolCallingManager into AxGen class for signature and result processing - Add comprehensive test suite to verify mutual exclusion behavior - Ensure only one tool calling method can be active at a time

Summary
What kind of change does this PR introduce? Feature - adds signature tool calling capability for LLMs without native tool support
What is the current behavior? Models without native tool calling APIs cannot leverage Ax's tool execution capabilities
What is the new behavior (if this is a feature change)?
Other information: