fix: filter empty text/reasoning parts before sending to LLM (#23284)

## Problem

Anthropic rejects requests containing empty text content blocks with:

```
messages: text content blocks must be non-empty
```

Empty text parts (`""` or whitespace-only like `" "`) get persisted in
the database when a stream sends `TextStart`/`TextEnd` with no
`TextDelta` in between. On the next turn, these parts are loaded from
the DB and sent to Anthropic, which rejects them.

## Fix

Filter empty/whitespace-only text and reasoning parts at the two LLM
dispatch boundaries, without modifying persistence (the raw record is
preserved):

- **`partsToMessageParts()`** in `chatprompt.go` — filters when
converting persisted DB messages to fantasy message parts for LLM calls.
This is the last gateway before the Anthropic provider creates
`TextBlockParam` objects.
- **`toResponseMessages()`** in `chatloop.go` — filters when building
in-flight conversation messages between steps within a single turn.

Note: `flushActiveState()` (the interruption path) already had this
guard — the normal `TextEnd` streaming path did not, but since we're not
changing persistence, the fix is applied at the dispatch layer.
This commit is contained in:
Kyle Carberry
2026-03-19 12:10:54 -04:00
committed by GitHub
parent 31fe58819e
commit 742694eb20
4 changed files with 227 additions and 4 deletions
+2 -2
View File
@@ -127,7 +127,7 @@ func (r stepResult) toResponseMessages() []fantasy.Message {
switch c.GetType() {
case fantasy.ContentTypeText:
text, ok := fantasy.AsContentType[fantasy.TextContent](c)
if !ok {
if !ok || strings.TrimSpace(text.Text) == "" {
continue
}
assistantParts = append(assistantParts, fantasy.TextPart{
@@ -136,7 +136,7 @@ func (r stepResult) toResponseMessages() []fantasy.Message {
})
case fantasy.ContentTypeReasoning:
reasoning, ok := fantasy.AsContentType[fantasy.ReasoningContent](c)
if !ok {
if !ok || strings.TrimSpace(reasoning.Text) == "" {
continue
}
assistantParts = append(assistantParts, fantasy.ReasoningPart{