feat(orchestrator): wire GoalDecomposer into bot_flow.py
Replace the old dual-path orchestrator (abstract goals vs legacy desires)
with unified GoalDecomposer-driven task routing:
1. GoalDecomposer reads mission.strategy + plugins config
2. Generates weighted Task objects (verb, target_screen, budget)
3. GrowthBrain.select_task() picks one probabilistically
4. Selected Task's target_screen routes through existing nav_graph
5. Feed loops + PluginRegistry handle the actual interactions
The abstract goals path (goal_executor.achieve('Nurture community'))
that caused infinite scrolling is now eliminated entirely.
Legacy desire fallback preserved for configs without plugins.
22/22 tests passing.
This commit is contained in:
@@ -445,56 +445,68 @@ def start_bot(**kwargs):
|
||||
has_scanned_own_profile = True
|
||||
|
||||
while not dopamine.is_app_session_over():
|
||||
# 1. Ask the Growth Brain for a Strategic Objective
|
||||
success_rates = getattr(session_state, "successfulInteractions", {})
|
||||
current_goal = growth_brain.get_current_goal(
|
||||
dopamine, getattr(configs.args, "goals", []), success_rates=success_rates
|
||||
# ── 1. Generate available tasks from mission + plugins ──
|
||||
from GramAddict.core.goal_decomposer import GoalDecomposer
|
||||
|
||||
decomposer = GoalDecomposer(
|
||||
plugins=configs.config.get("plugins", {}) if configs.config else {},
|
||||
actions={
|
||||
k: getattr(configs.args, k, None)
|
||||
for k in ("feed", "explore", "reels")
|
||||
if getattr(configs.args, k, None)
|
||||
},
|
||||
mission=configs.config.get("mission", {}) if configs.config else {},
|
||||
)
|
||||
available_tasks = decomposer.generate_tasks()
|
||||
|
||||
if current_goal == "ShiftContext":
|
||||
logger.info("🧠 [Free Will] Boredom critical. Forcing app restart to clear context.")
|
||||
device.app_stop(device.app_id)
|
||||
random_sleep(2.0, 4.0)
|
||||
device.app_start(device.app_id, use_monkey=True)
|
||||
random_sleep(4.0, 6.0)
|
||||
dopamine.boredom = max(0.0, dopamine.boredom * 0.2)
|
||||
continue
|
||||
if not available_tasks:
|
||||
# No plugins enabled = nothing to do. Fall back to legacy desire system.
|
||||
current_desire = growth_brain.get_current_desire(dopamine)
|
||||
if current_desire == "ShiftContext":
|
||||
logger.info("🧠 [Free Will] Boredom critical. Forcing app restart.")
|
||||
device.app_stop(device.app_id)
|
||||
random_sleep(2.0, 4.0)
|
||||
device.app_start(device.app_id, use_monkey=True)
|
||||
random_sleep(4.0, 6.0)
|
||||
dopamine.boredom = max(0.0, dopamine.boredom * 0.2)
|
||||
continue
|
||||
|
||||
# 2. Execution: GOAP Plan & Execute (Autonomous Mode)
|
||||
if getattr(configs.args, "goals", None):
|
||||
logger.info(f"🤖 Autonomous Mode Active. Delegating to GoalExecutor for: {current_goal}")
|
||||
# Legacy desire → target mapping (kept for backward compatibility)
|
||||
target_map = {
|
||||
"DiscoverNewContent": ["ExploreFeed", "ReelsFeed"],
|
||||
"NurtureCommunity": ["HomeFeed", "StoriesFeed"],
|
||||
"SocialReciprocity": ["FollowingList"],
|
||||
}
|
||||
|
||||
goal_executor = GoalExecutor(device=device, bot_username=getattr(configs.args, "username", ""))
|
||||
dm_config = configs.get_plugin_config("dm_reply")
|
||||
if dm_config.get("enabled", False):
|
||||
target_map["SocialReciprocity"].append("MessageInbox")
|
||||
|
||||
result = goal_executor.achieve(current_goal)
|
||||
import secrets
|
||||
|
||||
if result:
|
||||
logger.info("✅ Goal achieved autonomously!")
|
||||
else:
|
||||
logger.warning(f"⚠️ Goal execution failed for: {current_goal}")
|
||||
options = target_map.get(current_desire, ["HomeFeed"])
|
||||
current_target = secrets.choice(options)
|
||||
else:
|
||||
# ── 2. Select a concrete Task ──
|
||||
selected_task = growth_brain.select_task(dopamine, available_tasks)
|
||||
|
||||
continue # The GoalExecutor handles navigation internally
|
||||
if selected_task is None:
|
||||
# ShiftContext signal from high boredom
|
||||
logger.info("🧠 [Free Will] Boredom critical. Forcing app restart to clear context.")
|
||||
device.app_stop(device.app_id)
|
||||
random_sleep(2.0, 4.0)
|
||||
device.app_start(device.app_id, use_monkey=True)
|
||||
random_sleep(4.0, 6.0)
|
||||
dopamine.boredom = max(0.0, dopamine.boredom * 0.2)
|
||||
continue
|
||||
|
||||
# --- LEGACY PROCEDURAL FALLBACK (For config without goals) ---
|
||||
current_desire = current_goal
|
||||
current_target = selected_task.target_screen
|
||||
logger.info(
|
||||
f"🎯 [GoalDecomposer] Task: {selected_task.intent} "
|
||||
f"→ {current_target} (budget={selected_task.budget_posts})"
|
||||
)
|
||||
|
||||
# 2. Map Desire to Sub-Feed
|
||||
target_map = {
|
||||
"DiscoverNewContent": ["ExploreFeed", "ReelsFeed"],
|
||||
"NurtureCommunity": ["HomeFeed", "StoriesFeed"],
|
||||
"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"])
|
||||
current_target = secrets.choice(options)
|
||||
|
||||
logger.info(f"🧠 [Agent Orchestrator] Desire '{current_desire}' -> Routed to {current_target}")
|
||||
logger.info(f"🧠 [Agent Orchestrator] Routed to {current_target}")
|
||||
|
||||
logger.info(f"⚡ Navigating to {current_target}")
|
||||
success = nav_graph.navigate_to(current_target, zero_engine)
|
||||
|
||||
@@ -317,3 +317,86 @@ class TestGrowthBrainTaskSelection:
|
||||
|
||||
# With aggressive_growth, ExploreFeed (weight 0.45) should dominate
|
||||
assert len(counts) > 1, "Selection must pick from multiple screens"
|
||||
|
||||
|
||||
class TestOrchestratorTaskRouting:
|
||||
"""Tests that every Task produced by GoalDecomposer is routable by ScreenTopology."""
|
||||
|
||||
def test_task_to_screen_topology_mapping(self):
|
||||
"""Each Task.target_screen MUST exist in ScreenTopology.SCREEN_NAME_MAP."""
|
||||
from GramAddict.core.goal_decomposer import GoalDecomposer
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
plugins = {
|
||||
"likes": {"percentage": 100},
|
||||
"follow": {"percentage": 100},
|
||||
"comment": {"percentage": 40},
|
||||
"story_view": {"percentage": 80},
|
||||
"dm_reply": {"enabled": True},
|
||||
}
|
||||
actions = {"feed": "5-10", "explore": "5-10", "reels": "3-5"}
|
||||
mission = {"strategy": "aggressive_growth"}
|
||||
|
||||
decomposer = GoalDecomposer(plugins=plugins, actions=actions, mission=mission)
|
||||
tasks = decomposer.generate_tasks()
|
||||
|
||||
assert len(tasks) > 0
|
||||
|
||||
for task in tasks:
|
||||
assert task.target_screen in ScreenTopology.SCREEN_NAME_MAP, (
|
||||
f"Task '{task.verb}' → '{task.target_screen}' has no ScreenTopology mapping!"
|
||||
)
|
||||
|
||||
def test_all_task_screens_reachable_from_home(self):
|
||||
"""Every Task target must be reachable via BFS from HOME_FEED."""
|
||||
from GramAddict.core.goal_decomposer import GoalDecomposer
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
from GramAddict.core.perception.screen_identity import ScreenType
|
||||
|
||||
plugins = {
|
||||
"likes": {"percentage": 100},
|
||||
"story_view": {"percentage": 80},
|
||||
"dm_reply": {"enabled": True},
|
||||
}
|
||||
actions = {"feed": "5-10", "explore": "5-10"}
|
||||
mission = {"strategy": "community_builder"}
|
||||
|
||||
decomposer = GoalDecomposer(plugins=plugins, actions=actions, mission=mission)
|
||||
tasks = decomposer.generate_tasks()
|
||||
|
||||
for task in tasks:
|
||||
target_type = ScreenTopology.SCREEN_NAME_MAP.get(task.target_screen)
|
||||
assert target_type is not None
|
||||
|
||||
if target_type == ScreenType.HOME_FEED:
|
||||
continue # Already there
|
||||
|
||||
route = ScreenTopology.find_route(ScreenType.HOME_FEED, target_type)
|
||||
assert route is not None, (
|
||||
f"No HD Map route from HOME_FEED to {task.target_screen}! "
|
||||
f"The bot cannot navigate there."
|
||||
)
|
||||
|
||||
def test_task_target_screen_to_goal_string_conversion(self):
|
||||
"""Every task target_screen must convert to a valid GOAP goal string."""
|
||||
from GramAddict.core.goal_decomposer import GoalDecomposer
|
||||
from GramAddict.core.screen_topology import ScreenTopology
|
||||
|
||||
plugins = {"likes": {"percentage": 100}, "dm_reply": {"enabled": True}}
|
||||
actions = {"feed": "5-10", "explore": "5-10"}
|
||||
mission = {"strategy": "aggressive_growth"}
|
||||
|
||||
decomposer = GoalDecomposer(plugins=plugins, actions=actions, mission=mission)
|
||||
tasks = decomposer.generate_tasks()
|
||||
|
||||
for task in tasks:
|
||||
goal_str = ScreenTopology.screen_name_to_goal(task.target_screen)
|
||||
assert goal_str, (
|
||||
f"Task '{task.target_screen}' cannot be converted to GOAP goal!"
|
||||
)
|
||||
# The goal string should resolve back to a target screen
|
||||
target = ScreenTopology.goal_to_target_screen(goal_str)
|
||||
assert target is not None, (
|
||||
f"Goal string '{goal_str}' doesn't resolve to any ScreenType!"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
def test_bot_flow_prioritizes_goals_over_desires():
|
||||
def test_bot_flow_uses_goal_decomposer_not_abstract_goals():
|
||||
"""
|
||||
Test that when goals are present in config, the bot uses GoalExecutor
|
||||
instead of the legacy desire mapping.
|
||||
This should fail (RED) before we refactor bot_flow.py.
|
||||
Test that bot_flow.py uses GoalDecomposer for task-based navigation
|
||||
instead of the abstract goals string system.
|
||||
|
||||
The old system passed abstract strings like "Nurture my community"
|
||||
to GoalExecutor.achieve() — which caused endless scrolling because
|
||||
the LLM had no semantic bridge to concrete plugin actions.
|
||||
|
||||
The new system:
|
||||
1. GoalDecomposer reads mission + plugins → generates concrete Tasks
|
||||
2. GrowthBrain.select_task() picks a Task with weighted random
|
||||
3. The Task's target_screen routes through nav_graph to feed loops
|
||||
"""
|
||||
|
||||
# We won't run the whole start_bot (it's massive),
|
||||
# we'll just test the core orchestrator loop extraction if we can,
|
||||
# or we can test the behavior by mocking the device and config.
|
||||
|
||||
# Actually, a better way is to test that the goal string is passed to achieve.
|
||||
# Since we can't easily mock the massive `start_bot`, we will test the
|
||||
# conceptual behavior by just ensuring the code in bot_flow contains
|
||||
# GoalExecutor.achieve logic.
|
||||
|
||||
# Let's import the file and check for GoalExecutor usage
|
||||
with open("GramAddict/core/bot_flow.py", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
# This assertion will fail (RED) because GoalExecutor is not in the original bot_flow.py
|
||||
assert "GoalExecutor" in content, "bot_flow.py does not use GoalExecutor for autonomous goals"
|
||||
assert "goal_executor.achieve(current_goal)" in content, "bot_flow.py does not execute goals autonomously"
|
||||
# New system MUST be present
|
||||
assert "GoalDecomposer" in content, "bot_flow.py must use GoalDecomposer"
|
||||
assert "select_task" in content, "bot_flow.py must use GrowthBrain.select_task()"
|
||||
assert "available_tasks" in content, "bot_flow.py must generate available_tasks"
|
||||
|
||||
# Old abstract goals path MUST be gone
|
||||
assert "goal_executor.achieve(current_goal)" not in content, (
|
||||
"bot_flow.py still uses old abstract goal_executor.achieve()! "
|
||||
"This causes endless scrolling."
|
||||
)
|
||||
assert 'getattr(configs.args, "goals"' not in content, (
|
||||
"bot_flow.py still reads abstract goals from config!"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user