Build a Function-Calling Agent
Function calling lets a language model decide which code to run based on a natural language prompt. Instead of parsing user intent manually, the model selects the right function and fills in the parameters. LM-Kit.NET's SingleFunctionCall class maps prompts to C# methods decorated with attributes, handling parameter extraction and invocation. This tutorial builds a function-calling system where the model routes requests to typed methods.
Why Local Function Calling Matters
Two enterprise problems that on-device function calling solves:
- Keep business logic private. Function calling sends user requests through your internal methods: database lookups, API calls, calculations. With a local model, the routing decision happens on your infrastructure. No external service sees which functions exist or what parameters are being passed.
- Deterministic execution with AI routing. The model decides what to call, but the actual function runs normal C# code. You get the flexibility of natural language input with the reliability of typed method execution. No hallucinated responses for operations that need precise results.
Prerequisites
| Requirement | Minimum |
|---|---|
| .NET SDK | 8.0+ |
| VRAM | 4+ GB |
| Disk | ~3 GB free for model download |
Step 1: Create the Project
dotnet new console -n FunctionCallQuickstart
cd FunctionCallQuickstart
dotnet add package LM-Kit.NET
Step 2: Define Functions with Attributes
Create a class with methods decorated with [Description] attributes. The model uses these descriptions to match user intent:
using System.ComponentModel;
using System.Text;
using LMKit.Model;
using LMKit.FunctionCalling;
LMKit.Licensing.LicenseManager.SetLicenseKey("");
Console.InputEncoding = Encoding.UTF8;
Console.OutputEncoding = Encoding.UTF8;
// ──────────────────────────────────────
// 1. Define callable functions
// ──────────────────────────────────────
public class WeatherService
{
[Description("Get the current weather for a city")]
public static string GetWeather(
[Description("The city name")] string city,
[Description("Temperature unit: celsius or fahrenheit")] string unit = "celsius")
{
// In production, call a real weather API
return $"Weather in {city}: 22 degrees {unit}, partly cloudy.";
}
[Description("Get the weather forecast for the next N days")]
public static string GetForecast(
[Description("The city name")] string city,
[Description("Number of days to forecast")] int days = 3)
{
return $"{days}-day forecast for {city}: Sunny tomorrow, rain expected in 2 days.";
}
}
public class CalculatorService
{
[Description("Calculate the result of a mathematical expression")]
public static double Calculate(
[Description("First number")] double a,
[Description("Mathematical operator: add, subtract, multiply, divide")] string operation,
[Description("Second number")] double b)
{
return operation.ToLower() switch
{
"add" => a + b,
"subtract" => a - b,
"multiply" => a * b,
"divide" => b != 0 ? a / b : double.NaN,
_ => double.NaN
};
}
}
Step 3: Route Prompts to Functions
// ──────────────────────────────────────
// 2. Load model
// ──────────────────────────────────────
Console.WriteLine("Loading model...");
using LM model = LM.LoadFromModelID("gemma3:4b",
downloadingProgress: (_, len, read) =>
{
if (len.HasValue) Console.Write($"\r Downloading: {(double)read / len.Value * 100:F1}% ");
return true;
},
loadingProgress: p => { Console.Write($"\r Loading: {p * 100:F0}% "); return true; });
Console.WriteLine("\n");
// ──────────────────────────────────────
// 3. Register functions and call
// ──────────────────────────────────────
using var functionCall = new SingleFunctionCall(model);
functionCall.ImportFunctions<WeatherService>();
functionCall.ImportFunctions<CalculatorService>();
string[] prompts =
{
"What's the weather in Paris?",
"Give me a 5-day forecast for Tokyo",
"What is 145 multiplied by 23?",
"How much is 1000 divided by 7?"
};
foreach (string prompt in prompts)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.Write(" Prompt: ");
Console.ResetColor();
Console.WriteLine(prompt);
FunctionCallResult result = functionCall.Submit(prompt);
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write(" Function: ");
Console.ResetColor();
Console.WriteLine(result.Method.Name);
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.Write(" Params: ");
Console.ResetColor();
Console.WriteLine(string.Join(", ", result.Parameters));
Console.Write(" Result: ");
Console.WriteLine(result.Result);
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($" Confidence: {result.Confidence:P0}\n");
Console.ResetColor();
}
Step 4: Instance Methods
Register methods from an object instance (for functions that need state or dependencies):
public class OrderService
{
private readonly Dictionary<string, (string Product, double Price, string Status)> _orders = new()
{
["ORD-001"] = ("Laptop Stand", 49.99, "Shipped"),
["ORD-002"] = ("USB-C Hub", 34.99, "Processing"),
["ORD-003"] = ("Mechanical Keyboard", 129.99, "Delivered")
};
[Description("Look up an order by its order ID")]
public string GetOrderStatus(
[Description("The order ID (e.g., ORD-001)")] string orderId)
{
if (_orders.TryGetValue(orderId.ToUpper(), out var order))
return $"Order {orderId}: {order.Product} (${order.Price}) - Status: {order.Status}";
return $"Order {orderId} not found.";
}
[Description("Get the total value of all orders")]
public string GetOrderTotal()
{
double total = _orders.Values.Sum(o => o.Price);
return $"Total across {_orders.Count} orders: ${total:F2}";
}
}
// Register with instance
var orderService = new OrderService();
using var functionCall = new SingleFunctionCall(model);
functionCall.ImportFunctions(orderService);
FunctionCallResult result = functionCall.Submit("What's the status of order ORD-002?");
Console.WriteLine(result.Result);
Step 5: Before-Invoke Validation
Intercept function calls before execution to log, validate, or block them:
using var functionCall = new SingleFunctionCall(model);
functionCall.ImportFunctions<WeatherService>();
functionCall.ImportFunctions<CalculatorService>();
functionCall.BeforeMethodInvoke += (sender, e) =>
{
Console.ForegroundColor = ConsoleColor.DarkYellow;
Console.WriteLine($" [Intercepted] About to call: {e.Method.Name}");
Console.WriteLine($" [Intercepted] Parameters: {string.Join(", ", e.Parameters)}");
Console.ResetColor();
// Block specific functions if needed
// e.Cancel = true;
};
FunctionCallResult result = functionCall.Submit("What's the weather in London?");
Console.WriteLine($" Result: {result.Result}");
Step 6: Force Function Selection
By default, the model can decline to call any function if no match is found. Use ForceFunctionSelection to always route to the closest function:
// Default: model may not select a function for irrelevant prompts
using var flexible = new SingleFunctionCall(model)
{
InvokeFunctions = true,
ForceFunctionSelection = false
};
flexible.ImportFunctions<WeatherService>();
// Forced: always selects the closest function, even for edge cases
using var forced = new SingleFunctionCall(model)
{
InvokeFunctions = true,
ForceFunctionSelection = true
};
forced.ImportFunctions<WeatherService>();
Set InvokeFunctions = false to see which function the model would select without actually executing it:
using var dryRun = new SingleFunctionCall(model)
{
InvokeFunctions = false // Don't execute, just select
};
dryRun.ImportFunctions<WeatherService>();
dryRun.ImportFunctions<CalculatorService>();
FunctionCallResult result = dryRun.Submit("Multiply 5 by 10");
Console.WriteLine($"Would call: {result.Method.Name}");
Console.WriteLine($"With params: {string.Join(", ", result.Parameters)}");
Console.WriteLine($"Result is null (not executed): {result.Result == null}");
Step 7: Interactive Function-Calling Loop
using var functionCall = new SingleFunctionCall(model);
functionCall.ImportFunctions<WeatherService>();
functionCall.ImportFunctions<CalculatorService>();
functionCall.ImportFunctions(new OrderService());
Console.WriteLine("Available functions: GetWeather, GetForecast, Calculate, GetOrderStatus, GetOrderTotal");
Console.WriteLine("Ask anything in natural language (or 'quit' to exit):\n");
while (true)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("You: ");
Console.ResetColor();
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input) || input.Equals("quit", StringComparison.OrdinalIgnoreCase))
break;
try
{
FunctionCallResult result = functionCall.Submit(input);
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write($" [{result.Method.Name}] ");
Console.ResetColor();
Console.WriteLine(result.Result);
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($" Confidence: {result.Confidence:P0}\n");
Console.ResetColor();
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($" Error: {ex.Message}\n");
Console.ResetColor();
}
}
Common Issues
| Problem | Cause | Fix |
|---|---|---|
| Wrong function selected | Descriptions too similar | Make [Description] attributes more distinct; add parameter descriptions |
| Parameters parsed incorrectly | Ambiguous parameter types | Use specific types (int, double) instead of string where possible |
| No function selected | Prompt too unrelated to any function | Enable ForceFunctionSelection = true or add a catch-all function |
| Low confidence | Model uncertain about the match | Use a larger model; improve function descriptions |
| Method not found | Static methods not imported correctly | Use ImportFunctions<T>() for static methods, ImportFunctions(instance) for instance methods |
Next Steps
- Create an AI Agent with Tools: build agents with the ITool interface for richer tool integration.
- Connect to MCP Servers from Your Application: connect to external tool servers.
- Samples: Function Calling: function calling demo.