Skip to content

Ciclo de vida da SessionState

A sessão é o estado vivo de uma conversa ativa. Ela nasce quando o primeiro usuário manda uma mensagem, evolui a cada interação, transita entre modos e morre por inatividade ou encerramento explícito.


O que é uma sessão

SessionState é um objeto JSON armazenado no Redis com TTL. Cada sessão representa uma conversa ativa entre um contato e um canal — nunca há duas sessões ativas para o mesmo par (phoneNumberId, contactPhone).

typescript
type SessionState = {
  // Identidade
  mode: "flow" | "human" | "agent"
  routerId: string
  flowId?: string
  contactId: string        // ID no Postgres
  chatId: string           // ID no Postgres

  // Temporalidade
  startedAt: number
  lastInteractionAt: number
  metaWindowExpiresAt: number  // lastInteractionAt + 24h
  expiryTimeout: number        // TTL de inatividade em ms

  // Execução do flow
  currentBlockId?: string      // bloco aguardando input do usuário
  currentBlockEnteredAt?: number  // quando a sessão entrou no bloco atual
  lastInput?: string | null       // último texto do contato, preservado entre blocos
  variables: Record<string, string | string[] | null>

  // Contexto de sistema (preenchidos automaticamente)
  source?: "whatsapp" | "webchat" | "instagram"  // canal de origem
  contactIdentity?: string                    // = sessionId (ex: "wa-123-5511...")
  currentFlow?: { id: string; name: string }  // flow onde a sessão está parada
  currentBlock?: { id: string; name: string } // bloco onde a sessão está parada
  previousBlock?: { id: string; name: string } // bloco visitado antes do currentBlock
  sessionHistory?: Array<{                    // histórico de paradas em InputBlocks
    flow: { id: string; name: string }
    block: { id: string; name: string }
  }>

  // Estados especializados
  agentState?: AgentState           // presente apenas quando mode="agent" (bloco de agente IA)
  helpdesk?: { queueId?: string; ticketId?: string }
  frozenByError?: SessionFrozenError
  redirectHistory?: Array<{ flowId: string; blockId: string }>

  // Canal WhatsApp
  whatsApp: { contact: { name: string; phoneNumber: string } }
  lastIncomingMessageId?: string
}

A chave no Redis é session:{sessionId}, onde sessionId é derivado deterministicamente do canal:

  • WhatsApp: wa-{phoneNumberId}-{contactPhone}
  • WebChat: wc-{channelId}-{userId}
  • Instagram: ig-{igUserId}-{senderIgsid}

Criação

A sessão é criada pelo flow-ai-orchestrator quando uma mensagem chega e nenhuma sessão existe no Redis para aquele sessionId.

Fluxo de criação:

  1. Orchestrator recebe IncomingStreamEvent com sessionId.
  2. Tenta getSession(sessionId) → Redis retorna null.
  3. Resolve routerId e initialFlowId via cache wa:router:{phoneNumberId}.
  4. Cria SessionState com:
typescript
{
  mode: "flow",
  routerId,
  flowId: initialFlowId,
  contactId,
  chatId,
  startedAt: Date.now(),
  lastInteractionAt: Date.now(),
  metaWindowExpiresAt: Date.now() + META_WINDOW_MS,  // + 24h
  expiryTimeout: sessionExpiryMs,                     // do router (fallback DEFAULT_SESSION_TTL_MS = 30 min)
  currentBlockId: undefined,   // engine vai preencher no primeiro run
  variables: {},
  whatsApp: { contact: { name, phoneNumber } },
}
  1. Serializa para JSON e grava no Redis via setSession(sessionId, json, ttlMs) com SET PX.

Os campos source e contactIdentity são preenchidos na criação. currentFlow, currentBlock, previousBlock e sessionHistory são preenchidos pelo engine na primeira parada de InputBlock.

Por que a sessão não é criada no banco? A sessão muda a cada mensagem. Persistir no Postgres adicionaria latência de escrita em cada interação. O Redis fornece sub-milissegundo com TTL automático. O Postgres armazena o histórico duradouro (mensagens, tickets) — a sessão é estado volátil e efêmero.


Dois TTLs independentes

A sessão tem dois conceitos de expiração que coexistem:

CampoValorControlado por
expiryTimeoutTTL de inatividade (ms)Renovado a cada mensagem via setSession(..., ttlMs)
metaWindowExpiresAttimestamp fixo (lastInteraction + 24h)Atualizado pelo orchestrator a cada mensagem

expiryTimeout (TTL de inatividade): Renovado a cada mensagem. Se o usuário ficar 30 minutos sem interagir, o Redis apaga a chave automaticamente. Na próxima mensagem, uma nova sessão é criada do zero.

Modo da sessãoValor do expiryTimeout
"flow"settings.sessionExpiryMs do flow (fallback 30 min)
"agent"AgentBlock.inputTimeout ?? settings.sessionExpiryMs ?? 30 min
"human"max(tempo restante da janela Meta, 60 minutos)
"human" com frozenByErrormax(tempo restante da janela Meta, 60 minutos)

Em modo "human", o TTL é estendido porque a janela Meta define por quanto tempo ainda é possível contatar o usuário — perder a sessão antes disso significaria perder o contexto do atendimento humano em andamento.

Em modo "agent", o TTL vem do inputTimeout configurado no AgentBlock (ou do sessionExpiryMs do flow como fallback) — o engine também arma um timer de inatividade com esse valor para encerrar conversas de agente abandonadas.

metaWindowExpiresAt (janela Meta de 24h): A Meta permite enviar mensagens gratuítas para um usuário por 24h após o último contato. Após esse período, apenas templates são permitidos. O sistema rastreia essa expiração para tomar decisões (ex: fechar ticket automaticamente quando send_failed com código 131026).


Atualização durante execução

O flow-ai-engine atualiza a sessão após cada execução:

  • currentBlockId: sempre atualizado para o bloco em que o engine parou aguardando input.
  • variables: atualizado por ações SetContextVariableAction e saveInputAs dos blocos.
  • lastInteractionAt + metaWindowExpiresAt: atualizados pelo orchestrator em cada mensagem recebida, antes do engine executar.
  • mode, helpdesk, agentState, frozenByError, redirectHistory: atualizados pelo engine conforme transições.

O engine nunca persiste a sessão parcialmente — ele carrega o estado, executa toda a lógica, e grava o estado final de uma vez. Isso evita estados inconsistentes se o processo morrer no meio da execução (a entry ficará na pending list e será reprocessada com o estado original).

Índice de sessões ativas (sessions:active:{routerId}): a cada gravação de sessão, o orchestrator mantém um sorted set por router com o sessionId das sessões em mode: "flow" (score = lastInteractionAt). Sessões que entram em mode: "human" ou "agent" são removidas do índice via removeActiveSession; sessões em "flow" são reinseridas via upsertActiveSession. Isso permite ao monitoramento enumerar sessões ativas de um router sem varrer todas as chaves do Redis.


Transições de modo

flow → human (handoff para atendimento humano)

Ocorre quando o engine entra num bloco HumanAttendanceBlock durante walkBlocks():

typescript
// engine/runtime/execute.ts
session.mode = "human"
const remainingWindow = session.metaWindowExpiresAt - Date.now()
session.expiryTimeout = Math.max(remainingWindow, HUMAN_SESSION_MIN_TTL_MS)  // mín 1h
await publishHandoff(ctx)  // → stream:helpdesk

O core (flow-ai-core) consome o handoff, resolve a fila de atendimento via regras de distribuição, cria o ticket e grava session.helpdesk = { ticketId, queueId } na sessão.

engine
  → session.mode = "human"
  → stream:helpdesk (HelpdeskHandoffEvent)
      → core cria Ticket
      → core grava session.helpdesk
      → core notifica desk via Socket.io

flow → agent (bloco de agente IA)

Ocorre quando o engine entra num AgentBlock durante walkBlocks(). O engine muda o modo, semeia um agentState esqueleto e devolve o agentEvent no RunResult — a publicação em stream:agent só acontece depois de setSession(), para o agente nunca ler uma sessão desatualizada:

typescript
// engine/runtime/execute.ts (walkBlocks)
session.mode = "agent"
session.expiryTimeout = agentBlock.inputTimeout ?? settings.sessionExpiryMs ?? DEFAULT_SESSION_TTL_MS
session.agentState = {
  activeAgentId: agentBlock.agentId,
  agentChain: [{ agentId: agentBlock.agentId, returnToParent: false }],
  messages: [],
  contactContextSnapshot: "",
  configSnapshot: { agentId: agentBlock.agentId, systemPrompt: "", model: "", provider: "", guardrails: [], maxMessages: agentBlock.maxMessages },
  turnCount: 0,
}
return { deleted: false, agentEvent }  // handler publica em stream:agent APÓS setSession()

O configSnapshot sai daqui como esqueleto (strings vazias) — quem preenche systemPrompt, model, provider e guardrails a partir do cache agent:{agentId} é o flow-ai-agent ao iniciar a execução. A partir daí, enquanto mode: "agent", o orchestrator roteia as mensagens do contato direto para stream:agent (em vez de stream:flow).

engine (AgentBlock)
  → session.mode = "agent" + agentState semeado
  → setSession()
  → stream:agent (AgentStreamEvent)
      → flow-ai-agent roda o loop de tool-calling do LLM
      → preenche configSnapshot + acumula messages/turnCount

agent → flow (encerramento da sessão de agente)

Ocorre quando o agente encerra a conversa (tool endAgentSession), ou quando o monitoramento fecha o ticket de IA manualmente. O flow-ai-agent (ou o core, via publishAgentSessionEnded()) publica AgentSessionEndedEvent em stream:flow:

typescript
xadd(STREAMS.FLOW, {
  payload: JSON.stringify({ kind: "agentSessionEnded", sessionId, ... })
})

O engine consome e executa runFlowFromAgentSessionEnded():

typescript
session.mode = "flow"
session.expiryTimeout = activeFlow.definition.settings.sessionExpiryMs  // TTL configurado no flow
delete session.agentState
// avalia outputConditions do AgentBlock e navega — continua em walkBlocks()

Nota: assim como no humanSessionEnded, o evento pode trazer um jumpTarget para reposicionar a sessão num flow/bloco específico em vez de avaliar as outputConditions do AgentBlock.

agent → human (handoff a partir do agente)

Um agente pode transferir o atendimento para um humano sem passar de volta pelo flow. A tool transferToHuman (chamada pelo LLM) e o handoff manual disparado pelo monitoramento — AgentManualHandoffEvent (kind: "manualHandoff") publicado em stream:agent — compartilham a mesma transição performHumanHandoff no flow-ai-agent:

typescript
// flow-ai-agent/src/tools/actions.ts (performHumanHandoff)
session.mode = "human"
delete session.agentState
// fecha o ticket de IA + publica HelpdeskHandoffEvent em stream:helpdesk
// (com presetQueueId/presetUserId quando informados)

O contato entra então na fila de helpdesk com o contexto da sessão, seguindo o mesmo fluxo do handoff flow → human.

human → flow (encerramento do atendimento)

Ocorre quando o agente fecha ou encaminha o ticket no desk:

typescript
// core/helpdesk/publishHumanSessionEnded.ts
xadd(STREAMS.FLOW, {
  payload: JSON.stringify({ kind: "humanSessionEnded", sessionId, ... })
})

O engine consome FlowStreamHumanSessionEndedEvent e executa runFlowFromHumanSessionEnded():

typescript
session.mode = "flow"
session.expiryTimeout = activeFlow.definition.settings.sessionExpiryMs  // TTL configurado no flow
delete session.helpdesk
ctx.currentInput = null
// roda outputActions + outputConditions do HumanAttendanceBlock
// continua em walkBlocks()

Nota: o evento pode carregar um jumpTarget (usado pelo "finalizar e direcionar" do monitoramento). Nesse caso o engine reposiciona a sessão no flow/bloco indicado via runFlowFromSessionEndedWithJump() em vez de rodar as outputConditions do HumanAttendanceBlock.

flow → frozen (erro de runtime)

Quando o engine encontra um erro irrecuperável (bloco inexistente, loop detectado, exception em executeScript, etc.):

typescript
// session.frozenByError é preenchido
{
  message: "...",
  blockId: "blk_abc",
  phase: "outputActions",  // onde o erro ocorreu
  occurredAt: Date.now(),
}

Além de gravar frozenByError na sessão, o engine:

  1. Persiste SessionRuntimeError no Postgres (para histórico e diagnóstico).
  2. Adiciona o sessionId ao sorted set sessions:frozen no Redis (para monitoramento via GET /sessions/frozen).
  3. Publica uma mensagem de erro para o contato. A mensagem é configurável e interpolável por action (errorMessage, suportando {{contact.name}}, {{errorVariable}}, {{session.*}}, etc.); sem valor, usa o DEFAULT_RUNTIME_ERROR_MESSAGE ("Encontramos um problema técnico…"). Erros estruturais (loop, bloco inexistente, hop limit) sempre usam a mensagem default.
  4. Transiciona mode = "human" e publica handoff.

flow → exception (action blocking sem handoff)

As actions executeScript/httpCall/executeTool/callEndpoint aceitam errorHandoff: false. Nesse caso, ao falhar em modo blocking, o engine não congela a sessão: envia a errorMessage (interpolada) e redireciona para o defaultExceptionBlockId do flow via redirectSignal, seguindo a execução em mode = "flow" a partir do bloco de exceção (sem ticket de helpdesk, sem entrada em sessions:frozen). Um guard de re-entrância (redirectHopCount / MAX_REDIRECT_HOPS) protege contra o caso de a própria action de exceção falhar — ao estourar, degrada para freeze + handoff.

frozen → flow (desbloqueio pelo agente)

Quando o agente fecha o ticket de uma sessão frozen, o mesmo evento humanSessionEnded é publicado. O engine detecta frozenByError em runFlowFromHumanSessionEnded():

typescript
if (session.frozenByError) {
  delete session.frozenByError
  session.mode = "flow"
  session.expiryTimeout = activeFlow.definition.settings.sessionExpiryMs  // TTL configurado no flow
  delete session.helpdesk
  // navega para defaultExceptionBlockId (bloco de recuperação configurado no flow)
}

O desbloqueio pode também ser feito manualmente via POST /sessions/:sessionId/unfreeze com action: "resume" (aplica overrides de flowId, currentBlockId, variables) ou action: "delete" (apaga a sessão para recomeçar do zero).


Diagrama de estados

                    ┌─────────────────────────────┐
                    │         [INEXISTENTE]        │
                    │   Redis não tem a chave      │
                    └──────────────┬──────────────┘
                                   │ primeira mensagem do usuário
                                   │ (orchestrator cria com mode=flow)

        AgentBlock       ┌─────────────────────────────┐
    ┌──────────────────► │           [FLOW]             │ ──────► [EXPIRADA]
    │                    │  mode: "flow"                │   TTL   (chave some
    │   agentSessionEnded│  TTL: sessionExpiryMs        │ expirado do Redis)
    │   (endAgentSession)│  (renovável)                 │
    │  ┌──────────────── └──────────────┬──────────────┘
    │  │                                │
    │  ▼                       HumanAttendanceBlock
 ┌───────────────────────┐              ▼
 │        [AGENT]         │   ┌────────────────────────┐
 │  mode: "agent"         │   │        [HUMAN]         │
 │  agentState + loop LLM │──►│  mode: "human"         │
 │  no flow-ai-agent      │   │  TTL: max(janela,1h)   │
 └────────────────────────┘  └──────────┬─────────────┘
   transferToHuman /                     │         │
   handoff manual           agente fecha │         │ erro de runtime no engine
                            ticket        │         ▼
                                          │  ┌────────────────────────────┐
                                          │  │         [FROZEN]            │
                                          │  │  mode: "human"              │
                                          │  │  frozenByError preenchido   │
                                          │  │  no sorted set sessions:frozen │
                                          │  └────────────┬───────────────┘
                                          │               │ agente fecha ticket
                                          │               │ (desbloqueio)
                                          │               ▼
                                          └──────► [FLOW] em defaultExceptionBlockId
                                                   ou DELETE (restart manual)

O nó [EXPIRADA] representa qualquer modo cuja chave session:{sessionId} expirou no Redis — não só mode: "flow". Uma sessão em "agent" ou "human" também some quando o TTL correspondente estoura sem renovação.


Encerramento

A sessão é removida do Redis em dois cenários:

  1. TTL expirado: o Redis apaga automaticamente a chave após expiryTimeout ms sem renovação. Próxima mensagem do usuário cria nova sessão.
  2. Bloco endAttendance: o engine retorna { deleted: true } e o handler chama deleteSession(sessionId) explicitamente.

Não existe "encerramento gracioso" com evento publicado — a sessão simplesmente desaparece do Redis. O histórico permanece intacto no Postgres (Chat + Messages + Tickets).


Variáveis de sistema no builder

O engine expõe campos da sessão como tokens de interpolação {{session.*}} prontos para uso em textos, condições e configurações de blocos. Esses tokens não precisam ser definidos pelo builder — eles já existem automaticamente.

TokenValorDisponível a partir de
{{session.source}}Canal de origem: whatsapp, webchat ou instagramCriação da sessão
{{session.contactIdentity}}Identificador composto do contato (wa-... ou wc-...)Criação da sessão
{{session.currentFlow.id}}ID do flow onde a sessão está paradaPrimeira parada em InputBlock
{{session.currentFlow.name}}Nome do flow onde a sessão está paradaPrimeira parada em InputBlock
{{session.currentBlock.id}}ID do bloco onde a sessão está paradaPrimeira parada em InputBlock
{{session.currentBlock.name}}Título do bloco onde a sessão está paradaPrimeira parada em InputBlock
{{session.previousBlock.id}}ID do bloco visitado antes do atualSegunda parada em InputBlock (ausente na primeira)
{{session.previousBlock.name}}Título do bloco visitado antes do atualSegunda parada em InputBlock (ausente na primeira)
{{session.sessionHistory}}JSON com o histórico de paradas (mais recente primeiro)Primeira parada em InputBlock
{{session.flowId}}ID do flow em execuçãoSempre
{{session.routerId}}ID do router da sessãoSempre
{{session.blockId}}ID do bloco atualSempre
{{session.mode}}Modo atual da sessão (flow / human / agent)Sempre
{{session.chatId}}ID do Chat no PostgresSempre

sessionHistory: Array JSON ordenado do mais recente para o mais antigo. Cada entrada tem { flow: { id, name }, block: { id, name } }. Limite configurável via FlowSettings.sessionHistoryLimit (default: 20 entradas).

previousBlock: Atualizado toda vez que o engine entra em um bloco diferente do currentBlockId anterior — inclusive em blocos com título prefixado por #, já que não segue a convenção de exclusão do sessionHistory. Útil para fluxos de exceção/fallback que precisam saber de onde o usuário veio.

Convenção #: Blocos cujo title começa com # não são adicionados ao sessionHistory. Use esse prefixo em blocos utilitários ou de controle que não devem aparecer no histórico de navegação do usuário (ex: #verificar-plano, #rota-interna). O currentBlock ainda é atualizado normalmente para esses blocos.


Campos especializados

redirectHistory

Pilha usada pela action returnToFlow para saber de onde voltar após um redirectToBot. Cada entrada contém o flowId e blockId de origem, empilhada pelo redirectToBot e desempilhada (um pop) pelo returnToFlow. A pilha pode acumular mais de 5 entradas ao longo de várias execuções. O que limita a 5 é o redirectHopCount — um contador por execução (zerado a cada run do engine): ao atingir MAX_REDIRECT_HOPS = 5, o redirectToBot lança e a sessão congela, protegendo contra loops entre flows dentro de um mesmo run.

agentState

Presente apenas quando mode: "agent" — ou seja, enquanto o flow está pausado num AgentBlock e o flow-ai-agent conduz a conversa com o LLM. Campos:

  • activeAgentId — agente de IA em execução.
  • agentChain: AgentChainEntry[] — pilha de retorno multi-agente; o último elemento é o agente atual, e um pop ocorre ao voltar para o agente pai (transferToAgent com returnToOrchestrator).
  • messages: AgentMessage[] — histórico de mensagens (mantido para logging/monitoramento).
  • previousResponseId? — ID da última resposta da Responses API, encadeado como previous_response_id no turno seguinte.
  • contactContextSnapshot — snapshot de contact.context no início da sessão.
  • configSnapshot: AgentConfig — snapshot imutável da config do agente (systemPrompt, model, provider, guardrails, maxMessages). O engine o semeia como esqueleto (strings vazias); o flow-ai-agent preenche a partir do cache agent:{agentId}.
  • turnCount — número de turnos completos (mensagem do contato + resposta do agente).
  • ticketId? — ticket de IA criado para a sessão (único, mantido em transferências entre agentes).
  • sentiment? / sentimentHistory? — sentimento atual e histórico, quando a análise perRound está habilitada.

Isso permite que o agente de IA retome uma conversa de múltiplos turnos entre interações do usuário e sobreviva a transferências entre agentes especialistas.

variables

Dicionário livre de chave-string → valor. Preenchido por:

  • SetContextVariableAction (ação explícita do flow)
  • saveInputAs no InputConfig de um bloco (salva o que o usuário digitou)
  • executeScript que escreve em ctx.variables

Essas variáveis são locais à sessão — não persistem no banco e expiram com a sessão.


Arquivos relevantes

ArquivoPapel
packages/flow-ai-types/src/session.tsDefinição de SessionState, SessionFrozenError, AgentState, AgentConfig (o index.ts apenas reexporta)
packages/flow-ai-types/src/ai-agents.tsAgentStreamEvent, AgentSessionEndedEvent, AgentManualHandoffEvent, AgentStreamPayload
packages/flow-ai-redis/src/session.tsgetSession, setSession, deleteSession
packages/flow-ai-redis/src/frozen-sessions.tsmarkSessionFrozen, unmarkSessionFrozen, listFrozenSessionIds (sorted set sessions:frozen)
packages/flow-ai-redis/src/active-sessions.tsupsertActiveSession, removeActiveSession, listActiveSessionIds, pruneStaleActiveSessions (sorted set sessions:active:{routerId})
services/flow-ai-orchestrator/src/handlers/incoming.tsCriação e retomada de sessão; roteamento por modo (stream:flow / stream:helpdesk / stream:agent) e manutenção do índice de ativos
services/flow-ai-orchestrator/src/constants.tsDEFAULT_SESSION_TTL_MS, META_WINDOW_MS
services/flow-ai-engine/src/handlers/flow.tsCarregamento, execução e persistência da sessão; publicação do agentEvent após setSession()
services/flow-ai-engine/src/runtime/execute.tsTransições de modo, runFlowFromAgentSessionEnded, runFlowFromHumanSessionEnded, freezeAndReturn, walkBlocks
services/flow-ai-engine/src/constants.tsHUMAN_SESSION_MIN_TTL_MS, DEFAULT_RUNTIME_ERROR_MESSAGE
services/flow-ai-core/src/helpdesk/sessionState.tssetCurrentHelpdeskTicket
services/flow-ai-core/src/helpdesk/publishHumanSessionEnded.tsPublicação de humanSessionEnded
services/flow-ai-core/src/helpdesk/publishAgentSessionEnded.tsPublicação de agentSessionEnded (fechamento manual do ticket de IA)
services/flow-ai-core/src/http/routes/session.routes.tsGET /frozen, POST /:sessionId/unfreeze, GET /:sessionId, PATCH /:sessionId/variables, POST /:sessionId/redirect

flow-ai — plataforma proprietária de atendimento via WhatsApp