Skip to content

Function Calling — Auto

Key Points

  • AIFunctionFactory.Create(method) turns any [Description]-annotated method into an AIFunction.
  • 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:

new ChatOptions
{
    Tools = [...],
    AllowMultipleToolCalls = true
};

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

.UseFunctionInvocation(o =>
{
    o.MaximumIterationsPerRequest = 10;   // prevent infinite loops
})

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 possibleLLM 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.

Cross-references