9 Commits

11 changed files with 318 additions and 13 deletions

View File

@@ -460,9 +460,13 @@ def start_bot(**kwargs):
target_map = {
"DiscoverNewContent": ["ExploreFeed", "ReelsFeed"],
"NurtureCommunity": ["HomeFeed", "StoriesFeed"],
"SocialReciprocity": ["FollowingList", "MessageInbox"],
"SocialReciprocity": ["FollowingList"],
}
dm_config = configs.get_plugin_config("dm_reply")
if dm_config.get("enabled", False):
target_map["SocialReciprocity"].append("MessageInbox")
import secrets
options = target_map.get(current_desire, ["HomeFeed"])
@@ -860,7 +864,11 @@ def _run_zero_latency_feed_loop(
elif governance_decision == "CHECK_CURIOSITY":
logger.info("👀 [Curiosity] Spontaneously checking DMs / Notifications...")
explore_target = random.choice(["MessageInbox", "Notifications"])
dm_config = configs.get_plugin_config("dm_reply")
if dm_config.get("enabled", False):
explore_target = random.choice(["MessageInbox", "Notifications"])
else:
explore_target = "Notifications"
if explore_target == "MessageInbox":
nav_graph.do("tap direct message icon inbox")

View File

@@ -18,15 +18,13 @@ import time
from typing import Any, Dict, List
from GramAddict.core.utils import random_sleep
logger = logging.getLogger(__name__)
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
logger = logging.getLogger(__name__)
# Re-export for backward compatibility (optional but helps minimize import breakage)
__all__ = ["GoalExecutor", "ScreenIdentity", "ScreenType", "PathMemory", "NavigationKnowledge", "GoalPlanner"]
@@ -173,7 +171,9 @@ class GoalExecutor:
continue
# PLAN
action = self.planner.plan_next_step(goal, screen, explored_nav_actions=explored_nav_actions, action_failures=self.action_failures)
action = self.planner.plan_next_step(
goal, screen, explored_nav_actions=explored_nav_actions, action_failures=self.action_failures
)
if action is None:
# Goal achieved!
@@ -199,6 +199,21 @@ class GoalExecutor:
# Reset failures for this action since it eventually succeeded
self.action_failures[action] = 0
if "scroll" in action.lower():
logger.debug(
"📍 [GOAP State] Scrolled successfully. Clearing explored actions to allow retrying off-screen elements."
)
explored_nav_actions.clear()
# Keep action_failures for synthetic intents, but clear them for structural actions
# so that the HD Map can retry route actions that might now be visible!
from GramAddict.core.screen_topology import ScreenTopology
keys_to_clear = [
k for k in self.action_failures.keys() if ScreenTopology.is_structural_action(screen_type, k)
]
for k in keys_to_clear:
del self.action_failures[k]
# ── Back-Press Circuit Breaker ──
if action == "press back":
consecutive_back_presses += 1

View File

@@ -0,0 +1,56 @@
import logging
from typing import List, Optional
from GramAddict.core.config import Config
from GramAddict.core.llm_provider import query_llm
logger = logging.getLogger(__name__)
def ask_brain_for_action(
goal: str, screen_type: str, available_actions: List[str], explored_actions: set, context: dict = None
) -> Optional[str]:
"""Asks the VLM to decide the best available action to reach the goal, considering failures."""
if not available_actions:
return None
cfg = Config()
url = getattr(cfg.args, "ai_model_url", "http://localhost:11434/api/generate") if hasattr(cfg, "args") else "http://localhost:11434/api/generate"
model = getattr(cfg.args, "ai_model", "qwen3.5:latest") if hasattr(cfg, "args") else "qwen3.5:latest"
prompt = (
f"You are an autonomous Instagram agent. Your ultimate goal is: '{goal}'.\n"
f"You are currently on the screen: {screen_type}.\n"
f"These actions are available to you right now: {available_actions}\n"
)
if explored_actions:
prompt += f"You recently tried these actions but they failed or didn't help: {list(explored_actions)}\n"
if context:
prompt += f"Context: {context}\n"
prompt += (
"CRITICAL INSTRUCTIONS:\n"
"1. If your goal requires an element (like 'following list') that you previously tried but failed to find, it is highly likely hidden off-screen.\n"
"2. If you haven't scrolled yet, you MUST choose to 'scroll down' or 'scroll up' to reveal more of the screen.\n"
"3. Only choose 'press back' or 'tap home tab' if you are completely trapped or in the wrong section entirely.\n"
"4. DO NOT hallucinate actions. Reply ONLY with the exact string from the available actions list."
)
try:
response = query_llm(
url=url, model=model, prompt="Choose the next best action.", system=prompt, format_json=False
)
if response:
result = response if isinstance(response, str) else response.get("response", "")
result = result.strip().strip("'\"")
# Fuzzy match to available actions just in case
for act in available_actions:
if act.lower() in result.lower():
return act
logger.warning(f"🧠 [Brain] LLM returned an invalid action: '{result}'. Falling back.")
except Exception as e:
logger.debug(f"🧠 [Brain] Error querying LLM: {e}")
return None

View File

@@ -17,7 +17,9 @@ class GoalPlanner:
def __init__(self, username: str):
self.knowledge = NavigationKnowledge(username)
def plan_next_step(self, goal: str, screen: Dict[str, Any], explored_nav_actions: set = None, action_failures: dict = None) -> Optional[str]:
def plan_next_step(
self, goal: str, screen: Dict[str, Any], explored_nav_actions: set = None, action_failures: dict = None
) -> Optional[str]:
"""Plans the NEXT single action to take toward the goal."""
screen_type = screen["screen_type"]
available = screen.get("available_actions", [])
@@ -34,7 +36,9 @@ 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)
nav_action = self._plan_navigation(
goal_lower, screen_type, available, selected_tab, explored_nav_actions, action_failures
)
if nav_action:
return nav_action
@@ -89,7 +93,7 @@ class GoalPlanner:
else:
logger.debug(f"🛡️ [Aversive Filter] Masking trapped action: '{action}'")
available = safe_available
# Build avoid_actions for HD Map route planning
avoid_actions = (explored_nav_actions or set()).copy()
if action_failures:
@@ -97,7 +101,17 @@ class GoalPlanner:
if count >= 2: # MAX_RETRIES is 2 in goap
avoid_actions.add(act)
# ── 1. HD Map Routing (Primary Strategy) ──
# ── 1. Brain-Driven Decision Making (Primary Strategy) ──
# The user explicitly wants the AI to be the primary driver of goals.
from GramAddict.core.navigation.brain import ask_brain_for_action
brain_action = ask_brain_for_action(goal, screen_type.name, available, explored_nav_actions)
if brain_action:
logger.info(f"🧠 [Brain] Decided dynamically to execute: '{brain_action}'")
return brain_action
# ── 2. HD Map Routing (Fallback) ──
# If the Brain doesn't know what to do, try the deterministic topological map.
target_screen = ScreenTopology.goal_to_target_screen(goal)
if target_screen and target_screen != screen_type:
route = ScreenTopology.find_route(screen_type, target_screen, avoid_actions=avoid_actions)
@@ -112,9 +126,11 @@ class GoalPlanner:
)
return next_action
else:
logger.warning(f"🛡️ [HD Map] Route action '{next_action}' is trapped. Falling back.")
logger.warning(f"🛡️ [HD Map] Route action '{next_action}' is trapped. Skipping HD Map.")
else:
logger.debug(f"🛡️ [HD Map] Route action '{next_action}' already explored. Falling back.")
logger.debug(
f"🛡️ [HD Map] Route action '{next_action}' already explored and failed. Skipping HD Map."
)
# ── 2. Learned Knowledge (Qdrant) ──
required_screens = self.knowledge.get_requirements(goal)

View File

@@ -72,6 +72,16 @@ class IntentResolver:
if intent_lower in abstract_goals:
return None
# --- Strict VLM Hallucination Guard ---
# For known structural targets that the VLM frequently hallucinates when they are missing,
# we enforce a strict failure if they weren't caught by the structural fast paths.
if "following list" in intent_lower or "followers list" in intent_lower or "tap message button" in intent_lower:
logger.warning(
f"🛡️ [Hallucination Guard] Intent '{intent_description}' is a strict structural target. "
"Since it wasn't resolved by fast-paths, it is missing. Rejecting VLM fallback."
)
return None
# ── PRIMARY PATH: Visual Discovery ──
# If we have a device, the VLM SEES the screen and decides.
if device:

View File

@@ -155,6 +155,26 @@ class TelepathicEngine:
grid_items.sort(key=lambda n: (n.get("y", 9999), n.get("x", 9999)))
return grid_items[0]
# --- Profile Structural Fast Paths ---
if "following list" in intent_lower or "followers list" in intent_lower:
target_id = "profile_header_following" if "following" in intent_lower else "profile_header_followers"
for n in nodes:
res_id = n.get("id", "") or n.get("resource_id", "")
if target_id in res_id:
return n
# Fallback to text matching if ID not found
for n in nodes:
sem = (n.get("semantic_string", "") or "").lower()
desc = (n.get("description", "") or "").lower()
text = (n.get("text", "") or "").lower()
if "following" in intent_lower:
if "following" in sem or "abonniert" in sem or "following" in desc or "following" in text:
return n
else:
if "followers" in sem or "abonnenten" in sem or "followers" in desc or "followers" in text:
return n
# --- DM Engine Structural Fast Paths ---
if "find the message input text field" in intent_lower:
for n in nodes:

View File

@@ -89,6 +89,11 @@ telegram-reports: false # for using telegram-reports you have also to configure
interactions-count: 30-40
likes-count: 1-2
likes-percentage: 100
plugins:
dm_reply:
enabled: false # Generates AI replies to unread DMs
stories-count: 1-2
stories-percentage: 30-40
carousel-count: 2-3

View File

@@ -149,3 +149,48 @@ def test_no_hallucination_missing_button():
assert (
result is None
), f"VLM hallucinated an element! It picked id='{result.resource_id}', desc='{result.content_desc}'"
@pytest.mark.live_llm
def test_vlm_must_not_hallucinate_profile_targets():
"""
BENCHMARK: Ensures the TelepathicEngine does NOT hallucinate "following list"
when the element is missing or when the VLM tries to guess (e.g., picking "Grid view").
"""
from GramAddict.core.telepathic_engine import TelepathicEngine
# Use a dump that does NOT have a clear following button (e.g., home feed)
xml_path = "tests/fixtures/home_feed_with_ad.xml"
jpg_path = "tests/fixtures/home_feed_with_ad.jpg"
with open(xml_path, "r", encoding="utf-8") as f:
xml = f.read()
# We make a mock device
def _make_device_with_real_image(img_path):
from PIL import Image
img = Image.open(img_path)
class DummyDeviceV2:
def __init__(self, img):
self.img = img
def screenshot(self):
return self.img
class DummyDevice:
def __init__(self, img):
self.deviceV2 = DummyDeviceV2(img)
return DummyDevice(img)
device = _make_device_with_real_image(jpg_path)
engine = TelepathicEngine.get_instance()
# Try to resolve 'tap following list' on a screen where it doesn't exist
result = engine.find_best_node(xml, "tap following list", device=device, track=False)
assert (
result is None or result.get("skip") is True
), f"CRITICAL HALLUCINATION: Engine returned an element instead of None! Result: {result}"

View File

@@ -0,0 +1,34 @@
import pytest
from GramAddict.core.navigation.brain import ask_brain_for_action
from GramAddict.core.perception.screen_identity import ScreenType
import logging
logger = logging.getLogger(__name__)
@pytest.mark.live_llm
def test_brain_recommends_scroll_when_trapped():
"""
Test that the real, live LLM Brain correctly deduces that it should
scroll down when the target element is missing and it's trapped.
"""
goal = "open following list"
screen = "OWN_PROFILE"
available_actions = ["tap profile tab", "tap share button", "press back", "tap reels tab", "tap messages tab", "scroll down", "scroll up"]
explored_nav_actions = {"tap following list"}
# We query the actual LLM as configured in the environment (e.g. qwen3.5:latest)
# This prevents regressions where the LLM is misconfigured or returns empty strings.
brain_action = ask_brain_for_action(
goal=goal,
screen_type=screen,
available_actions=available_actions,
explored_actions=explored_nav_actions
)
logger.info(f"Brain action returned: '{brain_action}'")
assert brain_action is not None, "Brain LLM returned None. Is the URL/Model configured correctly?"
assert brain_action != "", "Brain LLM returned an empty string."
# The brain should reasonably choose 'scroll down' to find the missing following list
assert brain_action == "scroll down", f"Expected Brain to choose 'scroll down', but got '{brain_action}'"

View File

@@ -0,0 +1,33 @@
from unittest.mock import patch
from GramAddict.core.navigation.planner import GoalPlanner
from GramAddict.core.perception.screen_identity import ScreenType
def test_planner_falls_back_to_brain_when_hd_map_fails():
"""
Test that if HD Map routing fails because the structural target is not visible
(and thus in explored_nav_actions), the planner falls back to the Brain
to dynamically pick an action like 'scroll down'.
"""
planner = GoalPlanner("testuser")
# Simulate a screen where the target isn't visible
screen = {
"screen_type": ScreenType.OWN_PROFILE,
"available_actions": ["press back", "scroll down", "tap profile tab"],
"context": {},
}
# Simulate that 'tap following list' failed previously and is in explored actions
explored = {"tap following list"}
# The brain should realize that 'scroll down' is the best way to uncover the target
with patch("GramAddict.core.navigation.brain.ask_brain_for_action", return_value="scroll down") as mock_brain:
action = planner.plan_next_step("go to followers/following list", screen, explored_nav_actions=explored)
# Verify the brain was queried
mock_brain.assert_called_once()
# Verify the brain's decision is respected
assert action == "scroll down"

View File

@@ -0,0 +1,63 @@
import pytest
from unittest.mock import patch
from GramAddict.core.navigation.planner import GoalPlanner
from GramAddict.core.perception.screen_identity import ScreenType
@pytest.fixture
def planner():
return GoalPlanner("test_user")
@patch("GramAddict.core.navigation.brain.ask_brain_for_action")
@patch("GramAddict.core.screen_topology.ScreenTopology.find_route")
def test_brain_is_primary_strategy(mock_find_route, mock_ask_brain, planner):
"""
TDD Proof: Brain must be evaluated BEFORE HD Map.
If Brain returns a valid action, HD Map should never be queried.
"""
# 1. Setup State
goal = "open some screen"
screen = {
"screen_type": ScreenType.HOME_FEED,
"available_actions": ["action A", "action B"],
"context": {}
}
# 2. Setup Mocks
mock_ask_brain.return_value = "action A" # Brain picks A
mock_find_route.return_value = [("action B", ScreenType.EXPLORE_GRID)] # HD Map would pick B
# 3. Execute Planner
action = planner.plan_next_step(goal, screen)
# 4. Assertions
assert action == "action A", "Planner did not use the Brain's action!"
mock_ask_brain.assert_called_once()
mock_find_route.assert_not_called() # Crucial: HD Map must be skipped entirely!
@patch("GramAddict.core.navigation.brain.ask_brain_for_action")
@patch("GramAddict.core.screen_topology.ScreenTopology.find_route")
@patch("GramAddict.core.screen_topology.ScreenTopology.goal_to_target_screen")
def test_brain_fallback_to_hd_map(mock_goal_target, mock_find_route, mock_ask_brain, planner):
"""
TDD Proof: If Brain fails (returns None), Planner must fallback to HD Map.
"""
# 1. Setup State
goal = "open explore screen"
screen = {
"screen_type": ScreenType.HOME_FEED,
"available_actions": ["action A", "action B"],
"context": {}
}
# 2. Setup Mocks
mock_ask_brain.return_value = None # Brain fails or is confused
mock_goal_target.return_value = ScreenType.EXPLORE_GRID
mock_find_route.return_value = [("action B", ScreenType.EXPLORE_GRID)] # HD Map picks B
# 3. Execute Planner
action = planner.plan_next_step(goal, screen)
# 4. Assertions
assert action == "action B", "Planner did not fallback to HD Map when Brain failed!"
mock_ask_brain.assert_called_once()
mock_find_route.assert_called_once()