# Structured Output — Typed agent responses

> **Scope:** stacks=["dotnet"]
> **Layer:** 0 (always-load via persona maf-expert)
> **Keywords:** structured output, typed response, response format, schema validated output, record output, JSON schema agent, RunAsync generic
> **Read by Claude in:** implement (sempre que um agente produz saída consumida por outro sistema)

**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` **apenas** para o mapeamento de `AgentSpec.OutputSchema` → `ChatOptions.ResponseFormat = ChatResponseFormat.ForJsonSchema(...)`, que é o que o kit compila. `RunAsync<T>`, `Deserialize<T>` e os limites do `ResponseFormat` são **verificação documental**, com os nomes de tipo medidos por reflexão sobre `Microsoft.Extensions.AI.Abstractions` 10.9.0 em 2026-09-08. Last-verified: 2026-09-08.

---

## Por que sempre Structured Output

Texto livre de LLM é não-determinístico. Dois runs da mesma prompt podem retornar formatações diferentes, campos com nomes diferentes, ou dados em posições diferentes. Você não consegue plugar isso num pipeline downstream de forma confiável.

Um `sealed record` tipado é um contrato: o front-end, o banco, o próximo agente sabem exatamente a shape. O runtime MAF gera o JSON Schema a partir do tipo, envia para o modelo, e deserializa o retorno automaticamente. Se o modelo devolver algo fora do schema, a call falha rápido e com mensagem clara — muito melhor que descobrir um bug de parsing em produção.

**Regra prática:** 90%+ das chamadas a LLM em produção precisam de structured output. A única exceção é saída puramente textual para display final (ex: resposta de chatbot para o usuário). Se a saída vai ser lida por código, use `RunAsync<T>`.

---

## A API (MAF 1.0 GA)

### Form 1 — `RunAsync<T>` (recomendada)

```csharp
AgentResponse<CityInfo> response = await agent.RunAsync<CityInfo>("Provide information about Tokyo.");
CityInfo tokyo = response.Result;
```

O runtime MAF:
1. Gera o JSON Schema a partir de `T` automaticamente.
2. Seta `ResponseFormat` na chamada ao modelo.
3. Deserializa a resposta JSON diretamente em `.Result`.

**Use Form 1 por default.** É a forma mais simples e a que menos expõe detalhes de provider.

---

### Form 2 — `ChatResponseFormat.ForJsonSchema<T>()` (quando precisar de ChatOptions)

```csharp
AIAgent agent = _modelRegistry.GetChatClient("text-heavy").AsAIAgent(new ChatClientAgentOptions
{
    Name = "ProposalAgent",
    ChatOptions = new()
    {
        Instructions = "You are a commercial proposal specialist.",
        ResponseFormat = ChatResponseFormat.ForJsonSchema<ProposalDraft>()
    }
});

AgentResponse response = await agent.RunAsync("Draft a proposal for a SaaS migration project.");
ProposalDraft draft = response.Deserialize<ProposalDraft>(JsonSerializerOptions.Web);
```

**Use Form 2 quando:**
- Você precisa setar `ResponseFormat` junto com outras `ChatOptions` (ex.: `ModelId`, `MaxOutputTokens`, system prompt por invocação).
- Você quer reusar o mesmo agente para múltiplos tipos de saída, trocando o format por chamada via `ChatClientAgentRunOptions`.

> **Por que o exemplo não é `Temperature`.** Um alias de raciocínio não carrega temperatura, e em modelo de raciocínio ela é justamente a opção que não se envia — ver `ai-agents-providers-model-registry` §*temperature × reasoning*. Usar temperatura como exemplo de "outra `ChatOption` normal" ensina a colisão pela porta dos fundos.

**Setando ResponseFormat por invocação (sem recriar o agente):**

```csharp
var runOptions = new ChatClientAgentRunOptions
{
    ChatOptions = new() { ResponseFormat = ChatResponseFormat.ForJsonSchema<ProposalDraft>() }
};

AgentResponse response = await agent.RunAsync("Draft a proposal.", runOptions);
ProposalDraft draft = response.Deserialize<ProposalDraft>(JsonSerializerOptions.Web);
```

### Limites documentados do `ResponseFormat`

1. **Não aceita primitivo nem array na raiz.** `ChatResponseFormat.ForJsonSchema<string>()` ou `<List<Item>>` não é um schema de objeto válido para o provedor. Embrulhe:

   ```csharp
   // ERRADO: raiz é array
   // ResponseFormat = ChatResponseFormat.ForJsonSchema<List<ProposalItem>>()

   // CERTO: wrapper com a coleção como propriedade
   [Description("A list of proposal line items")]
   public sealed record ProposalItems
   {
       [JsonPropertyName("items")] public required IReadOnlyList<ProposalItem> Items { get; init; }
   }
   ```

2. **Em streaming, não desserialize os pedaços.** Um `ChatResponseUpdate` isolado é JSON incompleto. Monte a resposta primeiro e desserialize o todo:

   ```csharp
   AgentResponse full = await agent.RunStreamingAsync(prompt, session).ToAgentResponseAsync();
   ProposalDraft draft = full.Deserialize<ProposalDraft>(JsonSerializerOptions.Web);
   ```

3. **Bug conhecido com arrays — ISSUE ABERTA, não comportamento estável.** A issue **2874** do repositório `microsoft/agent-framework` relata falha de `ResponseFormat` com propriedades de array em certos provedores. Está **em aberto** na data desta verificação (2026-09-08): trate como armadilha a verificar no seu provedor, **não** como regra do framework. Se aparecer, o contorno é o wrapper do item 1 mais validação pós-call.

---

## Definindo o tipo de saída

Use `sealed class` ou `sealed record`. Regras:

1. `[Description("...")]` **na classe/record** — descreve ao modelo o que o objeto representa. Sem isso, o modelo depende só do nome do tipo.
2. `[JsonPropertyName("snake_case")]` **em CADA propriedade** — sem isso, o modelo pode retornar `camelCase`, `PascalCase`, ou `snake_case` dependendo do provider e da versão. Quando o provider muda, o desserializador quebra silenciosamente.
3. Tipos de propriedade devem ser **nullable** (`string?`, `int?`) em classes; em records posicionais use nullable quando o campo pode estar ausente.
4. Para listas: `IReadOnlyList<T>` — imutável por padrão, previne mutação acidental após desserialização.

**Exemplo realista — `ProposalDraft`:**

```csharp
using System.ComponentModel;
using System.Text.Json.Serialization;

[Description("A structured commercial proposal draft")]
public sealed record ProposalDraft(
    [property: JsonPropertyName("headline")] string Headline,
    [property: JsonPropertyName("client_pain_point")] string ClientPainPoint,
    [property: JsonPropertyName("sections")] IReadOnlyList<DeliverableSection> Sections,
    [property: JsonPropertyName("total_price")] decimal TotalPrice,
    [property: JsonPropertyName("validity_days")] int ValidityDays);

[Description("One deliverable section of a proposal")]
public sealed record DeliverableSection(
    [property: JsonPropertyName("title")] string Title,
    [property: JsonPropertyName("description")] string Description);
```

> Prefer `record` sobre `class` para tipos de saída — imutável, `==` por valor, `ToString()` útil para logs. Use `class` somente se o tipo precisar de lógica de mutação pós-desserialização.

---

## Exemplo C# completo

Um agente que recebe dados de cliente e retorna um `ProposalDraft` usando Form 1 (`RunAsync<T>`):

```csharp
using System.ComponentModel;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

// --- Tipos de saída ---

[Description("A structured commercial proposal draft")]
public sealed record ProposalDraft(
    [property: JsonPropertyName("headline")] string Headline,
    [property: JsonPropertyName("client_pain_point")] string ClientPainPoint,
    [property: JsonPropertyName("sections")] IReadOnlyList<DeliverableSection> Sections,
    [property: JsonPropertyName("total_price")] decimal TotalPrice,
    [property: JsonPropertyName("validity_days")] int ValidityDays);

[Description("One deliverable section of a proposal")]
public sealed record DeliverableSection(
    [property: JsonPropertyName("title")] string Title,
    [property: JsonPropertyName("description")] string Description);

// --- Agente ---

public sealed class ProposalDraftingAgent
{
    private readonly AIAgent _agent;

    public ProposalDraftingAgent(ModelRegistry modelRegistry)
    {
        _agent = modelRegistry.GetChatClient("text-heavy").AsAIAgent(new ChatClientAgentOptions
        {
            Name = "ProposalDraftingAgent",
            ChatOptions = new()
            {
                Instructions = """
                    You are a senior commercial proposal specialist.
                    Always produce a structured proposal based on the client context provided.
                    The total_price must reflect realistic market pricing for the scope described.
                    validity_days must be between 15 and 90.
                    """
            }
        });
    }

    public async Task<ProposalDraft> DraftAsync(string clientContext, CancellationToken ct = default)
    {
        AgentResponse<ProposalDraft> response = await _agent.RunAsync<ProposalDraft>(
            $"Draft a commercial proposal for the following client context:\n\n{clientContext}",
            cancellationToken: ct);

        return response.Result;
    }
}
```

**Consumindo `.Result`:**

```csharp
var agent = new ProposalDraftingAgent(modelRegistry);
ProposalDraft draft = await agent.DraftAsync("Client: RetailCo. Need: Migrate 5 legacy Java services to .NET 10.");

Console.WriteLine(draft.Headline);          // ex: "Modernization of RetailCo's Core Services"
Console.WriteLine(draft.TotalPrice);        // ex: 87500.00
Console.WriteLine(draft.Sections.Count);    // ex: 4
```

> **Model Registry alias `"text-heavy"`:** resolves to the project's configured high-quality model (ex: `gpt-4o`, `gemini-1.5-pro`, `claude-opus-4-5`). Nunca hardcode o nome do modelo — veja `ai-agents-providers-model-registry`.

---

## Validação pós-call

O JSON schema garante a **shape** da resposta, não a **semântica**. O modelo pode retornar um `TotalPrice` de `0` ou `ValidityDays` de `-1` e ainda conformar ao schema.

**Sempre valide regras de negócio depois de `RunAsync<T>`:**

```csharp
public async Task<Result<ProposalDraft>> DraftValidatedAsync(string clientContext, CancellationToken ct = default)
{
    AgentResponse<ProposalDraft> response = await _agent.RunAsync<ProposalDraft>(
        $"Draft a commercial proposal for:\n\n{clientContext}",
        cancellationToken: ct);

    ProposalDraft draft = response.Result;

    // Validação semântica — schema não cobre isso
    if (draft.TotalPrice <= 0)
        return Result.Failure<ProposalDraft>("Agent returned zero or negative price.");

    if (draft.ValidityDays is < 15 or > 90)
        return Result.Failure<ProposalDraft>($"ValidityDays {draft.ValidityDays} is out of range [15, 90].");

    if (draft.Sections is null || draft.Sections.Count == 0)
        return Result.Failure<ProposalDraft>("Agent returned proposal with no deliverable sections.");

    if (string.IsNullOrWhiteSpace(draft.Headline))
        return Result.Failure<ProposalDraft>("Agent returned proposal with empty headline.");

    return Result.Success(draft);
}
```

> Se a validação falhar, você pode re-prompt com o erro como contexto, ou retornar um domain error para o caller decidir. Nunca lance exception como fluxo de controle — use `Result<T>` ou equivalente.

---

## Anti-patterns

| Anti-pattern | Por quê é errado | Jeito certo |
|--------------|-----------------|-------------|
| Retornar texto livre e parsear com regex | LLM output é não-determinístico; regex quebra quando o modelo muda a formatação | Use `RunAsync<T>` com um `sealed record` tipado |
| Usar `dynamic` ou `JsonNode` em vez de um record tipado | Perde type-safety, IntelliSense e validação em compile time; erros surgem em runtime em produção | Defina um `sealed record` com `[JsonPropertyName]` em cada propriedade |
| Tipo de saída sem `[JsonPropertyName]` em cada propriedade | Quando o provider muda de versão ou o casing da resposta varia, `JsonSerializer` silenciosamente atribui `null` sem erro | Decore CADA propriedade com `[JsonPropertyName("snake_case")]` |
| Confiar apenas no JSON schema para garantir corretude | Schema valida shape, não semântica — `TotalPrice = 0` e `ValidityDays = -1` são válidos no schema | Sempre valide regras de negócio após `RunAsync<T>` e retorne `Result<T>` em caso de falha |
| Criar o `ChatClient` diretamente com model string hardcoded | Quebra troca de provider; o nome do modelo fica espalhado pelo codebase | Sempre use `_modelRegistry.GetChatClient("alias")` — veja `ai-agents-providers-model-registry` |

---

## Checklist (verifiable by morph-eval)

- [ ] Todo agente que alimenta outro sistema (front-end, banco, pipeline, outro agente) usa `RunAsync<T>` ou `ResponseFormat` — nenhum retorna `response.Text` para parsing downstream.
- [ ] O tipo de saída é um `sealed record` ou `sealed class` decorado com `[Description("...")]`.
- [ ] CADA propriedade do tipo de saída tem `[JsonPropertyName("...")]` explícito.
- [ ] Validação de regras de negócio existe após `RunAsync<T>` — o resultado é verificado além da shape do schema.
- [ ] Nenhum `ResponseFormat` com primitivo ou array na raiz — coleções vão dentro de um wrapper.
- [ ] Em streaming, a desserialização acontece depois de `ToAgentResponseAsync()`, nunca por update.
- [ ] O agente é construído a partir de `_modelRegistry.GetChatClient("alias")` — nenhum model name hardcoded.

---

## References

- `ai-agents-setup` — packages, DI, e setup mínimo de agente
- `ai-agents-sweet-spot` — quando usar agente, quando não usar
- `ai-agents-providers-model-registry` — como registrar e resolver modelos por alias
- `ai-agents-agent-session` — `RunStreamingAsync` numa sessão, e onde o histórico entra
- Microsoft Agent Framework docs: https://learn.microsoft.com/agent-framework/
- Issue aberta `microsoft/agent-framework#2874` — arrays com `ResponseFormat` (estado em 2026-09-08: **aberta**)

---

*MORPH-SPEC by Polymorphism Tech — ai-agents/structured-output.md v1.1 (2026-09-08)*
