From 45a32ed4e7e946308ad1d878f9c3fe15a226f0be Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Fri, 3 Apr 2026 19:59:45 +0200 Subject: [PATCH] agent: fix conversation history corruption on stream interruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs that combined to produce unrecoverable 400 errors from backends: 1. loop.go: runStreamStep returned nil on stream-closed-without-done, causing Run to commit a partial assistant message (with no ToolCalls) to state even when the backend had already sent tool call frames. Now returns a real error so state.Update is never called and the session history stays clean for a retry. 2. context.go: BoundedHistory and BoundedHistoryWithNotice could evict an assistant[tool_calls] message while its paired tool[result] messages remained in the tail window, producing a tool message with no preceding assistant — rejected by strict backends (OpenAI, DeepInfra) with a 400. Fixed by dropping assistant+tool pairs atomically in the hard-limit loop and adding a sanitizeHistory pass that strips any remaining orphaned tool messages before the history is returned. Co-Authored-By: Claude Sonnet 4.6 --- agent/context.go | 58 ++++++++++++++++++++++++++++++++++++++++++++---- agent/loop.go | 9 ++++++-- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/agent/context.go b/agent/context.go index 69f4caa..b3c6a1d 100644 --- a/agent/context.go +++ b/agent/context.go @@ -132,12 +132,24 @@ func (cb *ContextBuilder) BoundedHistory() []backend.Message { result = append(result, tail...) // Hard-limit safety: truncate from the front (after system) if still over. + // Drop assistant[tool_calls]+tool[result] pairs atomically so we never + // leave an orphaned tool message without its preceding assistant. for totalChars(result) > cb.cfg.HardLimit && len(result) > len(system)+1 { - // Drop the oldest non-system message. - result = append(result[:len(system)], result[len(system)+1:]...) + drop := len(system) // index of oldest non-system message + // If this message has tool calls, also drop the consecutive tool + // result messages that follow it. + if result[drop].Role == "assistant" && len(result[drop].ToolCalls) > 0 { + end := drop + 1 + for end < len(result) && result[end].Role == "tool" { + end++ + } + result = append(result[:drop], result[end:]...) + } else { + result = append(result[:drop], result[drop+1:]...) + } } - return result + return sanitizeHistory(result) } // Len returns the number of stored messages. @@ -229,9 +241,47 @@ func (cb *ContextBuilder) BoundedHistoryWithNotice() []backend.Message { result = append(result, tail...) for totalChars(result) > cb.cfg.HardLimit && len(result) > len(system)+1 { - result = append(result[:len(system)], result[len(system)+1:]...) + drop := len(system) + if result[drop].Role == "assistant" && len(result[drop].ToolCalls) > 0 { + end := drop + 1 + for end < len(result) && result[end].Role == "tool" { + end++ + } + result = append(result[:drop], result[end:]...) + } else { + result = append(result[:drop], result[drop+1:]...) + } } + return sanitizeHistory(result) +} + +// sanitizeHistory removes tool messages that are not preceded by an assistant +// message with tool calls. This prevents 400 errors from backends that +// reject tool messages not immediately following an assistant[tool_calls] +// turn. The situation arises when context compaction evicts an +// assistant[tool_calls] message while its paired tool[result] messages remain +// in the tail window, or when the compaction notice (a user message) is +// injected immediately before such a tail. +func sanitizeHistory(msgs []backend.Message) []backend.Message { + result := make([]backend.Message, 0, len(msgs)) + for _, m := range msgs { + if m.Role == "tool" { + // Walk backward through already-accepted messages to find the + // nearest non-tool predecessor. + preceded := false + for j := len(result) - 1; j >= 0; j-- { + if result[j].Role != "tool" { + preceded = result[j].Role == "assistant" && len(result[j].ToolCalls) > 0 + break + } + } + if !preceded { + continue // drop orphaned tool message + } + } + result = append(result, m) + } return result } diff --git a/agent/loop.go b/agent/loop.go index 9c9c594..8223d30 100644 --- a/agent/loop.go +++ b/agent/loop.go @@ -185,10 +185,15 @@ func (l *Loop) runStreamStep( } } - l.emit(OutputMsg{Role: "error", Content: "stream ended without done event"}) + // Stream closed without a done event — treat as a transient error so the + // caller does not save partial/corrupt state. Any tool calls that arrived + // before the drop are attached to the message for diagnostic use, but + // because we return a non-nil error the outer Run loop will NOT call + // state.Update, keeping the session history clean for a retry. msg.Content = content.String() msg.Role = "assistant" - return msg, nil + msg.ToolCalls = accumulatedTcs + return msg, fmt.Errorf("stream ended without done event") } func decideStopReason(m backend.Message) string {