n4nAI

Semantic Kernel agent tutorial: tool selection strategies

A practical guide to Semantic Kernel agent tool selection strategies covering function choice behaviors, planner integration, metadata design, and failure handling patterns.

n4n Team4 min read818 words

Audio narration

Coming soon — every post will get a voice note here.

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:

  1. Function descriptions don’t match the query — audit descriptions against real user queries
  2. Parameter schema too complex — flatten nested objects, use primitives
  3. 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:

  1. Start with Required for known tool calls, not Auto
  2. Filter functions per request using plugin or function lists
  3. Write discriminative descriptions — include negative constraints (“Do not use for…”)
  4. Validate parameters at function entry with Pydantic
  5. Use planners only when necessary — they add latency and failure modes
  6. Log every selection decision for debugging and eval
  7. Test against real queries — not hypothetical ones
  8. 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.

Tagssemantic-kernelagenttool-selectionplanner

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All semantic kernel planners & agents posts →