"""BDI Agent AI that wraps GOAP planning with BDI reasoning. This module provides the main BDI-based AI decision maker that: 1. Maintains persistent beliefs about the world 2. Manages desires based on personality 3. Commits to intentions (plans) and executes them 4. Uses GOAP planning to generate action sequences Performance optimizations: - Timeslicing: full BDI cycle only runs periodically - Plan persistence: reuses plans across turns - Cached belief updates: skips unchanged data """ from dataclasses import dataclass, field from typing import Optional, TYPE_CHECKING from backend.domain.action import ActionType from backend.domain.resources import ResourceType from backend.domain.personality import get_trade_price_modifier from backend.core.bdi.belief import BeliefBase from backend.core.bdi.desire import DesireManager from backend.core.bdi.intention import IntentionManager from backend.core.goap.planner import GOAPPlanner, ReactivePlanner from backend.core.goap.goals import get_all_goals from backend.core.goap.actions import get_all_actions if TYPE_CHECKING: from backend.domain.agent import Agent from backend.core.market import OrderBook from backend.core.goap.goal import Goal from backend.core.goap.action import GOAPAction from backend.core.goap.planner import Plan @dataclass class TradeItem: """A single item to buy/sell in a trade.""" order_id: str resource_type: ResourceType quantity: int price_per_unit: int @dataclass class AIDecision: """A decision made by the AI for an agent.""" action: ActionType target_resource: Optional[ResourceType] = None order_id: Optional[str] = None quantity: int = 1 price: int = 0 reason: str = "" trade_items: list[TradeItem] = field(default_factory=list) adjust_order_id: Optional[str] = None new_price: Optional[int] = None # GOAP/BDI-specific fields goal_name: str = "" plan_length: int = 0 bdi_info: dict = field(default_factory=dict) def to_dict(self) -> dict: return { "action": self.action.value, "target_resource": self.target_resource.value if self.target_resource else None, "order_id": self.order_id, "quantity": self.quantity, "price": self.price, "reason": self.reason, "trade_items": [ { "order_id": t.order_id, "resource_type": t.resource_type.value, "quantity": t.quantity, "price_per_unit": t.price_per_unit, } for t in self.trade_items ], "adjust_order_id": self.adjust_order_id, "new_price": self.new_price, "goal_name": self.goal_name, "plan_length": self.plan_length, "bdi_info": self.bdi_info, } class BDIAgentAI: """BDI-based AI decision maker that wraps GOAP planning. The BDI cycle: 1. Update beliefs from sensors (agent state, market) 2. Update desires based on beliefs and personality 3. Check if current intention should continue 4. If needed, generate new plan via GOAP 5. Execute next action from intention Performance features: - Timeslicing: full deliberation only every N turns - Plan persistence: reuse plans across turns - Reactive fallback: simple decisions when not deliberating """ # Class-level cache for planners (shared across instances) _planner_cache: Optional[GOAPPlanner] = None _reactive_cache: Optional[ReactivePlanner] = None _goals_cache: Optional[list] = None _actions_cache: Optional[list] = None def __init__( self, agent: "Agent", market: "OrderBook", step_in_day: int = 1, day_steps: int = 10, current_turn: int = 0, is_night: bool = False, # Persistent BDI state (passed in for continuity) beliefs: Optional[BeliefBase] = None, desires: Optional[DesireManager] = None, intentions: Optional[IntentionManager] = None, ): self.agent = agent self.market = market self.step_in_day = step_in_day self.day_steps = day_steps self.current_turn = current_turn self.is_night = is_night # Initialize or use existing BDI components self.beliefs = beliefs or BeliefBase() self.desires = desires or DesireManager(agent.personality) self.intentions = intentions or IntentionManager.from_personality(agent.personality) # Update beliefs from current state self.beliefs.update_from_sensors( agent=agent, market=market, step_in_day=step_in_day, day_steps=day_steps, current_turn=current_turn, is_night=is_night, ) # Update desires from beliefs self.desires.update_from_beliefs(self.beliefs) # Get cached planners and goals/actions self.planner = self._get_planner() self.reactive_planner = self._get_reactive_planner() self.goals = self._get_goals() self.actions = self._get_actions() # Personality shortcuts self.p = agent.personality self.skills = agent.skills @classmethod def _get_planner(cls) -> GOAPPlanner: """Get cached GOAP planner.""" if cls._planner_cache is None: from backend.config import get_config config = get_config() ai_config = config.ai cls._planner_cache = GOAPPlanner( max_iterations=ai_config.goap_max_iterations, ) return cls._planner_cache @classmethod def _get_reactive_planner(cls) -> ReactivePlanner: """Get cached reactive planner.""" if cls._reactive_cache is None: cls._reactive_cache = ReactivePlanner() return cls._reactive_cache @classmethod def _get_goals(cls) -> list: """Get cached goals list.""" if cls._goals_cache is None: cls._goals_cache = get_all_goals() return cls._goals_cache @classmethod def _get_actions(cls) -> list: """Get cached actions list.""" if cls._actions_cache is None: cls._actions_cache = get_all_actions() return cls._actions_cache @classmethod def reset_caches(cls) -> None: """Reset all caches (call after config reload).""" cls._planner_cache = None cls._reactive_cache = None cls._goals_cache = None cls._actions_cache = None def should_deliberate(self) -> bool: """Check if this agent should run full BDI deliberation this turn. Timeslicing: not every agent deliberates every turn. Agents are staggered based on their ID hash. """ from backend.config import get_config config = get_config() # Get thinking interval from config (default to 1 = every turn) bdi_config = getattr(config, 'bdi', None) thinking_interval = getattr(bdi_config, 'thinking_interval', 1) if bdi_config else 1 if thinking_interval <= 1: return True # Deliberate every turn # Stagger agents across turns agent_hash = hash(self.agent.id) % thinking_interval return (self.current_turn % thinking_interval) == agent_hash def decide(self) -> AIDecision: """Make a decision using BDI reasoning with GOAP planning. Decision flow: 1. Night time: mandatory sleep 2. Check if should deliberate (timeslicing) 3. If deliberating: run full BDI cycle 4. If not: continue current intention or reactive fallback """ # Night time - mandatory sleep if self.is_night: return AIDecision( action=ActionType.SLEEP, reason="Night time: sleeping", goal_name="Sleep", bdi_info={"mode": "night"}, ) # Check if we should run full deliberation if self.should_deliberate(): return self._deliberate() else: return self._continue_or_react() def _deliberate(self) -> AIDecision: """Run full BDI deliberation cycle.""" # Filter goals by desires filtered_goals = self.desires.filter_goals_by_desire(self.goals, self.beliefs) # Check if we should reconsider current intention should_replan = self.intentions.should_reconsider( beliefs=self.beliefs, desire_manager=self.desires, available_goals=filtered_goals, ) if not should_replan and self.intentions.has_intention(): # Continue with current intention action = self.intentions.get_next_action() if action: return self._convert_to_decision( goap_action=action, goal=self.intentions.current_intention.goal, plan=self.intentions.current_intention.plan, mode="continue", ) # Need to plan for a goal world_state = self.beliefs.to_world_state() plan = self.planner.plan_for_goals( initial_state=world_state, goals=filtered_goals, available_actions=self.actions, ) if plan and not plan.is_empty: # Commit to new intention self.intentions.commit_to_plan( goal=plan.goal, plan=plan, current_turn=self.current_turn, ) goap_action = plan.first_action return self._convert_to_decision( goap_action=goap_action, goal=plan.goal, plan=plan, mode="new_plan", ) # Fallback to reactive planning return self._reactive_fallback() def _continue_or_react(self) -> AIDecision: """Continue current intention or use reactive fallback (no deliberation).""" if self.intentions.has_intention(): action = self.intentions.get_next_action() if action: return self._convert_to_decision( goap_action=action, goal=self.intentions.current_intention.goal, plan=self.intentions.current_intention.plan, mode="timeslice_continue", ) # No intention, use reactive fallback return self._reactive_fallback() def _reactive_fallback(self) -> AIDecision: """Use reactive planning when no intention exists.""" world_state = self.beliefs.to_world_state() best_action = self.reactive_planner.select_best_action( state=world_state, goals=self.goals, available_actions=self.actions, ) if best_action: return self._convert_to_decision( goap_action=best_action, goal=None, plan=None, mode="reactive", ) # Ultimate fallback - rest return AIDecision( action=ActionType.REST, reason="No valid action found, resting", bdi_info={"mode": "fallback"}, ) def _convert_to_decision( self, goap_action: "GOAPAction", goal: Optional["Goal"], plan: Optional["Plan"], mode: str = "deliberate", ) -> AIDecision: """Convert a GOAP action to an AIDecision with proper parameters.""" action_type = goap_action.action_type target_resource = goap_action.target_resource # Build reason string if goal: reason = f"{goal.name}: {goap_action.name}" else: reason = f"Reactive: {goap_action.name}" # BDI debug info bdi_info = { "mode": mode, "dominant_desire": self.desires.dominant_desire.value if self.desires.dominant_desire else None, "commitment": self.intentions.commitment_strategy.value, "has_intention": self.intentions.has_intention(), } # Handle different action types if action_type == ActionType.CONSUME: return AIDecision( action=action_type, target_resource=target_resource, reason=reason, goal_name=goal.name if goal else "", plan_length=len(plan.actions) if plan else 0, bdi_info=bdi_info, ) elif action_type == ActionType.TRADE: return self._create_trade_decision(goap_action, goal, plan, reason, bdi_info) elif action_type in [ActionType.HUNT, ActionType.GATHER, ActionType.CHOP_WOOD, ActionType.GET_WATER, ActionType.WEAVE]: return AIDecision( action=action_type, target_resource=target_resource, reason=reason, goal_name=goal.name if goal else "", plan_length=len(plan.actions) if plan else 0, bdi_info=bdi_info, ) elif action_type == ActionType.BUILD_FIRE: return AIDecision( action=action_type, target_resource=ResourceType.WOOD, reason=reason, goal_name=goal.name if goal else "", plan_length=len(plan.actions) if plan else 0, bdi_info=bdi_info, ) elif action_type in [ActionType.REST, ActionType.SLEEP]: return AIDecision( action=action_type, reason=reason, goal_name=goal.name if goal else "", plan_length=len(plan.actions) if plan else 0, bdi_info=bdi_info, ) # Default case return AIDecision( action=action_type, target_resource=target_resource, reason=reason, goal_name=goal.name if goal else "", plan_length=len(plan.actions) if plan else 0, bdi_info=bdi_info, ) def _create_trade_decision( self, goap_action: "GOAPAction", goal: Optional["Goal"], plan: Optional["Plan"], reason: str, bdi_info: dict, ) -> AIDecision: """Create a trade decision with actual market parameters.""" target_resource = goap_action.target_resource action_name = goap_action.name.lower() if "buy" in action_name: # Find the best order to buy from order = self.market.get_cheapest_order(target_resource) if order and order.seller_id != self.agent.id: # Check trust for this seller trust = self.beliefs.get_trade_trust(order.seller_id) # Skip distrusted sellers if we're picky if trust < -0.5 and self.p.price_sensitivity > 1.2: # Try next cheapest? For now, fall back to gathering return self._create_gather_fallback(target_resource, reason, goal, plan, bdi_info) # Calculate quantity to buy # Use max(1, ...) to avoid division by zero can_afford = self.agent.money // max(1, order.price_per_unit) space = self.agent.inventory_space() quantity = min(2, can_afford, space, order.quantity) if quantity > 0: return AIDecision( action=ActionType.TRADE, target_resource=target_resource, order_id=order.id, quantity=quantity, price=order.price_per_unit, reason=f"{reason} @ {order.price_per_unit}c", goal_name=goal.name if goal else "", plan_length=len(plan.actions) if plan else 0, bdi_info=bdi_info, ) # Can't buy - fallback to gathering return self._create_gather_fallback(target_resource, reason, goal, plan, bdi_info) elif "sell" in action_name: # Create a sell order quantity_available = self.agent.get_resource_count(target_resource) # Calculate minimum to keep min_keep = self._get_min_keep(target_resource) quantity_to_sell = min(3, quantity_available - min_keep) if quantity_to_sell > 0: price = self._calculate_sell_price(target_resource) return AIDecision( action=ActionType.TRADE, target_resource=target_resource, quantity=quantity_to_sell, price=price, reason=f"{reason} @ {price}c", goal_name=goal.name if goal else "", plan_length=len(plan.actions) if plan else 0, bdi_info=bdi_info, ) # Invalid trade action - rest return AIDecision( action=ActionType.REST, reason="Trade not possible", bdi_info=bdi_info, ) def _create_gather_fallback( self, resource_type: ResourceType, reason: str, goal: Optional["Goal"], plan: Optional["Plan"], bdi_info: dict, ) -> AIDecision: """Create a gather action as fallback when buying isn't possible.""" action_map = { ResourceType.WATER: ActionType.GET_WATER, ResourceType.BERRIES: ActionType.GATHER, ResourceType.MEAT: ActionType.HUNT, ResourceType.WOOD: ActionType.CHOP_WOOD, } action = action_map.get(resource_type, ActionType.GATHER) return AIDecision( action=action, target_resource=resource_type, reason=f"{reason} (gathering instead)", goal_name=goal.name if goal else "", plan_length=len(plan.actions) if plan else 0, bdi_info=bdi_info, ) def _get_min_keep(self, resource_type: ResourceType) -> int: """Get minimum quantity to keep for survival.""" # Adjusted by hoarding rate from desires hoarding_mult = 0.5 + self.p.hoarding_rate base_min = { ResourceType.WATER: 2, ResourceType.MEAT: 1, ResourceType.BERRIES: 2, ResourceType.WOOD: 1, ResourceType.HIDE: 0, } return int(base_min.get(resource_type, 1) * hoarding_mult) def _calculate_sell_price(self, resource_type: ResourceType) -> int: """Calculate sell price based on fair value and market conditions.""" from backend.core.ai import get_energy_cost from backend.config import get_config config = get_config() economy = getattr(config, 'economy', None) energy_to_money_ratio = getattr(economy, 'energy_to_money_ratio', 150) if economy else 150 min_price = getattr(economy, 'min_price', 100) if economy else 100 energy_cost = get_energy_cost(resource_type) fair_value = max(min_price, int(round(energy_cost * energy_to_money_ratio))) # Apply trading skill sell_modifier = get_trade_price_modifier(self.skills.trading, is_buying=False) # Get market signal signal = self.market.get_market_signal(resource_type) if signal == "sell": # Scarcity price = int(round(fair_value * 1.3 * sell_modifier)) elif signal == "hold": price = int(round(fair_value * sell_modifier)) else: # Surplus cheapest = self.market.get_cheapest_order(resource_type) if cheapest and cheapest.seller_id != self.agent.id: # Undercut, but respect floor (80% of fair value or min_price) floor_price = max(min_price, int(round(fair_value * 0.8))) price = max(floor_price, cheapest.price_per_unit - 1) else: price = int(round(fair_value * 0.8 * sell_modifier)) return max(min_price, price) def record_action_result(self, success: bool, action_type: str) -> None: """Record the result of an action for learning and intention tracking.""" # Update intention self.intentions.advance_intention(success) # Update beliefs/memory if success: self.beliefs.record_successful_action(action_type) else: self.beliefs.record_failed_action(action_type) # Persistent BDI state storage for agents _agent_bdi_state: dict[str, tuple[BeliefBase, DesireManager, IntentionManager]] = {} def get_bdi_decision( agent: "Agent", market: "OrderBook", step_in_day: int = 1, day_steps: int = 10, current_turn: int = 0, is_night: bool = False, ) -> AIDecision: """Get a BDI-based AI decision for an agent. This is the main entry point for the BDI AI system. It maintains persistent BDI state for each agent. """ # Get or create persistent BDI state if agent.id not in _agent_bdi_state: beliefs = BeliefBase() desires = DesireManager(agent.personality) intentions = IntentionManager.from_personality(agent.personality) _agent_bdi_state[agent.id] = (beliefs, desires, intentions) else: beliefs, desires, intentions = _agent_bdi_state[agent.id] # Create AI instance with persistent state ai = BDIAgentAI( agent=agent, market=market, step_in_day=step_in_day, day_steps=day_steps, current_turn=current_turn, is_night=is_night, beliefs=beliefs, desires=desires, intentions=intentions, ) return ai.decide() def reset_bdi_state() -> None: """Reset all BDI state (call on simulation reset).""" global _agent_bdi_state _agent_bdi_state.clear() BDIAgentAI.reset_caches() def remove_agent_bdi_state(agent_id: str) -> None: """Remove BDI state for a specific agent (call on agent death).""" _agent_bdi_state.pop(agent_id, None)