openclaw - 💡(How to fix) Fix Bug: structured tool_calls with finish_reason stop are dropped as non_deliverable_terminal_turn

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

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.

Error Message

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.

  • 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.

Root Cause

  1. 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.

  2. 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
  1. 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.

Fix Action

Fix / Workaround

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.

Code Example

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

---

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
RAW_BUFFERClick to expand / collapse

Affected version: [email protected] (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

openclaw2026.5.28
nodev22.22.2
platformLinux aarch64 (NVIDIA GB10)
provideropenai-completions -> vLLM 0.19.1, model Qwen/Qwen3.6-27B-FP8, native auto tool choice

Root cause

  1. 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.

  2. 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
  1. 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.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - 💡(How to fix) Fix Bug: structured tool_calls with finish_reason stop are dropped as non_deliverable_terminal_turn