Tool selection is where most Semantic Kernel agents succeed or fail. The model picks the wrong function, hallucinates parameters, or gets stuck in loops — not because the LLM is broken, but because the selection strategy doesn’t match the task. This guide walks through semantic kernel agent tool selection strategies that work in production, from basic function choice configuration to planner-based orchestration and recovery patterns.
Understanding the selection problem
Every agent request faces a routing decision: which function executes, with what arguments, and in what order. Semantic Kernel exposes three function choice behaviors that control this:
- Auto — the model decides whether to call a function and which one
- Required — the model must call a function (useful for forcing tool use)
- None — no function calling, pure chat completion
The default auto behavior works for simple cases but breaks down when functions have overlapping capabilities, when parameter extraction fails silently, or when the model needs multi-step reasoning before acting.
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
from semantic_kernel.contents import ChatHistory
from semantic_kernel.functions import KernelArguments
from semantic_kernel import Kernel
kernel = Kernel()
kernel.add_service(OpenAIChatCompletion(service_id="default", ai_model_id="gpt-4o"))
# Register functions
kernel.add_plugin(WeatherPlugin(), plugin_name="weather")
kernel.add_plugin(SearchPlugin(), plugin_name="search")
# Default auto behavior
arguments = KernelArguments(
settings=kernel.get_prompt_execution_settings_from_service_id("default")
)
arguments.settings.function_choice_behavior = FunctionChoiceBehavior.Auto()
Function choice behaviors in practice
Auto with function filtering
When you have many registered functions, auto lets the model choose from all of them. This creates confusion. Filter the available set per request:
from semantic_kernel.functions import FunctionChoiceBehavior
# Only expose weather functions for this turn
arguments.settings.function_choice_behavior = FunctionChoiceBehavior.Auto(
functions=["weather.get_forecast", "weather.get_historical"]
)
Required for deterministic flows
Use Required when the workflow demands a tool call — for example, a retrieval step that must happen before generation:
from semantic_kernel.functions import FunctionChoiceBehavior
arguments.settings.function_choice_behavior = FunctionChoiceBehavior.Required(
functions=["search.query"]
)
This prevents the model from answering from parametric knowledge when you need grounded results.
None for final synthesis
After tool calls complete, switch to None for the synthesis turn:
# First turn: required search
arguments.settings.function_choice_behavior = FunctionChoiceBehavior.Required(
functions=["search.query"]
)
search_result = await kernel.invoke_prompt(prompt, arguments=arguments)
# Second turn: synthesis without tools
arguments.settings.function_choice_behavior = FunctionChoiceBehavior.None()
final_answer = await kernel.invoke_prompt(synthesis_prompt, arguments=arguments)
Kernel function metadata determines selection quality
The model selects functions based on name, description, and parameter schemas. Weak metadata causes misrouting. Treat every function as a public API with strict contracts.
Descriptions that drive correct selection
from semantic_kernel.functions import kernel_function
from pydantic import BaseModel, Field
class WeatherArgs(BaseModel):
location: str = Field(description="City and state, e.g., 'Seattle, WA'")
days: int = Field(default=3, ge=1, le=10, description="Forecast horizon in days")
class WeatherPlugin:
@kernel_function(
name="get_forecast",
description=(
"Returns a multi-day weather forecast for a given location. "
"Use for future conditions. Do not use for current conditions or historical data."
)
)
async def get_forecast(self, args: WeatherArgs) -> str:
...
@kernel_function(
name="get_current",
description=(
"Returns current weather conditions for a given location. "
"Use only for right-now temperature, precipitation, wind. "
"Do not use for forecasts."
)
)
async def get_current(self, args: WeatherArgs) -> str:
...
The descriptions explicitly differentiate use cases. Without “Do not use for…” guidance, the model picks arbitrarily between get_forecast and get_current.
Parameter descriptions as selection signals
Parameter descriptions help the model extract arguments correctly and avoid hallucinated fields:
class SearchArgs(BaseModel):
query: str = Field(description="Search query string. Be specific. Include entities, dates, constraints.")
top_k: int = Field(default=5, ge=1, le=20, description="Number of results to return")
recency_days: int | None = Field(
default=None,
description="Limit results to last N days. Omit for no recency filter."
)
source: str | None = Field(
default=None,
description="Source filter: 'news', 'academic', 'web'. Omit for all sources."
)
Planner-based selection for multi-step tasks
When a request requires multiple function calls with dependencies, function choice behaviors aren’t enough. Use a planner.
FunctionCallingStepwisePlanner
The stepwise planner executes one function at a time, observes the result, then decides the next step:
from semantic_kernel.planners import FunctionCallingStepwisePlanner
from semantic_kernel.planners.function_calling_stepwise import FunctionCallingStepwisePlannerOptions
planner = FunctionCallingStepwisePlanner(
service_id="default",
options=FunctionCallingStepwisePlannerOptions(
max_iterations=10,
max_tokens=4000,
available_functions=[
"search.query",
"weather.get_forecast",
"calculator.evaluate"
]
)
)
result = await planner.invoke(
kernel=kernel,
question="What's the weather in Tokyo for the next 3 days and how does it compare to historical averages?",
arguments=KernelArguments()
)
When to use a planner vs. direct calling
| Scenario | Approach |
|---|---|
| Single tool, known at design time | Direct function calling with Required |
| Tool choice depends on user input | Auto with filtered function set |
| Multi-step with conditional logic | FunctionCallingStepwisePlanner |
| Fixed sequence, known ahead | Manual orchestration in code |
| Need parallel execution | Manual orchestration with asyncio.gather |
Planners add latency (multiple model calls) and can loop. Set max_iterations conservatively and log every step.
Handling selection failures
Missing function selection
The model responds with text instead of calling a function. Common causes:
- Function descriptions don’t match the query — audit descriptions against real user queries
- Parameter schema too complex — flatten nested objects, use primitives
- Model capability mismatch — smaller models struggle with function calling; use gpt-4o or equivalent
Debug by logging the raw model response:
from semantic_kernel.connectors.ai.open_ai import OpenAIChatPromptExecutionSettings
settings = OpenAIChatPromptExecutionSettings()
settings.function_choice_behavior = FunctionChoiceBehavior.Auto()
# Enable response logging
async def invoke_with_logging(kernel, prompt, arguments):
result = await kernel.invoke_prompt(prompt, arguments=arguments)
print(f"Model response: {result}") # Check for tool_calls vs. content
return result
Hallucinated parameters
The model calls the right function but invents parameter values. Mitigations:
# Strict schema validation at function entry
from pydantic import ValidationError
@kernel_function(name="transfer_funds", description="Transfer money between accounts")
async def transfer_funds(self, args: TransferArgs) -> str:
try:
validated = TransferArgs.model_validate(args)
except ValidationError as e:
return f"Invalid parameters: {e}"
...
Infinite planner loops
The planner repeats the same function call. Add iteration tracking and circuit breakers:
class TrackedPlanner(FunctionCallingStepwisePlanner):
def __init__(self, *args, max_repeats=2, **kwargs):
super().__init__(*args, **kwargs)
self.call_history: list[tuple[str, str]] = [] # (function_name, args_hash)
self.max_repeats = max_repeats
async def invoke(self, kernel, question, arguments):
# Override to inject history checking
...
Performance tradeoffs
Latency comparison
| Strategy | Typical latency | Model calls |
|---|---|---|
Direct Required |
1x | 1 |
Auto with filtering |
1x | 1 |
| Stepwise planner (3 steps) | 3-4x | 3-4 |
| Stepwise planner (retry loops) | 5-10x | 5-10 |
Token costs
Each planner iteration sends full conversation history plus function schemas. For 20 functions with detailed schemas, that’s 3-5k tokens per iteration. Reduce the available function set per planner invocation:
planner = FunctionCallingStepwisePlanner(
service_id="default",
options=FunctionCallingStepwisePlannerOptions(
available_functions=get_relevant_functions(user_intent) # Dynamic filtering
)
)
Caching function schemas
Function schemas are static. Serialize and cache them to avoid rebuilding on every request:
import json
from semantic_kernel.functions import KernelFunctionMetadata
def get_cached_schemas(kernel: Kernel) -> str:
cache_key = "function_schemas_v1"
cached = redis.get(cache_key)
if cached:
return cached
schemas = []
for plugin_name, plugin in kernel.plugins.items():
for func_name, func in plugin.functions.items():
schemas.append(func.metadata.to_json())
result = json.dumps(schemas)
redis.setex(cache_key, 3600, result)
return result
Common pitfalls
Overloading a single plugin
Putting 30 functions in one plugin creates a selection nightmare. Split by domain:
# Bad: one plugin, 30 functions
kernel.add_plugin(EverythingPlugin(), plugin_name="tools")
# Good: domain-separated plugins
kernel.add_plugin(WeatherPlugin(), plugin_name="weather")
kernel.add_plugin(FinancePlugin(), plugin_name="finance")
kernel.add_plugin(SearchPlugin(), plugin_name="search")
Then filter by plugin at request time:
arguments.settings.function_choice_behavior = FunctionChoiceBehavior.Auto(
plugins=["weather", "search"] # Only these plugins available
)
Ignoring provider-specific behavior
Different model providers handle function calling differently. OpenAI, Anthropic, and local models have varying reliability. Test your selection strategy against each provider you support. If you route across providers — for example, using an inference gateway that falls back automatically — verify that function calling behavior degrades gracefully.
No observability on selection decisions
You can’t improve what you don’t measure. Log every selection:
import structlog
logger = structlog.get_logger()
async def tracked_invoke(kernel, function_name, arguments):
logger.info("function_selected", function=function_name, args=arguments)
try:
result = await kernel.invoke(function_name, arguments)
logger.info("function_completed", function=function_name, success=True)
return result
except Exception as e:
logger.error("function_failed", function=function_name, error=str(e))
raise
Testing selection logic
Unit test function selection by mocking the model response:
import pytest
from unittest.mock import AsyncMock, patch
@pytest.mark.asyncio
async def test_weather_forecast_selection():
kernel = Kernel()
kernel.add_plugin(WeatherPlugin(), plugin_name="weather")
# Mock the model to return a specific tool call
with patch.object(kernel, 'invoke_prompt', new_callable=AsyncMock) as mock_invoke:
mock_invoke.return_value = "tool_call: weather.get_forecast({'location': 'Seattle, WA', 'days': 3})"
result = await kernel.invoke_prompt(
"What's the forecast for Seattle?",
arguments=KernelArguments(settings=OpenAIChatPromptExecutionSettings(
function_choice_behavior=FunctionChoiceBehavior.Auto()
))
)
# Verify the right function was selected
assert "weather.get_forecast" in str(result)
Integration test with a real model on a curated eval set:
EVAL_CASES = [
("Current temp in NYC", "weather.get_current"),
("3-day forecast for London", "weather.get_forecast"),
("Historical rain data for Seattle", "weather.get_historical"),
]
async def eval_selection_accuracy():
correct = 0
for query, expected_function in EVAL_CASES:
result = await kernel.invoke_prompt(query, arguments=test_args)
selected = extract_function_name(result)
if selected == expected_function:
correct += 1
else:
print(f"FAIL: '{query}' -> {selected} (expected {expected_function})")
print(f"Accuracy: {correct}/{len(EVAL_CASES)}")
Summary checklist
When implementing semantic kernel agent tool selection strategies:
- Start with
Requiredfor known tool calls, notAuto - Filter functions per request using plugin or function lists
- Write discriminative descriptions — include negative constraints (“Do not use for…”)
- Validate parameters at function entry with Pydantic
- Use planners only when necessary — they add latency and failure modes
- Log every selection decision for debugging and eval
- Test against real queries — not hypothetical ones
- Monitor planner iteration counts — alert on loops
The difference between a fragile demo and a reliable agent is almost entirely in the selection layer. Invest there first.