Skip to content

What Writing a Custom Anthropic IChatCompletionService Would Actually Change

Concrete, code-cited version of the previous conversation. Everything under "sketch" is illustrative, not verified against the exact SK version pinned in this repo's .csproj — treat method/type names as representative, not copy-pasteable, until checked against the actual installed Microsoft.SemanticKernel.Abstractions version. Everything under "today, current code" is a direct citation from AgentService.cs as it exists now.

The one job a custom connector has to do

IChatCompletionService (SK's interface, already used generically by AddGoogleAIGeminiChatCompletion etc. in this codebase's KernelFactory.cs) boils down to: take a ChatHistory + PromptExecutionSettings in, make ONE real API call, return ChatMessageContent out — including correctly flagging any tool-call requests so SK's own auto-invocation loop can recognize and act on them. You are not writing the loop. SK's core drives the repeated calling; your class is the thing it calls each time.

Sketch: what the adapter class would look like

public class AnthropicNativeChatCompletionService : IChatCompletionService
{
    private readonly AnthropicClient _client;
    private readonly AiSettings _aiSettings;

    public async Task<IReadOnlyList<ChatMessageContent>> GetChatMessageContentsAsync(
        ChatHistory chatHistory,
        PromptExecutionSettings? executionSettings,
        Kernel? kernel,
        CancellationToken cancellationToken = default)
    {
        // 1. Translate SK's ChatHistory into Anthropic's Message list —
        //    this is roughly the same conversion already written today (see below).
        var (systemMessages, messages) = TranslateHistory(chatHistory);

        // 2. Translate kernel plugins into Anthropic tool schemas —
        //    SK exposes registered functions via `kernel.Plugins`; you'd reflect
        //    over these instead of hand-writing execute_sql's JSON schema literal,
        //    the way KernelFactory already lets Gemini/OpenAI do via SK's own
        //    plugin-to-schema machinery. This is genuinely new code — today's
        //    Claude path hand-writes ONE tool's schema; a real adapter needs to
        //    handle N registered tools generically.
        var tools = BuildToolsFromKernelPlugins(kernel);

        // 3. ONE call to the real Anthropic Messages API — no while loop here.
        var response = await _client.Messages.GetClaudeMessageAsync(new MessageParameters
        {
            System = systemMessages,      // cache_control lives here — see below
            Messages = messages,
            Tools = tools,
            MaxTokens = _aiSettings.Claude.MaxTokens,
            Model = _aiSettings.Claude.Model,
            PromptCaching = PromptCacheType.FineGrained
        });

        // 4. Translate the response back into SK's shape — THIS is the critical part
        //    for the auto-loop to work: a tool-use response must come back as
        //    ChatMessageContent containing FunctionCallContent items, or SK's
        //    core has nothing to recognize and won't drive another round.
        return new[] { TranslateResponseToSk(response) };
    }
}

Side-by-side: what maps to what, in today's actual code

System prompt + history conversion — already exists, nearly reusable as-is. Today (AgentService.cs):

var systemPromptText = _systemPromptBuilder.Build(merchant);
var systemMessages = new List<SystemMessage> {
    new SystemMessage(systemPromptText, new CacheControl { Type = CacheControlType.ephemeral })
};

var messages = new List<Message>();
foreach (var msg in chatHistory)
{
    var role = msg.Role == AuthorRole.User ? RoleType.User : RoleType.Assistant;
    messages.Add(new Message { Role = role, Content = new List<ContentBase> { new TextContent { Text = msg.Content ?? "" } } });
}

This exact block is the TranslateHistory step above, almost verbatim — the only change is the input type shifts from the hand-built List<ChatMessageContent> this app already threads through ChatRequestHandler to SK's own ChatHistory (which is a superset of the same information — role + text, plus optional function-call metadata this app's current stripped history doesn't carry anyway).

Tool schema — today hand-written once, would become reflection-based. Today:

var executeSqlTool = new Tool(new Function("execute_sql", "Executes a SELECT SQL...",
    JsonNode.Parse(@"{ ""type"": ""object"", ""properties"": { ""sql"": {...} }, ""required"": [""sql""] }")));

A real adapter needs BuildToolsFromKernelPlugins(kernel) instead — genuinely new code, since today's version only ever has to describe the one hand-known tool.

The loop itself — deleted, not reimplemented. Today, RunWithAnthropicSdkAsync contains:

while (response.StopReason == "tool_use")
{
    messages.Add(new Message { Role = RoleType.Assistant, Content = response.Content });
    foreach (var block in response.Content.OfType<ToolUseContent>()) { /* run SQL, cache, log, status update */ }
    messages.Add(new Message { Role = RoleType.User, Content = toolResultContents });
    response = await SendWithRetryAsync(parameters);
}

None of this loop structure moves into the adapter. It disappears — SK's core replaces it. What does move into the adapter is only the inner body's translation logic (run the SQL, build a tool-result block) reshaped to fit the single-call contract; the looping/re-calling is no longer your code's responsibility at all.

Retry/backoff — stays, doesn't disappear. SendWithRetryAsync's exponential backoff has nowhere else to live — SK doesn't provide retry policy out of the box (the existing SK-driven Gemini/OpenAI/Groq branch in this same file has its own separate hand-written retry loop around GetChatMessageContentAsync, proving this). The adapter's single API call would still need this wrapped around it.

sqlResultCache (per-turn dedup) — needs a new home. Today it's a local variable inside RunWithAnthropicSdkAsync, scoped to one loop's lifetime. Once the loop lives inside SK's core instead of this method, that cache can't be a local variable in the same place anymore — it would need to move into the plugin method itself (SunriseSqlPlugin.ExecuteSqlAsync) or a request-scoped service, since that's the only code that still runs on every tool invocation.

What stays completely unchanged, regardless of any of this

QueryExecutor (SELECT-only enforcement, 500-row cap), SunriseSqlPlugin's CSV + numeric-summary serialization, ExtractJsonPayload, SystemPromptBuilder, ChatRequestHandler's session/history/persistence logic — none of this cares which component is driving the tool-calling loop. This is the reason a rewrite here is lower- risk than it might sound: the actual SQL-safety and response-format logic this app most needs to trust is entirely orthogonal to the SK-vs-hand-rolled question.

The caching question this doesn't automatically answer

Setting PromptCaching = PromptCacheType.FineGrained and CacheControl on SystemMessage inside the adapter (step 3 above) works fine because that code is calling the native Anthropic.SDK client directly — same as today. What's still unverified is whether that cache-control decision can be driven from outside the adapter, i.e. whether AgentService (or whoever configures the request) can toggle caching via SK's own PromptExecutionSettings, the way GeminiPromptExecutionSettings/ OpenAIPromptExecutionSettings already let per-provider settings flow through in this codebase's KernelFactory.cs. The clean version of this adapter would define its own AnthropicPromptExecutionSettings : PromptExecutionSettings (mirroring the existing pattern) with an explicit caching-related property — but that's a design decision to make while writing the adapter, not something inherited for free just by implementing IChatCompletionService.