This semantic kernel auto function calling tutorial walks you through building a complete, runnable example that lets an LLM discover and invoke your code automatically. You’ll create plugins, configure the kernel with function calling behavior, and see exactly what the model sends and receives at each step.
Prerequisites
- Python 3.10+
- An OpenAI API key (or Azure OpenAI endpoint)
- Basic familiarity with async Python
Install the Semantic Kernel package:
pip install semantic-kernel[openai]
The [openai] extra pulls in the OpenAI connector. If you prefer Azure, use [azure] instead.
Project structure
Create a minimal layout:
sk-auto-func/
├── main.py
├── plugins/
│ ├── __init__.py
│ ├── weather.py
│ └── calculator.py
└── .env
Your .env file holds the API key:
OPENAI_API_KEY=sk-...
# Optional: override model
OPENAI_CHAT_MODEL=gpt-4o-mini
Build the plugins
Semantic Kernel plugins are plain Python classes decorated with @kernel_function. The decorator extracts the function name, description, and parameter schema from type hints and docstrings.
Weather plugin
# plugins/weather.py
from semantic_kernel.functions import kernel_function
from typing import Annotated
import random
class WeatherPlugin:
"""Provides current weather for a given location."""
@kernel_function(
name="get_current_weather",
description="Get the current weather for a city"
)
def get_current_weather(
self,
city: Annotated[str, "The city name, e.g. 'Seattle'"],
unit: Annotated[str, "Temperature unit: 'celsius' or 'fahrenheit'"] = "fahrenheit"
) -> str:
# Simulated data — replace with a real API call in production
temp_f = random.randint(40, 85)
temp_c = round((temp_f - 32) * 5 / 9)
temp = temp_f if unit == "fahrenheit" else temp_c
unit_label = "°F" if unit == "fahrenheit" else "°C"
conditions = random.choice(["sunny", "cloudy", "rainy", "partly cloudy"])
return f"{city}: {temp}{unit_label}, {conditions}"
Calculator plugin
# plugins/calculator.py
from semantic_kernel.functions import kernel_function
from typing import Annotated
import math
class CalculatorPlugin:
"""Basic arithmetic and math helpers."""
@kernel_function(
name="add",
description="Add two numbers"
)
def add(
self,
a: Annotated[float, "First number"],
b: Annotated[float, "Second number"]
) -> float:
return a + b
@kernel_function(
name="multiply",
description="Multiply two numbers"
)
def multiply(
self,
a: Annotated[float, "First number"],
b: Annotated[float, "Second number"]
) -> float:
return a * b
@kernel_function(
name="sqrt",
description="Square root of a number"
)
def sqrt(
self,
value: Annotated[float, "Non-negative number"]
) -> float:
if value < 0:
raise ValueError("Cannot take square root of negative number")
return math.sqrt(value)
Plugin package init
# plugins/__init__.py
from .weather import WeatherPlugin
from .calculator import CalculatorPlugin
__all__ = ["WeatherPlugin", "CalculatorPlugin"]
Configure the kernel with auto function calling
The kernel needs three things: the model client, the plugins, and a FunctionChoiceBehavior that tells the model it can call functions.
# main.py
import os
import asyncio
from dotenv import load_dotenv
from semantic_kernel import Kernel
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
from semantic_kernel.contents import ChatHistory
from plugins import WeatherPlugin, CalculatorPlugin
load_dotenv()
async def main():
kernel = Kernel()
# 1. Add the chat completion service
model_id = os.getenv("OPENAI_CHAT_MODEL", "gpt-4o-mini")
kernel.add_service(OpenAIChatCompletion(
service_id="default",
ai_model_id=model_id,
api_key=os.getenv("OPENAI_API_KEY")
))
# 2. Import plugins — each public method with @kernel_function becomes a tool
kernel.add_plugin(WeatherPlugin(), plugin_name="weather")
kernel.add_plugin(CalculatorPlugin(), plugin_name="calculator")
# 3. Enable auto function calling
# FunctionChoiceBehavior.Auto() lets the model decide when to call functions
settings = kernel.get_prompt_execution_settings_from_service_id("default")
settings.function_choice_behavior = FunctionChoiceBehavior.Auto()
# 4. Create a chat history with a system prompt that encourages tool use
history = ChatHistory()
history.add_system_message(
"You are a helpful assistant with access to weather and calculator tools. "
"Use them whenever they can answer the user's question more accurately."
)
# 5. Run a few test turns
user_queries = [
"What's the weather in Denver?",
"If it's 72°F in Denver, what's that in Celsius?",
"What's the square root of 144?",
"Multiply 13 by 37, then add 42."
]
for query in user_queries:
print(f"\n👤 User: {query}")
history.add_user_message(query)
# Invoke the kernel — this handles the function calling loop internally
result = await kernel.invoke(
plugin_name="chat",
function_name="chat",
chat_history=history,
settings=settings
)
# The result is the final assistant message after any tool calls
assistant_msg = str(result)
print(f"🤖 Assistant: {assistant_msg}")
history.add_assistant_message(assistant_msg)
if __name__ == "__main__":
asyncio.run(main())
Wait — the kernel.invoke call above assumes a chat plugin exists. Semantic Kernel doesn’t ship one by default. The simpler, more direct approach is to use the chat completion service directly with the kernel’s function calling behavior attached. Let me correct that.
Corrected: use the chat completion service directly
# main.py (corrected)
import os
import asyncio
from dotenv import load_dotenv
from semantic_kernel import Kernel
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
from semantic_kernel.contents import ChatHistory
from plugins import WeatherPlugin, CalculatorPlugin
load_dotenv()
async def main():
kernel = Kernel()
# 1. Add the chat completion service
model_id = os.getenv("OPENAI_CHAT_MODEL", "gpt-4o-mini")
chat_service = OpenAIChatCompletion(
service_id="default",
ai_model_id=model_id,
api_key=os.getenv("OPENAI_API_KEY")
)
kernel.add_service(chat_service)
# 2. Import plugins
kernel.add_plugin(WeatherPlugin(), plugin_name="weather")
kernel.add_plugin(CalculatorPlugin(), plugin_name="calculator")
# 3. Configure auto function calling on the execution settings
settings = kernel.get_prompt_execution_settings_from_service_id("default")
settings.function_choice_behavior = FunctionChoiceBehavior.Auto()
# 4. Chat loop
history = ChatHistory()
history.add_system_message(
"You are a helpful assistant with access to weather and calculator tools. "
"Use them whenever they can answer the user's question more accurately."
)
user_queries = [
"What's the weather in Denver?",
"If it's 72°F in Denver, what's that in Celsius?",
"What's the square root of 144?",
"Multiply 13 by 37, then add 42."
]
for query in user_queries:
print(f"\n👤 User: {query}")
history.add_user_message(query)
# Get response — the service handles the function calling loop
response = await chat_service.get_chat_message_content(
chat_history=history,
settings=settings,
kernel=kernel # Pass kernel so the service can resolve functions
)
assistant_msg = str(response)
print(f"🤖 Assistant: {assistant_msg}")
history.add_assistant_message(assistant_msg)
if __name__ == "__main__":
asyncio.run(main())
Run it
python main.py
Expected output (your temperatures will differ)
👤 User: What's the weather in Denver?
🤖 Assistant: Denver: 67°F, partly cloudy
👤 User: If it's 72°F in Denver, what's that in Celsius?
🤖 Assistant: 72°F is approximately 22.2°C.
👤 User: What's the square root of 144?
🤖 Assistant: The square root of 144 is 12.
👤 User: Multiply 13 by 37, then add 42.
🤖 Assistant: 13 × 37 = 481. Adding 42 gives 523.
Notice the second query: the model didn’t call a function — it knew the conversion formula. The fourth query triggered two function calls (multiply, then add) in a single turn. The kernel’s FunctionChoiceBehavior.Auto() manages that loop transparently.
Inspect the function calling traffic
To see what the model actually sends and receives, enable debug logging:
# Add near the top of main.py
import logging
logging.basicConfig(level=logging.DEBUG)
logging.getLogger("semantic_kernel").setLevel(logging.DEBUG)
Run again and you’ll see entries like:
DEBUG:semantic_kernel.connectors.ai.open_ai.services.open_ai_chat_completion:
Sending request with tools: [
{'type': 'function', 'function': {'name': 'weather-get_current_weather', ...}},
{'type': 'function', 'function': {'name': 'calculator-multiply', ...}},
...
]
DEBUG:semantic_kernel.connectors.ai.open_ai.services.open_ai_chat_completion:
Received tool calls: [
{'id': 'call_abc123', 'function': {'name': 'calculator-multiply', 'arguments': '{"a":13,"b":37}'}}
]
This is invaluable when the model picks the wrong function or hallucinates arguments.
Control function choice behavior
FunctionChoiceBehavior.Auto() is the default, but you have three options:
from semantic_kernel.connectors.ai.function_choice_behavior import (
FunctionChoiceBehavior,
FunctionChoiceType
)
# Model decides — default
settings.function_choice_behavior = FunctionChoiceBehavior.Auto()
# Force the model to call a function (useful for "plan then execute" patterns)
settings.function_choice_behavior = FunctionChoiceBehavior.Required()
# Disable function calling entirely
settings.function_choice_behavior = FunctionChoiceBehavior.None_()
# Or restrict to specific functions by name
settings.function_choice_behavior = FunctionChoiceBehavior.Auto(
functions=["weather-get_current_weather", "calculator-add"]
)
The functions filter is handy when you’ve registered many plugins but only want a subset available for a particular workflow.
Multiple function calls in one turn
The fourth query in our test (“Multiply 13 by 37, then add 42”) demonstrates chained calls. The model emits:
{
"tool_calls": [
{"id": "call_1", "function": {"name": "calculator-multiply", "arguments": "{\"a\":13,\"b\":37}"}},
{"id": "call_2", "function": {"name": "calculator-add", "arguments": "{\"a\":481,\"b\":42}"}}
]
}
The kernel executes them sequentially, feeds results back, and the model produces the final answer. No extra code required — FunctionChoiceBehavior.Auto() handles the loop until the model stops emitting tool calls.
Add a plugin with async functions
Real plugins often call HTTP APIs. Make the method async and the kernel awaits it automatically:
# plugins/http_weather.py
from semantic_kernel.functions import kernel_function
from typing import Annotated
import httpx
class HttpWeatherPlugin:
"""Weather via Open-Meteo (no key required)."""
@kernel_function(
name="get_forecast",
description="Get a 3-day forecast for a city"
)
async def get_forecast(
self,
city: Annotated[str, "City name"],
latitude: Annotated[float, "Latitude"],
longitude: Annotated[float, "Longitude"]
) -> str:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": latitude,
"longitude": longitude,
"daily": "temperature_2m_max,temperature_2m_min,weathercode",
"timezone": "auto",
"forecast_days": 3
}
)
resp.raise_for_status()
data = resp.json()
# Simplify weather codes
code_map = {0: "clear", 1: "mainly clear", 2: "partly cloudy", 3: "overcast",
45: "fog", 51: "light drizzle", 61: "rain", 71: "snow"}
days = []
for i in range(3):
code = data["daily"]["weathercode"][i]
hi = data["daily"]["temperature_2m_max"][i]
lo = data["daily"]["temperature_2m_min"][i]
days.append(f"Day {i+1}: {code_map.get(code, 'unknown')}, {lo}–{hi}°C")
return f"{city} forecast:\n" + "\n".join(days)
Register it the same way:
from plugins.http_weather import HttpWeatherPlugin
kernel.add_plugin(HttpWeatherPlugin(), plugin_name="http_weather")
Now the model can answer “What’s the 3-day forecast for Paris?” — but you’ll need to provide lat/long or add a geocoding plugin. This reveals a common pattern: compose plugins so the model can chain them (geocode → forecast).
Filter plugins per request
In production you may want different tool sets for different users or contexts. Create a kernel per request, or clone and modify:
def build_kernel_for_user(user_tier: str) -> Kernel:
kernel = Kernel()
kernel.add_service(chat_service)
# Everyone gets calculator
kernel.add_plugin(CalculatorPlugin(), plugin_name="calculator")
# Premium users get weather + HTTP weather
if user_tier == "premium":
kernel.add_plugin(WeatherPlugin(), plugin_name="weather")
kernel.add_plugin(HttpWeatherPlugin(), plugin_name="http_weather")
settings = kernel.get_prompt_execution_settings_from_service_id("default")
settings.function_choice_behavior = FunctionChoiceBehavior.Auto()
return kernel
This keeps the function namespace small for the model, reducing wrong-tool selections.
Error handling and retries
Function calls can fail. The kernel surfaces exceptions as tool results with an error message. You can catch and retry at the plugin level:
# plugins/robust_calculator.py
from semantic_kernel.functions import kernel_function
from typing import Annotated
import math
import logging
logger = logging.getLogger(__name__)
class RobustCalculatorPlugin:
@kernel_function(name="divide", description="Divide a by b")
def divide(
self,
a: Annotated[float, "Numerator"],
b: Annotated[float, "Denominator"]
) -> float:
try:
if b == 0:
raise ValueError("Division by zero")
return a / b
except Exception as e:
logger.warning(f"divide failed: {e}")
# Return a string — the model sees this as the tool result
return f"Error: {e}"
Returning a string error lets the model recover (“I got an error, let me try again with different arguments”) rather than crashing the turn.
Production considerations
Token usage
Each function schema adds tokens to the system prompt. With 20+ functions, you’re spending 1,500–3,000 tokens just on tool definitions. Mitigations:
- Filter plugins per request (shown above)
- Use concise descriptions — the model reads them
- Group related functions into fewer plugins with clearer names
Latency
Auto function calling adds round trips: model → tool → model → tool → model. For user-facing chat, this is visible. Options:
- Parallel function calling: OpenAI supports parallel tool calls; Semantic Kernel executes them concurrently when the model emits multiple calls in one response.
- Streaming: Use
get_streaming_chat_message_contentsto show partial results while tools run in the background. - Cache idempotent calls: Weather for the same city within 10 minutes can be cached at the plugin level.
Observability
Log every function call with arguments, latency, and outcome:
from semantic_kernel.functions import KernelFunctionMetadata
from functools import wraps
def logged_function(func):
@wraps(func)
async def wrapper(*args, **kwargs):
start = time.perf_counter()
try:
result = await func(*args, **kwargs)
duration = time.perf_counter() - start
logger.info(f"{func.__name__} ok ({duration:.2f}s) args={kwargs}")
return result
except Exception as e:
duration = time.perf_counter() - start
logger.error(f"{func.__name__} error ({duration:.2f}s) args={kwargs}: {e}")
raise
return wrapper
Apply @logged_function above @kernel_function on your plugin methods.
Testing without an LLM
Unit test your plugins directly — they’re just Python classes:
# test_plugins.py
import pytest
from plugins.calculator import CalculatorPlugin
def test_calculator():
calc = CalculatorPlugin()
assert calc.add(2, 3) == 5
assert calc.multiply(4, 5) == 20
assert calc.sqrt(16) == 4
with pytest.raises(ValueError):
calc.sqrt(-1)
For integration tests, use a fake model or the MockChatCompletion from semantic_kernel.connectors.ai.open_ai (if available) to verify the kernel wires functions correctly.
Summary
You now have a working semantic kernel auto function calling tutorial setup that:
- Defines plugins as plain Python classes with
@kernel_function - Registers them with a
Kernelinstance - Enables
FunctionChoiceBehavior.Auto()on the execution settings - Passes the kernel to the chat completion service so it can resolve and invoke functions
- Handles chained calls, async plugins, and error recovery automatically
The pattern scales: add more plugins, filter them per context, log everything, and cache where it matters. The kernel stays thin — your business logic lives in the plugins, testable and replaceable without touching the orchestration layer.
If you’re routing across multiple model providers in production, an inference gateway like n4n.ai can sit between your kernel and the providers, handling fallback and usage metering without changes to your plugin code.