Spaces:
Running
Running
| """world_engine β the main simulation orchestrator. | |
| Owns the tick loop (snapshot, act, perceive, decide, replan, resolve), | |
| day handoff, proximity conversations, events, wellbeing updates, health | |
| observation, and per-tick checkpointing; also runs headless via CLI. | |
| Architecture: the hub of the backend β every subsystem (brain, planner, | |
| Actions, memory, events, checkpoints) is called from here; Odin.py hosts | |
| it as a background task. | |
| Design: LLM calls run in worker threads and asyncio tasks so latency | |
| never freezes the clock; deterministic phases (resolve) and stochastic | |
| phases (decide) are strictly separated. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import hashlib | |
| import json | |
| import math | |
| import random | |
| import sys | |
| import time as _time | |
| from collections import deque | |
| from datetime import datetime, timedelta, timezone | |
| from pathlib import Path | |
| from typing import Any, Callable, Dict, List, Optional | |
| from types import SimpleNamespace | |
| from src.core.log import get_logger | |
| BACKEND_ROOT = Path(__file__).resolve().parents[2] | |
| if str(BACKEND_ROOT) not in sys.path: | |
| sys.path.append(str(BACKEND_ROOT)) | |
| from src.agents.Actions import AgentActionManager, LocationResolver, ActionState, ActionType | |
| from src.agents.conversation import ( | |
| generate_conversation, | |
| RelationshipMatrix, | |
| _is_action_blocked, | |
| ) | |
| from src.agents.Short_term import ( | |
| save_day_plan, | |
| load_day_plan, | |
| date_from_simulation_time, | |
| append_conversation, | |
| append_event, | |
| archive_to_long_term, | |
| consolidate_to_single_day, | |
| clear_short_term_data, | |
| reset_day_runtime, | |
| ) | |
| from src.config import ( | |
| PERSONALITIES_DIR, | |
| SIM_MINUTES_PER_TICK, | |
| REAL_SECONDS_PER_TICK, | |
| MAX_CONVERSATIONS_PER_AGENT, | |
| ) | |
| from src.core.agent_registry import AgentRegistry, AgentRuntimeState | |
| from src.core.checkpoint_manager import ( | |
| save_checkpoint, | |
| save_history, | |
| load_checkpoint, | |
| list_checkpoints, | |
| prune_checkpoints, | |
| latest_tick, | |
| KEEP_LAST, | |
| ) | |
| from src.core.snapshot import take_snapshot, WorldSnapshot | |
| from src.core.world_state import WorldState, Position, CurrentAction, AgentStatus | |
| from src.core.world_events import WorldEventManager | |
| from src.core.runtime_health import RuntimeHealthMonitor | |
| from src.llm.gemini_client import ProviderFailureError | |
| from src import config as _cfg | |
| logger = get_logger(__name__) | |
| class WorldEngine: | |
| """Main simulation orchestrator.""" | |
| def __init__( | |
| self, | |
| sim_start_date: str = "2026-07-03", | |
| sim_start_hhmm: str = "00:00", | |
| sim_end_hhmm: str = "24:00", | |
| ): | |
| self.sim_start_date = sim_start_date | |
| self.sim_start_hhmm = sim_start_hhmm | |
| self.sim_end_hhmm = sim_end_hhmm | |
| self.world = WorldState() | |
| self.registry = AgentRegistry() | |
| self.resolver = LocationResolver() | |
| self.relationship_matrix = RelationshipMatrix() | |
| self.event_manager = WorldEventManager() | |
| self._applied_event_effects: set[str] = set() | |
| self.health_monitor = RuntimeHealthMonitor() | |
| self._day_index = 0 | |
| # Per-agent set of subject ids currently within the perception circle | |
| # (edge-triggered so we only record a memory when someone *enters* range). | |
| self._in_range: Dict[str, set] = {} | |
| # Per-agent Brain (cognition) instances, built lazily. Each commands a | |
| # Body (the action state machine) β the human-like brain/body split. | |
| self._brains: Dict[str, Any] = {} | |
| # Rolling feed of recently-completed conversations (for the UI feed). | |
| self._recent_convs: deque = deque(maxlen=12) | |
| # Pixels already used at spawn, so agents don't stack on each other. | |
| self._spawn_points: List[tuple] = [] | |
| # Per-agent observation fingerprint from the previous tick (novelty detection). | |
| # Key: agent_id, Value: frozenset of (agent_id, action_description) tuples. | |
| self._last_obs: Dict[str, frozenset] = {} | |
| # Per-agent cached observations from the current tick's perceive phase, | |
| # so Phase 4 (LLM decide) can reuse them without recomputing. | |
| self._tick_observations: Dict[str, list] = {} | |
| # Last tick at which each agent started an advisory LLM decision. A | |
| # changing proximity observation must not generate an API call per tick. | |
| self._last_decision_tick: Dict[str, int] = {} | |
| # Background LLM work is not serializable, but its deterministic input | |
| # envelope is. Keep that envelope so a restore can restart an | |
| # in-flight conversation instead of silently losing it. | |
| self._pending_conversations: Dict[str, Dict[str, Any]] = {} | |
| self._conversation_tasks: Dict[str, asyncio.Task] = {} | |
| # Decisions are advisory; a slow provider response must not freeze the | |
| # simulation clock or WebSocket snapshots at an action boundary. | |
| self._decision_tasks: Dict[str, asyncio.Task] = {} | |
| # Advisory throttle: last tick an "unscheduled downtime" recovery | |
| # replan was attempted per agent. Not checkpointed β on restore a | |
| # fresh attempt is harmless. | |
| self._downtime_replan_tick: Dict[str, int] = {} | |
| def _conversation_key(first_id: str, second_id: str) -> str: | |
| return "|".join(sorted((first_id, second_id))) | |
| def checkpoint_state(self) -> Dict[str, Any]: | |
| """Serialize engine-owned state that is not held by WorldState.""" | |
| return { | |
| "sim_start_date": self.sim_start_date, | |
| "sim_start_hhmm": self.sim_start_hhmm, | |
| "sim_end_hhmm": self.sim_end_hhmm, | |
| "day_index": self._day_index, | |
| "in_range": { | |
| agent_id: sorted(subject_ids) | |
| for agent_id, subject_ids in self._in_range.items() | |
| }, | |
| # Novelty fingerprints affect whether an agent makes an LLM | |
| # decision next tick, so they are behavioural state rather than a | |
| # disposable UI cache. | |
| "last_observations": { | |
| agent_id: [list(item) for item in observations] | |
| for agent_id, observations in self._last_obs.items() | |
| }, | |
| "last_decision_tick": dict(self._last_decision_tick), | |
| "recent_conversations": list(self._recent_convs), | |
| "relationship_matrix": self.relationship_matrix.snapshot(), | |
| "event_attendance": self.event_manager.attendance_snapshot(), | |
| "applied_event_effects": sorted(self._applied_event_effects), | |
| "pending_conversations": list(self._pending_conversations.values()), | |
| } | |
| def restore_checkpoint_state(self, state: Dict[str, Any]) -> None: | |
| """Restore engine-owned state after the world and registry are loaded. | |
| The caller replaces ``self.registry`` with newly deserialized manager | |
| instances before this method runs. Brains own BodyControllers, which | |
| retain a manager reference, so cached brains must never survive that | |
| replacement. Otherwise the clock advances an old manager while the | |
| restored registry (and therefore the UI) remains frozen on its old | |
| action. | |
| """ | |
| self._brains.clear() | |
| if not state: | |
| return | |
| self.sim_start_date = state.get("sim_start_date", self.sim_start_date) | |
| self.sim_start_hhmm = state.get("sim_start_hhmm", self.sim_start_hhmm) | |
| self.sim_end_hhmm = state.get("sim_end_hhmm", self.sim_end_hhmm) | |
| self._day_index = state.get("day_index", self._day_index) | |
| self._in_range = { | |
| agent_id: set(subject_ids) | |
| for agent_id, subject_ids in state.get("in_range", {}).items() | |
| } | |
| self._last_obs = { | |
| agent_id: frozenset(tuple(item) for item in observations) | |
| for agent_id, observations in state.get("last_observations", {}).items() | |
| } | |
| self._last_decision_tick = { | |
| agent_id: int(tick) | |
| for agent_id, tick in state.get("last_decision_tick", {}).items() | |
| } | |
| self._recent_convs = deque(state.get("recent_conversations", []), maxlen=12) | |
| relationship_state = state.get("relationship_matrix") | |
| if relationship_state is not None: | |
| self.relationship_matrix.restore(relationship_state) | |
| self.event_manager.restore_attendance(state.get("event_attendance", {})) | |
| self._applied_event_effects = set(state.get("applied_event_effects", [])) | |
| # Older checkpoints did not store a personality baseline. Upgrade | |
| # them on restore so a resumed day gets the same mood normalization as | |
| # a fresh day without invalidating its current action state. | |
| for agent in self.registry.all_states(): | |
| if agent.emotion_baseline == 0.5: | |
| agent.emotion_baseline = self._emotion_baseline(agent.persona) | |
| self._pending_conversations = {} | |
| for pending in state.get("pending_conversations", []): | |
| first_id = pending.get("agent_a_id") | |
| second_id = pending.get("agent_b_id") | |
| if first_id and second_id: | |
| self._pending_conversations[self._conversation_key(first_id, second_id)] = pending | |
| async def resume_pending_conversations(self) -> None: | |
| """Restart durable conversation requests after a checkpoint restore. | |
| An API call already executing at save time cannot be serialized. Its | |
| request envelope is retained, so this resumes the same logical work | |
| from the original participants, prompt inputs, and simulation time. | |
| """ | |
| for key, pending in list(self._pending_conversations.items()): | |
| try: | |
| first = self.registry.get(pending["agent_a_id"]) | |
| second = self.registry.get(pending["agent_b_id"]) | |
| except KeyError: | |
| self._pending_conversations.pop(key, None) | |
| continue | |
| if not first.paused or not second.paused: | |
| self._pending_conversations.pop(key, None) | |
| continue | |
| self._start_conversation_task(first, second, pending) | |
| # ------------------------------------------------------------------ # | |
| # Persona discovery | |
| # ------------------------------------------------------------------ # | |
| def _discover_personas(self) -> List[Dict[str, Any]]: | |
| """Find all persona JSON files and return their parsed data.""" | |
| personas = [] | |
| for json_path in sorted(PERSONALITIES_DIR.glob("**/*.json")): | |
| try: | |
| data = json.loads(json_path.read_text(encoding="utf-8")) | |
| if "Name" in data and "Hostel" in data: | |
| personas.append(data) | |
| except Exception as e: | |
| logger.warning("[WorldEngine] failed to load %s: %s", json_path, e) | |
| logger.info( | |
| "[WorldEngine] discovered %d personas from %s", len(personas), PERSONALITIES_DIR | |
| ) | |
| return personas | |
| def _apply_event_opportunities(self, date: str) -> None: | |
| """Overlay deterministic, conflict-safe optional event attendance. | |
| The overlay happens after cached or generated plans are available, so | |
| it works equally for fresh, resumed, and handoff days. Only entirely | |
| flexible windows can be replaced; the event manager rejects hard | |
| commitments before this method sees a changed plan. | |
| """ | |
| states = self.registry.all_states() | |
| if not states: | |
| return | |
| plans = {state.agent_id: state.day_plan for state in states} | |
| personas = {state.agent_id: state.persona for state in states} | |
| updated = self.event_manager.apply_opportunities( | |
| date, plans, personas, self.relationship_matrix.get, | |
| ) | |
| for state in states: | |
| plan = updated.get(state.agent_id, state.day_plan) | |
| if plan == state.day_plan: | |
| continue | |
| state.day_plan = plan | |
| if state.manager: | |
| state.manager.replace_day_plan(plan) | |
| save_day_plan(state.persona_name, date, plan) | |
| def _apply_finished_event_effects(self, date: str, hhmm: str) -> None: | |
| """Persist bounded social/mood effects once an attended event ends.""" | |
| now = self._hhmm_to_minutes(hhmm) | |
| changed_relationships = False | |
| for event in self.event_manager.for_date(date): | |
| if event.id in self._applied_event_effects or now < self._hhmm_to_minutes(event.end_time): | |
| continue | |
| attendees = self.event_manager.attendance_snapshot().get(event.id, []) | |
| for agent_id in attendees: | |
| try: | |
| state = self.registry.get(agent_id) | |
| except KeyError: | |
| continue | |
| state.energy_level = max(0.0, min(1.0, state.energy_level + event.energy_effect)) | |
| state.emotion_state = max(0.0, min(1.0, state.emotion_state + event.emotion_effect)) | |
| append_event(state.persona_name, date, { | |
| "type": "world_event", "action": event.name, | |
| "location": event.location_id, | |
| "summary": f"Attended {event.name}: {event.description}", | |
| "details": {"event_id": event.id, "ended_at": event.end_time}, | |
| }) | |
| if event.relationship_effect and len(attendees) > 1: | |
| for first in attendees: | |
| for second in attendees: | |
| if first != second: | |
| self.relationship_matrix.update(first, second, event.relationship_effect) | |
| changed_relationships = True | |
| self._applied_event_effects.add(event.id) | |
| logger.info("[Events] completed %s with %d attendee(s)", event.id, len(attendees)) | |
| if changed_relationships: | |
| self.relationship_matrix.save() | |
| def _resolve_hostel_position(self, hostel_id: str) -> Position: | |
| """Convert a Hostel field value to a pixel Position.""" | |
| pos = self.resolver.resolve(hostel_id) | |
| if pos is None: | |
| logger.warning( | |
| "[WorldEngine] could not resolve hostel '%s', using default (0,0)", hostel_id | |
| ) | |
| pos = Position(x=0, y=0, location_id=hostel_id) | |
| return pos | |
| def _persona_name_to_id(self, name: str) -> str: | |
| """Convert a display name like 'Parv Singla' to a safe agent_id.""" | |
| import re | |
| safe = re.sub(r"[^A-Za-z0-9._-]+", "_", name.strip()).strip("._-").lower() | |
| return safe or "unknown" | |
| def _emotion_baseline(persona: Dict[str, Any]) -> float: | |
| """Derive a stable, modest mood baseline from stated personality traits. | |
| This is deliberately narrow: students can have a good or difficult day, | |
| but their normal state should cluster around neutral rather than begin | |
| at an arbitrary emotional extreme. | |
| """ | |
| traits = " ".join(str(persona.get(key, "")) for key in ("innate", "lifestyle", "learned")).lower() | |
| baseline = 0.50 | |
| for marker in ("friendly", "warm", "approachable", "outgoing", "energetic", "playful", "resilient", "optimistic"): | |
| if marker in traits: | |
| baseline += 0.015 | |
| for marker in ("introverted", "quiet", "reserved", "awkward", "irregular sleep", "bad sleep", "procrastinate"): | |
| if marker in traits: | |
| baseline -= 0.015 | |
| return max(0.40, min(0.62, baseline)) | |
| def _energy_baseline(persona: Dict[str, Any]) -> float: | |
| """Return a personality-informed morning energy level, not 100%.""" | |
| traits = " ".join(str(persona.get(key, "")) for key in ("innate", "lifestyle", "learned")).lower() | |
| baseline = 0.74 | |
| for marker in ("regular sleep", "early riser", "exercise", "sport", "disciplined", "morning person"): | |
| if marker in traits: | |
| baseline += 0.025 | |
| for marker in ("irregular sleep", "bad sleep", "late-night", "procrastinate", "overcommitted", "insomni"): | |
| if marker in traits: | |
| baseline -= 0.04 | |
| return max(0.56, min(0.86, baseline)) | |
| def _action_wellbeing_deltas(self, state: AgentRuntimeState, action: Any, duration: int) -> tuple[float, float]: | |
| """Return the total wellbeing effect for one action. | |
| When the day planner declares an energy_target for the action, the | |
| runtime glides the agent's energy from its current level toward that | |
| declared cumulative target (the LLM owns every value; this is only a | |
| smooth, deterministic path to it). The exponential progress factor | |
| means short actions barely move energy while long ones converge, and | |
| because it depends only on stored state and the plan it stays | |
| checkpoint-reproducible. | |
| Without a declared target the legacy delta path applies: the planner's | |
| energy_change drives the level, with the small deterministic jitter | |
| keyed by agent/action so replays remain reproducible. | |
| """ | |
| description = (getattr(action, "description", "") or "").lower() | |
| emotion = float(getattr(action, "emotion_change", 0.0) or 0.0) | |
| target = getattr(action, "energy_target", None) | |
| if target is not None: | |
| target = max(0.0, min(1.0, float(target))) | |
| remaining = target - state.energy_level | |
| progress = 1.0 - math.exp(-_cfg.SIM_ENERGY_FOLLOW_RATE * max(1, duration)) | |
| energy = remaining * progress | |
| return energy, emotion | |
| energy = float(getattr(action, "energy_change", 0.0) or 0.0) | |
| variation = _cfg.SIM_WELLBEING_VARIABILITY | |
| token = f"{state.agent_id}|{getattr(action, 'start_time', '')}|{getattr(action, 'end_time', '')}|{description}" | |
| digest = hashlib.blake2s(token.encode("utf-8"), digest_size=4).digest() | |
| jitter = (int.from_bytes(digest, "big") / 0xFFFFFFFF) * 2.0 - 1.0 | |
| energy += jitter * 0.035 * variation | |
| emotion += jitter * 0.045 * variation | |
| return energy, emotion | |
| def _memory_context(self, persona_name, persona, before_date=None, query_hint=""): | |
| """Build (relevant_memories, rolling_summary) for a day-planner call. | |
| Uses the configured long-term memory backend (keyword by default). This | |
| is 0-LLM for the keyword backend. Failures degrade to empty memory so | |
| planning never breaks. | |
| """ | |
| try: | |
| from src.agents.Long_term import get_retriever | |
| retr = get_retriever() | |
| query = query_hint or " ".join( | |
| str(persona.get(k, "")) | |
| for k in ("goals", "hobbies", "daily_plan_req", "Branch") | |
| ) | |
| memories = retr.retrieve(persona_name, query, k=6) | |
| summary = retr.rolling_summary(persona_name, days=2, before_date=before_date) | |
| if memories or summary: | |
| logger.info( | |
| "[WorldEngine] memory for '%s': %d recalls, summary=%s", | |
| persona_name, len(memories), "yes" if summary else "no", | |
| ) | |
| return memories, summary | |
| except Exception as e: | |
| logger.warning("[WorldEngine] memory context failed for %s: %s", persona_name, e) | |
| return [], None | |
| # ------------------------------------------------------------------ # | |
| # Initialization | |
| # ------------------------------------------------------------------ # | |
| async def initialize(self) -> None: | |
| """ | |
| Phase 1: Discover personas, generate day plans, register agents. | |
| Does NOT advance the clock β all agents are ready at tick 0. | |
| """ | |
| logger.info("[WorldEngine] ========== INITIALIZATION ==========") | |
| t0 = _time.perf_counter() | |
| # Initialise the Qdrant-only long-term memory backend once at startup. | |
| try: | |
| from src.agents.Long_term import get_retriever | |
| get_retriever() | |
| except Exception as exc: | |
| logger.warning("[WorldEngine] memory backend initialization failed: %s", exc) | |
| personas = self._discover_personas() | |
| if not personas: | |
| logger.error("[WorldEngine] no personas found β aborting") | |
| return | |
| self.relationship_matrix.ensure_grounded_seed([ | |
| {"agent_id": self._persona_name_to_id(persona["Name"]), "persona_name": persona["Name"]} | |
| for persona in personas | |
| ]) | |
| from src.agents.day_planner import run as day_planner_run | |
| # ββ Phase 1a (sequential, fast): resolve positions, check saved plans ββ | |
| logger.info("[WorldEngine] preparing %d agents ...", len(personas)) | |
| agent_setups = [] | |
| for persona in personas: | |
| try: | |
| name = persona.get("Name", "unknown") | |
| hostel = persona.get("Hostel", "") | |
| agent_id = self._persona_name_to_id(name) | |
| position = self.resolver.random_interior_point(hostel, occupied=self._spawn_points) | |
| self._spawn_points.append((position.x, position.y)) | |
| consolidate_to_single_day(name, self.sim_start_date) | |
| existing_plan = load_day_plan(name, self.sim_start_date) | |
| if existing_plan: | |
| reset_day_runtime(name, self.sim_start_date) | |
| logger.info( | |
| "[WorldEngine] reusing saved day plan for '%s' (%d actions) β no LLM", | |
| name, len(existing_plan), | |
| ) | |
| day_plan = existing_plan | |
| error = None | |
| manager = AgentActionManager(agent_id, day_plan, position, self.resolver) | |
| self.registry.register( | |
| AgentRuntimeState( | |
| agent_id=agent_id, persona=persona, persona_name=name, | |
| manager=manager, position=position, day_plan=day_plan, | |
| emotion_state=self._emotion_baseline(persona), | |
| emotion_baseline=self._emotion_baseline(persona), energy_level=self._energy_baseline(persona), | |
| ) | |
| ) | |
| self.world.register_agent(agent_id, position) | |
| logger.info("[WorldEngine] registered '%s' (hostel=%s) β %d plan actions (cached)", | |
| name, hostel, len(day_plan)) | |
| else: | |
| agent_setups.append((name, hostel, agent_id, position, persona)) | |
| except Exception as e: | |
| logger.error("[WorldEngine] failed to prepare agent '%s': %s β skipping", | |
| persona.get("Name", "unknown"), e) | |
| import traceback; traceback.print_exc() | |
| continue | |
| # ββ Phase 1b (parallel, slow): generate day plans for agents that need one ββ | |
| # A full plan makes several provider calls. Serialize startup planning | |
| # to prevent concurrent agents from stampeding the shared key ring. | |
| logger.info("[WorldEngine] generating day plans for %d agents (sequential) ...", len(agent_setups)) | |
| plan_t0 = _time.perf_counter() | |
| async def _plan_one(name: str, hostel: str, agent_id: str, | |
| position, persona: dict) -> None: | |
| logger.info("[WorldEngine] starting day plan for '%s' ...", name) | |
| mem, yday = self._memory_context(name, persona, before_date=self.sim_start_date) | |
| proxy = SimpleNamespace(persona=persona, relevant_memories=mem, yesterday_summary=yday) | |
| try: | |
| _loop = asyncio.get_event_loop() | |
| plan_result = await _loop.run_in_executor( | |
| None, | |
| lambda: day_planner_run( | |
| proxy, | |
| { | |
| "current_time": f"{self.sim_start_date} {self.sim_start_hhmm}", | |
| "places": None, | |
| "persona_name": name, | |
| "mode": "full_day", | |
| "current_location_id": hostel, | |
| "energy_level": self._energy_baseline(persona), | |
| "emotion_state": self._emotion_baseline(persona), | |
| "upcoming_events": self.event_manager.snapshot(self.sim_start_date, self.sim_start_hhmm).get("upcoming", []), | |
| }, | |
| ), | |
| ) | |
| day_plan = plan_result.get("day_plan", []) | |
| error = plan_result.get("error") | |
| if error: | |
| logger.warning("[WorldEngine] day plan for '%s' has issues: %s", name, error) | |
| manager = AgentActionManager(agent_id, day_plan, position, self.resolver) | |
| self.registry.register( | |
| AgentRuntimeState( | |
| agent_id=agent_id, persona=persona, persona_name=name, | |
| manager=manager, position=position, day_plan=day_plan, | |
| emotion_state=self._emotion_baseline(persona), | |
| emotion_baseline=self._emotion_baseline(persona), energy_level=self._energy_baseline(persona), | |
| ) | |
| ) | |
| self.world.register_agent(agent_id, position) | |
| logger.info("[WorldEngine] registered '%s' (hostel=%s) β %d plan actions", | |
| name, hostel, len(day_plan)) | |
| except ProviderFailureError: | |
| # Never continue startup with an empty-plan agent after every | |
| # available key rejects a request. Odin reports this cleanly. | |
| raise | |
| except Exception as e: | |
| logger.error("[WorldEngine] day plan failed for '%s': %s β skipping", name, e) | |
| import traceback; traceback.print_exc() | |
| # Register anyway with empty plan so the sim doesn't crash | |
| manager = AgentActionManager(agent_id, [], position, self.resolver) | |
| self.registry.register( | |
| AgentRuntimeState( | |
| agent_id=agent_id, persona=persona, persona_name=name, | |
| manager=manager, position=position, day_plan=[], | |
| emotion_state=self._emotion_baseline(persona), | |
| emotion_baseline=self._emotion_baseline(persona), energy_level=self._energy_baseline(persona), | |
| ) | |
| ) | |
| self.world.register_agent(agent_id, position) | |
| logger.info("[WorldEngine] registered '%s' with empty plan (fallback)", name) | |
| for name, hostel, agent_id, position, persona in agent_setups: | |
| await _plan_one(name, hostel, agent_id, position, persona) | |
| self._apply_event_opportunities(self.sim_start_date) | |
| plan_elapsed = _time.perf_counter() - plan_t0 | |
| logger.info("[WorldEngine] all day plans generated in %.1fs", plan_elapsed) | |
| # Assign frontend colors | |
| _COLORS = [ | |
| "#ffcc33", "#ff6b6b", "#51cf66", "#339af0", "#cc5de8", | |
| "#f76707", "#20c997", "#f06595", "#748ffc", "#ffd43b", | |
| ] | |
| for i, state in enumerate(self.registry.all_states()): | |
| state.color = _COLORS[i % len(_COLORS)] | |
| # Register hostel rooms as world resources | |
| for state in self.registry.all_states(): | |
| hostel = state.persona.get("Hostel", "") | |
| if hostel: | |
| self.world.register_resource(hostel) | |
| elapsed = _time.perf_counter() - t0 | |
| # Start the clock at the configured time of day (e.g. 08:00) so the day | |
| # begins with real activity instead of everyone asleep at midnight. | |
| self.world.tick = self._hhmm_to_minutes(self.sim_start_hhmm) | |
| # Persist the assembled roster before the first driver tick. This lets | |
| # an observer use stopped-only roster controls even when an API quota | |
| # failure prevents the very first tick from running. | |
| save_checkpoint(self.world, self.registry, self.world.tick, self.checkpoint_state()) | |
| logger.info( | |
| "[WorldEngine] initialized %d agents in %.1fs β starting simulation at %s", | |
| len(self.registry), elapsed, self.sim_start_hhmm, | |
| ) | |
| # ------------------------------------------------------------------ # | |
| # Tick loop | |
| # ------------------------------------------------------------------ # | |
| async def run_tick(self) -> Dict[str, Any]: | |
| """ | |
| Execute one simulation tick in six phases: | |
| 1. Snapshot β freeze world state (one copy for all agents). | |
| 2. Act β all agents advance their body state machines in parallel. | |
| 3. Perceive β build observations from snapshot, detect novelty, set | |
| flags for LLM decision; conversation detection happens here. | |
| 4. LLM Decideβ parallel LLM decision for agents with novel observations. | |
| 5. Replan β regenerate remaining-day plan for agents that chose replan. | |
| 6. Resolve β sequential: timeouts, day transitions, collisions, sync. | |
| Returns a dict snapshot for frontend use. | |
| """ | |
| from src.llm.gemini_client import provider_failure | |
| failure = provider_failure() | |
| if failure: | |
| raise failure | |
| current_tick = self.world.tick | |
| hhmm = self._minutes_to_hhmm(current_tick % (24 * 60)) | |
| # A generated conversation remains a real, visible simulation state | |
| # until its declared end, rather than disappearing with the LLM task. | |
| self._complete_finished_conversations(current_tick) | |
| agent_states = self.registry.all_states() | |
| # ββββββ PHASE 2: Act (parallel) ββββββ | |
| if agent_states: | |
| await self._phase_act(agent_states, current_tick, hhmm) | |
| # Perception must observe the positions and actions produced by this | |
| # tick's act phase, rather than a one-tick-old world mirror. | |
| self._sync_to_world_state() | |
| snapshot = take_snapshot(self.world) | |
| # ββββββ PHASE 3: Perceive (parallel) + conversation detection (sequential) ββββββ | |
| novelty_flags: Dict[str, bool] = {} # agent_id -> has novel observations | |
| self._tick_observations.clear() | |
| if agent_states: | |
| perceive_results = await asyncio.gather( | |
| *[self._phase_perceive(s, snapshot) for s in agent_states], | |
| return_exceptions=True, | |
| ) | |
| for state, result in zip(agent_states, perceive_results): | |
| if isinstance(result, Exception): | |
| logger.error( | |
| "[WorldEngine] agent '%s' perceive failed: %s", state.agent_id, result, | |
| ) | |
| else: | |
| novelty_flags[state.agent_id], obs = result | |
| self._tick_observations[state.agent_id] = obs | |
| # Phase 3b: Conversation detection (sequential) | |
| await self._check_conversations(current_tick, hhmm) | |
| self._timeout_conversations(current_tick) | |
| # ββββββ PHASE 4: LLM Decide (parallel, only non-paused agents with novelty) ββββββ | |
| decide_results: Dict[str, Any] = self._collect_finished_decisions() | |
| decide_agents = [ | |
| s for s in agent_states | |
| if not s.paused and novelty_flags.get(s.agent_id, False) | |
| and s.energy_level >= _cfg.DECIDE_MIN_ENERGY | |
| and s.emotion_state >= _cfg.DECIDE_MIN_EMOTION | |
| and current_tick - self._last_decision_tick.get( | |
| s.agent_id, -_cfg.DECIDE_COOLDOWN_TICKS | |
| ) >= _cfg.DECIDE_COOLDOWN_TICKS | |
| ] | |
| if decide_agents: | |
| for state in decide_agents: | |
| if state.agent_id in self._decision_tasks: | |
| continue | |
| self._last_decision_tick[state.agent_id] = current_tick | |
| self._decision_tasks[state.agent_id] = asyncio.create_task( | |
| self._phase_llm_decide(state, current_tick, hhmm), | |
| name=f"decide:{state.agent_id}:{current_tick}", | |
| ) | |
| # ββββββ PHASE 5: Replan (parallel, agents whose LLM decision was "replan") ββββββ | |
| replan_agents = [ | |
| s for s in agent_states | |
| if decide_results.get(s.agent_id) == "replan" | |
| and s.replan_count < _cfg.MAX_REPLANS_PER_AGENT_PER_DAY | |
| ] | |
| if replan_agents: | |
| await asyncio.gather( | |
| *[self._phase_replan(s, current_tick, hhmm) for s in replan_agents], | |
| return_exceptions=True, | |
| ) | |
| # ββββββ PHASE 5b: Unscheduled downtime recovery (parallel, deterministic) ββββββ | |
| # A force-accepted fallback plan strands the agent on "Unscheduled | |
| # downtime" for the rest of the day. The LLM decide path may never | |
| # fire for such an agent, so detect it here and replan the remaining | |
| # whole day explicitly. | |
| downtime_agents = [ | |
| s for s in agent_states if self._has_unscheduled_downtime(s, current_tick) | |
| ] | |
| if downtime_agents: | |
| for s in downtime_agents: | |
| self._downtime_replan_tick[s.agent_id] = current_tick | |
| logger.info( | |
| "[WorldEngine] agent '%s' stuck on unscheduled downtime β replanning remaining day", | |
| s.persona_name, | |
| ) | |
| await asyncio.gather( | |
| *[self._phase_replan(s, current_tick, hhmm) for s in downtime_agents], | |
| return_exceptions=True, | |
| ) | |
| # ββββββ PHASE 6: Resolve (sequential) ββββββ | |
| await self._check_last_action_triggers(current_tick, hhmm) | |
| self._apply_finished_event_effects(self.sim_start_date, hhmm) | |
| self._resolve_collisions() | |
| self._sync_to_world_state() | |
| # ----- Advance tick ----- | |
| self.world.advance_tick(minutes=SIM_MINUTES_PER_TICK) | |
| snapshot_tick = self.world.tick | |
| snapshot_hhmm = self._minutes_to_hhmm(snapshot_tick % (24 * 60)) | |
| # A frontend frame must describe the same clock state as the engine | |
| # and checkpoint it was built from. Returning the pre-advance clock | |
| # made the UI one tick behind rewind/fast-forward controls. | |
| health = self.health_monitor.observe(self, snapshot_tick, snapshot_hhmm) | |
| # ----- Save checkpoint ----- | |
| save_checkpoint( | |
| self.world, | |
| self.registry, | |
| self.world.tick, | |
| engine_state=self.checkpoint_state(), | |
| ) | |
| prune_checkpoints(keep_last=KEEP_LAST) | |
| # Build frontend snapshot | |
| return self._frontend_snapshot(snapshot_tick, snapshot_hhmm, health) | |
| def _frontend_snapshot(self, tick: int, hhmm: str, health: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: | |
| """Return the current UI-safe projection without advancing the world.""" | |
| real_min_per_day = (1440 * _cfg.REAL_SECONDS_PER_SIM_MINUTE | |
| / max(_cfg.TICK_SPEED, 1e-9)) / 60.0 | |
| return { | |
| "tick": tick, | |
| "time": hhmm, | |
| "day": self._day_index + 1, | |
| "speed": { | |
| "multiplier": _cfg.TICK_SPEED, | |
| "real_min_per_day": round(real_min_per_day), | |
| "real_ms_per_sim_minute": round((_cfg.REAL_SECONDS_PER_SIM_MINUTE / max(_cfg.TICK_SPEED, 1e-9)) * 1000), | |
| }, | |
| "recent_conversations": list(self._recent_convs), | |
| "events": self.event_manager.snapshot(self.sim_start_date, hhmm), | |
| "health": health if health is not None else self.health_monitor.latest, | |
| "agents": { | |
| s.agent_id: { | |
| "name": s.persona_name, | |
| "color": s.color, | |
| "position": { | |
| "x": s.position.x, | |
| "y": s.position.y, | |
| "location_id": s.position.location_id, | |
| }, | |
| "current_action": ( | |
| s.manager.current_action.model_dump() | |
| if s.manager and s.manager.current_action | |
| else None | |
| ), | |
| "activity": ( | |
| s.manager.current_action.description | |
| if s.manager and s.manager.current_action | |
| else "Idle" | |
| ), | |
| "paused": s.paused, | |
| "in_conversation": bool(s.paused and s.active_conversation), | |
| "in_last_action": s.manager.is_last_action if s.manager else False, | |
| "energy_level": s.energy_level, | |
| "emotion_state": s.emotion_state, | |
| "conversation": s.active_conversation, | |
| } | |
| for s in self.registry.all_states() | |
| }, | |
| } | |
| def current_frontend_snapshot(self) -> Dict[str, Any]: | |
| """Expose restored state immediately after a checkpoint rewind.""" | |
| tick = self.world.tick | |
| hhmm = self._minutes_to_hhmm(tick % (24 * 60)) | |
| return self._frontend_snapshot(tick, hhmm) | |
| async def _phase_act(self, agent_states, current_tick, hhmm) -> None: | |
| """Phase 2: all agents advance their body state machines in parallel.""" | |
| act_results = await asyncio.gather( | |
| *[self._run_agent_act(s, current_tick, hhmm) for s in agent_states], | |
| return_exceptions=True, | |
| ) | |
| for state, result in zip(agent_states, act_results): | |
| if isinstance(result, Exception): | |
| logger.error( | |
| "[WorldEngine] agent '%s' act failed: %s", state.agent_id, result, | |
| ) | |
| def _collect_finished_decisions(self) -> Dict[str, str]: | |
| """Consume completed advisory decisions without delaying a tick.""" | |
| completed: Dict[str, str] = {} | |
| for agent_id, task in list(self._decision_tasks.items()): | |
| if not task.done(): | |
| continue | |
| self._decision_tasks.pop(agent_id, None) | |
| try: | |
| completed[agent_id] = task.result() | |
| except asyncio.CancelledError: | |
| continue | |
| except Exception as exc: | |
| from src.llm.gemini_client import ProviderFailureError | |
| if isinstance(exc, ProviderFailureError): | |
| raise | |
| logger.error("[WorldEngine] agent '%s' LLM decide failed: %s", agent_id, exc) | |
| completed[agent_id] = "continue" | |
| return completed | |
| async def _phase_perceive(self, state: AgentRuntimeState, snapshot: WorldSnapshot) -> tuple: | |
| """Phase 3: build observations for one agent, detect novelty. | |
| Returns (novel: bool, observations: list). | |
| """ | |
| if state.paused: | |
| return False, [] | |
| observations = self._build_observations(state, snapshot) | |
| # Compute fingerprint: frozenset of (agent_id, action_description) | |
| fingerprint = frozenset( | |
| (o["agent_id"], o.get("current_action", "")) for o in observations | |
| ) | |
| prev = self._last_obs.get(state.agent_id, frozenset()) | |
| novel = fingerprint != prev | |
| self._last_obs[state.agent_id] = fingerprint | |
| return novel, observations | |
| async def _phase_llm_decide(self, state: AgentRuntimeState, tick: int, hhmm: str) -> str: | |
| """Phase 4: call the brain's LLM decision for one agent. | |
| Returns "continue" or "replan". | |
| """ | |
| from src.core.budget import GOVERNOR | |
| if not GOVERNOR.can_afford("decide", cost=1): | |
| logger.info("[WorldEngine] budget denied decide for '%s' β continuing", state.persona_name) | |
| return "continue" | |
| brain = self._brain_for(state) | |
| observations = self._tick_observations.get(state.agent_id, []) | |
| query = " ".join(str(item.get("current_action", "")) for item in observations) | |
| memories, _ = self._memory_context(state.persona_name, state.persona, query_hint=query) | |
| # Gemini is a synchronous SDK call. Run it in the default worker | |
| # pool so a slow response, rate-limit wait, or retry never prevents | |
| # WebSocket broadcasts and the rest of the simulation from advancing. | |
| decision = await asyncio.to_thread( | |
| brain.decide_tick, | |
| tick=tick, | |
| hhmm=hhmm, | |
| observations=observations, | |
| day_plan=state.day_plan, | |
| replan_count=state.replan_count, | |
| max_replans=_cfg.MAX_REPLANS_PER_AGENT_PER_DAY, | |
| energy_level=state.energy_level, | |
| emotion_state=state.emotion_state, | |
| relevant_memories=memories, | |
| ) | |
| return decision.decision | |
| async def _phase_replan(self, state: AgentRuntimeState, tick: int, hhmm: str) -> None: | |
| """Phase 5: regenerate remaining-day plan for one agent. | |
| Called only when the LLM returned "replan" and budget allows. | |
| """ | |
| from src.core.budget import GOVERNOR | |
| if not GOVERNOR.can_afford("replan", cost=4): | |
| logger.info("[WorldEngine] budget denied replan for '%s' β skipping", state.persona_name) | |
| return | |
| from src.agents.day_planner import run as day_planner_run | |
| from types import SimpleNamespace | |
| loop = asyncio.get_event_loop() | |
| mem, yday = self._memory_context( | |
| state.persona_name, state.persona, | |
| before_date=date_from_simulation_time(f"{self.sim_start_date} {hhmm}"), | |
| ) | |
| proxy = SimpleNamespace( | |
| persona=state.persona, | |
| relevant_memories=mem, | |
| yesterday_summary=yday, | |
| ) | |
| try: | |
| plan_result = await loop.run_in_executor( | |
| None, | |
| lambda: day_planner_run( | |
| proxy, | |
| { | |
| "current_time": f"{self.sim_start_date} {hhmm}", | |
| "places": None, | |
| "persona_name": state.persona_name, | |
| "mode": "remaining", | |
| "current_location_id": state.position.location_id, | |
| "energy_level": state.energy_level, | |
| "emotion_state": state.emotion_state, | |
| "upcoming_events": self.event_manager.snapshot(self.sim_start_date, hhmm).get("upcoming", []), | |
| }, | |
| ), | |
| ) | |
| new_plan = plan_result.get("day_plan", []) | |
| if plan_result.get("replan_rejected"): | |
| logger.warning( | |
| "[WorldEngine] replan rejected for '%s'; retaining prior %d-action plan: %s", | |
| state.persona_name, | |
| len(state.day_plan), | |
| plan_result.get("error", "validation retries exhausted"), | |
| ) | |
| return | |
| if new_plan and state.manager: | |
| state.day_plan = new_plan | |
| state.manager.replace_day_plan(new_plan) | |
| state.replan_count += 1 | |
| logger.info( | |
| "[WorldEngine] replan for '%s': %d actions (count=%d)", | |
| state.persona_name, len(new_plan), state.replan_count, | |
| ) | |
| except Exception as e: | |
| logger.error( | |
| "[WorldEngine] replan failed for '%s': %s", state.persona_name, e, | |
| ) | |
| def _has_unscheduled_downtime(self, state: AgentRuntimeState, tick: int) -> bool: | |
| """Detect agents stranded on the deterministic fallback schedule. | |
| The LLM decide path is gated (novelty, energy/emotion, cooldown, | |
| budget), so a force-accepted fallback day can leave an agent stuck on | |
| "Unscheduled downtime" for hours with no replan ever firing. This | |
| backstop scans the remaining plan every tick and flags it.""" | |
| if state.paused: | |
| return False | |
| if state.day_archived: | |
| return False | |
| if state.manager is None: | |
| return False | |
| if state.replan_count >= _cfg.MAX_REPLANS_PER_AGENT_PER_DAY: | |
| return False | |
| if ( | |
| tick - self._downtime_replan_tick.get(state.agent_id, -10**9) | |
| < _cfg.DOWNTIME_REPLAN_COOLDOWN_TICKS | |
| ): | |
| return False | |
| now_minutes = tick % (24 * 60) | |
| if now_minutes >= 24 * 60 - _cfg.DOWNTIME_REPLAN_MIN_HORIZON: | |
| # Too little of the day remains to justify a replan. | |
| return False | |
| for action in state.day_plan: | |
| end = self._hhmm_to_minutes(str(action.get("end", ""))) | |
| if end > now_minutes and "unscheduled downtime" in str( | |
| action.get("action", "") | |
| ).lower(): | |
| return True | |
| return False | |
| async def _run_agent_act( | |
| self, state: AgentRuntimeState, tick: int, hhmm: str | |
| ) -> None: | |
| """Phase 2: execute the decision β advance the body's state machine. | |
| Updates registry position and action from the manager after the body | |
| advances. Paused agents are skipped. | |
| """ | |
| if state.paused: | |
| return | |
| manager = state.manager | |
| if manager is None: | |
| return | |
| brain = self._brain_for(state) | |
| action = brain.act(tick) | |
| state.position = manager.position | |
| state.current_action = action.model_dump() if action else None | |
| # Per-tick energy/emotion update | |
| self._update_energy_emotion(state) | |
| def _timeout_conversations(self, current_tick: int) -> None: | |
| """Fail safe only for LLM requests that are still generating.""" | |
| for state in self.registry.all_states(): | |
| if ( | |
| not state.paused | |
| or not state.active_conversation | |
| or state.active_conversation.get("status") != "generating" | |
| ): | |
| continue | |
| if current_tick - state.conversation_start_tick < 30: | |
| continue | |
| partner_id = state.last_conversation_partner | |
| logger.info( | |
| "[WorldEngine] conversation timeout for '%s' (started tick %d, now tick %d)", | |
| state.persona_name, state.conversation_start_tick, current_tick, | |
| ) | |
| self._end_conversation(state, current_tick) | |
| if partner_id: | |
| try: | |
| partner = self.registry.get(partner_id) | |
| self._end_conversation(partner, current_tick) | |
| self._discard_pending_conversation(state.agent_id, partner.agent_id) | |
| except KeyError: | |
| pass | |
| def _end_conversation(state: AgentRuntimeState, current_tick: Optional[int] = None) -> None: | |
| """Restore agent state after a conversation ends (timeout or normal).""" | |
| state.paused = False | |
| state.conversation_start_tick = 0 | |
| state.active_conversation = None | |
| mgr = state.manager | |
| if mgr: | |
| mgr.resume_from_conversation(world_tick=current_tick) | |
| def _complete_finished_conversations(self, current_tick: int) -> None: | |
| """Resume each completed conversation once its simulated duration ends.""" | |
| completed: set[str] = set() | |
| for state in self.registry.all_states(): | |
| conversation = state.active_conversation or {} | |
| partner_id = conversation.get("partner_id") | |
| if ( | |
| not state.paused | |
| or conversation.get("status") != "active" | |
| or current_tick < conversation.get("ends_at_tick", current_tick + 1) | |
| or not partner_id | |
| or state.agent_id in completed | |
| ): | |
| continue | |
| try: | |
| partner = self.registry.get(partner_id) | |
| except KeyError: | |
| self._end_conversation(state, current_tick) | |
| continue | |
| self._end_conversation(state, current_tick) | |
| self._end_conversation(partner, current_tick) | |
| self.world.record_conversation(state.agent_id, current_tick) | |
| self.world.record_conversation(partner.agent_id, current_tick) | |
| completed.update((state.agent_id, partner.agent_id)) | |
| logger.info( | |
| "[WorldEngine] conversation '%s' <-> '%s' ended after its simulated duration", | |
| state.persona_name, partner.persona_name, | |
| ) | |
| def _resolve_collisions(self, min_gap: int = 4) -> None: | |
| """Ensure no two agents share (nearly) the same pixel β nudge later ones | |
| a few pixels away. Keeps the map readable and honours 'never same pixel'.""" | |
| seen: List[tuple] = [] | |
| g2 = min_gap * min_gap | |
| for s in self.registry.all_states(): | |
| x, y = s.position.x, s.position.y | |
| tries = 0 | |
| while tries < 15 and any((x - ox) ** 2 + (y - oy) ** 2 < g2 for ox, oy in seen): | |
| x = s.position.x + random.randint(-7, 7) | |
| y = s.position.y + random.randint(-7, 7) | |
| tries += 1 | |
| if (x, y) != (s.position.x, s.position.y): | |
| # Registry and action manager are two views of the live body. | |
| # Keep them atomic here; otherwise _run_agent_act restores the | |
| # old manager position on the next tick and the collision | |
| # reappears. | |
| corrected = Position(x=x, y=y, location_id=s.position.location_id) | |
| s.position = corrected | |
| if s.manager is not None: | |
| s.manager.position = corrected | |
| seen.append((x, y)) | |
| def _update_energy_emotion(self, state: AgentRuntimeState) -> None: | |
| """Apply the active action's bounded, personality-aware wellbeing effect.""" | |
| manager = state.manager | |
| if manager is None or manager.current_action is None: | |
| return | |
| action = manager.current_action | |
| try: | |
| start_min = self._hhmm_to_minutes(action.start_time) | |
| end_min = self._hhmm_to_minutes(action.end_time) | |
| duration = max(1, end_min - start_min) | |
| tick_step = _cfg.SIM_MINUTES_PER_TICK | |
| action_energy_change, action_emotion_change = self._action_wellbeing_deltas(state, action, duration) | |
| energy_tick = (action_energy_change / duration) * tick_step | |
| emotion_tick = (action_emotion_change / duration) * tick_step | |
| state.energy_level = max(0.0, min(1.0, state.energy_level + energy_tick)) | |
| state.emotion_state = max(0.0, min(1.0, state.emotion_state + emotion_tick)) | |
| except Exception: | |
| pass | |
| def _brain_for(self, state: AgentRuntimeState): | |
| """Lazily build and cache one Brain (+ Body) per agent.""" | |
| brain = self._brains.get(state.agent_id) | |
| if brain is None: | |
| from src.agents.body import BodyController | |
| from src.agents.brain import Brain | |
| brain = Brain( | |
| agent_id=state.agent_id, | |
| persona_name=state.persona_name, | |
| body=BodyController(state.manager), | |
| persona=state.persona, | |
| ) | |
| self._brains[state.agent_id] = brain | |
| return brain | |
| def _build_observations(self, state: AgentRuntimeState, w_snapshot: WorldSnapshot) -> list: | |
| """Build structured observations for one agent from the frozen snapshot. | |
| Reports every agent within the configured pixel radius, including their | |
| position and current action. Placeholder for future world-events. | |
| """ | |
| radius = float(_cfg.PERCEPTION_RADIUS_PX) | |
| nearby_snaps = w_snapshot.agents_within_px(state.agent_id, radius) | |
| observed = [] | |
| for ns in nearby_snaps: | |
| try: | |
| rs = self.registry.get(ns.agent_id) | |
| name = rs.persona_name if rs else ns.agent_id | |
| except KeyError: | |
| name = ns.agent_id | |
| observed.append({ | |
| "agent_id": ns.agent_id, | |
| "name": name, | |
| "position": {"x": ns.position.x, "y": ns.position.y}, | |
| "current_action": ns.current_action.description if ns.current_action else "idle", | |
| }) | |
| return observed | |
| # ------------------------------------------------------------------ # | |
| # Perception (0-LLM) + spatial helpers | |
| # ------------------------------------------------------------------ # | |
| def _neighbors_within_px(self, state, radius_px: float) -> List: | |
| """Registry states whose pixel position is within radius_px of `state` | |
| (excludes self). Nearest first.""" | |
| out = [] | |
| for other in self.registry.all_states(): | |
| if other.agent_id == state.agent_id: | |
| continue | |
| dx = other.position.x - state.position.x | |
| dy = other.position.y - state.position.y | |
| d2 = dx * dx + dy * dy | |
| if d2 <= radius_px * radius_px: | |
| out.append((d2, other)) | |
| out.sort(key=lambda t: t[0]) | |
| return [o for _d, o in out] | |
| def _perceive_and_record(self, tick: int, hhmm: str) -> None: | |
| """For each agent, find who is within the perception circle and record a | |
| memory the moment someone *enters* range (edge-triggered, de-duplicated). | |
| Pure spatial math β no LLM. Cheap keyword-backend writes only. | |
| """ | |
| radius = float(_cfg.PERCEPTION_RADIUS_PX) | |
| try: | |
| from src.agents.Long_term import get_retriever | |
| retriever = get_retriever() | |
| except Exception: | |
| retriever = None | |
| date_str = date_from_simulation_time(f"{self.sim_start_date} {hhmm}") | |
| for state in self.registry.all_states(): | |
| neighbors = self._neighbors_within_px(state, radius) | |
| current_ids = {n.agent_id for n in neighbors} | |
| previous_ids = self._in_range.get(state.agent_id, set()) | |
| newly_entered = current_ids - previous_ids | |
| self._in_range[state.agent_id] = current_ids | |
| if not newly_entered or retriever is None: | |
| continue | |
| loc = state.position.location_id or "campus" | |
| crowd = len(neighbors) | |
| for n in neighbors: | |
| if n.agent_id not in newly_entered: | |
| continue | |
| doing = "" | |
| if n.manager and n.manager.current_action: | |
| doing = f" ({n.manager.current_action.description})" | |
| text = ( | |
| f"At {hhmm} near {loc}, saw {n.persona_name}{doing}" | |
| + (f"; {crowd} people around." if crowd > 1 else ".") | |
| ) | |
| try: | |
| retriever.store(state.persona_name, text, kind="observation", | |
| importance=0.5, date_str=date_str) | |
| except Exception as e: | |
| logger.debug("[WorldEngine] observation store failed: %s", e) | |
| def _conversation_request( | |
| self, | |
| a: AgentRuntimeState, | |
| b: AgentRuntimeState, | |
| loc_id: str, | |
| tick: int, | |
| hhmm: str, | |
| ) -> Dict[str, Any]: | |
| """Capture the inputs needed to restart an in-flight LLM request.""" | |
| date_ctx = date_from_simulation_time(f"{self.sim_start_date} {hhmm}") | |
| memories_a, _ = self._memory_context( | |
| a.persona_name, a.persona, before_date=date_ctx, | |
| query_hint=f"{b.persona_name} {loc_id}", | |
| ) | |
| memories_b, _ = self._memory_context( | |
| b.persona_name, b.persona, before_date=date_ctx, | |
| query_hint=f"{a.persona_name} {loc_id}", | |
| ) | |
| return { | |
| "agent_a_id": a.agent_id, | |
| "agent_b_id": b.agent_id, | |
| "location_id": loc_id, | |
| "start_tick": tick, | |
| "hhmm": hhmm, | |
| "action_a": a.manager.current_action.description if a.manager and a.manager.current_action else "unknown", | |
| "action_b": b.manager.current_action.description if b.manager and b.manager.current_action else "unknown", | |
| "relationship_a_to_b": self.relationship_matrix.get(a.agent_id, b.agent_id), | |
| "relationship_b_to_a": self.relationship_matrix.get(b.agent_id, a.agent_id), | |
| "relationship_context_a": self.relationship_matrix.context(a.agent_id, b.agent_id).model_dump(), | |
| "relationship_context_b": self.relationship_matrix.context(b.agent_id, a.agent_id).model_dump(), | |
| "memories_a": memories_a, | |
| "memories_b": memories_b, | |
| "energy_a": a.energy_level, | |
| "emotion_a": a.emotion_state, | |
| "energy_b": b.energy_level, | |
| "emotion_b": b.emotion_state, | |
| } | |
| def _relationship_context_text(record: Dict[str, Any]) -> str: | |
| """Compact structured relationship context for a conversation prompt.""" | |
| if not isinstance(record, dict): | |
| return "" | |
| tags = ", ".join(str(tag) for tag in record.get("tags", [])[:4]) | |
| context = str(record.get("context", "")).strip() | |
| return "; ".join(part for part in (tags, context) if part) | |
| def _start_conversation_task( | |
| self, | |
| a: AgentRuntimeState, | |
| b: AgentRuntimeState, | |
| request: Dict[str, Any], | |
| ) -> None: | |
| """Run one durable conversation request and clear its bookkeeping.""" | |
| key = self._conversation_key(a.agent_id, b.agent_id) | |
| if key in self._conversation_tasks: | |
| return | |
| async def _runner() -> None: | |
| try: | |
| await self._run_conversation_pipeline(a, b, request) | |
| except asyncio.CancelledError: | |
| raise | |
| except Exception: | |
| logger.exception( | |
| "[WorldEngine] conversation pipeline crashed for '%s' <-> '%s'", | |
| a.persona_name, b.persona_name, | |
| ) | |
| self._end_conversation(a) | |
| self._end_conversation(b) | |
| finally: | |
| self._pending_conversations.pop(key, None) | |
| self._conversation_tasks.pop(key, None) | |
| self._conversation_tasks[key] = asyncio.create_task(_runner()) | |
| def _discard_pending_conversation(self, first_id: str, second_id: str) -> None: | |
| """Prevent a timed-out conversation from being restarted on restore.""" | |
| self._pending_conversations.pop(self._conversation_key(first_id, second_id), None) | |
| async def _check_conversations(self, tick: int, hhmm: str) -> None: | |
| """ | |
| Detect close one-to-one pairs and fire a background LLM conversation. | |
| Agents may meet while travelling: the action manager pauses their | |
| routes and resumes them from the exact path point when the chat ends. | |
| The pair-only rule, cooldown, per-day cap, and blocked-action (e.g. | |
| sleeping) guard still apply. | |
| Triggered pairs are paused immediately and resume when the background | |
| task completes. | |
| """ | |
| radius = float(_cfg.CONVERSATION_RADIUS_PX) | |
| def is_conversation_candidate(state: AgentRuntimeState) -> bool: | |
| return ( | |
| not state.paused | |
| and not (state.manager and state.manager.is_last_action) | |
| and bool(state.position.location_id) | |
| and state.energy_level >= _cfg.CONVERSATION_MIN_ENERGY | |
| and state.emotion_state >= _cfg.CONVERSATION_MIN_EMOTION | |
| ) | |
| candidates = [s for s in self.registry.all_states() if is_conversation_candidate(s)] | |
| candidate_ids = {state.agent_id for state in candidates} | |
| busy: set = set() # agents already committed to a conversation this tick | |
| for a in candidates: | |
| if a.agent_id in busy: | |
| continue | |
| neighbors = [ | |
| n for n in self._neighbors_within_px(a, radius) | |
| if n.agent_id in candidate_ids | |
| and n.agent_id not in busy | |
| ] | |
| if not neighbors: | |
| continue | |
| # Pair-only rule: a clean 1:1 encounter. More than one other agent | |
| # inside the circle is a crowd β skip (mirrors the old >2 rule). | |
| if len(neighbors) > 1: | |
| logger.debug( | |
| "[WorldEngine] %d agents within %.0fpx of '%s' β skipping (pair-only rule)", | |
| len(neighbors) + 1, radius, a.persona_name, | |
| ) | |
| continue | |
| b = neighbors[0] | |
| # Require a symmetric clean pair: b's circle must also contain only a. | |
| b_neighbors = [ | |
| n for n in self._neighbors_within_px(b, radius) | |
| if n.agent_id in candidate_ids | |
| ] | |
| if len(b_neighbors) > 1: | |
| continue | |
| # Cooldown check | |
| if not self.world.can_converse(a.agent_id, b.agent_id): | |
| continue | |
| # No back-to-back repeats: they can't talk again until at least one | |
| # of them has spoken with someone else since (avoids the two of them | |
| # looping conversations while standing together). | |
| if a.last_conversation_partner == b.agent_id or b.last_conversation_partner == a.agent_id: | |
| continue | |
| # Max conversations cap | |
| if a.conversation_count >= MAX_CONVERSATIONS_PER_AGENT: | |
| continue | |
| if b.conversation_count >= MAX_CONVERSATIONS_PER_AGENT: | |
| continue | |
| # Both must be in compatible (non-blocked, e.g. not sleeping) actions | |
| if a.manager and a.manager.current_action: | |
| if _is_action_blocked(CurrentAction( | |
| description=a.manager.current_action.description, | |
| start_tick=tick, end_tick=tick + 10, | |
| )): | |
| continue | |
| if b.manager and b.manager.current_action: | |
| if _is_action_blocked(CurrentAction( | |
| description=b.manager.current_action.description, | |
| start_tick=tick, end_tick=tick + 10, | |
| )): | |
| continue | |
| # A travelling pair may still carry different source-location ids. | |
| # Label the dialogue honestly instead of claiming they are already | |
| # inside either endpoint building. | |
| a_is_moving = bool(a.manager and a.manager.current_action and a.manager.current_action.action_type == ActionType.MOVE) | |
| b_is_moving = bool(b.manager and b.manager.current_action and b.manager.current_action.action_type == ActionType.MOVE) | |
| loc_id = ( | |
| "campus_path" | |
| if a_is_moving or b_is_moving or a.position.location_id != b.position.location_id | |
| else a.position.location_id | |
| ) or "campus" | |
| logger.info( | |
| "[WorldEngine] triggering conversation: '%s' <-> '%s' near %s (within %.0fpx)", | |
| a.persona_name, b.persona_name, loc_id, radius, | |
| ) | |
| # Capture the original request before replacing the actions with a | |
| # visible conversation state. This payload is checkpointed while | |
| # the provider call is in flight. | |
| request = self._conversation_request(a, b, loc_id, tick, hhmm) | |
| # Immediately set agents to conversation mode (frozen until bg task completes) | |
| if a.manager: | |
| a.manager.set_conversation_action(b.persona_name, tick) | |
| a.current_action = a.manager.current_action.model_dump() | |
| a.paused = True | |
| a.conversation_count += 1 | |
| a.last_conversation_partner = b.agent_id | |
| a.conversation_start_tick = tick | |
| a.active_conversation = { | |
| "partner_name": b.persona_name, | |
| "partner_id": b.agent_id, | |
| "location_id": loc_id, | |
| "started_tick": tick, | |
| "status": "generating", | |
| } | |
| if b.manager: | |
| b.manager.set_conversation_action(a.persona_name, tick) | |
| b.current_action = b.manager.current_action.model_dump() | |
| b.paused = True | |
| b.conversation_count += 1 | |
| b.last_conversation_partner = a.agent_id | |
| b.conversation_start_tick = tick | |
| b.active_conversation = { | |
| "partner_name": a.persona_name, | |
| "partner_id": a.agent_id, | |
| "location_id": loc_id, | |
| "started_tick": tick, | |
| "status": "generating", | |
| } | |
| self._pending_conversations[self._conversation_key(a.agent_id, b.agent_id)] = request | |
| self._start_conversation_task(a, b, request) | |
| busy.add(a.agent_id) | |
| busy.add(b.agent_id) | |
| logger.info( | |
| "[WorldEngine] conversation '%s' <-> '%s' started (async, waiting for LLM)", | |
| a.persona_name, b.persona_name, | |
| ) | |
| async def _run_conversation_pipeline( | |
| self, | |
| a: AgentRuntimeState, | |
| b: AgentRuntimeState, | |
| request: Dict[str, Any], | |
| ) -> None: | |
| """ | |
| Background task: generates the conversation via LLM, saves results, | |
| and resumes both agents with new remaining-day plans. | |
| Runs in a thread pool so it doesn't block the event loop. | |
| """ | |
| loop = asyncio.get_event_loop() | |
| loc_id = request["location_id"] | |
| tick = request["start_tick"] | |
| hhmm = request["hhmm"] | |
| a_action = CurrentAction( | |
| description=request["action_a"], | |
| start_tick=tick, | |
| end_tick=tick + 10, | |
| target_location_id=loc_id, | |
| ) | |
| b_action = CurrentAction( | |
| description=request["action_b"], | |
| start_tick=tick, | |
| end_tick=tick + 10, | |
| target_location_id=loc_id, | |
| ) | |
| # Step 1: Generate conversation (blocking LLM call β thread pool) | |
| conv_result = await loop.run_in_executor( | |
| None, | |
| lambda: generate_conversation( | |
| a.agent_id, b.agent_id, | |
| a.persona, b.persona, | |
| a.day_plan, b.day_plan, | |
| a_action, b_action, | |
| request["relationship_a_to_b"], request["relationship_b_to_a"], | |
| loc_id, hhmm, | |
| request["memories_a"], request["memories_b"], | |
| energy_a=request["energy_a"], emotion_a=request["emotion_a"], | |
| energy_b=request["energy_b"], emotion_b=request["emotion_b"], | |
| relationship_context_a=self._relationship_context_text(request.get("relationship_context_a", {})), | |
| relationship_context_b=self._relationship_context_text(request.get("relationship_context_b", {})), | |
| ), | |
| ) | |
| if conv_result is None: | |
| logger.warning( | |
| "[WorldEngine] conversation LLM failed for '%s' <-> '%s' β unpausing", | |
| a.persona_name, b.persona_name, | |
| ) | |
| self._end_conversation(a) | |
| self._end_conversation(b) | |
| return | |
| # If the conversation was already ended by timeout while the LLM was | |
| # running, discard the result and return silently. | |
| if not a.paused or not b.paused: | |
| logger.info( | |
| "[WorldEngine] conversation '%s' <-> '%s' ended by timeout while LLM ran β discarding result", | |
| a.persona_name, b.persona_name, | |
| ) | |
| return | |
| # Step 2: Save conversation to Short_term | |
| date_str = date_from_simulation_time(f"{self.sim_start_date} {hhmm}") | |
| conv_entry = { | |
| "participants": [a.persona_name, b.persona_name], | |
| "messages": [ | |
| {"speaker": m.speaker, "text": m.text} | |
| for m in conv_result.messages | |
| ], | |
| "summary": conv_result.summary, | |
| } | |
| append_conversation(a.persona_name, date_str, conv_entry) | |
| append_conversation(b.persona_name, date_str, conv_entry) | |
| # Add to the rolling UI feed (last N conversations across the campus). | |
| self._recent_convs.appendleft({ | |
| "time": hhmm, | |
| "participants": [a.persona_name, b.persona_name], | |
| "summary": conv_result.summary, | |
| "sentiment": conv_result.sentiment, | |
| "location": loc_id, | |
| }) | |
| # Step 3: Store conversation messages on agent states (for frontend snapshot) | |
| messages = conv_entry["messages"] | |
| a.active_conversation = { | |
| "partner_name": b.persona_name, | |
| "partner_id": b.agent_id, | |
| "messages": messages, | |
| "duration_minutes": conv_result.duration_minutes, | |
| "started_tick": self.world.tick, | |
| "ends_at_tick": self.world.tick + conv_result.duration_minutes, | |
| "status": "active", | |
| } | |
| b.active_conversation = { | |
| "partner_name": a.persona_name, | |
| "partner_id": a.agent_id, | |
| "messages": messages, | |
| "duration_minutes": conv_result.duration_minutes, | |
| "started_tick": self.world.tick, | |
| "ends_at_tick": self.world.tick + conv_result.duration_minutes, | |
| "status": "active", | |
| } | |
| # The action placeholder is intentionally open-ended while the LLM is | |
| # generating. Once a duration exists, make the UI/action snapshot tell | |
| # the same truth as ``ends_at_tick`` instead of displaying 23:59. | |
| conversation_end = self._minutes_to_hhmm((self.world.tick + conv_result.duration_minutes) % (24 * 60)) | |
| for state in (a, b): | |
| if state.manager and state.manager.current_action: | |
| state.manager.current_action.end_time = conversation_end | |
| state.current_action = state.manager.current_action.model_dump() | |
| # Step 4: Update relationship matrix | |
| self.relationship_matrix.update(a.agent_id, b.agent_id, conv_result.relationship_delta) | |
| self.relationship_matrix.update(b.agent_id, a.agent_id, conv_result.relationship_delta) | |
| self.relationship_matrix.save() | |
| # Conversations affect the people having them, not only their stored | |
| # relationship score. The LLM decides each participant's net energy | |
| # and mood change for the chat; only a 0..1 safety clamp is applied. | |
| for state, energy_delta, emotion_delta in ( | |
| (a, conv_result.energy_delta_a, conv_result.emotion_delta_a), | |
| (b, conv_result.energy_delta_b, conv_result.emotion_delta_b), | |
| ): | |
| state.energy_level = max(0.0, min(1.0, state.energy_level + float(energy_delta or 0.0))) | |
| state.emotion_state = max(0.0, min(1.0, state.emotion_state + float(emotion_delta or 0.0))) | |
| logger.info( | |
| "[WorldEngine] conversation '%s' <-> '%s' active until tick %d", | |
| a.persona_name, b.persona_name, self.world.tick + conv_result.duration_minutes, | |
| ) | |
| return | |
| # Step 5: Generate remaining-day plans (blocking β thread pool) | |
| from src.agents.day_planner import run as day_planner_run | |
| def _run_planner(state: AgentRuntimeState) -> list: | |
| mem, yday = self._memory_context( | |
| state.persona_name, state.persona, | |
| before_date=date_from_simulation_time(f"{self.sim_start_date} {hhmm}"), | |
| ) | |
| proxy = SimpleNamespace( | |
| persona=state.persona, | |
| relevant_memories=mem, | |
| yesterday_summary=yday, | |
| ) | |
| plan_result = day_planner_run( | |
| proxy, | |
| { | |
| "current_time": f"{self.sim_start_date} {hhmm}", | |
| "places": None, | |
| "persona_name": state.persona_name, | |
| "mode": "remaining", | |
| "current_location_id": state.position.location_id, | |
| "energy_level": state.energy_level, | |
| "emotion_state": state.emotion_state, | |
| }, | |
| ) | |
| return plan_result.get("day_plan", []) | |
| # Step 5: Resume both agents. Replanning is disabled by default β kept | |
| # behind a flag for future re-enablement. | |
| _ENABLE_POST_CONVERSATION_REPLAN = False | |
| for state in (a, b): | |
| try: | |
| if _ENABLE_POST_CONVERSATION_REPLAN: | |
| should_replan = bool(getattr(conv_result, "should_replan", False)) | |
| try: | |
| from src.core.budget import GOVERNOR | |
| except Exception: | |
| GOVERNOR = None | |
| allow_replan = ( | |
| should_replan | |
| and state.replan_count < _cfg.MAX_REPLANS_PER_AGENT_PER_DAY | |
| and (GOVERNOR is None or GOVERNOR.can_afford("replan", cost=4)) | |
| ) | |
| if allow_replan: | |
| new_plan = await loop.run_in_executor(None, _run_planner, state) | |
| if new_plan and state.manager: | |
| state.manager.resume_from_conversation(new_plan) | |
| state.day_plan = new_plan | |
| state.replan_count += 1 | |
| logger.info( | |
| "[WorldEngine] post-conversation replan for '%s' (%d actions): %s", | |
| state.persona_name, len(new_plan), | |
| getattr(conv_result, "plan_change", "") or "", | |
| ) | |
| elif state.manager: | |
| state.manager.resume_from_conversation(state.day_plan) | |
| else: | |
| if state.manager: | |
| state.manager.resume_from_conversation(state.day_plan) | |
| else: | |
| # No replan β resume the day already planned (0 LLM). | |
| if state.manager: | |
| state.manager.resume_from_conversation(state.day_plan) | |
| state.active_conversation = None | |
| except Exception as e: | |
| logger.error( | |
| "[WorldEngine] resume after conversation failed for '%s': %s", | |
| state.persona_name, e, | |
| ) | |
| if state.manager: | |
| try: | |
| state.manager.resume_from_conversation(state.day_plan) | |
| except Exception: | |
| pass | |
| # Step 6: Unpause agents | |
| a.paused = False | |
| b.paused = False | |
| logger.info( | |
| "[WorldEngine] conversation '%s' <-> '%s' complete β agents resumed", | |
| a.persona_name, b.persona_name, | |
| ) | |
| async def _check_last_action_triggers(self, tick: int, hhmm: str) -> None: | |
| """ | |
| When an agent enters their last action of the day plan: | |
| Archive durable day memories to Qdrant. | |
| """ | |
| for state in self.registry.all_states(): | |
| manager = state.manager | |
| if manager is None: | |
| continue | |
| if not manager._entered_last_action: | |
| continue | |
| # Already triggered? Use a flag on the registry state | |
| if state.day_archived: | |
| continue | |
| logger.info( | |
| "[WorldEngine] '%s' entered last action β archiving day", | |
| state.persona_name, | |
| ) | |
| # Archive current day | |
| date_str = date_from_simulation_time(f"{self.sim_start_date} {hhmm}") | |
| try: | |
| archive_result = await archive_to_long_term(state.persona_name, date_str) | |
| logger.info( | |
| "[WorldEngine] archive: %s", archive_result.get("summary", "")[:80], | |
| ) | |
| state.day_archived = True | |
| except Exception as e: | |
| logger.error("[WorldEngine] archive failed for '%s': %s", state.persona_name, e) | |
| def _sync_to_world_state(self) -> None: | |
| """Mirror the agent registry into WorldState for frontend queries.""" | |
| for state in self.registry.all_states(): | |
| agent_id = state.agent_id | |
| try: | |
| ws_agent = self.world.get_agent(agent_id) | |
| except KeyError: | |
| continue | |
| # Update position | |
| if state.position != ws_agent.position: | |
| self.world.move_agent(agent_id, state.position) | |
| # Update current action | |
| if state.manager and state.manager.current_action: | |
| action = state.manager.current_action | |
| if state.active_conversation: | |
| start_tick = state.active_conversation.get( | |
| "started_tick", state.conversation_start_tick, | |
| ) | |
| end_tick = state.active_conversation.get("ends_at_tick", start_tick + 30) | |
| else: | |
| day_start_tick = self.world.tick - (self.world.tick % (24 * 60)) | |
| start_minute = self._hhmm_to_minutes(action.start_time) | |
| end_minute = self._hhmm_to_minutes(action.end_time) | |
| start_tick = day_start_tick + start_minute | |
| end_tick = day_start_tick + end_minute | |
| if end_minute <= start_minute: | |
| end_tick += 24 * 60 | |
| ca = CurrentAction( | |
| description=action.description, | |
| start_tick=start_tick, | |
| end_tick=end_tick, | |
| target_location_id=action.location_id or state.position.location_id, | |
| ) | |
| self.world.set_agent_action(agent_id, ca) | |
| else: | |
| self.world.clear_agent_action(agent_id) | |
| async def _drain_conversations_for_handoff(self, timeout_seconds: float) -> int: | |
| """Give active conversations a bounded chance to persist their results.""" | |
| tasks = list(self._conversation_tasks.values()) | |
| if not tasks: | |
| return 0 | |
| logger.info( | |
| "[WorldEngine] day handoff: waiting up to %.0fs for %d conversation(s)", | |
| timeout_seconds, len(tasks), | |
| ) | |
| _done, pending = await asyncio.wait(tasks, timeout=max(0.0, timeout_seconds)) | |
| if not pending: | |
| return 0 | |
| logger.warning( | |
| "[WorldEngine] day handoff timed out with %d conversation(s); cancelling them", | |
| len(pending), | |
| ) | |
| timed_out_requests = list(self._pending_conversations.values()) | |
| for task in pending: | |
| task.cancel() | |
| await asyncio.gather(*pending, return_exceptions=True) | |
| for request in timed_out_requests: | |
| try: | |
| self._end_conversation(self.registry.get(request["agent_a_id"])) | |
| self._end_conversation(self.registry.get(request["agent_b_id"])) | |
| except KeyError: | |
| pass | |
| self._pending_conversations.pop( | |
| self._conversation_key(request["agent_a_id"], request["agent_b_id"]), None, | |
| ) | |
| return len(pending) | |
| async def _discard_pending_decisions_for_handoff(self) -> int: | |
| """Prevent a prior day's advisory decision from affecting a new day. | |
| Decision tasks deliberately run in the background so provider latency | |
| cannot stall a tick. Their observation and plan inputs belong to one | |
| simulated day, however, so a result that arrives after midnight must | |
| never be consumed as a next-day replan. | |
| """ | |
| tasks = list(self._decision_tasks.values()) | |
| if not tasks: | |
| return 0 | |
| pending = [task for task in tasks if not task.done()] | |
| for task in pending: | |
| task.cancel() | |
| # Consume every outcome before releasing the references. Cancelling a | |
| # to_thread wrapper cannot stop its SDK call, but it does ensure its | |
| # eventual result cannot mutate this engine on the following day. | |
| await asyncio.gather(*tasks, return_exceptions=True) | |
| self._decision_tasks.clear() | |
| logger.info( | |
| "[WorldEngine] discarded %d prior-day decision task(s) at handoff", | |
| len(tasks), | |
| ) | |
| return len(tasks) | |
| async def handoff_to_next_day( | |
| self, | |
| next_date: str, | |
| conversation_timeout_seconds: Optional[float] = None, | |
| ) -> Dict[str, Any]: | |
| """Finish one calendar day without replacing the engine or agents. | |
| Conversation output is allowed to settle first, then every agent's | |
| completed day is compressed into long-term memory. New plans are | |
| generated with the ending action, location, and wellbeing as explicit | |
| continuity context; positions, brains, relationships, and action | |
| managers remain owned by this same engine instance. | |
| """ | |
| old_date = self.sim_start_date | |
| timeout = ( | |
| _cfg.DAY_HANDOFF_CONVERSATION_TIMEOUT_SECONDS | |
| if conversation_timeout_seconds is None else conversation_timeout_seconds | |
| ) | |
| cancelled = await self._drain_conversations_for_handoff(timeout) | |
| await self._discard_pending_decisions_for_handoff() | |
| # Preserve the full replay before resetting the per-day action log. | |
| # History/checkpoint storage is observability, not a reason to stop a | |
| # living simulation if a file is temporarily unavailable. | |
| handoff_warnings: list[str] = [] | |
| try: | |
| save_history(self.world, old_date) | |
| except Exception as exc: | |
| logger.exception("[WorldEngine] unable to save replay for %s", old_date) | |
| handoff_warnings.append(f"history save failed: {exc}") | |
| async def _archive_and_clear(state: AgentRuntimeState) -> bool: | |
| try: | |
| # Re-index even when we made the early final-activity archive: | |
| # conversations and actions completed afterward must be part of | |
| # the authoritative end-of-day Qdrant snapshot. | |
| await archive_to_long_term(state.persona_name, old_date) | |
| state.day_archived = True | |
| clear_short_term_data(state.persona_name, old_date) | |
| return True | |
| except Exception as exc: | |
| logger.error( | |
| "[WorldEngine] final archive failed for '%s': %s", | |
| state.persona_name, exc, | |
| ) | |
| return False | |
| archive_results = await asyncio.gather( | |
| *[_archive_and_clear(state) for state in self.registry.all_states()], | |
| return_exceptions=False, | |
| ) | |
| from src.agents.day_planner import run as day_planner_run | |
| def _handoff_context(state: AgentRuntimeState) -> str: | |
| action = state.manager.current_action if state.manager else None | |
| action_text = action.description if action else "no active action" | |
| location = state.position.location_id or "their current campus position" | |
| return ( | |
| f"The previous day ended while the agent was {action_text} at {location}. " | |
| f"Energy is {state.energy_level:.2f}/1.0 and emotion is " | |
| f"{state.emotion_state:.2f}/1.0. Continue naturally from this " | |
| "physical and emotional state; do not abruptly relocate them. " | |
| "When assigning energy_change/emotion_change for the new day, " | |
| "keep the cumulative energy and mood totals between 0.0 and " | |
| "1.0 at all times." | |
| ) | |
| async def _plan_next_day(state: AgentRuntimeState) -> tuple[AgentRuntimeState, list]: | |
| memories, yesterday = self._memory_context( | |
| state.persona_name, state.persona, before_date=next_date, | |
| ) | |
| proxy = SimpleNamespace( | |
| persona=state.persona, | |
| relevant_memories=memories, | |
| yesterday_summary=yesterday, | |
| ) | |
| try: | |
| result = await asyncio.to_thread( | |
| day_planner_run, | |
| proxy, | |
| { | |
| "current_time": f"{next_date} 00:00", | |
| "places": None, | |
| "persona_name": state.persona_name, | |
| "mode": "next_day", | |
| "current_location_id": state.position.location_id, | |
| "handoff_context": _handoff_context(state), | |
| "energy_level": state.energy_level, | |
| "emotion_state": state.emotion_state, | |
| "upcoming_events": self.event_manager.snapshot(next_date, "00:00").get("upcoming", []), | |
| }, | |
| ) | |
| plan = result.get("day_plan", []) | |
| if plan: | |
| return state, plan | |
| except Exception as exc: | |
| logger.error( | |
| "[WorldEngine] next-day plan failed for '%s': %s", | |
| state.persona_name, exc, | |
| ) | |
| # A failed planner must not strand the agent. Reusing the previous | |
| # schedule is the safest fallback because it preserves continuity. | |
| return state, state.day_plan | |
| # Keep day-handoff planner traffic serial. A single key ring is shared | |
| # across agents, and concurrent full pipelines caused a burst of | |
| # provider failures precisely when a new day was being installed. | |
| planned = [] | |
| for state in self.registry.all_states(): | |
| planned.append(await _plan_next_day(state)) | |
| self.sim_start_date = next_date | |
| self.sim_start_hhmm = "00:00" | |
| self._day_index += 1 | |
| self._recent_convs.clear() | |
| self._in_range.clear() | |
| self._last_decision_tick.clear() | |
| self._downtime_replan_tick.clear() | |
| self._last_obs.clear() | |
| self._tick_observations.clear() | |
| self._applied_event_effects.clear() | |
| self.world.history.clear() | |
| for state, plan in planned: | |
| state.day_plan = plan | |
| state.day_archived = False | |
| state.conversation_count = 0 | |
| state.replan_count = 0 | |
| state.last_conversation_partner = None | |
| state.conversation_start_tick = 0 | |
| state.active_conversation = None | |
| state.paused = False | |
| if state.manager: | |
| state.manager.begin_new_day(plan) | |
| action = state.manager.tick(self.world.tick) | |
| state.position = state.manager.position | |
| state.current_action = action.model_dump() if action else None | |
| try: | |
| self._apply_event_opportunities(next_date) | |
| except Exception as exc: | |
| logger.exception("[WorldEngine] unable to apply events for %s", next_date) | |
| handoff_warnings.append(f"event overlay failed: {exc}") | |
| self._sync_to_world_state() | |
| try: | |
| save_checkpoint( | |
| self.world, | |
| self.registry, | |
| self.world.tick, | |
| engine_state=self.checkpoint_state(), | |
| ) | |
| prune_checkpoints(keep_last=KEEP_LAST) | |
| except Exception as exc: | |
| logger.exception("[WorldEngine] unable to checkpoint handoff at tick %d", self.world.tick) | |
| handoff_warnings.append(f"handoff checkpoint failed: {exc}") | |
| snapshot = self._frontend_snapshot(self.world.tick, "00:00") | |
| snapshot.update({ | |
| "type": "day_initialized", | |
| "phase": "initialized", | |
| "date": next_date, | |
| "cancelled_conversations": cancelled, | |
| "archived_agents": sum(bool(result) for result in archive_results), | |
| "warnings": handoff_warnings, | |
| "recent_conversations": [], | |
| }) | |
| return snapshot | |
| # ------------------------------------------------------------------ # | |
| # Main run loop | |
| # ------------------------------------------------------------------ # | |
| async def run( | |
| self, | |
| max_tick: int = 1440, | |
| on_tick: Optional[callable] = None, | |
| tick_speed: Optional[float] = None, | |
| tick_lock: Optional[asyncio.Lock] = None, | |
| ) -> None: | |
| """Run simulation until world.tick reaches max_tick (absolute). | |
| If ``on_tick`` is provided, it is called with each tick's snapshot dict | |
| (after the tick is processed). This is used by Odin.py to broadcast | |
| state to WebSocket clients. | |
| ``tick_speed`` is a speed multiplier (1.0 = configured real-time, 2.0 = | |
| double speed). When None, the value from config (env/CLI) is used. | |
| """ | |
| dynamic_speed = tick_speed is None | |
| initial_speed = _cfg.TICK_SPEED if dynamic_speed else tick_speed | |
| effective_sleep = _cfg.REAL_SECONDS_PER_TICK / max(initial_speed, 1e-9) | |
| logger.info("[WorldEngine] ========== STARTING SIMULATION ==========") | |
| logger.info( | |
| "[WorldEngine] %d agents, max tick %d (current=%d), %.1f real sec/tick (speed=%.1fx)", | |
| len(self.registry), max_tick, self.world.tick, effective_sleep, initial_speed, | |
| ) | |
| while self.world.tick < max_tick: | |
| if tick_lock is None: | |
| result = await self.run_tick() | |
| else: | |
| async with tick_lock: | |
| result = await self.run_tick() | |
| if not result: | |
| logger.info("[WorldEngine] simulation complete at tick %d", self.world.tick) | |
| break | |
| # Invoke callback if provided | |
| if on_tick is not None: | |
| try: | |
| await on_tick(result) | |
| except Exception: | |
| logger.exception("[WorldEngine] on_tick callback failed; simulation will continue") | |
| # Log periodic status | |
| elapsed = self.world.tick | |
| if elapsed % 60 == 0: | |
| active = sum( | |
| 1 for s in self.registry.all_states() | |
| if s.current_action is not None | |
| ) | |
| paused = sum(1 for s in self.registry.all_states() if s.paused) | |
| hhmm = self._minutes_to_hhmm(self.world.tick % (24 * 60)) | |
| logger.info( | |
| "[WorldEngine] tick=%d time=%s agents=%d active=%d paused=%d", | |
| elapsed, hhmm, len(self.registry), active, paused, | |
| ) | |
| # Read the mutable runtime speed on every iteration so UI controls | |
| # take effect without restarting a day-long run. | |
| active_speed = _cfg.TICK_SPEED if dynamic_speed else tick_speed | |
| await asyncio.sleep(_cfg.REAL_SECONDS_PER_TICK / max(active_speed, 1e-9)) | |
| logger.info("[WorldEngine] ========== SIMULATION ENDED ==========") | |
| if on_tick is not None: | |
| try: | |
| await on_tick({"type": "simulation_ended", "tick": self.world.tick}) | |
| except Exception: | |
| logger.exception("[WorldEngine] final on_tick callback failed") | |
| # ------------------------------------------------------------------ # | |
| # Helpers | |
| # ------------------------------------------------------------------ # | |
| def _minutes_to_hhmm(minutes: int) -> str: | |
| h, m = divmod(minutes, 60) | |
| return f"{h:02d}:{m:02d}" | |
| def _hhmm_to_minutes(hhmm: str) -> int: | |
| h, m = hhmm.split(":") | |
| return int(h) * 60 + int(m) | |
| # ------------------------------------------------------------------ # | |
| # Standalone CLI entry point | |
| # ------------------------------------------------------------------ # | |
| if __name__ == "__main__": | |
| import argparse | |
| from src.core.log import setup_logging | |
| setup_logging(run_id="world_engine", console=True) | |
| parser = argparse.ArgumentParser(description="WorldEngine β multi-agent simulation") | |
| parser.add_argument( | |
| "--days", type=int, default=1, | |
| help="Number of simulation days to run (default: 1)", | |
| ) | |
| parser.add_argument( | |
| "--start-date", default=_cfg.SIM_START_DATE, | |
| help="Simulation start date (default: from SIM_START_DATE / 2026-07-03)", | |
| ) | |
| parser.add_argument( | |
| "--start-time", default="00:00", | |
| help="Simulation start time HH:MM (default: 00:00)", | |
| ) | |
| parser.add_argument( | |
| "--resume", nargs="?", const=None, default=False, | |
| help="Resume from checkpoint. Pass a tick number, or no arg to prompt.", | |
| ) | |
| # Register all simulation toggles (--tick-speed, --reflex-llm, | |
| # --perception-radius-px, --real-seconds-per-sim-minute, etc.) | |
| _cfg.add_cli_arguments(parser) | |
| args = parser.parse_args() | |
| # Apply CLI overrides onto config (CLI > .env > built-in defaults). | |
| _cfg.apply_overrides(**_cfg.overrides_from_args(args)) | |
| logger.info("[WorldEngine] active settings:\n%s", _cfg.describe_settings()) | |
| async def _main() -> None: | |
| if args.resume is not False: | |
| ticks = list_checkpoints() | |
| if not ticks: | |
| logger.error("[WorldEngine] no checkpoints found β cannot resume") | |
| return | |
| if args.resume is None: | |
| # --resume with no value β prompt | |
| print(f"\nAvailable checkpoints ({len(ticks)} total, showing last 20):") | |
| for t in ticks[-20:]: | |
| print(f" {t:>5}") | |
| print() | |
| while True: | |
| tick_str = input("Resume from which tick? (or 'new' to start fresh): ").strip() | |
| if tick_str.lower() == "new": | |
| break | |
| try: | |
| resume_tick = int(tick_str) | |
| if resume_tick in ticks: | |
| break | |
| print(f"Tick {resume_tick} not found. Try again.") | |
| except ValueError: | |
| print("Invalid input. Enter a tick number or 'new'.") | |
| if tick_str.lower() == "new": | |
| pass # fall through to fresh start | |
| else: | |
| engine = WorldEngine( | |
| sim_start_date=args.start_date, | |
| sim_start_hhmm=args.start_time, | |
| ) | |
| world, registry, checkpoint_state = load_checkpoint( | |
| resume_tick, engine.resolver, return_metadata=True, | |
| ) | |
| engine.world = world | |
| engine.registry = registry | |
| engine.restore_checkpoint_state(checkpoint_state) | |
| await engine.resume_pending_conversations() | |
| logger.info( | |
| "[WorldEngine] resumed from tick %d β %d agents", | |
| resume_tick, len(registry), | |
| ) | |
| day_end_tick = ((engine.world.tick // (24 * 60)) + 1) * (24 * 60) | |
| await engine.run(max_tick=day_end_tick) | |
| save_history(engine.world, engine.sim_start_date) | |
| return | |
| else: | |
| # --resume <tick> | |
| resume_tick = int(args.resume) | |
| if resume_tick not in ticks: | |
| logger.error("[WorldEngine] tick %d not found in checkpoints", resume_tick) | |
| return | |
| engine = WorldEngine( | |
| sim_start_date=args.start_date, | |
| sim_start_hhmm=args.start_time, | |
| ) | |
| world, registry, checkpoint_state = load_checkpoint( | |
| resume_tick, engine.resolver, return_metadata=True, | |
| ) | |
| engine.world = world | |
| engine.registry = registry | |
| engine.restore_checkpoint_state(checkpoint_state) | |
| await engine.resume_pending_conversations() | |
| logger.info( | |
| "[WorldEngine] resumed from tick %d β %d agents", | |
| resume_tick, len(registry), | |
| ) | |
| day_end_tick = ((engine.world.tick // (24 * 60)) + 1) * (24 * 60) | |
| await engine.run(max_tick=day_end_tick) | |
| save_history(engine.world, engine.sim_start_date) | |
| return | |
| # Fresh start | |
| engine = WorldEngine( | |
| sim_start_date=args.start_date, | |
| sim_start_hhmm=args.start_time, | |
| ) | |
| await engine.initialize() | |
| for day in range(args.days): | |
| logger.info("[WorldEngine] --- Day %d ---", day + 1) | |
| day_end_tick = ((engine.world.tick // (24 * 60)) + 1) * (24 * 60) | |
| await engine.run(max_tick=day_end_tick) | |
| if day < args.days - 1: | |
| next_date = ( | |
| datetime.fromisoformat(engine.sim_start_date) + timedelta(days=1) | |
| ).strftime("%Y-%m-%d") | |
| await engine.handoff_to_next_day(next_date) | |
| else: | |
| save_history(engine.world, engine.sim_start_date) | |
| asyncio.run(_main()) | |