feat(umind): vigilancia de APIs — avisa cuando pasa algo, sin quemar plata

"Avisame cuando esta API devuelva stock en cero." La vigilancia apunta a una
HERRAMIENTA ya configurada del agente y nunca a una URL libre: así hereda
entero el blindaje de LlamarHerramientaWebhook —DNS resuelto en el connect,
IPs internas rechazadas, sin redirects, respuesta acotada— sin reimplementar
una línea de eso. Hay un test que falla si alguien le agrega un campo url.

El ahorro que hace viable la feature: se hashea la respuesta y, si no cambió,
no hay nada que evaluar y no se gasta un token. Preguntarle a la IA en cada
chequeo serían 1440 consultas diarias por cliente para responder algo que ya
sabíamos. El test verifica que el corte por hash esté ANTES de la evaluación.

Y se avisa al ENTRAR en condición, no en cada chequeo que la siga cumpliendo:
una alerta que llega cada media hora deja de leerse a la segunda. Es la lección
que el monitor de sitios ya había aprendido con las transiciones de estado.

Solo un SI explícito dispara el aviso. Un modelo que devuelve una explicación,
un error o cualquier otra cosa se toma como que no: un aviso que no llega
molesta menos que uno que despierta a alguien a las 3 AM sin motivo.

Si el envío del aviso falla, no se marca como avisado — se reintenta al
siguiente ciclo. Y tras diez fallos seguidos la vigilancia se apaga sola: una
API que dejó de existir no puede consultarse para siempre ni seguir gastando el
plan de quien la configuró.

Máximo 5 por agente, mínimo 5 minutos de intervalo. Solo canal interno: cada
vigilancia es una llamada saliente recurrente y consultas a la IA, o sea plan
del dueño gastado por quien la pida.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-24 21:43:43 -05:00
co-authored by Claude Opus 5
parent 047837dd23
commit 7ddc3f5286
10 changed files with 520 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
package services
import (
"encoding/json"
"fmt"
"strings"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// Las tools con las que el dueño arma vigilancias conversando. Solo canal
// interno: cada vigilancia es una llamada saliente recurrente y consultas a la
// IA, o sea plan del dueño gastado por quien la pida.
func umindVigilanciaTools(agenteID uint) []agentTool {
herramientas, err := models.GetUmindHerramientasActivas(agenteID)
if err != nil || len(herramientas) == 0 {
return nil // sin herramientas no hay nada que vigilar
}
nombres := make([]string, len(herramientas))
for i, h := range herramientas {
nombres[i] = h.Nombre
}
return []agentTool{
{Type: "function", Function: agentToolFunc{
Name: "crear_vigilancia",
Description: "Programa una vigilancia sobre una API: consulta cada cierto tiempo y avisa cuando se cumple una condición. " +
"Herramientas disponibles para vigilar: " + strings.Join(nombres, ", "),
Parameters: agentToolParam{
Type: "object",
Properties: map[string]agentToolParam{
"nombre": {Type: "string", Description: "Cómo llamarla, en pocas palabras"},
"herramienta": {Type: "string", Description: "Nombre de la herramienta a consultar"},
"condicion": {Type: "string", Description: "Qué tiene que pasar para avisar, en lenguaje natural"},
"cada_minutos": {Type: "number", Description: "Cada cuántos minutos revisar (mínimo 5, por defecto 30)"},
},
Required: []string{"nombre", "herramienta", "condicion"},
},
}},
{Type: "function", Function: agentToolFunc{
Name: "listar_vigilancias",
Description: "Lista las vigilancias activas sobre APIs.",
Parameters: agentToolParam{Type: "object", Properties: map[string]agentToolParam{}, Required: []string{}},
}},
{Type: "function", Function: agentToolFunc{
Name: "cancelar_vigilancia",
Description: "Apaga una vigilancia. Usá listar_vigilancias para saber el id.",
Parameters: agentToolParam{
Type: "object",
Properties: map[string]agentToolParam{"id": {Type: "number", Description: "El id de la vigilancia"}},
Required: []string{"id"},
},
}},
}
}
func executeUmindVigilanciaTool(agenteID uint, sessionID, name string, args map[string]interface{}) string {
switch name {
case "crear_vigilancia":
nombre, _ := args["nombre"].(string)
herramienta, _ := args["herramienta"].(string)
condicion, _ := args["condicion"].(string)
if strings.TrimSpace(nombre) == "" || strings.TrimSpace(condicion) == "" {
return `{"error": "falta el nombre o la condición"}`
}
// La herramienta tiene que existir y ser de este agente: es lo que
// hace que la vigilancia no pueda apuntar a una URL arbitraria.
if _, err := models.GetUmindHerramientaByNombre(agenteID, herramienta); err != nil {
return fmt.Sprintf(`{"error": "no existe una herramienta llamada %q en este agente"}`, herramienta)
}
cada := 30
if n, ok := args["cada_minutos"].(float64); ok && int(n) >= models.UmindVigilanciaIntervaloMin {
cada = int(n)
}
agente, err := models.GetUmindAgenteByID(agenteID)
if err != nil {
return `{"error": "no se pudo crear la vigilancia"}`
}
v := &models.UmindVigilancia{
AgenteID: agenteID, TenantID: agente.TenantID, Nombre: nombre,
Herramienta: herramienta, Condicion: condicion,
IntervaloMin: cada, Destino: sessionID, Activa: true,
}
if err := models.CreateUmindVigilancia(v); err != nil {
return fmt.Sprintf(`{"error": "no se pudo crear: ya tenés el máximo de %d vigilancias activas"}`, models.UmindVigilanciaMax)
}
b, _ := json.Marshal(map[string]interface{}{"ok": true, "id": v.ID, "cada_minutos": cada})
return string(b)
case "listar_vigilancias":
items, err := models.GetUmindVigilanciasByAgente(agenteID)
if err != nil {
return `{"error": "no se pudieron listar"}`
}
lista := []map[string]interface{}{}
for _, v := range items {
if !v.Activa {
continue
}
lista = append(lista, map[string]interface{}{
"id": v.ID, "nombre": v.Nombre, "condicion": v.Condicion,
"cada_minutos": v.IntervaloMin, "en_condicion": v.EnCondicion,
})
}
b, _ := json.Marshal(map[string]interface{}{"vigilancias": lista})
return string(b)
case "cancelar_vigilancia":
id, ok := args["id"].(float64)
if !ok || id <= 0 {
return `{"error": "falta el id"}`
}
v, err := models.GetUmindVigilanciaByID(uint(id))
if err != nil || v.AgenteID != agenteID {
return `{"error": "esa vigilancia no existe"}`
}
if err := models.DeleteUmindVigilancia(uint(id)); err != nil {
return `{"error": "no se pudo cancelar"}`
}
return `{"ok": true}`
}
return `{"error": "herramienta desconocida"}`
}