Skip to content

Commit 58d2eae

Browse files
ehsan6shaclaude
andcommitted
runtime: thinking-off on force-verdict retry (frees token budget)
When the model emits a prose-only first turn (no XML blocks), the backend injects FORCE_VERDICT_DIRECTIVE as the next user message and re-rolls. Until now, both turns ran with enable_thinking=self ._enable_thinking (True for Qwen 3). On RK3588 with Qwen3-1.7B at max_new_tokens=1500, the retry burns its entire budget inside <think>...</think> just like the first turn — never reaches the structured tail. The bridge then emits the synthetic "Model did not produce a structured verdict" fallback and the user sees a yellow non-answer. Lab evidence (anonymized transcript from a 'cannot-join-pool' session): two `thought` events ("You're running diag/summary..." + "Analyzing diagnostics...") followed by the synthetic verdict at T+41s — no tool_call, no real verdict. Fix: track a per-turn `next_thinking` flag. Default to the backend-level enable_thinking. When we set `next_content = FORCE_VERDICT_DIRECTIVE` (both the MAX_TURNS-1 preemptive branch AND the prose-only mid-loop branch), also set `next_thinking = False`. The retry then runs without thinking, so all 1500 tokens are available for the ~80-token <verdict> block — diagnostic context is already in KV cache from prior turns so the model doesn't need to re-reason, just format the conclusion. Also gates `output_for_parsing = _strip_think(...)` and the "Analyzing diagnostics..." marker on `next_thinking` instead of `self._enable_thinking` so the strip + marker correctly skip when the retry ran without `<think>` blocks. New test verifies turn 0 runs thinking ON, turn 1 (force-verdict retry) runs thinking OFF. All 56 existing tests still pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent aa8799f commit 58d2eae

2 files changed

Lines changed: 77 additions & 5 deletions

File tree

src/runtime/rkllm_runtime.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1140,6 +1140,16 @@ async def run_troubleshoot(
11401140
next_role: str = "user"
11411141
next_content: str = first_turn_content
11421142
next_keep_history: int = 0 # 0 on first turn; 1 thereafter
1143+
# next_thinking gates the per-call enable_thinking flag. Default
1144+
# follows the backend-level filename gate; force-verdict retries
1145+
# flip this off so the entire max_new_tokens budget can land on
1146+
# the structured <verdict> block (lab 2026-05-27: with thinking
1147+
# on, Qwen3-1.7B exhausts the budget inside <think>…</think>
1148+
# and never reaches the structured tail — the bridge then emits
1149+
# the synthetic "Model did not produce a structured verdict"
1150+
# fallback). Diagnostic data is already in KV cache from prior
1151+
# turns, so the retry only needs to format the conclusion.
1152+
next_thinking: bool = self._enable_thinking
11431153

11441154
for turn in range(MAX_TURNS):
11451155
# Last-chance: at MAX_TURNS-1 without a verdict, send the
@@ -1154,6 +1164,7 @@ async def run_troubleshoot(
11541164
next_role = "user"
11551165
next_content = FORCE_VERDICT_DIRECTIVE
11561166
next_keep_history = 1 # keep prior KV
1167+
next_thinking = False # see next_thinking init comment
11571168
force_verdict_attempted = True
11581169

11591170
try:
@@ -1162,11 +1173,11 @@ async def run_troubleshoot(
11621173
# routes the message to the right slot in the template.
11631174
output = await loop.run_in_executor(
11641175
None,
1165-
lambda r=next_role, c=next_content, kh=next_keep_history: (
1176+
lambda r=next_role, c=next_content, kh=next_keep_history, t=next_thinking: (
11661177
self._runtime.generate(
11671178
c,
11681179
role=r,
1169-
enable_thinking=self._enable_thinking,
1180+
enable_thinking=t,
11701181
keep_history=kh,
11711182
timeout_s=PER_TURN_TIMEOUT_S,
11721183
)
@@ -1200,9 +1211,11 @@ async def run_troubleshoot(
12001211
# structured response (tool calls, verdict, recommendations)
12011212
# only exists AFTER `</think>`. Pre-strip so the parsers
12021213
# can't be tripped by stray XML mentions inside the model's
1203-
# reasoning prose ("I should call <tool_call>").
1214+
# reasoning prose ("I should call <tool_call>"). Gate on
1215+
# the per-turn flag (next_thinking) — force-verdict retries
1216+
# run with thinking off so no `<think>` block exists to strip.
12041217
output_for_parsing = (
1205-
_strip_think(output) if self._enable_thinking else output
1218+
_strip_think(output) if next_thinking else output
12061219
)
12071220

12081221
# NOTE: history list is no longer rebuilt-per-turn — v1.2.3
@@ -1223,7 +1236,7 @@ async def run_troubleshoot(
12231236
if thought_text:
12241237
# SSE thought schema: minLength 1, maxLength 4000
12251238
yield {"type": "thought", "payload": thought_text[:4000]}
1226-
elif self._enable_thinking:
1239+
elif next_thinking:
12271240
# Qwen 3 turn was 100% structured output after <think>;
12281241
# avoid a silent stretch by emitting a tiny marker.
12291242
yield {"type": "thought", "payload": "Analyzing diagnostics..."}
@@ -1349,6 +1362,12 @@ async def run_troubleshoot(
13491362
next_role = "user"
13501363
next_content = FORCE_VERDICT_DIRECTIVE
13511364
# keep_history=1 already set after first turn.
1365+
# Turn off thinking for the retry so the entire
1366+
# max_new_tokens budget is available for the
1367+
# structured <verdict>/<recommendation> output —
1368+
# diagnostic context is already in KV cache, no
1369+
# need to re-reason.
1370+
next_thinking = False
13521371
history.append({
13531372
"role": "user",
13541373
"content": FORCE_VERDICT_DIRECTIVE,

tests/test_rkllm_runtime.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,59 @@ async def collect():
626626
assert any(FORCE_VERDICT_DIRECTIVE in p for p in prompts_seen)
627627

628628

629+
def test_force_verdict_retry_runs_with_thinking_off():
630+
"""Tier 1 verdict-recovery fix: when the model produces prose-only
631+
output on turn 0 (no XML, no tool_call), the backend injects the
632+
force-verdict directive AND turns thinking off for the retry so the
633+
entire max_new_tokens budget lands on the structured <verdict> block
634+
instead of the model exhausting it inside another <think>…</think>.
635+
636+
Verified by recording the enable_thinking flag on each generate()
637+
call: turn 0 runs with thinking ON (backend-level enable_thinking
638+
flag), turn 1 (the force-verdict retry) runs with thinking OFF."""
639+
import asyncio
640+
from src.runtime.rkllm_runtime import RKLLMBackend, FORCE_VERDICT_DIRECTIVE
641+
642+
turn_outputs = [
643+
"I am rambling about your question.", # prose only -> force-verdict
644+
'<verdict>{"summary":"green","severity":"green","root_cause":"ok"}</verdict>',
645+
]
646+
turn_idx = {"i": 0}
647+
thinking_per_turn: list[bool] = []
648+
649+
class ThinkingCaptureRuntime:
650+
def generate(self, prompt, role="user", enable_thinking=False, keep_history=0, timeout_s=90.0):
651+
thinking_per_turn.append(enable_thinking)
652+
i = turn_idx["i"]
653+
turn_idx["i"] = i + 1
654+
return turn_outputs[i] if i < len(turn_outputs) else "(end)"
655+
656+
# Simulate a Qwen 3 backend where the default IS thinking-on so we
657+
# can prove the retry overrides it. Setting _enable_thinking=True
658+
# bypasses the filename check.
659+
backend = RKLLMBackend(
660+
loaded=True,
661+
_runtime=ThinkingCaptureRuntime(),
662+
_enable_thinking=True,
663+
)
664+
backend.wire_runtime_deps(tool_executor=None, action_signer=lambda x: "f" * 64)
665+
666+
async def collect():
667+
return [ev async for ev in backend.run_troubleshoot("x")]
668+
669+
events = asyncio.run(collect())
670+
# Both turns ran
671+
assert len(thinking_per_turn) == 2, thinking_per_turn
672+
# Turn 0 used backend's thinking default (True for Qwen 3)
673+
assert thinking_per_turn[0] is True, "turn 0 should use backend's thinking default"
674+
# Turn 1 (force-verdict retry) MUST run with thinking off so the
675+
# full token budget is available for the structured block
676+
assert thinking_per_turn[1] is False, "force-verdict retry must run thinking=False"
677+
# Sanity: real verdict came through, not the synthetic fallback
678+
verdicts = [e for e in events if e["type"] == "verdict"]
679+
assert verdicts[0]["payload"]["root_cause"] == "ok"
680+
681+
629682
# ---------------------------------------------------------------------------
630683
# Qwen 3 thinking-mode swap (2026-05-26)
631684
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)