Skip to content

fix: prevent raw toolCall blocks from bypassing argument normalization#52

Open
rivetphilbot wants to merge 2 commits intoMartian-Engineering:mainfrom
rivetphilbot:fix/assembler-toolcall-arguments
Open

fix: prevent raw toolCall blocks from bypassing argument normalization#52
rivetphilbot wants to merge 2 commits intoMartian-Engineering:mainfrom
rivetphilbot:fix/assembler-toolcall-arguments

Conversation

@rivetphilbot
Copy link

@rivetphilbot rivetphilbot commented Mar 12, 2026

Problem

Two related bugs in assembler.ts caused xAI/OpenAI Chat Completions API to reject LCM-assembled tool call history with HTTP 422:

Bug 1: Raw block bypass (fixed in initial commit)

blockFromPart() had an early return path that returned metadata.raw verbatim for all block types. This bypassed toolCallBlockFromPart() / toolResultBlockFromPart(), which normalize the arguments field. When tool call arguments were stored as a JavaScript object (the common case), the raw block passed them as-is — but Chat Completions requires arguments to be a JSON string.

Bug 2: Wrong field name for toolCall type (fixed in this update)

Even after routing through toolCallBlockFromPart(), the function emitted tool call input under the input key for toolCall-typed blocks:

// Before (broken):
if (type === "functionCall") {
  block.arguments = input;
} else {
  block.input = input;  // ← toolCall lands here, but consumers read "arguments"
}

OpenClaw's provider layer (extractToolCalls for Chat Completions, Responses API path) reads block.arguments, not block.input. The mismatch caused arguments: undefined in the serialized payload, which xAI rejects with missing field "arguments".

Fix

// After (fixed):
if (type === "functionCall" || type === "toolCall") {
  block.arguments = input;  // OpenAI/xAI format
} else {
  block.input = input;       // Anthropic native format (tool_use, toolUse, tool-use)
}
  • toolCall and functionCallarguments (consumed by Chat Completions + Responses API)
  • tool_use, toolUse, tool-useinput (Anthropic native format, separate provider path)

Blast radius

  • xAI/OpenAI (Chat Completions): Fixed — extractToolCalls reads arguments
  • xAI/OpenAI (Responses API): Fixed — reads block.arguments
  • Anthropic: Unaffected — uses separate tool_use format with input, different provider path entirely
  • Existing data: All 5,688 tool calls in our Postgres store use rawType: "toolCall". The fix applies at assembly time, no data migration needed.

Reproduction

# arguments as object → 422
curl -X POST https://api.x.ai/v1/chat/completions \
  -d '{"model":"grok-3-mini","messages":[
    {"role":"user","content":"read SOUL.md"},
    {"role":"assistant","tool_calls":[{"id":"call-1","type":"function","function":{"name":"read","arguments":{"path":"/tmp/test.md"}}}]},
    {"role":"tool","tool_call_id":"call-1","content":"file contents"},
    {"role":"user","content":"thanks"}
  ]}'

# arguments as JSON string → 200 OK

Test coverage

New assembler-blocks.test.ts with 22 tests:

  • toolCallBlockFromPart: arguments vs input for all rawType variants (toolCall, functionCall, function_call, tool_use, toolUse, tool-use), string input, null input, empty toolCallId
  • toolResultBlockFromPart: default/function_call_output types, fallback chain (toolOutput → textContent → empty)
  • blockFromPart: raw bypass guard for tool types, OpenAI reasoning restoration, tool result routing, text fallback

Updated engine test for the raw metadata normalization behavior.

269 tests passing, 0 failures.

Context

Found while debugging persistent xAI 422 errors on Grok's heartbeat session (conversation 326). Every 30-minute heartbeat cycle failed because the conversation history contained tool calls from the first successful run, and subsequent replays hit both bugs.

blockFromPart() has an early return path that returns metadata.raw
verbatim when it exists as an object. This bypasses
toolCallBlockFromPart() and toolResultBlockFromPart(), which are
responsible for normalizing the arguments field.

When tool call arguments are stored as a JavaScript object (which is
the common case for OpenClaw-originated tool calls), the raw block
passes them through as-is. Chat Completions providers (xAI, OpenAI,
etc.) require arguments to be a JSON string, not an object, and
reject the request with HTTP 422:

  'invalid type: map, expected a string'

The Responses API has a safety net that stringifies arguments if
needed, but Chat Completions does not — so any provider using that
path is affected.

The fix checks the raw block's type field before the early return.
Tool-related types (toolCall, tool_use, functionCall, etc.) now fall
through to the proper normalization functions, which read from the
dedicated part columns and let OpenClaw's provider adapters handle
wire format conversion.

Found while testing Martian-Engineering#44 (PostgreSQL backend) — tool calls accumulated
during heartbeat sessions triggered the 422 on xAI's Chat Completions
API. Reproduced by sending arguments as an object vs JSON string to
the xAI API directly.
toolCallBlockFromPart emitted tool call input under the 'input' key
for toolCall-typed blocks. OpenClaw's provider layer (extractToolCalls
for Chat Completions, Responses API path) reads 'arguments', not
'input'. This caused xAI/OpenAI to reject assembled context with
HTTP 422: 'missing field arguments'.

The fix:
- toolCall and functionCall types now emit 'arguments' (consumed by
  OpenAI/xAI Chat Completions and Responses API)
- tool_use, tool-use, and toolUse types continue to emit 'input'
  (Anthropic native format, separate provider path)
- Export toolCallBlockFromPart, toolResultBlockFromPart, and
  blockFromPart for direct unit testing

New test file (assembler-blocks.test.ts) with 22 tests covering:
- toolCallBlockFromPart: arguments vs input for all rawType variants,
  string input, null input, empty toolCallId
- toolResultBlockFromPart: default/function_call_output types,
  fallback chain (toolOutput → textContent → empty)
- blockFromPart: raw bypass guard for tool types, OpenAI reasoning
  restoration, tool result routing, text fallback

Updated engine test: 'counts large raw metadata blocks' renamed to
'normalizes tool_result blocks instead of returning raw metadata
verbatim' — the old test was asserting buggy behavior (raw metadata
blobs leaking into assembled payload).

Found via xAI 422 errors on Grok heartbeat sessions (conv 326) where
LCM-assembled tool call history was rejected by the Chat Completions
API.
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.

1 participant