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 yget_flow_schemalistan exactamente esos tipos.createFlowGraphtiene undefault: neveren el alta de nodos: un tipo nuevo sin implementación no compila.validateFlowConfigrechaza 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 deshared; los valores los copia y el test los compara, para no meter código deshareden 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:
| Tool | Qué hace | Seguridad |
|---|---|---|
simulate_flow | Corre 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_metrics | Mé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_revisions | Las mismas métricas para dos versiones guardadas (revisión = computeFlowRevision) | Igual |
inspect_conversation_state | ConversationStateService.inspect sobre una conversación | Tenant 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ónde | Qué hace con ellos |
|---|---|
Editor (validateFlow) | Error en el nodo; no impide guardar |
simulateFlow | Los baja a warning y simula igual (en seco esos nodos son stubs) |
save_flow_version | Guarda como borrador y devuelve pendingTenantData; con setActive: true responde flow_needs_tenant_data |
activate_flow_version | flow_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_URLusa Postgres, en el esquemalanggraph, cerrado a la API porque contiene PHI. Sin ella, o si Postgres falla al arrancar, usaMemorySaver. - Cuándo escribe. Un checkpoint por turno (
durability: 'exit'). - Poda.
CheckpointRetentionSchedulerpoda a diario conlanggraph.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.
- olvida las conversaciones inactivas
- 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 estadoresume = { nodeId, at, revision }. Consettings.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
handoffy unendconreset_conversation. -
"Esperar respuesta" (
wait_input). Llama ainterrupt().resolveTurnInput(backend/src/bots/graphs/turn-input.ts) miragraph.getState():- Si hay un interrupt vivo, manda el mensaje como
Command({ resume, update }). Elupdatelleva 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.
- Si hay un interrupt vivo, manda el mensaje como
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.deliveredcuenta lo entregado.BotReply.actionslleva solo lo que falló, ydeliverBotTurnlo 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_msde 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
invokesimultá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.
outboundActionssolo concatena. Al empezar el turno se reinicia mandandonew 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. -
conversationIdva aparte delthread_idenconfigurable. Las herramientas lo usan para acotar a quién pueden tocar; no lo deduzcas delthread_id. -
Ramas en paralelo.
parallel_splitabre una arista normal por rama.parallel_joinse registra condefer: 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) nisendNowen 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
MessagesAnnotationguardaHumanMessageyAIMessage, que no tienen.role. Para convertirlos hay que mirar_getType(): leer.roledeja 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.
getLastMessageContentbusca 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.
createFlowGraphacepta como último argumento{ chatModelFactory }, para simular el LLM sin llamar a OpenAI. Hay ejemplos enflow-graph.bugs.spec.ts.
Nodos que leen datos: condición, API y documento
Pruebas en flow-graph.nodes.spec.ts.
conditionpor sentimiento. Compara palabras completas contraPOSITIVE_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 poryes. Sigue siendo una lista de palabras: "no está bien" también sale poryes. Para algo más fino, un nodoextract_dataollm.- URL de
api_call. Se arma conresolveUrlTemplate, no conresolveTemplate: cada{{var}}que cae en la ruta, la query o el fragmento va conencodeURIComponent, así que una búsqueda con&,#o espacios ya no parte la petición. Lo literal de la plantilla no se toca (un%20escrito 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 conresolveTemplate. document_source. Lee la fuente dedata.data_source_idconDataSourcesService.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 sindata_source_idsiguen por ese vínculo antiguo. Si la fuente ya no existe (404), la variable queda vacía y se registra unconsole.warn; cualquier otro fallo lanza y lo reintenta suretryPolicy.- Sin sus servicios, el flujo no se construye.
document_sourcesindataSourcesService, tenant o id de bot, ycatalog_searchsinproductEmbeddingsServiceo tenant, lanzan encreateFlowGraphcon 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.BotsRegistryregistra el error y usa el bot simple. El último recurso decompile()(START→END) queda comoconsole.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_providersconstatus = 'active') lo leeSupabaseBotsRepository.getActiveNodeModels()yBotsRegistrylo guarda un minuto. Si la lectura falla devuelve un conjunto vacío: todos los nodos usan el modelo del bot. defaultChatModelFactory(botConfig, override)creaChatOpenAI,ChatAnthropicoChatGoogleGenerativeAI. Losparamsdel bot no se arrastran a otro modelo.
Diferencias entre proveedores que el código ya absorbe:
| Qué | OpenAI | Claude clásico (Haiku 4.5, Sonnet/Opus 4.x) | Claude reciente (Sonnet 5 en adelante) | |
|---|---|---|---|---|
tool_choice para forzar herramienta | required | any | any | auto (la API da 400 con any) |
temperature | sí | sí | sí | no se envía (400) |
"Extraer datos" (withStructuredOutput) | por defecto | esquema 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:
- Resolver las versiones fijadas (
SubflowResolverService, con caché porversionIdsin caducidad: las versiones son inmutables). - Expandir (
expandSubflows). Sin subflujos devuelve el MISMO objeto, así que la revisión de un flujo de siempre no se mueve. - 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). - Compilar con el flujo expandido.
- Registrar la revisión (
computeFlowRevisiondel expandido) con ese mismo flujo, para que las etiquetas de los pasos internos lleguen abot_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, ointerruptedpara "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_skippedounavailable). - 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:
| Tabla | Qué guarda | Cuánto dura |
|---|---|---|
bot_turns, bot_node_runs, bot_tool_calls | Detalle de cada turno, nodo y herramienta | BOT_METERING_RAW_RETENTION_DAYS (180 días) |
bot_turns_daily, bot_node_daily, bot_tool_daily | Resumen por día local del tenant | Siempre |
bot_flow_revisions | Tipo y etiqueta de cada nodo por revisión | Siempre |
- Motor de reportería: fuentes
bots.turns,bots.node_runs,bots.tool_calls,bots.turns_dailyybots.nodes_daily, con el permisobot.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 tienebot.viewen ese tenant y al backend (service_role). - Plataforma:
bot_metering_platform_summary(desde, hasta), solo para superadmin. - Resumen y purga:
BotMeteringSchedulerrecalcula 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_costydaily_tokens: el tope diario, sin defecto, solo si se configura.
- Precedencia de reglas. Primero la del bot, luego la de todo el tenant (
bot_idNULL) 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.
BotMeteringSchedulerllama abot_evaluate_alertsjusto 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, conbot_upsert_alert_ruleybot_delete_alert_rule, que comprueban además que el bot sea del tenant. - Dar por vista:
bot.view, conbot_acknowledge_alert. - Evaluar: solo
service_role.
- Leer:
- 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).
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.
| Grupo | Qué hacen |
|---|---|
| Catálogo | search_catalog: búsqueda semántica sobre productos, con filtro por catálogos fijados, número de resultados y puntuación mínima |
| Agenda | Consultar disponibilidad, listar recursos reservables, reservar, listar las citas del contacto, cancelar, confirmar y resolver a nombre de quién queda la reserva |
| Salud | Enviar al paciente su propio documento clínico por enlace temporal |
| Contactos | Distinguir si quien escribe es un contacto ya registrado |
| Integración | consultar_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_idnull) no se exponen. parametrosrellena 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 nodoapi_call. La herramienta usaresolveJsonBodyTemplate, 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): solohttps, 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_BOTSconnodeId: 'tool:consultar_fuente'.
enviar_aviso ({ destino, asunto, mensaje }, config enviar_aviso: { enabled, destinos[] }).
destinoes 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", contarget.botIdpuesto (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
- Crea el archivo en
backend/src/bots/tools/<dominio>/<nombre>.tool.ts. - 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.
- Añádela a
ALL_TOOL_FACTORIES(tools/registry.ts), con suisAvailable(módulo, config). Si depende de un servicio nuevo, pásalo también al manifiestoGET agent-toolsdebots.controller.ts: sin él la factory devuelve null y la tool desaparece del selector del editor sin avisar. - Si necesita configuración por bot, añade el bloque a
tools_configy una tarjeta en la pantalla de edición del bot (miraBotSchedulingCardcomo ejemplo). Guarda solo tu clave, sobre lo que hay ahora: una tarjeta que escribetools_configentero borra lo de las demás. - 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:'
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étodo | Ruta | Permiso |
|---|---|---|
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_inputpendiente o elresumecon 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
postgresomemory(en RAM). Conmemoryel 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.
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.