Middleware — MAF in Go

A guardrail that blocks a run before the model, chained ahead of a logger — and the upstream nil-update panic I fixed along the way.

This is post 6 of 12 in Learning the Microsoft Agent Framework — Go, where I learn the framework by building one runnable lesson per concept — and where I hit the edge that turned into my first upstream fix. Tools let the agent act; sessions let it remember. Middleware is the seam around the run: the standard place for logging, tracing, and guardrails that never touch the agent’s logic.

A middleware is anything with a Run method

In Go there are no decorators, so a middleware is any value implementing agent.Middleware — a Run method that receives the next link in the chain, the context, the messages, and options, and returns the response stream. The Go 1.23 iterator shows up here directly: the return type is iter.Seq2[*agent.ResponseUpdate, error].

func (guardrailMiddleware) Run(
    next agent.RunFunc,
    ctx context.Context,
    messages []*message.Message,
    options ...agent.Option,
) iter.Seq2[*agent.ResponseUpdate, error] {
    for _, msg := range messages {
        if strings.Contains(strings.ToLower(msg.String()), "harmful") {
            return func(yield func(*agent.ResponseUpdate, error) bool) {
                yield(&agent.ResponseUpdate{
                    Role:     message.RoleAssistant,
                    Contents: message.Contents{&message.TextContent{Text: "I can't help with harmful requests."}},
                }, nil)
            }
        }
    }
    return next(ctx, messages, options...) // pass through
}

Blocking vs. passing through is one branch

That method is the whole idea in one place. return next(ctx, messages, options...) is a pass-through — hand the rest of the chain back untouched. Returning an iterator that yields your own *agent.ResponseUpdate short-circuits the chain: the model is never called. The guardrail refuses a harmful message with a canned response, offline, with no network at all.

Two ways to be a middleware

The guardrail is a struct implementing Run directly. When a middleware needs state, agent.MiddlewareFunc adapts a method value instead. Here a run counter logs the newest user message on the way in, then passes every update through unchanged:

func (rc *runCounter) run(next agent.RunFunc, ctx context.Context,
    messages []*message.Message, options ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] {
    rc.n++
    fmt.Printf("===== Run %d =====\n", rc.n)
    return next(ctx, messages, options...) // transparent
}

func (rc *runCounter) Middleware() agent.Middleware { return agent.MiddlewareFunc(rc.run) }

Order is wiring order

Both middlewares go into agent.Config.Middlewares, applied outermost-first. Wiring the guardrail before the logger means a blocked request stops at the guardrail — it never reaches the logger’s downstream or the model:

foundryprovider.AgentConfig{
    Instructions: "You are good at telling jokes.",
    Config: agent.Config{
        Name:        "Joker",
        Middlewares: []agent.Middleware{guardrailMiddleware{}, runLogger().Middleware()},
    },
}
sequenceDiagram
    participant You as main()
    participant G as guardrail
    participant L as logger
    participant F as Foundry (model)
    You->>G: RunText("something harmful")
    G-->>You: yield refusal — next() NOT called (no model)
    You->>G: RunText("a joke about a pirate")
    G->>L: next(...) (safe → pass through)
    L->>F: next(...) → POST /responses
    F-->>You: answer

The upstream fix: a nil-update panic in tool-approval middleware

Middleware is exactly where I found my first real SDK edge. The tool-approval middleware — the seam that pauses a run for human sign-off — assumed every *agent.ResponseUpdate it received from next was non-nil. When an inner stream yielded a nil update (which a legitimate provider can do at a stream boundary), the middleware dereferenced it and panicked, taking down the whole run instead of skipping the empty update. I reproduced it against a fake next that yields nil, added the guard, and sent it upstream as PR #472 to microsoft/agent-framework-go. Small fix, but it’s the kind you only find by reading the middleware chain closely and driving it with a hostile next — which is exactly what the offline test does here.

What the offline test proves

The test builds the agent with a fake credential, asserts both middlewares are wired in order, then drives each Run directly with a next that fails the test if called — so a harmful message must yield its refusal without ever invoking the model, and a safe one must pass a sentinel straight through. No Foundry, no tokens, just the wiring and the branch. That same “drive it with a fake next” discipline is how the nil-update panic became reproducible.

Middleware is the composable seam around every run. Next I open the box wider: telemetry, safety, and swapping the provider underneath.


Next: Observability, Safety, and Providers — MAF in Go

Sources & References

Concept overview of the middleware chain as the extension point for logging, tracing, and guardrails.
Upstream Go SDK — the agent.Middleware / MiddlewareFunc interface, and the tool-approval nil-update panic fixed in PR #472.