Oct 23, 20268 min read/2026/10/23/agents-that-defend-goals-05-the-control-layer-on-agent-framework/

Agents That Defend Goals, Part 5: The control layer on Microsoft Agent Framework

Five rules as function middleware around a real model, and the benchmark finding that the plumbing bounds the architecture.

Part 5 of a series on agents built from two scientists' accounts of what a mind is. Live demo: egarim.github.io/biological-agents. The code will be released later: I'm travelling in Asia, and the repository stays private until I'm back. The live demo runs the same agents today.


What survived

Strip the biology from the first three articles and five mechanisms remain. They are the ones that caught every lie and every shortcut in every run, and nothing else in the designs changed an outcome:

  1. Judge by effect, not by report. After every tool call, read the world yourself.
  2. Distrust, then forgive. A tool whose effect did not land is hidden for a few turns, then returns.
  3. The agent decides what the model sees. Tools outside the visible set are refused by the runtime, not by the prompt.
  4. Autonomy is earned by record. Irreversible actions unlock after the agent's predictions have proved right.
  5. The goal is evidence. "Done" is a fact in the world, never the agent's claim.

Those five are a control layer. They do not decide what to do next; they decide what the decision-maker may see, what counts afterwards, and when to stop. So the natural question is whether they can be wrapped around a real agent framework with a real model in the seat. This article is that experiment, on Microsoft Agent Framework.

Where the layer attaches

Agent Framework gives you three places to intercept: around the agent run, around each function call, and around each model call. The layer uses two of them.

Function-calling middleware sees every tool call with a next delegate to invoke the real function. That is exactly the before-and-after hook rules 1, 2 and 4 need:

AIAgent agent = new ChatClientAgent(client, new ChatClientAgentOptions
    {
        Name = Name,
        ChatOptions = new ChatOptions { Instructions = Instructions, Tools = WorldTools.For(world, control.Observe) },
    })
    .AsBuilder()
    .Use(control.OnFunctionCallAsync)
    .Build();

A delegating chat client rewrites the request before it leaves. That is rule 3: the model never receives the schema of a tool the layer distrusts.

public sealed class ToolFilteringChatClient(IChatClient inner, ControlLayer control) : DelegatingChatClient(inner)
{
    public override Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken ct = default)
    {
        control.BeginTick();                                   // one tick per model call
        return base.GetResponseAsync(messages, Filter(options), ct);
    }

    private ChatOptions? Filter(ChatOptions? options)
    {
        if (options?.Tools is null) return options;
        var filtered = options.Clone();
        filtered.Tools = options.Tools.Where(t => control.IsVisible(t.Name)).ToList();
        return filtered;
    }
}

The host loop supplies rule 5: it checks the task's goal predicate over the world's facts after every round, and the model's instructions say never to claim completion.

The middleware

Here is the heart of it, trimmed. Everything the model ever learns about a tool call is the string this method returns.

public async ValueTask<object?> OnFunctionCallAsync(
    AIAgent agent, FunctionInvocationContext context,
    Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next, CancellationToken ct)
{
    var name = context.Function.Name;
    var tool = _world.Tool(name);

    // Rule 3: a tool the model should not see is refused if it names it anyway.
    if (tool is null || !IsVisible(name))
    {
        Failures++;
        context.Terminate = Failures > Budget;
        return 
quot;REFUSED: {name} is not available. Choose among the tools offered."; } // Rule 4: a release action needs a record, or a person. if (tool.Kind == _task.ReleaseKind && !Self.Stage.MayDeploy) { if (Predictions.EarnedNextStage) Self.StageLevel++; else { Escalation =
quot;release requires human approval: recent predictions {Predictions.RecentAccuracy:P0}"; context.Terminate = true; // the decision is a human's now; the loop ends here return
quot;REFUSED: {name} is a release action and this agent has not earned it yet. Stop and report."; } } var result = await next(context, ct); var landed = _world.EffectsLanded(tool); // Rule 1 var claimedSuccess = LastOutcome?.Succeeded ?? true; Predictions.Record(landed); Self.RecordOutcome(tool.Id, landed); if (landed) return
quot;{result}\n\n[verified: {string.Join(", ", tool.Grants)} now hold]"; Failures++; if (!claimedSuccess) // An honest failure: the tool said it failed and it did. Costs budget and reliability, but it is not // a lie, so the tool stays visible — the oracle that reports red is still the oracle. return
quot;{result}\n\n[not verified: the tool reported failure]"; // Reported success, effect missing: a contradiction. Rule 2. Audit.Accept(
quot;{tool.Id} produces {string.Join(",", tool.Grants)}", false, ..., Tick); Quarantine.Add(tool.Id, Tick); return
quot;WARNING: {tool.Id} reported success but its effect did not land. It is distrusted for now; use a different route."; }

Two details in there were learned the hard way, and they are the real content of this article.

Lesson one: a refusal must stop the loop

The first version returned the refusal text and nothing else. With a dumb policy in the seat, the framework's own function-calling loop asked again, got refused again, and spun for forty-one ticks until the framework's iteration cap stopped it. A refusal returned from middleware does not by itself end the framework's loop. The fix is FunctionInvocationContext.Terminate, set on an escalation or an exhausted budget. The host then reports. Obvious in hindsight; invisible until a control run exposed it.

Lesson two: an honest failure is not a lie

The three designs in parts 1 to 3 treat any action whose effect did not land as a contradiction and quarantine the tool. On the toy worlds that is harmless. On real code it is not: a test runner that reports red is reporting the truth, and an agent that quarantines it has hidden its only oracle from itself exactly when it needs it. The layer distinguishes the two cases with one bit, whether the tool itself claimed success. Red is honest; "accepted" with no receipt is a lie.

The plumbing finding

Now the part that changed how I think about these comparisons. The benchmark stubs a module out of a production repository and asks the agent to rebuild it, graded by the repository's pristine tests. On the easy module, a routing rule with twelve tests, every agent and both models produced a real fix. On the hard one, a schema validator with seventy test cases, this happened:

model              levin      bach       hybrid     controlled (AF)
deepseek-chat      red  20    red  20    red  10    REAL  8
deepseek-reasoner  red   6    red   8    red   7    REAL  7

Only the Agent Framework agent passed, and it passed with both models. The per-cell notes say why, and the reason has nothing to do with Levin or Bach. The three designs talk to the model through a one-shot JSON protocol: eight lines of history, a 2,400-character test tail, a 4,096-token answer cap. The chat model inside the Levin design reached 67 of 70 tests and ran out of ticks, losing its oracle for three ticks every time a red run was treated as a lie. The reasoning model inside the three designs never wrote a file at all: its answers came back unparseable under that cap. The controlled agent had native tool calling, the whole conversation in context, and lesson two.

The decision layer's plumbing bounds what any architecture around it can show. On a hard task, compare agents only once their model plumbing is equal. It is a humbling result for a project about architectures, and it is the most useful thing the project produced.

What the framework gives you, and what it does not

For free: function middleware with a next delegate; a chat-client pipeline where a delegating client can rewrite the request; a built-in tool-approval flow that could replace the hand-written refusal for gated tools with a real human prompt; sessions, so the record can persist across runs. The whole layer is a few hundred lines and the framework is not forked.

Not for free, and never for free in any framework: the postconditions. Every tool needs a check the agent cannot author. Here it is the world's facts; in a real system it is a test suite, an HTTP status, a row in a registry. The framework cannot invent that. Neither can OpenClaw, Hermes or the Copilot SDK, all of which take the layer as a plugin or as host callbacks; the repository's docs/applying-elsewhere.md maps each one from its source.

Cloud or local

The layer does not care which model sits in the seat, and that is a design property, not an accident. The same middleware runs with a model from Azure AI Foundry, where the key stays on a server, or with a small model from Foundry Local on a laptop with no cloud at all. The benchmark runs used DeepSeek through an OpenAI-compatible endpoint. Swap the client, change nothing in the layer.

Try it

The code will be released later: I'm travelling in Asia, and the repository stays private until I'm back. The live demo runs the same agents today; the files and commands below are for when it is out.

  • Code: csharp/src/BiologicalAgents.Controlled/, four files. ControlLayer.cs is the middleware; ControlledAgent.cs is the host loop; ToolFilteringChatClient.cs is rule 3; PlannerChatClient.cs is an offline stand-in for the model so the tests need no key.
  • Run it offline beside the three designs, or with a model:
cd csharp
dotnet run --project src/BiologicalAgents.Controlled.Demo -- --compare
dotnet run --project src/BiologicalAgents.Controlled.Demo -- --policy deepseek

Next in the series: how the worlds are built so that the registry, not the agent, is the judge: part 6.