fix(umind): el agente descartaba la respuesta que ya había escrito

Cuando se agotan las tres rondas de herramientas sin una respuesta final, el
visitante recibe "dame un poco más de detalle". Pero un modelo puede mandar
texto Y pedir una herramienta en el mismo turno, y ese texto se tiraba: si
después se agotaban las rondas, la persona recibía el mensaje genérico aunque
el agente ya le hubiera contestado bien.

Ahora se guarda lo último que escribió y se usa antes de caer en el genérico.

Y el evento que quedaba en Auditoría decía "se agotaron las rondas" sin nada
más, que no alcanza para saber dónde mirar. Ahora registra qué herramientas
pidió en orden, y cada vez que una devuelve error queda su propio evento con la
respuesta cruda. Una herramienta que falla y un modelo que la reintenta es la
forma más común de agotar las rondas, y hasta ahora había que deducirlo.

Esto no arregla la causa de un agente puntual que responde el genérico a todo
—esa sale de los eventos que ahora sí se registran— pero deja de esconder
respuestas buenas y dice dónde buscar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-21 16:19:17 -05:00
co-authored by Claude Opus 5
parent b125ceef95
commit 44686d409a
2 changed files with 98 additions and 3 deletions
+61
View File
@@ -0,0 +1,61 @@
package services
import (
"strings"
"testing"
)
// Cuando se agotan las rondas de herramientas, el visitante recibía un mensaje
// genérico aunque el modelo ya le hubiera escrito una respuesta completa: el
// texto que viene junto con una llamada a herramienta se descartaba.
//
// Se prueba la lógica de selección, que es la que decide qué ve la persona.
func TestSePrefiereElTextoDelModeloAlMensajeGenerico(t *testing.T) {
const generico = "Un momento, por favor — dame un poco más de detalle sobre lo que necesitas."
elegir := func(final, parcial string) string {
if final != "" {
return final
}
if strings.TrimSpace(parcial) != "" {
return parcial
}
return generico
}
casos := []struct {
nombre string
final string
parcial string
esperado string
}{
{"respuesta normal", "Los 4 pilares son…", "", "Los 4 pilares son…"},
{"se agotaron las rondas pero el modelo había escrito", "", "Los 4 pilares son…", "Los 4 pilares son…"},
{"el modelo nunca escribió nada", "", "", generico},
{"texto parcial en blanco no cuenta", "", " \n ", generico},
}
for _, c := range casos {
if got := elegir(c.final, c.parcial); got != c.esperado {
t.Errorf("%s: eligió %q, esperaba %q", c.nombre, got, c.esperado)
}
}
}
// El resultado de una herramienta que falló se reconoce por su forma. Si esa
// detección se rompe, el evento de diagnóstico deja de registrarse y "se
// agotaron las rondas" vuelve a no decir dónde mirar.
func TestSeDetectaElErrorDeUnaHerramienta(t *testing.T) {
casos := map[string]bool{
`{"error": "consulta requerida"}`: true,
` {"error": "el servicio de embeddings no está configurado"}`: true,
`{"resultados": ["texto"]}`: false,
`{"resultados": [], "nota": "No se encontró información relacionada"}`: false,
}
for entrada, esError := range casos {
got := strings.HasPrefix(strings.TrimSpace(entrada), `{"error"`)
if got != esError {
t.Errorf("%q: detectado como error = %v, esperaba %v", entrada, got, esError)
}
}
}
+37 -3
View File
@@ -283,6 +283,13 @@ func ProcessWidgetMessage(agente *models.UmindAgente, sessionID, userText string
_ = models.SaveUmindMensaje(agente.ID, sessionID, "user", userText)
var finalResponse string
// Lo último que el modelo llegó a escribir, aunque haya venido acompañado
// de una llamada a herramienta.
var ultimoTextoParcial string
// Para el evento de diagnóstico: sin saber qué herramienta pidió en cada
// ronda, "se agotaron las rondas" no dice dónde mirar.
var herramientasPedidas []string
for round := 0; round < 3; round++ {
aiMsg, tokens, err := callAI(&ai, messages, tools)
if err != nil {
@@ -314,11 +321,28 @@ func ProcessWidgetMessage(agente *models.UmindAgente, sessionID, userText string
break
}
// Un modelo puede mandar texto Y pedir herramientas en el mismo turno.
// Ese texto se descartaba: si después se agotaban las rondas, el
// visitante recibía el mensaje genérico aunque el agente ya le hubiera
// escrito una respuesta buena.
if s, ok := aiMsg.Content.(string); ok && strings.TrimSpace(s) != "" {
ultimoTextoParcial = s
}
messages = append(messages, *aiMsg)
for _, tc := range aiMsg.ToolCalls {
var toolArgs map[string]interface{}
_ = json.Unmarshal([]byte(tc.Function.Arguments), &toolArgs)
toolResult := executeUmindTool(agente.ID, tc.Function.Name, toolArgs)
herramientasPedidas = append(herramientasPedidas, tc.Function.Name)
// Una herramienta que devuelve error y el modelo que la reintenta es
// la forma más común de agotar las rondas. Queda registrado para
// poder verlo en Auditoría en vez de deducirlo.
if strings.HasPrefix(strings.TrimSpace(toolResult), `{"error"`) {
models.RegistrarEventoUmind(agente.ID, "warn", "tool",
fmt.Sprintf("La herramienta %q devolvió error en la ronda %d", tc.Function.Name, round),
toolResult)
}
messages = append(messages, agentMessage{
Role: "tool",
ToolCallID: tc.ID,
@@ -329,9 +353,19 @@ func ProcessWidgetMessage(agente *models.UmindAgente, sessionID, userText string
}
if finalResponse == "" {
log.Printf("[UMIND] Agente %d: se agotaron las rondas de tool-calling sin una respuesta final", agente.ID)
models.RegistrarEventoUmind(agente.ID, "warn", "ai", "Se agotaron las rondas de tool-calling sin una respuesta final", "")
finalResponse = "Un momento, por favor — dame un poco más de detalle sobre lo que necesitas."
log.Printf("[UMIND] Agente %d: se agotaron las rondas de tool-calling sin respuesta final (herramientas: %v)",
agente.ID, herramientasPedidas)
models.RegistrarEventoUmind(agente.ID, "warn", "ai",
"Se agotaron las 3 rondas de herramientas sin una respuesta final",
fmt.Sprintf("Herramientas pedidas en orden: %s", strings.Join(herramientasPedidas, " → ")))
// Antes de dar el mensaje genérico, usar lo que el modelo ya escribió.
if strings.TrimSpace(ultimoTextoParcial) != "" {
finalResponse = ultimoTextoParcial
_ = models.SaveUmindMensaje(agente.ID, sessionID, "assistant", finalResponse)
} else {
finalResponse = "Un momento, por favor — dame un poco más de detalle sobre lo que necesitas."
}
}
return finalResponse, nil
}