From 4e4a8357950e9533c1c7b4a8fb9ac477e0b7245a Mon Sep 17 00:00:00 2001 From: viettranx Date: Thu, 2 Apr 2026 16:43:53 +0700 Subject: [PATCH] fix(agent): defer warning messages after parallel tool results When parallel tool calls trigger loop detection warnings, the warning messages (role="user") were inserted between tool result messages (role="tool"). This breaks the Anthropic API when routed through OpenAI-compatible proxies (e.g. LiteLLM): the proxy groups consecutive tool messages into a single user message with tool_result blocks, but an intervening user warning splits the group, causing orphaned tool_results and HTTP 400 "tool_use ids without tool_result blocks". Fix: accumulate warning messages during parallel result processing and append them after all tool results, preserving the consecutive grouping. Closes #642 --- internal/agent/loop.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/internal/agent/loop.go b/internal/agent/loop.go index a2c182ad..4e5ee449 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -699,7 +699,13 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (result *RunResult, // 5. Process results sequentially: emit events, append messages, save to session // Note: tool span start/end already emitted inside goroutines above. + // IMPORTANT: warning messages (role="user") must be deferred until AFTER all + // tool results are appended. Inserting a user message between tool results + // breaks the Anthropic API requirement that all tool_results for a set of + // tool_uses must be consecutive (causes "tool_use ids without tool_result" + // errors when routed through LiteLLM OpenAI→Anthropic conversion). var loopStuck bool + var deferredWarnings []providers.Message for _, r := range collected { // Record tool execution time for adaptive thresholds. toolTiming.Record(r.tc.Name, time.Since(r.spanStart).Milliseconds()) @@ -708,12 +714,14 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (result *RunResult, toolMsg, warningMsgs, action := l.processToolResult(ctx, rs, &req, emitRun, r.tc, r.registryName, r.result, hadBootstrap) messages = append(messages, toolMsg) rs.pendingMsgs = append(rs.pendingMsgs, toolMsg) - messages = append(messages, warningMsgs...) + deferredWarnings = append(deferredWarnings, warningMsgs...) if action == toolResultBreak { loopStuck = true break } } + // Append deferred warnings after all tool results to preserve consecutive grouping. + messages = append(messages, deferredWarnings...) // Check read-only streak after processing all parallel results. if !loopStuck {