Saltar al contenido principal

Bots y LangGraph

Los dos modos​

Un bot puede estar configurado de dos formas, y el código las trata distinto:

  • Prompt simple. Un agente de LangGraph con un prompt del sistema y las tools que le correspondan según los módulos activos.
  • Flujo visual. Un grafo dibujado en el panel, guardado como versión en bot_flow_versions. Cada bot tiene una versión activa, que es la que se ejecuta.

Los tipos de nodo del editor están en frontend/src/sections/bot/flow-editor/flow-types.ts y su ejecución en el backend (createFlowGraph en backend/src/bots/graphs/flow-graph.ts). Un nodo llm dentro de un flujo vuelve a caer en el modo agente, con la posibilidad de desactivar tools concretas en ese nodo.

Asistente de edición de flujos​

Módulo Nest aparte (backend/src/flow-assistant/): panel dentro del editor visual, bucle ReAct con tools tipadas (apply_patches, validate_flow, versiones, catálogo de tools de runtime). No se mezcla con el copiloto de plataforma. Historial en flow_assistant_threads / flow_assistant_messages. Autoriza por JWT + membership + bot del tenant, y por el flag platform_settings.flow_assistant.enabled (fail-closed).

Catálogo único de nodos. shared/src/flows/node-catalog.ts define los tipos de nodo, sus handles, qué pausan, dónde se retoma, qué tiene efectos y una data de ejemplo válida. De ahí salen FlowNodeType del runtime y del editor, la lista de nodos del prompt del asistente (describeFlowNodeCatalog()) y get_flow_schema. Antes eran tres listas a mano y ya divergían (el prompt enseñaba true/false en condition; el runtime lee yes/no). Lo vigilan:

  • bots/graphs/node-catalog.spec.ts: cada tipo del catálogo, con su ejemplo y sus handles, valida y corre un turno en el runtime; el prompt y get_flow_schema listan exactamente esos tipos.
  • createFlowGraph tiene un default: never en el alta de nodos: un tipo nuevo sin implementación no compila. validateFlowConfig rechaza tipos fuera del catálogo (UNKNOWN_NODE_TYPE).
  • flow-editor/node-catalog-drift.test.ts: NODE_META, los ajustes por defecto y el ejemplo de cada tipo contra la validación del editor. El editor importa solo tipos de shared; los valores los copia y el test los compara, para no meter código de shared en el bundle.

Buenas prácticas en el asistente. El summary de cada nodo en el catálogo lleva los hechos del motor que más bots rompen: límites de títulos de WhatsApp, extract_data que conserva valores, IA en segundo plano que descarta lo que envían sus herramientas, intentos por defecto de api_call y las dos salidas de send_message. El prompt (flow-assistant.prompt.ts) suma un bloque "Diseño y buenas prácticas": proponer arquitecturas antes de construir, un agente por servicio con enrutamiento por menú, tope por turno y orden del prompt para la caché. validateFlowConfig (y validateFlow del editor) da error si un título fijo de botón pasa de 20 caracteres, una fila de 24 o una descripción de 72 (SEND_INTERACTIVE_BUTTON_TOO_LONG / _ROW_TOO_LONG), y avisa si el título usa variables (SEND_INTERACTIVE_DYNAMIC_TITLE). El estándar completo está en Diseño de flujos.

Herramientas del asistente además de editar:

ToolQué haceSeguridad
simulate_flowCorre el borrador turno a turno con simulateFlow (bots/simulation/flow-simulator.ts)En seco (ver abajo); máx. 10 mensajes y 30 s
get_flow_metricsMétricas por nodo de bot_node_stats (alcance, errores, p95, tokens, costo)Con la sesión del usuario (supabaseAsUser): la RPC exige bot.view. Tenant y bot salen de la identidad, nunca de los argumentos
compare_flow_revisionsLas mismas métricas para dos versiones guardadas (revisión = computeFlowRevision)Igual
inspect_conversation_stateConversationStateService.inspect sobre una conversaciónTenant de la identidad; rechaza conversaciones de otro bot. Solo estructura: nombres de variables, sin valores ni texto

Reiniciar la memoria de una conversación no es tool del asistente: queda en el panel de la bandeja, con su permiso bot.update.

Simulación en seco. createFlowGraph(..., { dryRun: true }) sustituye los nodos con efectos (api_call, send_message, delay) y los que leen servicios (document_source, catalog_search) por stubs que devuelven un resultado marcado simulated. La IA es un modelo falso que responde [respuesta de la IA]: sin OpenAI, sin costo y sin mandar el borrador a un tercero. Usa un MemorySaver desechable y la misma entrada que un canal (resolveTurnInput, FlowTurnMeter), así que el recorrido, las ramas, los wait_input y la reanudación son los de producción. templates.simulation.spec.ts pasa todas las plantillas del editor por el simulador: ninguna falla en 3 mensajes y ninguna repite el saludo de Inicio.

Datos del tenant pendientes. DOC_NO_SOURCE (documento sin elegir) y API_NO_SAVED_SOURCE (API guardada sin elegir) son errores de validación que solo resuelve el tenant, eligiendo su documento o fuente en el editor: una plantilla no puede traerlos y el asistente no tiene tool para elegirlos. TENANT_DATA_ISSUE_CODES / needsTenantData() (flow-graph.ts) los agrupan, con el mismo criterio que NEEDS_TENANT_DATA en node-catalog-drift.test.ts:

DóndeQué hace con ellos
Editor (validateFlow)Error en el nodo; no impide guardar
simulateFlowLos baja a warning y simula igual (en seco esos nodos son stubs)
save_flow_versionGuarda como borrador y devuelve pendingTenantData; con setActive: true responde flow_needs_tenant_data
activate_flow_versionflow_needs_tenant_data si a la versión le faltan: en producción el nodo leería vacío

Cualquier otro error sigue bloqueando la simulación y el guardado (flow_invalid).

La respuesta de /message trae la última simulation y las últimas metrics que consultó el asistente; el panel las pinta como tarjetas (flow-assistant-cards.tsx) y la de simulación resalta en el lienzo los nodos de un mensaje.

El consumo del propio asistente va a token_usage_events con el bot y el hilo del asistente como conversación. No se registra en bot_turns: sumaría a las métricas y alertas del bot turnos que no atendieron a nadie.

Cómo se ejecuta un turno​

Cada mensaje entrante es un invoke del grafo compilado del bot (BotsService.sendMessage), con thread_id = <botId>:<conversationId> para que LangGraph recupere el estado de la conversación desde el checkpointer.

El checkpointer es uno y compartido (BotCheckpointerService, ADR 0008):

  • Dónde guarda. Con LANGGRAPH_CHECKPOINT_DATABASE_URL usa Postgres, en el esquema langgraph, cerrado a la API porque contiene PHI. Sin ella, o si Postgres falla al arrancar, usa MemorySaver.
  • Cuándo escribe. Un checkpoint por turno (durability: 'exit').
  • Poda. CheckpointRetentionScheduler poda a diario con langgraph.prune_checkpoints:
    • olvida las conversaciones inactivas LANGGRAPH_CHECKPOINT_RETENTION_DAYS (90 días);
    • en las vivas deja los últimos LANGGRAPH_CHECKPOINT_KEEP_LAST (20) checkpoints.
  • Recargar un bot no borra la memoria. El recorrido se invalida por la revisión del flujo; el historial se conserva.

Por dónde entra cada mensaje​

Si la ejecución anterior del hilo terminó, LangGraph trata una entrada normal como una ejecución nueva desde START. Antes eso hacía que un flujo Inicio → Saludo → IA saludara en cada mensaje. Ahora (ADR 0006) hay dos mecanismos:

  • Router en START. Cada nodo IA en modo canal deja en el estado resume = { nodeId, at, revision }. Con settings.on_new_message = 'resume' (el valor por defecto), el router manda el mensaje a ese nodo. Vuelve al primer nodo del flujo si:

    • la revisión no coincide;
    • pasaron más de settings.resume_ttl_hours (24 por defecto);
    • no hay punto de reanudación.

    Lo borran handoff y un end con reset_conversation.

  • "Esperar respuesta" (wait_input). Llama a interrupt(). resolveTurnInput (backend/src/bots/graphs/turn-input.ts) mira graph.getState():

    • Si hay un interrupt vivo, manda el mensaje como Command({ resume, update }). El update lleva el mensaje al historial y el reinicio de acciones.
    • Si el interrupt quedó huérfano porque el nodo ya no existe tras editar el flujo, manda una entrada normal y LangGraph descarta la tarea pendiente.

computeFlowRevision es la huella de tipos, datos, conexiones y ajustes. No incluye posición ni etiqueta. Para probar el TTL sin esperar, el router y los nodos IA leen la hora de configurable.now si viene.

Nada con efectos secundarios justo antes de interrupt() dentro del mismo nodo: al reanudar, LangGraph vuelve a ejecutar el nodo desde el principio.

Entrega paso a paso​

Si el canal pasa onActions (WhatsApp, Instagram, Messenger y Telegram, todos a través de deliverBotTurn en backend/src/channels/bot-turn-delivery.ts), BotsService recorre el grafo con graph.stream (streamMode: ['updates', 'values']). Entrega las acciones de cada nodo en cuanto termina: el saludo sale mientras la IA todavía está pensando.

  • El aviso de IA va delante del primer lote.
  • BotReply.delivered cuenta lo entregado. BotReply.actions lleva solo lo que falló, y deliverBotTurn lo reintenta al final.
  • El webchat y el endpoint HTTP no pasan onActions: para ellos el contrato sigue igual, con todas las acciones al final.
  • El bot de prompt simple no emite acciones por nodo y siempre va por invoke.
  • first_action_ms de la medición es el tiempo hasta el primer lote entregado.

Garantías del turno​

  • Un turno a la vez por conversación. LangGraph no serializa dos invoke simultáneos sobre el mismo hilo: los dos leen el mismo checkpoint y el último en escribir pisa al otro, así que el historial pierde un turno. ConversationTurnLock (backend/src/bots/conversation-turn-lock.ts) los pone en fila:

    • dentro del proceso, con una cola por conversación;
    • entre instancias, con un lock en Redis (BotCacheService.tryAcquireLock).

    Es fail-open: si Redis no responde o el lock no llega en 60 s, el turno corre igual.

  • Acciones del turno. outboundActions solo concatena. Al empezar el turno se reinicia mandando new Overwrite([]) en la entrada, que LangGraph aplica saltándose el reducer. No uses un array vacío como señal de reinicio: así era antes, y cualquier nodo que devolvía [] borraba lo que ya habían emitido otros nodos.

  • Tope de pasos. recursionLimit = FLOW_RECURSION_LIMIT (100). El valor por defecto de LangGraph es 25. Si un flujo lo alcanza, se registra el error y el contacto recibe un mensaje de cortesía, en vez de quedarse sin respuesta.

  • conversationId va aparte del thread_id en configurable. Las herramientas lo usan para acotar a quién pueden tocar; no lo deduzcas del thread_id.

  • Ramas en paralelo. parallel_split abre una arista normal por rama. parallel_join se registra con defer: true: LangGraph lo ejecuta cuando no queda ninguna otra tarea pendiente, así que espera a todas las ramas aunque tengan largos distintos o condiciones por dentro. Con aristas normales corría una vez por rama y duplicaba lo que venía detrás.

  • Nodo IA en segundo plano. Solo escribe su variable. No emite acciones ni entra en el historial de mensajes, y no es punto de reanudación. Tampoco recibe las herramientas marcadas deliversToCustomer (las que existen para enviar algo a la persona) ni sendNow en su contexto: antes las tenía todas, sus mensajes se descartaban y una plantilla de WhatsApp, que sale al instante, llegaba igual al cliente. El editor las muestra bloqueadas. Las demás (agenda, contacto, traspaso) sí actúan.

  • Turno en silencio. Si un flujo no emite acciones (por ejemplo, se pausa en "Esperar respuesta" sin decir nada), no se envía nada. Antes se reenviaba el último mensaje del estado, que podía ser el del propio usuario.

  • Historial que ve el modelo. El reducer de MessagesAnnotation guarda HumanMessage y AIMessage, que no tienen .role. Para convertirlos hay que mirar _getType(): leer .role deja al modelo sin historial.

  • "El mensaje del usuario" es el último mensaje humano, no el último mensaje. Si antes habló un nodo del flujo (por ejemplo un saludo), el último mensaje del estado es del bot. Antes, una condición detrás del saludo evaluaba el saludo, y el nodo IA lo recibía como si lo hubiera escrito la persona. getLastMessageContent busca el último humano. El nodo IA recibe dos cosas por separado:

    • el historial anterior a ese mensaje;
    • lo que el bot ya dijo en el turno, en el prompt, para que no lo repita.
  • Pruebas. createFlowGraph acepta como último argumento { chatModelFactory }, para simular el LLM sin llamar a OpenAI. Hay ejemplos en flow-graph.bugs.spec.ts.

Nodos que leen datos: condición, API y documento​

Pruebas en flow-graph.nodes.spec.ts.

  • condition por sentimiento. Compara palabras completas contra POSITIVE_WORDS (si, yes, ok, claro, bueno, bien, perfecto, gracias), con el mensaje sin tildes y en minúsculas. Antes buscaba subcadenas y "necesito", "casi" o "tokio" salían por yes. Sigue siendo una lista de palabras: "no está bien" también sale por yes. Para algo más fino, un nodo extract_data o llm.
  • URL de api_call. Se arma con resolveUrlTemplate, no con resolveTemplate: cada {{var}} que cae en la ruta, la query o el fragmento va con encodeURIComponent, así que una búsqueda con &, # o espacios ya no parte la petición. Lo literal de la plantilla no se toca (un %20 escrito a mano no se codifica dos veces). Una variable que cae antes de la ruta (esquema, host, puerto, como {{base_url}}/v1) va tal cual. Consecuencia: una variable no puede traer una query entera (?{{qs}}), porque su & y su = se codifican; hay que escribir cada parámetro en la plantilla. Vale igual para la URL de una fuente de API guardada. Headers y body siguen con resolveTemplate.
  • document_source. Lee la fuente de data.data_source_id con DataSourcesService.getById (acotada al tenant), que es lo que guarda el editor. Antes leía solo los vínculos por id de nodo (bot_data_source_nodes), que el editor nunca crea: el nodo dejaba siempre la variable vacía. Los flujos sin data_source_id siguen por ese vínculo antiguo. Si la fuente ya no existe (404), la variable queda vacía y se registra un console.warn; cualquier otro fallo lanza y lo reintenta su retryPolicy.
  • Sin sus servicios, el flujo no se construye. document_source sin dataSourcesService, tenant o id de bot, y catalog_search sin productEmbeddingsService o tenant, lanzan en createFlowGraph con el id del nodo. Antes el nodo no se registraba, sus aristas tumbaban el compile y el bot caía en silencio al flujo vacío START→END. BotsRegistry registra el error y usa el bot simple. El último recurso de compile() (START→END) queda como console.error, no como aviso.

Extraer datos (extract_data)​

buildExtractSchema() arma un JSON Schema con los campos que configuró la persona (tipo, descripción, obligatorio, opciones) y el nodo llama chatModel.withStructuredOutput(schema, { name: 'extraccion', includeRaw: true }): la forma de la respuesta la garantiza el modelo, no un JSON.parse con suerte. Se usa JSON Schema y no zod porque el esquema nace de la configuración del flujo, no del código.

Todo campo admite null —así el modelo puede decir "esto no estaba" en vez de inventarlo— y solo se escriben en variables los que traen valor. Si falta alguno obligatorio el nodo sale por incomplete. includeRaw es lo que permite medir los tokens del nodo con el usageCollector que inyecta measured (misma atribución que el nodo IA, misma facturación).

Repetir por cada elemento (for_each)​

Único sitio donde se usa Send de LangGraph: la arista condicional devuelve un Send por elemento hacia el MISMO nodo (el de la salida item), cada uno con su elemento en variables. El nodo en sí es un pass-through; con la lista vacía o ausente se va por empty.

Cada tarea escribe en el estado común, así que lo que el cuerpo guarde en variables se pisa entre elementos: el cuerpo natural es emitir acciones o llamar a una API. Por eso la validación exige que el cuerpo desemboque en un parallel_join (FOR_EACH_BRANCH_NOT_JOINED): sin él, lo que siga correría una vez por elemento —el mismo bug que se arregló en el paralelo con defer: true—. Tope: 25 elementos por defecto, 100 duro.

Pedir aprobación (require_approval)​

El nodo registra la aprobación (ApprovalsRepository.request, que es el ApprovalsPort que recibe createFlowGraph), emite el aviso opcional a quien escribe y termina su rama: el turno acaba y el bot sigue atendiendo. No es un interrupt y el porqué está en el ADR 0009: con el bot atendiendo, el siguiente mensaje del paciente reanudaría —o directamente descartaría— esa pausa.

La vuelta entra por el campo de estado enterAt, que el router de START honra antes que el punto de reanudación; BotsService lo limpia en cada turno normal para que no se cuele. Por eso el router se instala también cuando el flujo no tiene dónde retomar pero sí tiene aprobaciones.

ApprovalsService.continueOne() resuelve el destino de la rama (approved / rejected, y expired cuenta como rechazo), invoca el grafo, mide el turno con entry = 'approval' y entrega lo que salga con ConversationOutboundService (canal y destinatario salen de la conversación, y lo enviado se guarda en messages). Es idempotente por continued_at, y un fallo al invocar no la cierra: el barrido de ApprovalsScheduler la reintenta cada minuto y vence las de plazo pasado.

Quién puede decidir no se comprueba aquí: lo hace bot_decide_approval en la base, con la sesión del usuario, porque el backend va con service-role y auth.uid() es NULL. El endpoint POST /api/bots/approvals/:id/continue solo empuja la continuación de una fila ya decidida.

Reintentos​

retryPolicyFor() saca los intentos del catálogo (FlowNodeSpec.retry) y los deja sobrescribir con data.retry_attempts; se pasan a addNode(..., { retryPolicy }), con retryOn que excluye isGraphBubbleUp para no reintentar una pausa (interrupt). Solo sirve para los nodos que lanzan: llm, extract_data, document_source.

api_call no lanza nunca (su contrato es dejar el error en la variable), así que reintenta por dentro de executeApiCall, y solo lo pasajero: 5xx, 408, 429, timeouts y cortes de red. Un 404 o un 401 no se repiten.

Cada intento fallido deja su propia fila error en bot_node_runs: es lo que permite ver qué paso reintenta más.

Modelo por nodo​

Los nodos llm y extract_data pueden fijar su modelo con llm_provider (openai, anthropic, google), llm_model (el ai_models.model_code) y llm_temperature (0–1). Sin llm_model usan botConfig.llm, como siempre. Decisión y alcance en el ADR 0011.

Todo pasa por backend/src/bots/graphs/chat-model-factory.ts:

  • resolveNodeModel() decide el modelo al compilar el grafo. Si el proveedor no es de los tres, falta su clave (ANTHROPIC_API_KEY / GOOGLE_API_KEY) o el modelo no está activo en el catálogo, avisa en el log y el nodo cae al modelo del bot. Nunca rompe el flujo.
  • El catálogo activo (ai_models + ai_providers con status = 'active') lo lee SupabaseBotsRepository.getActiveNodeModels() y BotsRegistry lo guarda un minuto. Si la lectura falla devuelve un conjunto vacío: todos los nodos usan el modelo del bot.
  • defaultChatModelFactory(botConfig, override) crea ChatOpenAI, ChatAnthropic o ChatGoogleGenerativeAI. Los params del bot no se arrastran a otro modelo.

Diferencias entre proveedores que el código ya absorbe:

QuéOpenAIGoogleClaude clásico (Haiku 4.5, Sonnet/Opus 4.x)Claude reciente (Sonnet 5 en adelante)
tool_choice para forzar herramientarequiredanyanyauto (la API da 400 con any)
temperaturesísísíno se envía (400)
"Extraer datos" (withStructuredOutput)por defectoesquema sin tipos en lista (schemaForProvider)method: 'jsonSchema'method: 'jsonSchema'

LangChain llama a Claude con thinking: disabled. Por eso el catálogo solo trae modelos que lo aceptan: Opus 5.5 y Fable 5.1 lo rechazan con un 400.

La medición anota el proveedor efectivo del nodo en cada llamada (recordLangChain(..., provider)) y lee el modelo de response_metadata.model_name o, en Anthropic, de response_metadata.model. Así bot_node_runs.model sale por nodo y el costo sale de model_pricing, que la migración 20261106000001_modelos_por_nodo.sql siembra para los modelos nuevos.

Subflujos (subflow)​

Un subflujo es un flujo guardado en la biblioteca del tenant (bot_subflows y bot_subflow_versions) que un bot inserta fijando una versión concreta. No se compila como subgrafo de LangGraph: se aplana antes de construir el StateGraph (backend/src/bots/graphs/subflow-expand.ts), sustituyendo el nodo por los pasos de esa versión con los ids prefijados sf__<idDelNodo>__<idInterno>. El porqué está en el ADR 0010; en corto: en este motor todo se indexa por el id plano del nodo (router de START, medición, aprobaciones, Send, la guarda de interrupts), y un subgrafo real convertiría el subflujo en un único nodo para todo eso.

El orden, en BotsRegistry (registerTenant y reloadBot), es exactamente este:

  1. Resolver las versiones fijadas (SubflowResolverService, con caché por versionId sin caducidad: las versiones son inmutables).
  2. Expandir (expandSubflows). Sin subflujos devuelve el MISMO objeto, así que la revisión de un flujo de siempre no se mueve.
  3. Validar el resultado: los errores van al log y a buildIssues, pero no bloquean la compilación (romper un bot en producción por una regla nueva sería peor).
  4. Compilar con el flujo expandido.
  5. Registrar la revisión (computeFlowRevision del expandido) con ese mismo flujo, para que las etiquetas de los pasos internos lleguen a bot_flow_revisions.nodes.

El resultado queda en BotGraphInstance (expandedFlow, flowRevision, buildIssues). Quien case un id de nodo con el grafo tiene que mirar ahí, no botConfig.flowConfig: hoy lo hacen la medición, ApprovalsService.continueOne (busca la rama de continuación por node_id) y ConversationStateService.inspect (etiquetas y resumeStale).

Un nodo subflow que llega sin resolver al runtime lanza: es la única forma de que un paso que puede ser "verificar identidad" no se salte en silencio.

Medición por turno, nodo y herramienta​

Cada turno lleva un FlowTurnMeter (backend/src/bots/metering/) en configurable.turnMeter. createFlowGraph registra cada nodo a través de measured(), que anota:

  • orden y duración;
  • resultado: ok, error, o interrupted para "Esperar respuesta";
  • rama tomada;
  • acciones emitidas, por tipo.

El nodo recibe como usageCollector un collector hijo (TokenUsageCollector.scope()). Suma a la vez en el nodo y en el del turno, que es el de facturación, así que atribuir tokens a un nodo no cambia lo que se cobra.

El bucle del agente anota iteraciones y cada llamada a herramienta:

  • Qué se guarda: nombre, duración y resultado (ok, error, duplicate_skipped o unavailable).
  • Qué no se guarda nunca: ni argumentos ni resultado.

BotsService cierra el turno con su salida: end, interrupt, handoff, error o recursion_limit. También mide los turnos que no llegan al grafo (skipped_paused, skipped_keyword), para que el volumen cuadre con los mensajes recibidos. BotMeteringService.record lo guarda con una sola llamada al RPC record_bot_turn. Es best-effort, igual que el consumo, y deja una línea [bot-turn] en JSON en el log.

Dónde queda y cómo se consulta​

La migración 20261020000001_bot_flow_metering.sql y el ADR 0007 dejan tres tipos de tabla:

TablaQué guardaCuánto dura
bot_turns, bot_node_runs, bot_tool_callsDetalle de cada turno, nodo y herramientaBOT_METERING_RAW_RETENTION_DAYS (180 días)
bot_turns_daily, bot_node_daily, bot_tool_dailyResumen por día local del tenantSiempre
bot_flow_revisionsTipo y etiqueta de cada nodo por revisiónSiempre
  • Motor de reportería: fuentes bots.turns, bots.node_runs, bots.tool_calls, bots.turns_daily y bots.nodes_daily, con el permiso bot.view. Aparecen solas en el constructor de reportes.
  • p95 y embudo por nodo: bot_node_stats(tenant, bot, desde, hasta, revisión?). Admite a quien tiene bot.view en ese tenant y al backend (service_role).
  • Plataforma: bot_metering_platform_summary(desde, hasta), solo para superadmin.
  • Resumen y purga: BotMeteringScheduler recalcula los últimos 3 días cada hora y purga lo crudo una vez al día.

Alertas y tope diario​

La migración 20261020000002_bot_alerts.sql añade bot_alert_rules y bot_alerts.

  • Métricas vigiladas. Se evalúan sobre bot_turns_daily, por bot y día local:
    • error_rate: por defecto 10 %, con un mínimo de 20 mensajes;
    • recursion_limits: por defecto 1;
    • p95_ms: por defecto 30 s, con un mínimo de 20 mensajes;
    • daily_cost y daily_tokens: el tope diario, sin defecto, solo si se configura.
  • Precedencia de reglas. Primero la del bot, luego la de todo el tenant (bot_id NULL) y al final el defecto. Una regla desactivada del bot apaga esa métrica para ese bot.
  • El tope diario solo avisa. Es una decisión de producto: nada toca el camino del mensaje. BotMeteringScheduler llama a bot_evaluate_alerts justo después del resumen.
  • Una alerta por bot, métrica y día. Su valor se actualiza mientras nadie la dé por vista. Una vez vista, no se reabre ese día.
  • Acceso.
    • Leer: bot.view.
    • Umbrales: bot.update, con bot_upsert_alert_rule y bot_delete_alert_rule, que comprueban además que el bot sea del tenant.
    • Dar por vista: bot.view, con bot_acknowledge_alert.
    • Evaluar: solo service_role.
  • Dónde se ven. En la tarjeta Métricas del bot y en la campana (useBotAlerts, que consulta cada 5 minutos).

Pruebas de acceso: supabase/tests/bot_metering (medición y alertas). Si Docker no arranca, se pueden correr en PGlite (ver la memoria del equipo sobre worktrees).

Sin PHI

Las tablas de métricas se leen con el permiso bot.view, más amplio que el de la historia clínica. Por eso ni el medidor ni el log llevan textos: ni mensajes, ni argumentos de herramientas, ni valores de variables. Los errores se guardan como código (errorCodeOf), nunca como mensaje. Si añades un campo, que sea un identificador, un tipo, un tiempo o un contador.

Tools​

Las herramientas viven en backend/src/bots/tools/, agrupadas por dominio.

GrupoQué hacen
Catálogosearch_catalog: búsqueda semántica sobre productos, con filtro por catálogos fijados, número de resultados y puntuación mínima
AgendaConsultar disponibilidad, listar recursos reservables, reservar, listar las citas del contacto, cancelar, confirmar y resolver a nombre de quién queda la reserva
SaludEnviar al paciente su propio documento clínico por enlace temporal
ContactosDistinguir si quien escribe es un contacto ya registrado
Integraciónconsultar_fuente: llama a una fuente API guardada del bot. enviar_aviso: avisa a un destino fijo por clave (ver abajo)

Qué tools recibe un bot en cada ejecución depende de tres filtros encadenados: los módulos activos del tenant, la configuración del bot (tools_config) y, si es un flujo, las tools desactivadas en ese nodo.

Tools que salen del bot (tools/integration/)​

Las dos se configuran en tools_config y se leen con BotToolsConfigService (caché de 60 s por bot, invalidada al recargar el bot).

consultar_fuente ({ fuente, parametros }, config consultar_fuente: { enabled, source_ids }).

  • La IA elige una fuente por nombre entre las habilitadas; nunca pasa una URL. Solo cuentan fuentes source_type='api' de este bot. Las de alcance global (bot_id null) no se exponen.
  • parametros rellena solo los {{...}} de la fuente, nunca las variables del flujo. Se rechazan claves reservadas (channel, last_message) y valores de más de 200 caracteres.
  • La petición la arma buildApiRequest (graphs/api-call-request.ts), el mismo helper del nodo api_call. La herramienta usa resolveJsonBodyTemplate, que escapa los valores dentro de un string JSON; el nodo mantiene su sustitución cruda (una variable lista sigue funcionando).
  • Salida con makeSafeFetch (tools/integration/safe-fetch.ts): solo https, IP privada, link-local o de metadatos bloqueada después de resolver el DNS, y sin seguir redirecciones.
  • 10 s, un intento, tope de 3 llamadas por ejecución (contado de forma síncrona: las tools de una respuesta corren en paralelo). El resultado va envuelto como datos, no instrucciones, y recortado a 4 000 caracteres. Traza en API_CALL_DEBUG_BOTS con nodeId: 'tool:consultar_fuente'.

enviar_aviso ({ destino, asunto, mensaje }, config enviar_aviso: { enabled, destinos[] }).

  • destino es una clave; cada destino es { clave, descripcion, canal, contact_id } y una clave puede tener varios. El panel comprueba que el contacto sea del tenant del bot.
  • Envía con routeToContact + ChannelDispatcherRegistry.dispatch, igual que el nodo "Enviar mensaje", con target.botId puesto (el lease del sandbox se comprueba con el bot correcto).
  • El cuerpo va en una envoltura fija del servidor. Tope por conversación, clave y día con RateLimitService (3 por defecto, max_por_conversacion_dia).
  • Por WhatsApp solo llega dentro de la ventana de 24 h del destinatario.

Documento registrado (config documento_registrado: { enabled }, tools/integration/documento-registrado.ts). Con la opción, get_my_contact añade documento: { tipo, numero } para que el bot confirme la cédula de un contacto ya registrado en vez de pedirla. El número sale de ContactsService.getDocument(tenantId, contactId), con el contacto resuelto desde la conversación, nunca desde un argumento. Apagado por defecto: quien escribe desde un teléfono compartido puede no ser el titular de la ficha.

El panel escribe con updateBotToolConfigKey (frontend/src/actions/bot-outbound-tools.ts), que cambia solo su clave de tools_config tras comprobar sesión, bot.update sobre el tenant del bot y la pertenencia de fuentes y contactos.

Añadir una tool​

  1. Crea el archivo en backend/src/bots/tools/<dominio>/<nombre>.tool.ts.
  2. Descríbela bien. La descripción de la tool es lo que lee el modelo para decidir si la usa: una descripción vaga produce un bot que la llama cuando no debe, o que no la llama nunca.
  3. Añádela a ALL_TOOL_FACTORIES (tools/registry.ts), con su isAvailable (módulo, config). Si depende de un servicio nuevo, pásalo también al manifiesto GET agent-tools de bots.controller.ts: sin él la factory devuelve null y la tool desaparece del selector del editor sin avisar.
  4. Si necesita configuración por bot, añade el bloque a tools_config y una tarjeta en la pantalla de edición del bot (mira BotSchedulingCard como ejemplo). Guarda solo tu clave, sobre lo que hay ahora: una tarjeta que escribe tools_config entero borra lo de las demás.
  5. Documenta qué hace en el manual de usuario, porque el usuario final la va a ver funcionar sin saber por qué.

Depurar​

DEBUG_LOGS=true imprime, con prefijo [dbg:, qué tools resolvió el bot, cuáles descartó y por qué (módulo, canal, disponibilidad), con qué argumentos exactos las llamó el modelo, las excepciones que acaban convertidas en una frase amable, y cada rama de salida de la creación de citas con su código de error de Postgres.

docker compose logs backend | grep '\[dbg:'
No la dejes encendida

Con DEBUG_LOGS activo se imprimen datos reales de conversación: nombres, teléfonos, correos. Las claves y tokens sí se tapan, pero el resto no. Es para depurar un rato, no para dejarla puesta.

Qué devolvió la API de un flujo​

El nodo "Llamada a API" nunca lanza: la respuesta o el error (HTTP 401, timeout) quedan en la variable del nodo y el modelo los parafrasea, así que en el log no queda rastro. Para ver qué devolvió la API, enciende la traza solo para ese bot con su id:

API_CALL_DEBUG_BOTS=<bot_id> # o varios separados por coma, o * para todos
docker logs -f xenpia-chatbot-backend-1 2>&1 | grep '\[api_call:debug\]'

Sale una línea JSON por llamada con el nodo, el método, la URL, el cuerpo enviado, el estado, los milisegundos y la respuesta recortada a 4000 caracteres. Nunca imprime cabeceras, que es donde viaja el token, y tapa los parámetros de la URL con nombre de secreto. La variable se lee en cada llamada: basta cambiarla en el .env y recrear el contenedor, sin reconstruir la imagen. Como la respuesta puede traer datos del cliente, apágala al terminar.

Estado del bot en una conversación​

En la bandeja, menú ⋮ → Estado del bot de cualquier conversación. Lo sirve el backend:

MétodoRutaPermiso
GET/api/bots/conversations/:conversationId/state?tenant_id=bot.view
GET/api/bots/conversations/:conversationId/state/history?tenant_id=bot.view
POST/api/bots/conversations/:conversationId/state/replay (body tenant_id, checkpoint_id, message)bot.update
POST/api/bots/conversations/:conversationId/state/reset (body tenant_id)bot.update

PermissionGuard contrasta el tenant con las membresías reales del token, y ConversationStateService comprueba además que la conversación sea de ese tenant (404 si no). El bot sale del último bot_turns de la conversación y el hilo es <botId>:<conversationId>.

Devuelve solo estructura, nunca contenido:

  • dónde está: el nodo de un wait_input pendiente o el resume con la etiqueta del nodo, y si ya caducó (TTL o flujo cambiado);
  • cuántos mensajes tiene su memoria y los nombres de sus variables, sin valores;
  • los últimos turnos con su entrada, salida y los nodos recorridos (de bot_node_runs);
  • si la memoria es postgres o memory (en RAM). Con memory el panel avisa.

Historial y reproducción. history() recorre getStateHistory y devuelve un punto por mensaje (hay uno por turno porque los canales invocan con durability: 'exit', y la poda deja los últimos LANGGRAPH_CHECKPOINT_KEEP_LAST): checkpoint, fecha, next, nodo de pausa, cuántos mensajes y los NOMBRES de las variables. Nada de contenido: los checkpoints son PHI y bot.view es más amplio que poder leer la conversación.

replay() toma los valores de ese punto, los siembra en un MemorySaver desechable y corre el simulador en seco de la fase 7 con el mensaje que teclee el agente. Los nodos con efectos (send_message, api_call, tools con sendNow) están stubbeados, la IA responde un texto fijo y no se escribe medición ni se toca el hilo real. El mensaje lo escribe quien depura en vez de sacarlo del checkpoint, así que tampoco sale texto del paciente hacia el panel. La reanudación se normaliza a la revisión de hoy: se reproduce contra el flujo actual, que es lo que se quiere al comprobar un arreglo.

Rebobinar el hilo vivo no existe a propósito: bifurcar desde un checkpoint viejo volvería a ejecutar los nodos con efectos y avanzaría la conversación real. Para desatascar está el reinicio.

Reiniciar memoria del bot hace deleteThread en el checkpointer: el siguiente mensaje empieza desde Inicio, sin historial ni variables. Los mensajes del chat y la medición no se tocan.

LangSmith (opcional, sin contenido)​

LangChain manda trazas a LangSmith en cuanto el .env tiene LANGSMITH_TRACING=true (o LANGCHAIN_TRACING_V2 / LANGCHAIN_TRACING). Como cada traza llevaría mensajes y prompts con PHI, enforceLangSmithPrivacy() (bots/observability/langsmith-privacy.ts) corre al arrancar main.ts y, si hay trazas, fuerza LANGSMITH_HIDE_INPUTS/OUTPUTS y LANGCHAIN_HIDE_INPUTS/OUTPUTS a true, pase lo que pase en el .env. Se ven el árbol de llamadas, los tiempos y los errores.

Apagado en producción

No se enciende en PROD sin un acuerdo de tratamiento de datos con LangSmith: aunque el contenido vaya oculto, los metadatos salen del servidor.

Caché​

El runtime del bot cachea en Redis los embeddings, las búsquedas de catálogo, el conocimiento, la agenda y las respuestas de API, cada uno con su TTL configurable. Todos los TTL están en las variables de entorno con el prefijo BOT_CACHE_. Poner un TTL a 0 desactiva esa capa, que es lo que se hace al depurar comportamientos raros de caché.

Consumo​

Cada ejecución registra los tokens gastados, agregados por tenant y por modelo. Es lo que alimenta la pantalla de consumo y el cálculo de excedentes en facturación.