Skip to content

pi-embedded-runner: empty-reasoning-only completions on openai-codex-responses bypass failover (assistantTexts=[], usage.output>0) #85364

Description

@gabrielexito-stack

Summary

For openai-codex provider with gpt-5.5 over the openai-codex-responses API, the codex app-server can return a turn where the entire output is hidden reasoning tokens with assistantTexts=[] and zero visible content. Gateway accepts the turn as a successful completion (no failover triggered) and the channel transport delivers nothing — Discord/Telegram clients eventually show "The application did not respond."

The downstream agent and the user receive no useful content despite the model burning real tokens. This is a silent failure mode: decision=succeeded masks a fully empty turn.

Environment

  • OpenClaw 2026.5.20 (e510042) — npm install at ~/.local/lib/node_modules/openclaw
  • Node 25.8.1, macOS 25.3.0 (arm64)
  • Provider: openai-codex / model gpt-5.5 / modelApi: openai-codex-responses
  • Codex app-server: stdio
  • Channel: Discord direct (channel 1499764682443980892)
  • Auth: openai-codex OAuth profile [email protected]

Reproduction

  1. Configure an OpenClaw agent that uses openai-codex provider with gpt-5.5 (codex app-server transport).
  2. Have an active Discord (or any messaging) channel session for the agent.
  3. Send a short prompt that the model will answer with reasoning tokens (e.g. "u there partner ??" after the session has prior history).
  4. Periodically the codex app-server returns a turn where output tokens are non-zero but assistantTexts is [] and messagesSnapshot contains only the user message.

In our incident (2026-05-22), every other turn on this session succeeded; this is intermittent, not deterministic.

Error / trajectory evidence

Trajectory model.completed event for the empty turn (sanitized), ~/.openclaw/agents/chatgpt/sessions/3c0b493e-…trajectory.jsonl:

{
  "type": "model.completed",
  "ts": "2026-05-22T10:18:35.515Z",
  "runId": "d10373ab-0a95-40a7-ae7f-7459c745ea61",
  "provider": "openai-codex",
  "modelId": "gpt-5.5",
  "modelApi": "openai-codex-responses",
  "data": {
    "timedOut": false,
    "yieldDetected": false,
    "aborted": false,
    "promptError": null,
    "usage": {"input": 24794, "output": 111, "cacheRead": 4608, "total": 29513},
    "assistantTexts": [],
    "messagesSnapshot": [{"role": "user", "content": "u there partner ??..."}]
  }
}

The preceding (runId=5a61ca06) and following turns on the same session have non-empty assistantTexts. No embedded_run_failover_decision event was emitted for this run, no model-fallback chain was attempted — Gateway treated the turn as completed successfully.

Root cause

In src/agents/pi-embedded-runner/run/incomplete-turn.ts, the empty-response retry predicate is gated through isEmptyResponseAssistantTurn():

// src/agents/pi-embedded-runner/run/incomplete-turn.ts:412
function isEmptyResponseAssistantTurn(params: {
  payloadCount: number;
  attempt: Pick<IncompleteTurnAttempt, "assistantTexts" | "currentAttemptAssistant" | "lastAssistant">;
}): boolean {
  if (params.payloadCount !== 0) {
    return false;
  }
  if (joinAssistantTexts(params.attempt.assistantTexts).length > 0) {
    return false;
  }
  const assistant = params.attempt.currentAttemptAssistant ?? params.attempt.lastAssistant;
  if (!assistant) {
    return true;
  }
  if (assistant.stopReason === "error") {
    return false;
  }
  if (
    isIncompleteTerminalAssistantTurn({ hasAssistantVisibleText: false, lastAssistant: assistant }) ||
    isReasoningOnlyAssistantTurn(assistant)  // ← problem
  ) {
    return false;
  }
  return true;
}

The predicate explicitly returns false when isReasoningOnlyAssistantTurn(assistant) is true, meaning a reasoning-only turn goes to resolveReasoningOnlyRetryInstruction instead of resolveEmptyResponseRetryInstruction. For the openai-codex-responses payload shape, the reasoning-only retry path doesn't appear to trigger either — messagesSnapshot has no assistant message at all, and the codex app-server's response carries hidden reasoning under usage but exposes nothing on the assistant role for isReasoningOnlyAssistantTurn to detect. The net effect: the turn passes through all empty/reasoning guards and is treated as success.

embedded_run_failover_decision (src/agents/pi-embedded-runner/run/failover-observation.ts:70) is therefore never fired, and the model-fallback chain configured for the agent (which works fine for HTTP 408/timeouts — proven by the ops/gpt-5.5 → claude-opus-4-7 fallback at 15:31:17 ICT in the same incident window) does not activate.

Suggested fix

Add an emptyAssistantTextWithReasoningTokens failover predicate, triggered when:

  • payloadCount === 0
  • joinAssistantTexts(attempt.assistantTexts).length === 0
  • usage.output > 0 (the model spent tokens but emitted nothing visible)
  • messagesSnapshot has no assistant message
  • transport is openai-codex-responses (scope to the affected provider)

Either trigger the empty-response retry path (emptyResponseRetryInstruction), or classify the turn as FailoverReason="empty_response" and let the standard model-fallback chain decide.

// rough shape
function isCodexEmptyVisibleTurnDespiteOutputTokens(params: {
  modelApi?: string;
  attempt: IncompleteTurnAttempt;
  usage?: { output?: number };
}): boolean {
  return (
    params.modelApi === "openai-codex-responses" &&
    (params.usage?.output ?? 0) > 0 &&
    joinAssistantTexts(params.attempt.assistantTexts).length === 0 &&
    !params.attempt.messagesSnapshot?.some((m) => m.role === "assistant")
  );
}

Workaround

Switch the agent's primary model off gpt-5.5 (the codex app-server path). Config-level swap in ~/.openclaw/agents/<id>/agent.json to e.g. claude-sonnet-4-6 avoids the failure mode entirely.

Severity

P2 — silent message loss on a production-facing channel. Not a crash, but the user sees no response and no error, and the model-fallback chain configured by the operator is bypassed. Different from #85192 (DeepSeek V4 unsigned thinking blocks) and #76048 (ZAI GLM-5 reasoning_content routing) — those are distinct providers; this is openai-codex-responses with non-empty usage.output and messagesSnapshot missing an assistant entry entirely.

Related

  • #78384 — assistant can reply to stale user intent after reasoning-only/tool retry (overlaps with the reasoning-only detection path)
  • #85192 — DeepSeek V4 unsigned thinking blocks miss reasoning-only retry (same class, different provider)
  • #80918 — silent send miss: incomplete-turn classifier discards stopReason=stop final after update_plan (adjacent)
  • #84076 — Codex app-server stalls after item/completed (different symptom; this issue is a clean model.completed with empty content, not a stall)

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal backlog priority with limited blast radius.clawsweeper:needs-infoClawSweeper needs more reporter information before it can verify this issue.impact:auth-providerAuth, provider routing, model choice, or SecretRef resolution may break.impact:message-lossChannel message delivery can be lost, duplicated, or misrouted.issue-rating: 🦐 gold shrimpDecent issue quality, but reproduction details are still incomplete.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions