Oct 29, 20268 min read/2026/10/29/retry-penalize-or-stop/

Retry, Penalize, or Stop? How Creatura Handles a Tool That Says It Worked When It Didn't

A tool's report is a claim. The world's facts are the evidence. Here is what Creatura does when the two disagree.

The question

A reader left a comment on my post about the birth of Creatura. They underlined one line: "checked against what actually happened, not what the agent claims". They build agents with Semantic Kernel and tool calling, and they said that the agent saying it did something and the system really being in that state are two different things. Most of their bugs lived in that gap.

Then they asked the question I want to answer here: when the emulated world and the agent's report disagree, does Creatura retry, penalize, or stop the run?

The short answer is all three, in stages. The long answer is this post. And because they work with Semantic Kernel, I built a Semantic Kernel host for Creatura to answer it in their own framework.

Judged by effect, never by report

In Creatura, every tool declares two things: what it requires, and what it grants. The grants are facts that should become true in the world when the tool works. In the sample "restore the service" world, apply-patch requires component.identified and grants patch.applied; run-tests requires patch.applied and grants tests.green.

After every tool call, the control layer looks at the world's facts and checks whether the granted effects now hold. The tool's own report is not part of that check. A report is a claim. The facts are the evidence.

That gives three outcomes:

Outcome What the tool said What the world shows
Landed anything the granted effects hold
Honest failure "I failed" nothing landed
Lie "success" the effect is missing

There is one condition the host has to meet. The tool wrapper must pass along what the system actually answered. If it doesn't, the layer has no way to tell an honest failure from a lie, so it treats the report as a claim of success. That detail matters, and it comes back later in this post.

Stage one: no blind retry

The reader's first option was retry. Creatura does not retry the same tool blindly, because a tool that just lied to you is the last thing you want to call again.

A tool caught lying is quarantined. It is hidden from the tools the model is offered for a cooldown, three ticks by default, and then it is trusted again. Quarantine is a pause, not a death sentence: real tools have bad moments.

The model is told why. The tool result it reads is not the tool's cheerful report. The layer replaces it with this:

WARNING: apply-patch reported '...' but patch.applied did not become true. Its effect did not land. It is distrusted for now; use a different route.

So the retry that does happen is a retry by another route, chosen by a model that knows the first route lied. In the sample world that route exists: apply-config grants the same patch.applied. And if the model names the hidden tool anyway, the call is refused, not run.

Stage two: penalize

A lie is not free. It counts as a failure against the round's budget. The budget is between one and four failures, depending on how much freedom the agent has earned: a design that has proven it can predict its own actions gets more room than one that hasn't.

The lie is also recorded in three places: in the agent's predictions, in its ledger of what each tool really does, and in an audit. Refused calls spend budget too, so a model that keeps reaching for the hidden tool burns through its room quickly.

An honest failure is treated differently, and on purpose. A tool that says "I failed" and did fail is telling the truth, so it is not called a lie and it stays available. The oracle that reports red is still the oracle. It still costs budget, because nothing got done.

Stage three: stop

When the failures exceed the budget, the run ends with a hand-off to a human: "failures exceeded the freedom budget". It also ends when no route is left and nothing is waiting out a quarantine. If a quarantined tool could come back, the agent waits for it. If nothing can, it hands off.

So: no blind retry, a penalty that is recorded, and a stop that hands the problem to a person before the agent can do real damage.

Extensions can be stricter than this. In the convoy simulation, where several trucks share one flooded world, a road caught lying is distrusted for good, and the truck that caught it warns the others through a signed, shared ledger. I wrote about that in Three Trucks After a Flood.

A Semantic Kernel host, built for this comment

Creatura already ran on Microsoft Agent Framework. To answer this reader properly, I built Creatura.Hosts.SemanticKernel, on Semantic Kernel 1.81.0. Semantic Kernel keeps its own function-calling loop and chat history; Creatura's layer rides inside it.

One filter admits and judges. An IAutoFunctionInvocationFilter admits the call before next() and judges it after. The layer's verdict replaces the function's result, so the model reads the verdict, never the tool's claim. When the layer says stop, the filter sets context.Terminate, which ends Semantic Kernel's whole automatic loop, not just the one call.

A chat client hides tools. A DelegatingChatClient sits under Semantic Kernel's function-calling loop (UseKernelFunctionInvocation). It sees every model request, counts each one as a tick, and removes distrusted functions before the request leaves. The model never even gets their schema.

The world becomes a plugin. The world's tools become one kernel plugin. Semantic Kernel function names allow only letters, digits and underscores, so apply-patch becomes apply_patch, and name clashes are refused up front rather than discovered at run time.

Two things changed from my original design, and I think they are the interesting part.

First, the plan was to hide distrusted tools in a prompt-render filter. But Semantic Kernel renders a prompt once per invocation and then makes several model requests without rendering again. And FunctionChoiceBehavior can't be subclassed outside the package. So the only hook that runs before every single model request is the chat client, and that is where the hiding went.

Second, the plan was two filters, one to admit and one to judge. It is one, because Terminate only exists on the auto-invocation filter. If the filter that judges can't stop the loop, it isn't much of a judge.

There is one limit. The model must come in as an IChatClient. The classic IChatCompletionService connectors (AddOpenAIChatCompletion) run their function-calling loop inside the connector, with no hook before each request, so there is nowhere to hide a tool. Wiring it up looks like this:

var agent = new ControlledKernelAgent((world, task) => openAi.GetChatClient(model).AsIChatClient(), model);
var run   = await agent.RunAsync(world);   // or ControlledKernelAgent.Offline()

How I know it works

Offline, with a planner in the model's seat, the Semantic Kernel host reproduces the Agent Framework host's four reference traces byte for byte. The only difference is the host's name.

The tests cover each of the reader's cases: a lie reaching the model as a warning; an honest failure not being called a lie; a distrusted tool that is neither offered nor run; and a stop that ends the loop and hands off.

Then I broke the host seven ways on purpose, to check that the tests notice. The first pass caught six. The seventh got through: when the wrapper stopped passing along what the world answered, nothing noticed. That is exactly the condition from the beginning of this post, the one that lets the layer tell an honest failure from a lie. So I added a test for it, and now all seven breaks are caught. 737 tests pass in total.

What is not done yet: I have not run this host against a real model. Everything above is offline. That is the next step.

The honest caveat

"What actually happened" sounds like solid ground. It isn't always.

In a noisy-sensor version of the convoy world, where the world's own readings are sometimes wrong, the exact effect check raised false alarms on about 7% of actions: it called honest tools liars because a sensor blinked. A hand-written check that fuses two signals cut that to 0.4%.

Then I tried to learn the judge instead of writing it. A small learned judge, trained in the loop over three pre-registered attempts, ended level with the hand-written rule, not ahead: false alarms about 0.75% against 0.4%. The registered claims said it would beat the rule. They failed, and I published that. Every run, the failures included, is in the experiment log.

So the answer to "retry, penalize, or stop?" comes with a footnote: all three only work as well as your check of the world. If the world's facts are noisy, you need a judge for the facts too, and you should measure it as carefully as you measure the agent.

Try it

The code is on GitHub: github.com/egarim/creatura, and the anatomy page draws the parts. The Semantic Kernel host is commit 73620c5. The offline sample runs it beside the other designs, with no model and no key:

dotnet run --project samples/SemanticKernelHost -- --compare

If you build agents with tool calling, I would like to hear where your gap between report and reality shows up.