# Middleware Patterns — Intercepting agent behavior

> **Scope:** stacks=["dotnet"]
> **Layer:** 1 (on-keyword)
> **Keywords:** middleware, agent middleware, function calling middleware, retry, anti-hallucination, resilience, telemetry middleware, agent run middleware
> **Read by Claude in:** implement (when wiring cross-cutting behavior into an agent)

**Verified against:** Microsoft.Agents.AI 1.20.0 + Microsoft.Extensions.AI 10.9.0 (ai-pin 2026-09-08); `provado por templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactory.cs` para a **ordem do pipeline** — a fábrica do kit é o único lugar que conhece essa ordem, e ela é medida por teste, não herdada de folclore. As assinaturas de `Use(...)` foram **medidas por reflexão sobre `AIAgentBuilder` e `ChatClientBuilder`** em 2026-09-08. Last-verified: 2026-09-08.

---

## Os 3 níveis de middleware

| Tipo | Intercepts | Use Case |
|------|-----------|----------|
| **Agent Run** | Invocação completa do agent | Logging, auth, rate limiting |
| **Function Calling** | Chamadas individuais de tools | Error handling, validação, auditoria |
| **IChatClient** | Chamadas brutas ao LLM | Telemetry, caching, content filtering |

Cada nível tem uma assinatura de delegate distinta. Os três podem coexistir no mesmo agent — cada um atua na sua camada sem interferir nos outros.

---

## Registrando middleware

O registration point é sempre `.AsBuilder().Use(...).Build()`. Encadeie múltiplos middlewares antes de chamar `Build()`.

```csharp
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

// Obter o IChatClient via Model Registry — provider-agnostic
IChatClient chatClient = _modelRegistry.GetChatClient("text-default");

// IChatClient-level middleware (antes de criar o agent)
IChatClient instrumentedClient = chatClient
    .AsBuilder()
        .Use(getResponseFunc: TelemetryMiddleware, getStreamingResponseFunc: null)
    .Build();

// Agent Run + Function Calling middleware
AIAgent agent = instrumentedClient
    .AsAIAgent(instructions: "You are a helpful assistant.")
    .AsBuilder()
        .Use(runFunc: LoggingMiddleware, runStreamingFunc: null)
        .Use(ResilientToolMiddleware)   // function calling middleware
    .Build();
```

### As assinaturas, medidas

Ambas as sobrecargas de dois delegates existem no pin (reflexão em 2026-09-08):

- `AIAgentBuilder.Use(Func<...> runFunc, Func<...> runStreamingFunc)`
- `ChatClientBuilder.Use(Func<...> getResponseFunc, Func<...> getStreamingResponseFunc)`

Cada builder tem ainda `Use(factory)` (um delegate que embrulha o inner) e `Use(sharedFunc)` (**um** delegate que atende os dois modos). O `Use(sharedFunc)` é a resposta certa quando o middleware não se importa com streaming: escreve-se uma vez e vale para os dois caminhos.

> **Fornecer só `runFunc`/`getResponseFunc` faz o caminho de streaming rodar NÃO-streaming.** Não é erro de compilação e não há aviso: a chamada streaming é atendida pelo delegate não-streaming e o consumidor recebe a resposta inteira de uma vez, tarde. Numa UI de chat isso aparece como "o streaming parou de funcionar depois que adicionamos telemetria". Se o middleware não precisa distinguir os modos, use `Use(sharedFunc)`; se precisa, forneça **os dois**.

### A ordem do pipeline, medida

A posição de cada elo não é estética. A ordem que o ai-kit compila e testa, de fora para dentro:

```
OpenTelemetryAgent            span invoke_agent — cobre a invocação inteira
 └─ ChatClientAgent           (UseProvidedChatClientAsIs = true: o pipeline abaixo é seu)
     ├─ timeout               TimeoutSeconds vira CancellationToken
     ├─ usage-cost            vê UMA resposta com o uso já somado das voltas
     ├─ FunctionInvoking      MaxToolIterations / AllowConcurrentToolInvocation
     ├─ telemetria de chat    span chat, ABAIXO do FICC
     └─ cliente do provider   memoizado pelo ModelRegistry
```

**Por que o middleware de custo fica ACIMA do `FunctionInvokingChatClient` (FICC):** acima dele, o middleware vê **uma** resposta cujo `Usage` já traz o total de todas as voltas de tool (medido: duas voltas de 10/4 e 30/6 chegam como `in=40, out=10`) e publica **uma** linha por chamada. Abaixo dele veria respostas parciais — e leria o mesmo objeto `UsageDetails` que o **próprio FICC muta** para acumular, o que faz a mesma conta dar resultado diferente conforme a hora de ler.

**Por que a telemetria de chat fica ABAIXO do FICC:** só assim o span `chat` fecha antes das tools e o `invoke_agent` fica sendo a raiz.

> No `ChatClientBuilder`, **o primeiro registrado é o mais externo**. Registrar o custo antes de `UseFunctionInvocation` é o que o coloca acima do FICC.

---

## Pattern: telemetria

IChatClient-level middleware que mede latência e token usage de cada chamada bruta ao LLM.

```csharp
using System.Diagnostics;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

async Task<ChatResponse> TelemetryMiddleware(
    IEnumerable<ChatMessage> messages,
    ChatOptions? options,
    IChatClient innerChatClient,
    CancellationToken cancellationToken)
{
    var sw = Stopwatch.StartNew();
    var response = await innerChatClient.GetResponseAsync(messages, options, cancellationToken);
    sw.Stop();
    Console.WriteLine($"LLM call: {sw.ElapsedMilliseconds}ms, Tokens: {response.Usage?.TotalTokenCount}");
    return response;
}
```

Para o setup completo de OpenTelemetry (traces, métricas, Aspire Dashboard, os **dois** nomes de `ActivitySource`), consulte `ai-agents-observability-patterns`, que é o dono canônico. Não duplique aqui — este middleware é um ponto de entrada, não a solução completa.

> **Correção de 2026-09-08:** `AgentOpenTelemetryConsts` **não existe** em `Microsoft.Agents.AI` 1.20.0 (medido). As fontes reais são `Experimental.Microsoft.Agents.AI` e `Experimental.Microsoft.Extensions.AI`.

---

## Pattern: resilência (retry + rate-limit)

Function Calling middleware que intercepta erros de tools antes que o agent receba exceções brutas. Devolver uma string descritiva ao agent permite que ele se autocorrija ou informe o usuário adequadamente.

```csharp
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

async ValueTask<object?> ResilientToolMiddleware(
    AIAgent agent,
    FunctionInvocationContext context,
    Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next,
    CancellationToken cancellationToken)
{
    try
    {
        return await next(context, cancellationToken);
    }
    catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
    {
        return "Service is temporarily busy. Please try again in a moment.";
    }
    catch (TimeoutException)
    {
        return $"Tool '{context.Function.Name}' timed out. Try a simpler query.";
    }
    catch (Exception ex)
    {
        return $"Error in {context.Function.Name}: {ex.Message}";
    }
}
```

Template completo com registro e stub de anti-hallucination: `templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template`.

---

## Pattern: anti-hallucination

O LLM gera os argumentos de tool call baseado em linguagem natural — pode gerar formatos inválidos (datas malformadas, IDs fora de range, strings vazias). Validar **antes** de chamar `next()` permite que o agent se autocorrija na próxima iteração, sem precisar de retry externo.

```csharp
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

async ValueTask<object?> AntiHallucinationMiddleware(
    AIAgent agent,
    FunctionInvocationContext context,
    Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next,
    CancellationToken cancellationToken)
{
    // Validate 'date' argument before calling the tool
    if (context.Arguments.TryGetValue("date", out var dateArg)
        && !DateTime.TryParse(dateArg?.ToString(), out _))
    {
        return $"Invalid date format for '{context.Function.Name}'. Expected ISO 8601 (e.g. 2026-01-31).";
    }

    // Validate 'orderId' is a positive integer
    if (context.Arguments.TryGetValue("orderId", out var idArg)
        && (!int.TryParse(idArg?.ToString(), out var id) || id <= 0))
    {
        return $"Invalid orderId '{idArg}' for '{context.Function.Name}'. Must be a positive integer.";
    }

    return await next(context, cancellationToken);
}
```

**Princípio:** devolva uma mensagem de erro descritiva — o LLM vai corrigir o argumento na próxima iteração. Não lance exceção; não chame `next()` com argumento inválido.

---

## Decision tree

```
Precisa de middleware no agent?
│
├─ Logar / autorizar a invocação completa do agent?
│   └─ Agent Run middleware
│       .Use(runFunc: MyMiddleware, runStreamingFunc: null)
│
├─ Tratar falhas de tool / validar argumentos do LLM?
│   └─ Function Calling middleware
│       .Use(ResilientToolMiddleware)
│
└─ Medir / cachear / filtrar chamadas brutas ao LLM?
    └─ IChatClient middleware
        .Use(getResponseFunc: TelemetryMiddleware, getStreamingResponseFunc: null)
        — aplicado no IChatClient.AsBuilder() ANTES de .AsAIAgent()
```

---

## Anti-patterns

| Anti-pattern | Problema |
|--------------|---------|
| Swallow exception sem retornar contexto ao agent | O agent não consegue se autocorrigir — para silenciosamente |
| Middleware que muta estado global | Viola isolation; causa race conditions em requests concorrentes |
| Fornecer apenas middleware não-streaming | Chamadas streaming rodam em modo não-streaming silenciosamente — downgrade imperceptível |
| Lógica de negócio em middleware | Middleware é cross-cutting only; lógica de domínio pertence ao tool ou ao handler |
| Registrar middleware depois de `Build()` | `Build()` finaliza o pipeline — chamadas após não têm efeito |

---

## Checklist (verifiable by morph-eval)

- [ ] Middleware registrado via `.AsBuilder().Use(...).Build()` — nunca depois de `Build()`
- [ ] IChatClient middleware aplicado no `chatClient.AsBuilder()` antes de `.AsAIAgent()`
- [ ] Function Calling middleware retorna string descritiva em vez de lançar exceção
- [ ] Anti-hallucination: argumentos validados antes de chamar `next()`
- [ ] Nenhum middleware com `using Microsoft.SemanticKernel` — MAF 1.0 GA não usa SK
- [ ] Streaming: se nenhum middleware streaming for fornecido, o downgrade para não-streaming está documentado como decisão consciente — ou o middleware usa `Use(sharedFunc)`
- [ ] A posição de cada elo é intencional: custo **acima** do `FunctionInvokingChatClient`, telemetria de chat **abaixo** dele
- [ ] Middleware que chama serviços externos tem timeout configurado

---

## References

- `ai-agents-setup` — pacotes, DI, quick start
- `ai-agents-production` — panorama completo: observability, A2A, MCP, caching, middleware-as-one-of-many-concerns
- `ai-agents-providers-model-registry` — `_modelRegistry.GetChatClient(alias)`, provider-agnostic pattern
- `ai-agents-observability-patterns` — dono canônico de spans, fontes e duplicação
- Reference implementation da ordem do pipeline (compila e é testada contra o pin): `templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactory.cs` + `Middleware/UsageCostMiddleware.cs`

---

*MORPH-SPEC by Polymorphism Tech — ai-agents/middleware-patterns.md v2.0 (2026-09-08)*
