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:
2026-04-29 17:17:39 +02:00
parent 6db579f45b
commit b6846ab0fe
3 changed files with 139 additions and 2 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"