Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions internal/ajean/chat_conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
139 changes: 139 additions & 0 deletions internal/ajean/chat_tool_pairs.go
Original file line number Diff line number Diff line change
@@ -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_<i>_<k>) ;
// - 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
}
137 changes: 137 additions & 0 deletions internal/ajean/chat_tool_pairs_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
7 changes: 5 additions & 2 deletions internal/ajean/llm_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading