Skip to content

Semantic Kernel Fundamentals, Mapped Against This Codebase's Two Paths

Goal: understand SK's actual building blocks in general, then see exactly which of them this codebase uses (Gemini/OpenAI/Groq path) and which it bypasses entirely (Claude path) — with real citations, not abstractions.

SK's building blocks, in plain terms

Kernel — the container. Holds registered AI services (chat completion, embeddings, etc.) and registered plugins (your tools). You build one via Kernel.CreateBuilder()...Build(). Nothing "runs" by building a Kernel — it's just wiring, a DI container specialized for AI apps.

IChatCompletionService — the actual interface that talks to a model provider. This is the seam every connector (AddOpenAIChatCompletion, AddGoogleAIGeminiChatCompletion, etc.) implements. Its core job, stripped to essentials: take a ChatHistory + PromptExecutionSettings in, make one real HTTP call to the provider, return a ChatMessageContent (or a list of them) out. One call in, one call out — this interface itself does NOT loop.

ChatHistory — SK's message-list type. A list of ChatMessageContent (role + content + optional function-call/function-result metadata), passed by reference. Whoever holds a reference to it can see it mutate as a conversation (or a tool-calling loop) progresses.

Plugins / [KernelFunction] — your tools, exposed to SK. Decorate a C# method with [KernelFunction("name")] + [Description(...)] on the method and its parameters; SK reflects over this to build the JSON tool schema automatically and to know what to invoke when the model asks for that tool by name. kernelBuilder.Plugins.AddFromObject(instance, "PluginName") registers an object's methods this way.

PromptExecutionSettings + FunctionChoiceBehavior.Auto() — settings passed alongside a completion request. FunctionChoiceBehavior.Auto() is the flag that turns on SK's auto function-invocation loop — this is the actual "agentic loop" logic, and critically: it lives in SK's core, not inside any specific connector. Any IChatCompletionService that correctly reports "the model wants to call function X" gets this loop driven over it automatically by SK itself, repeatedly calling that same single-call service, invoking your registered plugin methods, appending results, and re-calling — until the model stops requesting functions.

Filters (IFunctionInvocationFilter, IAutoFunctionInvocationFilter) — hooks SK calls before/after each internal step, since the loop itself is otherwise opaque to your calling code. This is the sanctioned way to observe/react to what's happening mid-loop without owning the loop.

Where each of these actually appears (or doesn't) in this codebase

The SK path (Gemini/OpenAI/Groq) — every concept above is genuinely used

KernelFactory.Create (Agent/Kernel/KernelFactory.cs):

var kernelBuilder = Kernel.CreateBuilder();
kernelBuilder.Services.AddSingleton<IFunctionInvocationFilter>(new FunctionStatusFilter(onStatusUpdate));
kernelBuilder.Services.AddSingleton<IAutoFunctionInvocationFilter>(new AutoInvocationLoggingFilter(...));

// provider-specific connector registration, e.g.:
kernelBuilder.AddGoogleAIGeminiChatCompletion(modelId: ..., apiKey: ...);

kernelBuilder.Plugins.AddFromObject(sqlPlugin, "SunriseSqlPlugin");
var kernel = kernelBuilder.Build();

SunriseSqlPlugin.ExecuteSqlAsync is decorated:

[KernelFunction("execute_sql")]
[Description("Executes a SELECT SQL query against the merchant's analytical DuckDB store...")]
public async Task<string> ExecuteSqlAsync(
    [Description("A valid SELECT SQL statement...")] string sql)

— this attribute is what SK reflects over to build the tool schema on this path. (On the Claude path, this same attribute sits there unused — the Claude path hand-writes an equivalent JSON schema instead, see below.)

AgentService.RunAsync (SK branch):

var (kernel, executionSettings) = _kernelFactory.Create(sqlPlugin, onStatusUpdate);
var history = new ChatHistory();
history.AddSystemMessage(_systemPromptBuilder.Build(merchant));
foreach (var message in chatHistory) history.Add(message);
history.AddUserMessage(userMessage);

var chatService = kernel.GetRequiredService<IChatCompletionService>();
result = await chatService.GetChatMessageContentAsync(history, executionSettings, kernel);

One call. Whatever tool-calling happened — one round or five — happened inside that single GetChatMessageContentAsync call, driven by FunctionChoiceBehavior.Auto() (set inside KernelFactory.Create, per-provider). FunctionStatusFilter and AutoInvocationLoggingFilter are the only places this codebase can see into that loop — everything else about it is invisible to AgentService.

The Claude path — every one of these concepts is bypassed

RunWithAnthropicSdkAsync never touches Kernel, never touches IChatCompletionService, never touches ChatHistory, never registers a plugin with SK. Proof, directly in AgentService.RunAsync:

if (_aiSettings.ActiveProvider.ToLower() == "claude")
    return await RunWithAnthropicSdkAsync(...);   // returns here, kernel never built

var (kernel, executionSettings) = _kernelFactory.Create(...); // unreachable for claude

Instead, every one of SK's jobs is done by hand:

SK concept Claude path's hand-rolled equivalent
IChatCompletionService (one call in/out) client.Messages.GetClaudeMessageAsync(parameters), native SDK
Auto function-invocation loop while (response.StopReason == "tool_use") { ... }
ChatHistory mutation List<Message> messages, manually .Add()'d at each step
Plugin reflection → tool schema Hand-written JSON schema literal for execute_sql
FunctionChoiceBehavior.Auto() N/A — there's no "auto" switch, the loop IS the switch
Filters for mid-loop observability Direct _logger.LogInformation(...) / onStatusUpdate?.Invoke(...) calls inline in the loop body
Retry policy SendWithRetryAsync — hand-written exponential backoff (2s→4s→8s), separate from SK entirely (worth noting: even the SK branch has its own separate hand-written retry loop — SK doesn't provide this for free either)

The one-sentence version

SK path: you configure a Kernel once, call one method, and SK's core drives the whole tool-calling exchange for you, invisibly. Claude path: every single step of that same exchange — the loop condition, the message-list growth, the tool schema, the retries — is written out explicitly, by hand, in one long method. Neither is "wrong" — the Claude path trades SK's automation for full control and (critically) working prompt caching, which SK's available connectors couldn't reliably carry for Anthropic specifically (see 02-agentic-loop.md and the caching-history notes for why).