Skip to content

Bug: structured tool_calls with finish_reason stop are dropped as non_deliverable_terminal_turn #88791

Description

@kiagentkronos-cell

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

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

  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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions