From 0dbafd0a82b83c5b552ec2156d4df64a793b7bb7 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Wed, 29 Apr 2026 00:31:40 +0200 Subject: [PATCH] feat(navigation): implement Anti-Loop Guard to prevent Grand Tour deception Fixes an issue where E2E tests reported 100% success on isolated navigation actions, but the bot would get stuck in deterministic loops (HOME -> EXPLORE -> PROFILE -> HOME) during live autonomous execution. 1. Added `visited_screens` tracking to the primary GOAP step execution loop. 2. Updated GoalPlanner to preemptively strip available UI actions if the ScreenTopology dictates that taking the action would lead to a screen we have already visited in the current goal execution. 3. Explicitly permits 'press back' to preserve valid dead-end backtracking. --- = | 0 GramAddict/core/goap.py | 10 ++++++-- GramAddict/core/navigation/planner.py | 34 ++++++++++++++++++++------- 3 files changed, 34 insertions(+), 10 deletions(-) delete mode 100644 = diff --git a/= b/= deleted file mode 100644 index e69de29..0000000 diff --git a/GramAddict/core/goap.py b/GramAddict/core/goap.py index c9351d7..572565c 100644 --- a/GramAddict/core/goap.py +++ b/GramAddict/core/goap.py @@ -17,11 +17,11 @@ import logging import time from typing import Any, Dict, List -from GramAddict.core.utils import random_sleep from GramAddict.core.navigation.knowledge import NavigationKnowledge from GramAddict.core.navigation.path_memory import PathMemory from GramAddict.core.navigation.planner import GoalPlanner from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType +from GramAddict.core.utils import random_sleep logger = logging.getLogger(__name__) @@ -117,10 +117,12 @@ class GoalExecutor: consecutive_back_presses = 0 MAX_CONSECUTIVE_BACK = 3 explored_nav_actions = set() + visited_screens = set() for step_num in range(max_steps): # PERCEIVE screen = self.perceive() screen_type = screen["screen_type"] + visited_screens.add(screen_type) if last_screen_type and screen_type != last_screen_type: logger.debug( @@ -172,7 +174,11 @@ class GoalExecutor: # PLAN action = self.planner.plan_next_step( - goal, screen, explored_nav_actions=explored_nav_actions, action_failures=self.action_failures + goal, + screen, + explored_nav_actions=explored_nav_actions, + action_failures=self.action_failures, + visited_screens=visited_screens, ) if action is None: diff --git a/GramAddict/core/navigation/planner.py b/GramAddict/core/navigation/planner.py index 0b86cfb..3e9d7d0 100644 --- a/GramAddict/core/navigation/planner.py +++ b/GramAddict/core/navigation/planner.py @@ -18,7 +18,12 @@ class GoalPlanner: self.knowledge = NavigationKnowledge(username) def plan_next_step( - self, goal: str, screen: Dict[str, Any], explored_nav_actions: set = None, action_failures: dict = None + self, + goal: str, + screen: Dict[str, Any], + explored_nav_actions: set = None, + action_failures: dict = None, + visited_screens: set = None, ) -> Optional[str]: """Plans the NEXT single action to take toward the goal.""" screen_type = screen["screen_type"] @@ -37,7 +42,7 @@ class GoalPlanner: # ── 3. Am I on the right screen? If not, navigate there ── selected_tab = screen.get("selected_tab") nav_action = self._plan_navigation( - goal_lower, screen_type, available, selected_tab, explored_nav_actions, action_failures + goal_lower, screen_type, available, selected_tab, explored_nav_actions, action_failures, visited_screens ) if nav_action: return nav_action @@ -75,6 +80,7 @@ class GoalPlanner: selected_tab: Optional[str] = None, explored_nav_actions: set = None, action_failures: dict = None, + visited_screens: set = None, ) -> Optional[str]: """If we're on the wrong screen, figure out how to navigate. @@ -94,24 +100,34 @@ 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. + visited_screens = visited_screens or set() + + # 0b. No-Op Guard & Anti-Loop Guard: + # - Strip tab actions that navigate to the CURRENT screen. + # - Strip actions that navigate to PREVIOUSLY VISITED screens (except back-tracking). 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}") + elif expected in visited_screens and action != "press back": + noop_actions.add(action) + logger.debug(f"🛡️ [Anti-Loop Guard] Stripping '{action}' — leads to visited {expected.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}") + logger.debug( + f"🛡️ [No-Op Guard] Stripping '{action}' — known to navigate to current {screen_type.name}" + ) + elif dest in visited_screens and action in available and action != "press back": + noop_actions.add(action) + logger.debug(f"🛡️ [Anti-Loop Guard] Stripping '{action}' — known to navigate to visited {dest.name}") available = [a for a in available if a not in noop_actions] @@ -123,14 +139,16 @@ class GoalPlanner: avoid_actions.add(act) target_screen = ScreenTopology.goal_to_target_screen(goal) - + # ── 1. HD Map Pre-Check for Dead Ends ── # If the topological map KNOWS the target is unreachable due to action_failures, # we must preempt the Brain from blindly routing into a dead end. if target_screen and target_screen != screen_type: route = ScreenTopology.find_route(screen_type, target_screen, avoid_actions=avoid_actions) if route is None and ScreenTopology.find_route(screen_type, target_screen): - logger.warning(f"🛡️ [HD Map] Target {target_screen.name} is unreachable due to masked edges! Preventing Brain from blind routing.") + logger.warning( + f"🛡️ [HD Map] Target {target_screen.name} is unreachable due to masked edges! Preventing Brain from blind routing." + ) return None # ── 2. Brain-Driven Decision Making (Primary Strategy) ──