Skip to content

fix(loop): add headless NEEDS_PRO escalation for reasonix run#2195

Closed
chunc4730-collab wants to merge 1 commit into
esengine:mainfrom
chunc4730-collab:fix/headless-needs-pro-escalation
Closed

fix(loop): add headless NEEDS_PRO escalation for reasonix run#2195
chunc4730-collab wants to merge 1 commit into
esengine:mainfrom
chunc4730-collab:fix/headless-needs-pro-escalation

Conversation

@chunc4730-collab

Copy link
Copy Markdown

Problem

In headless one-shot mode (reasonix run), the flash model can emit <<<NEEDS_PRO>>> to signal that a task exceeds its capability. In TUI mode, the UI layer catches this and retries with deepseek-v4-pro. In headless mode, no TUI is listening — the escalation event is lost and the turn ends without retrying.

Fix

6 lines added in src/loop.ts (CacheFirstLoop.step()), immediately after streamModelResponse returns. When the <<<NEEDS_PRO marker is found in assistantContent and the model isn't already pro, we switch this.model = "deepseek-v4-pro" and continue the iteration.

The flash model's NEEDS_PRO response is never persisted (we skip appendAndPersist), so it doesn't pollute the session log.

Testing

  • Simple task (2+2): unchanged behavior ✓
  • Complex security audit: flash handled it, no false escalation ✓
  • Escalation path: when flash emits NEEDS_PRO → model switches to pro → iteration retries

Related

This is the loop.ts counterpart to the existing TUI escalation handler in src/cli/ui/slash/handlers/model.ts.

In headless one-shot mode (`reasonix run`), the flash model can emit
`<<<NEEDS_PRO>>>` to signal that a task exceeds its capability. In TUI
mode, the UI layer catches this and retries with deepseek-v4-pro. In
headless mode, no TUI is listening — the escalation event is lost and
the turn ends without retrying.

This fix adds inline NEEDS_PRO detection in CacheFirstLoop.step(),
immediately after streamModelResponse returns. When the marker is
found and the model isn't already pro, we switch to deepseek-v4-pro
and continue the iteration — no process restart needed.

The flash model's NEEDS_PRO response is never persisted (we skip
appendAndPersist), so it doesn't pollute the session log.

@esengine esengine left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the gap — in headless reasonix run there's no TUI layer to act on <<<NEEDS_PRO>>>, so detecting it in the loop is the right place. But the escalation needs to be one-shot, and as written it's permanent.

The escalation contract (src/prompt-fragments.ts:18) promises: 'This aborts the current call and retries this turn on deepseek-v4-pro, one shot.' Your code does this.model = "deepseek-v4-pro" and never restores it — and this.model isn't reset per turn (only set in the constructor / configure(), loop.ts:217/412). So after a single <<<NEEDS_PRO>>>, every subsequent turn for the rest of the session runs on pro, which contradicts the cost-aware 'one shot this turn' design (this whole product is built around cheap tokens / not burning pro unnecessarily).

Please make it one-shot: capture the base model, escalate for the retry, and restore it after the escalated turn completes (or scope the pro override to the retried call rather than mutating this.model for the session). A test that asserts turn N+1 is back on flash after an escalation on turn N would pin it.

Minor: consider writing a one-line stderr note when you escalate in headless (the TUI surfaces '⇧ flash requested escalation — '; headless currently escalates silently). The <<<NEEDS_PRO: reason>>> form carries a reason you could log. Not blocking.

Also FYI this touches the same loop.ts region as #2162 (just merged) so you'll likely need a rebase. CI re-approved on my side.

@esengine

Copy link
Copy Markdown
Owner

CI is green now, but the one-shot issue from my review is still open — the code still does this.model = "deepseek-v4-pro" with no restore, so a single <<<NEEDS_PRO>>> permanently pins the rest of the session to pro. The escalation contract (src/prompt-fragments.ts) and the TUI both treat it as 'retry this turn on pro, one shot'. Please capture the base model and restore it after the escalated turn (or scope the override to the single retried call) so headless matches that contract and doesn't silently run every subsequent turn on the expensive tier. A test asserting turn N+1 is back on flash after an escalation on turn N would lock it in.

@esengine

Copy link
Copy Markdown
Owner

Heads up — #2236 (by @BeiZi6) implements the one-shot behavior I asked for here: it captures the base model and restores it after the escalated turn (restoreModelIfNeeded() in finally), so escalation is genuinely 'this turn only' instead of permanently switching the session to pro, plus an anchored marker regex and a regression test. Since that's the piece this PR left open, I'm landing #2236. Thanks for surfacing the gap originally (#2196) — closing this in favor of #2236 unless you want to take it over.

@esengine

Copy link
Copy Markdown
Owner

#2236 is now merged — it lands the one-shot headless escalation correctly (base-model capture + restore in finally, anchored marker, regression test). So this is fully superseded; closing. Thanks again for raising #2196 and the original attempt — it's what surfaced the gap.

@esengine esengine closed this May 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants