agent: fix conversation history corruption on stream interruption
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 <noreply@anthropic.com>
This commit is contained in:
parent
7edaf27132
commit
45a32ed4e7
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Reference in New Issue