Function Calling — Auto
Key Points
AIFunctionFactory.Create(method)turns any[Description]-annotated method into anAIFunction.UseFunctionInvocation()middleware auto-handles tool-call loop: model says "call X" → middleware invokes → adds result → model continues.- Replaces deprecated SK Planners. The LLM decides when/which tools.
- Strict JSON Schema (OpenAI) ensures schema-correct invocations.
- Multiple tool calls per turn (parallel) supported by capable models.
Function declaration
[Description("Gets the current weather for a city")]
async Task<Weather> GetWeather(
[Description("City name")] string city,
[Description("Units, F or C")] string units = "F",
CancellationToken ct = default)
{
var w = await _weather.GetAsync(city, units, ct);
return new Weather(w.Temp, w.Condition);
}
[Description] attributes inform the LLM how to use the function. Names, types, defaults all participate.
Setup
chat = chat.AsBuilder()
.UseFunctionInvocation()
.Build();
var resp = await chat.GetResponseAsync(
"What's the weather in Seattle?",
new ChatOptions
{
Tools = [AIFunctionFactory.Create(GetWeather)]
});
The middleware: 1. Sends messages + tool definitions to LLM. 2. LLM responds with tool-call ("GetWeather", { city: "Seattle" }). 3. Middleware invokes GetWeather("Seattle"). 4. Adds result to messages. 5. Re-calls LLM. 6. LLM responds with natural language using the result. 7. Returns final response.
Iterates until LLM stops requesting tools.
Multiple functions
new ChatOptions
{
Tools = [
AIFunctionFactory.Create(GetWeather),
AIFunctionFactory.Create(GetForecast),
AIFunctionFactory.Create(GetAirQuality)
]
};
LLM picks zero or more.
Parallel tool calls
OpenAI / Claude support calling multiple tools per turn:
LLM may call GetWeather, GetForecast, GetAirQuality in one response. Middleware invokes all (parallel possible) and returns all results.
Tool mode
new ChatOptions
{
Tools = [...],
ToolMode = ChatToolMode.Auto // LLM decides
// ChatToolMode.RequireAny // must call SOME tool
// ChatToolMode.RequireSpecific("GetWeather") // must call this one
// ChatToolMode.None // disable
};
Schema generation
AIFunctionFactory.Create reflects on the method to build JSON Schema:
{
"name": "GetWeather",
"description": "Gets the current weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": { "type": "string", "description": "City name" },
"units": { "type": "string", "description": "Units, F or C", "default": "F" }
},
"required": ["city"]
}
}
OpenAI strict mode: schema enforced.
Custom AIFunction
public class WeatherFunction(WeatherService svc) : AIFunction
{
public override string Name => "GetWeather";
public override string Description => "...";
public override JsonSchema Schema => /* manual */;
public override async ValueTask<object?> InvokeAsync(IEnumerable<KeyValuePair<string, object?>> args, CancellationToken ct)
{
var city = (string)args.First(a => a.Key == "city").Value!;
return await svc.GetAsync(city);
}
}
Use when reflection-based isn't enough.
Instance method functions
public class Calculator
{
[Description("Adds two numbers")]
public int Add(int a, int b) => a + b;
}
var calc = new Calculator();
new ChatOptions { Tools = [AIFunctionFactory.Create(calc.Add)] };
Tool result types
Tool methods can return: - Primitives (string, int, etc.) - Custom records / classes (serialized to JSON for the LLM) - Task<T> / ValueTask<T> (awaited)
[Description("Search products")]
async Task<List<Product>> SearchProducts(string query) => await _db.QueryAsync(query);
LLM gets serialized JSON.
Errors / exceptions
If your tool throws, middleware catches and surfaces to LLM as "error: ..." message. LLM may retry or give up.
[Description("Charge a payment")]
async Task<ChargeResult> Charge(string accountId, decimal amount, CancellationToken ct)
{
if (amount <= 0) throw new ArgumentException("Amount must be positive");
return await _payment.ChargeAsync(accountId, amount, ct);
}
LLM might rephrase its arguments after seeing the error.
Iteration limits
Default: 10. LLM keeps calling tools until done or limit.
Streaming with tools
await foreach (var update in chat.GetStreamingResponseAsync(messages, opts, ct))
{
if (update.Contents.OfType<FunctionCallContent>().Any())
Console.WriteLine($"calling tool: {/* details */}");
Console.Write(update.Text);
}
Tool calls + text interleaved.
Senior considerations
- Document tools clearly — descriptions are the LLM's contract.
- Idempotent tools when possible — LLM may retry or call twice.
- Validate inputs in tools — LLM occasionally hallucinates schema.
- Cap iterations — no infinite loops.
- Tool security: tools become attack vectors. Validate auth context. Don't expose dangerous ops without confirmation.
- Tracing: each tool call is a span; full agent execution visible.
Anti-patterns
- ❌ Vague descriptions (LLM picks wrong tool).
- ❌ Stateful tools without idempotency.
- ❌ Letting tools execute privileged ops without check.
- ❌ Too many tools — confuses LLM. Group into a few logical ones.