fix: preserva campos propietarios del proveedor en el loop de tool-calling
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e0c2f77f8c
commit
8eba3ab97f
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user