From 6389cdebfadce9d5d59deec6813d154dfe5297eb Mon Sep 17 00:00:00 2001 From: Olivi Date: Thu, 22 Oct 2026 15:53:38 +0200 Subject: [PATCH] =?UTF-8?q?Appariement=20tool=5Fcalls/tool=20:=20un=20stop?= =?UTF-8?q?=20en=20pleine=20s=C3=A9rie=20ne=20bloque=20plus=20la=20convers?= =?UTF-8?q?ation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un arrêt entre deux appels d'outil laissait dans la vue modèle l'assistant porteur de TOUS ses tool_calls et une partie seulement des réponses `tool`. Persistée, cette demi-paire faisait refuser tous les tours suivants en 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. Relevé en base le 2026-10-22 : deux ids d'appel sans réponse, dont un en dernier message de la conversation active (308 appels contre 306 réponses). repairToolPairs garantit qu'après chaque assistant porteur d'appels suivent immédiatement une réponse par tool_call_id, dans l'ordre : les réponses présentes sont conservées, les manquantes reçoivent un marqueur « interrompu », un id vide est synthétisé, les messages média injectés par see_image/see_video sont replacés après le bloc et les réponses réellement orphelines sont retirées. Appliquée à l'envoi, à la sortie sur stop et dans persist() : les conversations déjà empoisonnées se réparent seules au tour suivant, et le blob reste sain. Co-Authored-By: Claude Code --- internal/ajean/chat_conversation.go | 6 ++ internal/ajean/chat_tool_pairs.go | 139 +++++++++++++++++++++++++ internal/ajean/chat_tool_pairs_test.go | 137 ++++++++++++++++++++++++ internal/ajean/llm_client.go | 7 +- 4 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 internal/ajean/chat_tool_pairs.go create mode 100644 internal/ajean/chat_tool_pairs_test.go 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)