# Agent Session — de onde vem o histórico de conversa

> **Scope:** stacks=["dotnet"]
> **Layer:** 2 (on-keyword)
> **Keywords:** agent session, chat history, conversation state, chat reducer, history truncation, ChatHistoryProvider, conversa própria, multi-tenant
> **Read by Claude in:** implement

**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/Conversation/ConversationStore.cs` (a subclasse de `ChatHistoryProvider`, a invariante de instância única e o teste de duas sessões concorrentes). Nomes de tipo e membros medidos por reflexão sobre `Microsoft.Agents.AI.Abstractions` 1.20.0 em 2026-09-08. Last-verified: 2026-09-08.

---

## O critério — de onde o histórico deve vir

**Primeiro o critério, depois o veredito.** Existem três fontes possíveis de histórico e elas não são intercambiáveis:

| Situação | Fonte de histórico |
|---|---|
| A conversa carrega **campos de negócio** além das mensagens (usage por turno, título, dossiê, takeover humano, estado de funil) | **Conversa própria** — tabela/`jsonb` do projeto, exposta por um `ChatHistoryProvider` seu |
| O histórico precisa ser **visível e manipulável pela aplicação** (retry, remontagem, auditoria, edição, exclusão a pedido do titular) | **Conversa própria** |
| Multi-tenant com isolamento por chave composta e retenção sob controle do projeto | **Conversa própria** |
| O histórico é **só** a lista de mensagens, sem campo próprio, e o projeto não precisa lê-lo fora do agente | `AgentSession` + um `ChatHistoryProvider` embutido |
| Protótipo, CLI, demo, teste | `InMemoryChatHistoryProvider` |

**O veredito da casa: conversa própria como default.** Isso não é preferência — é a aplicação do critério acima aos projetos que existem. GHLBrain, MORPH_OS e ProspectPRO caem todos nas três primeiras linhas.

### Os três fundamentos, cada um citável

1. **ADR D3 do GHLBrain.** O estado da conversa vive em `conv_checkpoints`; `ToolAgentRunner.cs:126-130` roda *"sem AgentThread/sessão"* — porque o **retry depende de o histórico ser visível e manipulável**: para repetir uma volta que falhou é preciso remontar as mensagens, e não dá para remontar o que está dentro de um objeto opaco. Registrado em `docs/specs/elevacao-9/brutos/scan-ghlbrain.md:58` e `:599`.
2. **Doc oficial de self-hosting** (learn.microsoft.com/en-us/agent-framework/hosting/self-hosting/, `ms.date` 2026-08-17), verbatim: *"MAF doesn't include a general-purpose durable session store"*. Produção exige implementação própria. A mesma página recomenda, em conversa longa, um `ChatHistoryProvider` externo **com mensagens por linha** em vez de reescrever a sessão inteira a cada turno.
3. **Padrão do canal** (Rasmus, parte 06 — "Conversations"): objeto de conversa próprio do domínio, com **um** mapper para `ChatMessage`. A conversa é entidade de negócio; o formato do provedor é detalhe de transporte.

### Ligação com a Responses API

Com `store: true` (o default da Responses API) **o histórico foge para o provedor** e passa a existir em dois lugares. Quem usa conversa própria mantém **`store: false`** — ver `ai-agents-setup` §*`store: false` é a regra da casa*, incluindo o atalho `AsIChatClientWithStoredOutputDisabled`.

### Quando NÃO há histórico nenhum

- Agente one-shot (gerador, executor, classificador) — recebe uma entrada, devolve uma saída, fim.
- Agentes stateless em pipeline multi-agent — cada chamada é independente.
- Jobs de batch/background — não há conversa.

> *"Add Memory/Session because agents need state"* é um anti-pattern de `sweet-spot.md`.
> Só adicione histórico quando o critério acima aponta para ele.

---

## A API

**Pacote:** `Microsoft.Agents.AI` 1.20.0 (base; já requerido por `ai-agents-setup`).

**Tipos e membros — medidos por reflexão sobre as assemblies do pin em 2026-09-08:**

| Tipo | Onde | O que é |
|---|---|---|
| `AgentSession` | `Microsoft.Agents.AI.Abstractions` | Container de estado da conversa. Abstrato; tem `StateBag`. Criado por `agent.CreateSessionAsync()` |
| `ChatClientAgentSession` | `Microsoft.Agents.AI` | A `AgentSession` do `ChatClientAgent`; acrescenta `ConversationId` (o id gerenciado pelo serviço, quando há) |
| `ChatHistoryProvider` | `Microsoft.Agents.AI.Abstractions` | Classe **abstrata** a estender. Pontos de override: `ProvideChatHistoryAsync(InvokingContext, ct)` e `StoreChatHistoryAsync(InvokedContext, ct)` (ambos `protected`). Declare também `StateKeys` |
| `ProviderSessionState<TState>` | `Microsoft.Agents.AI.Abstractions` | Estado **por sessão**, com `GetOrInitializeState(session)` / `SaveState(session, state)` e um `StateKey` |
| `InMemoryChatHistoryProvider` | `Microsoft.Agents.AI.Abstractions` | Implementação embutida. `sealed`. Aceita `ChatReducer` e `ReducerTriggerEvent` |
| `IChatReducer`, `MessageCountingChatReducer`, `SummarizingChatReducer` | `Microsoft.Extensions.AI` | Redutores de histórico |
| `ChatClientAgentOptions` | `Microsoft.Agents.AI` | Aceita `ChatHistoryProvider`, `AIContextProviders`, e as três flags de conflito (`Throw/Warn/ClearOnChatHistoryProviderConflict`) |
| `agent.SerializeSessionAsync(session, …)` / `agent.DeserializeSessionAsync(JsonElement, …)` | `AIAgent` | Serialização da sessão. **São `…Async` e devolvem `ValueTask`** — não existe forma síncrona |

> **Correções contra a versão anterior deste standard, todas medidas:** `AgentThread` e `GetNewThread()` **não existem** em `Microsoft.Agents.AI` 1.20.0 (renomeados para `AgentSession` / `CreateSessionAsync` ainda no ciclo pré-GA, `preview.260128.1` e `preview.260205.1`); `CosmosChatHistoryProvider` **não está** em `Microsoft.Agents.AI` nem em `Microsoft.Agents.AI.Abstractions` 1.20.0 — se existir, é de outro pacote, e este standard não afirma qual.

### A forma de um provider de conversa própria

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

public sealed class ConversationStore : ChatHistoryProvider
{
    public const string ConversationIdStateKey = "app.conversation.id";

    private readonly IConversationStore _store;                      // o acervo do PROJETO
    private readonly ProviderSessionState<string> _conversationId;   // estado POR SESSÃO

    public ConversationStore(IConversationStore store)
    {
        ArgumentNullException.ThrowIfNull(store);
        _store = store;
        _conversationId = new ProviderSessionState<string>(NewConversationId, ConversationIdStateKey);
    }

    // Declara a chave que este provider ocupa no estado da sessão — é o que permite
    // ao MAF serializar e retomar uma sessão sem adivinhar o que é de quem.
    public override IReadOnlyList<string> StateKeys => [ConversationIdStateKey];

    protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(
        InvokingContext context, CancellationToken cancellationToken = default)
        => await _store.LoadAsync(GetConversationId(context.Session!), cancellationToken);

    protected override async ValueTask StoreChatHistoryAsync(
        InvokedContext context, CancellationToken cancellationToken = default)
    {
        // Volta que falhou não é gravada: persistir o pedido sozinho deixaria a conversa
        // terminando numa pergunta que o modelo nunca respondeu.
        if (context.InvokeException is not null) return;

        List<ChatMessage> round = [.. context.RequestMessages ?? [], .. context.ResponseMessages ?? []];
        if (round.Count == 0) return;

        // Grava SÓ esta volta. Regravar o histórico inteiro a cada turno faz a conversa
        // crescer em progressão geométrica.
        await _store.AppendAsync(GetConversationId(context.Session!), round, cancellationToken);
    }

    public string GetConversationId(AgentSession session)
        => _conversationId.GetOrInitializeState(session);

    private static string NewConversationId(AgentSession? session)
        => session is ChatClientAgentSession { ConversationId.Length: > 0 } managed
            ? managed
            : Guid.CreateVersion7().ToString("n");   // ordenável no tempo; NewGuid fragmenta índice
}
```

### A invariante de instância — o erro que só aparece em produção

**O provider é UMA instância compartilhada por todas as sessões.** Ele não pode ter campo de estado por conversa. O único estado por sessão vive em `ProviderSessionState<TState>`, **dentro da `AgentSession`**.

Um campo de instância guardando "a conversa atual" é como o histórico de um usuário aparece na tela de outro — e a falha **não** aparece em teste de uma sessão só: aparece sob concorrência, em produção, como vazamento de dado alheio. O canário é um teste com **duas sessões concorrentes** sobre o **mesmo** provider.

### Histórico embutido, quando o critério aponta para ele

```csharp
AIAgent agent = _modelRegistry.GetChatClient("text-default")
    .AsAIAgent(new ChatClientAgentOptions
    {
        ChatOptions = new() { Instructions = "You are a helpful assistant." },
        ChatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
        {
            ChatReducer = new MessageCountingChatReducer(20),
            ReducerTriggerEvent = ChatReducerTriggerEvent.AfterMessageAdded,
        }),
    });
```

---

## Exemplo C# — agente conversacional com session persistente entre turnos

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

// ── 1. Registrar o agente conversacional no DI ──
// Program.cs
builder.Services.AddSingleton<IConversationStore, PostgresConversationStore>();
builder.Services.AddSingleton<ChatHistoryProvider>(sp =>
    new ConversationStore(sp.GetRequiredService<IConversationStore>()));   // UMA instância

builder.Services.AddSingleton<AIAgent>(sp =>
{
    var registry = sp.GetRequiredService<ModelRegistry>();
    return registry.GetChatClient("text-default")
        .AsAIAgent(new ChatClientAgentOptions
        {
            ChatOptions = new() { Instructions = "You are a helpful customer support assistant." },
            ChatHistoryProvider = sp.GetRequiredService<ChatHistoryProvider>(),
        });
});

// ── 2. Uma session por conversa (por conexão SignalR, por "start chat") ──
public class ChatService(AIAgent _agent, ConversationStore _history)
{
    public async Task<AgentSession> StartConversationAsync(string conversationId)
    {
        var session = await _agent.CreateSessionAsync();
        _history.BindConversation(session, conversationId);   // a conversa JÁ EXISTE como registro
        return session;
    }

    public async Task<string> SendMessageAsync(AgentSession session, string userMessage)
    {
        AgentResponse response = await _agent.RunAsync(userMessage, session);
        return response.Text;
    }

    // Retomar depois de um restart: a sessão é dado, não objeto vivo.
    public async Task<AgentSession> ResumeAsync(JsonElement serialized)
        => await _agent.DeserializeSessionAsync(serialized);
}

// ── 3. Uso ──
AgentSession session = await chatService.StartConversationAsync(conversation.Id);
string reply1 = await chatService.SendMessageAsync(session, "Meu pedido #1234 está atrasado.");
string reply2 = await chatService.SendMessageAsync(session, "E quando foi despachado?");
// O contexto do segundo turno veio do ACERVO DO PROJETO — legível por relatório, por auditoria,
// e apagável a pedido do titular.
```

---

## History management

O histórico cresce a cada turno. Quando estoura o context window do modelo, as chamadas falham ou truncam silenciosamente.

| Estratégia | Quando usar | Como implementar |
|------------|-------------|------------------|
| **Sliding window** | Conversas longas com contexto recente suficiente | `MessageCountingChatReducer(n)` no `InMemoryChatHistoryProvider`, ou um `LIMIT` na query do provider próprio |
| **Token budget** | Modelos com context window pequeno | Contar tokens acumulados no provider próprio e truncar |
| **Summarization** | Histórico precisa de contexto antigo comprimido | `SummarizingChatReducer`, ou compaction (`ai-agents-context-providers` §Compaction) |

**Com conversa própria, a truncation é query, não middleware.** `ProvideChatHistoryAsync` devolve o que a query devolveu — `ORDER BY created_at DESC LIMIT n` já é o sliding window, e é auditável no banco. `IChatReducer` existe para o caminho embutido.

**Grave só a volta corrente.** `StoreChatHistoryAsync` recebe `RequestMessages` + `ResponseMessages` **daquela** volta; regravar o histórico completo a cada turno é crescimento geométrico do acervo.

---

## Anti-patterns

| Anti-pattern | Problema |
|-------------|----------|
| **Estado por sessão em campo do provider** | O provider é **uma** instância para todas as sessões. Um campo `_currentConversation` faz o histórico de um usuário aparecer para outro. Estado por sessão vive em `ProviderSessionState<TState>`, com `StateKey` declarado em `StateKeys`. A falha não aparece em teste de uma sessão só |
| **`ConversationId` / `previous_response_id` como fronteira de autorização** | Esses ids são escopados à **chave da API**, não ao usuário: quem tem o id e a chave lê a conversa. Mapeie o id do cliente para o id do serviço e **verifique o dono** antes de retomar |
| Adicionar histórico em agente one-shot (gerador, executor) | Complexidade desnecessária; estado nunca é usado |
| Histórico ilimitado sem truncation | Context window estoura; custo aumenta linearmente com a conversa |
| Compartilhar uma `AgentSession` entre múltiplos usuários | Histórico de um usuário vaza para outro |
| Usar `InMemoryChatHistoryProvider` sem criar uma `AgentSession` por conversa | A isolation depende de `AgentSession` — sempre uma session por conversa, via `agent.CreateSessionAsync()` |
| Gravar a volta que falhou | Sem resposta, a conversa termina numa pergunta não respondida; a volta seguinte a repete e o usuário se vê duplicado |
| Deixar `store` ligado tendo histórico próprio | Duas cópias da conversa, uma delas fora do seu controle de retenção |

> **Nota de migração (histórico, não API viva):** `AgentThread` → `AgentSession` e `GetNewThread()` → `CreateSessionAsync()` aconteceram **antes** do GA de 2026-04-02. Código escrito contra os nomes antigos não compila com o pin. Não são alternativas: são nomes mortos.

---

## Checklist (verifiable by morph-eval)

- [ ] A escolha da fonte de histórico foi feita pela tabela de critério — e está escrita em `decisions.md` quando não foi a conversa própria
- [ ] O provider de histórico é registrado como **uma** instância compartilhada
- [ ] Nenhum campo de instância do provider guarda estado de conversa; o que é por sessão está em `ProviderSessionState<TState>` e declarado em `StateKeys`
- [ ] Existe teste com **duas sessões concorrentes** sobre o mesmo provider
- [ ] Uma `AgentSession` por conversa/usuário — nunca compartilhada
- [ ] `ConversationId` nunca é usado como prova de autorização; o dono é verificado antes de retomar
- [ ] `store: false` quando o histórico é próprio
- [ ] Estratégia de truncation definida (query com `LIMIT`, reducer ou summarization)
- [ ] Agentes one-shot (gerador, executor, classificador) **não** recebem histórico
- [ ] Nenhuma ocorrência de `AgentThread` / `GetNewThread()` no código

---

## References

- `ai-agents-setup` — packages, DI base, `store: false`
- `ai-agents-sweet-spot` — decisão de quando adicionar estado ao agente
- `ai-agents-context-providers` — a mesma invariante de instância, e compaction
- `ai-agents-middleware-patterns` — pipeline e middleware
- Reference implementation (compila contra o pin): `templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/ConversationStore.cs`
- MAF Conversations / storage: https://learn.microsoft.com/en-us/agent-framework/agents/conversations/storage (`ms.date` 2026-07-01)
- MAF self-hosting: https://learn.microsoft.com/en-us/agent-framework/hosting/self-hosting/ (`ms.date` 2026-08-17)

---

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