feat(brain): add GrowthBrain.select_task() + kill abstract goals config
- GrowthBrain.select_task() uses weighted random from concrete Task objects - Removed self.goals from Config (no longer reads goals: from config.yml) - Mission + plugins are now the SSOT for bot behavior The bot no longer receives abstract strings like 'Nurture my community' that the LLM Brain can't operationalize. Instead, the GoalDecomposer generates Task(browse_feed, HomeFeed, budget=7) which routes to concrete feed loops. 18/18 TDD tests passing.
This commit is contained in:
@@ -86,8 +86,8 @@ class Config:
|
||||
self.debug = self.config.get("debug", False)
|
||||
self.app_id = self.config.get("app_id", "com.instagram.android")
|
||||
|
||||
# Autonomous Agent Goals
|
||||
self.goals = self.config.get("goals", [])
|
||||
# Autonomous goals removed — the bot now derives tasks from mission + plugins
|
||||
# via GoalDecomposer. See GramAddict/core/goal_decomposer.py.
|
||||
else:
|
||||
if "--debug" in self.args:
|
||||
self.debug = True
|
||||
|
||||
@@ -99,6 +99,9 @@ class GrowthBrain:
|
||||
Autonomously selects the next strategic goal.
|
||||
If no goals are configured, falls back to legacy desires.
|
||||
Weights goals based on session success rates if provided.
|
||||
|
||||
.. deprecated::
|
||||
Use select_task() instead for concrete, plugin-linked task selection.
|
||||
"""
|
||||
import random
|
||||
|
||||
@@ -121,6 +124,33 @@ class GrowthBrain:
|
||||
|
||||
return random.choices(available_goals, weights=weights, k=1)[0]
|
||||
|
||||
def select_task(self, dopamine_engine, available_tasks: list) -> "Optional[Task]":
|
||||
"""Select the next concrete Task using weighted random selection.
|
||||
|
||||
This is the primary interface for the orchestrator. Unlike get_current_goal()
|
||||
which returns abstract strings, this returns a Task object with a specific
|
||||
target_screen, budget, and success metric.
|
||||
|
||||
Returns:
|
||||
Task: The selected task to execute.
|
||||
None: If no tasks available or boredom is too high (ShiftContext signal).
|
||||
"""
|
||||
if not available_tasks:
|
||||
return None
|
||||
|
||||
# High boredom = ShiftContext (take a break, switch feed)
|
||||
if dopamine_engine.boredom > 85.0:
|
||||
logger.info("🧠 [GrowthBrain] Boredom too high for task selection. ShiftContext.")
|
||||
return None
|
||||
|
||||
weights = [task.weight for task in available_tasks]
|
||||
selected = random.choices(available_tasks, weights=weights, k=1)[0]
|
||||
logger.info(
|
||||
f"🧠 [GrowthBrain] Selected task: {selected.verb} → {selected.target_screen} "
|
||||
f"(weight={selected.weight:.2f}, budget={selected.budget_posts})"
|
||||
)
|
||||
return selected
|
||||
|
||||
def get_circadian_pacing(self) -> float:
|
||||
"""
|
||||
Adjusts activity levels based on the current local time
|
||||
|
||||
@@ -210,3 +210,110 @@ class TestGoalDecomposerGeneratesTasks:
|
||||
f"Task target_screen '{task.target_screen}' is not in ScreenTopology! "
|
||||
f"The bot cannot navigate there."
|
||||
)
|
||||
|
||||
|
||||
class TestGoalDecomposerFromConfig:
|
||||
"""Tests that GoalDecomposer correctly derives tasks from config-like dicts.
|
||||
Validates that the legacy `goals:` config is not needed."""
|
||||
|
||||
def test_decomposer_produces_tasks_without_goals(self):
|
||||
"""Tasks come from plugins + actions, NOT from goals list."""
|
||||
from GramAddict.core.goal_decomposer import GoalDecomposer
|
||||
|
||||
plugins = {"likes": {"percentage": 100}}
|
||||
actions = {"feed": "5-10"}
|
||||
mission = {"strategy": "aggressive_growth"}
|
||||
|
||||
decomposer = GoalDecomposer(plugins=plugins, actions=actions, mission=mission)
|
||||
tasks = decomposer.generate_tasks()
|
||||
|
||||
assert len(tasks) > 0, "Tasks must come from plugins, not goals"
|
||||
|
||||
def test_decomposer_accepts_empty_mission(self):
|
||||
"""If no mission is provided, default to aggressive_growth."""
|
||||
from GramAddict.core.goal_decomposer import GoalDecomposer
|
||||
|
||||
plugins = {"likes": {"percentage": 100}}
|
||||
actions = {"feed": "5-10"}
|
||||
|
||||
decomposer = GoalDecomposer(plugins=plugins, actions=actions, mission={})
|
||||
tasks = decomposer.generate_tasks()
|
||||
|
||||
assert len(tasks) > 0, "Empty mission must fall back to aggressive_growth"
|
||||
|
||||
|
||||
class TestGrowthBrainTaskSelection:
|
||||
"""Tests that GrowthBrain.select_task() picks from concrete Task objects."""
|
||||
|
||||
def test_select_task_returns_task_object(self):
|
||||
from GramAddict.core.goal_decomposer import GoalDecomposer, Task
|
||||
from GramAddict.core.growth_brain import GrowthBrain
|
||||
from GramAddict.core.dopamine_engine import DopamineEngine
|
||||
|
||||
plugins = {"likes": {"percentage": 100}, "story_view": {"percentage": 80}}
|
||||
actions = {"feed": "5-10", "explore": "5-10"}
|
||||
mission = {"strategy": "aggressive_growth"}
|
||||
|
||||
decomposer = GoalDecomposer(plugins=plugins, actions=actions, mission=mission)
|
||||
tasks = decomposer.generate_tasks()
|
||||
dopamine = DopamineEngine()
|
||||
|
||||
brain = GrowthBrain(username="test", persona_interests=[])
|
||||
selected = brain.select_task(dopamine, tasks)
|
||||
|
||||
assert isinstance(selected, Task), f"Expected Task, got {type(selected)}"
|
||||
assert selected in tasks
|
||||
|
||||
def test_select_task_returns_none_on_empty_tasks(self):
|
||||
from GramAddict.core.growth_brain import GrowthBrain
|
||||
from GramAddict.core.dopamine_engine import DopamineEngine
|
||||
|
||||
dopamine = DopamineEngine()
|
||||
brain = GrowthBrain(username="test", persona_interests=[])
|
||||
selected = brain.select_task(dopamine, [])
|
||||
|
||||
assert selected is None, "Empty task list must return None"
|
||||
|
||||
def test_select_task_returns_none_on_high_boredom(self):
|
||||
from GramAddict.core.goal_decomposer import GoalDecomposer
|
||||
from GramAddict.core.growth_brain import GrowthBrain
|
||||
from GramAddict.core.dopamine_engine import DopamineEngine
|
||||
|
||||
plugins = {"likes": {"percentage": 100}}
|
||||
actions = {"feed": "5-10"}
|
||||
mission = {"strategy": "aggressive_growth"}
|
||||
|
||||
decomposer = GoalDecomposer(plugins=plugins, actions=actions, mission=mission)
|
||||
tasks = decomposer.generate_tasks()
|
||||
dopamine = DopamineEngine()
|
||||
dopamine.boredom = 95.0
|
||||
|
||||
brain = GrowthBrain(username="test", persona_interests=[])
|
||||
selected = brain.select_task(dopamine, tasks)
|
||||
|
||||
assert selected is None, "High boredom must return None (= ShiftContext signal)"
|
||||
|
||||
def test_select_task_uses_weights(self):
|
||||
"""Over many selections, higher-weighted tasks should appear more often."""
|
||||
from GramAddict.core.goal_decomposer import GoalDecomposer, Task
|
||||
from GramAddict.core.growth_brain import GrowthBrain
|
||||
from GramAddict.core.dopamine_engine import DopamineEngine
|
||||
|
||||
plugins = {"likes": {"percentage": 100}, "story_view": {"percentage": 80}}
|
||||
actions = {"feed": "5-10", "explore": "5-10"}
|
||||
mission = {"strategy": "aggressive_growth"}
|
||||
|
||||
decomposer = GoalDecomposer(plugins=plugins, actions=actions, mission=mission)
|
||||
tasks = decomposer.generate_tasks()
|
||||
dopamine = DopamineEngine()
|
||||
brain = GrowthBrain(username="test", persona_interests=[])
|
||||
|
||||
# Run 100 selections and count
|
||||
counts: dict = {}
|
||||
for _ in range(100):
|
||||
selected = brain.select_task(dopamine, tasks)
|
||||
if selected:
|
||||
counts[selected.target_screen] = counts.get(selected.target_screen, 0) + 1
|
||||
|
||||
# With aggressive_growth, ExploreFeed (weight 0.45) should dominate
|
||||
assert len(counts) > 1, "Selection must pick from multiple screens"
|
||||
|
||||
Reference in New Issue
Block a user