Appearance
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-agentescolhe o stream de saída por prefixo dosessionId:wc-*→stream:outgoing-chat, caso contráriostream:outgoing-meta(services/flow-ai-agent/src/tools/response.ts:27). Não há branch paraig-*— um agente respondendo numa sessão Instagram publica emstream: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 kind → AgentStreamEvent (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-agentneste guia. O executor não ramifica portype; 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 campotask). Não passam peloAgentBlock/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-agent lê stream: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 SessionState2. 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) → instructionsBaseO 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ário | Conteúdo |
|---|---|
| Mensagem direta do usuário (sem handoff) | Resultado de preprocessMessages(event.messages, ...) — ver seção abaixo |
Handoff com lastUserInput | Prefixo "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 doxadde otoolOutputtraz 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 estourandoMAX_LOOP_ITERATIONS— chamaendAgentSession("fallback_stuck")e devolve o controle ao flow. A comunicação ao usuário nesse caso é responsabilidade do flow (veragentEndReasonabaixo).
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
| Tipo | Comportamento |
|---|---|
text | Texto cru (msg.text.body) |
interactive | Tí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ção | Resultado 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 string — LlmProvider.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ção —
ACTION_TOOL_DEFINITIONSfiltradas poragentConfig.enabledInternalTools(vazio = todas):transferToAgent,transferToHuman,endAgentSession,extendInactivityTimeout,setVariable,deleteVariable,setContactVariable ragSearch— apenas seagentConfig.hasRagSources === true(o router tem documentos RAG)updateSentiment— apenas sesentimentConfig.enabled && sentimentConfig.perRound.enabled- tools publicadas (mini-flows) —
agentConfig.tools[], cada uma vira umaLlmToolDefinitiondespachada viadispatchPublishedTool
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;sendListMessagesoma de rows ≤10; títulos/legendas com limites de caracteres). Payload fora dos limites é rejeitado antes doxadde devolvido ao modelo para correção — ver "Robustez de fim de turno".
| Tool | Tipo de mensagem | Parâmetros principais |
|---|---|---|
sendTextMessage | Texto simples | text: string |
sendButtonMessage | Botões interativos (até 3) | body: string, buttons: [{id, title}] |
sendListMessage | Lista interativa com seções | body: string, buttonText: string, sections: [{title?, rows}] |
sendImageMessage | Imagem com URL pública | url: string, caption?: string |
sendAudioMessage | Áudio com URL pública | url: string |
sendVideoMessage | Vídeo com URL pública | url: string, caption?: string |
sendDocumentMessage | Documento com URL pública | url: 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):
- Guarda no executor: se
anyMessageSentna execução atual, a call é bloqueada antes de chamartransferToAgent(não transfere após enviar mensagem —executor/index.ts:689) - Verifica profundidade máxima do
agentChain(limite: 10) - Valida que
agentIdexiste no cache Redis (aborta se não existir) - Verifica ciclos — aborta se
agentIdjá existe na cadeia - Empurra novo
AgentChainEntry, atualizaactiveAgentId, limpamessages - Reseta
previousResponseId = undefined(nova conversa com o especialista) - Publica novo
AgentStreamEventemstream: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):
- Remove entry atual do
agentChain - Atualiza
activeAgentIdpara o agente pai - Reseta
previousResponseId = undefined - Publica
AgentStreamEventao agente pai comhandoffContext = completionContext
Comportamento — encerramento total (agente raiz):
- Salva
agentEndReasonemsession.variables(disponível nasoutputConditionsdo AgentBlock) - Transiciona
session.mode = "flow", deletaagentState - Fecha o ticket de IA (
closeKind: "resolved", persistesentimentHistory), emiteSummaryRequestedEventemstream:summaryeai_ticket closedemstream:helpdesk - Publica
AgentSessionEndedEventemstream:flow
O modelo só deve chamar
endAgentSessionquando 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 ticketDelega 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 MetaRecalcula 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
| Tool | Escopo | Efeito |
|---|---|---|
setVariable | Sessão (session.variables) | Persiste um valor disponível em turns futuros e nos blocos do flow |
deleteVariable | Sessão | Remove uma variável de sessão |
setContactVariable | Contato (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 charsGera 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:flowProteções
| Proteção | Limite | Comportamento |
|---|---|---|
| Profundidade da cadeia | 10 entradas | Aborta transferência, retorna { type: "break" } |
| Ciclo A→B→A | Detecção por agentChain.some(e => e.agentId === targetId) | Aborta transferência |
| Agente inexistente | Cache Redis ausente | Aborta sem modificar session |
| Transferência após mensagem | anyMessageSent = true | Bloqueia, seta shouldBreak |
| Iterações máximas | MAX_LOOP_ITERATIONS = 20 | Loop termina automaticamente |
| maxMessages | agentState.configSnapshot.maxMessages | Forç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 flowoutputConditions 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:
| agentEndReason | Significado sugerido |
|---|---|
resolved | Atendimento concluído com sucesso |
escalated | Assunto fora do escopo do agente |
maxMessages | Limite de turnos atingido |
fallback_stuck | Turno 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á emmode="agent", a chaveinactivity:{sessionId}gravada pelo executor (e também pelo handlerflow.ts:256) expira sem efeito — não existeendAgentSession("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), pormaxMessages, pelo fallbackfallback_stuck, ou por handoff manual do desk. OextendInactivityTimeoutcontinua reescrevendo a chave (respeitandosession.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 chamaupdateSentimenta 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:
| Stream | Constante | Evento | Consumidor |
|---|---|---|---|
stream:agent-turns | AGENT_TURNS | AgentTurnStreamEvent (por iteração do loop) | flow-ai-core (core-agent-turns) → tabela agent_turns, retenção 30d |
stream:llm-usage | LLM_USAGE | LlmUsageStreamEvent (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:summary | SUMMARY | SummaryRequestedEvent (ao fechar ticket de IA) | flow-ai-core (core-summary) |
stream:helpdesk | HELPDESK | HelpdeskAITicketEvent (create/close/updated) e HelpdeskHandoffEvent | flow-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 especialistaendAgentSessioncom 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ário | Comportamento |
|---|---|
| Sessão não encontrada no Redis | Executor loga WARN e ignora o evento |
agentState ausente na sessão | Executor loga ERROR e ignora o evento |
llmCredentialId ausente no cache | Executor loga ERROR e ignora |
| Credencial LLM não encontrada no cache | Executor loga ERROR e ignora |
agentId do alvo não encontrado no cache (transferToAgent) | Transferência abortada ({ type: "break" }), loop encerra |
| Ciclo de agentes detectado | Transferência abortada, loop encerra |
| Profundidade máxima de cadeia atingida (10) | Transferência abortada, loop encerra |
| Argumentos de tool inválidos/truncados | Gate rejeita antes de qualquer efeito colateral; toolOutput acionável; até 3 falhas por tool antes de desencorajar |
| Erro de runtime em tool call | toolOutput = "erro: <mensagem>", loop continua |
| Falha no download ou transcrição de mídia | Placeholder descritivo injetado no firstInput, loop LLM continua normalmente |
| Arquivo de mídia > 5MB | Placeholder com tamanho real, loop LLM continua |
Exceção não tratada em handle/handleManualHandoff | O 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 AgentBlock | Engine 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 atingido | endAgentSession("maxMessages") forçado automaticamente |
Turno mudo após nudge / estouro de MAX_LOOP_ITERATIONS sem entrega | endAgentSession("fallback_stuck") — flow trata via outputConditions |
Arquivos relevantes
| Arquivo | Responsabilidade |
|---|---|
services/flow-ai-engine/src/runtime/execute.ts | walkBlocks (entrada no AgentBlock, publica agentEvent), runFlowFromAgentSessionEnded, recovery de estado preso |
services/flow-ai-engine/src/handlers/flow.ts | Handler de stream:flow, ordem setSession → xadd (evita race condition) |
services/flow-ai-engine/src/consumer.ts | Consumer de stream:flow, parseFlowEvent aceita agentSessionEnded |
services/flow-ai-engine/src/inactivity-handler.ts | Subscriber de __keyevent@0__:expired — só age em mode="flow" (ver Nota de inatividade) |
services/flow-ai-agent/src/consumer.ts | Consumer único/serial de stream:agent (grupo agent), roteia AgentStreamPayload |
services/flow-ai-agent/src/executor/index.ts | handle (loop de tool calling), handleManualHandoff, preprocessMessages, downloadWhatsAppMedia, transcribeAudio, criação do ticket de IA |
services/flow-ai-agent/src/tools/response.ts | 7 tools de envio; publishOutgoing (roteamento wc-/meta) |
services/flow-ai-agent/src/tools/actions.ts | transferToAgent, transferToHuman/performHumanHandoff, endAgentSession, extendInactivityTimeout, set/delete/setContactVariable, updateSentiment |
services/flow-ai-agent/src/tools/rag.ts | ragSearch — embedding + busca pgvector |
services/flow-ai-agent/src/tools/channel-filter.ts | deriveChannelFromSessionId, filterToolsByChannel |
services/flow-ai-agent/src/tools/published-tools.ts | buildPublishedTools, dispatchPublishedTool (mini-flows) |
services/flow-ai-agent/src/providers/openai.ts | Provider OpenAI (Responses API stateful) |
services/flow-ai-agent/src/providers/factory.ts | Factory de providers — só openai implementado |
services/flow-ai-core/src/http/routes/ai-agent.routes.ts | CRUD de agentes + populateAiAgentCache |
services/flow-ai-core/src/bootstrap.ts | warmupAiAgentCaches no boot |
services/flow-ai-core/src/helpdesk/publishAgentSessionEnded.ts | Encerramento de sessão de IA disparado pelo desk (HTTP) |
packages/flow-ai-redis/src/constants.ts | STREAMS/GROUPS (AGENT, AGENT_TURNS, LLM_USAGE, ...) |
packages/flow-ai-redis/src/cache.ts | getAiAgent (agent:{id}), getLlmCredential (llm:credential:{id}) |
packages/flow-ai-types/src/ai-agents.ts | AgentStreamEvent, AgentManualHandoffEvent, AgentStreamPayload, AiAgentCacheConfig, INTERNAL_TOOL_CATALOG, SentimentConfig, telemetria |
packages/flow-ai-types/src/session.ts | SessionMode, AgentState, AgentChainEntry, AgentConfig |