| | """Agent — a simulated person with persona, memory, needs, and relationships.""" |
| |
|
| | from __future__ import annotations |
| |
|
| | from dataclasses import dataclass, field |
| | from enum import Enum |
| | from typing import Optional, TYPE_CHECKING |
| |
|
| | from soci.agents.persona import Persona |
| | from soci.agents.memory import MemoryStream, MemoryType |
| | from soci.agents.needs import NeedsState |
| | from soci.agents.relationships import RelationshipGraph |
| |
|
| | if TYPE_CHECKING: |
| | from soci.world.clock import SimClock |
| |
|
| |
|
| | class AgentState(Enum): |
| | IDLE = "idle" |
| | MOVING = "moving" |
| | WORKING = "working" |
| | EATING = "eating" |
| | SLEEPING = "sleeping" |
| | SOCIALIZING = "socializing" |
| | EXERCISING = "exercising" |
| | SHOPPING = "shopping" |
| | RELAXING = "relaxing" |
| | IN_CONVERSATION = "in_conversation" |
| |
|
| |
|
| | @dataclass |
| | class AgentAction: |
| | """An action an agent has decided to take.""" |
| |
|
| | type: str |
| | target: str = "" |
| | detail: str = "" |
| | duration_ticks: int = 1 |
| | needs_satisfied: dict[str, float] = field(default_factory=dict) |
| |
|
| | def to_dict(self) -> dict: |
| | return { |
| | "type": self.type, |
| | "target": self.target, |
| | "detail": self.detail, |
| | "duration_ticks": self.duration_ticks, |
| | "needs_satisfied": self.needs_satisfied, |
| | } |
| |
|
| |
|
| | class Agent: |
| | """A simulated person living in the city.""" |
| |
|
| | def __init__(self, persona: Persona) -> None: |
| | self.persona = persona |
| | self.id = persona.id |
| | self.name = persona.name |
| | self.memory = MemoryStream() |
| | self.needs = NeedsState() |
| | self.relationships = RelationshipGraph() |
| |
|
| | |
| | self.state: AgentState = AgentState.IDLE |
| | self.location: str = persona.home_location |
| | self.current_action: Optional[AgentAction] = None |
| | self._action_ticks_remaining: int = 0 |
| |
|
| | |
| | self.mood: float = 0.3 |
| | |
| | self.daily_plan: list[str] = [] |
| | self._has_plan_today: bool = False |
| | |
| | self._last_llm_tick: int = -1 |
| | |
| | self.is_player: bool = False |
| | |
| | self.partner_id: Optional[str] = None |
| |
|
| | |
| | self.life_events: list[dict] = [] |
| | |
| | self.goals: list[dict] = [] |
| | self._next_goal_id: int = 0 |
| | |
| | self.pregnant: bool = False |
| | self.pregnancy_start_tick: int = 0 |
| | self.pregnancy_partner_id: Optional[str] = None |
| | |
| | self.children: list[str] = [] |
| |
|
| | @property |
| | def is_busy(self) -> bool: |
| | return self._action_ticks_remaining > 0 |
| |
|
| | def needs_new_plan(self, clock: SimClock) -> bool: |
| | """Does this agent need a new daily plan?""" |
| | if self.is_player: |
| | return False |
| | |
| | if clock.hour == 6 and clock.minute == 0 and not self._has_plan_today: |
| | return True |
| | return not self._has_plan_today |
| |
|
| | def start_action(self, action: AgentAction) -> None: |
| | """Begin executing an action.""" |
| | self.current_action = action |
| | self._action_ticks_remaining = action.duration_ticks |
| | |
| | state_map = { |
| | "move": AgentState.MOVING, |
| | "work": AgentState.WORKING, |
| | "eat": AgentState.EATING, |
| | "sleep": AgentState.SLEEPING, |
| | "talk": AgentState.IN_CONVERSATION, |
| | "exercise": AgentState.EXERCISING, |
| | "shop": AgentState.SHOPPING, |
| | "relax": AgentState.RELAXING, |
| | "wander": AgentState.IDLE, |
| | } |
| | self.state = state_map.get(action.type, AgentState.IDLE) |
| |
|
| | def tick_action(self) -> bool: |
| | """Advance current action by one tick. Returns True if action completed.""" |
| | if self._action_ticks_remaining > 0: |
| | self._action_ticks_remaining -= 1 |
| | |
| | if self.current_action: |
| | for need, amount in self.current_action.needs_satisfied.items(): |
| | per_tick = amount / max(1, self.current_action.duration_ticks) |
| | self.needs.satisfy(need, per_tick) |
| | if self._action_ticks_remaining <= 0: |
| | self.state = AgentState.IDLE |
| | self.current_action = None |
| | return True |
| | return False |
| |
|
| | def tick_needs(self, is_sleeping: bool = False) -> None: |
| | """Decay needs by one tick.""" |
| | self.needs.tick(is_sleeping=is_sleeping) |
| | |
| | avg_needs = ( |
| | self.needs.hunger + self.needs.energy + self.needs.social + |
| | self.needs.purpose + self.needs.comfort + self.needs.fun |
| | ) / 6.0 |
| | |
| | self.mood += (avg_needs - 0.5 - self.mood) * 0.1 |
| | self.mood = max(-1.0, min(1.0, self.mood)) |
| |
|
| | def add_observation( |
| | self, |
| | tick: int, |
| | day: int, |
| | time_str: str, |
| | content: str, |
| | importance: int = 5, |
| | location: str = "", |
| | involved_agents: Optional[list[str]] = None, |
| | ) -> None: |
| | """Record an observation in memory.""" |
| | self.memory.add( |
| | tick=tick, |
| | day=day, |
| | time_str=time_str, |
| | memory_type=MemoryType.OBSERVATION, |
| | content=content, |
| | importance=importance, |
| | location=location or self.location, |
| | involved_agents=involved_agents, |
| | ) |
| |
|
| | def add_reflection( |
| | self, |
| | tick: int, |
| | day: int, |
| | time_str: str, |
| | content: str, |
| | importance: int = 7, |
| | ) -> None: |
| | """Record a reflection in memory.""" |
| | self.memory.add( |
| | tick=tick, |
| | day=day, |
| | time_str=time_str, |
| | memory_type=MemoryType.REFLECTION, |
| | content=content, |
| | importance=importance, |
| | location=self.location, |
| | ) |
| |
|
| | def set_daily_plan(self, plan: list[str], day: int, tick: int, time_str: str) -> None: |
| | """Set the agent's plan for today.""" |
| | self.daily_plan = plan |
| | self._has_plan_today = True |
| | plan_text = "; ".join(plan) |
| | self.memory.add( |
| | tick=tick, |
| | day=day, |
| | time_str=time_str, |
| | memory_type=MemoryType.PLAN, |
| | content=f"My plan for today: {plan_text}", |
| | importance=6, |
| | location=self.location, |
| | ) |
| |
|
| | def reset_daily_plan(self) -> None: |
| | """Reset plan flag for a new day.""" |
| | self._has_plan_today = False |
| |
|
| | def add_life_event(self, day: int, tick: int, event_type: str, description: str) -> dict: |
| | """Record a significant life event (promotion, marriage, birth, etc.).""" |
| | event = {"day": day, "tick": tick, "type": event_type, "description": description} |
| | self.life_events.append(event) |
| | return event |
| |
|
| | def add_goal(self, description: str, status: str = "active") -> dict: |
| | """Add a personal goal.""" |
| | goal = {"id": self._next_goal_id, "description": description, "status": status, "progress": 0.0} |
| | self._next_goal_id += 1 |
| | self.goals.append(goal) |
| | return goal |
| |
|
| | def update_goal(self, goal_id: int, status: str = None, progress: float = None) -> None: |
| | """Update goal status or progress.""" |
| | for g in self.goals: |
| | if g["id"] == goal_id: |
| | if status: |
| | g["status"] = status |
| | if progress is not None: |
| | g["progress"] = min(1.0, max(0.0, progress)) |
| | break |
| |
|
| | def seed_biography(self, day: int, tick: int) -> None: |
| | """Generate initial life events from persona background. Called on sim reset.""" |
| | p = self.persona |
| | |
| | self.add_life_event(0, 0, "origin", f"Born and raised. {p.background}") |
| | |
| | if p.occupation and p.occupation.lower() not in ("newcomer", "unknown", "unemployed"): |
| | self.add_life_event(0, 0, "career", f"Works as {p.occupation}") |
| | |
| | occ_lower = (p.occupation or "").lower() |
| | if "student" in occ_lower: |
| | self.add_goal("Graduate and find a career") |
| | elif p.age < 30: |
| | self.add_goal("Advance in my career") |
| | if p.age >= 20 and p.age < 40: |
| | self.add_goal("Find meaningful relationships") |
| | if p.age >= 30: |
| | self.add_goal("Build a stable and happy life") |
| |
|
| | def biography_summary(self) -> str: |
| | """Short biography string for LLM context.""" |
| | parts = [] |
| | for e in self.life_events[-10:]: |
| | parts.append(f"Day {e['day']}: {e['description']}") |
| | return "\n".join(parts) if parts else "No significant life events yet." |
| |
|
| | def build_context(self, tick: int, world_description: str, location_description: str) -> str: |
| | """Build the full context string for LLM prompts.""" |
| | parts = [ |
| | f"CURRENT STATE:", |
| | f"- Time: Day {self.memory.memories[-1].day if self.memory.memories else 1}", |
| | f"- Location: {location_description}", |
| | f"- Mood: {self._mood_description()}", |
| | f"- Needs: {self.needs.describe()}", |
| | f"- Currently: {self.state.value}", |
| | f"", |
| | f"WORLD: {world_description}", |
| | f"", |
| | f"MY LIFE STORY:", |
| | self.biography_summary(), |
| | f"", |
| | ] |
| | if self.children: |
| | parts.append(f"MY CHILDREN: {', '.join(self.children)}") |
| | parts.append("") |
| | if self.pregnant: |
| | parts.append("I am currently pregnant.") |
| | parts.append("") |
| | active_goals = [g for g in self.goals if g["status"] == "active"] |
| | if active_goals: |
| | parts.append("MY GOALS:") |
| | for g in active_goals: |
| | pct = int(g.get("progress", 0) * 100) |
| | parts.append(f"- {g['description']} ({pct}% progress)") |
| | parts.append("") |
| | parts.extend([ |
| | f"PEOPLE I KNOW:", |
| | self.relationships.describe_known_people(), |
| | f"", |
| | f"RECENT MEMORIES:", |
| | self.memory.context_summary(tick), |
| | ]) |
| | if self.daily_plan: |
| | parts.insert(5, f"- Today's plan: {'; '.join(self.daily_plan)}") |
| | return "\n".join(parts) |
| |
|
| | def _mood_description(self) -> str: |
| | if self.mood > 0.6: |
| | return "feeling great" |
| | elif self.mood > 0.2: |
| | return "in a good mood" |
| | elif self.mood > -0.2: |
| | return "feeling okay" |
| | elif self.mood > -0.6: |
| | return "in a bad mood" |
| | else: |
| | return "feeling terrible" |
| |
|
| | def to_dict(self) -> dict: |
| | return { |
| | "persona": self.persona.to_dict(), |
| | "memory": self.memory.to_dict(), |
| | "needs": self.needs.to_dict(), |
| | "relationships": self.relationships.to_dict(), |
| | "state": self.state.value, |
| | "location": self.location, |
| | "current_action": self.current_action.to_dict() if self.current_action else None, |
| | "action_ticks_remaining": self._action_ticks_remaining, |
| | "mood": round(self.mood, 3), |
| | "daily_plan": self.daily_plan, |
| | "has_plan_today": self._has_plan_today, |
| | "last_llm_tick": self._last_llm_tick, |
| | "is_player": self.is_player, |
| | "partner_id": self.partner_id, |
| | "life_events": self.life_events, |
| | "goals": self.goals, |
| | "_next_goal_id": self._next_goal_id, |
| | "pregnant": self.pregnant, |
| | "pregnancy_start_tick": self.pregnancy_start_tick, |
| | "pregnancy_partner_id": self.pregnancy_partner_id, |
| | "children": self.children, |
| | } |
| |
|
| | @classmethod |
| | def from_dict(cls, data: dict) -> Agent: |
| | persona = Persona.from_dict(data["persona"]) |
| | agent = cls(persona) |
| | agent.memory = MemoryStream.from_dict(data["memory"]) |
| | agent.needs = NeedsState.from_dict(data["needs"]) |
| | agent.relationships = RelationshipGraph.from_dict(data["relationships"]) |
| | agent.state = AgentState(data["state"]) |
| | agent.location = data["location"] |
| | if data["current_action"]: |
| | agent.current_action = AgentAction(**data["current_action"]) |
| | agent._action_ticks_remaining = data["action_ticks_remaining"] |
| | agent.mood = data["mood"] |
| | agent.daily_plan = data["daily_plan"] |
| | agent._has_plan_today = data["has_plan_today"] |
| | agent._last_llm_tick = data["last_llm_tick"] |
| | agent.is_player = data["is_player"] |
| | agent.partner_id = data.get("partner_id") |
| | agent.life_events = data.get("life_events", []) |
| | agent.goals = data.get("goals", []) |
| | agent._next_goal_id = data.get("_next_goal_id", 0) |
| | agent.pregnant = data.get("pregnant", False) |
| | agent.pregnancy_start_tick = data.get("pregnancy_start_tick", 0) |
| | agent.pregnancy_partner_id = data.get("pregnancy_partner_id") |
| | agent.children = data.get("children", []) |
| | return agent |
| |
|