From 8eba3ab97f70bfae62d5d10fa6624dbddf01036b Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:39:09 -0500 Subject: [PATCH] fix: preserva campos propietarios del proveedor en el loop de tool-calling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini (modelos "thinking" como 2.5) exige que el tool_call se reenvíe con su thought_signature intacto en la ronda siguiente, o rechaza con 400 "Function call is missing a thought_signature". Nuestro parseo a agentMessage/agentToolCall no conocía ese campo y lo descartaba en el unmarshal, así que cualquier tool call con Gemini fallaba apenas el modelo pedía usar una herramienta (buscar_conocimiento incluida). agentMessage ahora guarda los bytes JSON originales cuando se parsea de una respuesta (UnmarshalJSON) y los reenvía tal cual al volver a serializar (MarshalJSON) — preserva thought_signature u otro campo propietario que llegue, sin que el código necesite conocer su nombre/forma exacta. Los mensajes que armamos nosotros (user/tool/system) no llevan Raw y siguen serializando normal. Afecta tanto a uMind como al agente de Telegram, que comparten este mismo motor. Co-Authored-By: Claude Sonnet 5 --- pkg/services/agent_message_test.go | 42 ++++++++++++++++++++++++++ pkg/services/telegram_agent_service.go | 32 ++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 pkg/services/agent_message_test.go diff --git a/pkg/services/agent_message_test.go b/pkg/services/agent_message_test.go new file mode 100644 index 0000000..37954ee --- /dev/null +++ b/pkg/services/agent_message_test.go @@ -0,0 +1,42 @@ +package services + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestAgentMessageRawRoundTrip confirma que un campo que el proveedor manda +// y que nuestra struct no conoce (ej. thought_signature de Gemini) sobrevive +// a un ciclo unmarshal->marshal en vez de perderse — es justo lo que hacía +// que Gemini rechazara la segunda ronda de una tool call. +func TestAgentMessageRawRoundTrip(t *testing.T) { + original := `{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"buscar_conocimiento","arguments":"{}"},"thought_signature":"opaco-123"}]}` + + var msg agentMessage + if err := json.Unmarshal([]byte(original), &msg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(msg.ToolCalls) != 1 || msg.ToolCalls[0].Function.Name != "buscar_conocimiento" { + t.Fatalf("no se poblaron los campos tipados normalmente: %+v", msg) + } + + out, err := json.Marshal(msg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !strings.Contains(string(out), `"thought_signature":"opaco-123"`) { + t.Errorf("se perdió el campo desconocido al reserializar: %s", out) + } + + // Un mensaje armado por nosotros (no parseado) no debe llevar Raw ni + // romperse por eso — tiene que serializar normal. + propio := agentMessage{Role: "user", Content: "hola"} + outPropio, err := json.Marshal(propio) + if err != nil { + t.Fatalf("marshal de mensaje propio: %v", err) + } + if !strings.Contains(string(outPropio), `"role":"user"`) || !strings.Contains(string(outPropio), `"content":"hola"`) { + t.Errorf("mensaje propio no serializó bien: %s", outPropio) + } +} diff --git a/pkg/services/telegram_agent_service.go b/pkg/services/telegram_agent_service.go index 64a0f9b..fd3c7d0 100644 --- a/pkg/services/telegram_agent_service.go +++ b/pkg/services/telegram_agent_service.go @@ -47,6 +47,38 @@ type agentMessage struct { // Adjunto opcional para que el modelo lea el archivo real (solo Anthropic). AttachmentBase64 string `json:"-"` AttachmentMime string `json:"-"` + // Raw guarda el JSON tal cual vino del proveedor cuando este mensaje se + // parseó de una respuesta (no cuando lo armamos nosotros). Algunos + // proveedores mandan campos propios fuera de nuestra struct — ej. Gemini + // exige que el tool_call de un modelo "thinking" se reenvíe con su + // thought_signature intacto, si no rechaza la siguiente ronda con 400. + // Reenviar los bytes originales en vez de reserializar desde los campos + // tipados preserva cualquier campo así sin que tengamos que conocerlo. + Raw json.RawMessage `json:"-"` +} + +// UnmarshalJSON guarda una copia de los bytes originales en Raw además de +// poblar los campos normales — así un mensaje que vino de la respuesta del +// proveedor se puede reenviar exactamente como llegó. +func (m *agentMessage) UnmarshalJSON(data []byte) error { + type alias agentMessage + var a alias + if err := json.Unmarshal(data, &a); err != nil { + return err + } + *m = agentMessage(a) + m.Raw = append(json.RawMessage(nil), data...) + return nil +} + +// MarshalJSON reenvía Raw tal cual si el mensaje vino de un unmarshal +// previo; si lo armamos nosotros (Raw vacío), serializa los campos normales. +func (m agentMessage) MarshalJSON() ([]byte, error) { + if len(m.Raw) > 0 { + return m.Raw, nil + } + type alias agentMessage + return json.Marshal(alias(m)) } type agentToolCall struct {