dropDanglingToolCalls removes any assistant message whose tool_calls include an id with no answering tool message in the kept slice: the mirror of dropOrphanTools. An assistant.tool_calls followed by fewer tool results than calls issued 400s every OpenAI-compatible backend with "missing tool respons
(kept []Message)
| 358 | // TUI appends the assistant.tool_calls as soon as the round closes, but a Ctrl+C |
| 359 | // / stream-error / idle-stall then drops the pending calls so their tool results |
| 360 | // never arrive (see tui.endTurn). On the user's next request that dangling |
| 361 | // assistant would otherwise reach the wire and wedge the conversation until |
| 362 | // /clear. Empty ids count as unanswered: an unidentifiable call can't be paired. |
| 363 | func dropDanglingToolCalls(kept []Message) []Message { |
| 364 | out := kept[:0] |
| 365 | for i, m := range kept { |
| 366 | if m.Role == RoleAssistant && len(m.ToolCalls) > 0 { |
| 367 | // Answers must come from the contiguous run of tool messages that |
| 368 | // follows THIS assistant, the only shape the wire accepts. A global |
| 369 | // ID lookup would let a later turn's reused ID (index-derived |
| 370 | // "call_0"-style IDs are common on local backends) vouch for an |
| 371 | // aborted call here, sending the dangling assistant to the wire and |
| 372 | // wedging the conversation with 400s until /clear. |
| 373 | answered := map[string]bool{} |
| 374 | for j := i + 1; j < len(kept) && kept[j].Role == RoleTool; j++ { |
| 375 | if kept[j].ToolCallID != "" { |
| 376 | answered[kept[j].ToolCallID] = true |
| 377 | } |
| 378 | } |
| 379 | dangling := false |
| 380 | for _, tc := range m.ToolCalls { |
| 381 | if !answered[tc.ID] { |
| 382 | dangling = true |
| 383 | break |
| 384 | } |
| 385 | } |
| 386 | if dangling { |
| 387 | continue |
| 388 | } |
| 389 | } |
| 390 | out = append(out, m) |
| 391 | } |
| 392 | return out |
| 393 | } |