Skip to content

Conversation

@jkomoros
Copy link
Contributor

@jkomoros jkomoros commented Nov 9, 2025

What Broke

Users started encountering this error when using LLM-based patterns:

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

Affected patterns:

  • demo-setup: Execute button would fail immediately on second run
  • omnibot: Started failing after tool calls
  • Any pattern using llmDialog with tools and caching

User experience:

  • First execution: Works fine
  • Reset and re-execute: Fails with API error
  • Cache-busting workarounds (Date.now() timestamps) were needed

Why It Broke

This issue emerged from the intersection of three changes:

1. Tool Call Caching Enabled (Nov 6, commit d394ec8)

Previously, requests with tools were excluded from caching to prevent incomplete responses. The commit enabled caching for tool calls with this rationale:

"With the sequential request architecture, each request includes complete context (including tool results from previous rounds), making each response cacheable."

This was correct in theory but exposed a hidden issue.

2. Anthropic API Behavior

When Claude responds with tool calls, the API can return responses like:

{
  "role": "assistant",
  "content": [
    {"type": "text", "text": ""},          // ← Empty text block
    {"type": "tool_use", "id": "...", ...}
  ]
}

The empty text block is valid in the initial response (Anthropic accepts it as input).

3. Stricter API Validation (Recent)

Anthropic's API recently became stricter about outgoing requests - it now rejects empty text blocks when they're sent back in conversation history, even though it can return them in responses.

The sequence:

  1. User sends message → LLM responds with empty text + tool call → cached
  2. Tools execute → tool results added to conversation
  3. Next LLM request → sends cached messages including empty text → API rejects

Why This Fix Works

The fix filters empty text content blocks at two critical points:

1. When Building Assistant Messages (buildAssistantMessage)

// Before: Included all text parts, even empty ones
...content.filter((part) => part.type === "text")

// After: Only include non-empty text
...content.filter((part) =>
  part.type === "text" && 
  part.text && 
  part.text.trim() !== ""
)

Prevents empty text parts from being stored in the first place.

2. Before Sending to API (startRequest)

const rawMessages = messagesCell.withTx(tx).get();
const filteredMessages = rawMessages.map((msg) => {
  if (Array.isArray(msg.content)) {
    const filteredContent = msg.content.filter((part) => {
      if (part.type === "text") {
        return part.text && part.text.trim() !== "";
      }
      return true; // Keep tool-call, tool-result, etc.
    });
    return { ...msg, content: filteredContent };
  }
  return msg;
});

Handles cached messages that already have empty text parts (defense in depth).

Why Both Filters Are Necessary

Filter #1 alone: Would fix new conversations but cached conversations would still have empty text

Filter #2 alone: Would fix the API error but continue storing empty text unnecessarily

Both together:

  • Prevents new empty text from being stored
  • Filters existing empty text from cached messages
  • Ensures API compatibility regardless of source

Testing

Verified the fix resolves all known issues:

demo-setup pattern:

  • Execute button works on first run
  • Reset → Execute works without errors
  • Tool calls execute successfully
  • Multiple execute cycles work

omnibot:

  • Tool calls work
  • Conversation continues correctly
  • No API errors

Server logs:

  • No "text content blocks must be non-empty" errors
  • Clean LLM API responses

Why This Is The Right Fix

This isn't a workaround - it addresses the fundamental issue:

Reality: LLM APIs can return empty text blocks in responses

  • Anthropic does this, especially with tool calls
  • We don't control their response format

Constraint: Those same APIs reject empty text blocks in requests

  • Stricter outgoing validation than incoming
  • We must filter before sending

Caching implication: Empty text blocks can persist across requests

  • Once cached, they're sent in subsequent requests
  • Must filter both at storage time and send time

The fix ensures CommonTools never sends empty text blocks to any LLM API, regardless of what the API returns or what's in the cache.

Alternative Considered

Could disable caching for tool calls again, but that would:

  • Lose performance benefits of caching tool interactions
  • Not fix already-cached conversations
  • Not prevent the issue if Anthropic returns empty text in non-tool responses

Filtering empty text is the correct solution.


Fixes issues reported by @jkomoros (Alex) via Claude Code investigation.

🤖 Generated with Claude Code

Co-Authored-By: Claude noreply@anthropic.com


Summary by cubic

Prevents Anthropic "messages: text content blocks must be non-empty" errors by validating fresh responses and hardening tool handling. Conversations with tool calls now re-run reliably without cache-busting.

  • Bug Fixes
    • Validate fresh responses with hasValidContent; replace empty/invalid content with a friendly error.
    • Ensure tool results always include output (use explicit null when needed); verify tool call/result ID match and surface a clear error on mismatch.
    • Do not transform cached history; legacy invalid caches may error, but new runs won’t write bad data.

Written for commit af30df9. Summary will update automatically on new commits.

## Problem

After enabling tool call caching (d394ec8, Nov 6), users encountered
'text content blocks must be non-empty' errors when:
1. Running demo-setup Execute button multiple times
2. Using omnibot after tool calls
3. Any cached conversation with tool calls

Error from Anthropic API:
```
AI_APICallError: messages: text content blocks must be non-empty
```

## Root Cause

When Claude responds with tool calls, it can include empty text parts:
```json
{
  "role": "assistant",
  "content": [
    {"type": "text", "text": ""},  // Empty!
    {"type": "tool_use", ...}
  ]
}
```

Before tool call caching was enabled, these messages weren't cached so the
issue didn't surface. After caching was enabled:

1. First request: LLM returns empty text + tool call
2. Tool results added to conversation
3. Response cached with empty text parts
4. Next request: Cached messages (with empty text) sent to API
5. API rejects: "text content blocks must be non-empty"

Anthropic API recently became stricter about rejecting empty text blocks.

## The Fix

**Two-point defense:**

1. **buildAssistantMessage (line 611-613)**: Filter out empty text parts when
   constructing assistant messages from LLM responses

2. **Request construction (lines 1069-1082)**: Filter all messages before
   sending to API to remove any empty text content that may have been cached

Both filters check:
- Part is type "text"
- Part has non-null text field
- Text is non-empty after trimming

## Why Both Filters Are Needed

- Filter #1: Prevents storing empty text parts initially
- Filter #2: Defense in depth for cached messages that already have empty text

## Testing

Verified fix resolves:
- ✅ demo-setup Execute button works on first and subsequent runs
- ✅ demo-setup Reset + Execute works without errors
- ✅ Tool calls execute successfully
- ✅ Conversation continues after tool calls
- ✅ No API errors in logs

## Related

- Introduced by: d394ec8 "Allow caching of tool calls"
- Workaround used in patterns: Cache-busting with Date.now() timestamps
- Those workarounds can now be removed (though they're harmless)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@jkomoros jkomoros requested a review from bfollington November 9, 2025 16:18
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 1 file

Comment on lines 611 to 613
...content.filter((part) => part.type === "text") as BuiltInLLMTextPart[],
...content.filter((part) =>
part.type === "text" && (part as BuiltInLLMTextPart).text && (part as BuiltInLLMTextPart).text.trim() !== ""
) as BuiltInLLMTextPart[],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this lead to an empty content array? That doesn't seem like it would be valid either.

@bfollington
Copy link
Contributor

This is one possible solution to https://linear.app/common-tools/issue/CT-1031/interruptedaborted-tool-call-can-end-up-caching-empty-string-as

I need to check if there are any other edgecases before merging.

bfollington and others added 5 commits November 9, 2025 09:21
…iltering

## Problem with Previous Fix

The previous fix filtered empty text blocks, but could create invalid states:
- Message with only empty text: `content: [{type:"text", text:""}]`
- After filtering: `content: []` ← Invalid (except for final message)

## Root Cause

Vercel AI SDK v5.x bug: returns empty text blocks before tool calls.
Critical scenario: stream aborts after empty text but BEFORE tool calls.

Result:
1. Stream sends: {type:"text", text:""}
2. Stream crashes before tool-call events
3. We store: content: [{type:"text", text:""}] (no tool calls!)
4. Gets cached
5. Next request: filter creates content: []
6. API rejects: "messages must have non-empty content"

## New Solution

### 1. Validate Fresh Responses
Check if response has valid content before storing:
- If invalid (empty/only whitespace), insert proper error message
- Provides user feedback: "I encountered an error generating a response..."
- Maintains valid message history

### 2. Enhanced Cached Message Filtering
- Filter empty text blocks from cached messages
- Remove messages that become empty after filtering
- Respect API rule: final assistant message CAN be empty

### 3. Remove Unnecessary Filtering
Removed filtering from buildAssistantMessage since we now validate upstream.

## Benefits

- Handles root cause (aborted/incomplete responses)
- User gets clear error message instead of silent failure
- Logs warnings for debugging
- Never sends invalid payloads to Anthropic API
- Backward compatible with already-cached invalid messages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
## Problems Fixed

1. **Empty/invalid LLM responses**: Stream aborts after empty text but before tool calls
2. **Tool results with no output**: Tools returning undefined/null broke API validation
3. **Tool result filtering**: Cached tool result messages could be removed incorrectly
4. **Mismatched tool calls/results**: If executeToolCalls fails partially

## Root Causes

**Vercel AI SDK bug**: Returns empty text blocks before tool calls:
```json
{
  "content": [
    { "type": "text", "text": "" },  // ← SDK bug
    { "type": "tool-call", ... }
  ]
}
```

**Critical scenario**: Stream crashes after empty text but BEFORE tool calls:
- Receive: `content: [{type:"text", text:""}]` (no tool calls!)
- Store as-is, gets cached
- Next request: Filter creates `content: []` ← Invalid!
- API rejects: "messages must have non-empty content"

**Tool result validation**: Anthropic API **requires** tool_result for every tool_use:
- If tool returns undefined/null, output field was undefined
- Error: "tool_use ids were found without tool_result blocks"

## Solution

### 1. Validate Fresh Responses
Check if response has valid content before storing:
```typescript
function hasValidContent(content): boolean {
  // Returns true only if content has non-empty text OR tool calls OR tool results
}

if (!hasValidContent(llmResult.content)) {
  // Insert proper error message instead of invalid content
  content: "I encountered an error generating a response. Please try again."
}
```

### 2. Ensure Tool Results Always Valid
Convert undefined/null results to explicit null value:
```typescript
if (toolResult.result === undefined || toolResult.result === null) {
  output = { type: "json", value: null };
}
```

### 3. Never Filter Tool Messages
Tool result messages must always be preserved:
```typescript
if (msg.role === "tool") return true;  // Never filter
```

### 4. Validate Tool Call/Result Match
Ensure every tool call has a corresponding result:
```typescript
if (toolResults.length !== toolCallParts.length) {
  // Log detailed error, insert error message
}
```

### 5. Enhanced Cached Message Filtering
- Filter empty text blocks from cached messages (backward compat)
- Remove messages that become empty after filtering (except final)
- Respect Anthropic API rule: final assistant message CAN be empty

## Benefits

- ✅ Handles root cause (aborted/incomplete responses)
- ✅ User gets clear error messages instead of silent failure
- ✅ Logs warnings for debugging
- ✅ Never sends invalid payloads to Anthropic API
- ✅ Backward compatible with already-cached invalid messages
- ✅ Test coverage for all edge cases

## Test Coverage

Added 7 new test cases:
- hasValidContent validates empty/non-empty text
- hasValidContent validates tool calls and results
- createToolResultMessages handles undefined/null results

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Instead of filtering empty text blocks from within messages and then
removing messages that become empty, use the same hasValidContent()
validation as fresh responses. This is cleaner and more consistent:

Before:
- Map over messages to filter empty text blocks from content arrays
- Then filter out messages that became empty after text filtering
- Complex multi-step transformation

After:
- Single validation pass using hasValidContent()
- Remove messages that fail validation entirely
- Same validation logic as fresh responses

Benefits:
- Simpler code (one filter instead of map + filter)
- Consistent validation across fresh and cached messages
- Don't try to "fix" invalid data, just remove invalid messages
- Clearer intent: validate and remove, not transform and patch
Instead of validating and filtering cached messages, let the API reject
invalid legacy data. This significantly simplifies the code:

Before:
- Validate all cached messages with hasValidContent()
- Filter out invalid messages with logging
- Special handling for tool messages and final assistant messages

After:
- Send messages as-is to API
- If legacy invalid data exists, API will reject with clear error
- User can start fresh conversation

This is acceptable because:
1. Going forward, we never write invalid data (validated at source)
2. Legacy conversations with invalid data are rare edge cases
3. Clear API error is better than silent data manipulation
4. Much simpler code

The prevention happens entirely at write time:
- Fresh responses validated with hasValidContent()
- Tool results always have valid output
- Tool call/result counts validated
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 1 file

@bfollington bfollington merged commit 87552a9 into main Nov 9, 2025
9 checks passed
@bfollington bfollington deleted the tools-caching-fix branch November 9, 2025 19:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants