Skip to content

Agentes de IA — Arquitetura e comunicação

Agentes de IA são entidades LLM (Large Language Model) que substituem o flow determinístico em trechos de atendimento que exigem raciocínio em linguagem natural. O sistema suporta múltiplos agentes em cadeia — um orquestrador roteia para especialistas e pode receber de volta o controle quando o especialista encerra.

Este guia descreve a arquitetura funcional completa: como uma mensagem do contato (WhatsApp ou WebChat) chega ao agente, como o executor constrói e executa o loop de tool calling, como múltiplos agentes se comunicam e como o controle retorna ao flow determinístico. O contrato inbound é WhatsApp-shaped: AgentStreamEvent.messages é sempre WhatsAppIncomingMessage[] e phoneNumberId/to servem de slots genéricos de transporte para os demais canais.


Posicionamento no sistema

Agentes de IA são introduzidos como um novo tipo de bloco no flow builder — o AgentBlock — análogo ao HumanAttendanceBlock. O flow determinístico continua funcionando normalmente até pausar em um AgentBlock, que entrega o controle a um agente LLM configurado.

contato envia mensagem


flow-ai-meta-api (valida HMAC, normaliza)
     │  stream:incoming

flow-ai-orchestrator (cria/atualiza SessionState)
     │  stream:flow (mode="flow") ou stream:agent (mode="agent")

flow-ai-engine (walkBlocks → AgentBlock)
     │  stream:agent (AgentStreamEvent)

flow-ai-agent (loop LLM + tool calls)
     │  stream:outgoing-meta / -chat  →  gateway  →  usuário
     │  stream:agent-turns / stream:llm-usage (telemetria)
     │  stream:helpdesk (cria/fecha ticket de IA; handoff → humano)
     │  stream:flow (AgentSessionEndedEvent)

flow-ai-engine (retoma walkBlocks após AgentBlock)

O flow-ai-agent é um serviço dedicado, sem HTTP — não é o engine. Ele consome stream:agent (grupo agent, consumer único e serial) e roda o loop de tool calling do LLM. É separado do engine para não bloquear os workers do engine durante chamadas I/O ao LLM, que podem levar segundos, e para serializar os turnos por sessão. Dependências principais: @anthropic-ai/sdk + openai + flow-ai-runtime + Prisma.

Nota (KNOWN DRIFT — canal de saída): o flow-ai-agent escolhe o stream de saída por prefixo do sessionId: wc-*stream:outgoing-chat, caso contrário stream:outgoing-meta (services/flow-ai-agent/src/tools/response.ts:27). Não há branch para ig-* — um agente respondendo numa sessão Instagram publica em stream:outgoing-meta (canal errado). WhatsApp e WebChat funcionam corretamente.


Tipos TypeScript relevantes

AgentBlock

Definido em packages/flow-ai-types/src/flow-definition.ts:221.

typescript
type AgentBlock = {
  type: "agent"
  id: string
  title?: string
  position?: BlockPosition
  tagIds?: string[]
  agentId: string          // ID do AiAgent configurado
  maxMessages: number      // limite de turnos antes de forçar encerramento
  inputTimeout?: number    // TTL de inatividade em ms (usa expiryTimeout da sessão se ausente)
  outputConditions: OutputCondition[]
  defaultOutput: BlockTarget
}

AgentState — extensão da SessionState

Definido em packages/flow-ai-types/src/session.ts:54.

typescript
type AgentState = {
  activeAgentId: string          // agente executando agora
  agentChain: AgentChainEntry[]  // pilha de retorno; último = agente atual
  messages: AgentMessage[]       // histórico local da sessão (para log/debug)
  previousResponseId?: string    // ID do último response OpenAI (stateful API)
  contactContextSnapshot: string
  configSnapshot: AgentConfig     // { agentId, systemPrompt, model, provider, guardrails[], maxMessages }
  turnCount: number              // número de turnos consumidos (global na cadeia)
  ticketId?: string              // ticket de IA da sessão — único, mantido entre transferências
  sentiment?: SentimentResult    // sentimento atual (só com perRound habilitado)
  sentimentHistory?: Array<{ turn: number; sentiment: string; score: number; updatedAt: number }>
}

type AgentChainEntry = {
  agentId: string
  returnToParent: boolean   // se true, retorna ao agente pai ao encerrar
}

AgentState vive dentro de SessionState.agentState. Quando mode="agent", este campo está presente. Ao retornar para mode="flow" (ou mode="human" num handoff), é deletado. SessionMode = "flow" | "human" | "agent" — não existe modo "frozen".

AgentStreamEvent e AgentStreamPayload

stream:agent carrega uma união AgentStreamPayload = AgentStreamEvent | AgentManualHandoffEvent (packages/flow-ai-types/src/ai-agents.ts:80). O consumer distingue pela presença do campo kind: sem kindAgentStreamEvent (start/resume, via handle); kind: "manualHandoff"AgentManualHandoffEvent (via handleManualHandoff).

typescript
type AgentStreamEvent = {
  sessionId: string
  chatId: string
  to: string                // phoneNumber do contato (E.164 sem "+")
  phoneNumberId: string
  agentId: string
  maxMessages: number
  messages: WhatsAppIncomingMessage[]  // vazio em transferências proativas
  handoffContext?: string              // contexto construído pelo remetente
  lastUserInput?: string               // última mensagem do usuário
  timestamp: number
}

// Publicado pelo flow-ai-core para forçar handoff manual de uma sessão em mode:"agent"
type AgentManualHandoffEvent = {
  kind: "manualHandoff"
  sessionId: string
  chatId: string
  to: string
  phoneNumberId: string
  queueId?: string   // vira presetQueueId no handoff
  userId?: string    // vira presetUserId no handoff
  reason?: string    // registrado em agentHandoffReason
  timestamp: number
}

AgentSessionEndedEvent

typescript
type AgentSessionEndedEvent = {
  kind: "agentSessionEnded"
  sessionId: string
  chatId: string
  to: string
  phoneNumberId: string
  // Quando presente, reposiciona a sessão neste flow/bloco em vez de rodar as
  // outputConditions do AgentBlock. Usado pelo "finalizar e direcionar".
  jumpTarget?: { flowId: string; blockId: string }
  timestamp: number
}

Tipos de AiAgent

O modelo AiAgent (packages/flow-ai-database/prisma/schema.prisma:1283) tem um campo type validado na app layer via Zod: "orchestrator" | "specialist" | "system".

  • orchestrator / specialist — agentes de fluxo, executados pelo flow-ai-agent neste guia. O executor não ramifica por type; a distinção é organizacional (o orquestrador tende a ter rotas para especialistas).
  • system — agentes de sistema (co-pilotos do builder, ex. prompt-helper, script-generator, identificados pelo campo task). Não passam pelo AgentBlock/stream:agent — pertencem a outro subsistema.

Fluxo completo de uma mensagem

Fase 1 — Roteamento pelo orchestrator

Quando o orchestrator processa uma mensagem e a sessão está em mode="agent":

session.mode === "agent"


publica em stream:agent (em vez de stream:flow)

O orchestrator não processa o conteúdo da mensagem — apenas roteia para o stream correto com base no mode.

Fase 2 — Engine: entrada no AgentBlock

Quando walkBlocks encontra um AgentBlock pela primeira vez:

typescript
// execute.ts — walkBlocks

if (block.type === "agent") {
  session.mode = "agent"
  session.expiryTimeout = agentBlock.inputTimeout ?? flow.settings.sessionExpiryMs
  session.agentState = {
    activeAgentId: agentBlock.agentId,
    agentChain: [{ agentId: agentBlock.agentId, returnToParent: false }],
    messages: [],
    previousResponseId: undefined,
    configSnapshot: { agentId, maxMessages, ... },
    turnCount: 0,
  }
  // Retorna agentEvent — NÃO publica diretamente
  return { deleted: false, agentEvent }
}

O handler flow.ts persiste a sessão antes de publicar em stream:agent:

typescript
// handlers/flow.ts

await setSession(event.sessionId, JSON.stringify(session), session.expiryTimeout)
if (result.agentEvent) {
  await xadd(STREAMS.AGENT, { payload: JSON.stringify(result.agentEvent) })
}

Essa ordem garante que o flow-ai-agent sempre encontrará agentState no Redis ao iniciar.

Fase 3 — flow-ai-agent: executor

O consumer do flow-ai-agentstream:agent e chama handle(event):

1. Carrega a sessão do Redis

typescript
const rawSession = await getSession(event.sessionId)
const session = JSON.parse(rawSession) as SessionState

2. Remove timer de inatividade anterior

typescript
await redis.del(INACTIVITY_KEY_PREFIX + event.sessionId)

3. Carrega config do agente e credencial LLM via cache

typescript
const rawAgent = await getAiAgent(event.agentId)          // cache Redis agent:{agentId}
const rawCredential = await getLlmCredential(llmCredentialId)  // llm:credential:{id}
const provider = createLlmProvider(credential, agentConfig.model)

O payload de agent:{agentId} é o AiAgentCacheConfig completo (packages/flow-ai-types/src/ai-agents.ts:400), não um subconjunto: inclui systemPrompt (concatenação dos SystemPrompts ativos em order ASC), guardrails[] (Router-level primeiro, depois Agent-level, só ativos), routes[], model, llmCredentialId, enabledInternalTools[], nativeToolDescriptions, hasRagSources, ragTags[], tools[] (mini-flows publicados) e sentimentConfig.

Apenas o provider openai está implementado; a factory (services/flow-ai-agent/src/providers/factory.ts:5) lança para qualquer outro credential.provider. Embora o serviço declare @anthropic-ai/sdk como dependência, o factory ainda não instancia um provider Anthropic.

4. Constrói as instructions (system prompt)

typescript
rawInstructions = agentConfig.systemPrompt
               + guardrailText       // "## Restrições e regras de comportamento" + lista
               + routingText         // "## Agentes disponíveis para transferência" + instrução returnToOrchestrator
               + lastSessionText     // "---\nResumo da última sessão:\n..." (se contact.lastSessionSummary)
               + contactCtxText      // "---\nContexto do contato:\n..." (se contact.context)
// interpolado uma vez (interpolate) → instructionsBase

O routingText só é injetado se o agente tiver rotas configuradas. Ele instrui o modelo a usar sempre returnToOrchestrator: true ao transferir para que o especialista possa retornar.

A cada iteração, buildInstructions() acrescenta ao instructionsBase um bloco "## Variáveis e dados já conhecidos" — variáveis de sessão (session.variables) e de contato (contact.contactVariables) já coletadas — instruindo o modelo a tratá-las como verdade e não re-perguntar.

4.5. Garante o ticket de IA

Antes de rodar o loop, se agentState.ticketId estiver ausente, o executor cria um Ticket com kind: "ai", openedByKind: "ai_agent", openReason: "ai_session", status: "assigned" e grava o ID em agentState.ticketId. O ticket é único por sessão e mantido entre transferências entre agentes. Se houver um ticket de IA órfão (sessão recriada após reset/expiração de TTL) ele é fechado com closeKind: "system" e encadeado via previousTicketId. A criação/fechamento emite HelpdeskAITicketEvent (kind: "ai_ticket") em stream:helpdesk para o monitoramento do desk.

5. Constrói o firstInput

CenárioConteúdo
Mensagem direta do usuário (sem handoff)Resultado de preprocessMessages(event.messages, ...) — ver seção abaixo
Handoff com lastUserInputPrefixo "Você recebeu este atendimento..." + contexto + última mensagem
Retorno de sub-agente (handoffContext sem lastUserInput)"Um agente especialista encerrou..." + contexto

6. Executa o loop de tool calling

typescript
let currentInput = firstInput  // string ou LlmToolResult[]
let currentResponseId = session.agentState.previousResponseId
// allTools = toolset resolvido para este agente/canal (ver "Tools disponíveis")

for (let i = 0; i < MAX_LOOP_ITERATIONS; i++) {
  const response = await provider.chat(instructions, currentInput, allTools, currentResponseId)
  currentResponseId = response.responseId
  session.agentState.previousResponseId = currentResponseId  // persiste imediatamente

  if (response.toolCalls.length === 0) {
    // "zero tool call" só é fim legítimo se o agente já entregou algo OU já encerrou/transferiu.
    if (anyMessageSent || loopBroken) break
    // turno mudo → 1 nudge pedindo finalizar com uma tool; persistindo, fallback ao flow.
    if (nudgeCount < MAX_NUDGES) { nudgeCount++; currentInput = NUDGE_FINALIZE; continue }
    await endAgentSession(ctx, "fallback_stuck"); break
  }

  const toolResults = []
  for (const toolCall of response.toolCalls) {
    // 1) gate: parseError (args truncados) ou validateToolArgs(...) reprovado →
    //    toolOutput descritivo, SEM efeito colateral (3ª falha por tool desencoraja insistir)
    // 2) senão executa a ferramenta e coleta toolOutput
    // SEMPRE empurra exatamente 1 toolResult por call_id (senão a próxima request toma 400)
    // se shouldBreak (endAgentSession, transferToAgent, limite atingido), loopBroken=true
  }
  if (loopBroken) break
  if (anyMessageSent && !sentMessage) break  // sem nova mensagem após resposta enviada

  currentInput = toolResults  // próxima iteração com outputs das tools
}

// rede final: estourou as iterações sem entregar nada e sem ação terminal → endAgentSession("fallback_stuck")

A API OpenAI Responses é stateful — cada request com previous_response_id continua o histórico sem re-enviar mensagens anteriores. previousResponseId é zerado quando o agente muda (transferência ou retorno).

Robustez de fim de turno

O loop garante que nenhum turno termine mudo (sem entregar nada ao usuário e sem ação terminal):

  • Validação antes do envio — os argumentos de cada tool passam por validateToolArgs (zod) antes de qualquer efeito colateral. Fora dos limites da Meta (≤3 botões, ≤10 rows, tamanhos de texto) ou com argumentos truncados (parseError), a call é rejeitada antes do xadd e o toolOutput traz uma mensagem acionável; o modelo corrige e reenvia. Cada tool pode falhar a validação até 3 vezes por turno antes de a mensagem desencorajar novas tentativas.
  • Invariante anti-freeze — se um turno termina sem nenhuma tool call e o agente não entregou nada nem tomou ação terminal, o executor injeta 1 nudge (input role user) pedindo para finalizar com uma tool. Persistindo mudo — ou estourando MAX_LOOP_ITERATIONS — chama endAgentSession("fallback_stuck") e devolve o controle ao flow. A comunicação ao usuário nesse caso é responsabilidade do flow (ver agentEndReason abaixo).

Pré-processamento de mídia inbound

Antes de chamar o LLM, o executor transforma automaticamente mensagens de mídia em texto enriquecido. O modelo não sabe que houve uma chamada ao Whisper ou à Graph API — ele simplesmente recebe o conteúdo como parte da mensagem do usuário.

Esse pré-processamento acontece em preprocessMessages(), chamado na fase 5 do executor para mensagens diretas (sem handoff).

Áudio — transcrição via Whisper

AgentStreamEvent.messages = [{ type: "audio", audio: { id: "…", mime_type: "audio/ogg; codecs=opus" } }]

1. getWaCredentials(event.phoneNumberId) → { accessToken, graphApiBaseUrl? }
2. GET /v19.0/{mediaId}  (Bearer {accessToken})  → { url, mime_type }
3. GET {url}             (Bearer {accessToken})  → buffer binário
4. Verifica tamanho ≤ 5MB
5. POST https://api.openai.com/v1/audio/transcriptions
      model: whisper-1, file: buffer
6. → firstInput: '[Áudio transcrito]: "quero saber o saldo da minha conta"'

O accessToken vem do cache Redis wa:credentials:{phoneNumberId}, lido via getWaCredentials do flow-ai-redis. A apiKey usada no Whisper é a mesma credencial LLM do agente.

Outros tipos — placeholders informativos

TipoComportamento
textTexto cru (msg.text.body)
interactiveTítulo do button_reply / list_reply selecionado
image[Imagem recebida — legenda: "…" — interpretação de imagens em breve]
document[Documento recebido (nome.pdf) — leitura de documentos não suportada ainda]
video[Vídeo recebido — processamento de vídeos não suportado]
contacts[Contato compartilhado: Nome (telefones); …]
sticker[Sticker]
(outros)[<type>]

O agente recebe o placeholder como parte do firstInput e pode informar o usuário de forma adequada (ex: "recebi seu vídeo, mas ainda não consigo processá-lo — pode descrever sua dúvida em texto?").

Política de fallback

SituaçãoResultado no firstInput
Credenciais WA não encontradas no Redis[Áudio recebido — credenciais de canal não encontradas]
Falha ao resolver URL na Graph API[Áudio recebido — falha ao resolver URL (4xx/5xx)]
Falha no download do arquivo[Áudio recebido — falha no download (4xx/5xx)]
Arquivo > 5MB[Áudio recebido — arquivo muito grande (7.2MB, limite 5MB)]
Erro inesperado (rede, timeout)[Áudio recebido — erro no processamento]

Em nenhum cenário a execução é abortada — o loop LLM sempre continua com o placeholder descritivo.

Sem mudança de interface

O pré-processamento é transparente para o provider LLM. O firstInput continua sendo uma stringLlmProvider.chat() não foi alterado. A extensão da interface para suporte nativo a imagens (passagem de image_url inline no turno do usuário) fica para uma fase posterior.


Tools disponíveis

O conjunto de tools é dinâmico por agente e por canal — não é fixo. Montado em services/flow-ai-agent/src/executor/index.ts a partir de:

  • 7 tools de resposta (sempre presentes) — RESPONSE_TOOL_DEFINITIONS
  • tools de açãoACTION_TOOL_DEFINITIONS filtradas por agentConfig.enabledInternalTools (vazio = todas): transferToAgent, transferToHuman, endAgentSession, extendInactivityTimeout, setVariable, deleteVariable, setContactVariable
  • ragSearch — apenas se agentConfig.hasRagSources === true (o router tem documentos RAG)
  • updateSentiment — apenas se sentimentConfig.enabled && sentimentConfig.perRound.enabled
  • tools publicadas (mini-flows)agentConfig.tools[], cada uma vira uma LlmToolDefinition despachada via dispatchPublishedTool

O conjunto resultante passa por filterToolsByChannel(...) para remover tipos não suportados pelo canal da sessão (deriveChannelFromSessionId). Descrições podem ser sobrescritas por agentConfig.nativeToolDescriptions. O catálogo canônico das tools internas — fonte única de verdade para a UI — vive em INTERNAL_TOOL_CATALOG (packages/flow-ai-types/src/ai-agents.ts:160).

Tools de resposta

Enviam mensagens ao usuário. A saída vai para stream:outgoing-chat (sessões wc-*) ou stream:outgoing-meta (demais) — ver a Nota de canal no topo do guia. Após o envio, retornam "sent — do not send more messages; wait for the user's next reply" como tool output para sinalizar ao modelo que deve parar e aguardar resposta.

Os argumentos são validados (validateToolArgs, zod) antes do envio, espelhando os limites da WhatsApp Cloud API (ex.: sendButtonMessage ≤3 botões; sendListMessage soma de rows ≤10; títulos/legendas com limites de caracteres). Payload fora dos limites é rejeitado antes do xadd e devolvido ao modelo para correção — ver "Robustez de fim de turno".

ToolTipo de mensagemParâmetros principais
sendTextMessageTexto simplestext: string
sendButtonMessageBotões interativos (até 3)body: string, buttons: [{id, title}]
sendListMessageLista interativa com seçõesbody: string, buttonText: string, sections: [{title?, rows}]
sendImageMessageImagem com URL públicaurl: string, caption?: string
sendAudioMessageÁudio com URL públicaurl: string
sendVideoMessageVídeo com URL públicaurl: string, caption?: string
sendDocumentMessageDocumento com URL públicaurl: string, filename: string, caption?: string

Todas as mensagens são publicadas pelo flow-ai-agent (não pelo engine).

Tools de ação

transferToAgent

Transfere o atendimento para outro agente especialista.

typescript
// Parâmetros
{
  agentId: string          // ID do AiAgent destino (deve ter cache Redis)
  handoffContext: string   // contexto resumido construído sobre a conversa
  lastUserInput: string    // última mensagem do usuário
  returnToOrchestrator?: boolean  // se true, especialista retorna ao remetente ao encerrar
}

Comportamento interno (transferToAgent, na ordem do código):

  1. Guarda no executor: se anyMessageSent na execução atual, a call é bloqueada antes de chamar transferToAgent (não transfere após enviar mensagem — executor/index.ts:689)
  2. Verifica profundidade máxima do agentChain (limite: 10)
  3. Valida que agentId existe no cache Redis (aborta se não existir)
  4. Verifica ciclos — aborta se agentId já existe na cadeia
  5. Empurra novo AgentChainEntry, atualiza activeAgentId, limpa messages
  6. Reseta previousResponseId = undefined (nova conversa com o especialista)
  7. Publica novo AgentStreamEvent em stream:agent

endAgentSession

Encerra o atendimento do agente atual.

typescript
// Parâmetros
{
  reason?: string            // ex: "resolved", "escalated"
  completionContext?: string // resumo — obrigatório quando returnToOrchestrator=true
}

Comportamento — retorno ao agente pai (returnToParent=true no entry atual):

  1. Remove entry atual do agentChain
  2. Atualiza activeAgentId para o agente pai
  3. Reseta previousResponseId = undefined
  4. Publica AgentStreamEvent ao agente pai com handoffContext = completionContext

Comportamento — encerramento total (agente raiz):

  1. Salva agentEndReason em session.variables (disponível nas outputConditions do AgentBlock)
  2. Transiciona session.mode = "flow", deleta agentState
  3. Fecha o ticket de IA (closeKind: "resolved", persiste sentimentHistory), emite SummaryRequestedEvent em stream:summary e ai_ticket closed em stream:helpdesk
  4. Publica AgentSessionEndedEvent em stream:flow

O modelo só deve chamar endAgentSession quando o usuário confirmar explicitamente que sua solicitação foi resolvida, pedir para encerrar, ou se tornar completamente inativo após múltiplas tentativas.

transferToHuman

Transfere imediatamente o atendimento para um agente humano — o contato entra na fila de helpdesk.

typescript
// Parâmetros
{ reason?: string }  // registrado em session.variables.agentHandoffReason e no ticket

Delega para performHumanHandoff (services/flow-ai-agent/src/tools/actions.ts:282) — a mesma transição usada pelo handoff manual. Fecha o ticket de IA (closeKind: "system"), publica um HelpdeskHandoffEvent em stream:helpdesk, seta session.mode = "human", deleta agentState e regrava a sessão. Encerra o loop ({ type: "break" }).

extendInactivityTimeout

Estende o timer de inatividade quando o usuário menciona que vai responder mais tarde.

typescript
// Parâmetros
{ minutes: number }  // limitado pela janela de 24h da Meta

Recalcula session.expiryTimeout = min(now + minutes*60000, metaWindowExpiresAt) - now e reescreve a chave inactivity:{sessionId} com o novo TTL.

Variáveis de contexto — setVariable / deleteVariable / setContactVariable

ToolEscopoEfeito
setVariableSessão (session.variables)Persiste um valor disponível em turns futuros e nos blocos do flow
deleteVariableSessãoRemove uma variável de sessão
setContactVariableContato (Contact.contactVariables)Persiste entre sessões — dados cadastrais, preferências

Os valores são reinjetados no prompt a cada iteração pelo bloco "Variáveis e dados já conhecidos".

ragSearch

Consulta a base de conhecimento do router por similaridade semântica (pgvector). Disponível apenas quando o router tem documentos RAG (hasRagSources).

typescript
// Parâmetros
{ query: string }  // máx. 500 chars

Gera embedding via OpenAI text-embedding-3-small, busca os 5 chunks mais próximos (distância coseno <=>) em agent_rag_chunks join agent_rag_documents, filtrando por agentConfig.ragTags quando configuradas. Retorna os trechos formatados como [Trecho N]. Ver services/flow-ai-agent/src/tools/rag.ts.

updateSentiment

Registra o sentimento atual do cliente. Disponível apenas com análise perRound habilitada.

typescript
// Parâmetros
{
  sentiment: "positive" | "neutral" | "frustrated" | "angry" | "urgent" | "confused"
  score: number          // 0 a 10
  signals: string[]      // frases que justificam
  escalationRisk: boolean
  note?: string          // frase curta para orientar um atendente humano
}

Só grava se a última mensagem do usuário tiver >= minMessageLength chars. Atualiza agentState.sentiment + agentState.sentimentHistory, escreve o cache sentiment:ticket:{ticketId} e, quando a categoria muda, emite ai_ticket updated em stream:helpdesk para o monitoramento. Ver a seção "Sentimento" abaixo.


Roteamento multi-agente

agentChain

A cadeia de agentes ativos é mantida em agentState.agentChain. Cada entrada registra o agente e se ele deve retornar ao pai ao encerrar.

Exemplo de cadeia:

Início:   [{ orch, returnToParent: false }]

Transfer orch → suporte (returnToOrchestrator: true):
          [{ orch, false }, { suporte, true }]

Suporte escalates → endAgentSession:
  currentEntry.returnToParent = true → volta ao pai
  chain.pop() → [{ orch, false }]
  publica AgentStreamEvent ao orquestrador com completionContext

Orch → financeiro (returnToOrchestrator: true):
          [{ orch, false }, { financeiro, true }]

Financeiro resolve → endAgentSession:
  volta ao orquestrador → [{ orch, false }]

Orquestrador encerra → endAgentSession:
  agente raiz (returnToParent: false, agentChain.length === 1)
  → publica AgentSessionEndedEvent em stream:flow

Proteções

ProteçãoLimiteComportamento
Profundidade da cadeia10 entradasAborta transferência, retorna { type: "break" }
Ciclo A→B→ADetecção por agentChain.some(e => e.agentId === targetId)Aborta transferência
Agente inexistenteCache Redis ausenteAborta sem modificar session
Transferência após mensagemanyMessageSent = trueBloqueia, seta shouldBreak
Iterações máximasMAX_LOOP_ITERATIONS = 20Loop termina automaticamente
maxMessagesagentState.configSnapshot.maxMessagesForça endAgentSession("maxMessages")

Retorno ao flow — agentSessionEnded

Quando o agente raiz encerra a sessão:

endAgentSession (actions.ts)
  session.mode = "flow"
  delete session.agentState
  session.variables.agentEndReason = reason
  setSession → Redis
  fecha ticket de IA (closeKind "resolved") + xadd(SUMMARY) + xadd(HELPDESK ai_ticket closed)
  xadd(STREAMS.FLOW, AgentSessionEndedEvent)

flow-ai-engine consumer (consumer.ts)
  parseFlowEvent → kind="agentSessionEnded" → válido
  handleFlowEvent(event)

runFlowFromAgentSessionEnded (execute.ts)
  guard: mode não "agent" nem "flow" → ignora
  currentBlock.type !== "agent" → ignora
  session.mode = "flow"
  session.expiryTimeout = flow.settings.sessionExpiryMs
  delete session.agentState
  pickOutputTarget(agentBlock.outputConditions, agentBlock.defaultOutput, ctx)
    → avalia session.variables.agentEndReason
  navigateToTarget → session.currentBlockId = próximo bloco
  walkBlocks → continua execução normal do flow

outputConditions do AgentBlock

A variável agentEndReason fica disponível em session.variables durante a avaliação das outputConditions. Isso permite configurar saídas diferentes por motivo de encerramento:

agentEndReasonSignificado sugerido
resolvedAtendimento concluído com sucesso
escalatedAssunto fora do escopo do agente
maxMessagesLimite de turnos atingido
fallback_stuckTurno não produziu entrega após nudge / estouro de iterações — configure uma saída com copy estática de fallback (e que não volte ao mesmo agente)
(custom)Qualquer string passada ao endAgentSession

Timeout de inatividade

Após cada execução do executor que mantém a sessão em mode="agent", um timer de inatividade é (re)criado no Redis:

typescript
// services/flow-ai-agent/src/executor/index.ts:860
if (session.mode === "agent" && session.agentState?.activeAgentId === event.agentId) {
  const ttlSeconds = Math.ceil(session.expiryTimeout / 1000)
  await redis.setex(`inactivity:${sessionId}`, ttlSeconds, "")
}

O único subscriber de expiração de chaves inactivity:* vive no flow-ai-engine (services/flow-ai-engine/src/inactivity-handler.ts), via psubscribe("__keyevent@0__:expired"). O flow-ai-agent não tem inactivity-handler próprio.

Nota (drift de comportamento): o subscriber do engine descarta explicitamente qualquer sessão com mode !== "flow" (inactivity-handler.ts:98). Como uma sessão de agente está em mode="agent", a chave inactivity:{sessionId} gravada pelo executor (e também pelo handler flow.ts:256) expira sem efeito — não existe endAgentSession("inactivity") no código. Ou seja, o timeout de inatividade não encerra automaticamente uma sessão de IA hoje; a sessão só sai do modo agente por ação do modelo (endAgentSession/transferToHuman/transferToAgent), por maxMessages, pelo fallback fallback_stuck, ou por handoff manual do desk. O extendInactivityTimeout continua reescrevendo a chave (respeitando session.metaWindowExpiresAt), mas isso apenas reagenda uma expiração que hoje é no-op para o modo agente.


Handoff manual (agente IA → humano)

Além do transferToHuman chamado pelo próprio modelo, um operador pode forçar o handoff de uma sessão em mode="agent" a partir do monitoramento do desk. O flow-ai-core publica um AgentManualHandoffEvent (kind: "manualHandoff") em stream:agent.

Como o flow-ai-agent roda um consumer único e serial, esse evento é enfileirado atrás de qualquer turno em voo da mesma sessão — sem corrida sobre a sessão. handleManualHandoff (services/flow-ai-agent/src/executor/index.ts:250) recarrega a sessão, verifica idempotência (se já saiu do modo IA, é no-op) e reusa performHumanHandoff com presetQueueId/presetUserId vindos do evento — a mesma transição do tool transferToHuman.


Base de conhecimento (RAG)

Quando o router tem documentos RAG (hasRagSources), o agente ganha a tool ragSearch. Os documentos ficam em AgentRagDocument (schema.prisma:1396) e cada chunk vetorizado em AgentRagChunk (schema.prisma:1413): coluna embedding do tipo vector(1536) (pgvector), inserida via SQL bruto, com índice HNSW para busca por similaridade coseno. A ingestão/embedding é responsabilidade do flow-ai-core; o flow-ai-agent apenas consulta (embedding da query via OpenAI text-embedding-3-small + top-5 por <=>).


Sentimento

Quando Router.sentimentConfig habilita perRound, a tool updateSentiment é oferecida ao agente. O SentimentConfig (packages/flow-ai-types/src/ai-agents.ts:358) define vários modos, dos quais o agente de IA usa apenas o perRound:

  • perRound — o modelo chama updateSentiment a cada mudança de tom emocional (aplicado via instrução no prompt).
  • onHandoff, bucketHuman, onClose — analisados por outros subsistemas (handoff e atendimento humano), fora do loop do agente.

O resultado (SentimentResult) é gravado em agentState.sentiment + agentState.sentimentHistory, no cache sentiment:ticket:{ticketId} e, na mudança de categoria, emite ai_ticket updated em stream:helpdesk. No fechamento do ticket de IA, o sentimentHistory é persistido na coluna Ticket.sentimentHistory.


Telemetria e ciclo de vida do ticket de IA

Além dos streams de saída, o flow-ai-agent emite:

StreamConstanteEventoConsumidor
stream:agent-turnsAGENT_TURNSAgentTurnStreamEvent (por iteração do loop)flow-ai-core (core-agent-turns) → tabela agent_turns, retenção 30d
stream:llm-usageLLM_USAGELlmUsageStreamEvent (uma vez por execução do agente / handle(), com tokens agregados de todas as iterações do turno)flow-ai-core (core-llm-usage) → tabela llm_usage
stream:summarySUMMARYSummaryRequestedEvent (ao fechar ticket de IA)flow-ai-core (core-summary)
stream:helpdeskHELPDESKHelpdeskAITicketEvent (create/close/updated) e HelpdeskHandoffEventflow-ai-core

O ticket de IA (Ticket.kind = "ai") acompanha o ciclo de vida da sessão: criado uma vez antes do loop (openedByKind: "ai_agent", openReason: "ai_session"), mantido entre transferências entre agentes, e fechado ao endAgentSession (closeKind: "resolved") ou no handoff para humano (closeKind: "system"). O encadeamento com tickets anteriores usa previousTicketId.

Cada iteração e cada chamada LLM abrem spans OpenTelemetry (withSpan/withGenAiSpan/startSpan), com o contexto de trace propagado pelo runWithMessageContext a partir do stream.


LlmCredential e provider

O LlmCredential é associado ao Router (não diretamente ao agente) e o AiAgentCacheConfig carrega o llmCredentialId resolvido. A credencial é cifrada no Postgres — o flow-ai-core é a única fronteira de cripto — e cacheada decifrada no Redis em llm:credential:{credentialId}, lida via getLlmCredential(id).

typescript
// LlmCredentialCacheConfig — packages/flow-ai-types/src/ai-agents.ts:467
type LlmCredentialCacheConfig = {
  id: string
  provider: string    // "openai" | "anthropic" | ...
  apiKey: string      // texto plano — nunca sai do Redis para o DB
  label: string
}

O createLlmProvider(credential, model) instancia o provider correto. Atualmente apenas OpenAIProvider está implementado — o factory lança Provider LLM não suportado para qualquer outro provider. Usa a Responses API (stateful, não Chat Completions).

Por que Responses API

A Responses API mantém o histórico da conversa server-side. Cada request retorna um response.id que é passado como previous_response_id no próximo request — sem re-enviar o histórico completo. Isso reduz tokens de entrada em sessões longas.

O previousResponseId é zerado em duas situações:

  • transferToAgent — nova conversa com o especialista
  • endAgentSession com retorno ao pai — nova conversa com o orquestrador

Cache Redis dos agentes

O cache de agentes é populado pelo flow-ai-core e mantido pelas operações CRUD da UI.

Chave: agent:{agentId}
Payload: AiAgentCacheConfig (ai-agents.ts:400) —
  { id, routerId, name, type, model, description?, systemPrompt, guardrails[],
    isActive, llmCredentialId, routes[], enabledInternalTools[],
    nativeToolDescriptions, hasRagSources, ragTags[], tools[], sentimentConfig }

populateAiAgentCache(agentId) (services/flow-ai-core/src/http/routes/ai-agent.routes.ts:66) recompõe esse payload e é chamado em toda mutação relevante — não só no CRUD de agente, mas também ao alterar guardrails, system prompts, rotas, tools publicadas e fontes RAG.

Warm-up no boot

typescript
// services/flow-ai-core/src/bootstrap.ts:65
async function warmupAiAgentCaches() {
  const agents = await prisma.aiAgent.findMany({ where: { isActive: true }, select: { id: true } })
  await Promise.all(agents.map((a) => populateAiAgentCache(a.id)))
}

Chamado durante o bootstrap do flow-ai-core (antes do listen()), garantindo que o cache está populado antes de qualquer requisição.


Cenários de erro

CenárioComportamento
Sessão não encontrada no RedisExecutor loga WARN e ignora o evento
agentState ausente na sessãoExecutor loga ERROR e ignora o evento
llmCredentialId ausente no cacheExecutor loga ERROR e ignora
Credencial LLM não encontrada no cacheExecutor loga ERROR e ignora
agentId do alvo não encontrado no cache (transferToAgent)Transferência abortada ({ type: "break" }), loop encerra
Ciclo de agentes detectadoTransferência abortada, loop encerra
Profundidade máxima de cadeia atingida (10)Transferência abortada, loop encerra
Argumentos de tool inválidos/truncadosGate rejeita antes de qualquer efeito colateral; toolOutput acionável; até 3 falhas por tool antes de desencorajar
Erro de runtime em tool calltoolOutput = "erro: <mensagem>", loop continua
Falha no download ou transcrição de mídiaPlaceholder descritivo injetado no firstInput, loop LLM continua normalmente
Arquivo de mídia > 5MBPlaceholder com tamanho real, loop LLM continua
Exceção não tratada em handle/handleManualHandoffO consumer captura, loga ERROR e ainda assim xack a entry (não fica pendente no stream)
agentSessionEnded chega e a sessão já não está num AgentBlockEngine ignora o evento; se a sessão ficou presa em mode="flow" no AgentBlock, a próxima mensagem do usuário dispara recovery automático (execute.ts:290)
maxMessages atingidoendAgentSession("maxMessages") forçado automaticamente
Turno mudo após nudge / estouro de MAX_LOOP_ITERATIONS sem entregaendAgentSession("fallback_stuck") — flow trata via outputConditions

Arquivos relevantes

ArquivoResponsabilidade
services/flow-ai-engine/src/runtime/execute.tswalkBlocks (entrada no AgentBlock, publica agentEvent), runFlowFromAgentSessionEnded, recovery de estado preso
services/flow-ai-engine/src/handlers/flow.tsHandler de stream:flow, ordem setSession → xadd (evita race condition)
services/flow-ai-engine/src/consumer.tsConsumer de stream:flow, parseFlowEvent aceita agentSessionEnded
services/flow-ai-engine/src/inactivity-handler.tsSubscriber de __keyevent@0__:expired — só age em mode="flow" (ver Nota de inatividade)
services/flow-ai-agent/src/consumer.tsConsumer único/serial de stream:agent (grupo agent), roteia AgentStreamPayload
services/flow-ai-agent/src/executor/index.tshandle (loop de tool calling), handleManualHandoff, preprocessMessages, downloadWhatsAppMedia, transcribeAudio, criação do ticket de IA
services/flow-ai-agent/src/tools/response.ts7 tools de envio; publishOutgoing (roteamento wc-/meta)
services/flow-ai-agent/src/tools/actions.tstransferToAgent, transferToHuman/performHumanHandoff, endAgentSession, extendInactivityTimeout, set/delete/setContactVariable, updateSentiment
services/flow-ai-agent/src/tools/rag.tsragSearch — embedding + busca pgvector
services/flow-ai-agent/src/tools/channel-filter.tsderiveChannelFromSessionId, filterToolsByChannel
services/flow-ai-agent/src/tools/published-tools.tsbuildPublishedTools, dispatchPublishedTool (mini-flows)
services/flow-ai-agent/src/providers/openai.tsProvider OpenAI (Responses API stateful)
services/flow-ai-agent/src/providers/factory.tsFactory de providers — só openai implementado
services/flow-ai-core/src/http/routes/ai-agent.routes.tsCRUD de agentes + populateAiAgentCache
services/flow-ai-core/src/bootstrap.tswarmupAiAgentCaches no boot
services/flow-ai-core/src/helpdesk/publishAgentSessionEnded.tsEncerramento de sessão de IA disparado pelo desk (HTTP)
packages/flow-ai-redis/src/constants.tsSTREAMS/GROUPS (AGENT, AGENT_TURNS, LLM_USAGE, ...)
packages/flow-ai-redis/src/cache.tsgetAiAgent (agent:{id}), getLlmCredential (llm:credential:{id})
packages/flow-ai-types/src/ai-agents.tsAgentStreamEvent, AgentManualHandoffEvent, AgentStreamPayload, AiAgentCacheConfig, INTERNAL_TOOL_CATALOG, SentimentConfig, telemetria
packages/flow-ai-types/src/session.tsSessionMode, AgentState, AgentChainEntry, AgentConfig

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