diff --git a/internal/ajean/chat_conversation.go b/internal/ajean/chat_conversation.go index 1b7f23d..56839a6 100644 --- a/internal/ajean/chat_conversation.go +++ b/internal/ajean/chat_conversation.go @@ -215,6 +215,12 @@ func reloadEncryptedStores() { // delta). L'appelant NE doit PAS détenir mu. func (c *Conversation) persist() { c.mu.Lock() + // Appariement assistant(tool_calls) / tool : un arrêt en pleine série laissait + // une demi-paire, et le moteur refusait alors TOUS les tours suivants. On + // répare ici — l'entonnoir unique d'écriture de la vue modèle — pour que les + // conversations déjà empoisonnées par une version antérieure se réparent + // seules (StartTurn persiste avant de générer) et que le blob reste sain. + c.Messages = repairToolPairs(c.Messages) // Images/vidéos en base64 → références (chat_images.go / chat_video.go) // avant d'écrire : c'est ce qui faisait peser une conversation des dizaines // de Mo. diff --git a/internal/ajean/chat_tool_pairs.go b/internal/ajean/chat_tool_pairs.go new file mode 100644 index 0000000..015737d --- /dev/null +++ b/internal/ajean/chat_tool_pairs.go @@ -0,0 +1,139 @@ +package ajean + +// chat_tool_pairs.go — garantir l'appariement assistant(tool_calls) / tool. +// +// Le moteur (llama.cpp, DeepSeek, tout endpoint OpenAI strict) exige qu'un +// message assistant porteur de tool_calls soit IMMÉDIATEMENT suivi d'une réponse +// `tool` par tool_call_id. Observé en usage réel (2026-10-22) : un stop pendant +// une série d'appels laissait dans la vue modèle l'assistant avec TOUS ses appels +// et une partie seulement des réponses. Persistée, cette demi-paire faisait +// refuser tous les tours suivants (400 « An assistant message with 'tool_calls' +// must be followed by tool messages responding to each 'tool_call_id' ») : la +// conversation devenait définitivement inutilisable, « Vider le contexte » étant +// le seul recours. Deux ids orphelins ont été relevés dans ajean.db, dont un en +// dernier message de la conversation active. +// +// La réparation est appliquée à l'envoi ET à la persistance : elle répare donc +// aussi les conversations déjà empoisonnées par une version antérieure. + +import "fmt" + +// toolInterruptedMarker remplace une réponse d'outil qui n'a jamais eu lieu. +// Même registre que « [erreur] » : le modèle doit comprendre que l'appel n'a pas +// abouti, et surtout ne pas le rejouer en croyant à un échec de l'outil. +const toolInterruptedMarker = "[interrompu] l'appel d'outil n'a pas été exécuté (arrêt demandé)." + +// repairToolPairs renvoie une COPIE de msgs où chaque assistant porteur de +// tool_calls est suivi d'exactement une réponse `tool` par appel, dans l'ordre +// des appels. Les réponses présentes sont conservées telles quelles ; celles qui +// manquent reçoivent toolInterruptedMarker. +// +// Trois autres formes sont normalisées au passage : +// - un id d'appel vide reçoit un id déterministe (call_repair__) ; +// - les messages `user` multimodaux injectés par see_image / see_video, qui +// s'intercalaient entre deux réponses (assistant(a,b) → tool(a) → user(image) +// → tool(b)), sont replacés APRÈS le bloc `tool` ; +// - un `tool` sans appel correspondant est supprimé (il n'a plus de sens). +// +// Idempotente : repairToolPairs(repairToolPairs(x)) == repairToolPairs(x). +func repairToolPairs(msgs []Message) []Message { + if len(msgs) == 0 { + return msgs + } + out := make([]Message, 0, len(msgs)) + for i := 0; i < len(msgs); i++ { + m := msgs[i] + if m.Role == "tool" { + // Atteint seulement hors d'un bloc d'appels : réponse orpheline. + continue + } + if m.Role != "assistant" || len(m.ToolCalls) == 0 { + out = append(out, m) + continue + } + // Ids attendus, en ordre d'appel. Un id vide est synthétisé à partir de la + // position : stable d'une passe à l'autre, donc idempotent. + calls := append([]ToolCall(nil), m.ToolCalls...) + want := make([]string, len(calls)) + known := make(map[string]bool, len(calls)) + for k := range calls { + id := calls[k].ID + if id == "" { + id = fmt.Sprintf("call_repair_%d_%d", i, k) + calls[k].ID = id + } + want[k] = id + known[id] = true + } + m.ToolCalls = calls + out = append(out, m) + + // Ramasse ce qui suit et appartient encore au bloc : les réponses des + // appels, plus les messages média du harnais qui les coupaient. + got := make(map[string]Message, len(calls)) + var deferred []Message + for i+1 < len(msgs) { + n := msgs[i+1] + if n.Role == "tool" { + if !known[n.ToolCallID] { + break // réponse d'un autre appel : traitée (et supprimée) au tour suivant + } + if _, dup := got[n.ToolCallID]; !dup { + got[n.ToolCallID] = n + } + i++ + continue + } + if hasMediaParts(n) { + deferred = append(deferred, n) + i++ + continue + } + break + } + for _, id := range want { + if r, ok := got[id]; ok { + out = append(out, r) + continue + } + out = append(out, Message{Role: "tool", ToolCallID: id, Content: toolInterruptedMarker}) + } + out = append(out, deferred...) + } + return out +} + +// hasMediaParts : message `user` multimodal (image/vidéo injectée par le +// harnais, ou pièce jointe). Le contenu est tantôt construit en mémoire +// ([]map[string]any), tantôt relu depuis la base ([]any). +func hasMediaParts(m Message) bool { + if m.Role != "user" { + return false + } + estMedia := func(p any) bool { + part, ok := p.(map[string]any) + if !ok { + return false + } + switch part["type"] { + case "image_url", "input_video": + return true + } + return false + } + switch c := m.Content.(type) { + case []map[string]any: + for _, p := range c { + if estMedia(p) { + return true + } + } + case []any: + for _, p := range c { + if estMedia(p) { + return true + } + } + } + return false +} diff --git a/internal/ajean/chat_tool_pairs_test.go b/internal/ajean/chat_tool_pairs_test.go new file mode 100644 index 0000000..0d46d89 --- /dev/null +++ b/internal/ajean/chat_tool_pairs_test.go @@ -0,0 +1,137 @@ +package ajean + +import ( + "reflect" + "testing" +) + +// checkToolPairs reproduit la règle du moteur : après un assistant porteur de +// tool_calls, une réponse `tool` par appel, dans l'ordre et sans interruption. +func checkToolPairs(t *testing.T, msgs []Message) { + t.Helper() + for i := 0; i < len(msgs); i++ { + m := msgs[i] + if m.Role == "tool" { + t.Fatalf("réponse tool orpheline à l'index %d : %+v", i, m) + } + if m.Role != "assistant" || len(m.ToolCalls) == 0 { + continue + } + for k, tc := range m.ToolCalls { + if tc.ID == "" { + t.Fatalf("appel %d de l'index %d sans id", k, i) + } + j := i + 1 + k + if j >= len(msgs) || msgs[j].Role != "tool" || msgs[j].ToolCallID != tc.ID { + t.Fatalf("appel %q (index %d) non suivi de sa réponse à l'index %d", tc.ID, i, j) + } + } + i += len(m.ToolCalls) // saute le bloc qui vient d'être vérifié + } +} + +func TestRepairToolPairs(t *testing.T) { + sys := Message{Role: "system", Content: "profil"} + user := Message{Role: "user", Content: "bonjour"} + tc := func(id, name string) ToolCall { + return ToolCall{ID: id, Type: "function", Function: ToolCallFunc{Name: name, Arguments: "{}"}} + } + tool := func(id, out string) Message { return Message{Role: "tool", ToolCallID: id, Content: out} } + // Média injecté par see_image / see_video : il coupait le bloc d'appels. + media := Message{Role: "user", Content: []map[string]any{ + {"type": "text", "text": "Image :"}, + {"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,AAAA"}}, + }} + + cases := []struct { + name string + in []Message + want []Message + }{ + { + name: "paire complète inchangée", + in: []Message{sys, user, {Role: "assistant", ToolCalls: []ToolCall{tc("call_1", "bash")}}, tool("call_1", "ok"), {Role: "assistant", Content: "fini"}}, + want: []Message{sys, user, {Role: "assistant", ToolCalls: []ToolCall{tc("call_1", "bash")}}, tool("call_1", "ok"), {Role: "assistant", Content: "fini"}}, + }, + { + // Relevé en base le 2026-10-22 : un `write {}` sans aucune réponse, + // dernier message de la conversation active. + name: "appel unique sans réponse", + in: []Message{sys, user, {Role: "assistant", ToolCalls: []ToolCall{tc("call_00_h80I1oyYBSnaMTHXKD537077", "write")}}}, + want: []Message{sys, user, {Role: "assistant", ToolCalls: []ToolCall{tc("call_00_h80I1oyYBSnaMTHXKD537077", "write")}}, tool("call_00_h80I1oyYBSnaMTHXKD537077", toolInterruptedMarker)}, + }, + { + // Deuxième forme relevée : deux appels, une seule réponse (stop entre les deux). + name: "série partielle", + in: []Message{ + sys, user, + {Role: "assistant", ToolCalls: []ToolCall{tc("call_00_KBHfzaBD9l4po9ROg7SU7934", "panel_list_workflows"), tc("call_01_CWXhb9RtReVfTInNWEXN9009", "bash")}}, + tool("call_00_KBHfzaBD9l4po9ROg7SU7934", "[erreur] panneau injoignable"), + }, + want: []Message{ + sys, user, + {Role: "assistant", ToolCalls: []ToolCall{tc("call_00_KBHfzaBD9l4po9ROg7SU7934", "panel_list_workflows"), tc("call_01_CWXhb9RtReVfTInNWEXN9009", "bash")}}, + tool("call_00_KBHfzaBD9l4po9ROg7SU7934", "[erreur] panneau injoignable"), + tool("call_01_CWXhb9RtReVfTInNWEXN9009", toolInterruptedMarker), + }, + }, + { + name: "réponse orpheline supprimée", + in: []Message{user, tool("call_inconnu", "résultat"), {Role: "assistant", Content: "salut"}}, + want: []Message{user, {Role: "assistant", Content: "salut"}}, + }, + { + name: "id vide synthétisé de façon déterministe", + in: []Message{user, {Role: "assistant", ToolCalls: []ToolCall{{Type: "function", Function: ToolCallFunc{Name: "bash", Arguments: "{}"}}}}}, + want: []Message{user, {Role: "assistant", ToolCalls: []ToolCall{tc("call_repair_1_0", "bash")}}, tool("call_repair_1_0", toolInterruptedMarker)}, + }, + { + name: "média intercalé replacé après le bloc", + in: []Message{ + user, + {Role: "assistant", ToolCalls: []ToolCall{tc("call_a", "see_image"), tc("call_b", "bash")}}, + tool("call_a", "image chargée"), + media, + tool("call_b", "ok"), + }, + want: []Message{ + user, + {Role: "assistant", ToolCalls: []ToolCall{tc("call_a", "see_image"), tc("call_b", "bash")}}, + tool("call_a", "image chargée"), + tool("call_b", "ok"), + media, + }, + }, + { + name: "system de tête et assistant simple préservés", + in: []Message{sys, user, {Role: "assistant", Content: "je réponds"}}, + want: []Message{sys, user, {Role: "assistant", Content: "je réponds"}}, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := repairToolPairs(c.in) + if !reflect.DeepEqual(got, c.want) { + t.Fatalf("réparation inattendue\n obtenu : %+v\n voulu : %+v", got, c.want) + } + checkToolPairs(t, got) + // Idempotence : une seconde passe ne doit plus rien changer. + if again := repairToolPairs(got); !reflect.DeepEqual(again, got) { + t.Fatalf("non idempotente\n 1re passe : %+v\n 2e passe : %+v", got, again) + } + }) + } +} + +// TestRepairToolPairsNeModifiePasLEntree verrouille le contrat « copie » : la +// vue modèle peut être réparée pendant qu'un autre appareil la lit. +func TestRepairToolPairsNeModifiePasLEntree(t *testing.T) { + in := []Message{ + {Role: "assistant", ToolCalls: []ToolCall{{ID: "", Type: "function", Function: ToolCallFunc{Name: "bash", Arguments: "{}"}}}}, + } + _ = repairToolPairs(in) + if in[0].ToolCalls[0].ID != "" { + t.Fatalf("l'appel d'entrée a été modifié : id=%q", in[0].ToolCalls[0].ID) + } +} diff --git a/internal/ajean/llm_client.go b/internal/ajean/llm_client.go index 2083dc4..ba982dc 100644 --- a/internal/ajean/llm_client.go +++ b/internal/ajean/llm_client.go @@ -1036,7 +1036,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // Les images/vidéos de l'historique y sont rangées par référence // (chat_images.go / chat_video.go) : on remet leurs octets juste avant // l'envoi. - "messages": expandVideoRefs(expandImageRefs(normalizeSystemMessages(messages))), + "messages": expandVideoRefs(expandImageRefs(normalizeSystemMessages(repairToolPairs(messages)))), "stream": true, "temperature": temperature, // include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE @@ -1596,7 +1596,10 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // garde, un stop pendant une série d'appels laissait défiler toute // la série avant de reprendre la main. if ctx.Err() != nil { - return extra, nil + // L'assistant ci-dessus porte TOUS les appels : sans compléter le + // bloc, la vue persistée gardait une demi-paire que le moteur + // refuse ensuite à chaque tour (voir chat_tool_pairs.go). + return repairToolPairs(extra), nil } var args map[string]any _ = json.Unmarshal([]byte(tc.Function.Arguments), &args)