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