diff --git a/GramAddict/core/goap.py b/GramAddict/core/goap.py index 831c0a0..4bb2b64 100644 --- a/GramAddict/core/goap.py +++ b/GramAddict/core/goap.py @@ -234,6 +234,11 @@ class GoalExecutor: explored_nav_actions.clear() visited_screens.clear() consecutive_back_presses = 0 + # CRITICAL: Also clear the planner's learned traps. + # Without this, traps learned before restart persist and + # immediately re-trap the bot on the same (or similar) screen. + if hasattr(self, 'planner') and hasattr(self.planner, 'knowledge'): + self.planner.knowledge.clear_traps() continue # Check if it was a navigation action (vs a goal action). If we are not on the required screen, diff --git a/GramAddict/core/navigation/knowledge.py b/GramAddict/core/navigation/knowledge.py index 58097ad..fb60f1d 100644 --- a/GramAddict/core/navigation/knowledge.py +++ b/GramAddict/core/navigation/knowledge.py @@ -164,11 +164,28 @@ class NavigationKnowledge: pass return None + def clear_traps(self): + """Clear all in-memory traps. Called after force-restart to allow fresh routing.""" + count = len(self._learned_traps) + self._learned_traps.clear() + if count > 0: + logger.info(f"๐Ÿงน [NavigationKnowledge] Cleared {count} in-memory traps for fresh routing.") + def learn_trap(self, screen_type: ScreenType, action: str, trap_reason: str = "softlock"): """Aversively learn that an action on a screen is dangerous/useless.""" trap_key = f"{screen_type.name}_{action}" self._learned_traps.add(trap_key) + # GUARD: Never persist traps for UNKNOWN screens to Qdrant. + # UNKNOWN is a catch-all โ€” persisting traps here permanently blocks + # ALL unidentified screens, creating inescapable dead-ends. + if screen_type == ScreenType.UNKNOWN: + logger.warning( + f"๐Ÿ›ก๏ธ [Aversive Learning] Trap '{action}' on UNKNOWN kept in-memory only (not persisted). " + "UNKNOWN is a catch-all โ€” permanent traps here block all unidentified screens." + ) + return + if not self._db or not self._db.is_connected: return @@ -185,7 +202,11 @@ class NavigationKnowledge: logger.error(f"๐Ÿ’€ [Aversive Learning] BURNED action '{action}' on {screen_type.name} due to: {trap_reason}") def is_trap(self, screen_type: ScreenType, action: str) -> bool: - """Check if an action on this screen is a known trap.""" + """Check if an action on this screen is a known trap. + + Traps have time-based expiry: entries older than 30 minutes are + auto-forgiven and deleted from Qdrant to prevent permanent dead-ends. + """ from GramAddict.core.screen_topology import ScreenTopology if ScreenTopology.is_structural_action(screen_type, action): @@ -198,6 +219,8 @@ class NavigationKnowledge: if not self._db or not self._db.is_connected: return False + TRAP_EXPIRY_SECONDS = 1800 # 30 minutes: old traps expire + try: from qdrant_client.models import FieldCondition, Filter, MatchValue @@ -212,6 +235,20 @@ class NavigationKnowledge: limit=1, )[0] if results: + timestamp = results[0].payload.get("timestamp", 0) + age_seconds = time.time() - timestamp + + # Time-based expiry: old traps are forgiven + if age_seconds > TRAP_EXPIRY_SECONDS: + logger.info( + f"๐Ÿ”„ [Aversive Decay] Forgave expired trap '{action}' on {screen_type.name} " + f"(age: {age_seconds/60:.0f}min). Allowing re-exploration." + ) + # Delete the stale trap from Qdrant + seed = f"trap_{trap_key}" + self._db.delete_point(seed) + return False + self._learned_traps.add(trap_key) return True except Exception: diff --git a/tests/tdd/test_navigation_trap_recovery.py b/tests/tdd/test_navigation_trap_recovery.py new file mode 100644 index 0000000..1631dd8 --- /dev/null +++ b/tests/tdd/test_navigation_trap_recovery.py @@ -0,0 +1,62 @@ +""" +TDD: Navigation Trap Recovery & UNKNOWN Screen Handling + +1. After force-restart, all in-memory traps must be cleared to allow fresh routing. +2. Traps on UNKNOWN screens must NEVER persist โ€” UNKNOWN is a catch-all, + permanent traps here block ALL unidentified screens. +3. Trap system must have time-decay โ€” old traps expire after a threshold. +""" + +import pytest + + +class TestNavigationTrapRecovery: + """Trap system must not create permanent dead-ends.""" + + def test_app_restart_clears_in_memory_traps(self): + """ + META-TEST: When 'force start instagram' succeeds in GOAP, + the planner's learned traps MUST be cleared. + """ + import inspect + from GramAddict.core.goap import GoalExecutor + + source = inspect.getsource(GoalExecutor.achieve) + # Find the force start instagram block + assert "_learned_traps" in source or "clear_traps" in source or "wipe_traps" in source, ( + "GOAP.navigate_to must clear the planner's _learned_traps after a force restart. " + "Currently only action_failures and explored_nav_actions are cleared, " + "leaving traps active โ€” which causes immediate re-trapping on restart." + ) + + def test_unknown_screen_traps_never_persist_to_qdrant(self): + """ + META-TEST: Traps learned on UNKNOWN screens must NOT be persisted to Qdrant. + UNKNOWN is a catch-all โ€” persisting traps here blocks ALL unidentified screens forever. + """ + import inspect + from GramAddict.core.navigation.knowledge import NavigationKnowledge + + source = inspect.getsource(NavigationKnowledge.learn_trap) + source_lower = source.lower() + + # Must have a guard against persisting UNKNOWN traps + assert "unknown" in source_lower, ( + "learn_trap must guard against persisting traps on UNKNOWN screens. " + "UNKNOWN is a catch-all โ€” permanent traps here block ALL unidentified screens." + ) + + def test_trap_system_has_time_decay(self): + """ + META-TEST: is_trap must consider the age of the trap entry. + Old traps (persisted in Qdrant) must expire to prevent permanent dead-ends. + """ + import inspect + from GramAddict.core.navigation.knowledge import NavigationKnowledge + + source = inspect.getsource(NavigationKnowledge.is_trap) + # Must reference time-based expiry or timestamp comparison + assert "timestamp" in source or "time" in source or "age" in source or "expire" in source, ( + "is_trap must implement time-based expiry for Qdrant-persisted traps. " + "Permanent traps cause permanent dead-ends." + )