Skip to content

fix(assembler): drop empty content for user/toolResult roles, not only assistant#606

Merged
jalehman merged 1 commit into
Martian-Engineering:mainfrom
castaples:fix/assembler-empty-content-all-roles
May 11, 2026
Merged

fix(assembler): drop empty content for user/toolResult roles, not only assistant#606
jalehman merged 1 commit into
Martian-Engineering:mainfrom
castaples:fix/assembler-empty-content-all-roles

Conversation

@castaples

Copy link
Copy Markdown
Contributor

Summary

Fix Bedrock messages.N is empty validation rejection by extending the assemble pass's empty-content filter to cover user and toolResult roles, not only assistant.

The pre-existing cleanedEntries filter at src/assembler.ts only guarded the assistant role. An empty content array produced upstream for a user or toolResult message during incremental compaction could survive the cleaned-tail pass and reach Bedrock Converse, which rejects it with the literal wording:

Validation error: The content field in the Message object at messages.0 is empty. Add a ContentBlock object to the content field and try again.

A direct probe against eu.anthropic.claude-haiku-4-5-20251001-v1:0 via ConverseCommand confirms this exact wording is reproducible only when content === [] (empty array); bare strings produce a different SerializationException error, so the production wording is specific to the empty-array shape.

The asymmetry between the assistant guard and the missing user/toolResult guard explains why the v0.9.3 prefill safety net (#572) and the v0.9.4 blank-text guard did not fully close the validation surface.

Production reproduction signal

Observed in a long-running OpenClaw session against eu.anthropic.claude-opus-4-6-v1 via bedrock-converse-stream. The failed turn was recorded as:

{
  "type": "message",
  "timestamp": "2026-05-01T07:50:52.195Z",
  "message": {
    "role": "assistant",
    "content": [{"type":"text","text":"[assistant turn failed before producing content]"}],
    "api": "bedrock-converse-stream",
    "provider": "amazon-bedrock",
    "model": "eu.anthropic.claude-opus-4-6-v1",
    "stopReason": "error",
    "errorMessage": "Validation error: The content field in the Message object at messages.0 is empty. Add a ContentBlock object to the content field and try again."
  }
}

Timeline (the LCM conversations row for this session at the time of failure):

UTC time Event
07:29:24 First leaf summary sum_14ac9f3745cdfa00 created
07:32:50–07:33:12 Active turns (subagent calls + tool results)
07:50:51 New leaf summary sum_91b1ebdf4c1bb243 committed
07:50:52 Assistant turn fails with messages.0 is empty (≤1 s after the summary)
07:58:21 Third leaf summary created on retry
07:58:32 Fourth leaf summary + second identical failure

The ≤1 s delta between summary commit and failure points to a transient mid-compaction state rather than a persisted shape — replaying the same conversation's stored lcm.db state today through the assemble pipeline produces zero empty-array messages, so the empty array is produced and consumed within the compaction window.

What changed

  1. New helper isEmptyMessageContent(message) in src/assembler.ts (exported with @internal JSDoc, matching the existing toolCallBlockFromPart / toolResultBlockFromPart / blockFromPart / contentFromParts convention). It returns true when content is:

    • undefined or null
    • empty string "" or whitespace-only string
    • empty array [] for any role
    • thinking-only or blank-text-only content for the assistant role specifically (preserves the existing v0.9.3 / v0.9.4 guards)
  2. Replace the cleanedEntries filter to use the new helper. The filter now drops empty content for any role.

  3. Extended comment on the filter explains the new compaction-window failure mode and notes that dropping a toolResult is safe because sanitizeToolUseResultPairing runs immediately afterwards and re-pairs missing results with the existing synthetic [lossless-claw] missing tool result … placeholder.

  4. Regression test test/assembler-empty-content.test.ts (28 cases):

    • universal empty handling across user/assistant/toolResult
    • non-empty preservation across the same three roles
    • assistant-only thinking-only / blank-text guards remain in place
    • the exact production failure shape (empty-array user and toolResult)
  5. Changeset (patch bump per AGENTS.md guidance for fixes / compatibility work).

Risk and compatibility

  • The helper preserves all pre-existing behaviour for the assistant role (thinking-only and blank-text-only guards still fire).
  • New filtering is strictly additive — empty content arrays for user/toolResult were always rejected by the downstream provider; we now drop them earlier rather than producing an unrecoverable rejection.
  • Downstream sanitizeToolUseResultPairing already handles missing tool results via synthetic placeholders, so a dropped empty toolResult is recoverable.
  • No public API change.

Test plan

  • npm test — 879 / 886 tests pass on Windows. The 7 failures are pre-existing Windows-specific path/HOME issues (backslashes vs forward slashes, HOME env handling) unrelated to this change. Will run green on CI's ubuntu-latest.
  • npm test -- test/assembler-empty-content.test.ts test/assembler-blocks.test.ts test/transcript-repair.test.ts — 75 / 75 green.
  • npm run build — bundle builds cleanly (688.4 KB).
  • CI green (will run on push).

Related

  • v0.9.3 hotfix [hotfix] v0.9.3 prefill safety net + Codex/Ollama silent redirects (A/8) #572 (prefill safety net + blank-text guard) — sibling fix, addresses the text field … is blank wording for assistant content
  • The pre-existing isThinkingOnlyContent / isBlankContent helpers (lines 97–124) — the new isEmptyMessageContent helper builds on them and keeps their assistant-specific scope intact

Happy to iterate on naming, helper placement, or the changeset wording — and to extend the filter to additional empty shapes if maintainers know of other transient post-compaction states that produce them.

Made with Cursor

…y assistant

The assemble pass's `cleanedEntries` filter only guarded `assistant`
messages against empty/blank content. An empty `user` or `toolResult`
content array produced upstream during incremental compaction could
survive the cleaned-tail filter and reach Bedrock Converse, which
rejects it with the literal wording:

  The content field in the Message object at messages.N is empty.
  Add a ContentBlock object to the content field and try again.

A direct probe against Bedrock confirms this exact wording is
reproducible only when `content === []`; bare strings or non-empty
arrays produce a different (`SerializationException`) error. The
asymmetry between the assistant guard and the missing user/toolResult
guard explains why the 0.9.3 prefill safety net (Martian-Engineering#572) and the 0.9.4
blank-text guard did not fully close the validation surface.

Extract a unified `isEmptyMessageContent` helper that drops
`undefined`, `null`, empty-string, whitespace-only string, and
empty-array content for any role, while preserving the existing
assistant-only thinking-only and blank-text guards. Dropping a
`toolResult` here is safe — `sanitizeToolUseResultPairing` runs
immediately below and re-pairs missing results with a synthetic
`[lossless-claw] missing tool result …` placeholder.

Add `test/assembler-empty-content.test.ts` covering 28 cases:
universal empty handling across all three roles, non-empty
preservation, the assistant-only thinking/blank-text guards, and a
direct regression for the production failure shape.

Co-authored-by: Cursor <cursoragent@cursor.com>
@jalehman jalehman self-assigned this May 11, 2026
@jalehman

Copy link
Copy Markdown
Contributor

Thank you!

@jalehman jalehman merged commit 0cdb664 into Martian-Engineering:main May 11, 2026
2 checks passed
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.

2 participants