#17461

Empty-turn retry loop never runs for direct openai→openai turns; judge counts draft-only text as content

Openmanjughosaopened 1d ago

Summary

The built-in empty-turn retry loop never runs for direct openai→openai turns, and the buffered-turn judge counts draft-only text as content. Together these let a degenerate turn through to the client instead of being retried on another connection.

Reported separately from #17278 per your request.

Environment

  • OmniRoute Desktop 3.8.52, Windows 11
  • Upstream: local OpenAI-compatible bridges (JetHub pools on 127.0.0.1:64646, WorkBuddy bridge on 127.0.0.1:47825)
  • Affected provider in practice: GLM-5.3 over a direct openai→openai connection

Problem 1 — the gate excludes direct (non-translated) turns

runEmptyTurnRetryLoop is called behind two conditions:

let e = false;
try { e = isFeatureFlagEnabled("FLUSH_EMPTY_RETRY_ENABLED") } catch { e = false }
let o = targetFormat === FORMATS.OPENAI_RESPONSES || needsTranslation(targetFormat, clientResponseFormat);
if (e && o) { let r = await runEmptyTurnRetryLoop({ … }) }
  • FLUSH_EMPTY_RETRY_ENABLED defaults to false.
  • Even with the flag on, o is false for openai→openai, so direct providers (GLM, WorkBuddy, and every openai-compatible-chat-* node) never reach the retry loop.

So the loop only helps translated paths, which is the opposite of where the degenerate turns actually come from in my traffic.

Problem 2 — the judge treats draft-only text as content

judgeBufferedTurn decides emptiness with:

!k.has(i.finishReason) && !i.toolCallsPresent && !(i.contentText.length > 0) && ( … )

contentText.length > 0 is the only text test. A turn whose entire output is a compression-draft skeleton:

<summary>…</summary>
<analysis>…</analysis>

has non-zero contentText, so it is classified as a normal turn and forwarded downstream. The same is true of the inline early-pass probe (isUsefulSummary), which sees the same non-empty text and passes it before the judge ever runs.

Observed symptom

With GLM-5.3 on long conversations, the client receives a turn that carries only draft markup. The downstream consumer strips the tags, is left with an empty turn, and kills the stream. Log line from the client side:

[ACP] stream error: the turn degenerated again after the continuation nudge

The gateway's own recovery machinery (runEmptyTurnRetryLoop, bounded at STREAM_RECOVERY.EMPTY_TURN_RETRY_MAX = 4) is never consulted, so a recoverable turn becomes a dead session.

Requests

  1. Let the flag cover direct paths. The translate-only guard looks like it predates direct-provider support. If the retry loop is safe for translated turns it should be safe for openai→openai; gating on e alone would let operators opt in for all streaming paths.
  2. Make the judge look at content, not just length. A contentText that is only <summary>/<analysis> blocks (no text outside the tags) should classify as an empty turn so the existing retry path can take over. Same for the early-pass probe, otherwise the probe returns before the judge is reached.

Local workaround I am running (for reference)

Three text edits in the server bundle, all asserted to match exactly once:

  • P1 judge: treat draft-only contentText as empty
  • P2 probe: draft-only text must not early-pass
  • P3 gate: if (e && o) → if (e), with FLUSH_EMPTY_RETRY_ENABLED=true set by the launcher

P1/P2/P3 are applied to 3 identical chunk copies. This is a bundle hot-patch that dies on the next update — hence the issue.

Note

I have not been able to confirm whether the retry loop is correct for direct paths (e.g. whether credential rotation and leased/pinned semantics behave the same when needsTranslation is false). If there is a reason the guard exists, say so and I will drop request 1 and keep only the judge fix.

Activity

0

No activity yet.