~/blog/rebuilding-cognichain-on-microsoft-extensions-ai
Back to Blog

article

Rebuilding CogniChain on Microsoft.Extensions.AI

A 'finished' .NET AI library turned out to be quietly broken. Rebuilding it taught me more about the gap between code that compiles and code that works than shipping it the first time did.

Terminal-style blog artwork representing a .NET LLM library rebuilt on Microsoft.Extensions.AI

CogniChain has been sitting in my portfolio for a while: a small .NET library for building LLM-powered applications — prompt templates, chained workflows, tool calling, conversation memory, retries, streaming. I built it, announced it, it had tests, it shipped. Done, right?

I asked Claude Code to bring it up to date — newer packages, refreshed docs, the usual maintenance pass. The first thing it came back with wasn’t a version bump. It was a table.

Claim Reality
Conversation memory The orchestrator’s Memory object was built and exposed, but nothing in the execution path ever read it. Every example that called AddSystemMessage(...) was talking to a black hole.
Tool calling Tools were registered in a dictionary. Nothing ever asked the model to choose one. Every “tool calling” example called the tool by hardcoded name.
Streaming RunStreamingAsync awaited each step to completion, then fired a callback once per step — not per token. It streamed nothing.
Retry logic catch (Exception). Every exception. 401s, bad requests, cancellation — all retried three times with backoff before giving up.
“100% test coverage of core functionality” 23 tests. The retry logic, the streaming logic, and the orchestrator itself had none.

None of this threw an exception. None of it failed a test. It just silently didn’t do what the code visibly claimed to do. That’s a specific kind of bug I don’t think about often enough: not “this crashes” but “this compiles, runs, and returns a plausible-looking result while quietly doing nothing.”

Why patch it wasn’t the plan

I could have fixed the retry loop and called it a day. But the deeper problem was that CogniChain had hand-rolled a bunch of plumbing — prompt building, tool schemas, streaming, retries — that the .NET ecosystem has since standardized. Microsoft.Extensions.AI now ships IChatClient, structured output, AIFunction-based tool calling, and a proper middleware pipeline as first-party, actively maintained building blocks. Semantic Kernel — which I’d used in one of the example projects — has moved to maintenance mode; new orchestration work happens in the Microsoft Agent Framework instead.

Patching the old design would have meant fixing bugs inside code that duplicated something better, now built into the platform. So instead: a clean v1.0, breaking the old API entirely, built as a thin, typed layer on top of Microsoft.Extensions.AI rather than beside it.

// before — a workflow builder around code that never actually talked to a model
var orchestrator = new LLMOrchestrator(new OrchestratorConfig { RetryPolicy = ... });
orchestrator.Memory.AddSystemMessage("You are a helpful coding assistant.");

var workflow = orchestrator.CreateWorkflow()
    .WithPrompt(new PromptTemplate("Help me with: {task}"))
    .WithVariables(new Dictionary<string, string> { ["task"] = "..." })
    .AddStep(new YourLLMCallStep());   // you had to write this yourself

// after — the chain calls the model itself, and the system message actually reaches it
var chain = Chain.Create(chatClient)
    .WithSystemMessage("You are a helpful coding assistant.")
    .Prompt("Help me with: {task}")
    .Build();

var result = await chain.RunAsync(new { task = "writing a C# async method" });

Structured output became a first-class step instead of something you’d parse out of free text yourself:

public sealed record MovieSuggestion(string Title, int Year, string Reason);

var chain = Chain.Create(chatClient)
    .Prompt<MovieSuggestion>("Suggest one movie about {theme}.")
    .Build();

var result = await chain.RunAsync(new { theme = "time travel" });
Console.WriteLine(result.Value.Title);   // typed, no JSON wrangling

And the pieces that used to be silently broken — history, streaming, retries — are now delegated to Microsoft.Extensions.AI primitives that are actually exercised by real traffic across a much bigger surface than my library ever had.

The bug that survived the rebuild

Here’s the part I actually want to write about, because it’s the more useful lesson.

Once the rebuild was done, tests passing, CI green, PR open — I asked for a proper review pass instead of calling it finished. It came back with ten confirmed bugs. The worst one: a chain used as a nested step (my Branch conditional, or dropping one chain inside another) silently ran against the parent chain’s chat client, ignoring its own configured tools, its own system message, its own options entirely.

var whenTrue = Chain.Create<int>(specializedClient)
    .WithSystemMessage("You are a specialist in X.")
    .WithTools(someSpecializedTool)
    .Prompt("...")
    .Build();

outerChain.Branch(predicate, whenTrue, whenFalse);
// whenTrue's system message, tools, and even its own chat client were all silently
// discarded at run time — it ran with the OUTER chain's client and config instead.

This is precisely the class of bug the entire rebuild existed to eliminate. Code that reads as correctly configured, that has a green test suite, that compiles clean — and quietly does something different from what it visibly says. I’d built the compile-time type safety I wanted (the builder’s generics enforce that steps line up), and it still let a runtime wiring bug like this through, because type safety was never going to catch “this object’s own fields are never read.”

The fix took real thought, not just a patch: nested chains now get their own execution context — their own chat client, tools, and options — while still sharing conversation history and usage tracking with the parent, so branching doesn’t break multi-turn memory. Ten bugs like this got found and fixed in that pass, each with a regression test written to fail on the old behavior first.

What I keep taking from this

A test suite passing and a build going green tell you the code does something consistently. They don’t tell you it does the right thing, and they especially don’t catch a whole class of bugs where a piece of configuration is built, exposed, and never actually consulted at run time. That’s true whether a human writes the code or an agent does — the review step isn’t optional busywork you skip under time pressure, it’s where a different set of eyes (in this case, a second pass I explicitly asked for) catches the thing the first pass was too close to see.

The other thing: “it has tests” is not the same claim as “it works.” CogniChain 0.x had tests. They passed. They tested the parts that were fine and never touched the parts that weren’t. Coverage of something isn’t coverage of the thing that matters.

CogniChain v1.0 is on GitHub now, with a migration guide for anyone who was actually depending on the old API (unlikely, but the changelog is there for them):

If you’re maintaining an old side project you haven’t opened in a while: it might be worth asking whether it actually still works, not just whether it still builds.

Back to all posts