Affected version: openclaw@2026.5.28 (also present in 2026.5.27)
Summary
When an OpenAI-compatible provider streams a structured tool call but reports finish_reason: stop (instead of tool_calls), OpenClaw maps the turns stopReason to stop, never executes the pending tool call, and the run terminates with terminalError: non_deliverable_terminal_turn. The result is silently dropped: no assistant reply and no error message are delivered to the user.
The streaming response assembler already guards the opposite inconsistency (stopReason === toolUse but no tool-call blocks -> downgrade to stop), but the symmetric case (stopReason === stop but tool-call blocks are present) is not handled.
Impact
- User-visible silence: the agent appears to not answer. The turn ends in status: error but, because it is classified non-deliverable, nothing reaches the channel.
- replaySafe: no for this class, so there is no automatic retry.
- Observed intermittently in production (~12 incomplete-turn terminations over 3 days) against a self-hosted vLLM (Qwen/Qwen3.6-27B-FP8, openai-completions API).
Environment
|
|
| openclaw |
2026.5.28 |
| node |
v22.22.2 |
| platform |
Linux aarch64 (NVIDIA GB10) |
| provider |
openai-completions -> vLLM 0.19.1, model Qwen/Qwen3.6-27B-FP8, native auto tool choice |
Root cause
-
finish_reason is mapped purely by string in openai-transport-stream.ts -> mapStopReason(). finish_reason: stop always maps to stopReason: stop, regardless of whether the assembled message contains tool-call blocks.
-
The stream finalizer guards only one direction:
const hasToolCalls = output.content.some((block) => block.type === "toolCall");
if (output.stopReason === "toolUse" && !hasToolCalls) output.stopReason = "stop";
// <- missing: the symmetric stop but DOES have tool calls case
- Downstream classification in attempt-trajectory-status.ts -> resolveAttemptTrajectoryTerminal(): With stopReason === stop, no visible assistant text and no delivery evidence, the turn falls through to non_deliverable_terminal_turn.
Proposed fix
Add the symmetric guard in the streaming finalizer of openai-transport-stream.ts:
const hasToolCalls = output.content.some((block) => block.type === "toolCall");
if (output.stopReason === "toolUse" && !hasToolCalls) output.stopReason = "stop";
if (output.stopReason === "stop" && hasToolCalls) output.stopReason = "toolUse"; // NEW
Why this is correct:
- Mirrors the existing guard at the same point after finishAllToolCallBlocks()
- If a provider emits structured tool calls but reports finish_reason: stop, the turn is treated as toolUse and the runtime executes the tool and continues
- Only promotes stop -> toolUse when tool-call blocks are actually present; normal text completions unaffected; does not touch length (truncated args must remain truncation)
Optional hardening
If a non-streaming assembly path exists, it would benefit from the same hasToolCalls && stopReason === stop check.
Affected version: openclaw@2026.5.28 (also present in 2026.5.27)
Summary
When an OpenAI-compatible provider streams a structured tool call but reports finish_reason: stop (instead of tool_calls), OpenClaw maps the turns stopReason to stop, never executes the pending tool call, and the run terminates with terminalError: non_deliverable_terminal_turn. The result is silently dropped: no assistant reply and no error message are delivered to the user.
The streaming response assembler already guards the opposite inconsistency (stopReason === toolUse but no tool-call blocks -> downgrade to stop), but the symmetric case (stopReason === stop but tool-call blocks are present) is not handled.
Impact
Environment
Root cause
finish_reason is mapped purely by string in openai-transport-stream.ts -> mapStopReason(). finish_reason: stop always maps to stopReason: stop, regardless of whether the assembled message contains tool-call blocks.
The stream finalizer guards only one direction:
Proposed fix
Add the symmetric guard in the streaming finalizer of openai-transport-stream.ts:
Why this is correct:
Optional hardening
If a non-streaming assembly path exists, it would benefit from the same hasToolCalls && stopReason === stop check.