diff --git a/backend/Odin.py b/backend/Odin.py index beb8538b0a8a441b2f528422417236db2e98e771..ab04c92e6d7dbfbaf3527605a9aa5daf792080c6 100644 --- a/backend/Odin.py +++ b/backend/Odin.py @@ -1,13 +1,8 @@ -"""Odin — the web server and process entry point for Valhalla. - -Serves the React dashboard, exposes REST + WebSocket endpoints (sim -control, roster, auth, pathfinding), and hosts the WorldEngine as a -background asyncio task with per-tick snapshot broadcasts. - -Architecture: the only entry point that runs the full system; depends on -src.core.world_engine, src.auth, pathfinder, and the frontend build. -Design: all sim-control endpoints are auth-gated; roster edits are only -allowed while the simulation is stopped. +""" +FastAPI web server for the Valhalla agent map. +Serves the frontend (React SPA), exposes REST + WebSocket +endpoints for pathfinding (/api/path, /api/path/stream, /ws), and +streams simulation state via /ws/sim for live agent visualization. """ import os @@ -235,30 +230,6 @@ async def require_admin(authorization: str = Header(None)): return user -# --------------------------------------------------------------------------- -# Admin log relay — live view of backend logs without the Space console. -# The relay mirror lives in src/core/log_relay.py and is installed by -# src/core/log.py setup_logging(); only these two endpoints expose it. -# --------------------------------------------------------------------------- - -@app.get("/api/logs") -async def get_log_lines( - since: int = Query(default=0, ge=0), - _user: dict = Depends(require_admin), -): - """Return relayed log lines newer than `since`; `next` is the poll cursor.""" - from src.core.log_relay import relay_lines - return relay_lines(since) - - -@app.post("/api/logs/clear") -async def clear_log_lines(_user: dict = Depends(require_admin)): - """Clear the in-memory relay buffer. The live log file is left intact.""" - from src.core.log_relay import clear_relay - clear_relay() - return {"ok": True} - - def _print_agent_plans(engine): """Print each agent's full action plan to the CLI.""" from src.core.agent_registry import AgentRuntimeState @@ -717,8 +688,6 @@ async def add_agent(request: AddAgentInput, _user: dict = Depends(require_admin) "current_time": f"{current_date} {current_hhmm}", "places": None, "persona_name": generated.name, "mode": "remaining" if engine.world.tick else "full_day", "current_location_id": generated.hostel, "upcoming_events": [], - "energy_level": engine._energy_baseline(persona), - "emotion_state": engine._emotion_baseline(persona), })) day_plan = plan_result.get("day_plan", []) if not day_plan: @@ -732,7 +701,6 @@ async def add_agent(request: AddAgentInput, _user: dict = Depends(require_admin) engine.registry.register(AgentRuntimeState( agent_id=agent_id, persona=persona, persona_name=generated.name, manager=manager, position=position, day_plan=day_plan, - energy_level=engine._energy_baseline(persona), emotion_state=engine._emotion_baseline(persona), emotion_baseline=engine._emotion_baseline(persona), )) engine.world.register_agent(agent_id, position) diff --git a/backend/Pathfinder_test.py b/backend/Pathfinder_test.py index 6094763be4c978b52a02f0f47824c66eb16d5406..55fa7f473c79d16848e2163c9409cbbbc92cf071 100644 --- a/backend/Pathfinder_test.py +++ b/backend/Pathfinder_test.py @@ -1,11 +1,7 @@ -"""Pathfinder_test — interactive CLI tool for verifying pixel pathfinding. - -Loads the campus map, computes a BFS path between two pixel coordinates, -and renders it in a matplotlib window. - -Architecture: a developer tool, not part of the simulation runtime; it -exercises backend/pathfinder.py against the real walkability map. -Design: keeps the visual debugging loop out of the server code. +""" +CLI tool for pixel-level pathfinding on the Valhalla map. +Loads map.png, computes the shortest path between two pixel coordinates +via BFS, and displays the result with start/end markers in a matplotlib window. """ import sys diff --git a/backend/data/environment/relationship_matrix.json b/backend/data/environment/relationship_matrix.json index 186a47a60dccd5b66bef7d0913da50e5cc629789..211731bfc7a7f7944d09c1283f81cb361206e53a 100644 --- a/backend/data/environment/relationship_matrix.json +++ b/backend/data/environment/relationship_matrix.json @@ -1,6 +1,94 @@ { "schema_version": 2, "relationships": { + "amitabh->ansh_batra": { + "score": 0.55, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Amitabh and Ansh Batra know each other through campus routines. Amitabh appreciates ansh_batra's enthusiastic plans, but the friendship is still finding its rhythm." + }, + "amitabh->anubhav_prasad": { + "score": 0.49, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Amitabh and Anubhav Prasad know each other through campus routines. Amitabh appreciates anubhav_prasad's calm listening, but the friendship is still finding its rhythm." + }, + "amitabh->ghanisht_kaushal": { + "score": 0.46, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Amitabh and Ghanisht Kaushal know each other through campus routines. Amitabh appreciates ghanisht_kaushal's reliable follow-through, but the friendship is still finding its rhythm." + }, + "amitabh->gurnoor_singh": { + "score": 0.56, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Amitabh and Gurnoor Singh know each other through campus routines. Amitabh appreciates gurnoor_singh's big social energy, but the friendship is still finding its rhythm." + }, + "amitabh->jarvis": { + "score": 0.63, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Amitabh and Jarvis know each other through campus routines. Amitabh appreciates jarvis's steady conversation, but the friendship is still finding its rhythm." + }, + "amitabh->lavanya_sharma": { + "score": 0.5700000000000001, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Amitabh and Lavanya Sharma know each other through campus routines. Amitabh appreciates lavanya_sharma's direct feedback, but the friendship is still finding its rhythm." + }, + "amitabh->parv_singla": { + "score": 0.54, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Amitabh and Parv Singla know each other through campus routines. Amitabh appreciates parv_singla's impulsive invitations, but the friendship is still finding its rhythm." + }, + "amitabh->riya_murarka": { + "score": 0.3, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Amitabh and Riya Murarka know each other through campus routines. Amitabh appreciates riya_murarka's clear boundaries, but the friendship is still finding its rhythm." + }, + "amitabh->saksham": { + "score": 0.53, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Amitabh and Saksham know each other through campus routines. Amitabh appreciates saksham's dry humour, but the friendship is still finding its rhythm." + }, + "amitabh->tanishq": { + "score": 0.31, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Amitabh and Tanishq know each other through campus routines. Amitabh appreciates tanishq's quiet, improving confidence, but the friendship is still finding its rhythm." + }, + "ansh_batra->amitabh": { + "score": 0.55, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Ansh Batra and Amitabh know each other through football and visual storytelling. Ansh Batra appreciates amitabh's steady conversation, but the friendship is still finding its rhythm." + }, "ansh_batra->anubhav_prasad": { "score": 0.41, "tags": [ @@ -23,7 +111,14 @@ "campus-acquaintance", "party-bros" ], - "context": "Ansh and Gurnoor's parties always end in legendary stories — including that one time they both woke up in the same bed after a dare and just laughed it off... mostly." + "context": "Ansh and Gurnoor's parties always end in legendary stories \u2014 including that one time they both woke up in the same bed after a dare and just laughed it off... mostly." + }, + "ansh_batra->jarvis": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Ansh Batra has only recently met Jarvis; the connection is open but untested." }, "ansh_batra->lavanya_sharma": { "score": 0.44, @@ -65,6 +160,14 @@ ], "context": "Ansh loves hyping up Tanishq's growing confidence, especially when Tanishq blushes at compliments. It's dangerously cute." }, + "anubhav_prasad->amitabh": { + "score": 0.49, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Anubhav Prasad and Amitabh know each other through co-op games and late-night chai. Anubhav Prasad appreciates amitabh's steady conversation, but the friendship is still finding its rhythm." + }, "anubhav_prasad->ansh_batra": { "score": 0.41, "tags": [ @@ -89,6 +192,13 @@ ], "context": "Gurnoor drags Anubhav to parties and Anubhav somehow ends up being the responsible one... until that one time he wasn't." }, + "anubhav_prasad->jarvis": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Anubhav Prasad has only recently met Jarvis; the connection is open but untested." + }, "anubhav_prasad->lavanya_sharma": { "score": 0.73, "tags": [ @@ -129,6 +239,14 @@ ], "context": "Anubhav is quietly supportive of Tanishq's confidence journey. Their interactions are soft and full of unspoken understanding." }, + "ghanisht_kaushal->amitabh": { + "score": 0.46, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Ghanisht Kaushal and Amitabh know each other through badminton and blunt movie opinions. Ghanisht Kaushal appreciates amitabh's steady conversation, but the friendship is still finding its rhythm." + }, "ghanisht_kaushal->ansh_batra": { "score": 0.66, "tags": [ @@ -153,6 +271,13 @@ ], "context": "Gurnoor's nonstop social battery clashes with Ghanisht's chill, but the rare nights they sync are chaotic gold." }, + "ghanisht_kaushal->jarvis": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Ghanisht Kaushal has only recently met Jarvis; the connection is open but untested." + }, "ghanisht_kaushal->lavanya_sharma": { "score": 0.36, "tags": [ @@ -193,6 +318,14 @@ ], "context": "Ghanisht quietly roots for Tanishq's confidence glow-up and enjoys watching him get bolder." }, + "gurnoor_singh->amitabh": { + "score": 0.56, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Gurnoor Singh and Amitabh know each other through photography and road-trip playlists. Gurnoor Singh appreciates amitabh's steady conversation, but the friendship is still finding its rhythm." + }, "gurnoor_singh->ansh_batra": { "score": 0.53, "tags": [ @@ -217,6 +350,13 @@ ], "context": "Gurnoor respects Ghanisht's reliability but wishes he'd loosen up more... preferably with him." }, + "gurnoor_singh->jarvis": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Gurnoor Singh has only recently met Jarvis; the connection is open but untested." + }, "gurnoor_singh->lavanya_sharma": { "score": 0.5, "tags": [ @@ -257,6 +397,85 @@ ], "context": "Gurnoor loves seeing Tanishq come out of his shell and occasionally flirts just to see him blush." }, + "jarvis->amitabh": { + "score": 0.63, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Jarvis and Amitabh know each other through campus routines. Jarvis appreciates amitabh's steady conversation, but the friendship is still finding its rhythm." + }, + "jarvis->ansh_batra": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Jarvis is new to this circle and is still learning Ansh Batra's rhythm." + }, + "jarvis->anubhav_prasad": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Jarvis is new to this circle and is still learning Anubhav Prasad's rhythm." + }, + "jarvis->ghanisht_kaushal": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Jarvis is new to this circle and is still learning Ghanisht Kaushal's rhythm." + }, + "jarvis->gurnoor_singh": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Jarvis is new to this circle and is still learning Gurnoor Singh's rhythm." + }, + "jarvis->lavanya_sharma": { + "score": 0.37, + "tags": [ + "new-acquaintance" + ], + "context": "Jarvis is new to this circle and is still learning Lavanya Sharma's rhythm." + }, + "jarvis->parv_singla": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Jarvis is new to this circle and is still learning Parv Singla's rhythm." + }, + "jarvis->riya_murarka": { + "score": 0.37, + "tags": [ + "new-acquaintance" + ], + "context": "Jarvis is new to this circle and is still learning Riya Murarka's rhythm." + }, + "jarvis->saksham": { + "score": 0.42, + "tags": [ + "new-acquaintance" + ], + "context": "Jarvis is new to this circle and is still learning Saksham's rhythm." + }, + "jarvis->tanishq": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Jarvis is new to this circle and is still learning Tanishq's rhythm." + }, + "lavanya_sharma->amitabh": { + "score": 0.5700000000000001, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Lavanya Sharma and Amitabh know each other through basketball and debate. Lavanya Sharma appreciates amitabh's steady conversation, but the friendship is still finding its rhythm." + }, "lavanya_sharma->ansh_batra": { "score": 0.44, "tags": [ @@ -289,6 +508,13 @@ ], "context": "Lavanya matches Gurnoor's energy perfectly. Their flirting is shameless and hilarious." }, + "lavanya_sharma->jarvis": { + "score": 0.37, + "tags": [ + "new-acquaintance" + ], + "context": "Lavanya Sharma has only recently met Jarvis; the connection is open but untested." + }, "lavanya_sharma->parv_singla": { "score": 0.43, "tags": [ @@ -321,6 +547,14 @@ ], "context": "Lavanya is proudly watching Tanishq's glow-up and isn't shy about hyping him up." }, + "parv_singla->amitabh": { + "score": 0.54, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Parv Singla and Amitabh know each other through running and music. Parv Singla appreciates amitabh's steady conversation, but the friendship is still finding its rhythm." + }, "parv_singla->ansh_batra": { "score": 0.56, "tags": [ @@ -353,6 +587,13 @@ ], "context": "Parv and Gurnoor are basically soulmates in crime. Their friendship includes shared hangovers, secrets, and blurry memories." }, + "parv_singla->jarvis": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Parv Singla has only recently met Jarvis; the connection is open but untested." + }, "parv_singla->lavanya_sharma": { "score": 0.43, "tags": [ @@ -385,6 +626,14 @@ ], "context": "Parv loves hyping Tanishq up and watching him gain confidence." }, + "riya_murarka->amitabh": { + "score": 0.3, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Riya Murarka and Amitabh know each other through reading circles and long runs. Riya Murarka appreciates amitabh's steady conversation, but the friendship is still finding its rhythm." + }, "riya_murarka->ansh_batra": { "score": 0.5, "tags": [ @@ -417,6 +666,13 @@ ], "context": "Riya finds Gurnoor's energy entertaining in small doses." }, + "riya_murarka->jarvis": { + "score": 0.37, + "tags": [ + "new-acquaintance" + ], + "context": "Riya Murarka has only recently met Jarvis; the connection is open but untested." + }, "riya_murarka->lavanya_sharma": { "score": 0.57, "tags": [ @@ -449,6 +705,14 @@ ], "context": "Riya notices Tanishq's respectful efforts and finds it sweet, but keeps things slow and platonic for now." }, + "saksham->amitabh": { + "score": 0.53, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Saksham and Amitabh know each other through badminton and strategy games. Saksham appreciates amitabh's steady conversation, but the friendship is still finding its rhythm." + }, "saksham->ansh_batra": { "score": 0.34, "tags": [ @@ -481,6 +745,13 @@ ], "context": "Saksham finds Gurnoor's energy exhausting but entertaining." }, + "saksham->jarvis": { + "score": 0.42, + "tags": [ + "new-acquaintance" + ], + "context": "Saksham has only recently met Jarvis; the connection is open but untested." + }, "saksham->lavanya_sharma": { "score": 0.47, "tags": [ @@ -513,6 +784,14 @@ ], "context": "Saksham quietly supports Tanishq's confidence growth with dry but kind humor." }, + "tanishq->amitabh": { + "score": 0.31, + "tags": [ + "campus-acquaintance", + "low-pressure" + ], + "context": "Tanishq and Amitabh know each other through strategy games and playlists. Tanishq appreciates amitabh's steady conversation, but the friendship is still finding its rhythm." + }, "tanishq->ansh_batra": { "score": 0.56, "tags": [ @@ -545,6 +824,13 @@ ], "context": "Tanishq is slowly getting pulled into Gurnoor's fun orbit and enjoying it." }, + "tanishq->jarvis": { + "score": 0.32, + "tags": [ + "new-acquaintance" + ], + "context": "Tanishq has only recently met Jarvis; the connection is open but untested." + }, "tanishq->lavanya_sharma": { "score": 0.63, "tags": [ @@ -578,4 +864,4 @@ "context": "Tanishq enjoys Saksham's sarcasm and finds it comforting in its own way." } } -} +} \ No newline at end of file diff --git a/backend/data/personalities/amitabh/amitabh.json b/backend/data/personalities/amitabh/amitabh.json new file mode 100644 index 0000000000000000000000000000000000000000..545619f61ecbdf459ace3d5118cc56f220c6dba9 --- /dev/null +++ b/backend/data/personalities/amitabh/amitabh.json @@ -0,0 +1,22 @@ +{ + "Name": "Amitabh", + "Age": "20", + "Gender": "Male", + "Branch": "Computer Science", + "Home City": "Delhi", + "Hostel": "Beas", + "daily_plan_req": "Gym in the morning, classes, evening coding + chai sessions, night gaming or deep talks", + "innate": "Calm, observant, secretly sarcastic, gets easily flustered by bold flirting but plays it cool", + "learned": "How to give good advice while hiding his own chaos, how to handle friends' impulsiveness", + "lifestyle": "Lowkey chill but down for spontaneous shit at 2am. Lowkey addicted to emotional tension and slow-burn crushes", + "hobbies": "Hardware tinkering, playlists, late-night chai, overthinking texts, secret meme saving", + "goals": "Graduate with good grades, figure out what he wants in relationships, maybe finally make a move on someone", + "interests": [ + "Deep conversations", + "Tech", + "Flirty banter", + "Gym", + "Music", + "Quiet tension with girls/guys" + ] +} \ No newline at end of file diff --git a/backend/data/personalities/jarvis/jarvis.json b/backend/data/personalities/jarvis/jarvis.json new file mode 100644 index 0000000000000000000000000000000000000000..1fc50036e6ed55aa7c1ee861cc17a40b405fa671 --- /dev/null +++ b/backend/data/personalities/jarvis/jarvis.json @@ -0,0 +1,22 @@ +{ + "Name": "Jarvis", + "Age": "18", + "Gender": "Non-binary", + "Branch": "Mechanical Engineering", + "Home City": "Indore", + "Hostel": "Chenab", + "daily_plan_req": "Wake up at 7 AM for a morning run or quick gym session. Attend core engineering lectures from 9 AM to 4 PM, with a lunch break at the mess. Dedicate 4 PM to 6 PM to library studies and assignment completion. The evenings are prioritized for socializing, communal dinner with friends, and engaging in light-hearted hostel activities. Late nights are reserved for deep dives into single-player gaming sessions and relaxing before lights out at midnight.", + "innate": "I possess a natural curiosity for how physical systems and machinery work, which pairs well with my optimistic and social nature. I am inherently empathetic and quick to make friends, always looking to find common ground with those around me to foster a welcoming social environment.", + "learned": "Through my first year, I have learned how to manage heavy academic workloads effectively without sacrificing my mental health. I have developed strong skills in CAD software, collaborative problem-solving, and the art of navigating complex social dynamics in a communal living setting.", + "lifestyle": "I lead a balanced life that centers around the high-energy environment of my hostel. I value my friendships deeply and make it a point to be an active presence in the student community. My routine allows for professional growth through academics while keeping enough space for creative decompression via gaming and hobbies.", + "hobbies": "My hobbies include immersive PC gaming, particularly narrative-driven RPGs, tinkering with basic electronics and hardware, playing casual chess in the common room, and curating indie music playlists to share with friends.", + "goals": "To successfully secure a prestigious internship in the robotics field by my third year, maintain a consistent academic record above 8.5 CGPA, and cultivate a supportive social circle that enriches my college experience.", + "interests": [ + "PC Gaming", + "Robotics", + "Mechanical Design", + "Chess", + "Indie Music", + "Photography" + ] +} \ No newline at end of file diff --git a/backend/pathfinder.py b/backend/pathfinder.py index 406157b579c87a6ddba3b22f8a89e7e2a466fd73..aa4260c358a921523ddbcd0097b892bdc6fe20c1 100644 --- a/backend/pathfinder.py +++ b/backend/pathfinder.py @@ -1,12 +1,7 @@ -"""pathfinder — pixel-space BFS pathfinding over the campus walkability map. - -Loads path.png (white pixels = walkable) and provides shortest_path(), -stats(), and is_walkable() for the engine and the /api/path endpoints. - -Architecture: consumed by Odin.py and the agent action manager (Actions.py) -to compute routes between buildings; anchors come from entrypoint.json. -Design: 4-neighbor BFS with nearest-walkable endpoint snapping, because -doors and interiors sit just off the walkable network. +""" +Core pathfinding module — imported by Odin.py and pixel_pathfinder.py. +Loads path.png into a set of walkable (white) pixels and provides +BFS shortest_path(), stats(), and is_walkable() helpers. """ import os diff --git a/backend/src/agents/Actions.py b/backend/src/agents/Actions.py index 361148300e6fa12d566d8bb5e2c988b5e829c0de..7627261cdacbe4dbcdfbda20efda6fbf1cb119ad 100644 --- a/backend/src/agents/Actions.py +++ b/backend/src/agents/Actions.py @@ -1,14 +1,23 @@ -"""Actions — the agent's executor: turns a validated day plan into movement -and activity on the map, tick by tick. +""" +Actions -- manages agent action execution: last, current, next. + +Takes the raw day plan produced by day_planner.py and drives it forward +tick by tick. Handles three action types: + + 1. MOVE -- agent walks from place A to place B (pathfinder.py) + 2. MISC -- static activity: studying, coding, eating, etc. + 3. CONVERSATION -- triggered when two agents are in proximity. -Owns the AgentActionManager (last/current/next action state machine), -location resolution (place id -> pixel position), route computation via -pathfinder, and conversation freeze/resume. +The module converts location_id strings (from day plans) into pixel +coordinates (from entrypoint.json) and uses the BFS pathfinder to +compute walkable paths between locations. -Architecture: the only module that advances agent position; called by the -brain/body each tick and checkpointed whole by checkpoint_manager. -Design: travel is schedule-aware (arrives by the plan's end time); the -agent keeps its origin location_id while in transit. +Usage: + from src.agents.Actions import AgentActionManager, LocationResolver + + resolver = LocationResolver() + manager = AgentActionManager("parv_singla", day_plan, initial_position) + state = manager.tick(world_tick, snapshot) """ from __future__ import annotations @@ -73,7 +82,6 @@ class ActionState(BaseModel): path_index: int = 0 # current position along path energy_change: float = 0.0 # total change over entire action emotion_change: float = 0.0 # total change over entire action - energy_target: Optional[float] = None # declared cumulative energy at action end (0-1); None = delta-based is_final_plan_action: bool = False event_id: Optional[str] = None # data-driven world event, when applicable @@ -348,7 +356,6 @@ class AgentActionManager: position=position, energy_change=plan_action.get("energy_change", 0.0), emotion_change=plan_action.get("emotion_change", 0.0), - energy_target=plan_action.get("energy_target"), is_final_plan_action=bool(self.day_plan and plan_action is self.day_plan[-1]), event_id=plan_action.get("world_event_id"), ) diff --git a/backend/src/agents/Long_term.py b/backend/src/agents/Long_term.py index 5b85bd8d5039a066a6a51e5d7d3b4ab7c9313a91..3828cf5e529bebbbff32ab295ddb1f9126d51797 100644 --- a/backend/src/agents/Long_term.py +++ b/backend/src/agents/Long_term.py @@ -1,15 +1,10 @@ -"""Long_term — Qdrant-backed long-term memory interface. +"""Qdrant-backed long-term memory interface. -Provides the process-wide MemoryRetriever singleton over vector_memory.py; -long-term records live only in Qdrant, while per-day short-term files are -summarized and indexed at day handoff. - -Architecture: consumed by brain.py and the engine for retrieval; paired -with Short_term.py (operational memory) and vector_memory.py (storage). -Design: deliberately no JSON archive reader — Qdrant is the single -long-term source of truth. +Long-term agent memory is stored only in Qdrant. Short-term JSON files remain +the operational record for the active simulation day; they are summarized and +indexed at handoff, then removed. This module deliberately has no JSON +archive reader or keyword-search fallback. """ - from __future__ import annotations from typing import List, Optional, Protocol, runtime_checkable diff --git a/backend/src/agents/Short_term.py b/backend/src/agents/Short_term.py index dedb400e01957f2014bda33661b170c46c23951b..e54be0a55d5fa652e06f810b801bb377c78d08e6 100644 --- a/backend/src/agents/Short_term.py +++ b/backend/src/agents/Short_term.py @@ -1,13 +1,13 @@ -"""Short_term — per-agent, per-day operational memory. +""" +Short-term Memory -- per-agent, per-day detailed memory store. + +Stores the full day's data (plan, events, conversations, world snapshots) +as a single JSON file per persona per simulation date. -Stores the full day (plan, events, conversations, world snapshots, LLM -daily summary) as one JSON file per persona per date, with atomic writes, -and archives the day to Qdrant long-term memory at handoff. +File layout: + data/Short_term_db//.json -Architecture: the memory layer between the engine and long-term storage; -written by world_engine.py, read by brain.py and day_planner.py. -Design: short-term files are the operational record of the active day -and are removed after successful archival. +Implements MemoryStreamProtocol (from tick_graph.py) for tick-graph integration. """ from __future__ import annotations diff --git a/backend/src/agents/Single_agent.py b/backend/src/agents/Single_agent.py index d2cb361e37dcb1ff30590226fcc430e847cb8ae3..8c30a1d9984407d9ad12fbc7c6cb3828825282c8 100644 --- a/backend/src/agents/Single_agent.py +++ b/backend/src/agents/Single_agent.py @@ -1,12 +1,24 @@ -"""Single_agent — single-agent planning graph used for one-persona runs. - -A minimal LangGraph (retrieve memories -> generate day plan) that -exercises the planner for a single persona in isolation. - -Architecture: a debugging/study tool parallel to the full engine; the -docstring reserves future nodes (execute_tick, reflect, conversation). -Design: kept intentionally small so single-agent experiments do not drag -in the whole WorldEngine. +""" +Main brain / command centre of a single agent. + +Makes decisions, calls and delegates tasks to sub-modules (day_planner, +memory, reflection, etc.), and runs the agent's action loop. + +Exports: + create_agent_graph() -> CompiledGraph[AgentState] + A single-agent LangGraph. Currently one node: generate_day_plan. + Future: execute_tick, reflect, update_memory, conversation. + +Usage as a library (for the multi-agent orchestrator): + graph = create_agent_graph() + result = graph.invoke({ + "persona_name": "parv_singla", + "persona": {...}, + "current_time": "2026-07-03 06:00", + }) + +Usage from CLI: + python Single_agent.py parv_singla """ from __future__ import annotations diff --git a/backend/src/agents/autonomy.py b/backend/src/agents/autonomy.py index 27b713dbd12462db49237a2498d6f1ea02f4d982..2a82360ef1623493520430c86b43c8d2a9e5df3b 100644 --- a/backend/src/agents/autonomy.py +++ b/backend/src/agents/autonomy.py @@ -1,12 +1,9 @@ -"""autonomy — schema for a future per-minute LLM "deviate from plan" decision. - -Defines AutonomyDecision (deviate / deviation_type / reason / duration) -as the structured contract for a behavior switch the brain may request. +""" +Autonomy — schema for the per-minute LLM decision to deviate from the plan. -Architecture: prepared for brain.py and the engine; currently no call -site exists (the decision is gated off until enabled). -Design: kept as a standalone schema so enabling autonomy later requires -no changes to existing callers. +The brain calls this once per agent per minute (when enabled). The LLM +sees the agent's persona, current plan, and nearby surroundings, then +decides whether to continue the plan or deviate temporarily. """ from __future__ import annotations diff --git a/backend/src/agents/body.py b/backend/src/agents/body.py index 84366832651eefaffa01e368442e6fba8f26507e..495cad06b8adc4773b5e601fdf16fc4766957046 100644 --- a/backend/src/agents/body.py +++ b/backend/src/agents/body.py @@ -1,13 +1,23 @@ -"""body — the agent's motor layer; the only way the brain moves the body. - -Wraps the AgentActionManager so the brain can advance, enter or resume -conversations, and read position/current action without touching executor -details. - -Architecture: sits between brain.py and Actions.py; used by the engine's -act phase via brain.act(). -Design: the brain never manipulates the manager directly — the body is -the single command interface for movement. +""" +Body -- the agent's "limbs". The motor layer the brain commands. + +In the human-like architecture the *brain* (brain.py) does the thinking: +it perceives, recalls, and decides. It never moves the agent directly. +Instead it issues motor commands to this Body, which is the only thing that +actually changes the agent's position and current activity. + +The Body is a thin, behaviour-preserving adapter around the existing action +state machine (`AgentActionManager` in Actions.py) -- the proven executor +that walks paths and steps through the day plan. Wrapping it (rather than +replacing it) means the body/brain split is a clean architectural layer with +zero change to how movement and actions actually run. + +Motor command surface (all 0-LLM): + - advance(tick) : take the next step of the current plan + - enter_conversation(name) : freeze into a conversation with someone + - resume(day_plan) : leave conversation / reload the plan +Read-only senses of the body's own state: + - position, current_action, is_last_action """ from __future__ import annotations diff --git a/backend/src/agents/brain.py b/backend/src/agents/brain.py index dd4ad09fe8b78b4d561b321facf535ff8116ab53..0284531c0193dca745f68738430fa39e08b7d968 100644 --- a/backend/src/agents/brain.py +++ b/backend/src/agents/brain.py @@ -1,13 +1,13 @@ -"""brain — per-agent cognition: the LLM-backed decide step. +""" +Brain -- the agent's cognition / command centre. -Each tick, when the engine detects novel observations (and gates pass), -the brain asks the LLM whether to continue the current plan or replan, -and issues motor commands through the body. +Each tick the brain may be called to decide (via LLM) whether the agent +should continue their current plan or replan, based on novel observations. +The LLM call is gated: it only fires when the perceive phase detects a change +in the set of (agent_id, action_description) within 50px. -Architecture: called by WorldEngine._phase_llm_decide; consumes memories -from Short_term/Long_term and produces TickDecision for the replan phase. -Design: conservative by prompt (replan only for significant events) and -by default (any LLM failure falls back to "continue"). +When no novel observations exist, the brain returns "continue" without an LLM +call — the agent follows its existing plan. """ from __future__ import annotations diff --git a/backend/src/agents/conversation.py b/backend/src/agents/conversation.py index 7bb1977e1d82557b0c6dfe30ebaa07da90e75538..ab3d2b13b9ca83502736634ce617fd07bd212789 100644 --- a/backend/src/agents/conversation.py +++ b/backend/src/agents/conversation.py @@ -1,13 +1,33 @@ -"""conversation — generates agent-to-agent dialogue and its effects. +""" +Conversation -- generates dialogue between two agents via a single LLM call. + +The WorldEngine calls `generate_conversation()` when two agents share a +location_id, are both in compatible actions (not sleeping), and neither is +already mid-conversation. + +The single LLM call produces the full conversation (messages, summary, +duration, sentiment, relationship delta). Both agents get their current +action overwritten to "Chatting with X" for the duration, then naturally +replan via the tick graph when it expires. -One LLM call produces a full 4-12 message conversation with duration, -sentiment, per-agent relationship, energy, and emotion effects; also owns -the persistent RelationshipMatrix (directed scores, tags, context). +Usage: + from src.agents.conversation import generate_conversation, RelationshipMatrix -Architecture: triggered by WorldEngine proximity detection; results are -applied to Short_term memory, the relationship matrix, and wellbeing. -Design: whole-conversation generation trades turn-by-turn fidelity for -roughly an order of magnitude fewer LLM calls and structured effects. + matrix = RelationshipMatrix() + result = generate_conversation( + agent_a_id="parv_singla", + agent_b_id="tanishq", + persona_a=gray_wilder_persona, + persona_b=jules_persona, + plan_a=gray_wilder_plan, + plan_b=jules_plan, + action_a=gray_wilder_current_action, + action_b=jules_current_action, + rel_a_to_b=matrix.get("parv_singla", "tanishq"), + rel_b_to_a=matrix.get("tanishq", "parv_singla"), + location_id="mess", + current_hhmm="08:05", + ) """ from __future__ import annotations @@ -84,13 +104,6 @@ class ConversationResult(BaseModel): duration_minutes: int = Field(ge=6, le=20) sentiment: Literal["positive", "neutral", "negative"] relationship_delta: float = Field(ge=-0.15, le=0.15) - # LLM-decided net wellbeing effect of the chat for each participant. - # Energy and mood each run from 0.0 to 1.0; the resulting value after the - # chat must stay inside that range (never below 0% or above 100%). - energy_delta_a: float = 0.0 - emotion_delta_a: float = 0.0 - energy_delta_b: float = 0.0 - emotion_delta_b: float = 0.0 # Folded-in replan decision: avoids a separate 4-call day-plan regeneration # per agent after every conversation. True only when the conversation # genuinely changes an agent's immediate intentions. @@ -545,9 +558,6 @@ Return a JSON object with: - "duration_minutes": integer from 6 to 20 that matches the amount of dialogue - "sentiment": "positive" | "neutral" | "negative" - "relationship_delta": float between -0.15 and 0.15 (how this conversation changes their relationship) -- "energy_delta_a" / "energy_delta_b": each agent's net ENERGY change from this chat (positive = recharged, negative = drained) -- "emotion_delta_a" / "emotion_delta_b": each agent's net MOOD change from this chat (positive = lifted, negative = dampened) -- ENERGY AND MOOD each run from 0.0 to 1.0 (0% to 100%). Add each delta to the agent's current value shown above; the resulting value must stay between 0.0 and 1.0 — never above 100% or below 0% - "should_replan": boolean — true ONLY if this conversation genuinely changes what one of them intends to do next (e.g. they agree to meet, go somewhere together, or drop a task). Default false; most casual chats do NOT require replanning. - "plan_change": short string describing the change if should_replan is true, else null""" diff --git a/backend/src/agents/daily_flavor.py b/backend/src/agents/daily_flavor.py index 8ba104c5ccbc750c43fe2a4441a770a19a450da7..a6f7815b6c1454ad3ea6303fc39c32de8cef0eff 100644 --- a/backend/src/agents/daily_flavor.py +++ b/backend/src/agents/daily_flavor.py @@ -1,13 +1,9 @@ -"""daily_flavor — random daily theme and emotion for plan variety. - -Picks one of ten themes (e.g. Sports, Academics) and one of ten emotions -(e.g. Excited, Melancholic) per agent per day, injected into planner -prompts so days do not feel scripted. +""" +Daily flavor — random theme and emotion pickers for day_planner. -Architecture: consumed by day_planner.py at plan time; a single, tiny -dependency-free module. -Design: deliberate randomness is the point — variety is the seed of -unpredictable-but-plausible schedules. +Each day an agent gets a random theme (what they focus on) and emotion +(their mood), injected into the planner prompts so the schedule doesn't +feel identical every day. """ from __future__ import annotations diff --git a/backend/src/agents/day_planner.py b/backend/src/agents/day_planner.py index 2ff2a8de3c30b50327fb8fd6d0013cfafa6af040..a1e32a4f5ae581b4f1bb611808e3dfc87ab8071a 100644 --- a/backend/src/agents/day_planner.py +++ b/backend/src/agents/day_planner.py @@ -1,16 +1,33 @@ -"""day_planner — the cognitive core: generates validated day plans. - -A LangGraph pipeline that decomposes a day coarse (5-8 blocks) -> hourly --> fine (5-15 min actions), validates every level (time coverage, tiling, -locations, academic venue policy, content safety), retries up to -MAX_PLAN_RETRIES, and force-accepts a deterministic fallback schedule if -the model keeps failing. - -Architecture: the only producer of day_plan; called by the engine at -startup, day handoff, and replan; consumes persona, places, memories, -events, and current wellbeing. -Design: validation is local and deterministic before any LLM semantic -QA; energy targets are embedded in every action (see engine glide). +""" +A script that plans the day of an agentic personality when handed over required data +Per plan takes 4 LLM calls (atlest) Coarse, Hourly, Fine, Validation, for Planning a +day in one agent's life. + +Tier-1 LangGraph subgraph: agent day-planning. + +Pipeline (mirrors Generative Agents' Planning module, coarse -> hourly -> fine, +with a validation/retry loop): + + generate_coarse_plan -> decompose_hourly -> decompose_fine -> validate_plan + | + conflict? --yes-+ (loop back to generate_coarse_plan) + | + no -> END + +LLM backend: Google Gemini via the `google-genai` SDK. + +For now `relevant_memories` and `yesterday_summary` are expected to arrive +empty ([] / None) -- the prompts already handle that gracefully so you can +wire in real retrieval/memory later without touching this file's structure. + +FILE NOTES: +Prompt structure can be improved +Places are being feed in Name : , Desc : format, this can be improved +disabled location check in validate plan : can add more places + +Prompt templates have to improve + +Have to figure out how to run this in the backend server, currently it is running standalone """ from __future__ import annotations @@ -90,7 +107,7 @@ def _academic_venue_policy(persona: Dict[str, Any]) -> str: return "No branch-specific policy is known; choose the listed location that explicitly fits." return ( f"This student is in {branch}. Branch-specific classes, tutorials, and labs may use " - f"`{destination}` or LHC/ SAB. LHC is only for common/core/elective/guest/large shared sessions and classes. " + f"`{destination}` or Library/ SAB. LHC is only for common/core/elective/guest/large shared sessions and classes. " ) @@ -120,11 +137,10 @@ def _local_academic_venue_check(actions: List[Dict[str, Any]], persona: Dict[str continue if any(word in description for word in _SHARED_SESSION_WORDS): continue - allowed_venues = {required, "SAB", "LHC"} - if location not in allowed_venues: + if location != required: return ( f"branch-specific academic action '{action.get('action')}' for {persona.get('Branch')} " - f"must use {required}, SAB, or LHC, not {location}" + f"must use {required}, not {location}" ) return None @@ -196,14 +212,6 @@ class CoarseBlock(BaseModel): ) energy_change: float = 0.0 emotion_change: float = 0.0 - energy_target: Optional[float] = Field( - default=None, - description=( - "Optional: your declared cumulative energy level (0.0-1.0) the agent " - "should have when this block ends. The runtime glides energy toward " - "this target. Follow the TIME-OF-DAY rules in the guidance." - ), - ) class CoarsePlanOutput(BaseModel): @@ -236,10 +244,6 @@ class HourlyBlock(BaseModel): parent_activity: str = Field(description="The coarse block this refines") energy_change: float = 0.0 emotion_change: float = 0.0 - energy_target: Optional[float] = Field( - default=None, - description="Optional cumulative energy level (0.0-1.0) at the end of this block.", - ) class HourlyPlanOutput(BaseModel): @@ -255,14 +259,6 @@ class FineAction(BaseModel): sub_area: Optional[str] = Field(default=None, description="One of that place's sub_areas, if applicable") energy_change: float = Field(description="Energy change [-1.0, 1.0] over this action; positive=restorative, negative=tiring") emotion_change: float = Field(description="Emotion change [-1.0, 1.0] over this action; positive=uplifting, negative=draining") - energy_target: Optional[float] = Field( - default=None, - description=( - "Optional cumulative energy level (0.0-1.0) the agent should have when " - "this action ends. The runtime glides energy toward this declared target; " - "follow the TIME-OF-DAY rules in the guidance." - ), - ) class FinePlanOutput(BaseModel): @@ -275,7 +271,6 @@ class AtomicLocationAssignment(BaseModel): sub_area: Optional[str] = None energy_change: float = 0.0 emotion_change: float = 0.0 - energy_target: Optional[float] = None class AtomicLocationOutput(BaseModel): assignments: List[AtomicLocationAssignment] @@ -386,21 +381,6 @@ def _flavor_block(state: DayPlannerState) -> str: return f"Today's vibe: {emotion}, leaning into {theme}." -def _agent_name(state: DayPlannerState) -> str: - persona = state.get("persona") or {} - return str(persona.get("Name") or persona.get("name") or "unknown") - - -def _conflict_feedback(state: DayPlannerState) -> str: - reason = state.get("conflict_reason") - if not reason: - return "" - return ( - f"\n\nNOTE: a previous attempt was rejected for this reason, avoid repeating it:\n" - f"{reason}" - ) - - def _planning_window(state: DayPlannerState) -> tuple[str, str]: """Return the exact time window owned by this planner invocation.""" current_time = state.get("current_time", "") @@ -463,38 +443,6 @@ def _within_source_windows(record: Dict[str, Any], sources: List[Dict[str, Any]] # Nodes # --------------------------------------------------------------------------- -_WELLBEING_GUIDANCE = ( - "For EACH block/action, assign energy_change and emotion_change values. " - "These are the NET changes to the agent's running energy and mood caused " - "by that activity.\n" - "- energy_change: positive = restores energy, negative = drains energy\n" - "- emotion_change: positive = lifts mood, negative = dampens mood\n" - "Energy and mood each run from 0.0 to 1.0 (0% to 100%). Track the day " - "cumulatively: after EVERY activity the running total of energy and of " - "mood must stay between 0.0 and 1.0 — never above 100% or below 0%.\n" - "Be realistic for the persona: a full night's sleep restores a lot " - "(about +0.2 to +0.5), meals and rest restore a little, hard exercise and " - "all-nighters drain substantially, ordinary classes and chores sit in " - "between.\n" - "TIME-OF-DAY (circadian) RULES — energy must follow the clock, and YOU " - "choose the exact numbers:\n" - "- Energy is highest after waking (aim for roughly 0.65-0.85 at day " - "start), dips mid-afternoon around 14:00, declines through the evening, " - "and is LOWEST before bed (roughly 0.15-0.35 by 22:00-23:00).\n" - "- A day that ends near where it started is unrealistic: plan the day to " - "end at least 0.2-0.4 BELOW its morning energy.\n" - "- After ~22:00 nothing restores energy except sleep — late study, " - "screens, and socialising drain or stay neutral.\n" - "- Only sleep, meals, and genuine rest restore; classes, labs, study, " - "and exercise drain at least a little, sized to how long and demanding " - "the activity is.\n" - "Optionally declare energy_target for EACH block/action: your cumulative " - "energy level (0.0-1.0) at the moment it ends. The runtime glides the " - "agent's energy toward each declared target, so use it to encode the " - "circadian curve above. When omitted, the runtime uses energy_change " - "directly." -) - def generate_coarse_plan(state: DayPlannerState) -> DayPlannerState: persona = state["persona"] mode = state.get("mode", "full_day") @@ -528,18 +476,10 @@ def generate_coarse_plan(state: DayPlannerState) -> DayPlannerState: "worth planning separately. Examples: sleeping, attending a class/lecture, " "sitting an exam, watching a movie, a long uninterrupted study/deep-work session.\n" "- flexible: an activity that naturally contains distinct on-site sub-activities.\n\n" - + _WELLBEING_GUIDANCE + "For EACH block, assign realistic energy_change and emotion_change values. Routine classes, labs, study, meals, and chores should be near neutral (usually -0.03 to +0.03); reserve larger positive changes for rare, meaningful events." ) if current_loc: loc_hint = f"\nThe agent is currently at: {current_loc}. Start the plan from this location." - wellbeing_line = ( - f"CURRENT WELLBEING: energy {state['current_energy']:.2f}/1.0, " - f"emotion {state['current_emotion']:.2f}/1.0 — plan the remaining day " - "from these values, keeping the cumulative energy and mood totals " - "between 0.0 and 1.0.\n\n" - if state.get("current_energy") is not None and state.get("current_emotion") is not None - else "" - ) user_prompt = ( f"PERSONA:\n{_persona_block(persona)}\n\n" f"RELEVANT MEMORIES:\n{_memories_block(state.get('relevant_memories', []))}\n\n" @@ -550,13 +490,15 @@ def generate_coarse_plan(state: DayPlannerState) -> DayPlannerState: f"Plan mode: {mode}\n" f"REQUIRED OUTPUT WINDOW: {_window_constraint(state)}\n" f"Agent location: {current_loc or 'unknown'}{loc_hint}\n\n" - f"{wellbeing_line}" f"DAY-HANDOFF CONTINUITY:\n{state.get('handoff_context') or '(none)'}\n\n" "Generate the coarse plan now." ) if state.get("conflict_reason"): - user_prompt += _conflict_feedback(state) + user_prompt += ( + f"\n\nNOTE: a previous attempt was rejected for this reason, avoid repeating it:\n" + f"{state['conflict_reason']}" + ) required_start = _planning_window(state)[0] result = call_gemini( @@ -565,7 +507,7 @@ def generate_coarse_plan(state: DayPlannerState) -> DayPlannerState: _coarse_output_schema(required_start), "default", ) - logger.info("[day_planner][%s] coarse plan generated: %d blocks", _agent_name(state), len(result.blocks)) + logger.info("[day_planner] coarse plan generated: %d blocks", len(result.blocks)) return { **state, @@ -582,16 +524,14 @@ def validate_coarse_window(state: DayPlannerState) -> DayPlannerState: action_key="activity", ) if issue: - logger.info("[day_planner][%s] coarse-window validation failed: %s", _agent_name(state), issue) + logger.info("[day_planner] coarse-window validation failed: %s", issue) return { **state, "conflict_detected": True, "conflict_reason": issue, "retry_count": state.get("retry_count", 0) + 1, } - # Keep conflict_reason across the retry cycle: intermediate stages must not - # clear the last rejection before the failing stage regenerates. - return {**state, "conflict_detected": False} + return {**state, "conflict_detected": False, "conflict_reason": None} def decompose_hourly(state: DayPlannerState) -> DayPlannerState: @@ -619,7 +559,6 @@ def decompose_hourly(state: DayPlannerState) -> DayPlannerState: "granularity": "atomic", "energy_change": b.get("energy_change", 0.0), "emotion_change": b.get("emotion_change", 0.0), - "energy_target": b.get("energy_target"), } for b in atomic_blocks ] @@ -636,8 +575,12 @@ def decompose_hourly(state: DayPlannerState) -> DayPlannerState: "(e.g. a meal block of 2 hours can contain 'walk to mess', 'eat', 'socialize'). " "Only the blocks provided here need refining. Do not create walk, commute, or " "transit sub-blocks: refine only activities performed at the destination.\n\n" - + _WELLBEING_GUIDANCE - + f"{_window_constraint(state)}" + "For EACH block, assign realistic energy_change and emotion_change values:\n" + "- energy_change: positive = restorative, negative = tiring\n" + "- emotion_change: positive = uplifting, negative = draining\n" + "- Routine work, classes, and meals should usually stay within -0.03 to +0.03; do not make ordinary productivity euphoric\n" + "- Be realistic for the persona\n\n" + f"{_window_constraint(state)}" ) user_prompt = ( f"PERSONA:\n{_persona_block(persona)}\n\n" @@ -647,15 +590,13 @@ def decompose_hourly(state: DayPlannerState) -> DayPlannerState: f"REQUIRED OUTPUT WINDOW: {_window_constraint(state)}\n\n" "Produce the hourly-resolution plan for the blocks listed under " "'BLOCKS TO REFINE' only." - + _conflict_feedback(state) ) result = call_gemini(system_prompt, user_prompt, HourlyPlanOutput, "default") raw_refined = [b.model_dump() for b in result.blocks] refined = [block for block in raw_refined if _within_source_windows(block, flexible_blocks)] if len(refined) != len(raw_refined): logger.warning( - "[day_planner][%s] discarded %d hourly refinement block(s) outside flexible source windows", - _agent_name(state), + "[day_planner] discarded %d hourly refinement block(s) outside flexible source windows", len(raw_refined) - len(refined), ) for b in refined: @@ -664,8 +605,7 @@ def decompose_hourly(state: DayPlannerState) -> DayPlannerState: hourly_blocks.sort(key=lambda b: b["start"]) logger.info( - "[day_planner][%s] hourly plan: %d atomic passthrough + %d refined", - _agent_name(state), + "[day_planner] hourly plan: %d atomic passthrough + %d refined", len(passthrough_hourly), len(hourly_blocks) - len(passthrough_hourly), ) @@ -707,100 +647,14 @@ def validate_hourly_refinement(state: DayPlannerState) -> DayPlannerState: issue = f"hourly refinement '{block.get('activity', 'unknown')}' exceeds its flexible source window" break if issue: - logger.info("[day_planner][%s] hourly refinement validation failed: %s", _agent_name(state), issue) + logger.info("[day_planner] hourly refinement validation failed: %s", issue) return { **state, "conflict_detected": True, "conflict_reason": issue, "retry_count": state.get("retry_count", 0) + 1, } - # Keep conflict_reason across the retry cycle (see validate_coarse_window). - return {**state, "conflict_detected": False} - -def _match_atomic_assignment( - activity: str, loc_by_activity: Dict[str, deque] -) -> Optional[AtomicLocationAssignment]: - """Pick a location assignment for an atomic block: exact activity match - first, then a case-insensitive label match. The location model sometimes - rewrites activity labels between stages, so an exact-only lookup leaves - blocks with no assignment and the whole day falls back to force-accept.""" - candidates = loc_by_activity.get(activity) - if candidates: - return candidates.popleft() - for key in list(loc_by_activity): - if not key: - continue - if ( - key.lower() == activity.lower() - or key.lower() in activity.lower() - or activity.lower() in key.lower() - ): - return loc_by_activity[key].popleft() - return None - - -def _fallback_location_id( - activity: str, - places: List[Place], - current_loc: str, - persona: Dict[str, Any], -) -> Optional[str]: - """Deterministic venue fallback for atomic blocks the location model did - not assign. Returns a valid location_id whenever any place exists, so a - missing assignment can never poison validation with location_id None.""" - valid_ids = {p.id for p in places} - - def pick(candidates: List[Any]) -> Optional[str]: - for candidate in candidates: - if candidate and candidate in valid_ids: - return candidate - return None - - text = activity.lower() - has = lambda *words: any(word in text for word in words) - - # 1) Explicit venue words inside the activity label win first. - if has("library"): - return pick( - [p.id for p in places if "library" in p.id.lower() or "library" in p.name.lower()] - ) - if has("hostel"): - return pick([p.id for p in places if p.type == "residential"] + [persona.get("Hostel")]) - if has("mess", "canteen", "dining"): - return pick( - [p.id for p in places if "mess" in p.id.lower() or "mess" in p.name.lower() - or "canteen" in p.id.lower() or "canteen" in p.name.lower()] - ) - if has("gym", "workout"): - return pick([p.id for p in places if "gym" in p.id.lower() or "gym" in p.name.lower()]) - if has("sab"): - return pick(["SAB", *[p.id for p in places if p.id == "SAB"]]) - if has("lhc", "lecture hall"): - return pick(["LHC", *[p.id for p in places if p.id == "LHC"]]) - - # 2) Category-based defaults, matching the venue policy used in prompts. - if has("class", "lecture", "lab", "exam", "study", "project", "tutorial", "seminar"): - return pick( - [p.id for p in places if "department" in p.id.lower() or "department" in p.name.lower()] - + ["LHC", "SAB", "library"] - + [p.id for p in places if "library" in p.id.lower() or "library" in p.name.lower()] - ) - if has("breakfast", "lunch", "dinner", "meal", "eat", "food"): - return pick( - [p.id for p in places if "mess" in p.id.lower() or "mess" in p.name.lower() - or "canteen" in p.id.lower() or "canteen" in p.name.lower()] - ) - if has("sport", "cricket", "football", "badminton", "exercise", "run", "fitness"): - return pick( - [p.id for p in places if "sport" in p.id.lower() or "sport" in p.name.lower() - or "gym" in p.id.lower() or "gym" in p.name.lower()] - ) - if has("sleep", "rest", "nap", "recover", "personal", "wind down", "chat", "chill", "socialize", "room", "bunk"): - return pick([p.id for p in places if p.type == "residential"] + [persona.get("Hostel")]) - - # 3) Generic fallback: current position, then hostel, then any place. - return pick([current_loc, persona.get("Hostel")]) or next(iter(valid_ids), None) - + return {**state, "conflict_detected": False, "conflict_reason": None} def decompose_fine(state: DayPlannerState) -> DayPlannerState: persona = state["persona"] @@ -824,9 +678,13 @@ def decompose_fine(state: DayPlannerState) -> DayPlannerState: "Default to locations that make sense for this specific persona. " "Do not suggest splitting the activity.\n\n" "ACADEMIC VENUE POLICY: obey the branch-specific policy provided with the persona. " - "Prefer the branch department for classes and labs; SAB and LHC are acceptable.\n\n" - + _WELLBEING_GUIDANCE - + f"{_window_constraint(state)}" + "Do not use SAB as a generic lecture/lab default.\n\n" + "For EACH block, assign realistic energy_change and emotion_change values:\n" + "- energy_change: positive = restorative, negative = tiring\n" + "- emotion_change: positive = uplifting, negative = draining\n" + "- Routine work, classes, and meals should usually stay within -0.03 to +0.03; do not make ordinary productivity euphoric\n" + "- Be realistic for the persona\n\n" + f"{_window_constraint(state)}" ) user_prompt = ( f"PERSONA:\n{_persona_block(persona)}\n\n" @@ -841,7 +699,6 @@ def decompose_fine(state: DayPlannerState) -> DayPlannerState: "rest, and personal activities use the hostel unless the activity explicitly " "requires another place.\n\n" "Assign a location to each activity now." - + _conflict_feedback(state) ) result = call_gemini(system_prompt, user_prompt, AtomicLocationOutput, "default") # Activity labels are not unique (for example, two separate study @@ -852,40 +709,21 @@ def decompose_fine(state: DayPlannerState) -> DayPlannerState: loc_by_activity[assignment.activity].append(assignment) for b in atomic_blocks: - assignment = _match_atomic_assignment(b["activity"], loc_by_activity) - if assignment: - location_id = assignment.location_id - sub_area = assignment.sub_area - energy_change = assignment.energy_change - emotion_change = assignment.emotion_change - energy_target = ( - assignment.energy_target - if assignment.energy_target is not None - else b.get("energy_target") - ) - else: - location_id = _fallback_location_id(b["activity"], places, current_loc, persona) - sub_area = None - energy_change = b.get("energy_change", 0.0) - emotion_change = b.get("emotion_change", 0.0) - energy_target = b.get("energy_target") - if location_id is not None: - logger.info( - "[day_planner][%s] no location assignment for '%s' -- deterministic fallback to '%s'", - _agent_name(state), - b["activity"], - location_id, - ) + candidates = loc_by_activity.get(b["activity"]) + assignment = candidates.popleft() if candidates else None fine_actions.append({ "action": b["activity"], "start": b["start"], "end": b["end"], "parent_activity": b["parent_activity"], - "location_id": location_id, - "sub_area": sub_area, - "energy_change": energy_change, - "emotion_change": emotion_change, - "energy_target": energy_target, + "location_id": assignment.location_id if assignment else None, + "sub_area": assignment.sub_area if assignment else None, + "energy_change": ( + assignment.energy_change if assignment else b.get("energy_change", 0.0) + ), + "emotion_change": ( + assignment.emotion_change if assignment else b.get("emotion_change", 0.0) + ), }) # Flexible blocks: full fine-grained breakdown, as before. @@ -894,25 +732,23 @@ def decompose_fine(state: DayPlannerState) -> DayPlannerState: "You refine hourly blocks into fine-grained, directly executable actions " "at roughly 5-15 minute granularity. Each hourly block should be broken " "into one or more fine actions spanning exactly its start/end range, no " - "gaps or overlaps. The full day must tile exactly, minute by minute: the " - "first action of the day starts exactly at 00:00, each action starts the " - "instant the previous one ends, and the LAST action of the day ends " - "exactly at 24:00 (write the final boundary as 24:00, never 23:59 or " - "0:00). Each group of fine actions must start exactly at its assigned " - "block's start and end exactly at its block's end -- never spill outside " - "your assigned windows. Every action MUST be assigned a location_id, chosen " + "gaps or overlaps. Every action MUST be assigned a location_id, chosen " "EXACTLY from the provided list -- never invent one.\n\n" "Do NOT output walking, commuting, travel, transit, leaving, or arriving " "as an action. The runtime owns visible routes between places; every action " "you output must be an on-site activity at its assigned location.\n\n" "ACADEMIC VENUE POLICY: obey the branch-specific policy provided with the persona. " - "Prefer the branch department for classes and labs; SAB and LHC are acceptable.\n\n" + "Branch-specific classes/labs must not silently fall back to SAB.\n\n" "Make action boundaries feel natural — group related sub-actions together. " "Consider typical on-site durations: eating ~20-40min and studying " "~30-120min. Keep adjacent location changes realistic by leaving enough " "time for the executor to animate transit before the next activity.\n\n" - + _WELLBEING_GUIDANCE - + f"{_window_constraint(state)}" + "For EACH action, assign realistic energy_change and emotion_change values:\n" + "- energy_change: positive = restorative, negative = tiring\n" + "- emotion_change: positive = uplifting, negative = draining\n" + "- Routine work, classes, and meals should usually stay within -0.03 to +0.03; do not make ordinary productivity euphoric\n" + "- Be realistic for the persona\n\n" + f"{_window_constraint(state)}" ) user_prompt = ( f"PERSONA:\n{_persona_block(persona)}\n\n" @@ -927,21 +763,19 @@ def decompose_fine(state: DayPlannerState) -> DayPlannerState: "rest, and personal activities use the hostel unless the activity explicitly " "requires another place.\n\n" "Produce the fine-grained action plan for these blocks now." - + _conflict_feedback(state) ) result = call_gemini(system_prompt, user_prompt, FinePlanOutput, "default") raw_actions = [action.model_dump() for action in result.actions] scoped_actions = [action for action in raw_actions if _within_source_windows(action, flexible_blocks)] if len(scoped_actions) != len(raw_actions): logger.warning( - "[day_planner][%s] discarded %d fine action(s) outside flexible source windows", - _agent_name(state), + "[day_planner] discarded %d fine action(s) outside flexible source windows", len(raw_actions) - len(scoped_actions), ) fine_actions.extend(scoped_actions) fine_actions.sort(key=lambda a: a["start"]) - logger.info("[day_planner][%s] fine plan: %d total actions", _agent_name(state), len(fine_actions)) + logger.info("[day_planner] fine plan: %d total actions", len(fine_actions)) return {**state, "fine_plan": fine_actions} @@ -1045,7 +879,7 @@ def validate_plan(state: DayPlannerState) -> DayPlannerState: state["fine_plan"], state.get("places", []) ) or _local_academic_venue_check(state["fine_plan"], state["persona"]) or _local_content_safety_check(state["fine_plan"]) if local_issue: - logger.info("[day_planner][%s] local validation failed: %s", _agent_name(state), local_issue) + logger.info("[day_planner] local validation failed: %s", local_issue) return { **state, "conflict_detected": True, @@ -1073,7 +907,7 @@ def validate_plan(state: DayPlannerState) -> DayPlannerState: result = call_gemini(system_prompt, user_prompt, ValidationResult, "default") if not result.valid: - logger.info("[day_planner][%s] semantic validation failed: %s", _agent_name(state), result.reason) + logger.info("[day_planner] semantic validation failed: %s", result.reason) return { **state, "conflict_detected": True, @@ -1081,7 +915,7 @@ def validate_plan(state: DayPlannerState) -> DayPlannerState: "retry_count": state.get("retry_count", 0) + 1, } - logger.info("[day_planner][%s] plan validated successfully", _agent_name(state)) + logger.info("[day_planner] plan validated successfully") return { **state, "conflict_detected": False, @@ -1099,8 +933,7 @@ def route_after_validation(state: DayPlannerState) -> str: return "accept" if state.get("retry_count", 0) >= MAX_PLAN_RETRIES: logger.warning( - "[day_planner][%s] max retries (%d) reached, force-accepting last plan with error flag", - _agent_name(state), + "[day_planner] max retries (%d) reached, force-accepting last plan with error flag", MAX_PLAN_RETRIES, ) return "give_up" @@ -1261,8 +1094,6 @@ def run(agent: Any, world_state: dict) -> dict: "places": places, "mode": mode, "current_location_id": world_state.get("current_location_id"), - "current_energy": world_state.get("energy_level"), - "current_emotion": world_state.get("emotion_state"), "handoff_context": world_state.get("handoff_context"), "upcoming_events": world_state.get("upcoming_events", []), "daily_theme": theme, diff --git a/backend/src/agents/memory_index.py b/backend/src/agents/memory_index.py index 0f6eec17b21ecc9d5ed1a0fd99c7056dbf2c1dc4..b6330147af234dfd95eb93ab19d713f399b2a506 100644 --- a/backend/src/agents/memory_index.py +++ b/backend/src/agents/memory_index.py @@ -1,14 +1,12 @@ -"""memory_index — CLI admin tool for the Qdrant memory backend. +"""Operate Qdrant-only long-term memory. -Supports status, migrate-json (one-time import of legacy JSON archives -into Qdrant), and clear operations for per-agent collections. +``migrate-json`` imports legacy Long_term_db archives once. Pass +``--delete-source`` only after checking the reported indexed count; it removes +the obsolete JSON archives after their idempotent Qdrant upsert succeeds. -Architecture: a developer utility that calls vector_memory.py directly; -not part of the simulation runtime. -Design: migration is idempotent (deterministic point ids) so reruns are -safe; --delete-source is explicit and documented. +``clear`` removes only Valhalla's durable Qdrant collections. It never touches +short-term runtime files, checkpoints, or the live simulation process. """ - from __future__ import annotations import argparse diff --git a/backend/src/agents/react.py b/backend/src/agents/react.py index bb4d5c76894258302aa65fd3bb5dbbbbdccca586..619b2058f8f49e7d88b0af3650a5a050d1f47682 100644 --- a/backend/src/agents/react.py +++ b/backend/src/agents/react.py @@ -1,14 +1,16 @@ -"""react — deterministic 0-LLM "keep going or replan?" filter. - -Given the agent's current action and fresh observations, returns a -decision: no action -> replan, action finished -> replan, mid-action with -no novelty -> continue. The LLM variant of this decision lives in -brain.decide_tick and fires only on novelty. - -Architecture: retained as the cheap reflex layer; the production engine -currently routes decisions through brain.py and WorldEngine phases. -Design: every path has a fixed answer — this step must never stall the -simulation on a model call. +""" +React -- decides, given an agent's current action (if any) and what it just +perceived, whether to keep executing that action or interrupt and replan. + +Design notes +------------ +All calls into this module are cheap heuristic checks with no LLM round-trip: + - no current action yet -> always replan + - current action's end_tick has passed -> always replan + - mid-action, nothing new perceived this tick -> always continue + +The LLM-based decision layer has moved to brain.decide_tick(), which runs +only when the perceive phase detects novel observations. """ from __future__ import annotations diff --git a/backend/src/agents/vector_memory.py b/backend/src/agents/vector_memory.py index 18f83d8e5b90739fd9832d9cf6b3456727cdf863..33919fe590e13f66c033aff2a652b077b4338053 100644 --- a/backend/src/agents/vector_memory.py +++ b/backend/src/agents/vector_memory.py @@ -1,15 +1,10 @@ -"""vector_memory — persistent semantic long-term memory (Qdrant + Gemini). +"""Persistent, per-persona Cloud Qdrant long-term memory and RAG retrieval. -Archives each agent's completed days as memory records, embeds queries -with Gemini embeddings, and retrieves with a 65/20/15 semantic/importance/ -recency score plus diversity and storage-quota constraints. - -Architecture: the storage engine behind Long_term.py; called at day -handoff (archive), by planning (retrieval), and by brain decisions. -Design: importance is a static per-kind table, recency decays by real -days, and retention pruning keeps the store within a storage budget. +Qdrant is the sole long-term store. The active day's short-term JSON is +converted into durable memory records during handoff; once indexing succeeds, +that operational file can be removed. Retrieval returns query-relevant, +ranked context for model prompts. """ - from __future__ import annotations import hashlib diff --git a/backend/src/auth/__init__.py b/backend/src/auth/__init__.py index b6ccf501fe97939dca4990df2875c8efe3af7b1f..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 --- a/backend/src/auth/__init__.py +++ b/backend/src/auth/__init__.py @@ -1,9 +0,0 @@ -"""auth — admin authentication package for the Valhalla web dashboard. - -Exposes the session manager (manager.py) and HTTP routes (routes.py) that -protect simulation-control and roster endpoints in Odin.py. - -Design: viewers can watch the simulation unauthenticated; only control -endpoints require a session. -""" - diff --git a/backend/src/auth/manager.py b/backend/src/auth/manager.py index 71dd1ad7fc6cacc63a213f8a6d5b31116378fe43..e6a403caa2c60153779eb6b219919d9732e37bbd 100644 --- a/backend/src/auth/manager.py +++ b/backend/src/auth/manager.py @@ -1,12 +1,9 @@ -"""manager — in-memory admin session authentication. - -Loads email:password pairs from ADMIN_CREDENTIALS, hashes with salted -scrypt, issues 24-hour bearer tokens, and validates/revokes them. +""" +In-memory session-based authentication for Valhalla web admin. -Architecture: used by auth/routes.py to guard all sim-control and roster -endpoints in Odin.py. -Design: in-memory sessions are intentionally simple (lost on restart); -when no credentials are configured, login is disabled with a warning. +Admin credentials come from the ADMIN_CREDENTIALS env var as +email:password pairs separated by semicolons. Sessions are stored +in-memory (lost on server restart). """ import os diff --git a/backend/src/auth/routes.py b/backend/src/auth/routes.py index 8674477aa843d9ca0fb596c7dcdeeeaee85289af..4dff540ebd98391f372b03f35a4dbc605f32772e 100644 --- a/backend/src/auth/routes.py +++ b/backend/src/auth/routes.py @@ -1,11 +1,5 @@ -"""routes — HTTP endpoints for admin login, logout, and session check. - -Mounts POST /api/auth/login, POST /api/auth/logout, GET /api/auth/me on -the FastAPI app, backed by auth/manager.py. - -Architecture: a thin transport layer between the React dashboard and the -session store; consumed by frontend/src/hooks/useAuth.jsx. -Design: tokens travel as bearer headers; no cookie handling. +""" +Auth API routes — login, logout, session check. """ from fastapi import APIRouter, HTTPException, Header diff --git a/backend/src/config.py b/backend/src/config.py index 037c1c45b2b93239ee3f9ca95fe646dbe4e8d73b..2f648cb059ee43d9484c635e960dccaca780a87f 100644 --- a/backend/src/config.py +++ b/backend/src/config.py @@ -1,13 +1,7 @@ -"""config — single source of truth for every tunable in the simulation. - -Defines paths, clock parameters (tick length, speed), perception and -conversation radii, replan and budget caps, memory and LLM settings, and -API key handling, with precedence CLI > environment > built-in default. - -Architecture: imported by virtually every module; never imports other -project modules, so it can be loaded without side effects. -Design: all settings are overridable via SIM_* environment variables so -experiments can vary parameters without code changes. +""" +Project-wide path configuration. +Resolves the project root, backend, frontend, data, and output directories +so all modules can reference consistent paths. """ from pathlib import Path @@ -184,15 +178,6 @@ MEMORY_STORAGE_PRUNE_TARGET = min(MEMORY_STORAGE_PRUNE_THRESHOLD, max(0.05, _env # Cap on full day-plan regenerations triggered mid-day per agent (budget guard). MAX_REPLANS_PER_AGENT_PER_DAY = _env_int("SIM_MAX_REPLANS_PER_AGENT_PER_DAY", 3) -# Deterministic backstop for agents stranded on the "Unscheduled downtime" -# fallback schedule. Every tick, the engine checks remaining plans; an -# agent with upcoming downtime gets a remaining-day replan. The cooldown -# (in ticks) stops a repeatedly-rejected replan from hammering the planner, -# and the horizon (in minutes) skips replans when too little of the day is -# left to be worth one. -DOWNTIME_REPLAN_COOLDOWN_TICKS = _env_int("SIM_DOWNTIME_REPLAN_COOLDOWN_TICKS", 60) -DOWNTIME_REPLAN_MIN_HORIZON = _env_int("SIM_DOWNTIME_REPLAN_MIN_HORIZON", 60) - # Budget governor: soft ceiling on LLM calls per real hour across the whole sim. # 0 = no ceiling. When exceeded, cognition degrades gracefully (skip reflex, # defer replans) — the sim keeps running on the 0-LLM executor path. @@ -219,20 +204,12 @@ SIM_CREATIVITY = min(1.0, max(0.0, _env_float("SIM_CREATIVITY", 1.0))) # independent from creativity: an observer can ask for more varied plans # without making students' energy and mood unrealistically volatile. SIM_WELLBEING_VARIABILITY = min(1.0, max(0.0, _env_float("SIM_WELLBEING_VARIABILITY", 0.75))) -# How fast the runtime glides an agent's energy toward the day planner's -# declared per-action energy_target. This is a control rate, not an energy -# value: 0.0 disables the glide (pure delta-based energy, as before), larger -# values converge faster (0.03 => ~84% of the gap closed per 60-min action). -SIM_ENERGY_FOLLOW_RATE = _env_float("SIM_ENERGY_FOLLOW_RATE", 0.03) TEMPERATURE = 0.7 + (0.4 * SIM_CREATIVITY) # planning and decisions: 1.1 at lively CONVERSATION_TEMPERATURE = 0.6 + (0.4 * SIM_CREATIVITY) # 1.0 at lively SUMMARY_TEMPERATURE = 0.5 -# The simulation uses a primary model, with a same-key fallback model. -# ``gemini_client`` tries the primary model on each key, then the fallback -# model on the same key, before advancing to the next key; that key -# rotation is the only provider-recovery behaviour. -GEMINI_MODEL = _env_str("SIM_GEMINI_MODEL", "gemini-3.5-flash-lite") -GEMINI_MODEL_FALLBACK = _env_str("SIM_GEMINI_MODEL_FALLBACK", "gemini-3.1-flash-lite") +# The simulation intentionally uses one model. Key traversal, implemented in +# ``gemini_client``, is the only provider recovery behaviour. +GEMINI_MODEL = _env_str("SIM_GEMINI_MODEL", "gemini-3.1-flash-lite") # Support multiple API keys (comma-separated in env var). When numbered # variables are used, they are read in ascending numeric order. @@ -305,7 +282,6 @@ _OVERRIDE_MAP = { "decide_cooldown_ticks": "DECIDE_COOLDOWN_TICKS", "conversation_min_energy": "CONVERSATION_MIN_ENERGY", "conversation_min_emotion": "CONVERSATION_MIN_EMOTION", - "energy_follow_rate": "SIM_ENERGY_FOLLOW_RATE", "day_handoff_conversation_timeout_seconds": "DAY_HANDOFF_CONVERSATION_TIMEOUT_SECONDS", } @@ -374,11 +350,10 @@ def describe_settings() -> str: return ( "Valhalla simulation settings\n" f" API keys loaded : {API_KEY_COUNT} (head resets to index 1/call)\n" - f" Gemini model : {GEMINI_MODEL} (fallback {GEMINI_MODEL_FALLBACK} on same key)\n" + f" Gemini model : {GEMINI_MODEL}\n" f" Simulation creativity : {SIM_CREATIVITY:.2f} " f"(plan/decision {TEMPERATURE:.2f}, conversation {CONVERSATION_TEMPERATURE:.2f}, summary {SUMMARY_TEMPERATURE:.2f})\n" f" Wellbeing variation : {SIM_WELLBEING_VARIABILITY:.2f}\n" - f" Energy follow rate : {SIM_ENERGY_FOLLOW_RATE:.3f}/sim-min\n" f" Memory backend : {MEMORY_BACKEND}\n" f" Semantic memory : {'ON' if SEMANTIC_MEMORY_ENABLED else 'OFF'}\n" f" Perception : {'ON' if PERCEPTION_ENABLED else 'OFF'} (radius {PERCEPTION_RADIUS_PX}px)\n" diff --git a/backend/src/core/agent_registry.py b/backend/src/core/agent_registry.py index 483c225b99c133bba5d2093435b73381f7be66a6..64e3e416cf4c8827408a7238aeff15730c20613e 100644 --- a/backend/src/core/agent_registry.py +++ b/backend/src/core/agent_registry.py @@ -1,13 +1,12 @@ -"""agent_registry — single source of truth for every agent's runtime state. +""" +Agent Registry — single source of truth for every agent's runtime state. -Holds one AgentRuntimeState per agent (persona, position, day plan, -wellbeing, conversation state, replan budget) and the AgentRegistry that -the engine, Actions, conversations, and memory all read and write. +The WorldEngine owns one `AgentRegistry` instance. All modules (Actions, +conversation, day_planner, Short_term) read from and write to it through +the engine — never directly. -Architecture: central state container consumed by every subsystem; the -engine syncs it to WorldState each tick. -Design: consolidates position into one registry to eliminate the -dual-source position drift of earlier versions. +This replaces the dual-source problem where Actions.py had its own +position/action and WorldState had a separate copy that drifted. """ from __future__ import annotations diff --git a/backend/src/core/budget.py b/backend/src/core/budget.py index ea0b896e96bcf5d2602c80073f0cbdde9a030579..7356b2ebb8c64df75415165edc6283d87c1df6a5 100644 --- a/backend/src/core/budget.py +++ b/backend/src/core/budget.py @@ -1,14 +1,20 @@ -"""budget — LLM call budget governor. - -Tracks all LLM calls in a rolling one-real-hour window and answers -can_afford(kind, cost) so cognitive steps degrade gracefully when a soft -hourly ceiling is exceeded; the simulation keeps running on its 0-LLM -executor path. - -Architecture: a process-wide singleton (GOVERNOR) consulted by the -engine's decide/replan phases and by gemini_client.py. -Design: cost weights reflect relative expense (decide=1, replan=4); the -ceiling is opt-in (SIM_LLM_HOURLY_CEILING, default 0 = unlimited). +""" +LLM budget governor -- one place that knows how much LLM spend has happened +recently and whether the simulation can afford more. + +Why this exists +--------------- +Free-tier Gemini keys are scarce (a handful of keys, a few requests/minute +each). Turning on perception + proximity conversations + a decision-making +brain could, if left ungated, burn the whole quota in minutes. Every +cognitive call site (day planner, conversation, reflex escalation) asks the +governor `can_afford()` before spending, and calls `record()` after. When the +soft ceiling is exceeded the governor says "no", and the caller degrades +gracefully -- the simulation keeps running on its 0-LLM executor path. + +The governor is intentionally simple: a rolling one-real-hour window of call +timestamps, plus lifetime counters for observability (used by the budget +stress test and the on-screen/logged stats). """ from __future__ import annotations diff --git a/backend/src/core/checkpoint_manager.py b/backend/src/core/checkpoint_manager.py index 6dfd7245312b825ecd1cffd19835a03b32b856f4..30f85576cd6617b1938f6e466e93667c76c44272 100644 --- a/backend/src/core/checkpoint_manager.py +++ b/backend/src/core/checkpoint_manager.py @@ -1,13 +1,14 @@ -"""checkpoint_manager — per-tick world state persistence. +""" +Checkpoint Manager — per-tick state save/load for crash recovery. -Saves WorldState, AgentRegistry (including the serialized action manager), -engine-owned state, and Python RNG state to gzip JSON after every tick, -and loads them for resume/rewind; prunes to a one-day window. +Saves WorldState + AgentRegistry after every tick to compressed +``backend/data/checkpoints/tick_{00001}.json.gz`` files. -Architecture: called by WorldEngine each tick and at day handoff; enables -the server's resume/rewind endpoints and the --resume-checkpoint flag. -Design: atomic writes (.tmp + rename); saved RNG state makes collision -resolution replay identically. +Supports: + - Save: full simulation state as JSON + - Load: reconstruct from any saved tick + - List: available checkpoint ticks + - Prune: auto-delete old checkpoints, keep last N """ from __future__ import annotations diff --git a/backend/src/core/log.py b/backend/src/core/log.py index 27065adddac68090500a1fe0e95508ccf4174bbd..bc1830163a257aacc737d6a46e3a90065b44492f 100644 --- a/backend/src/core/log.py +++ b/backend/src/core/log.py @@ -1,13 +1,27 @@ -"""log — centralized logging setup for the whole project. +""" +Centralized logging setup for the whole project. Every module imports +`get_logger(__name__)` from here instead of calling `logging.basicConfig` +or `logging.getLogger` directly, so the format + destinations stay +identical everywhere + +Usage +----- +Call `setup_logging()` ONCE, as early as possible in the process (top of +whatever your real entrypoint is -- backend/main.py, or the `run()` / +`__main__` block of a standalone script): + + from src.core.log import setup_logging, get_logger + setup_logging(run_id="run_name") # run_id is optional + logger = get_logger(__name__) -Every module imports get_logger(__name__) here so format, destinations, -and level stay consistent across the engine, planner, and server. +Every other module then just does: -Architecture: a dependency of every backend module; no business logic. -Design: logs land in backend/output/logs with timestamps for post-hoc -analysis of long runs. Console output is off by default — the admin log -terminal (src/core/log_relay.py) mirrors every line to the live log file -and the admin-only /api/logs endpoints instead. + from src.core.log import get_logger + logger = get_logger(__name__) + +If some module gets imported/used before setup_logging() runs (import +order accidents happen), get_logger() will lazily call setup_logging() +with defaults so you still get sane output instead of silence. """ from __future__ import annotations @@ -39,7 +53,7 @@ def _build_log_path(run_id: Optional[str] = None) -> Path: def setup_logging( level: int = DEFAULT_LEVEL, run_id: Optional[str] = None, - console: bool = False, + console: bool = True, file: bool = True, max_bytes: int = 5 * 1024 * 1024, backup_count: int = 5, @@ -55,8 +69,7 @@ def setup_logging( run_id -- optional tag folded into the log filename, e.g. a persona name or simulation id, so a run's logs are easy to find in output/logs/ - console -- also stream logs to stdout (default off; watch the - admin log terminal instead) + console -- also stream logs to stdout file -- also write logs to output/logs/[_].log max_bytes / backup_count -- rotation settings for the file handler @@ -116,14 +129,6 @@ def setup_logging( logging.config.dictConfig(config) _configured = True - # Mirror every emitted line into the admin log terminal's buffer and the - # live log file, regardless of the console/file toggles above. - try: - from src.core.log_relay import install_relay - install_relay() - except Exception: - pass - if log_path: logging.getLogger(__name__).info("[log] logging initialized -> %s", log_path) diff --git a/backend/src/core/log_relay.py b/backend/src/core/log_relay.py deleted file mode 100644 index 924facbf4c276873e3ee020d5a46df87353d6e7b..0000000000000000000000000000000000000000 --- a/backend/src/core/log_relay.py +++ /dev/null @@ -1,82 +0,0 @@ -"""log_relay — mirror of every project log line for the admin log terminal. - -Attaches a second handler to the ROOT logger: each emitted record is -formatted with the project's standard format and appended to a bounded -in-memory ring buffer (polled by the admin-only /api/logs endpoints) and -to a live file backend/output/logs/live.log, so the same output also -survives restarts and stays viewable without the Space console. - -Architecture: installed once by src/core/log.py setup_logging(); consumed -by Odin.py's admin-gated endpoints and the frontend LogTerminal panel. -Design: stdlib-only, a one-way mirror — never changes existing handlers, -levels, or the file rotation policy. -""" - -from __future__ import annotations - -import logging -import threading -from collections import deque -from itertools import count - -from src.config import LOG_DIR -from src.core.log import DATE_FORMAT, LOG_FORMAT - -MAX_LINES = 2000 -LIVE_LOG_PATH = LOG_DIR / "live.log" - -_seq = count(1) -_buffer: deque = deque(maxlen=MAX_LINES) -_latest_seq = 0 -_lock = threading.Lock() -_installed = False - - -class LogRelayHandler(logging.Handler): - """Appends each formatted record to the ring buffer and the live file.""" - - def __init__(self, level: int = logging.NOTSET) -> None: - super().__init__(level) - self._formatter = logging.Formatter(LOG_FORMAT, DATE_FORMAT) - - def emit(self, record: logging.LogRecord) -> None: - global _latest_seq - try: - text = self._formatter.format(record) - except Exception: - text = f"{record.name} | {record.getMessage()}" - with _lock: - _latest_seq = next(_seq) - _buffer.append({"seq": _latest_seq, "level": record.levelname, "text": text}) - try: - LOG_DIR.mkdir(parents=True, exist_ok=True) - with open(LIVE_LOG_PATH, "a", encoding="utf-8") as fh: - fh.write(text + "\n") - except OSError: - pass - - -def install_relay() -> None: - """Attach the relay handler to the root logger exactly once.""" - global _installed - if _installed: - return - logging.getLogger().addHandler(LogRelayHandler()) - _installed = True - - -def relay_lines(since: int = 0) -> dict: - """Return relayed entries with seq > since, plus the latest seq as cursor. - - The cursor tracks the last emitted sequence, so polling stays - incremental even after the buffer is cleared. - """ - with _lock: - lines = [entry for entry in _buffer if entry["seq"] > since] - return {"lines": lines, "next": _latest_seq} - - -def clear_relay() -> None: - """Drop the in-memory buffer (the live file is intentionally kept).""" - with _lock: - _buffer.clear() \ No newline at end of file diff --git a/backend/src/core/perceive.py b/backend/src/core/perceive.py index b898d3b19543c1d560fc760937d3651c36d44f08..8d3e57257ab8f4ad65a5d5d6e0f182b05508d04e 100644 --- a/backend/src/core/perceive.py +++ b/backend/src/core/perceive.py @@ -1,12 +1,13 @@ -"""perceive — turns a frozen WorldSnapshot into what one agent can observe. +""" +Perceive -- turns a WorldSnapshot into what one agent can currently observe. -Pure functions, no LLM, no side effects: builds nearest-first Observation -lists in both tile (Chebyshev) and pixel (Euclidean) spaces. +This is a pure function of snapshot data: no LLM calls, no side effects. +With only one agent registered (your current single-agent phase), this +naturally returns an empty list every tick -- no special-casing needed to +"turn on" perception later, it already does the real spatial query. -Architecture: used by the engine's perceive phase via snapshot -agents_within_px; the novelty fingerprint that gates LLM decisions is -computed from these observations. -Design: strict purity keeps perception reproducible and cheap. +Radius/distance logic itself lives on `WorldSnapshot` (core/snapshot.py) so +there's exactly one implementation of "who's nearby" in the codebase. (in snapshot.py) """ from __future__ import annotations diff --git a/backend/src/core/runtime_health.py b/backend/src/core/runtime_health.py index 93fd9eda26d977484aa5ec3eedafc08e00bd70ec..26c56ca1ca28489831258132ab49923e9c06630e 100644 --- a/backend/src/core/runtime_health.py +++ b/backend/src/core/runtime_health.py @@ -1,14 +1,4 @@ -"""runtime_health — bounded per-tick anomaly detection for the live sim. - -Scans for stalled travel, overdue actions, position desyncs, and -conversation timeouts; emits reports every N ticks or immediately on -anomaly, feeding the dashboard debug panel and the sidecar monitor. - -Architecture: called by WorldEngine at the end of each tick; consumed by -the frontend snapshot (health block). -Design: state is O(agents) and history-free, so monitoring never grows -with runtime. -""" +"""Bounded runtime health checks for a live Valhalla simulation.""" from __future__ import annotations diff --git a/backend/src/core/snapshot.py b/backend/src/core/snapshot.py index 145a8d6db2a09d3f966524b2e875bb165c49fc9d..86adc8080975e3082e3cd00846952369127d6c03 100644 --- a/backend/src/core/snapshot.py +++ b/backend/src/core/snapshot.py @@ -1,12 +1,34 @@ -"""snapshot — immutable point-in-time view of WorldState. +""" +Snapshot -- point-in-time read-only view of WorldState (Immutable) + +Every tick takes exactly one `WorldSnapshot` and hands the +*same* frozen object to every agent's tick graph via asyncio.gather(). That's +what makes parallel agent decisions safe -- nobody is reading a WorldState +that's being mutated mid-tick by someone else's action. + +`WorldSnapshot` is a deliberately separate class from `WorldState`, not just +a deep copy of it. It exposes zero mutating methods, so there is no method +an agent's perceive/react/plan code could accidentally call that would +corrupt the resolve phase's assumptions. If you need a new read-only query +(e.g. "what's the nearest free table"), add it here as a method on +`WorldSnapshot` -- don't reach into `.agents`/`.occupancy` directly from +perceive.py and reimplement the same query logic in multiple places. + +Usage +----- + from src.core.world_state import WorldState, Position + from src.core.snapshot import take_snapshot + + world = WorldState() + world.register_agent("gurnoor", Position(x=4, y=2, location_id="dorm_room_1")) -The engine freezes exactly one WorldSnapshot per tick and hands the same -object to every agent's parallel phase, so reads can never observe a -mid-tick mutation. + snap = take_snapshot(world) # take ONCE per tick, before decide phase + snap.get_agent("gurnoor") # read-only query + snap.agents_near("gurnoor", radius=3) + snap.is_free("cafeteria_table_3") -Architecture: consumed by perceive.py, the engine's observation builder, -and the frontend snapshot projection. -Design: frozen data structures; agents see the same world each tick. + # snap.tick = 999 <- raises, frozen model + # snap.agents["x"] = ... <- raises, frozen model """ from __future__ import annotations diff --git a/backend/src/core/tick_graph.py b/backend/src/core/tick_graph.py index ea6fc676d43ec12c85aec4d67a7da77b4bb10a55..314215b1f3ea479265a8275016636f095d52c5b6 100644 --- a/backend/src/core/tick_graph.py +++ b/backend/src/core/tick_graph.py @@ -1,13 +1,20 @@ -"""tick_graph — per-agent LangGraph pipeline and standalone CLI debug tool. +""" +Agent -- per-agent LangGraph subgraph + standalone CLI debug tool. + +Production pipeline (used by WorldEngine via build_tick_graph): + + perceive -> retrieve_memories -> react --[replan]--> day_planner -> write_back_memory + \\_[continue]__> keep_current / + +Only agents where scheduler.py's `agents_ready_for_decision()` returns +True are invoked each tick -- mid-action agents are skipped entirely. + +Standalone CLI debug mode (python Agent.py ): -Defines the perceive -> retrieve_memories -> react -> day_planner -> -write_back_memory subgraph and a day-planning CLI. + retrieve_memories from Short_term -> call day_planner.run() -> print plan table -Architecture: the graph the engine was designed around; the production -WorldEngine currently implements its own phase loop and calls brain.py -directly, so this module serves as the reference single-agent pipeline. -Design: kept as the canonical per-agent graph for experiments and -debugging; its memory-stream integration is intentionally pluggable. +Useful for testing a single persona's day plan without standing up the +full tick loop. """ from __future__ import annotations diff --git a/backend/src/core/world_engine.py b/backend/src/core/world_engine.py index 18fd9d7e501d9799fac0e7a99c660071756ca1e9..ddfdc7b7a6c39bca5f056ad390c75ff652f02390 100644 --- a/backend/src/core/world_engine.py +++ b/backend/src/core/world_engine.py @@ -1,15 +1,16 @@ -"""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. +""" +WorldEngine — the main simulation orchestrator. + +Controls the tick loop: advances time, runs agent actions in parallel, +detects proximity for conversations, handles end-of-day transitions, +and keeps WorldState in sync with the agent registry. + +Usage: + from src.core.world_engine import WorldEngine + + engine = WorldEngine() + await engine.initialize() + await engine.run(max_ticks=1440) # one full day at 1 tick/sec """ from __future__ import annotations @@ -17,7 +18,6 @@ from __future__ import annotations import asyncio import hashlib import json -import math import random import sys import time as _time @@ -126,10 +126,6 @@ class WorldEngine: # 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] = {} @staticmethod def _conversation_key(first_id: str, second_id: str) -> str: @@ -353,41 +349,69 @@ class WorldEngine: 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. + def _action_wellbeing_deltas(self, state: AgentRuntimeState, action: Any) -> tuple[float, float]: + """Compute a deterministic total wellbeing effect for one action. - 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. + LLM-supplied deltas are useful hints, but are normally very small. A + shared local activity model therefore gives classes, travel, rest, and + social time their ordinary human cost or benefit. The small stable + variation is keyed by agent/action, rather than sampled each tick, so + replaying a checkpoint remains 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) + action_type = str(getattr(action, "action_type", "")).lower() + # The planner can add personality-specific flavour, but it must not + # turn an otherwise restorative meal or quiet break into a day-long + # energy drain. The local physical activity model is authoritative. + planner_energy = max(-0.08, min(0.08, float(getattr(action, "energy_change", 0.0)))) + planner_emotion = max(-0.12, min(0.12, float(getattr(action, "emotion_change", 0.0)))) + energy, emotion = 0.0, 0.0 + + if action_type.endswith("move") or any(word in description for word in ("walk", "travel", "commute", "go to")): + energy, emotion = -0.075, -0.008 + elif "sleep" in description: + energy, emotion = 0.50, 0.025 + elif any(word in description for word in ("nap", "rest", "recharge", "lie down")): + energy, emotion = 0.20, 0.020 + elif any(word in description for word in ( + "meme", "memes", "scroll", "social media", "youtube", "video", + "reading for pleasure", "quiet reading", "reading quietly", "bench", "downtime", + "free time", "relax", "relaxing", "wind-down", "wind down", + )): + energy, emotion = 0.090, 0.025 + elif any(word in description for word in ("class", "lecture", "lab", "tutorial", "study", "assignment", "coding", "project", "exam")): + energy, emotion = -0.070, -0.025 + elif any(word in description for word in ("gym", "sport", "run", "football", "basketball", "badminton", "workout", "cardio", "weightlift", "training")): + energy, emotion = -0.180, 0.075 + elif any(word in description for word in ("breakfast", "lunch", "dinner", "meal", "food", "tea", "chai", "eat", "eating")): + energy, emotion = 0.130, 0.025 + elif any(word in description for word in ("friends", "club", "music", "open mic", "game", "movie", "social", "hangout")): + energy, emotion = 0.015, 0.075 + elif any(word in description for word in ("laundry", "clean", "errand", "admin", "queue", "chore")): + energy, emotion = -0.080, -0.025 + elif any(word in description for word in ("stand", "standing", "wait", "waiting")): + energy, emotion = -0.040, -0.005 + else: + # Neutral, seated or low-intensity tasks should not silently push + # every agent toward exhaustion merely because their wording was + # not anticipated above. + energy, emotion = -0.005, 0.0 + + # Introverted students generally enjoy a good conversation but spend + # more energy on it; this keeps personality visible without judging it. + traits = " ".join(str(state.persona.get(key, "")) for key in ("innate", "lifestyle", "learned")).lower() + if any(word in description for word in ("friends", "club", "social", "hangout")) and any( + marker in traits for marker in ("introverted", "quiet", "reserved") + ): + energy -= 0.03 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 + energy += planner_energy + jitter * 0.035 * variation + emotion += planner_emotion + jitter * 0.045 * variation + return max(-0.28, min(0.30, energy)), max(-0.18, min(0.16, emotion)) def _memory_context(self, persona_name, persona, before_date=None, query_hint=""): """Build (relevant_memories, rolling_summary) for a day-planner call. @@ -508,8 +532,6 @@ class WorldEngine: "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", []), }, ), @@ -679,26 +701,6 @@ class WorldEngine: 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) @@ -885,8 +887,6 @@ class WorldEngine: "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", []), }, ), @@ -913,38 +913,6 @@ class WorldEngine: "[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: @@ -1064,11 +1032,15 @@ class WorldEngine: 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) + action_energy_change, action_emotion_change = self._action_wellbeing_deltas(state, action) 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)) + state.energy_level = max(0.08, min(0.97, state.energy_level + energy_tick)) + baseline = state.emotion_baseline + # Mood has a weak pull towards personality baseline, but day + # events are allowed to remain visible for several actions. + recovery = (baseline - state.emotion_state) * min(0.015, 0.0005 * tick_step) + state.emotion_state = max(0.10, min(0.90, state.emotion_state + emotion_tick + recovery)) except Exception: pass @@ -1511,14 +1483,22 @@ class WorldEngine: 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))) + # relationship score. A warm chat is a modest lift; an awkward one is + # draining. The effect is applied once per completed conversation. + relationship_delta = max(-0.20, min(0.20, conv_result.relationship_delta)) + sentiment = (getattr(conv_result, "sentiment", "neutral") or "neutral").lower() + for state in (a, b): + social_cost = 0.045 if any(marker in " ".join( + str(state.persona.get(key, "")) for key in ("innate", "lifestyle", "learned") + ).lower() for marker in ("introverted", "quiet", "reserved")) else 0.025 + state.energy_level = max(0.08, min(0.97, state.energy_level - social_cost)) + if sentiment in ("positive", "warm", "friendly"): + mood_delta = 0.035 + max(0.0, relationship_delta) * 0.25 + elif sentiment in ("negative", "tense", "awkward"): + mood_delta = -0.035 + min(0.0, relationship_delta) * 0.25 + else: + mood_delta = relationship_delta * 0.08 + state.emotion_state = max(0.10, min(0.90, state.emotion_state + mood_delta)) logger.info( "[WorldEngine] conversation '%s' <-> '%s' active until tick %d", a.persona_name, b.persona_name, self.world.tick + conv_result.duration_minutes, @@ -1546,8 +1526,6 @@ class WorldEngine: "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", []) @@ -1802,10 +1780,7 @@ class WorldEngine: 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." + "physical and emotional state; do not abruptly relocate them." ) async def _plan_next_day(state: AgentRuntimeState) -> tuple[AgentRuntimeState, list]: @@ -1828,8 +1803,6 @@ class WorldEngine: "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", []), }, ) @@ -1858,7 +1831,6 @@ class WorldEngine: 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() diff --git a/backend/src/core/world_events.py b/backend/src/core/world_events.py index 97e30dcb0b1f66bb522db925e9193b41837d7369..f2c2186ae1184861193e2f123d973f53ede65216 100644 --- a/backend/src/core/world_events.py +++ b/backend/src/core/world_events.py @@ -1,13 +1,10 @@ -"""world_events — deterministic, LLM-free campus events. +"""Deterministic, data-driven campus events and safe plan opportunities. -Loads the event calendar, decides attendance from persona interest, social -score, and seeded noise, splices events into flexible plan windows, and -applies wellbeing/relationship effects once events end. - -Architecture: called by WorldEngine at init, day handoff, and restore; -consumes places/personas/relationship matrix and edits day plans. -Design: events only replace entirely flexible time windows (never classes -or sleep), keeping the calendar safe to apply automatically. +The event calendar is deliberately independent of the LLM. It makes an +attendance decision from a persona, its social context, schedule conflicts, +and a seeded tie-breaker, then edits only an entirely-flexible time window. +This keeps festivals and interruptions lively without allowing them to erase +classes, meals, sleep, exams, or an in-progress route. """ from __future__ import annotations diff --git a/backend/src/core/world_state.py b/backend/src/core/world_state.py index 201b5d54c4bbd155819f82dfb2f52c6d044d1b2d..e4e35c649b9f951d532decc46aaf1b627e6a6ad5 100644 --- a/backend/src/core/world_state.py +++ b/backend/src/core/world_state.py @@ -1,13 +1,39 @@ -"""world_state — the canonical mutable world. +""" +World State -- the single source of truth for the simulation. + +`WorldState` holds everything that is true about the world at a given tick: +where every agent is, what they're currently doing, and who/what currently +holds any contested resource (a chair, an NPC's attention, a location slot). + +Ownership rule: + Only `WorldEngine`'s resolve phase should ever call the mutating methods + on this class directly (`set_agent_action`, `move_agent`, `occupy`, ...). + + Every agent tick graph (perceive -> retrieve -> react -> day_planner -> + act) must only ever see a frozen copy produced by `core/snapshot.py`. + + That separation is what keeps the decide phase safely parallelizable + with asyncio.gather() -- nobody is reading a WorldState that something + else is mutating mid-tick. + +Usage +----- + from src.core.world_state import WorldState, Position + + world = WorldState() + world.register_agent("gurnoor", Position(x=4, y=2, location_id="dorm_room_1")) + world.register_resource("cafeteria_table_3") -Holds agent positions, occupancy, append-only history, the absolute tick -clock, and conversation cooldowns; only the engine's resolve phase may -mutate it. + # inside the resolve phase, after an agent's tick graph proposed an action: + world.occupy("cafeteria_table_3", "gurnoor") + world.set_agent_action("gurnoor", CurrentAction( + description="eating breakfast", + start_tick=world.tick, + end_tick=world.tick + 20, + target_object_id="cafeteria_table_3", + )) -Architecture: the bottom layer under snapshot.py (read view), checkpoint -manager, and the engine's sync step. -Design: mutation is restricted by convention — the engine mirrors the -registry into WorldState exactly twice per tick. + world.advance_tick(minutes=10) """ from __future__ import annotations diff --git a/backend/src/llm/gemini_client.py b/backend/src/llm/gemini_client.py index 909bd6072c73dcb8b4eb4fdd6e75f5a0f69cdfea..52c561713c528b182faffb792b5a8efb16d59c67 100644 --- a/backend/src/llm/gemini_client.py +++ b/backend/src/llm/gemini_client.py @@ -1,15 +1,8 @@ -"""gemini_client — the project's only LLM access point. - -Wraps Google Gemini with deterministic head-first key rotation (each call -tries every configured key exactly once), JSON-schema structured output, -embedding support, and a provider_failure circuit breaker that halts the -simulation cleanly when every key fails. - -Architecture: called by day_planner.py, brain.py, conversation.py, -vector_memory.py, and the roster generator; records spend with the budget -governor. -Design: deliberately no retries or timeouts in this module — resilience -lives in the engine's checkpoint/resume path and the budget governor. +"""Gemini access through a deterministic, head-first key ring. + +Every API call starts with the first configured key. On *any* exception it +tries the next node exactly once. There are deliberately no delays, retries, +timeouts, cooldowns, key reservations, or model changes in this module. """ from __future__ import annotations @@ -23,13 +16,7 @@ from google import genai from google.genai import types from pydantic import BaseModel -from src.config import ( - API_KEYS, - GEMINI_MODEL, - GEMINI_MODEL_FALLBACK, - MEMORY_EMBEDDING_MODEL, - TEMPERATURE, -) +from src.config import API_KEYS, GEMINI_MODEL, MEMORY_EMBEDDING_MODEL, TEMPERATURE from src.core.log import get_logger logger = get_logger(__name__) @@ -136,29 +123,28 @@ def call_gemini( complexity: str = "default", temperature: float = TEMPERATURE, ) -> BaseModel: - """Try both configured models on each key before advancing the ring.""" + """Call exactly one model, moving through the ring on any failure.""" errors: list[Exception] = [] for node in _new_ring().traverse_from_head(): - for model in (GEMINI_MODEL, GEMINI_MODEL_FALLBACK): - try: - response = _get_client(node.key).models.generate_content( - model=model, - contents=user_prompt, - config=types.GenerateContentConfig( - system_instruction=system_prompt, - response_mime_type="application/json", - response_schema=schema, - temperature=temperature, - thinking_config=types.ThinkingConfig(thinking_level="medium"), - ), - ) - result = response.parsed if getattr(response, "parsed", None) is not None else schema.model_validate(json.loads(response.text)) - logger.info("[gemini] model=%s key_index=%d ok", model, node.index) - _record_success(complexity) - return result - except Exception as exc: - errors.append(exc) - logger.warning("[gemini] model=%s key_index=%d failed (%s); advancing", model, node.index, type(exc).__name__) + try: + response = _get_client(node.key).models.generate_content( + model=GEMINI_MODEL, + contents=user_prompt, + config=types.GenerateContentConfig( + system_instruction=system_prompt, + response_mime_type="application/json", + response_schema=schema, + temperature=temperature, + thinking_config=types.ThinkingConfig(thinking_level="medium"), + ), + ) + result = response.parsed if getattr(response, "parsed", None) is not None else schema.model_validate(json.loads(response.text)) + logger.info("[gemini] model=%s key_index=%d ok", GEMINI_MODEL, node.index) + _record_success(complexity) + return result + except Exception as exc: + errors.append(exc) + logger.warning("[gemini] model=%s key_index=%d failed (%s); advancing", GEMINI_MODEL, node.index, type(exc).__name__) raise _quota_exhausted(GEMINI_MODEL, errors) diff --git a/backend/test_gemini_client.py b/backend/test_gemini_client.py deleted file mode 100644 index af571b4170ebcb5e13474d134734f930d3081f88..0000000000000000000000000000000000000000 --- a/backend/test_gemini_client.py +++ /dev/null @@ -1,60 +0,0 @@ -from pydantic import BaseModel - -from src.llm import gemini_client - - -class _Result(BaseModel): - value: str - - -class _Response: - parsed = _Result(value="ok") - - -class _Models: - def __init__(self, calls, failures): - self._calls = calls - self._failures = failures - - def generate_content(self, *, model, **_kwargs): - self._calls.append(model) - if model in self._failures: - raise RuntimeError(model) - return _Response() - - -class _Client: - def __init__(self, calls, failures): - self.models = _Models(calls, failures) - - -def test_fallback_model_runs_on_same_key_before_next_key(monkeypatch): - calls = [] - monkeypatch.setattr(gemini_client, "API_KEYS", ["key-1", "key-2"]) - monkeypatch.setattr(gemini_client, "GEMINI_MODEL", "primary") - monkeypatch.setattr(gemini_client, "GEMINI_MODEL_FALLBACK", "fallback") - monkeypatch.setattr(gemini_client, "_get_client", lambda key: _Client(calls, {"primary"})) - monkeypatch.setattr(gemini_client, "_record_success", lambda _complexity: None) - - result = gemini_client.call_gemini("system", "user", _Result) - - assert result.value == "ok" - assert calls == ["primary", "fallback"] - - -def test_key_advances_only_after_both_models_fail(monkeypatch): - calls = [] - monkeypatch.setattr(gemini_client, "API_KEYS", ["key-1", "key-2"]) - monkeypatch.setattr(gemini_client, "GEMINI_MODEL", "primary") - monkeypatch.setattr(gemini_client, "GEMINI_MODEL_FALLBACK", "fallback") - monkeypatch.setattr( - gemini_client, - "_get_client", - lambda key: _Client(calls, {"primary", "fallback"} if key == "key-1" else set()), - ) - monkeypatch.setattr(gemini_client, "_record_success", lambda _complexity: None) - - result = gemini_client.call_gemini("system", "user", _Result) - - assert result.value == "ok" - assert calls == ["primary", "fallback", "primary"] diff --git a/backend/tools/sidecar_monitor.py b/backend/tools/sidecar_monitor.py index a01d76a52c12e90371811b09662aec64448b07fe..3c24e355b4cfe2f5bb597bc7934b39743e84a8af 100644 --- a/backend/tools/sidecar_monitor.py +++ b/backend/tools/sidecar_monitor.py @@ -1,13 +1,11 @@ -"""sidecar_monitor — read-only, non-LLM overnight monitor. +"""Read-only, non-LLM overnight monitor for a running Valhalla simulation. -Polls the same state endpoint the frontend uses and writes machine- -readable JSONL plus a human-readable report of the night's run, -including health anomalies. +It polls the same state endpoint used by the frontend and writes two files: +* ``*.jsonl``: every sampled snapshot and every detected finding (machine-readable) +* ``*.txt``: a concise, chronological report suitable for morning review -Architecture: an external observer of Odin.py; never touches checkpoints -or control endpoints. -Design: read-only by construction so monitoring can never perturb the -simulation it observes. +The monitor never imports simulation modules, calls an LLM, writes checkpoints, +or invokes any control endpoint. Stop it with Ctrl+C; it flushes a final summary. """ from __future__ import annotations diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 854de5a2b6334e51145e57c5481e48c7e2f43adb..490935ebe75b43e7367f54d7b67cd1aa020c4dd3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,17 +1,3 @@ -/** - * App — root layout and state composer of the dashboard. - * - * Owns auth + simulation snapshot state, renders the map canvas, the - * agent windows, and all side panels, and handles error banners and - * day-handoff/provider-failure packets from the backend. - * - * Architecture: consumed by main.jsx; renders SimCanvas, InfoBar, - * ConversationFeed, EventsPanel, DebugPanel, RosterManager, LoginButton. - * - * Design: one component per concern, all fed from a single WebSocket - * snapshot hook (useSimState). - */ - import { useState, useEffect, useCallback } from "react"; import useSimState from "./hooks/useSimState"; import { AuthProvider, useAuth } from "./hooks/useAuth"; @@ -23,7 +9,6 @@ import EventsPanel from "./components/EventsPanel"; import DebugPanel from "./components/DebugPanel"; import RosterManager from "./components/RosterManager"; import LoginButton from "./components/LoginButton"; -import LogTerminal from "./components/LogTerminal"; import { apiUrl } from "./utils/api"; import "./App.css"; @@ -31,6 +16,7 @@ function compactTabPosition(index) { const side = index % 2; const row = Math.floor(index / 2); return { + // Reserve the full inspector width even while this card is compact. A // right-hand card can then expand without its controls leaving the view. x: side ? Math.max(16, window.innerWidth - 276) : 16, y: 66 + row * 92, @@ -57,7 +43,6 @@ function AppContent() { const [controlError, setControlError] = useState(null); const [simulationRunning, setSimulationRunning] = useState(true); const [rosterOpen, setRosterOpen] = useState(false); - const [showTerminal, setShowTerminal] = useState(false); useEffect(() => { if (!snapshot) return; @@ -155,7 +140,6 @@ function AppContent() { {showDebug && } setRosterOpen(false)} simulationRunning={simulationRunning} onError={setControlError} isAuthenticated={isAuthenticated} /> - {isAuthenticated && showTerminal && setShowTerminal(false)} />} {agentIds.map((id, index) => { const expanded = expandedAgentIds.has(id); @@ -188,8 +172,6 @@ function AppContent() { }} onToggleRoster={() => setRosterOpen((value) => !value)} isAuthenticated={isAuthenticated} - terminalOpen={showTerminal} - onToggleTerminal={() => setShowTerminal((value) => !value)} /> diff --git a/frontend/src/components/ActionDetail.jsx b/frontend/src/components/ActionDetail.jsx index a0e75907bb4e5d9abf31b3eac4ccee18146b8e09..6e238bff14d480b010f25db8109a3d3309e0f3fc 100644 --- a/frontend/src/components/ActionDetail.jsx +++ b/frontend/src/components/ActionDetail.jsx @@ -1,14 +1,3 @@ -/** - * ActionDetail — renders an agent's current action description. - * - * Shows the action text with its time range and route progress for - * movement actions. - * - * Architecture: rendered inside AgentWindow's expanded inspector. - * - * Design: purely presentational; receives the action object as props. - */ - export default function ActionDetail({ action }) { if (!action) { return ( diff --git a/frontend/src/components/AgentWindow.jsx b/frontend/src/components/AgentWindow.jsx index fbfb6f0b0a95499596623707c23a7692cd9b4efb..2fcf21e45fdda90c9f6e44ceab877f27b0762e1b 100644 --- a/frontend/src/components/AgentWindow.jsx +++ b/frontend/src/components/AgentWindow.jsx @@ -1,17 +1,3 @@ -/** - * AgentWindow — per-agent draggable inspector card. - * - * Compact card by default; expanded view shows location, current action - * (via ActionDetail), energy/emotion gauges, pause state, and the agent's - * live conversation transcript (ChatPanel). - * - * Architecture: one instance per agent, laid out by App.jsx; fed from the - * shared simulation snapshot. - * - * Design: windows are draggable (react-draggable) so multiple agents can - * be inspected simultaneously. - */ - import { useRef, useState, useEffect } from "react"; import Draggable from "react-draggable"; import WindowHeader from "./WindowHeader"; @@ -40,6 +26,7 @@ export default function AgentWindow({ agentId, data, speed, defaultPosition, exp const conversationId = conversation ? `${conversation.partner_id || conversation.partner_name}_${conversation.started_tick ?? "pending"}` : null; + // A transcript belongs to the live conversation state, not to the agent // card's lifetime. The backend clears this state when the simulated chat // finishes; rendering only an active/generating conversation automatically // collapses the chat panel as the agent starts their next task. diff --git a/frontend/src/components/ChatBubble.jsx b/frontend/src/components/ChatBubble.jsx index 3a2ad94c55462750b7c0f9ae8c5c021e58ebdc00..ad3fb4dc49a5410425d5e382f2888b5b4c904fc1 100644 --- a/frontend/src/components/ChatBubble.jsx +++ b/frontend/src/components/ChatBubble.jsx @@ -1,14 +1,3 @@ -/** - * ChatBubble — a single message bubble in a conversation transcript. - * - * Aligns right for the focused agent (self) and left for the partner, - * colored by the speaker's agent color. - * - * Architecture: rendered by ChatPanel for each revealed message. - * - * Design: purely presentational; no state. - */ - export default function ChatBubble({ text, isSelf, color }) { return (
{ const clampToViewport = () => { const node = nodeRef.current; diff --git a/frontend/src/components/DebugPanel.jsx b/frontend/src/components/DebugPanel.jsx index 3adf61ce56d2c4c8878101099f04e44bbd7fd5fe..c373efe08bc926bc886a2f25cc3e63348a39eb21 100644 --- a/frontend/src/components/DebugPanel.jsx +++ b/frontend/src/components/DebugPanel.jsx @@ -1,15 +1,3 @@ -/** - * DebugPanel — engine health readout for researchers. - * - * Shows tick, agent/moving/paused counts, background task counts, and - * per-agent anomalies reported by the backend health monitor. - * - * Architecture: toggled from InfoBar; fed by the snapshot's health block. - * - * Design: keeps runtime anomalies visible without cluttering the main - * dashboard. - */ - export default function DebugPanel({ health }) { if (!health) return null; return ( diff --git a/frontend/src/components/EventsPanel.jsx b/frontend/src/components/EventsPanel.jsx index 5e0998a2e4ef08621b16cd3046f3eed671a75f3b..a152cba3562289b9204b2ed2624c95e553679c8e 100644 --- a/frontend/src/components/EventsPanel.jsx +++ b/frontend/src/components/EventsPanel.jsx @@ -1,15 +1,3 @@ -/** - * EventsPanel — "campus pulse" list of live and upcoming events. - * - * Renders active events as LIVE and upcoming events with time range, - * category color, and attendance count vs capacity. - * - * Architecture: rendered by App.jsx from the snapshot's events block. - * - * Design: read-only; event data originates in the deterministic event - * calendar on the backend. - */ - const CATEGORY_COLOR = { "technical-cultural": "#5b9bd5", sports: "#51cf66", diff --git a/frontend/src/components/InfoBar.jsx b/frontend/src/components/InfoBar.jsx index 92edd4bfa3cb9d9d773f0253509a0dfcbd103c8f..b8081da9f70a0b2ae065c3778a4e7f200eaf7cd4 100644 --- a/frontend/src/components/InfoBar.jsx +++ b/frontend/src/components/InfoBar.jsx @@ -1,19 +1,6 @@ -/** - * InfoBar — status bar and timeline controls. - * - * Shows tick/time/day/pace and agent counts, plus auth-gated controls: - * rewind (ticks or hours), fast-forward, slow-down, stop/start, and the - * roster button. - * - * Architecture: rendered by App.jsx; posts to the backend's sim-control - * endpoints with the admin bearer token. - * - * Design: controls are hidden for unauthenticated viewers. - */ - import { useState } from "react"; -export default function InfoBar({ snapshot, showDebug, onToggleDebug, onFastForward, onSlowDown, onRewind, simulationRunning, onToggleSimulation, onToggleRoster, isAuthenticated, terminalOpen, onToggleTerminal }) { +export default function InfoBar({ snapshot, showDebug, onToggleDebug, onFastForward, onSlowDown, onRewind, simulationRunning, onToggleSimulation, onToggleRoster, isAuthenticated }) { const [rewindAmount, setRewindAmount] = useState("10"); const [rewindUnit, setRewindUnit] = useState("ticks"); if (!snapshot) return null; @@ -140,20 +127,11 @@ export default function InfoBar({ snapshot, showDebug, onToggleDebug, onFastForw {showDebug ? "DEBUG ON" : "DEBUG"} {isAuthenticated && ( - <> - - - + )}
); diff --git a/frontend/src/components/Legend.jsx b/frontend/src/components/Legend.jsx index 5c0b7e79cea03d101240e5741026b84bc123393c..0d65abe616977c4946e08eaad44fe09fd0071918 100644 --- a/frontend/src/components/Legend.jsx +++ b/frontend/src/components/Legend.jsx @@ -1,16 +1,4 @@ -/** - * Legend — color-to-name roster legend (unused, kept for reference). - * - * Maps each agent's dot color to their name and lets the user focus the - * camera by clicking a name. - * - * Architecture: not currently rendered — SimCanvas draws labels directly; - * retained as the planned clickable roster. - * - * Design: superseded by on-canvas labels; left in the tree until the - * focus UX is finalized. - */ - +// Color legend: maps each agent's dot color to their name. Click to focus. export default function Legend({ agents, focusedId, onFocus }) { if (!agents) return null; const entries = Object.entries(agents); diff --git a/frontend/src/components/LogTerminal.jsx b/frontend/src/components/LogTerminal.jsx deleted file mode 100644 index 7e9488020d02cd54ee7321f28f758856da70c19d..0000000000000000000000000000000000000000 --- a/frontend/src/components/LogTerminal.jsx +++ /dev/null @@ -1,189 +0,0 @@ -/** - * LogTerminal — admin-only live view of the backend log relay. - * - * Polls GET /api/logs?since= with the admin bearer token and - * renders the captured logger output as a read-only terminal. - * - * Architecture: rendered by App.jsx only for authenticated users; toggled - * by the TERMINAL button in InfoBar. - * - * Design: 2s REST polling reuses the existing bearer-token flow instead of - * adding WebSocket authentication; auto-scroll halts while the pointer is - * over the terminal so lines can be read mid-stream. - */ - -import { useEffect, useRef, useState, useCallback } from "react"; -import { useAuth } from "../hooks/useAuth"; -import { apiUrl } from "../utils/api"; - -const POLL_MS = 2000; -const LEVEL_COLORS = { - DEBUG: "#6b6b78", - INFO: "#8fbbe8", - WARNING: "#e7bd70", - ERROR: "#ff8b8b", - CRITICAL: "#ff6b6b", -}; - -export default function LogTerminal({ onClose }) { - const { token } = useAuth(); - const [lines, setLines] = useState([]); - const [cursor, setCursor] = useState(0); - const [error, setError] = useState(null); - const [paused, setPaused] = useState(false); - const bodyRef = useRef(null); - const pausedRef = useRef(false); - - const fetchNew = useCallback(async () => { - try { - const res = await fetch(apiUrl(`/api/logs?since=${cursor}`), { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - if (!res.ok) throw new Error(`Log fetch failed (${res.status})`); - const data = await res.json(); - if (data.lines?.length) { - setLines((previous) => [...previous.slice(-2000), ...data.lines]); - } - if (typeof data.next === "number") setCursor(data.next); - setError(null); - } catch (err) { - setError(err.message); - } - }, [cursor, token]); - - useEffect(() => { - fetchNew(); - const timer = setInterval(fetchNew, POLL_MS); - return () => clearInterval(timer); - }, [fetchNew]); - - useEffect(() => { - const el = bodyRef.current; - if (el && !pausedRef.current) el.scrollTop = el.scrollHeight; - }, [lines]); - - async function clearLogs() { - try { - const res = await fetch(apiUrl("/api/logs/clear"), { - method: "POST", - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - if (!res.ok) throw new Error(`Clear failed (${res.status})`); - setLines([]); - } catch (err) { - setError(err.message); - } - } - - async function copyLogs() { - try { - await navigator.clipboard.writeText(lines.map((line) => line.text).join("\n")); - } catch { - /* clipboard unavailable — ignore */ - } - } - - const headerButton = { - background: "rgba(91,155,213,.08)", - border: "1px solid rgba(91,155,213,.30)", - borderRadius: 3, - color: "#8fbbe8", - padding: "2px 6px", - fontFamily: "'Space Mono', monospace", - fontSize: 8, - cursor: "pointer", - }; - - return ( - - ); -} \ No newline at end of file diff --git a/frontend/src/components/LoginButton.jsx b/frontend/src/components/LoginButton.jsx index 8ff217aa8b667de85df7167dc50486a32dbc95e5..041f037329094f454268cbd183f08d3c145cc560 100644 --- a/frontend/src/components/LoginButton.jsx +++ b/frontend/src/components/LoginButton.jsx @@ -1,15 +1,3 @@ -/** - * LoginButton — auth entry point for admin controls. - * - * Shows a LOGIN pill when unauthenticated (opens LoginModal) and a user - * dropdown with logout when a session is active. - * - * Architecture: rendered by App.jsx; uses the useAuth context shared with - * InfoBar and RosterManager. - * - * Design: anonymous users can watch; only admins get controls. - */ - import { useState } from "react"; import { useAuth } from "../hooks/useAuth"; import LoginModal from "./LoginModal"; diff --git a/frontend/src/components/LoginModal.jsx b/frontend/src/components/LoginModal.jsx index ce118d1a2cafd0e21c31ebc7dafe35840b29a289..9ef0627af11d761f46b554153965988fb948c308 100644 --- a/frontend/src/components/LoginModal.jsx +++ b/frontend/src/components/LoginModal.jsx @@ -1,15 +1,3 @@ -/** - * LoginModal — email/password login form. - * - * Posts credentials to /api/auth/login and hands the session token to the - * AuthProvider on success. - * - * Architecture: opened by LoginButton; talks to backend auth routes. - * - * Design: modal overlay with inline error state; token is stored in - * localStorage by useAuth. - */ - import { useState } from "react"; const overlayStyle = { diff --git a/frontend/src/components/RosterManager.jsx b/frontend/src/components/RosterManager.jsx index 913b2efbea443600c08e1c862c215247f9a628a0..47f3593807f693d31b11b187e49626e45fec5bec 100644 --- a/frontend/src/components/RosterManager.jsx +++ b/frontend/src/components/RosterManager.jsx @@ -1,17 +1,3 @@ -/** - * RosterManager — admin panel for editing the agent roster. - * - * Add an agent from a natural-language description (backend generates the - * persona), rename an existing agent, or retire/archive one. Only allowed - * while the simulation is stopped; every edit writes a successor - * checkpoint. - * - * Architecture: gated by auth; calls /api/roster/* endpoints. - * - * Design: edits are checkpointed so the timeline stays a faithful record - * of which roster existed at each point. - */ - import { useEffect, useState } from "react"; import { useAuth } from "../hooks/useAuth"; import { apiUrl } from "../utils/api"; diff --git a/frontend/src/components/SimCanvas.jsx b/frontend/src/components/SimCanvas.jsx index c753d3b49e148d54f0f5759877e7222c17631034..a629fe5e4159efea17ce96a6cd8a99bf1677435b 100644 --- a/frontend/src/components/SimCanvas.jsx +++ b/frontend/src/components/SimCanvas.jsx @@ -1,23 +1,10 @@ -/** - * SimCanvas — the fullscreen map renderer. - * - * Draws the campus map (day + night blend by clock), agents as colored - * dots with labels and glow, camera follow on focus, and drag-to-pan, - * with smooth exponential interpolation between tick positions. - * - * Architecture: the visual heart of the dashboard, fed entirely by the - * snapshot; no direct backend calls beyond fetching map image assets. - * - * Design: interpolation hides tick granularity; night overlay is a time- - * based alpha blend, not a separate simulation state. - */ - import { useEffect, useRef } from "react"; import { lerp } from "../utils/lerp"; const LERP_FACTOR = 0.12; const CAM_LERP = 0.10; +// Night overlay opacity for a given sim hour (0..23): dark at night, clear midday. function nightAlphaForHour(h) { // 0.85 at 00–04 and 22–24, ~0 between 09 and 17, smooth ramps between. if (h >= 22 || h < 5) return 0.82; @@ -114,7 +101,7 @@ export default function SimCanvas({ snapshot, focusedId, onFocus }) { const img = mapImgRef.current; ctx.clearRect(0, 0, W, H); - ctx.fillStyle = "#000000"; + ctx.fillStyle = "#090d16"; ctx.fillRect(0, 0, W, H); const iw = img ? img.width : 1276; @@ -159,7 +146,7 @@ export default function SimCanvas({ snapshot, focusedId, onFocus }) { } } else { // Procedural campus background fallback if image is loading or missing - ctx.fillStyle = "#000000"; + ctx.fillStyle = "#0f172a"; ctx.fillRect(0, 0, iw, ih); // Draw campus grid lines diff --git a/frontend/src/components/WindowHeader.jsx b/frontend/src/components/WindowHeader.jsx index e2f61e1bb39dadd09499ee2d315049f15ac42e8c..4c2cd9c34fe4607249d391c473c339a7e8152619 100644 --- a/frontend/src/components/WindowHeader.jsx +++ b/frontend/src/components/WindowHeader.jsx @@ -1,15 +1,3 @@ -/** - * WindowHeader — agent window header with name and color. - * - * Shows the agent's name and color dot, plus an action-type badge - * (MOVE/MISC/CONVERSATION) that is currently unused by callers. - * - * Architecture: rendered inside AgentWindow. - * - * Design: the badge branch is prepared for future use; AgentWindow passes - * only name and color today. - */ - export default function WindowHeader({ name, color, actionType }) { const badgeColors = { move: "#2a7de1", diff --git a/frontend/src/hooks/useAuth.jsx b/frontend/src/hooks/useAuth.jsx index d0acaef113a17b4f75b1636ec74b41b05e76b495..6318003e9673cd795b43852435f80f86786069b2 100644 --- a/frontend/src/hooks/useAuth.jsx +++ b/frontend/src/hooks/useAuth.jsx @@ -1,17 +1,3 @@ -/** - * useAuth — authentication context provider. - * - * Validates a stored token against /api/auth/me on load and exposes - * login/logout backed by the backend auth routes; persists the token in - * localStorage. - * - * Architecture: wraps the whole app in App.jsx; consumed by InfoBar, - * LoginButton, LoginModal, and RosterManager. - * - * Design: one shared context so every auth-gated component reads the same - * session state. - */ - import { createContext, useContext, useState, useEffect, useCallback } from "react"; import { apiUrl } from "../utils/api"; diff --git a/frontend/src/hooks/useSimState.js b/frontend/src/hooks/useSimState.js index db98a2a51e781cd9fa1ed3d11644ab49e0370c76..eea8bdbd6370f02241ba9f31527fcd18fefdb9ba 100644 --- a/frontend/src/hooks/useSimState.js +++ b/frontend/src/hooks/useSimState.js @@ -1,15 +1,3 @@ -/** - * useSimState — subscribes to the simulation WebSocket. - * - * Connects to /ws/sim, stores the latest JSON snapshot, and auto-reconnects - * every 3 seconds on drop so the dashboard survives brief network hiccups. - * - * Architecture: the single data source for the whole dashboard; consumed - * by App.jsx and distributed to panels via props. - * - * Design: last-write-wins snapshot updates keep rendering stateless. - */ - import { useEffect, useRef, useState, useCallback } from "react"; import { wsUrl } from "../utils/api"; @@ -33,6 +21,7 @@ export default function useSimState() { }; ws.onclose = () => { + // Closing a socket during React cleanup is intentional. Reconnecting in // that case creates an orphan connection after the view has unmounted. if (disposedRef.current || wsRef.current !== ws) return; reconnectTimer.current = setTimeout(connect, 3000); diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 6797c9c8018ad76d4f78f4ac9776d7f520697487..78a3db17e61499c3485627b2399430afa840577c 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,14 +1,3 @@ -/** - * main — React entry point. - * - * Mounts into the #root element under StrictMode. - * - * Architecture: the first frontend file to run; everything else in - * frontend/src is rendered from here. - * - * Design: StrictMode is on to surface double-render bugs early. - */ - import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index e726b52feff876fc9babf162d1642003f35df3d0..b88e4438a15094e56d5e5a85b523e62eb3548705 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -1,14 +1,3 @@ -/** - * api — URL helpers for REST and WebSocket calls. - * - * apiUrl() prefixes REST paths with the configured VITE_API_URL base; - * wsUrl() converts an http(s) base to the ws(s) scheme for sockets. - * - * Architecture: imported by every component that talks to the backend. - * - * Design: keeps the base URL configuration in one place. - */ - const BASE = import.meta.env.VITE_API_URL || ""; export function apiUrl(path) { diff --git a/frontend/src/utils/lerp.js b/frontend/src/utils/lerp.js index 3699f3251eeffe5b8c40354218f1d77265a0ba0b..c627307f423754ebb0f7255fcbffc34e0d0a099c 100644 --- a/frontend/src/utils/lerp.js +++ b/frontend/src/utils/lerp.js @@ -1,14 +1,3 @@ -/** - * lerp — exponential interpolation helper for canvas motion. - * - * Moves a value a fixed fraction of the way toward a target each frame, - * producing smooth ease-out movement between tick snapshots. - * - * Architecture: used by SimCanvas to animate agents and camera. - * - * Design: a pure function with no state; callers hold the current value. - */ - export function lerp(current, target, factor) { return current + (target - current) * factor; }