soci2 / src /soci /agents /agent.py
RayMelius's picture
Add life events, goals, pregnancy system; fix profile editor bugs
4ff38f2
"""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 # move, work, eat, sleep, talk, exercise, shop, relax, wander
target: str = "" # Location ID or agent ID depending on action
detail: str = "" # Free-text detail: what specifically they're doing
duration_ticks: int = 1 # How many ticks this action takes
needs_satisfied: dict[str, float] = field(default_factory=dict) # e.g. {"hunger": 0.4}
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()
# Current state
self.state: AgentState = AgentState.IDLE
self.location: str = persona.home_location
self.current_action: Optional[AgentAction] = None
self._action_ticks_remaining: int = 0
# Mood: -1.0 (terrible) to 1.0 (great)
self.mood: float = 0.3
# Daily plan (list of planned actions for today)
self.daily_plan: list[str] = []
self._has_plan_today: bool = False
# Last time we made an LLM call for this agent
self._last_llm_tick: int = -1
# Whether this agent is a human player
self.is_player: bool = False
# Romance: current partner ID (dating/engaged/married)
self.partner_id: Optional[str] = None
# Life history — append-only timeline of significant events
self.life_events: list[dict] = []
# Personal goals — evolving aspirations
self.goals: list[dict] = []
self._next_goal_id: int = 0
# Pregnancy (female agents)
self.pregnant: bool = False
self.pregnancy_start_tick: int = 0
self.pregnancy_partner_id: Optional[str] = None
# Children (name strings)
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
# Plan at the start of each day (6am)
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
# Set agent state based on action type
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
# Satisfy needs based on action
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)
# Mood is influenced by need satisfaction
avg_needs = (
self.needs.hunger + self.needs.energy + self.needs.social +
self.needs.purpose + self.needs.comfort + self.needs.fun
) / 6.0
# Mood drifts toward need satisfaction level
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
# Origin event
self.add_life_event(0, 0, "origin", f"Born and raised. {p.background}")
# Career event
if p.occupation and p.occupation.lower() not in ("newcomer", "unknown", "unemployed"):
self.add_life_event(0, 0, "career", f"Works as {p.occupation}")
# Seed initial goals based on persona
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