Arquitetura De Continuação
O Que Significa Continuação
Continuação é o mecanismo que permite a uma conversa multi-turno preservar contexto entre interações separadas do usuário sem reenviar todo o histórico do chat a cada request.
Cada vez que o usuário envia uma mensagem, o MangoStudio precisa decidir como apresentar a conversa ao provedor. As três estratégias são:
| Estratégia | Exemplos de provedor | Trade-off |
|---|---|---|
| Cursor durável | OpenAI Responses, Gemini Interactions | Um ID de wire por turno. Barato, mas frágil: o cursor fica obsoleto se o provedor o invalidar. |
| Replay | OpenAI-compatible Chat Completions | Reenvia o histórico completo em todo turno. Robusto, mas caro com alto volume de tokens. |
| Loop local | Anthropic Messages, openai-compatible no turno | Acumula estado em memória dentro de um único request. Nunca persiste entre turnos. |
A decisão é tomada uma vez por turno, antes da primeira chamada ao provedor, e não pode mudar no meio do turno.
Três Conceitos De Estado
A arquitetura separa o estado em três preocupações distintas que nunca devem ser misturadas.
1. Estado de cursor durável (chats.lastProviderState)
Persistido na tabela chats após cada turno concluído. Carrega o ContinuationEnvelope, um wrapper tipado e versionado, do provedor que executou o último turno com sucesso.
{
"schemaVersion": 1,
"provider": "openai",
"mode": "responses",
"modelName": "gpt-4o",
"systemPromptHash": "abc123",
"toolsetHash": "def456",
"cursor": "resp_abc123"
}
Somente envelopes duráveis com modos responses ou interactions e cursor válido são armazenados aqui. Envelopes stateless-loop são filtrados por decideTurnPersistence e nunca tocam o banco.
chats.lastProviderState é a fonte exclusiva de continuação cross-turn. providerState no nível da mensagem nunca é lido para continuação.
2. Estado do loop local (providerState dentro de um único request)
Provedores que não suportam cursores duráveis, como Anthropic e openai-compatible, acumulam mensagens em providerState durante o loop de tool calls.
Esse estado é carregado entre iterações do mesmo turno agentic, mas nunca persiste como chats.lastProviderState. O orquestrador em stream-text-turn.ts devolve esse estado ao provedor em cada iteração, e decideTurnPersistence o descarta no momento de salvar.
{
"provider": "openai-compatible",
"mode": "stateless-loop",
"loopMessages": [
/* … */
]
}
3. Estado de auditoria da mensagem (messages.providerState)
Cada mensagem persistida carrega um campo opcional providerState que armazena o envelope no momento em que a mensagem foi criada. Isso serve apenas para auditoria e nunca deve ser usado para continuação.
Por quê:
providerStatede uma mensagem reflete o estado no instante em que o turno rodou, e esse estado pode ter sido substituído por turnos posteriores.- Usar cursores obsoletos pode ressuscitar estado interno do provedor que já foi invalidado, gerando erros 404 difíceis de diagnosticar e até corrupção lógica.
O orquestrador lê exclusivamente chats.lastProviderState.
Matriz De Provedores
| Provedor | Cursor durável | Campo wire | Replay builder | Estado cross-turn (chats.lastProviderState) |
Estado local |
|---|---|---|---|---|---|
| OpenAI (Responses) | sim | previous_response_id |
buildOpenAIResponsesReplay |
ContinuationEnvelope (modo responses) |
resultados de tools no request atual |
| Gemini (Interactions) | sim | previous_interaction_id |
buildGeminiInteractionsReplay |
ContinuationEnvelope (modo interactions) |
resultados de tools no request atual |
| OpenAI-compatible | não | nenhum | buildChatCompletionsReplay |
nenhum (sempre começa do zero) | apenas loopMessages |
| Anthropic | não | nenhum | replay direto de Messages | nenhum (sempre começa do zero) | apenas loopMessages |
| DeepSeek | não | nenhum | buildChatCompletionsReplay |
nenhum (sempre começa do zero) | apenas loopMessages |
Arquivos principais:
continuation-envelope.ts— schema, parse, validação e serialização do envelopecontinuation-runtime.ts— lógica de decisão e filtro de persistência do turnoreplay-builder.ts— implementações de replay por provedor
Ciclo De Vida Do Fallback
O ciclo de fallback tem quatro fases.
-
Validação —
decideContinuationverifica o envelope persistido em relação ao contexto atual do turno: provedor, modelo, hash do prompt de sistema e hash do toolset. Se algo mudou, o turno degrada. -
Degradação — Um evento
continuation_degradedé emitido com o código de motivo:provider_changed,model_changed,system_prompt_changed,toolset_changed,cursor_expired,cursor_invalidouenvelope_malformed. O orquestrador também emitefallback_noticeno SSE econtinuation_transitionpersistido em message parts. -
Replay — O request ao provedor envia o histórico completo do banco em vez de um cursor. Todos os provedores implementam esse caminho via replay builder.
-
Recuperação — Após um turno de replay bem-sucedido, provedores stateful geram um novo cursor. Os turnos seguintes retomam a continuação baseada em cursor.
Perda de cursor durante loop de tools ativo
Quando um cursor expira no meio do turno, entre iterações do loop de tools, a restrição de segurança é:
- Se a iteração atual já possui
toolResultsem voo, o turno aborta com motivotool_result_cursor_loss. Fazer replay a partir do histórico descartaria silenciosamente resultados de tools ainda não persistidos, produzindo resposta incorreta. - Se a iteração atual não possui resultados de tools, o provedor tenta novamente usando replay completo.
Essa lógica fica em responses-stream.ts e interactions-stream.ts.
Eventos De Timeline E Visibilidade Para O Usuário
Eventos de degradação de continuação são expostos por dois canais:
| Canal | Tipo | Finalidade |
|---|---|---|
| SSE em tempo real | fallback_notice |
Toast/status indicator no frontend |
| Message parts persistidos | continuation_transition |
Revisão do histórico mostra o que aconteceu |
| Logs | console.warn |
Debug operacional |
O part continuation_transition carrega recovered: false durante o turno e é alterado para recovered: true quando o turno termina com sucesso.
Snapshots De Contexto E Compactação
Depois que cada turno termina, o orquestrador calcula um ContextSnapshot que descreve o uso de tokens de entrada:
estimatedInputTokens— estimativa local, em torno de 4 chars/token, ou valor reportado pelo provedorcontextLimit— janela máxima de contexto de entrada do modeloestimatedUsageRatio— fração do contexto já consumidaseverity—normal,info,warning,dangeroucritical
Limiares definidos em context-policy.ts:
- 0-69% normal
- 70-84% info
- 85-91% warning
- 92-96% danger
- 97%+ critical
O snapshot é persistido como chats.lastContextState e usado pelo frontend para alertar usuários antes que excedam a janela de contexto do modelo.
Compactação no lado do provedor
Provedores stateful como OpenAI e Gemini suportam compactação no servidor por meio de:
- OpenAI: parâmetro
context_managementcomcompact_threshold - Gemini: compactação de interações embutida
A compactação fica habilitada por padrão e pode ser desativada por request via contextSettings.providerCompactionEnabled.
Continuação específica do DeepSeek
O DeepSeek usa estratégia de continuação turn-local. Regras principais:
- Sem cursor durável — O DeepSeek não tem cursor server-side. Todo turno do usuário começa do zero com replay completo do histórico persistido.
reasoning_contentem loops de tools — Quando o DeepSeek emite tool calls durante um turno com thinking habilitado, oreasoning_contentacumulado precisa ser incluído na mensagem do assistente enviada de volta ao provedor na iteração seguinte. Omissão causa erros HTTP 400.- Sem reasoning cross-turn —
reasoning_contenté removido da mensagem final quando não há tool calls pendentes. Nunca persiste entre turnos do usuário, o que também preserva a estabilidade do prefixo para cache hits. - Métricas de cache —
promptCacheHitTokensepromptCacheMissTokenssão extraídos da resposta de uso da API DeepSeek e armazenados nos metadados do provider state para observabilidade. - Prefixo estável — Prompt de sistema, definições de tools em ordem determinística e histórico persistido mantêm um prefixo estável entre turnos para maximizar cache hits de contexto no DeepSeek.
Modos Comuns De Falha E Onde Depurar
| Sintoma | Causa provável | Onde olhar |
|---|---|---|
Todo turno começa com degrade_to_replay |
chats.lastProviderState é nulo ou inválido |
Verifique a linha do chat no DB e logs [continuation][clear] |
cursor_expired no meio do turno com tools |
Cursor expirou durante execução de tools | Handler de erro de cursor em responses-stream.ts ou interactions-stream.ts |
| Mismatch de provedor em toda troca | lastProviderState guarda provedor antigo |
Esperado: o primeiro turno após trocar sempre degrada |
| Modelo errado foi chamado | resolve-model.ts escolheu outro modelo |
Verifique modelId no topo de stream-text-turn.ts |
| Replay envia mensagens erradas | replay-builder.ts formatou incorretamente |
Testes unitários em tests/unit/services/providers/replay-builder.test.ts |
stateless-loop vazando entre turnos |
loopMessages persistido como durável |
Filtro decideTurnPersistence; deve retornar null para modos stateless |
Decisão Central De Design: Estado Em Chats Vs Mensagens
O sistema diverge explicitamente de uma abordagem ingênua onde o providerState da mensagem mais recente é usado para continuação. Isso é intencional:
chats.lastProviderStateé reativo — é atualizado atomicamente com a conclusão do turno e nunca referencia um cursor substituído.messages.providerStateé imutável — registra como o cursor estava quando a mensagem foi criada, para depuração e trilha de auditoria.
Misturar os dois criaria edge cases em que um cursor de uma cadeia de mensagens deletada ou substituída seria ressuscitado, levando a erros do provedor.