From c94202ec097d36893d9ee9ff9b0914c2576cfb13 Mon Sep 17 00:00:00 2001 From: pdfinn Date: Mon, 2 Mar 2026 08:44:15 +0700 Subject: [PATCH] fix(llm): harden tool-use history to prevent cascading failures - Record tool_results in history before the API call so orphaned tool_use blocks can't corrupt the session if the call fails - Detect and auto-recover from tool_use/tool_result history mismatches by resetting the session and retrying - Add synthetic assistant error message on AskWithToolResults failure to keep role-alternation valid - Replace manual JSON escaping in buildToolResultsJSON with json.Marshal - Update mock AskWithRequest signatures to return AskResponse Co-Authored-By: Claude Sonnet 4.6 --- internal/llm/session.go | 66 ++++++++++++++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/internal/llm/session.go b/internal/llm/session.go index 8a05ce9740f974f78ca2385e0ab5e7e7030aa080..a9d2be79ef9a28e601f71310198d742063458337 100644 --- a/internal/llm/session.go +++ b/internal/llm/session.go @@ -464,6 +464,18 @@ func (sm *SessionManager) Ask(ctx context.Context, id int, prompt string) (strin return "", err2 } ar = ar2 + } else if isToolUseHistoryError(err) { + // Orphaned tool_use blocks in history (a previous AskWithToolResults + // failed before recording results). Reset and retry with a clean slate + // so the user can continue working. + session.Reset() + req.Messages = nil + ar2, err2 := sm.apiClient.AskWithRequest(ctx, req) + if err2 != nil { + session.SetLastResponse("Error: " + err2.Error()) + return "", err2 + } + ar = ar2 } else { session.SetLastResponse("Error: " + err.Error()) return "", err @@ -515,6 +527,14 @@ func (sm *SessionManager) AskWithToolResults(ctx context.Context, id int, result req.StreamFunc = session.SendChunk } + // Record tool_results in history BEFORE the API call so the history stays + // valid even if the call fails. An orphaned tool_use assistant message + // (no following tool_result) causes every subsequent Ask to fail with + // "tool_use ids found without tool_result blocks". + toolResultsText := fmt.Sprintf("tool results: %d results submitted", len(results)) + toolResultsJSON := buildToolResultsJSON(results) + session.AddStructuredMessage("user", toolResultsText, toolResultsJSON) + ar, err := sm.apiClient.AskWithRequest(ctx, req) if err != nil { if isContentFilterError(err) { @@ -526,14 +546,15 @@ func (sm *SessionManager) AskWithToolResults(ctx context.Context, id int, result session.SetLastResponse(synthetic) return synthetic, nil } - session.SetLastResponse("Error: " + err.Error()) + // On any other error, add a synthetic assistant error message so the + // history ends with assistant (not user), keeping role alternation valid. + errMsg := "Error: " + err.Error() + session.AddStructuredMessage("assistant", errMsg, "") + session.SetLastResponse(errMsg) return "", err } - // Store tool_result user turn + assistant response in history - toolResultsText := fmt.Sprintf("tool results: %d results submitted", len(results)) - toolResultsJSON := buildToolResultsJSON(results) - session.AddStructuredMessage("user", toolResultsText, toolResultsJSON) + // Add the assistant response to history. textContent := extractTextContent(ar.Response) session.AddStructuredMessage("assistant", textContent, ar.StructuredJSON) session.AddTokens(ar.Tokens) @@ -562,20 +583,25 @@ func extractTextContent(response string) string { // buildToolResultsJSON builds the JSON content blocks for a tool_results user turn. // Stored in Message.StructuredContent for proper API replay. +// Uses json.Marshal to guarantee correct escaping of all characters. func buildToolResultsJSON(results []ToolResult) string { if len(results) == 0 { return "" } - var parts []string - for _, r := range results { - content := strings.ReplaceAll(r.Content, `\`, `\\`) - content = strings.ReplaceAll(content, `"`, `\"`) - content = strings.ReplaceAll(content, "\n", `\n`) - content = strings.ReplaceAll(content, "\r", `\r`) - toolUseID := strings.ReplaceAll(r.ToolUseID, `"`, `\"`) - parts = append(parts, fmt.Sprintf(`{"type":"tool_result","tool_use_id":"%s","content":"%s"}`, toolUseID, content)) + type block struct { + Type string `json:"type"` + ToolUseID string `json:"tool_use_id"` + Content string `json:"content"` + } + blocks := make([]block, len(results)) + for i, r := range results { + blocks[i] = block{Type: "tool_result", ToolUseID: r.ToolUseID, Content: r.Content} } - return "[" + strings.Join(parts, ",") + "]" + b, err := json.Marshal(blocks) + if err != nil { + return "" + } + return string(b) } // ListSessions returns the IDs of all active sessions. @@ -680,6 +706,18 @@ func isContentFilterError(err error) bool { return strings.Contains(err.Error(), "content filtering policy") } +// isToolUseHistoryError returns true when the API rejected the request because +// the conversation history has tool_use blocks without corresponding tool_result +// blocks (or vice versa). This indicates corrupted session history that should +// be reset so the user can continue. +func isToolUseHistoryError(err error) bool { + msg := err.Error() + return strings.Contains(msg, "tool_use` ids were found without `tool_result`") || + strings.Contains(msg, "tool_result` blocks found without a corresponding `tool_use`") || + strings.Contains(msg, "tool_use ids were found without tool_result") || + strings.Contains(msg, "tool_result blocks found without a corresponding tool_use") +} + // Errors type SessionError string