Function Call Utility Operational Flow in LemonAI: How AI-Generated Functions Execute
The LemonAI function call utility executes AI-generated functions through a three-stage pipeline: discovering server-side tools in src/tools/, converting them to OpenAI-compatible schemas via resolveFunctionCall(), and parsing LLM-generated tool_calls in src/routers/agent/proxy.js for actual execution.
The hexdocom/lemonai repository provides a robust framework for LLM agents to invoke server-side JavaScript capabilities. Understanding the operational flow of the function call utility reveals how the system bridges declarative tool schemas with concrete execution logic. This architecture keeps the LLM client agnostic to tool implementations while enabling safe, observable, multi-step interactions.
Tool Discovery and Schema Generation
The operational flow begins with automatic discovery of available capabilities. The utility scans the filesystem to build a registry of executable tools, then transforms these definitions into a format compatible with major LLM providers.
Dynamic Tool Loading
In src/tools/index.js, the system reads every .js file from the src/tools/ directory to populate the tools map. The loader specifically skips the browser_use entry while constructing a plain object where tools[toolName] = require(path…). This dynamic require pattern ensures that adding a new tool module automatically makes it available to the function call utility without manual registration.
Each tool module exports three critical metadata fields: name (the function identifier), description (for the LLM to understand capabilities), and params (a JSON-Schema-like object defining required and optional arguments). The discovery phase concludes with a populated tools object that maps function names to their implementations.
OpenAI Schema Conversion
The src/utils/function.call.js file contains the core transformation logic. The convertTool() function (lines 6-16) wraps each tool definition into the standard OpenAI function-calling format:
{
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: tool.params
}
}
The exported resolveFunctionCall() function (lines 18-24) iterates through the tools map and returns an array of these converted schemas. This array becomes the value for the options.tools field in LLM requests, allowing models like GPT-4o or Gemini to recognize available server-side capabilities as native functions they can invoke.
LLM Request Composition
When the agent creates a completion request in src/completion/llm.base.js (lines 94-100), it attaches the tool schemas to the request payload:
async function buildCompletionOptions() {
const tools = await resolveFunctionCall();
return {
model: "gpt-4o",
stream: true,
tools, // LLM receives schema and may emit tool_calls
temperature: 0.2
};
}
The LLM receives this schema via its tools parameter and can decide to call one of the functions based on the conversation context. When the model chooses to execute a tool, it generates a tool_calls entry in the streaming response delta rather than returning plain text content.
Tool Call Parsing and Execution
Once the LLM emits a function call intention, the operational flow shifts to parsing, routing, and execution. This stage involves three coordinated components that transform the LLM's decision into actual JavaScript execution.
Streaming Response Handling
The LLM.messageToValue() method in src/completion/llm.base.js (lines 242-247) inspects every streamed delta from the provider. When it detects choice.delta.tool_calls, it extracts the function name and arguments, storing them on this.tools for further processing. This parsing layer abstracts provider-specific response formats (OpenAI, Azure, Gemini) into a unified internal representation.
Proxy-Level Forwarding
The HTTP proxy in src/routers/agent/proxy.js (lines 93-106) intercepts the SSE stream before it reaches the client. When it identifies tool_calls in the delta, it extracts readable metadata:
if (parsed.choices?.[0]?.delta?.tool_calls) {
const toolCalls = parsed.choices[0].delta.tool_calls;
for (const toolCall of toolCalls) {
if (toolCall.function?.name) {
fullContent += `Function: ${toolCall.function.name}\n`;
}
if (toolCall.function?.arguments) {
fullContent += `Arguments: ${toolCall.function.arguments}\n`;
}
}
}
This proxy layer logs the function invocation details and forwards them to the client stream, providing observability into which AI-generated functions the model selected and with what parameters.
Function Execution and Result Handling
The actual execution occurs in higher-level orchestration code that looks up the function name in the tools map populated during the discovery phase. The system invokes the matching implementation (typically containing resolveMemory logic or direct action handlers) and feeds the result back into the conversational loop as a new message.
This design maintains a strict separation of concerns: the LLM client remains agnostic to concrete tool implementations, while the routing layer handles the imperative execution logic. The result persistence (via resolveMemory in individual tool modules) ensures that subsequent conversation turns can reference previous tool execution outputs.
Implementation Examples
Creating custom tools and handling their execution requires understanding the contract between the schema generator and the runtime environment.
Creating a Custom Tool
New tools follow a standard export pattern that the discovery system recognizes automatically. For example, a calculator tool in src/tools/calc.js would implement:
module.exports = {
name: "calc",
description: "Evaluate a basic arithmetic expression.",
params: {
type: "object",
properties: {
expression: {
type: "string",
description: "Arithmetic expression, e.g. 2+3*4"
}
},
required: ["expression"]
},
async execute({ expression }) {
return { result: eval(expression) };
}
};
Because src/tools/index.js automatically loads every .js file, this calc tool becomes instantly available to resolveFunctionCall() without additional configuration.
Generating Tool Schemas
To expose available functions to the LLM, import the utility and await the schema generation:
const resolveFunctionCall = require("@src/utils/function.call");
async function prepareAgentRequest() {
const toolDefinitions = await resolveFunctionCall();
// Returns: [{type:'function', function:{name, description, parameters}}, ...]
return {
model: "gpt-4o",
tools: toolDefinitions,
stream: true
};
}
Handling Tool Calls in Stream
A simplified handler demonstrating the execution flow:
LLM.messageToValue = async function(message) {
const val = originalParser(message);
if (val.type === "tool_calls") {
const { name, arguments: args } = val.tool_calls[0].function;
const tool = require("@src/tools")[name];
const result = await tool.execute(JSON.parse(args));
// Return result to LLM as tool response message
return {
role: "tool",
tool_call_id: val.tool_calls[0].id,
content: JSON.stringify(result)
};
}
return val;
};
Summary
- Tool Discovery:
src/tools/index.jsdynamically loads all tool modules (excludingbrowser_use) into a registry map used throughout the system. - Schema Generation:
src/utils/function.call.jsconverts tool metadata into OpenAI-compatible function schemas viaresolveFunctionCall(). - LLM Integration:
src/completion/llm.base.jsattaches schemas to requests and parsestool_callsdeltas usingmessageToValue(). - Routing & Execution:
src/routers/agent/proxy.jsforwards tool calls to the client, while higher-level orchestration executes the matched JavaScript functions and returns results to the conversation. - Extensibility: Adding new capabilities requires only creating a new file in
src/tools/with the standardname,description,params, and execution exports.
Frequently Asked Questions
How does LemonAI discover new tools automatically?
The system uses src/tools/index.js to perform a filesystem scan of the src/tools/ directory at startup. It requires every .js file (skipping browser_use) and populates a tools object where keys are tool names and values are the module exports. This dynamic require pattern means any new file following the standard export contract (name, description, params) becomes available immediately without registry updates.
What format does resolveFunctionCall() return for LLM compatibility?
resolveFunctionCall() in src/utils/function.call.js returns an array of objects conforming to the OpenAI function-calling specification. Each element contains type: 'function' and a nested function object with name, description, and parameters (the JSON Schema from the tool's params field). This format works with OpenAI, Azure OpenAI, and other compatible providers.
Where does the actual function execution happen if not in the LLM class?
While src/completion/llm.base.js parses the tool_calls from the stream, the imperative execution occurs in higher-level agent orchestration code (typically invoked through src/routers/agent/proxy.js or the agent controller). This code looks up the function name in the tools map and invokes the module's execute() or resolveMemory() method, maintaining separation between the LLM client and business logic.
How does the system handle tool results after execution?
After the orchestration layer executes the matched function, it typically formats the result as a tool response message (with role tool) and appends it to the conversation history. The LLM receives this result in the next completion request, allowing for multi-step reasoning where subsequent AI-generated functions can reference previous execution outputs stored via resolveMemory or direct return values.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →