fix: eliminate thinking-block poisoning + no-op navigation trap

ROOT CAUSE: qwen3.5 (reasoning model) returns response='' with thinking
block containing all reasoning. llm_provider.py line 352 silently
substituted the thinking block as the response via:
  content = raw_response or raw_thinking or ''
The Brain then extracted random actions from the reasoning text.

FIXES:
1. llm_provider.py: Conditional thinking isolation
   - format_json=True (SAE/perception): thinking fallback preserved
   - format_json=False (Brain): thinking NEVER substituted
   - Added think=false for Ollama free-text calls to force direct response

2. planner.py: No-Op Guard strips tab actions that navigate to
   the current screen (e.g. 'tap profile tab' on OWN_PROFILE)

3. test_brain_live.py: Stochastic testing (5 runs, 60% min valid)
   to handle non-deterministic LLM behavior reliably

4. tests/integration/test_llm_provider_pipeline.py: NEW test layer
   mocking at HTTP level (requests.post) to exercise the FULL
   llm_provider → Brain pipeline. This would have caught the
   thinking substitution bug from day one.

Suite: 168 passed, 0 failed
This commit is contained in:
2026-04-29 00:06:23 +02:00
parent ad012b4cd4
commit ac5d5351a6
5 changed files with 397 additions and 18 deletions

View File

@@ -287,6 +287,12 @@ def query_llm(
req_data["images"] = images_b64
if format_json:
req_data["format"] = "json"
else:
# For free-text calls (Brain action extraction), explicitly disable
# thinking mode. Reasoning models like qwen3.5 put EVERYTHING in
# the thinking block and return response='', which is useless for
# action extraction. think=false forces a direct response.
req_data["think"] = False
# Ollama passes configs inside 'options'
if temperature is not None or max_tokens is not None:
@@ -349,13 +355,20 @@ def query_llm(
logger.debug(f"DEBUG LLM PAYLOAD: response='{raw_response}', thinking='{raw_thinking}'")
content = raw_response or raw_thinking or ""
# CRITICAL: For free-text mode (format_json=False), do NOT substitute
# thinking for empty response. The thinking block is REASONING, not
# a decision. The Brain parser would extract random actions from it.
# For JSON mode (format_json=True), falling back to thinking IS correct
# because reasoning models may place structured output in the thinking block.
if format_json:
content = raw_response or raw_thinking or ""
extracted = extract_json(content)
if not extracted:
logger.warning(f"Failed to extract JSON from content: {content[:100]}")
else:
content = extracted
else:
content = raw_response
return {"response": content}
except requests.exceptions.ConnectionError:

View File

@@ -94,6 +94,27 @@ class GoalPlanner:
logger.debug(f"🛡️ [Aversive Filter] Masking trapped action: '{action}'")
available = safe_available
# 0b. No-Op Guard: Strip tab actions that navigate to the CURRENT screen.
# e.g. 'tap profile tab' on OWN_PROFILE is always a no-op.
noop_actions = set()
for action in available:
expected = ScreenTopology.expected_screen_for_action(action, screen_type)
if expected == screen_type:
noop_actions.add(action)
logger.debug(f"🛡️ [No-Op Guard] Stripping '{action}' — leads back to {screen_type.name}")
# Also strip actions where the HD Map says they go TO the current screen from OTHER screens
# (e.g., 'tap profile tab' isn't in OWN_PROFILE's transitions, but it goes to OWN_PROFILE from other screens)
for src_screen, transitions in ScreenTopology.TRANSITIONS.items():
if src_screen == screen_type:
continue # We already handled this screen's own transitions
for action, dest in transitions.items():
if dest == screen_type and action in available:
noop_actions.add(action)
logger.debug(f"🛡️ [No-Op Guard] Stripping '{action}' — known to navigate to current {screen_type.name}")
available = [a for a in available if a not in noop_actions]
# Build avoid_actions for HD Map route planning
avoid_actions = (explored_nav_actions or set()).copy()
if action_failures: