Spaces:
Running
Running
Fix
#7
by Dontcryx - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
- backend/Odin.py +5 -37
- backend/Pathfinder_test.py +4 -8
- backend/data/environment/relationship_matrix.json +288 -2
- backend/data/personalities/amitabh/amitabh.json +22 -0
- backend/data/personalities/jarvis/jarvis.json +22 -0
- backend/pathfinder.py +4 -9
- backend/src/agents/Actions.py +18 -11
- backend/src/agents/Long_term.py +5 -10
- backend/src/agents/Short_term.py +8 -8
- backend/src/agents/Single_agent.py +21 -9
- backend/src/agents/autonomy.py +5 -8
- backend/src/agents/body.py +20 -10
- backend/src/agents/brain.py +8 -8
- backend/src/agents/conversation.py +28 -18
- backend/src/agents/daily_flavor.py +5 -9
- backend/src/agents/day_planner.py +82 -251
- backend/src/agents/memory_index.py +6 -8
- backend/src/agents/react.py +13 -11
- backend/src/agents/vector_memory.py +5 -10
- backend/src/auth/__init__.py +0 -9
- backend/src/auth/manager.py +5 -8
- backend/src/auth/routes.py +2 -8
- backend/src/config.py +8 -33
- backend/src/core/agent_registry.py +7 -8
- backend/src/core/budget.py +17 -11
- backend/src/core/checkpoint_manager.py +9 -8
- backend/src/core/log.py +24 -19
- backend/src/core/log_relay.py +0 -82
- backend/src/core/perceive.py +8 -7
- backend/src/core/runtime_health.py +1 -11
- backend/src/core/snapshot.py +29 -7
- backend/src/core/tick_graph.py +15 -8
- backend/src/core/world_engine.py +92 -120
- backend/src/core/world_events.py +6 -9
- backend/src/core/world_state.py +34 -8
- backend/src/llm/gemini_client.py +26 -40
- backend/test_gemini_client.py +0 -60
- backend/tools/sidecar_monitor.py +6 -8
- frontend/src/App.jsx +1 -19
- frontend/src/components/ActionDetail.jsx +0 -11
- frontend/src/components/AgentWindow.jsx +1 -14
- frontend/src/components/ChatBubble.jsx +0 -11
- frontend/src/components/ChatPanel.jsx +0 -13
- frontend/src/components/ConversationFeed.jsx +1 -13
- frontend/src/components/DebugPanel.jsx +0 -12
- frontend/src/components/EventsPanel.jsx +0 -12
- frontend/src/components/InfoBar.jsx +6 -28
- frontend/src/components/Legend.jsx +1 -13
- frontend/src/components/LogTerminal.jsx +0 -189
- frontend/src/components/LoginButton.jsx +0 -12
backend/Odin.py
CHANGED
|
@@ -1,13 +1,8 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
Serves the React
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
Architecture: the only entry point that runs the full system; depends on
|
| 8 |
-
src.core.world_engine, src.auth, pathfinder, and the frontend build.
|
| 9 |
-
Design: all sim-control endpoints are auth-gated; roster edits are only
|
| 10 |
-
allowed while the simulation is stopped.
|
| 11 |
"""
|
| 12 |
|
| 13 |
import os
|
|
@@ -235,30 +230,6 @@ async def require_admin(authorization: str = Header(None)):
|
|
| 235 |
return user
|
| 236 |
|
| 237 |
|
| 238 |
-
# ---------------------------------------------------------------------------
|
| 239 |
-
# Admin log relay — live view of backend logs without the Space console.
|
| 240 |
-
# The relay mirror lives in src/core/log_relay.py and is installed by
|
| 241 |
-
# src/core/log.py setup_logging(); only these two endpoints expose it.
|
| 242 |
-
# ---------------------------------------------------------------------------
|
| 243 |
-
|
| 244 |
-
@app.get("/api/logs")
|
| 245 |
-
async def get_log_lines(
|
| 246 |
-
since: int = Query(default=0, ge=0),
|
| 247 |
-
_user: dict = Depends(require_admin),
|
| 248 |
-
):
|
| 249 |
-
"""Return relayed log lines newer than `since`; `next` is the poll cursor."""
|
| 250 |
-
from src.core.log_relay import relay_lines
|
| 251 |
-
return relay_lines(since)
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
@app.post("/api/logs/clear")
|
| 255 |
-
async def clear_log_lines(_user: dict = Depends(require_admin)):
|
| 256 |
-
"""Clear the in-memory relay buffer. The live log file is left intact."""
|
| 257 |
-
from src.core.log_relay import clear_relay
|
| 258 |
-
clear_relay()
|
| 259 |
-
return {"ok": True}
|
| 260 |
-
|
| 261 |
-
|
| 262 |
def _print_agent_plans(engine):
|
| 263 |
"""Print each agent's full action plan to the CLI."""
|
| 264 |
from src.core.agent_registry import AgentRuntimeState
|
|
@@ -717,8 +688,6 @@ async def add_agent(request: AddAgentInput, _user: dict = Depends(require_admin)
|
|
| 717 |
"current_time": f"{current_date} {current_hhmm}", "places": None,
|
| 718 |
"persona_name": generated.name, "mode": "remaining" if engine.world.tick else "full_day",
|
| 719 |
"current_location_id": generated.hostel, "upcoming_events": [],
|
| 720 |
-
"energy_level": engine._energy_baseline(persona),
|
| 721 |
-
"emotion_state": engine._emotion_baseline(persona),
|
| 722 |
}))
|
| 723 |
day_plan = plan_result.get("day_plan", [])
|
| 724 |
if not day_plan:
|
|
@@ -732,7 +701,6 @@ async def add_agent(request: AddAgentInput, _user: dict = Depends(require_admin)
|
|
| 732 |
engine.registry.register(AgentRuntimeState(
|
| 733 |
agent_id=agent_id, persona=persona, persona_name=generated.name,
|
| 734 |
manager=manager, position=position, day_plan=day_plan,
|
| 735 |
-
energy_level=engine._energy_baseline(persona),
|
| 736 |
emotion_state=engine._emotion_baseline(persona), emotion_baseline=engine._emotion_baseline(persona),
|
| 737 |
))
|
| 738 |
engine.world.register_agent(agent_id, position)
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI web server for the Valhalla agent map.
|
| 3 |
+
Serves the frontend (React SPA), exposes REST + WebSocket
|
| 4 |
+
endpoints for pathfinding (/api/path, /api/path/stream, /ws), and
|
| 5 |
+
streams simulation state via /ws/sim for live agent visualization.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
import os
|
|
|
|
| 230 |
return user
|
| 231 |
|
| 232 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
def _print_agent_plans(engine):
|
| 234 |
"""Print each agent's full action plan to the CLI."""
|
| 235 |
from src.core.agent_registry import AgentRuntimeState
|
|
|
|
| 688 |
"current_time": f"{current_date} {current_hhmm}", "places": None,
|
| 689 |
"persona_name": generated.name, "mode": "remaining" if engine.world.tick else "full_day",
|
| 690 |
"current_location_id": generated.hostel, "upcoming_events": [],
|
|
|
|
|
|
|
| 691 |
}))
|
| 692 |
day_plan = plan_result.get("day_plan", [])
|
| 693 |
if not day_plan:
|
|
|
|
| 701 |
engine.registry.register(AgentRuntimeState(
|
| 702 |
agent_id=agent_id, persona=persona, persona_name=generated.name,
|
| 703 |
manager=manager, position=position, day_plan=day_plan,
|
|
|
|
| 704 |
emotion_state=engine._emotion_baseline(persona), emotion_baseline=engine._emotion_baseline(persona),
|
| 705 |
))
|
| 706 |
engine.world.register_agent(agent_id, position)
|
backend/Pathfinder_test.py
CHANGED
|
@@ -1,11 +1,7 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
Loads
|
| 4 |
-
and
|
| 5 |
-
|
| 6 |
-
Architecture: a developer tool, not part of the simulation runtime; it
|
| 7 |
-
exercises backend/pathfinder.py against the real walkability map.
|
| 8 |
-
Design: keeps the visual debugging loop out of the server code.
|
| 9 |
"""
|
| 10 |
|
| 11 |
import sys
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CLI tool for pixel-level pathfinding on the Valhalla map.
|
| 3 |
+
Loads map.png, computes the shortest path between two pixel coordinates
|
| 4 |
+
via BFS, and displays the result with start/end markers in a matplotlib window.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
import sys
|
backend/data/environment/relationship_matrix.json
CHANGED
|
@@ -1,6 +1,94 @@
|
|
| 1 |
{
|
| 2 |
"schema_version": 2,
|
| 3 |
"relationships": {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"ansh_batra->anubhav_prasad": {
|
| 5 |
"score": 0.41,
|
| 6 |
"tags": [
|
|
@@ -23,7 +111,14 @@
|
|
| 23 |
"campus-acquaintance",
|
| 24 |
"party-bros"
|
| 25 |
],
|
| 26 |
-
"context": "Ansh and Gurnoor's parties always end in legendary stories
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
},
|
| 28 |
"ansh_batra->lavanya_sharma": {
|
| 29 |
"score": 0.44,
|
|
@@ -65,6 +160,14 @@
|
|
| 65 |
],
|
| 66 |
"context": "Ansh loves hyping up Tanishq's growing confidence, especially when Tanishq blushes at compliments. It's dangerously cute."
|
| 67 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
"anubhav_prasad->ansh_batra": {
|
| 69 |
"score": 0.41,
|
| 70 |
"tags": [
|
|
@@ -89,6 +192,13 @@
|
|
| 89 |
],
|
| 90 |
"context": "Gurnoor drags Anubhav to parties and Anubhav somehow ends up being the responsible one... until that one time he wasn't."
|
| 91 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
"anubhav_prasad->lavanya_sharma": {
|
| 93 |
"score": 0.73,
|
| 94 |
"tags": [
|
|
@@ -129,6 +239,14 @@
|
|
| 129 |
],
|
| 130 |
"context": "Anubhav is quietly supportive of Tanishq's confidence journey. Their interactions are soft and full of unspoken understanding."
|
| 131 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
"ghanisht_kaushal->ansh_batra": {
|
| 133 |
"score": 0.66,
|
| 134 |
"tags": [
|
|
@@ -153,6 +271,13 @@
|
|
| 153 |
],
|
| 154 |
"context": "Gurnoor's nonstop social battery clashes with Ghanisht's chill, but the rare nights they sync are chaotic gold."
|
| 155 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
"ghanisht_kaushal->lavanya_sharma": {
|
| 157 |
"score": 0.36,
|
| 158 |
"tags": [
|
|
@@ -193,6 +318,14 @@
|
|
| 193 |
],
|
| 194 |
"context": "Ghanisht quietly roots for Tanishq's confidence glow-up and enjoys watching him get bolder."
|
| 195 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
"gurnoor_singh->ansh_batra": {
|
| 197 |
"score": 0.53,
|
| 198 |
"tags": [
|
|
@@ -217,6 +350,13 @@
|
|
| 217 |
],
|
| 218 |
"context": "Gurnoor respects Ghanisht's reliability but wishes he'd loosen up more... preferably with him."
|
| 219 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
"gurnoor_singh->lavanya_sharma": {
|
| 221 |
"score": 0.5,
|
| 222 |
"tags": [
|
|
@@ -257,6 +397,85 @@
|
|
| 257 |
],
|
| 258 |
"context": "Gurnoor loves seeing Tanishq come out of his shell and occasionally flirts just to see him blush."
|
| 259 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
"lavanya_sharma->ansh_batra": {
|
| 261 |
"score": 0.44,
|
| 262 |
"tags": [
|
|
@@ -289,6 +508,13 @@
|
|
| 289 |
],
|
| 290 |
"context": "Lavanya matches Gurnoor's energy perfectly. Their flirting is shameless and hilarious."
|
| 291 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
"lavanya_sharma->parv_singla": {
|
| 293 |
"score": 0.43,
|
| 294 |
"tags": [
|
|
@@ -321,6 +547,14 @@
|
|
| 321 |
],
|
| 322 |
"context": "Lavanya is proudly watching Tanishq's glow-up and isn't shy about hyping him up."
|
| 323 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
"parv_singla->ansh_batra": {
|
| 325 |
"score": 0.56,
|
| 326 |
"tags": [
|
|
@@ -353,6 +587,13 @@
|
|
| 353 |
],
|
| 354 |
"context": "Parv and Gurnoor are basically soulmates in crime. Their friendship includes shared hangovers, secrets, and blurry memories."
|
| 355 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
"parv_singla->lavanya_sharma": {
|
| 357 |
"score": 0.43,
|
| 358 |
"tags": [
|
|
@@ -385,6 +626,14 @@
|
|
| 385 |
],
|
| 386 |
"context": "Parv loves hyping Tanishq up and watching him gain confidence."
|
| 387 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
"riya_murarka->ansh_batra": {
|
| 389 |
"score": 0.5,
|
| 390 |
"tags": [
|
|
@@ -417,6 +666,13 @@
|
|
| 417 |
],
|
| 418 |
"context": "Riya finds Gurnoor's energy entertaining in small doses."
|
| 419 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
"riya_murarka->lavanya_sharma": {
|
| 421 |
"score": 0.57,
|
| 422 |
"tags": [
|
|
@@ -449,6 +705,14 @@
|
|
| 449 |
],
|
| 450 |
"context": "Riya notices Tanishq's respectful efforts and finds it sweet, but keeps things slow and platonic for now."
|
| 451 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
"saksham->ansh_batra": {
|
| 453 |
"score": 0.34,
|
| 454 |
"tags": [
|
|
@@ -481,6 +745,13 @@
|
|
| 481 |
],
|
| 482 |
"context": "Saksham finds Gurnoor's energy exhausting but entertaining."
|
| 483 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
"saksham->lavanya_sharma": {
|
| 485 |
"score": 0.47,
|
| 486 |
"tags": [
|
|
@@ -513,6 +784,14 @@
|
|
| 513 |
],
|
| 514 |
"context": "Saksham quietly supports Tanishq's confidence growth with dry but kind humor."
|
| 515 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
"tanishq->ansh_batra": {
|
| 517 |
"score": 0.56,
|
| 518 |
"tags": [
|
|
@@ -545,6 +824,13 @@
|
|
| 545 |
],
|
| 546 |
"context": "Tanishq is slowly getting pulled into Gurnoor's fun orbit and enjoying it."
|
| 547 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
"tanishq->lavanya_sharma": {
|
| 549 |
"score": 0.63,
|
| 550 |
"tags": [
|
|
@@ -578,4 +864,4 @@
|
|
| 578 |
"context": "Tanishq enjoys Saksham's sarcasm and finds it comforting in its own way."
|
| 579 |
}
|
| 580 |
}
|
| 581 |
-
}
|
|
|
|
| 1 |
{
|
| 2 |
"schema_version": 2,
|
| 3 |
"relationships": {
|
| 4 |
+
"amitabh->ansh_batra": {
|
| 5 |
+
"score": 0.55,
|
| 6 |
+
"tags": [
|
| 7 |
+
"campus-acquaintance",
|
| 8 |
+
"low-pressure"
|
| 9 |
+
],
|
| 10 |
+
"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."
|
| 11 |
+
},
|
| 12 |
+
"amitabh->anubhav_prasad": {
|
| 13 |
+
"score": 0.49,
|
| 14 |
+
"tags": [
|
| 15 |
+
"campus-acquaintance",
|
| 16 |
+
"low-pressure"
|
| 17 |
+
],
|
| 18 |
+
"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."
|
| 19 |
+
},
|
| 20 |
+
"amitabh->ghanisht_kaushal": {
|
| 21 |
+
"score": 0.46,
|
| 22 |
+
"tags": [
|
| 23 |
+
"campus-acquaintance",
|
| 24 |
+
"low-pressure"
|
| 25 |
+
],
|
| 26 |
+
"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."
|
| 27 |
+
},
|
| 28 |
+
"amitabh->gurnoor_singh": {
|
| 29 |
+
"score": 0.56,
|
| 30 |
+
"tags": [
|
| 31 |
+
"campus-acquaintance",
|
| 32 |
+
"low-pressure"
|
| 33 |
+
],
|
| 34 |
+
"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."
|
| 35 |
+
},
|
| 36 |
+
"amitabh->jarvis": {
|
| 37 |
+
"score": 0.63,
|
| 38 |
+
"tags": [
|
| 39 |
+
"campus-acquaintance",
|
| 40 |
+
"low-pressure"
|
| 41 |
+
],
|
| 42 |
+
"context": "Amitabh and Jarvis know each other through campus routines. Amitabh appreciates jarvis's steady conversation, but the friendship is still finding its rhythm."
|
| 43 |
+
},
|
| 44 |
+
"amitabh->lavanya_sharma": {
|
| 45 |
+
"score": 0.5700000000000001,
|
| 46 |
+
"tags": [
|
| 47 |
+
"campus-acquaintance",
|
| 48 |
+
"low-pressure"
|
| 49 |
+
],
|
| 50 |
+
"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."
|
| 51 |
+
},
|
| 52 |
+
"amitabh->parv_singla": {
|
| 53 |
+
"score": 0.54,
|
| 54 |
+
"tags": [
|
| 55 |
+
"campus-acquaintance",
|
| 56 |
+
"low-pressure"
|
| 57 |
+
],
|
| 58 |
+
"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."
|
| 59 |
+
},
|
| 60 |
+
"amitabh->riya_murarka": {
|
| 61 |
+
"score": 0.3,
|
| 62 |
+
"tags": [
|
| 63 |
+
"campus-acquaintance",
|
| 64 |
+
"low-pressure"
|
| 65 |
+
],
|
| 66 |
+
"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."
|
| 67 |
+
},
|
| 68 |
+
"amitabh->saksham": {
|
| 69 |
+
"score": 0.53,
|
| 70 |
+
"tags": [
|
| 71 |
+
"campus-acquaintance",
|
| 72 |
+
"low-pressure"
|
| 73 |
+
],
|
| 74 |
+
"context": "Amitabh and Saksham know each other through campus routines. Amitabh appreciates saksham's dry humour, but the friendship is still finding its rhythm."
|
| 75 |
+
},
|
| 76 |
+
"amitabh->tanishq": {
|
| 77 |
+
"score": 0.31,
|
| 78 |
+
"tags": [
|
| 79 |
+
"campus-acquaintance",
|
| 80 |
+
"low-pressure"
|
| 81 |
+
],
|
| 82 |
+
"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."
|
| 83 |
+
},
|
| 84 |
+
"ansh_batra->amitabh": {
|
| 85 |
+
"score": 0.55,
|
| 86 |
+
"tags": [
|
| 87 |
+
"campus-acquaintance",
|
| 88 |
+
"low-pressure"
|
| 89 |
+
],
|
| 90 |
+
"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."
|
| 91 |
+
},
|
| 92 |
"ansh_batra->anubhav_prasad": {
|
| 93 |
"score": 0.41,
|
| 94 |
"tags": [
|
|
|
|
| 111 |
"campus-acquaintance",
|
| 112 |
"party-bros"
|
| 113 |
],
|
| 114 |
+
"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."
|
| 115 |
+
},
|
| 116 |
+
"ansh_batra->jarvis": {
|
| 117 |
+
"score": 0.32,
|
| 118 |
+
"tags": [
|
| 119 |
+
"new-acquaintance"
|
| 120 |
+
],
|
| 121 |
+
"context": "Ansh Batra has only recently met Jarvis; the connection is open but untested."
|
| 122 |
},
|
| 123 |
"ansh_batra->lavanya_sharma": {
|
| 124 |
"score": 0.44,
|
|
|
|
| 160 |
],
|
| 161 |
"context": "Ansh loves hyping up Tanishq's growing confidence, especially when Tanishq blushes at compliments. It's dangerously cute."
|
| 162 |
},
|
| 163 |
+
"anubhav_prasad->amitabh": {
|
| 164 |
+
"score": 0.49,
|
| 165 |
+
"tags": [
|
| 166 |
+
"campus-acquaintance",
|
| 167 |
+
"low-pressure"
|
| 168 |
+
],
|
| 169 |
+
"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."
|
| 170 |
+
},
|
| 171 |
"anubhav_prasad->ansh_batra": {
|
| 172 |
"score": 0.41,
|
| 173 |
"tags": [
|
|
|
|
| 192 |
],
|
| 193 |
"context": "Gurnoor drags Anubhav to parties and Anubhav somehow ends up being the responsible one... until that one time he wasn't."
|
| 194 |
},
|
| 195 |
+
"anubhav_prasad->jarvis": {
|
| 196 |
+
"score": 0.32,
|
| 197 |
+
"tags": [
|
| 198 |
+
"new-acquaintance"
|
| 199 |
+
],
|
| 200 |
+
"context": "Anubhav Prasad has only recently met Jarvis; the connection is open but untested."
|
| 201 |
+
},
|
| 202 |
"anubhav_prasad->lavanya_sharma": {
|
| 203 |
"score": 0.73,
|
| 204 |
"tags": [
|
|
|
|
| 239 |
],
|
| 240 |
"context": "Anubhav is quietly supportive of Tanishq's confidence journey. Their interactions are soft and full of unspoken understanding."
|
| 241 |
},
|
| 242 |
+
"ghanisht_kaushal->amitabh": {
|
| 243 |
+
"score": 0.46,
|
| 244 |
+
"tags": [
|
| 245 |
+
"campus-acquaintance",
|
| 246 |
+
"low-pressure"
|
| 247 |
+
],
|
| 248 |
+
"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."
|
| 249 |
+
},
|
| 250 |
"ghanisht_kaushal->ansh_batra": {
|
| 251 |
"score": 0.66,
|
| 252 |
"tags": [
|
|
|
|
| 271 |
],
|
| 272 |
"context": "Gurnoor's nonstop social battery clashes with Ghanisht's chill, but the rare nights they sync are chaotic gold."
|
| 273 |
},
|
| 274 |
+
"ghanisht_kaushal->jarvis": {
|
| 275 |
+
"score": 0.32,
|
| 276 |
+
"tags": [
|
| 277 |
+
"new-acquaintance"
|
| 278 |
+
],
|
| 279 |
+
"context": "Ghanisht Kaushal has only recently met Jarvis; the connection is open but untested."
|
| 280 |
+
},
|
| 281 |
"ghanisht_kaushal->lavanya_sharma": {
|
| 282 |
"score": 0.36,
|
| 283 |
"tags": [
|
|
|
|
| 318 |
],
|
| 319 |
"context": "Ghanisht quietly roots for Tanishq's confidence glow-up and enjoys watching him get bolder."
|
| 320 |
},
|
| 321 |
+
"gurnoor_singh->amitabh": {
|
| 322 |
+
"score": 0.56,
|
| 323 |
+
"tags": [
|
| 324 |
+
"campus-acquaintance",
|
| 325 |
+
"low-pressure"
|
| 326 |
+
],
|
| 327 |
+
"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."
|
| 328 |
+
},
|
| 329 |
"gurnoor_singh->ansh_batra": {
|
| 330 |
"score": 0.53,
|
| 331 |
"tags": [
|
|
|
|
| 350 |
],
|
| 351 |
"context": "Gurnoor respects Ghanisht's reliability but wishes he'd loosen up more... preferably with him."
|
| 352 |
},
|
| 353 |
+
"gurnoor_singh->jarvis": {
|
| 354 |
+
"score": 0.32,
|
| 355 |
+
"tags": [
|
| 356 |
+
"new-acquaintance"
|
| 357 |
+
],
|
| 358 |
+
"context": "Gurnoor Singh has only recently met Jarvis; the connection is open but untested."
|
| 359 |
+
},
|
| 360 |
"gurnoor_singh->lavanya_sharma": {
|
| 361 |
"score": 0.5,
|
| 362 |
"tags": [
|
|
|
|
| 397 |
],
|
| 398 |
"context": "Gurnoor loves seeing Tanishq come out of his shell and occasionally flirts just to see him blush."
|
| 399 |
},
|
| 400 |
+
"jarvis->amitabh": {
|
| 401 |
+
"score": 0.63,
|
| 402 |
+
"tags": [
|
| 403 |
+
"campus-acquaintance",
|
| 404 |
+
"low-pressure"
|
| 405 |
+
],
|
| 406 |
+
"context": "Jarvis and Amitabh know each other through campus routines. Jarvis appreciates amitabh's steady conversation, but the friendship is still finding its rhythm."
|
| 407 |
+
},
|
| 408 |
+
"jarvis->ansh_batra": {
|
| 409 |
+
"score": 0.32,
|
| 410 |
+
"tags": [
|
| 411 |
+
"new-acquaintance"
|
| 412 |
+
],
|
| 413 |
+
"context": "Jarvis is new to this circle and is still learning Ansh Batra's rhythm."
|
| 414 |
+
},
|
| 415 |
+
"jarvis->anubhav_prasad": {
|
| 416 |
+
"score": 0.32,
|
| 417 |
+
"tags": [
|
| 418 |
+
"new-acquaintance"
|
| 419 |
+
],
|
| 420 |
+
"context": "Jarvis is new to this circle and is still learning Anubhav Prasad's rhythm."
|
| 421 |
+
},
|
| 422 |
+
"jarvis->ghanisht_kaushal": {
|
| 423 |
+
"score": 0.32,
|
| 424 |
+
"tags": [
|
| 425 |
+
"new-acquaintance"
|
| 426 |
+
],
|
| 427 |
+
"context": "Jarvis is new to this circle and is still learning Ghanisht Kaushal's rhythm."
|
| 428 |
+
},
|
| 429 |
+
"jarvis->gurnoor_singh": {
|
| 430 |
+
"score": 0.32,
|
| 431 |
+
"tags": [
|
| 432 |
+
"new-acquaintance"
|
| 433 |
+
],
|
| 434 |
+
"context": "Jarvis is new to this circle and is still learning Gurnoor Singh's rhythm."
|
| 435 |
+
},
|
| 436 |
+
"jarvis->lavanya_sharma": {
|
| 437 |
+
"score": 0.37,
|
| 438 |
+
"tags": [
|
| 439 |
+
"new-acquaintance"
|
| 440 |
+
],
|
| 441 |
+
"context": "Jarvis is new to this circle and is still learning Lavanya Sharma's rhythm."
|
| 442 |
+
},
|
| 443 |
+
"jarvis->parv_singla": {
|
| 444 |
+
"score": 0.32,
|
| 445 |
+
"tags": [
|
| 446 |
+
"new-acquaintance"
|
| 447 |
+
],
|
| 448 |
+
"context": "Jarvis is new to this circle and is still learning Parv Singla's rhythm."
|
| 449 |
+
},
|
| 450 |
+
"jarvis->riya_murarka": {
|
| 451 |
+
"score": 0.37,
|
| 452 |
+
"tags": [
|
| 453 |
+
"new-acquaintance"
|
| 454 |
+
],
|
| 455 |
+
"context": "Jarvis is new to this circle and is still learning Riya Murarka's rhythm."
|
| 456 |
+
},
|
| 457 |
+
"jarvis->saksham": {
|
| 458 |
+
"score": 0.42,
|
| 459 |
+
"tags": [
|
| 460 |
+
"new-acquaintance"
|
| 461 |
+
],
|
| 462 |
+
"context": "Jarvis is new to this circle and is still learning Saksham's rhythm."
|
| 463 |
+
},
|
| 464 |
+
"jarvis->tanishq": {
|
| 465 |
+
"score": 0.32,
|
| 466 |
+
"tags": [
|
| 467 |
+
"new-acquaintance"
|
| 468 |
+
],
|
| 469 |
+
"context": "Jarvis is new to this circle and is still learning Tanishq's rhythm."
|
| 470 |
+
},
|
| 471 |
+
"lavanya_sharma->amitabh": {
|
| 472 |
+
"score": 0.5700000000000001,
|
| 473 |
+
"tags": [
|
| 474 |
+
"campus-acquaintance",
|
| 475 |
+
"low-pressure"
|
| 476 |
+
],
|
| 477 |
+
"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."
|
| 478 |
+
},
|
| 479 |
"lavanya_sharma->ansh_batra": {
|
| 480 |
"score": 0.44,
|
| 481 |
"tags": [
|
|
|
|
| 508 |
],
|
| 509 |
"context": "Lavanya matches Gurnoor's energy perfectly. Their flirting is shameless and hilarious."
|
| 510 |
},
|
| 511 |
+
"lavanya_sharma->jarvis": {
|
| 512 |
+
"score": 0.37,
|
| 513 |
+
"tags": [
|
| 514 |
+
"new-acquaintance"
|
| 515 |
+
],
|
| 516 |
+
"context": "Lavanya Sharma has only recently met Jarvis; the connection is open but untested."
|
| 517 |
+
},
|
| 518 |
"lavanya_sharma->parv_singla": {
|
| 519 |
"score": 0.43,
|
| 520 |
"tags": [
|
|
|
|
| 547 |
],
|
| 548 |
"context": "Lavanya is proudly watching Tanishq's glow-up and isn't shy about hyping him up."
|
| 549 |
},
|
| 550 |
+
"parv_singla->amitabh": {
|
| 551 |
+
"score": 0.54,
|
| 552 |
+
"tags": [
|
| 553 |
+
"campus-acquaintance",
|
| 554 |
+
"low-pressure"
|
| 555 |
+
],
|
| 556 |
+
"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."
|
| 557 |
+
},
|
| 558 |
"parv_singla->ansh_batra": {
|
| 559 |
"score": 0.56,
|
| 560 |
"tags": [
|
|
|
|
| 587 |
],
|
| 588 |
"context": "Parv and Gurnoor are basically soulmates in crime. Their friendship includes shared hangovers, secrets, and blurry memories."
|
| 589 |
},
|
| 590 |
+
"parv_singla->jarvis": {
|
| 591 |
+
"score": 0.32,
|
| 592 |
+
"tags": [
|
| 593 |
+
"new-acquaintance"
|
| 594 |
+
],
|
| 595 |
+
"context": "Parv Singla has only recently met Jarvis; the connection is open but untested."
|
| 596 |
+
},
|
| 597 |
"parv_singla->lavanya_sharma": {
|
| 598 |
"score": 0.43,
|
| 599 |
"tags": [
|
|
|
|
| 626 |
],
|
| 627 |
"context": "Parv loves hyping Tanishq up and watching him gain confidence."
|
| 628 |
},
|
| 629 |
+
"riya_murarka->amitabh": {
|
| 630 |
+
"score": 0.3,
|
| 631 |
+
"tags": [
|
| 632 |
+
"campus-acquaintance",
|
| 633 |
+
"low-pressure"
|
| 634 |
+
],
|
| 635 |
+
"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."
|
| 636 |
+
},
|
| 637 |
"riya_murarka->ansh_batra": {
|
| 638 |
"score": 0.5,
|
| 639 |
"tags": [
|
|
|
|
| 666 |
],
|
| 667 |
"context": "Riya finds Gurnoor's energy entertaining in small doses."
|
| 668 |
},
|
| 669 |
+
"riya_murarka->jarvis": {
|
| 670 |
+
"score": 0.37,
|
| 671 |
+
"tags": [
|
| 672 |
+
"new-acquaintance"
|
| 673 |
+
],
|
| 674 |
+
"context": "Riya Murarka has only recently met Jarvis; the connection is open but untested."
|
| 675 |
+
},
|
| 676 |
"riya_murarka->lavanya_sharma": {
|
| 677 |
"score": 0.57,
|
| 678 |
"tags": [
|
|
|
|
| 705 |
],
|
| 706 |
"context": "Riya notices Tanishq's respectful efforts and finds it sweet, but keeps things slow and platonic for now."
|
| 707 |
},
|
| 708 |
+
"saksham->amitabh": {
|
| 709 |
+
"score": 0.53,
|
| 710 |
+
"tags": [
|
| 711 |
+
"campus-acquaintance",
|
| 712 |
+
"low-pressure"
|
| 713 |
+
],
|
| 714 |
+
"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."
|
| 715 |
+
},
|
| 716 |
"saksham->ansh_batra": {
|
| 717 |
"score": 0.34,
|
| 718 |
"tags": [
|
|
|
|
| 745 |
],
|
| 746 |
"context": "Saksham finds Gurnoor's energy exhausting but entertaining."
|
| 747 |
},
|
| 748 |
+
"saksham->jarvis": {
|
| 749 |
+
"score": 0.42,
|
| 750 |
+
"tags": [
|
| 751 |
+
"new-acquaintance"
|
| 752 |
+
],
|
| 753 |
+
"context": "Saksham has only recently met Jarvis; the connection is open but untested."
|
| 754 |
+
},
|
| 755 |
"saksham->lavanya_sharma": {
|
| 756 |
"score": 0.47,
|
| 757 |
"tags": [
|
|
|
|
| 784 |
],
|
| 785 |
"context": "Saksham quietly supports Tanishq's confidence growth with dry but kind humor."
|
| 786 |
},
|
| 787 |
+
"tanishq->amitabh": {
|
| 788 |
+
"score": 0.31,
|
| 789 |
+
"tags": [
|
| 790 |
+
"campus-acquaintance",
|
| 791 |
+
"low-pressure"
|
| 792 |
+
],
|
| 793 |
+
"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."
|
| 794 |
+
},
|
| 795 |
"tanishq->ansh_batra": {
|
| 796 |
"score": 0.56,
|
| 797 |
"tags": [
|
|
|
|
| 824 |
],
|
| 825 |
"context": "Tanishq is slowly getting pulled into Gurnoor's fun orbit and enjoying it."
|
| 826 |
},
|
| 827 |
+
"tanishq->jarvis": {
|
| 828 |
+
"score": 0.32,
|
| 829 |
+
"tags": [
|
| 830 |
+
"new-acquaintance"
|
| 831 |
+
],
|
| 832 |
+
"context": "Tanishq has only recently met Jarvis; the connection is open but untested."
|
| 833 |
+
},
|
| 834 |
"tanishq->lavanya_sharma": {
|
| 835 |
"score": 0.63,
|
| 836 |
"tags": [
|
|
|
|
| 864 |
"context": "Tanishq enjoys Saksham's sarcasm and finds it comforting in its own way."
|
| 865 |
}
|
| 866 |
}
|
| 867 |
+
}
|
backend/data/personalities/amitabh/amitabh.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"Name": "Amitabh",
|
| 3 |
+
"Age": "20",
|
| 4 |
+
"Gender": "Male",
|
| 5 |
+
"Branch": "Computer Science",
|
| 6 |
+
"Home City": "Delhi",
|
| 7 |
+
"Hostel": "Beas",
|
| 8 |
+
"daily_plan_req": "Gym in the morning, classes, evening coding + chai sessions, night gaming or deep talks",
|
| 9 |
+
"innate": "Calm, observant, secretly sarcastic, gets easily flustered by bold flirting but plays it cool",
|
| 10 |
+
"learned": "How to give good advice while hiding his own chaos, how to handle friends' impulsiveness",
|
| 11 |
+
"lifestyle": "Lowkey chill but down for spontaneous shit at 2am. Lowkey addicted to emotional tension and slow-burn crushes",
|
| 12 |
+
"hobbies": "Hardware tinkering, playlists, late-night chai, overthinking texts, secret meme saving",
|
| 13 |
+
"goals": "Graduate with good grades, figure out what he wants in relationships, maybe finally make a move on someone",
|
| 14 |
+
"interests": [
|
| 15 |
+
"Deep conversations",
|
| 16 |
+
"Tech",
|
| 17 |
+
"Flirty banter",
|
| 18 |
+
"Gym",
|
| 19 |
+
"Music",
|
| 20 |
+
"Quiet tension with girls/guys"
|
| 21 |
+
]
|
| 22 |
+
}
|
backend/data/personalities/jarvis/jarvis.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"Name": "Jarvis",
|
| 3 |
+
"Age": "18",
|
| 4 |
+
"Gender": "Non-binary",
|
| 5 |
+
"Branch": "Mechanical Engineering",
|
| 6 |
+
"Home City": "Indore",
|
| 7 |
+
"Hostel": "Chenab",
|
| 8 |
+
"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.",
|
| 9 |
+
"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.",
|
| 10 |
+
"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.",
|
| 11 |
+
"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.",
|
| 12 |
+
"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.",
|
| 13 |
+
"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.",
|
| 14 |
+
"interests": [
|
| 15 |
+
"PC Gaming",
|
| 16 |
+
"Robotics",
|
| 17 |
+
"Mechanical Design",
|
| 18 |
+
"Chess",
|
| 19 |
+
"Indie Music",
|
| 20 |
+
"Photography"
|
| 21 |
+
]
|
| 22 |
+
}
|
backend/pathfinder.py
CHANGED
|
@@ -1,12 +1,7 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
Loads path.png
|
| 4 |
-
stats(), and is_walkable()
|
| 5 |
-
|
| 6 |
-
Architecture: consumed by Odin.py and the agent action manager (Actions.py)
|
| 7 |
-
to compute routes between buildings; anchors come from entrypoint.json.
|
| 8 |
-
Design: 4-neighbor BFS with nearest-walkable endpoint snapping, because
|
| 9 |
-
doors and interiors sit just off the walkable network.
|
| 10 |
"""
|
| 11 |
|
| 12 |
import os
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Core pathfinding module — imported by Odin.py and pixel_pathfinder.py.
|
| 3 |
+
Loads path.png into a set of walkable (white) pixels and provides
|
| 4 |
+
BFS shortest_path(), stats(), and is_walkable() helpers.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
import os
|
backend/src/agents/Actions.py
CHANGED
|
@@ -1,14 +1,23 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
from __future__ import annotations
|
|
@@ -73,7 +82,6 @@ class ActionState(BaseModel):
|
|
| 73 |
path_index: int = 0 # current position along path
|
| 74 |
energy_change: float = 0.0 # total change over entire action
|
| 75 |
emotion_change: float = 0.0 # total change over entire action
|
| 76 |
-
energy_target: Optional[float] = None # declared cumulative energy at action end (0-1); None = delta-based
|
| 77 |
is_final_plan_action: bool = False
|
| 78 |
event_id: Optional[str] = None # data-driven world event, when applicable
|
| 79 |
|
|
@@ -348,7 +356,6 @@ class AgentActionManager:
|
|
| 348 |
position=position,
|
| 349 |
energy_change=plan_action.get("energy_change", 0.0),
|
| 350 |
emotion_change=plan_action.get("emotion_change", 0.0),
|
| 351 |
-
energy_target=plan_action.get("energy_target"),
|
| 352 |
is_final_plan_action=bool(self.day_plan and plan_action is self.day_plan[-1]),
|
| 353 |
event_id=plan_action.get("world_event_id"),
|
| 354 |
)
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Actions -- manages agent action execution: last, current, next.
|
| 3 |
+
|
| 4 |
+
Takes the raw day plan produced by day_planner.py and drives it forward
|
| 5 |
+
tick by tick. Handles three action types:
|
| 6 |
+
|
| 7 |
+
1. MOVE -- agent walks from place A to place B (pathfinder.py)
|
| 8 |
+
2. MISC -- static activity: studying, coding, eating, etc.
|
| 9 |
+
3. CONVERSATION -- triggered when two agents are in proximity.
|
| 10 |
|
| 11 |
+
The module converts location_id strings (from day plans) into pixel
|
| 12 |
+
coordinates (from entrypoint.json) and uses the BFS pathfinder to
|
| 13 |
+
compute walkable paths between locations.
|
| 14 |
|
| 15 |
+
Usage:
|
| 16 |
+
from src.agents.Actions import AgentActionManager, LocationResolver
|
| 17 |
+
|
| 18 |
+
resolver = LocationResolver()
|
| 19 |
+
manager = AgentActionManager("parv_singla", day_plan, initial_position)
|
| 20 |
+
state = manager.tick(world_tick, snapshot)
|
| 21 |
"""
|
| 22 |
|
| 23 |
from __future__ import annotations
|
|
|
|
| 82 |
path_index: int = 0 # current position along path
|
| 83 |
energy_change: float = 0.0 # total change over entire action
|
| 84 |
emotion_change: float = 0.0 # total change over entire action
|
|
|
|
| 85 |
is_final_plan_action: bool = False
|
| 86 |
event_id: Optional[str] = None # data-driven world event, when applicable
|
| 87 |
|
|
|
|
| 356 |
position=position,
|
| 357 |
energy_change=plan_action.get("energy_change", 0.0),
|
| 358 |
emotion_change=plan_action.get("emotion_change", 0.0),
|
|
|
|
| 359 |
is_final_plan_action=bool(self.day_plan and plan_action is self.day_plan[-1]),
|
| 360 |
event_id=plan_action.get("world_event_id"),
|
| 361 |
)
|
backend/src/agents/Long_term.py
CHANGED
|
@@ -1,15 +1,10 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
Architecture: consumed by brain.py and the engine for retrieval; paired
|
| 8 |
-
with Short_term.py (operational memory) and vector_memory.py (storage).
|
| 9 |
-
Design: deliberately no JSON archive reader — Qdrant is the single
|
| 10 |
-
long-term source of truth.
|
| 11 |
"""
|
| 12 |
-
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
from typing import List, Optional, Protocol, runtime_checkable
|
|
|
|
| 1 |
+
"""Qdrant-backed long-term memory interface.
|
| 2 |
|
| 3 |
+
Long-term agent memory is stored only in Qdrant. Short-term JSON files remain
|
| 4 |
+
the operational record for the active simulation day; they are summarized and
|
| 5 |
+
indexed at handoff, then removed. This module deliberately has no JSON
|
| 6 |
+
archive reader or keyword-search fallback.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
|
|
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
from typing import List, Optional, Protocol, runtime_checkable
|
backend/src/agents/Short_term.py
CHANGED
|
@@ -1,13 +1,13 @@
|
|
| 1 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
and archives the day to Qdrant long-term memory at handoff.
|
| 6 |
|
| 7 |
-
|
| 8 |
-
written by world_engine.py, read by brain.py and day_planner.py.
|
| 9 |
-
Design: short-term files are the operational record of the active day
|
| 10 |
-
and are removed after successful archival.
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Short-term Memory -- per-agent, per-day detailed memory store.
|
| 3 |
+
|
| 4 |
+
Stores the full day's data (plan, events, conversations, world snapshots)
|
| 5 |
+
as a single JSON file per persona per simulation date.
|
| 6 |
|
| 7 |
+
File layout:
|
| 8 |
+
data/Short_term_db/<persona_name>/<YYYY-MM-DD>.json
|
|
|
|
| 9 |
|
| 10 |
+
Implements MemoryStreamProtocol (from tick_graph.py) for tick-graph integration.
|
|
|
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
backend/src/agents/Single_agent.py
CHANGED
|
@@ -1,12 +1,24 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
from __future__ import annotations
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Main brain / command centre of a single agent.
|
| 3 |
+
|
| 4 |
+
Makes decisions, calls and delegates tasks to sub-modules (day_planner,
|
| 5 |
+
memory, reflection, etc.), and runs the agent's action loop.
|
| 6 |
+
|
| 7 |
+
Exports:
|
| 8 |
+
create_agent_graph() -> CompiledGraph[AgentState]
|
| 9 |
+
A single-agent LangGraph. Currently one node: generate_day_plan.
|
| 10 |
+
Future: execute_tick, reflect, update_memory, conversation.
|
| 11 |
+
|
| 12 |
+
Usage as a library (for the multi-agent orchestrator):
|
| 13 |
+
graph = create_agent_graph()
|
| 14 |
+
result = graph.invoke({
|
| 15 |
+
"persona_name": "parv_singla",
|
| 16 |
+
"persona": {...},
|
| 17 |
+
"current_time": "2026-07-03 06:00",
|
| 18 |
+
})
|
| 19 |
+
|
| 20 |
+
Usage from CLI:
|
| 21 |
+
python Single_agent.py parv_singla
|
| 22 |
"""
|
| 23 |
|
| 24 |
from __future__ import annotations
|
backend/src/agents/autonomy.py
CHANGED
|
@@ -1,12 +1,9 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
Defines AutonomyDecision (deviate / deviation_type / reason / duration)
|
| 4 |
-
as the structured contract for a behavior switch the brain may request.
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
no changes to existing callers.
|
| 10 |
"""
|
| 11 |
|
| 12 |
from __future__ import annotations
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Autonomy — schema for the per-minute LLM decision to deviate from the plan.
|
|
|
|
|
|
|
| 3 |
|
| 4 |
+
The brain calls this once per agent per minute (when enabled). The LLM
|
| 5 |
+
sees the agent's persona, current plan, and nearby surroundings, then
|
| 6 |
+
decides whether to continue the plan or deviate temporarily.
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
backend/src/agents/body.py
CHANGED
|
@@ -1,13 +1,23 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Body -- the agent's "limbs". The motor layer the brain commands.
|
| 3 |
+
|
| 4 |
+
In the human-like architecture the *brain* (brain.py) does the thinking:
|
| 5 |
+
it perceives, recalls, and decides. It never moves the agent directly.
|
| 6 |
+
Instead it issues motor commands to this Body, which is the only thing that
|
| 7 |
+
actually changes the agent's position and current activity.
|
| 8 |
+
|
| 9 |
+
The Body is a thin, behaviour-preserving adapter around the existing action
|
| 10 |
+
state machine (`AgentActionManager` in Actions.py) -- the proven executor
|
| 11 |
+
that walks paths and steps through the day plan. Wrapping it (rather than
|
| 12 |
+
replacing it) means the body/brain split is a clean architectural layer with
|
| 13 |
+
zero change to how movement and actions actually run.
|
| 14 |
+
|
| 15 |
+
Motor command surface (all 0-LLM):
|
| 16 |
+
- advance(tick) : take the next step of the current plan
|
| 17 |
+
- enter_conversation(name) : freeze into a conversation with someone
|
| 18 |
+
- resume(day_plan) : leave conversation / reload the plan
|
| 19 |
+
Read-only senses of the body's own state:
|
| 20 |
+
- position, current_action, is_last_action
|
| 21 |
"""
|
| 22 |
|
| 23 |
from __future__ import annotations
|
backend/src/agents/brain.py
CHANGED
|
@@ -1,13 +1,13 @@
|
|
| 1 |
-
"""
|
|
|
|
| 2 |
|
| 3 |
-
Each tick
|
| 4 |
-
|
| 5 |
-
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
Design: conservative by prompt (replan only for significant events) and
|
| 10 |
-
by default (any LLM failure falls back to "continue").
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Brain -- the agent's cognition / command centre.
|
| 3 |
|
| 4 |
+
Each tick the brain may be called to decide (via LLM) whether the agent
|
| 5 |
+
should continue their current plan or replan, based on novel observations.
|
| 6 |
+
The LLM call is gated: it only fires when the perceive phase detects a change
|
| 7 |
+
in the set of (agent_id, action_description) within 50px.
|
| 8 |
|
| 9 |
+
When no novel observations exist, the brain returns "continue" without an LLM
|
| 10 |
+
call — the agent follows its existing plan.
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
backend/src/agents/conversation.py
CHANGED
|
@@ -1,13 +1,33 @@
|
|
| 1 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
the persistent RelationshipMatrix (directed scores, tags, context).
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
@@ -84,13 +104,6 @@ class ConversationResult(BaseModel):
|
|
| 84 |
duration_minutes: int = Field(ge=6, le=20)
|
| 85 |
sentiment: Literal["positive", "neutral", "negative"]
|
| 86 |
relationship_delta: float = Field(ge=-0.15, le=0.15)
|
| 87 |
-
# LLM-decided net wellbeing effect of the chat for each participant.
|
| 88 |
-
# Energy and mood each run from 0.0 to 1.0; the resulting value after the
|
| 89 |
-
# chat must stay inside that range (never below 0% or above 100%).
|
| 90 |
-
energy_delta_a: float = 0.0
|
| 91 |
-
emotion_delta_a: float = 0.0
|
| 92 |
-
energy_delta_b: float = 0.0
|
| 93 |
-
emotion_delta_b: float = 0.0
|
| 94 |
# Folded-in replan decision: avoids a separate 4-call day-plan regeneration
|
| 95 |
# per agent after every conversation. True only when the conversation
|
| 96 |
# genuinely changes an agent's immediate intentions.
|
|
@@ -545,9 +558,6 @@ Return a JSON object with:
|
|
| 545 |
- "duration_minutes": integer from 6 to 20 that matches the amount of dialogue
|
| 546 |
- "sentiment": "positive" | "neutral" | "negative"
|
| 547 |
- "relationship_delta": float between -0.15 and 0.15 (how this conversation changes their relationship)
|
| 548 |
-
- "energy_delta_a" / "energy_delta_b": each agent's net ENERGY change from this chat (positive = recharged, negative = drained)
|
| 549 |
-
- "emotion_delta_a" / "emotion_delta_b": each agent's net MOOD change from this chat (positive = lifted, negative = dampened)
|
| 550 |
-
- 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%
|
| 551 |
- "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.
|
| 552 |
- "plan_change": short string describing the change if should_replan is true, else null"""
|
| 553 |
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Conversation -- generates dialogue between two agents via a single LLM call.
|
| 3 |
+
|
| 4 |
+
The WorldEngine calls `generate_conversation()` when two agents share a
|
| 5 |
+
location_id, are both in compatible actions (not sleeping), and neither is
|
| 6 |
+
already mid-conversation.
|
| 7 |
+
|
| 8 |
+
The single LLM call produces the full conversation (messages, summary,
|
| 9 |
+
duration, sentiment, relationship delta). Both agents get their current
|
| 10 |
+
action overwritten to "Chatting with X" for the duration, then naturally
|
| 11 |
+
replan via the tick graph when it expires.
|
| 12 |
|
| 13 |
+
Usage:
|
| 14 |
+
from src.agents.conversation import generate_conversation, RelationshipMatrix
|
|
|
|
| 15 |
|
| 16 |
+
matrix = RelationshipMatrix()
|
| 17 |
+
result = generate_conversation(
|
| 18 |
+
agent_a_id="parv_singla",
|
| 19 |
+
agent_b_id="tanishq",
|
| 20 |
+
persona_a=gray_wilder_persona,
|
| 21 |
+
persona_b=jules_persona,
|
| 22 |
+
plan_a=gray_wilder_plan,
|
| 23 |
+
plan_b=jules_plan,
|
| 24 |
+
action_a=gray_wilder_current_action,
|
| 25 |
+
action_b=jules_current_action,
|
| 26 |
+
rel_a_to_b=matrix.get("parv_singla", "tanishq"),
|
| 27 |
+
rel_b_to_a=matrix.get("tanishq", "parv_singla"),
|
| 28 |
+
location_id="mess",
|
| 29 |
+
current_hhmm="08:05",
|
| 30 |
+
)
|
| 31 |
"""
|
| 32 |
|
| 33 |
from __future__ import annotations
|
|
|
|
| 104 |
duration_minutes: int = Field(ge=6, le=20)
|
| 105 |
sentiment: Literal["positive", "neutral", "negative"]
|
| 106 |
relationship_delta: float = Field(ge=-0.15, le=0.15)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
# Folded-in replan decision: avoids a separate 4-call day-plan regeneration
|
| 108 |
# per agent after every conversation. True only when the conversation
|
| 109 |
# genuinely changes an agent's immediate intentions.
|
|
|
|
| 558 |
- "duration_minutes": integer from 6 to 20 that matches the amount of dialogue
|
| 559 |
- "sentiment": "positive" | "neutral" | "negative"
|
| 560 |
- "relationship_delta": float between -0.15 and 0.15 (how this conversation changes their relationship)
|
|
|
|
|
|
|
|
|
|
| 561 |
- "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.
|
| 562 |
- "plan_change": short string describing the change if should_replan is true, else null"""
|
| 563 |
|
backend/src/agents/daily_flavor.py
CHANGED
|
@@ -1,13 +1,9 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
Picks one of ten themes (e.g. Sports, Academics) and one of ten emotions
|
| 4 |
-
(e.g. Excited, Melancholic) per agent per day, injected into planner
|
| 5 |
-
prompts so days do not feel scripted.
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
unpredictable-but-plausible schedules.
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Daily flavor — random theme and emotion pickers for day_planner.
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
+
Each day an agent gets a random theme (what they focus on) and emotion
|
| 5 |
+
(their mood), injected into the planner prompts so the schedule doesn't
|
| 6 |
+
feel identical every day.
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
backend/src/agents/day_planner.py
CHANGED
|
@@ -1,16 +1,33 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
|
@@ -90,7 +107,7 @@ def _academic_venue_policy(persona: Dict[str, Any]) -> str:
|
|
| 90 |
return "No branch-specific policy is known; choose the listed location that explicitly fits."
|
| 91 |
return (
|
| 92 |
f"This student is in {branch}. Branch-specific classes, tutorials, and labs may use "
|
| 93 |
-
f"`{destination}` or
|
| 94 |
|
| 95 |
)
|
| 96 |
|
|
@@ -120,11 +137,10 @@ def _local_academic_venue_check(actions: List[Dict[str, Any]], persona: Dict[str
|
|
| 120 |
continue
|
| 121 |
if any(word in description for word in _SHARED_SESSION_WORDS):
|
| 122 |
continue
|
| 123 |
-
|
| 124 |
-
if location not in allowed_venues:
|
| 125 |
return (
|
| 126 |
f"branch-specific academic action '{action.get('action')}' for {persona.get('Branch')} "
|
| 127 |
-
f"must use {required},
|
| 128 |
)
|
| 129 |
return None
|
| 130 |
|
|
@@ -196,14 +212,6 @@ class CoarseBlock(BaseModel):
|
|
| 196 |
)
|
| 197 |
energy_change: float = 0.0
|
| 198 |
emotion_change: float = 0.0
|
| 199 |
-
energy_target: Optional[float] = Field(
|
| 200 |
-
default=None,
|
| 201 |
-
description=(
|
| 202 |
-
"Optional: your declared cumulative energy level (0.0-1.0) the agent "
|
| 203 |
-
"should have when this block ends. The runtime glides energy toward "
|
| 204 |
-
"this target. Follow the TIME-OF-DAY rules in the guidance."
|
| 205 |
-
),
|
| 206 |
-
)
|
| 207 |
|
| 208 |
|
| 209 |
class CoarsePlanOutput(BaseModel):
|
|
@@ -236,10 +244,6 @@ class HourlyBlock(BaseModel):
|
|
| 236 |
parent_activity: str = Field(description="The coarse block this refines")
|
| 237 |
energy_change: float = 0.0
|
| 238 |
emotion_change: float = 0.0
|
| 239 |
-
energy_target: Optional[float] = Field(
|
| 240 |
-
default=None,
|
| 241 |
-
description="Optional cumulative energy level (0.0-1.0) at the end of this block.",
|
| 242 |
-
)
|
| 243 |
|
| 244 |
|
| 245 |
class HourlyPlanOutput(BaseModel):
|
|
@@ -255,14 +259,6 @@ class FineAction(BaseModel):
|
|
| 255 |
sub_area: Optional[str] = Field(default=None, description="One of that place's sub_areas, if applicable")
|
| 256 |
energy_change: float = Field(description="Energy change [-1.0, 1.0] over this action; positive=restorative, negative=tiring")
|
| 257 |
emotion_change: float = Field(description="Emotion change [-1.0, 1.0] over this action; positive=uplifting, negative=draining")
|
| 258 |
-
energy_target: Optional[float] = Field(
|
| 259 |
-
default=None,
|
| 260 |
-
description=(
|
| 261 |
-
"Optional cumulative energy level (0.0-1.0) the agent should have when "
|
| 262 |
-
"this action ends. The runtime glides energy toward this declared target; "
|
| 263 |
-
"follow the TIME-OF-DAY rules in the guidance."
|
| 264 |
-
),
|
| 265 |
-
)
|
| 266 |
|
| 267 |
|
| 268 |
class FinePlanOutput(BaseModel):
|
|
@@ -275,7 +271,6 @@ class AtomicLocationAssignment(BaseModel):
|
|
| 275 |
sub_area: Optional[str] = None
|
| 276 |
energy_change: float = 0.0
|
| 277 |
emotion_change: float = 0.0
|
| 278 |
-
energy_target: Optional[float] = None
|
| 279 |
|
| 280 |
class AtomicLocationOutput(BaseModel):
|
| 281 |
assignments: List[AtomicLocationAssignment]
|
|
@@ -386,21 +381,6 @@ def _flavor_block(state: DayPlannerState) -> str:
|
|
| 386 |
return f"Today's vibe: {emotion}, leaning into {theme}."
|
| 387 |
|
| 388 |
|
| 389 |
-
def _agent_name(state: DayPlannerState) -> str:
|
| 390 |
-
persona = state.get("persona") or {}
|
| 391 |
-
return str(persona.get("Name") or persona.get("name") or "unknown")
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
def _conflict_feedback(state: DayPlannerState) -> str:
|
| 395 |
-
reason = state.get("conflict_reason")
|
| 396 |
-
if not reason:
|
| 397 |
-
return ""
|
| 398 |
-
return (
|
| 399 |
-
f"\n\nNOTE: a previous attempt was rejected for this reason, avoid repeating it:\n"
|
| 400 |
-
f"{reason}"
|
| 401 |
-
)
|
| 402 |
-
|
| 403 |
-
|
| 404 |
def _planning_window(state: DayPlannerState) -> tuple[str, str]:
|
| 405 |
"""Return the exact time window owned by this planner invocation."""
|
| 406 |
current_time = state.get("current_time", "")
|
|
@@ -463,38 +443,6 @@ def _within_source_windows(record: Dict[str, Any], sources: List[Dict[str, Any]]
|
|
| 463 |
# Nodes
|
| 464 |
# ---------------------------------------------------------------------------
|
| 465 |
|
| 466 |
-
_WELLBEING_GUIDANCE = (
|
| 467 |
-
"For EACH block/action, assign energy_change and emotion_change values. "
|
| 468 |
-
"These are the NET changes to the agent's running energy and mood caused "
|
| 469 |
-
"by that activity.\n"
|
| 470 |
-
"- energy_change: positive = restores energy, negative = drains energy\n"
|
| 471 |
-
"- emotion_change: positive = lifts mood, negative = dampens mood\n"
|
| 472 |
-
"Energy and mood each run from 0.0 to 1.0 (0% to 100%). Track the day "
|
| 473 |
-
"cumulatively: after EVERY activity the running total of energy and of "
|
| 474 |
-
"mood must stay between 0.0 and 1.0 — never above 100% or below 0%.\n"
|
| 475 |
-
"Be realistic for the persona: a full night's sleep restores a lot "
|
| 476 |
-
"(about +0.2 to +0.5), meals and rest restore a little, hard exercise and "
|
| 477 |
-
"all-nighters drain substantially, ordinary classes and chores sit in "
|
| 478 |
-
"between.\n"
|
| 479 |
-
"TIME-OF-DAY (circadian) RULES — energy must follow the clock, and YOU "
|
| 480 |
-
"choose the exact numbers:\n"
|
| 481 |
-
"- Energy is highest after waking (aim for roughly 0.65-0.85 at day "
|
| 482 |
-
"start), dips mid-afternoon around 14:00, declines through the evening, "
|
| 483 |
-
"and is LOWEST before bed (roughly 0.15-0.35 by 22:00-23:00).\n"
|
| 484 |
-
"- A day that ends near where it started is unrealistic: plan the day to "
|
| 485 |
-
"end at least 0.2-0.4 BELOW its morning energy.\n"
|
| 486 |
-
"- After ~22:00 nothing restores energy except sleep — late study, "
|
| 487 |
-
"screens, and socialising drain or stay neutral.\n"
|
| 488 |
-
"- Only sleep, meals, and genuine rest restore; classes, labs, study, "
|
| 489 |
-
"and exercise drain at least a little, sized to how long and demanding "
|
| 490 |
-
"the activity is.\n"
|
| 491 |
-
"Optionally declare energy_target for EACH block/action: your cumulative "
|
| 492 |
-
"energy level (0.0-1.0) at the moment it ends. The runtime glides the "
|
| 493 |
-
"agent's energy toward each declared target, so use it to encode the "
|
| 494 |
-
"circadian curve above. When omitted, the runtime uses energy_change "
|
| 495 |
-
"directly."
|
| 496 |
-
)
|
| 497 |
-
|
| 498 |
def generate_coarse_plan(state: DayPlannerState) -> DayPlannerState:
|
| 499 |
persona = state["persona"]
|
| 500 |
mode = state.get("mode", "full_day")
|
|
@@ -528,18 +476,10 @@ def generate_coarse_plan(state: DayPlannerState) -> DayPlannerState:
|
|
| 528 |
"worth planning separately. Examples: sleeping, attending a class/lecture, "
|
| 529 |
"sitting an exam, watching a movie, a long uninterrupted study/deep-work session.\n"
|
| 530 |
"- flexible: an activity that naturally contains distinct on-site sub-activities.\n\n"
|
| 531 |
-
+
|
| 532 |
)
|
| 533 |
if current_loc:
|
| 534 |
loc_hint = f"\nThe agent is currently at: {current_loc}. Start the plan from this location."
|
| 535 |
-
wellbeing_line = (
|
| 536 |
-
f"CURRENT WELLBEING: energy {state['current_energy']:.2f}/1.0, "
|
| 537 |
-
f"emotion {state['current_emotion']:.2f}/1.0 — plan the remaining day "
|
| 538 |
-
"from these values, keeping the cumulative energy and mood totals "
|
| 539 |
-
"between 0.0 and 1.0.\n\n"
|
| 540 |
-
if state.get("current_energy") is not None and state.get("current_emotion") is not None
|
| 541 |
-
else ""
|
| 542 |
-
)
|
| 543 |
user_prompt = (
|
| 544 |
f"PERSONA:\n{_persona_block(persona)}\n\n"
|
| 545 |
f"RELEVANT MEMORIES:\n{_memories_block(state.get('relevant_memories', []))}\n\n"
|
|
@@ -550,13 +490,15 @@ def generate_coarse_plan(state: DayPlannerState) -> DayPlannerState:
|
|
| 550 |
f"Plan mode: {mode}\n"
|
| 551 |
f"REQUIRED OUTPUT WINDOW: {_window_constraint(state)}\n"
|
| 552 |
f"Agent location: {current_loc or 'unknown'}{loc_hint}\n\n"
|
| 553 |
-
f"{wellbeing_line}"
|
| 554 |
f"DAY-HANDOFF CONTINUITY:\n{state.get('handoff_context') or '(none)'}\n\n"
|
| 555 |
"Generate the coarse plan now."
|
| 556 |
)
|
| 557 |
|
| 558 |
if state.get("conflict_reason"):
|
| 559 |
-
user_prompt +=
|
|
|
|
|
|
|
|
|
|
| 560 |
|
| 561 |
required_start = _planning_window(state)[0]
|
| 562 |
result = call_gemini(
|
|
@@ -565,7 +507,7 @@ def generate_coarse_plan(state: DayPlannerState) -> DayPlannerState:
|
|
| 565 |
_coarse_output_schema(required_start),
|
| 566 |
"default",
|
| 567 |
)
|
| 568 |
-
logger.info("[day_planner]
|
| 569 |
|
| 570 |
return {
|
| 571 |
**state,
|
|
@@ -582,16 +524,14 @@ def validate_coarse_window(state: DayPlannerState) -> DayPlannerState:
|
|
| 582 |
action_key="activity",
|
| 583 |
)
|
| 584 |
if issue:
|
| 585 |
-
logger.info("[day_planner]
|
| 586 |
return {
|
| 587 |
**state,
|
| 588 |
"conflict_detected": True,
|
| 589 |
"conflict_reason": issue,
|
| 590 |
"retry_count": state.get("retry_count", 0) + 1,
|
| 591 |
}
|
| 592 |
-
|
| 593 |
-
# clear the last rejection before the failing stage regenerates.
|
| 594 |
-
return {**state, "conflict_detected": False}
|
| 595 |
|
| 596 |
|
| 597 |
def decompose_hourly(state: DayPlannerState) -> DayPlannerState:
|
|
@@ -619,7 +559,6 @@ def decompose_hourly(state: DayPlannerState) -> DayPlannerState:
|
|
| 619 |
"granularity": "atomic",
|
| 620 |
"energy_change": b.get("energy_change", 0.0),
|
| 621 |
"emotion_change": b.get("emotion_change", 0.0),
|
| 622 |
-
"energy_target": b.get("energy_target"),
|
| 623 |
}
|
| 624 |
for b in atomic_blocks
|
| 625 |
]
|
|
@@ -636,8 +575,12 @@ def decompose_hourly(state: DayPlannerState) -> DayPlannerState:
|
|
| 636 |
"(e.g. a meal block of 2 hours can contain 'walk to mess', 'eat', 'socialize'). "
|
| 637 |
"Only the blocks provided here need refining. Do not create walk, commute, or "
|
| 638 |
"transit sub-blocks: refine only activities performed at the destination.\n\n"
|
| 639 |
-
|
| 640 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 641 |
)
|
| 642 |
user_prompt = (
|
| 643 |
f"PERSONA:\n{_persona_block(persona)}\n\n"
|
|
@@ -647,15 +590,13 @@ def decompose_hourly(state: DayPlannerState) -> DayPlannerState:
|
|
| 647 |
f"REQUIRED OUTPUT WINDOW: {_window_constraint(state)}\n\n"
|
| 648 |
"Produce the hourly-resolution plan for the blocks listed under "
|
| 649 |
"'BLOCKS TO REFINE' only."
|
| 650 |
-
+ _conflict_feedback(state)
|
| 651 |
)
|
| 652 |
result = call_gemini(system_prompt, user_prompt, HourlyPlanOutput, "default")
|
| 653 |
raw_refined = [b.model_dump() for b in result.blocks]
|
| 654 |
refined = [block for block in raw_refined if _within_source_windows(block, flexible_blocks)]
|
| 655 |
if len(refined) != len(raw_refined):
|
| 656 |
logger.warning(
|
| 657 |
-
"[day_planner]
|
| 658 |
-
_agent_name(state),
|
| 659 |
len(raw_refined) - len(refined),
|
| 660 |
)
|
| 661 |
for b in refined:
|
|
@@ -664,8 +605,7 @@ def decompose_hourly(state: DayPlannerState) -> DayPlannerState:
|
|
| 664 |
|
| 665 |
hourly_blocks.sort(key=lambda b: b["start"])
|
| 666 |
logger.info(
|
| 667 |
-
"[day_planner]
|
| 668 |
-
_agent_name(state),
|
| 669 |
len(passthrough_hourly), len(hourly_blocks) - len(passthrough_hourly),
|
| 670 |
)
|
| 671 |
|
|
@@ -707,100 +647,14 @@ def validate_hourly_refinement(state: DayPlannerState) -> DayPlannerState:
|
|
| 707 |
issue = f"hourly refinement '{block.get('activity', 'unknown')}' exceeds its flexible source window"
|
| 708 |
break
|
| 709 |
if issue:
|
| 710 |
-
logger.info("[day_planner]
|
| 711 |
return {
|
| 712 |
**state,
|
| 713 |
"conflict_detected": True,
|
| 714 |
"conflict_reason": issue,
|
| 715 |
"retry_count": state.get("retry_count", 0) + 1,
|
| 716 |
}
|
| 717 |
-
|
| 718 |
-
return {**state, "conflict_detected": False}
|
| 719 |
-
|
| 720 |
-
def _match_atomic_assignment(
|
| 721 |
-
activity: str, loc_by_activity: Dict[str, deque]
|
| 722 |
-
) -> Optional[AtomicLocationAssignment]:
|
| 723 |
-
"""Pick a location assignment for an atomic block: exact activity match
|
| 724 |
-
first, then a case-insensitive label match. The location model sometimes
|
| 725 |
-
rewrites activity labels between stages, so an exact-only lookup leaves
|
| 726 |
-
blocks with no assignment and the whole day falls back to force-accept."""
|
| 727 |
-
candidates = loc_by_activity.get(activity)
|
| 728 |
-
if candidates:
|
| 729 |
-
return candidates.popleft()
|
| 730 |
-
for key in list(loc_by_activity):
|
| 731 |
-
if not key:
|
| 732 |
-
continue
|
| 733 |
-
if (
|
| 734 |
-
key.lower() == activity.lower()
|
| 735 |
-
or key.lower() in activity.lower()
|
| 736 |
-
or activity.lower() in key.lower()
|
| 737 |
-
):
|
| 738 |
-
return loc_by_activity[key].popleft()
|
| 739 |
-
return None
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
def _fallback_location_id(
|
| 743 |
-
activity: str,
|
| 744 |
-
places: List[Place],
|
| 745 |
-
current_loc: str,
|
| 746 |
-
persona: Dict[str, Any],
|
| 747 |
-
) -> Optional[str]:
|
| 748 |
-
"""Deterministic venue fallback for atomic blocks the location model did
|
| 749 |
-
not assign. Returns a valid location_id whenever any place exists, so a
|
| 750 |
-
missing assignment can never poison validation with location_id None."""
|
| 751 |
-
valid_ids = {p.id for p in places}
|
| 752 |
-
|
| 753 |
-
def pick(candidates: List[Any]) -> Optional[str]:
|
| 754 |
-
for candidate in candidates:
|
| 755 |
-
if candidate and candidate in valid_ids:
|
| 756 |
-
return candidate
|
| 757 |
-
return None
|
| 758 |
-
|
| 759 |
-
text = activity.lower()
|
| 760 |
-
has = lambda *words: any(word in text for word in words)
|
| 761 |
-
|
| 762 |
-
# 1) Explicit venue words inside the activity label win first.
|
| 763 |
-
if has("library"):
|
| 764 |
-
return pick(
|
| 765 |
-
[p.id for p in places if "library" in p.id.lower() or "library" in p.name.lower()]
|
| 766 |
-
)
|
| 767 |
-
if has("hostel"):
|
| 768 |
-
return pick([p.id for p in places if p.type == "residential"] + [persona.get("Hostel")])
|
| 769 |
-
if has("mess", "canteen", "dining"):
|
| 770 |
-
return pick(
|
| 771 |
-
[p.id for p in places if "mess" in p.id.lower() or "mess" in p.name.lower()
|
| 772 |
-
or "canteen" in p.id.lower() or "canteen" in p.name.lower()]
|
| 773 |
-
)
|
| 774 |
-
if has("gym", "workout"):
|
| 775 |
-
return pick([p.id for p in places if "gym" in p.id.lower() or "gym" in p.name.lower()])
|
| 776 |
-
if has("sab"):
|
| 777 |
-
return pick(["SAB", *[p.id for p in places if p.id == "SAB"]])
|
| 778 |
-
if has("lhc", "lecture hall"):
|
| 779 |
-
return pick(["LHC", *[p.id for p in places if p.id == "LHC"]])
|
| 780 |
-
|
| 781 |
-
# 2) Category-based defaults, matching the venue policy used in prompts.
|
| 782 |
-
if has("class", "lecture", "lab", "exam", "study", "project", "tutorial", "seminar"):
|
| 783 |
-
return pick(
|
| 784 |
-
[p.id for p in places if "department" in p.id.lower() or "department" in p.name.lower()]
|
| 785 |
-
+ ["LHC", "SAB", "library"]
|
| 786 |
-
+ [p.id for p in places if "library" in p.id.lower() or "library" in p.name.lower()]
|
| 787 |
-
)
|
| 788 |
-
if has("breakfast", "lunch", "dinner", "meal", "eat", "food"):
|
| 789 |
-
return pick(
|
| 790 |
-
[p.id for p in places if "mess" in p.id.lower() or "mess" in p.name.lower()
|
| 791 |
-
or "canteen" in p.id.lower() or "canteen" in p.name.lower()]
|
| 792 |
-
)
|
| 793 |
-
if has("sport", "cricket", "football", "badminton", "exercise", "run", "fitness"):
|
| 794 |
-
return pick(
|
| 795 |
-
[p.id for p in places if "sport" in p.id.lower() or "sport" in p.name.lower()
|
| 796 |
-
or "gym" in p.id.lower() or "gym" in p.name.lower()]
|
| 797 |
-
)
|
| 798 |
-
if has("sleep", "rest", "nap", "recover", "personal", "wind down", "chat", "chill", "socialize", "room", "bunk"):
|
| 799 |
-
return pick([p.id for p in places if p.type == "residential"] + [persona.get("Hostel")])
|
| 800 |
-
|
| 801 |
-
# 3) Generic fallback: current position, then hostel, then any place.
|
| 802 |
-
return pick([current_loc, persona.get("Hostel")]) or next(iter(valid_ids), None)
|
| 803 |
-
|
| 804 |
|
| 805 |
def decompose_fine(state: DayPlannerState) -> DayPlannerState:
|
| 806 |
persona = state["persona"]
|
|
@@ -824,9 +678,13 @@ def decompose_fine(state: DayPlannerState) -> DayPlannerState:
|
|
| 824 |
"Default to locations that make sense for this specific persona. "
|
| 825 |
"Do not suggest splitting the activity.\n\n"
|
| 826 |
"ACADEMIC VENUE POLICY: obey the branch-specific policy provided with the persona. "
|
| 827 |
-
"
|
| 828 |
-
|
| 829 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 830 |
)
|
| 831 |
user_prompt = (
|
| 832 |
f"PERSONA:\n{_persona_block(persona)}\n\n"
|
|
@@ -841,7 +699,6 @@ def decompose_fine(state: DayPlannerState) -> DayPlannerState:
|
|
| 841 |
"rest, and personal activities use the hostel unless the activity explicitly "
|
| 842 |
"requires another place.\n\n"
|
| 843 |
"Assign a location to each activity now."
|
| 844 |
-
+ _conflict_feedback(state)
|
| 845 |
)
|
| 846 |
result = call_gemini(system_prompt, user_prompt, AtomicLocationOutput, "default")
|
| 847 |
# Activity labels are not unique (for example, two separate study
|
|
@@ -852,40 +709,21 @@ def decompose_fine(state: DayPlannerState) -> DayPlannerState:
|
|
| 852 |
loc_by_activity[assignment.activity].append(assignment)
|
| 853 |
|
| 854 |
for b in atomic_blocks:
|
| 855 |
-
|
| 856 |
-
if
|
| 857 |
-
location_id = assignment.location_id
|
| 858 |
-
sub_area = assignment.sub_area
|
| 859 |
-
energy_change = assignment.energy_change
|
| 860 |
-
emotion_change = assignment.emotion_change
|
| 861 |
-
energy_target = (
|
| 862 |
-
assignment.energy_target
|
| 863 |
-
if assignment.energy_target is not None
|
| 864 |
-
else b.get("energy_target")
|
| 865 |
-
)
|
| 866 |
-
else:
|
| 867 |
-
location_id = _fallback_location_id(b["activity"], places, current_loc, persona)
|
| 868 |
-
sub_area = None
|
| 869 |
-
energy_change = b.get("energy_change", 0.0)
|
| 870 |
-
emotion_change = b.get("emotion_change", 0.0)
|
| 871 |
-
energy_target = b.get("energy_target")
|
| 872 |
-
if location_id is not None:
|
| 873 |
-
logger.info(
|
| 874 |
-
"[day_planner][%s] no location assignment for '%s' -- deterministic fallback to '%s'",
|
| 875 |
-
_agent_name(state),
|
| 876 |
-
b["activity"],
|
| 877 |
-
location_id,
|
| 878 |
-
)
|
| 879 |
fine_actions.append({
|
| 880 |
"action": b["activity"],
|
| 881 |
"start": b["start"],
|
| 882 |
"end": b["end"],
|
| 883 |
"parent_activity": b["parent_activity"],
|
| 884 |
-
"location_id": location_id,
|
| 885 |
-
"sub_area": sub_area,
|
| 886 |
-
"energy_change":
|
| 887 |
-
|
| 888 |
-
|
|
|
|
|
|
|
|
|
|
| 889 |
})
|
| 890 |
|
| 891 |
# Flexible blocks: full fine-grained breakdown, as before.
|
|
@@ -894,25 +732,23 @@ def decompose_fine(state: DayPlannerState) -> DayPlannerState:
|
|
| 894 |
"You refine hourly blocks into fine-grained, directly executable actions "
|
| 895 |
"at roughly 5-15 minute granularity. Each hourly block should be broken "
|
| 896 |
"into one or more fine actions spanning exactly its start/end range, no "
|
| 897 |
-
"gaps or overlaps.
|
| 898 |
-
"first action of the day starts exactly at 00:00, each action starts the "
|
| 899 |
-
"instant the previous one ends, and the LAST action of the day ends "
|
| 900 |
-
"exactly at 24:00 (write the final boundary as 24:00, never 23:59 or "
|
| 901 |
-
"0:00). Each group of fine actions must start exactly at its assigned "
|
| 902 |
-
"block's start and end exactly at its block's end -- never spill outside "
|
| 903 |
-
"your assigned windows. Every action MUST be assigned a location_id, chosen "
|
| 904 |
"EXACTLY from the provided list -- never invent one.\n\n"
|
| 905 |
"Do NOT output walking, commuting, travel, transit, leaving, or arriving "
|
| 906 |
"as an action. The runtime owns visible routes between places; every action "
|
| 907 |
"you output must be an on-site activity at its assigned location.\n\n"
|
| 908 |
"ACADEMIC VENUE POLICY: obey the branch-specific policy provided with the persona. "
|
| 909 |
-
"
|
| 910 |
"Make action boundaries feel natural — group related sub-actions together. "
|
| 911 |
"Consider typical on-site durations: eating ~20-40min and studying "
|
| 912 |
"~30-120min. Keep adjacent location changes realistic by leaving enough "
|
| 913 |
"time for the executor to animate transit before the next activity.\n\n"
|
| 914 |
-
|
| 915 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 916 |
)
|
| 917 |
user_prompt = (
|
| 918 |
f"PERSONA:\n{_persona_block(persona)}\n\n"
|
|
@@ -927,21 +763,19 @@ def decompose_fine(state: DayPlannerState) -> DayPlannerState:
|
|
| 927 |
"rest, and personal activities use the hostel unless the activity explicitly "
|
| 928 |
"requires another place.\n\n"
|
| 929 |
"Produce the fine-grained action plan for these blocks now."
|
| 930 |
-
+ _conflict_feedback(state)
|
| 931 |
)
|
| 932 |
result = call_gemini(system_prompt, user_prompt, FinePlanOutput, "default")
|
| 933 |
raw_actions = [action.model_dump() for action in result.actions]
|
| 934 |
scoped_actions = [action for action in raw_actions if _within_source_windows(action, flexible_blocks)]
|
| 935 |
if len(scoped_actions) != len(raw_actions):
|
| 936 |
logger.warning(
|
| 937 |
-
"[day_planner]
|
| 938 |
-
_agent_name(state),
|
| 939 |
len(raw_actions) - len(scoped_actions),
|
| 940 |
)
|
| 941 |
fine_actions.extend(scoped_actions)
|
| 942 |
|
| 943 |
fine_actions.sort(key=lambda a: a["start"])
|
| 944 |
-
logger.info("[day_planner]
|
| 945 |
|
| 946 |
return {**state, "fine_plan": fine_actions}
|
| 947 |
|
|
@@ -1045,7 +879,7 @@ def validate_plan(state: DayPlannerState) -> DayPlannerState:
|
|
| 1045 |
state["fine_plan"], state.get("places", [])
|
| 1046 |
) or _local_academic_venue_check(state["fine_plan"], state["persona"]) or _local_content_safety_check(state["fine_plan"])
|
| 1047 |
if local_issue:
|
| 1048 |
-
logger.info("[day_planner]
|
| 1049 |
return {
|
| 1050 |
**state,
|
| 1051 |
"conflict_detected": True,
|
|
@@ -1073,7 +907,7 @@ def validate_plan(state: DayPlannerState) -> DayPlannerState:
|
|
| 1073 |
result = call_gemini(system_prompt, user_prompt, ValidationResult, "default")
|
| 1074 |
|
| 1075 |
if not result.valid:
|
| 1076 |
-
logger.info("[day_planner]
|
| 1077 |
return {
|
| 1078 |
**state,
|
| 1079 |
"conflict_detected": True,
|
|
@@ -1081,7 +915,7 @@ def validate_plan(state: DayPlannerState) -> DayPlannerState:
|
|
| 1081 |
"retry_count": state.get("retry_count", 0) + 1,
|
| 1082 |
}
|
| 1083 |
|
| 1084 |
-
logger.info("[day_planner]
|
| 1085 |
return {
|
| 1086 |
**state,
|
| 1087 |
"conflict_detected": False,
|
|
@@ -1099,8 +933,7 @@ def route_after_validation(state: DayPlannerState) -> str:
|
|
| 1099 |
return "accept"
|
| 1100 |
if state.get("retry_count", 0) >= MAX_PLAN_RETRIES:
|
| 1101 |
logger.warning(
|
| 1102 |
-
"[day_planner]
|
| 1103 |
-
_agent_name(state),
|
| 1104 |
MAX_PLAN_RETRIES,
|
| 1105 |
)
|
| 1106 |
return "give_up"
|
|
@@ -1261,8 +1094,6 @@ def run(agent: Any, world_state: dict) -> dict:
|
|
| 1261 |
"places": places,
|
| 1262 |
"mode": mode,
|
| 1263 |
"current_location_id": world_state.get("current_location_id"),
|
| 1264 |
-
"current_energy": world_state.get("energy_level"),
|
| 1265 |
-
"current_emotion": world_state.get("emotion_state"),
|
| 1266 |
"handoff_context": world_state.get("handoff_context"),
|
| 1267 |
"upcoming_events": world_state.get("upcoming_events", []),
|
| 1268 |
"daily_theme": theme,
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
A script that plans the day of an agentic personality when handed over required data
|
| 3 |
+
Per plan takes 4 LLM calls (atlest) Coarse, Hourly, Fine, Validation, for Planning a
|
| 4 |
+
day in one agent's life.
|
| 5 |
+
|
| 6 |
+
Tier-1 LangGraph subgraph: agent day-planning.
|
| 7 |
+
|
| 8 |
+
Pipeline (mirrors Generative Agents' Planning module, coarse -> hourly -> fine,
|
| 9 |
+
with a validation/retry loop):
|
| 10 |
+
|
| 11 |
+
generate_coarse_plan -> decompose_hourly -> decompose_fine -> validate_plan
|
| 12 |
+
|
|
| 13 |
+
conflict? --yes-+ (loop back to generate_coarse_plan)
|
| 14 |
+
|
|
| 15 |
+
no -> END
|
| 16 |
+
|
| 17 |
+
LLM backend: Google Gemini via the `google-genai` SDK.
|
| 18 |
+
|
| 19 |
+
For now `relevant_memories` and `yesterday_summary` are expected to arrive
|
| 20 |
+
empty ([] / None) -- the prompts already handle that gracefully so you can
|
| 21 |
+
wire in real retrieval/memory later without touching this file's structure.
|
| 22 |
+
|
| 23 |
+
FILE NOTES:
|
| 24 |
+
Prompt structure can be improved
|
| 25 |
+
Places are being feed in Name : , Desc : format, this can be improved
|
| 26 |
+
disabled location check in validate plan : can add more places
|
| 27 |
+
|
| 28 |
+
Prompt templates have to improve
|
| 29 |
+
|
| 30 |
+
Have to figure out how to run this in the backend server, currently it is running standalone
|
| 31 |
"""
|
| 32 |
|
| 33 |
from __future__ import annotations
|
|
|
|
| 107 |
return "No branch-specific policy is known; choose the listed location that explicitly fits."
|
| 108 |
return (
|
| 109 |
f"This student is in {branch}. Branch-specific classes, tutorials, and labs may use "
|
| 110 |
+
f"`{destination}` or Library/ SAB. LHC is only for common/core/elective/guest/large shared sessions and classes. "
|
| 111 |
|
| 112 |
)
|
| 113 |
|
|
|
|
| 137 |
continue
|
| 138 |
if any(word in description for word in _SHARED_SESSION_WORDS):
|
| 139 |
continue
|
| 140 |
+
if location != required:
|
|
|
|
| 141 |
return (
|
| 142 |
f"branch-specific academic action '{action.get('action')}' for {persona.get('Branch')} "
|
| 143 |
+
f"must use {required}, not {location}"
|
| 144 |
)
|
| 145 |
return None
|
| 146 |
|
|
|
|
| 212 |
)
|
| 213 |
energy_change: float = 0.0
|
| 214 |
emotion_change: float = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
|
| 217 |
class CoarsePlanOutput(BaseModel):
|
|
|
|
| 244 |
parent_activity: str = Field(description="The coarse block this refines")
|
| 245 |
energy_change: float = 0.0
|
| 246 |
emotion_change: float = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
|
| 248 |
|
| 249 |
class HourlyPlanOutput(BaseModel):
|
|
|
|
| 259 |
sub_area: Optional[str] = Field(default=None, description="One of that place's sub_areas, if applicable")
|
| 260 |
energy_change: float = Field(description="Energy change [-1.0, 1.0] over this action; positive=restorative, negative=tiring")
|
| 261 |
emotion_change: float = Field(description="Emotion change [-1.0, 1.0] over this action; positive=uplifting, negative=draining")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
|
| 263 |
|
| 264 |
class FinePlanOutput(BaseModel):
|
|
|
|
| 271 |
sub_area: Optional[str] = None
|
| 272 |
energy_change: float = 0.0
|
| 273 |
emotion_change: float = 0.0
|
|
|
|
| 274 |
|
| 275 |
class AtomicLocationOutput(BaseModel):
|
| 276 |
assignments: List[AtomicLocationAssignment]
|
|
|
|
| 381 |
return f"Today's vibe: {emotion}, leaning into {theme}."
|
| 382 |
|
| 383 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
def _planning_window(state: DayPlannerState) -> tuple[str, str]:
|
| 385 |
"""Return the exact time window owned by this planner invocation."""
|
| 386 |
current_time = state.get("current_time", "")
|
|
|
|
| 443 |
# Nodes
|
| 444 |
# ---------------------------------------------------------------------------
|
| 445 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
def generate_coarse_plan(state: DayPlannerState) -> DayPlannerState:
|
| 447 |
persona = state["persona"]
|
| 448 |
mode = state.get("mode", "full_day")
|
|
|
|
| 476 |
"worth planning separately. Examples: sleeping, attending a class/lecture, "
|
| 477 |
"sitting an exam, watching a movie, a long uninterrupted study/deep-work session.\n"
|
| 478 |
"- flexible: an activity that naturally contains distinct on-site sub-activities.\n\n"
|
| 479 |
+
"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."
|
| 480 |
)
|
| 481 |
if current_loc:
|
| 482 |
loc_hint = f"\nThe agent is currently at: {current_loc}. Start the plan from this location."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
user_prompt = (
|
| 484 |
f"PERSONA:\n{_persona_block(persona)}\n\n"
|
| 485 |
f"RELEVANT MEMORIES:\n{_memories_block(state.get('relevant_memories', []))}\n\n"
|
|
|
|
| 490 |
f"Plan mode: {mode}\n"
|
| 491 |
f"REQUIRED OUTPUT WINDOW: {_window_constraint(state)}\n"
|
| 492 |
f"Agent location: {current_loc or 'unknown'}{loc_hint}\n\n"
|
|
|
|
| 493 |
f"DAY-HANDOFF CONTINUITY:\n{state.get('handoff_context') or '(none)'}\n\n"
|
| 494 |
"Generate the coarse plan now."
|
| 495 |
)
|
| 496 |
|
| 497 |
if state.get("conflict_reason"):
|
| 498 |
+
user_prompt += (
|
| 499 |
+
f"\n\nNOTE: a previous attempt was rejected for this reason, avoid repeating it:\n"
|
| 500 |
+
f"{state['conflict_reason']}"
|
| 501 |
+
)
|
| 502 |
|
| 503 |
required_start = _planning_window(state)[0]
|
| 504 |
result = call_gemini(
|
|
|
|
| 507 |
_coarse_output_schema(required_start),
|
| 508 |
"default",
|
| 509 |
)
|
| 510 |
+
logger.info("[day_planner] coarse plan generated: %d blocks", len(result.blocks))
|
| 511 |
|
| 512 |
return {
|
| 513 |
**state,
|
|
|
|
| 524 |
action_key="activity",
|
| 525 |
)
|
| 526 |
if issue:
|
| 527 |
+
logger.info("[day_planner] coarse-window validation failed: %s", issue)
|
| 528 |
return {
|
| 529 |
**state,
|
| 530 |
"conflict_detected": True,
|
| 531 |
"conflict_reason": issue,
|
| 532 |
"retry_count": state.get("retry_count", 0) + 1,
|
| 533 |
}
|
| 534 |
+
return {**state, "conflict_detected": False, "conflict_reason": None}
|
|
|
|
|
|
|
| 535 |
|
| 536 |
|
| 537 |
def decompose_hourly(state: DayPlannerState) -> DayPlannerState:
|
|
|
|
| 559 |
"granularity": "atomic",
|
| 560 |
"energy_change": b.get("energy_change", 0.0),
|
| 561 |
"emotion_change": b.get("emotion_change", 0.0),
|
|
|
|
| 562 |
}
|
| 563 |
for b in atomic_blocks
|
| 564 |
]
|
|
|
|
| 575 |
"(e.g. a meal block of 2 hours can contain 'walk to mess', 'eat', 'socialize'). "
|
| 576 |
"Only the blocks provided here need refining. Do not create walk, commute, or "
|
| 577 |
"transit sub-blocks: refine only activities performed at the destination.\n\n"
|
| 578 |
+
"For EACH block, assign realistic energy_change and emotion_change values:\n"
|
| 579 |
+
"- energy_change: positive = restorative, negative = tiring\n"
|
| 580 |
+
"- emotion_change: positive = uplifting, negative = draining\n"
|
| 581 |
+
"- Routine work, classes, and meals should usually stay within -0.03 to +0.03; do not make ordinary productivity euphoric\n"
|
| 582 |
+
"- Be realistic for the persona\n\n"
|
| 583 |
+
f"{_window_constraint(state)}"
|
| 584 |
)
|
| 585 |
user_prompt = (
|
| 586 |
f"PERSONA:\n{_persona_block(persona)}\n\n"
|
|
|
|
| 590 |
f"REQUIRED OUTPUT WINDOW: {_window_constraint(state)}\n\n"
|
| 591 |
"Produce the hourly-resolution plan for the blocks listed under "
|
| 592 |
"'BLOCKS TO REFINE' only."
|
|
|
|
| 593 |
)
|
| 594 |
result = call_gemini(system_prompt, user_prompt, HourlyPlanOutput, "default")
|
| 595 |
raw_refined = [b.model_dump() for b in result.blocks]
|
| 596 |
refined = [block for block in raw_refined if _within_source_windows(block, flexible_blocks)]
|
| 597 |
if len(refined) != len(raw_refined):
|
| 598 |
logger.warning(
|
| 599 |
+
"[day_planner] discarded %d hourly refinement block(s) outside flexible source windows",
|
|
|
|
| 600 |
len(raw_refined) - len(refined),
|
| 601 |
)
|
| 602 |
for b in refined:
|
|
|
|
| 605 |
|
| 606 |
hourly_blocks.sort(key=lambda b: b["start"])
|
| 607 |
logger.info(
|
| 608 |
+
"[day_planner] hourly plan: %d atomic passthrough + %d refined",
|
|
|
|
| 609 |
len(passthrough_hourly), len(hourly_blocks) - len(passthrough_hourly),
|
| 610 |
)
|
| 611 |
|
|
|
|
| 647 |
issue = f"hourly refinement '{block.get('activity', 'unknown')}' exceeds its flexible source window"
|
| 648 |
break
|
| 649 |
if issue:
|
| 650 |
+
logger.info("[day_planner] hourly refinement validation failed: %s", issue)
|
| 651 |
return {
|
| 652 |
**state,
|
| 653 |
"conflict_detected": True,
|
| 654 |
"conflict_reason": issue,
|
| 655 |
"retry_count": state.get("retry_count", 0) + 1,
|
| 656 |
}
|
| 657 |
+
return {**state, "conflict_detected": False, "conflict_reason": None}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 658 |
|
| 659 |
def decompose_fine(state: DayPlannerState) -> DayPlannerState:
|
| 660 |
persona = state["persona"]
|
|
|
|
| 678 |
"Default to locations that make sense for this specific persona. "
|
| 679 |
"Do not suggest splitting the activity.\n\n"
|
| 680 |
"ACADEMIC VENUE POLICY: obey the branch-specific policy provided with the persona. "
|
| 681 |
+
"Do not use SAB as a generic lecture/lab default.\n\n"
|
| 682 |
+
"For EACH block, assign realistic energy_change and emotion_change values:\n"
|
| 683 |
+
"- energy_change: positive = restorative, negative = tiring\n"
|
| 684 |
+
"- emotion_change: positive = uplifting, negative = draining\n"
|
| 685 |
+
"- Routine work, classes, and meals should usually stay within -0.03 to +0.03; do not make ordinary productivity euphoric\n"
|
| 686 |
+
"- Be realistic for the persona\n\n"
|
| 687 |
+
f"{_window_constraint(state)}"
|
| 688 |
)
|
| 689 |
user_prompt = (
|
| 690 |
f"PERSONA:\n{_persona_block(persona)}\n\n"
|
|
|
|
| 699 |
"rest, and personal activities use the hostel unless the activity explicitly "
|
| 700 |
"requires another place.\n\n"
|
| 701 |
"Assign a location to each activity now."
|
|
|
|
| 702 |
)
|
| 703 |
result = call_gemini(system_prompt, user_prompt, AtomicLocationOutput, "default")
|
| 704 |
# Activity labels are not unique (for example, two separate study
|
|
|
|
| 709 |
loc_by_activity[assignment.activity].append(assignment)
|
| 710 |
|
| 711 |
for b in atomic_blocks:
|
| 712 |
+
candidates = loc_by_activity.get(b["activity"])
|
| 713 |
+
assignment = candidates.popleft() if candidates else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 714 |
fine_actions.append({
|
| 715 |
"action": b["activity"],
|
| 716 |
"start": b["start"],
|
| 717 |
"end": b["end"],
|
| 718 |
"parent_activity": b["parent_activity"],
|
| 719 |
+
"location_id": assignment.location_id if assignment else None,
|
| 720 |
+
"sub_area": assignment.sub_area if assignment else None,
|
| 721 |
+
"energy_change": (
|
| 722 |
+
assignment.energy_change if assignment else b.get("energy_change", 0.0)
|
| 723 |
+
),
|
| 724 |
+
"emotion_change": (
|
| 725 |
+
assignment.emotion_change if assignment else b.get("emotion_change", 0.0)
|
| 726 |
+
),
|
| 727 |
})
|
| 728 |
|
| 729 |
# Flexible blocks: full fine-grained breakdown, as before.
|
|
|
|
| 732 |
"You refine hourly blocks into fine-grained, directly executable actions "
|
| 733 |
"at roughly 5-15 minute granularity. Each hourly block should be broken "
|
| 734 |
"into one or more fine actions spanning exactly its start/end range, no "
|
| 735 |
+
"gaps or overlaps. Every action MUST be assigned a location_id, chosen "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
"EXACTLY from the provided list -- never invent one.\n\n"
|
| 737 |
"Do NOT output walking, commuting, travel, transit, leaving, or arriving "
|
| 738 |
"as an action. The runtime owns visible routes between places; every action "
|
| 739 |
"you output must be an on-site activity at its assigned location.\n\n"
|
| 740 |
"ACADEMIC VENUE POLICY: obey the branch-specific policy provided with the persona. "
|
| 741 |
+
"Branch-specific classes/labs must not silently fall back to SAB.\n\n"
|
| 742 |
"Make action boundaries feel natural — group related sub-actions together. "
|
| 743 |
"Consider typical on-site durations: eating ~20-40min and studying "
|
| 744 |
"~30-120min. Keep adjacent location changes realistic by leaving enough "
|
| 745 |
"time for the executor to animate transit before the next activity.\n\n"
|
| 746 |
+
"For EACH action, assign realistic energy_change and emotion_change values:\n"
|
| 747 |
+
"- energy_change: positive = restorative, negative = tiring\n"
|
| 748 |
+
"- emotion_change: positive = uplifting, negative = draining\n"
|
| 749 |
+
"- Routine work, classes, and meals should usually stay within -0.03 to +0.03; do not make ordinary productivity euphoric\n"
|
| 750 |
+
"- Be realistic for the persona\n\n"
|
| 751 |
+
f"{_window_constraint(state)}"
|
| 752 |
)
|
| 753 |
user_prompt = (
|
| 754 |
f"PERSONA:\n{_persona_block(persona)}\n\n"
|
|
|
|
| 763 |
"rest, and personal activities use the hostel unless the activity explicitly "
|
| 764 |
"requires another place.\n\n"
|
| 765 |
"Produce the fine-grained action plan for these blocks now."
|
|
|
|
| 766 |
)
|
| 767 |
result = call_gemini(system_prompt, user_prompt, FinePlanOutput, "default")
|
| 768 |
raw_actions = [action.model_dump() for action in result.actions]
|
| 769 |
scoped_actions = [action for action in raw_actions if _within_source_windows(action, flexible_blocks)]
|
| 770 |
if len(scoped_actions) != len(raw_actions):
|
| 771 |
logger.warning(
|
| 772 |
+
"[day_planner] discarded %d fine action(s) outside flexible source windows",
|
|
|
|
| 773 |
len(raw_actions) - len(scoped_actions),
|
| 774 |
)
|
| 775 |
fine_actions.extend(scoped_actions)
|
| 776 |
|
| 777 |
fine_actions.sort(key=lambda a: a["start"])
|
| 778 |
+
logger.info("[day_planner] fine plan: %d total actions", len(fine_actions))
|
| 779 |
|
| 780 |
return {**state, "fine_plan": fine_actions}
|
| 781 |
|
|
|
|
| 879 |
state["fine_plan"], state.get("places", [])
|
| 880 |
) or _local_academic_venue_check(state["fine_plan"], state["persona"]) or _local_content_safety_check(state["fine_plan"])
|
| 881 |
if local_issue:
|
| 882 |
+
logger.info("[day_planner] local validation failed: %s", local_issue)
|
| 883 |
return {
|
| 884 |
**state,
|
| 885 |
"conflict_detected": True,
|
|
|
|
| 907 |
result = call_gemini(system_prompt, user_prompt, ValidationResult, "default")
|
| 908 |
|
| 909 |
if not result.valid:
|
| 910 |
+
logger.info("[day_planner] semantic validation failed: %s", result.reason)
|
| 911 |
return {
|
| 912 |
**state,
|
| 913 |
"conflict_detected": True,
|
|
|
|
| 915 |
"retry_count": state.get("retry_count", 0) + 1,
|
| 916 |
}
|
| 917 |
|
| 918 |
+
logger.info("[day_planner] plan validated successfully")
|
| 919 |
return {
|
| 920 |
**state,
|
| 921 |
"conflict_detected": False,
|
|
|
|
| 933 |
return "accept"
|
| 934 |
if state.get("retry_count", 0) >= MAX_PLAN_RETRIES:
|
| 935 |
logger.warning(
|
| 936 |
+
"[day_planner] max retries (%d) reached, force-accepting last plan with error flag",
|
|
|
|
| 937 |
MAX_PLAN_RETRIES,
|
| 938 |
)
|
| 939 |
return "give_up"
|
|
|
|
| 1094 |
"places": places,
|
| 1095 |
"mode": mode,
|
| 1096 |
"current_location_id": world_state.get("current_location_id"),
|
|
|
|
|
|
|
| 1097 |
"handoff_context": world_state.get("handoff_context"),
|
| 1098 |
"upcoming_events": world_state.get("upcoming_events", []),
|
| 1099 |
"daily_theme": theme,
|
backend/src/agents/memory_index.py
CHANGED
|
@@ -1,14 +1,12 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
Design: migration is idempotent (deterministic point ids) so reruns are
|
| 9 |
-
safe; --delete-source is explicit and documented.
|
| 10 |
"""
|
| 11 |
-
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
import argparse
|
|
|
|
| 1 |
+
"""Operate Qdrant-only long-term memory.
|
| 2 |
|
| 3 |
+
``migrate-json`` imports legacy Long_term_db archives once. Pass
|
| 4 |
+
``--delete-source`` only after checking the reported indexed count; it removes
|
| 5 |
+
the obsolete JSON archives after their idempotent Qdrant upsert succeeds.
|
| 6 |
|
| 7 |
+
``clear`` removes only Valhalla's durable Qdrant collections. It never touches
|
| 8 |
+
short-term runtime files, checkpoints, or the live simulation process.
|
|
|
|
|
|
|
| 9 |
"""
|
|
|
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
import argparse
|
backend/src/agents/react.py
CHANGED
|
@@ -1,14 +1,16 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
from __future__ import annotations
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
React -- decides, given an agent's current action (if any) and what it just
|
| 3 |
+
perceived, whether to keep executing that action or interrupt and replan.
|
| 4 |
+
|
| 5 |
+
Design notes
|
| 6 |
+
------------
|
| 7 |
+
All calls into this module are cheap heuristic checks with no LLM round-trip:
|
| 8 |
+
- no current action yet -> always replan
|
| 9 |
+
- current action's end_tick has passed -> always replan
|
| 10 |
+
- mid-action, nothing new perceived this tick -> always continue
|
| 11 |
+
|
| 12 |
+
The LLM-based decision layer has moved to brain.decide_tick(), which runs
|
| 13 |
+
only when the perceive phase detects novel observations.
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
backend/src/agents/vector_memory.py
CHANGED
|
@@ -1,15 +1,10 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
Architecture: the storage engine behind Long_term.py; called at day
|
| 8 |
-
handoff (archive), by planning (retrieval), and by brain decisions.
|
| 9 |
-
Design: importance is a static per-kind table, recency decays by real
|
| 10 |
-
days, and retention pruning keeps the store within a storage budget.
|
| 11 |
"""
|
| 12 |
-
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
import hashlib
|
|
|
|
| 1 |
+
"""Persistent, per-persona Cloud Qdrant long-term memory and RAG retrieval.
|
| 2 |
|
| 3 |
+
Qdrant is the sole long-term store. The active day's short-term JSON is
|
| 4 |
+
converted into durable memory records during handoff; once indexing succeeds,
|
| 5 |
+
that operational file can be removed. Retrieval returns query-relevant,
|
| 6 |
+
ranked context for model prompts.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
|
|
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
import hashlib
|
backend/src/auth/__init__.py
CHANGED
|
@@ -1,9 +0,0 @@
|
|
| 1 |
-
"""auth — admin authentication package for the Valhalla web dashboard.
|
| 2 |
-
|
| 3 |
-
Exposes the session manager (manager.py) and HTTP routes (routes.py) that
|
| 4 |
-
protect simulation-control and roster endpoints in Odin.py.
|
| 5 |
-
|
| 6 |
-
Design: viewers can watch the simulation unauthenticated; only control
|
| 7 |
-
endpoints require a session.
|
| 8 |
-
"""
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/src/auth/manager.py
CHANGED
|
@@ -1,12 +1,9 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
Loads email:password pairs from ADMIN_CREDENTIALS, hashes with salted
|
| 4 |
-
scrypt, issues 24-hour bearer tokens, and validates/revokes them.
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
when no credentials are configured, login is disabled with a warning.
|
| 10 |
"""
|
| 11 |
|
| 12 |
import os
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
In-memory session-based authentication for Valhalla web admin.
|
|
|
|
|
|
|
| 3 |
|
| 4 |
+
Admin credentials come from the ADMIN_CREDENTIALS env var as
|
| 5 |
+
email:password pairs separated by semicolons. Sessions are stored
|
| 6 |
+
in-memory (lost on server restart).
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
import os
|
backend/src/auth/routes.py
CHANGED
|
@@ -1,11 +1,5 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
Mounts POST /api/auth/login, POST /api/auth/logout, GET /api/auth/me on
|
| 4 |
-
the FastAPI app, backed by auth/manager.py.
|
| 5 |
-
|
| 6 |
-
Architecture: a thin transport layer between the React dashboard and the
|
| 7 |
-
session store; consumed by frontend/src/hooks/useAuth.jsx.
|
| 8 |
-
Design: tokens travel as bearer headers; no cookie handling.
|
| 9 |
"""
|
| 10 |
|
| 11 |
from fastapi import APIRouter, HTTPException, Header
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Auth API routes — login, logout, session check.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
from fastapi import APIRouter, HTTPException, Header
|
backend/src/config.py
CHANGED
|
@@ -1,13 +1,7 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
API key handling, with precedence CLI > environment > built-in default.
|
| 6 |
-
|
| 7 |
-
Architecture: imported by virtually every module; never imports other
|
| 8 |
-
project modules, so it can be loaded without side effects.
|
| 9 |
-
Design: all settings are overridable via SIM_* environment variables so
|
| 10 |
-
experiments can vary parameters without code changes.
|
| 11 |
"""
|
| 12 |
|
| 13 |
from pathlib import Path
|
|
@@ -184,15 +178,6 @@ MEMORY_STORAGE_PRUNE_TARGET = min(MEMORY_STORAGE_PRUNE_THRESHOLD, max(0.05, _env
|
|
| 184 |
# Cap on full day-plan regenerations triggered mid-day per agent (budget guard).
|
| 185 |
MAX_REPLANS_PER_AGENT_PER_DAY = _env_int("SIM_MAX_REPLANS_PER_AGENT_PER_DAY", 3)
|
| 186 |
|
| 187 |
-
# Deterministic backstop for agents stranded on the "Unscheduled downtime"
|
| 188 |
-
# fallback schedule. Every tick, the engine checks remaining plans; an
|
| 189 |
-
# agent with upcoming downtime gets a remaining-day replan. The cooldown
|
| 190 |
-
# (in ticks) stops a repeatedly-rejected replan from hammering the planner,
|
| 191 |
-
# and the horizon (in minutes) skips replans when too little of the day is
|
| 192 |
-
# left to be worth one.
|
| 193 |
-
DOWNTIME_REPLAN_COOLDOWN_TICKS = _env_int("SIM_DOWNTIME_REPLAN_COOLDOWN_TICKS", 60)
|
| 194 |
-
DOWNTIME_REPLAN_MIN_HORIZON = _env_int("SIM_DOWNTIME_REPLAN_MIN_HORIZON", 60)
|
| 195 |
-
|
| 196 |
# Budget governor: soft ceiling on LLM calls per real hour across the whole sim.
|
| 197 |
# 0 = no ceiling. When exceeded, cognition degrades gracefully (skip reflex,
|
| 198 |
# 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)))
|
|
| 219 |
# independent from creativity: an observer can ask for more varied plans
|
| 220 |
# without making students' energy and mood unrealistically volatile.
|
| 221 |
SIM_WELLBEING_VARIABILITY = min(1.0, max(0.0, _env_float("SIM_WELLBEING_VARIABILITY", 0.75)))
|
| 222 |
-
# How fast the runtime glides an agent's energy toward the day planner's
|
| 223 |
-
# declared per-action energy_target. This is a control rate, not an energy
|
| 224 |
-
# value: 0.0 disables the glide (pure delta-based energy, as before), larger
|
| 225 |
-
# values converge faster (0.03 => ~84% of the gap closed per 60-min action).
|
| 226 |
-
SIM_ENERGY_FOLLOW_RATE = _env_float("SIM_ENERGY_FOLLOW_RATE", 0.03)
|
| 227 |
TEMPERATURE = 0.7 + (0.4 * SIM_CREATIVITY) # planning and decisions: 1.1 at lively
|
| 228 |
CONVERSATION_TEMPERATURE = 0.6 + (0.4 * SIM_CREATIVITY) # 1.0 at lively
|
| 229 |
SUMMARY_TEMPERATURE = 0.5
|
| 230 |
-
# The simulation uses
|
| 231 |
-
# ``gemini_client``
|
| 232 |
-
|
| 233 |
-
# rotation is the only provider-recovery behaviour.
|
| 234 |
-
GEMINI_MODEL = _env_str("SIM_GEMINI_MODEL", "gemini-3.5-flash-lite")
|
| 235 |
-
GEMINI_MODEL_FALLBACK = _env_str("SIM_GEMINI_MODEL_FALLBACK", "gemini-3.1-flash-lite")
|
| 236 |
|
| 237 |
# Support multiple API keys (comma-separated in env var). When numbered
|
| 238 |
# variables are used, they are read in ascending numeric order.
|
|
@@ -305,7 +282,6 @@ _OVERRIDE_MAP = {
|
|
| 305 |
"decide_cooldown_ticks": "DECIDE_COOLDOWN_TICKS",
|
| 306 |
"conversation_min_energy": "CONVERSATION_MIN_ENERGY",
|
| 307 |
"conversation_min_emotion": "CONVERSATION_MIN_EMOTION",
|
| 308 |
-
"energy_follow_rate": "SIM_ENERGY_FOLLOW_RATE",
|
| 309 |
"day_handoff_conversation_timeout_seconds": "DAY_HANDOFF_CONVERSATION_TIMEOUT_SECONDS",
|
| 310 |
}
|
| 311 |
|
|
@@ -374,11 +350,10 @@ def describe_settings() -> str:
|
|
| 374 |
return (
|
| 375 |
"Valhalla simulation settings\n"
|
| 376 |
f" API keys loaded : {API_KEY_COUNT} (head resets to index 1/call)\n"
|
| 377 |
-
f" Gemini model : {GEMINI_MODEL}
|
| 378 |
f" Simulation creativity : {SIM_CREATIVITY:.2f} "
|
| 379 |
f"(plan/decision {TEMPERATURE:.2f}, conversation {CONVERSATION_TEMPERATURE:.2f}, summary {SUMMARY_TEMPERATURE:.2f})\n"
|
| 380 |
f" Wellbeing variation : {SIM_WELLBEING_VARIABILITY:.2f}\n"
|
| 381 |
-
f" Energy follow rate : {SIM_ENERGY_FOLLOW_RATE:.3f}/sim-min\n"
|
| 382 |
f" Memory backend : {MEMORY_BACKEND}\n"
|
| 383 |
f" Semantic memory : {'ON' if SEMANTIC_MEMORY_ENABLED else 'OFF'}\n"
|
| 384 |
f" Perception : {'ON' if PERCEPTION_ENABLED else 'OFF'} (radius {PERCEPTION_RADIUS_PX}px)\n"
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Project-wide path configuration.
|
| 3 |
+
Resolves the project root, backend, frontend, data, and output directories
|
| 4 |
+
so all modules can reference consistent paths.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from pathlib import Path
|
|
|
|
| 178 |
# Cap on full day-plan regenerations triggered mid-day per agent (budget guard).
|
| 179 |
MAX_REPLANS_PER_AGENT_PER_DAY = _env_int("SIM_MAX_REPLANS_PER_AGENT_PER_DAY", 3)
|
| 180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
# Budget governor: soft ceiling on LLM calls per real hour across the whole sim.
|
| 182 |
# 0 = no ceiling. When exceeded, cognition degrades gracefully (skip reflex,
|
| 183 |
# defer replans) — the sim keeps running on the 0-LLM executor path.
|
|
|
|
| 204 |
# independent from creativity: an observer can ask for more varied plans
|
| 205 |
# without making students' energy and mood unrealistically volatile.
|
| 206 |
SIM_WELLBEING_VARIABILITY = min(1.0, max(0.0, _env_float("SIM_WELLBEING_VARIABILITY", 0.75)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
TEMPERATURE = 0.7 + (0.4 * SIM_CREATIVITY) # planning and decisions: 1.1 at lively
|
| 208 |
CONVERSATION_TEMPERATURE = 0.6 + (0.4 * SIM_CREATIVITY) # 1.0 at lively
|
| 209 |
SUMMARY_TEMPERATURE = 0.5
|
| 210 |
+
# The simulation intentionally uses one model. Key traversal, implemented in
|
| 211 |
+
# ``gemini_client``, is the only provider recovery behaviour.
|
| 212 |
+
GEMINI_MODEL = _env_str("SIM_GEMINI_MODEL", "gemini-3.1-flash-lite")
|
|
|
|
|
|
|
|
|
|
| 213 |
|
| 214 |
# Support multiple API keys (comma-separated in env var). When numbered
|
| 215 |
# variables are used, they are read in ascending numeric order.
|
|
|
|
| 282 |
"decide_cooldown_ticks": "DECIDE_COOLDOWN_TICKS",
|
| 283 |
"conversation_min_energy": "CONVERSATION_MIN_ENERGY",
|
| 284 |
"conversation_min_emotion": "CONVERSATION_MIN_EMOTION",
|
|
|
|
| 285 |
"day_handoff_conversation_timeout_seconds": "DAY_HANDOFF_CONVERSATION_TIMEOUT_SECONDS",
|
| 286 |
}
|
| 287 |
|
|
|
|
| 350 |
return (
|
| 351 |
"Valhalla simulation settings\n"
|
| 352 |
f" API keys loaded : {API_KEY_COUNT} (head resets to index 1/call)\n"
|
| 353 |
+
f" Gemini model : {GEMINI_MODEL}\n"
|
| 354 |
f" Simulation creativity : {SIM_CREATIVITY:.2f} "
|
| 355 |
f"(plan/decision {TEMPERATURE:.2f}, conversation {CONVERSATION_TEMPERATURE:.2f}, summary {SUMMARY_TEMPERATURE:.2f})\n"
|
| 356 |
f" Wellbeing variation : {SIM_WELLBEING_VARIABILITY:.2f}\n"
|
|
|
|
| 357 |
f" Memory backend : {MEMORY_BACKEND}\n"
|
| 358 |
f" Semantic memory : {'ON' if SEMANTIC_MEMORY_ENABLED else 'OFF'}\n"
|
| 359 |
f" Perception : {'ON' if PERCEPTION_ENABLED else 'OFF'} (radius {PERCEPTION_RADIUS_PX}px)\n"
|
backend/src/core/agent_registry.py
CHANGED
|
@@ -1,13 +1,12 @@
|
|
| 1 |
-
"""
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
the engine
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
Design: consolidates position into one registry to eliminate the
|
| 10 |
-
dual-source position drift of earlier versions.
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agent Registry — single source of truth for every agent's runtime state.
|
| 3 |
|
| 4 |
+
The WorldEngine owns one `AgentRegistry` instance. All modules (Actions,
|
| 5 |
+
conversation, day_planner, Short_term) read from and write to it through
|
| 6 |
+
the engine — never directly.
|
| 7 |
|
| 8 |
+
This replaces the dual-source problem where Actions.py had its own
|
| 9 |
+
position/action and WorldState had a separate copy that drifted.
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
from __future__ import annotations
|
backend/src/core/budget.py
CHANGED
|
@@ -1,14 +1,20 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
from __future__ import annotations
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LLM budget governor -- one place that knows how much LLM spend has happened
|
| 3 |
+
recently and whether the simulation can afford more.
|
| 4 |
+
|
| 5 |
+
Why this exists
|
| 6 |
+
---------------
|
| 7 |
+
Free-tier Gemini keys are scarce (a handful of keys, a few requests/minute
|
| 8 |
+
each). Turning on perception + proximity conversations + a decision-making
|
| 9 |
+
brain could, if left ungated, burn the whole quota in minutes. Every
|
| 10 |
+
cognitive call site (day planner, conversation, reflex escalation) asks the
|
| 11 |
+
governor `can_afford()` before spending, and calls `record()` after. When the
|
| 12 |
+
soft ceiling is exceeded the governor says "no", and the caller degrades
|
| 13 |
+
gracefully -- the simulation keeps running on its 0-LLM executor path.
|
| 14 |
+
|
| 15 |
+
The governor is intentionally simple: a rolling one-real-hour window of call
|
| 16 |
+
timestamps, plus lifetime counters for observability (used by the budget
|
| 17 |
+
stress test and the on-screen/logged stats).
|
| 18 |
"""
|
| 19 |
|
| 20 |
from __future__ import annotations
|
backend/src/core/checkpoint_manager.py
CHANGED
|
@@ -1,13 +1,14 @@
|
|
| 1 |
-
"""
|
|
|
|
| 2 |
|
| 3 |
-
Saves WorldState
|
| 4 |
-
|
| 5 |
-
and loads them for resume/rewind; prunes to a one-day window.
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Checkpoint Manager — per-tick state save/load for crash recovery.
|
| 3 |
|
| 4 |
+
Saves WorldState + AgentRegistry after every tick to compressed
|
| 5 |
+
``backend/data/checkpoints/tick_{00001}.json.gz`` files.
|
|
|
|
| 6 |
|
| 7 |
+
Supports:
|
| 8 |
+
- Save: full simulation state as JSON
|
| 9 |
+
- Load: reconstruct from any saved tick
|
| 10 |
+
- List: available checkpoint ticks
|
| 11 |
+
- Prune: auto-delete old checkpoints, keep last N
|
| 12 |
"""
|
| 13 |
|
| 14 |
from __future__ import annotations
|
backend/src/core/log.py
CHANGED
|
@@ -1,13 +1,27 @@
|
|
| 1 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
Every module
|
| 4 |
-
and level stay consistent across the engine, planner, and server.
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
@@ -39,7 +53,7 @@ def _build_log_path(run_id: Optional[str] = None) -> Path:
|
|
| 39 |
def setup_logging(
|
| 40 |
level: int = DEFAULT_LEVEL,
|
| 41 |
run_id: Optional[str] = None,
|
| 42 |
-
console: bool =
|
| 43 |
file: bool = True,
|
| 44 |
max_bytes: int = 5 * 1024 * 1024,
|
| 45 |
backup_count: int = 5,
|
|
@@ -55,8 +69,7 @@ def setup_logging(
|
|
| 55 |
run_id -- optional tag folded into the log filename, e.g. a
|
| 56 |
persona name or simulation id, so a run's logs are
|
| 57 |
easy to find in output/logs/
|
| 58 |
-
console -- also stream logs to stdout
|
| 59 |
-
admin log terminal instead)
|
| 60 |
file -- also write logs to output/logs/<timestamp>[_<run_id>].log
|
| 61 |
max_bytes /
|
| 62 |
backup_count -- rotation settings for the file handler
|
|
@@ -116,14 +129,6 @@ def setup_logging(
|
|
| 116 |
logging.config.dictConfig(config)
|
| 117 |
_configured = True
|
| 118 |
|
| 119 |
-
# Mirror every emitted line into the admin log terminal's buffer and the
|
| 120 |
-
# live log file, regardless of the console/file toggles above.
|
| 121 |
-
try:
|
| 122 |
-
from src.core.log_relay import install_relay
|
| 123 |
-
install_relay()
|
| 124 |
-
except Exception:
|
| 125 |
-
pass
|
| 126 |
-
|
| 127 |
if log_path:
|
| 128 |
logging.getLogger(__name__).info("[log] logging initialized -> %s", log_path)
|
| 129 |
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Centralized logging setup for the whole project. Every module imports
|
| 3 |
+
`get_logger(__name__)` from here instead of calling `logging.basicConfig`
|
| 4 |
+
or `logging.getLogger` directly, so the format + destinations stay
|
| 5 |
+
identical everywhere
|
| 6 |
+
|
| 7 |
+
Usage
|
| 8 |
+
-----
|
| 9 |
+
Call `setup_logging()` ONCE, as early as possible in the process (top of
|
| 10 |
+
whatever your real entrypoint is -- backend/main.py, or the `run()` /
|
| 11 |
+
`__main__` block of a standalone script):
|
| 12 |
+
|
| 13 |
+
from src.core.log import setup_logging, get_logger
|
| 14 |
+
setup_logging(run_id="run_name") # run_id is optional
|
| 15 |
+
logger = get_logger(__name__)
|
| 16 |
|
| 17 |
+
Every other module then just does:
|
|
|
|
| 18 |
|
| 19 |
+
from src.core.log import get_logger
|
| 20 |
+
logger = get_logger(__name__)
|
| 21 |
+
|
| 22 |
+
If some module gets imported/used before setup_logging() runs (import
|
| 23 |
+
order accidents happen), get_logger() will lazily call setup_logging()
|
| 24 |
+
with defaults so you still get sane output instead of silence.
|
| 25 |
"""
|
| 26 |
|
| 27 |
from __future__ import annotations
|
|
|
|
| 53 |
def setup_logging(
|
| 54 |
level: int = DEFAULT_LEVEL,
|
| 55 |
run_id: Optional[str] = None,
|
| 56 |
+
console: bool = True,
|
| 57 |
file: bool = True,
|
| 58 |
max_bytes: int = 5 * 1024 * 1024,
|
| 59 |
backup_count: int = 5,
|
|
|
|
| 69 |
run_id -- optional tag folded into the log filename, e.g. a
|
| 70 |
persona name or simulation id, so a run's logs are
|
| 71 |
easy to find in output/logs/
|
| 72 |
+
console -- also stream logs to stdout
|
|
|
|
| 73 |
file -- also write logs to output/logs/<timestamp>[_<run_id>].log
|
| 74 |
max_bytes /
|
| 75 |
backup_count -- rotation settings for the file handler
|
|
|
|
| 129 |
logging.config.dictConfig(config)
|
| 130 |
_configured = True
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
if log_path:
|
| 133 |
logging.getLogger(__name__).info("[log] logging initialized -> %s", log_path)
|
| 134 |
|
backend/src/core/log_relay.py
DELETED
|
@@ -1,82 +0,0 @@
|
|
| 1 |
-
"""log_relay — mirror of every project log line for the admin log terminal.
|
| 2 |
-
|
| 3 |
-
Attaches a second handler to the ROOT logger: each emitted record is
|
| 4 |
-
formatted with the project's standard format and appended to a bounded
|
| 5 |
-
in-memory ring buffer (polled by the admin-only /api/logs endpoints) and
|
| 6 |
-
to a live file backend/output/logs/live.log, so the same output also
|
| 7 |
-
survives restarts and stays viewable without the Space console.
|
| 8 |
-
|
| 9 |
-
Architecture: installed once by src/core/log.py setup_logging(); consumed
|
| 10 |
-
by Odin.py's admin-gated endpoints and the frontend LogTerminal panel.
|
| 11 |
-
Design: stdlib-only, a one-way mirror — never changes existing handlers,
|
| 12 |
-
levels, or the file rotation policy.
|
| 13 |
-
"""
|
| 14 |
-
|
| 15 |
-
from __future__ import annotations
|
| 16 |
-
|
| 17 |
-
import logging
|
| 18 |
-
import threading
|
| 19 |
-
from collections import deque
|
| 20 |
-
from itertools import count
|
| 21 |
-
|
| 22 |
-
from src.config import LOG_DIR
|
| 23 |
-
from src.core.log import DATE_FORMAT, LOG_FORMAT
|
| 24 |
-
|
| 25 |
-
MAX_LINES = 2000
|
| 26 |
-
LIVE_LOG_PATH = LOG_DIR / "live.log"
|
| 27 |
-
|
| 28 |
-
_seq = count(1)
|
| 29 |
-
_buffer: deque = deque(maxlen=MAX_LINES)
|
| 30 |
-
_latest_seq = 0
|
| 31 |
-
_lock = threading.Lock()
|
| 32 |
-
_installed = False
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
class LogRelayHandler(logging.Handler):
|
| 36 |
-
"""Appends each formatted record to the ring buffer and the live file."""
|
| 37 |
-
|
| 38 |
-
def __init__(self, level: int = logging.NOTSET) -> None:
|
| 39 |
-
super().__init__(level)
|
| 40 |
-
self._formatter = logging.Formatter(LOG_FORMAT, DATE_FORMAT)
|
| 41 |
-
|
| 42 |
-
def emit(self, record: logging.LogRecord) -> None:
|
| 43 |
-
global _latest_seq
|
| 44 |
-
try:
|
| 45 |
-
text = self._formatter.format(record)
|
| 46 |
-
except Exception:
|
| 47 |
-
text = f"{record.name} | {record.getMessage()}"
|
| 48 |
-
with _lock:
|
| 49 |
-
_latest_seq = next(_seq)
|
| 50 |
-
_buffer.append({"seq": _latest_seq, "level": record.levelname, "text": text})
|
| 51 |
-
try:
|
| 52 |
-
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
| 53 |
-
with open(LIVE_LOG_PATH, "a", encoding="utf-8") as fh:
|
| 54 |
-
fh.write(text + "\n")
|
| 55 |
-
except OSError:
|
| 56 |
-
pass
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
def install_relay() -> None:
|
| 60 |
-
"""Attach the relay handler to the root logger exactly once."""
|
| 61 |
-
global _installed
|
| 62 |
-
if _installed:
|
| 63 |
-
return
|
| 64 |
-
logging.getLogger().addHandler(LogRelayHandler())
|
| 65 |
-
_installed = True
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
def relay_lines(since: int = 0) -> dict:
|
| 69 |
-
"""Return relayed entries with seq > since, plus the latest seq as cursor.
|
| 70 |
-
|
| 71 |
-
The cursor tracks the last emitted sequence, so polling stays
|
| 72 |
-
incremental even after the buffer is cleared.
|
| 73 |
-
"""
|
| 74 |
-
with _lock:
|
| 75 |
-
lines = [entry for entry in _buffer if entry["seq"] > since]
|
| 76 |
-
return {"lines": lines, "next": _latest_seq}
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
def clear_relay() -> None:
|
| 80 |
-
"""Drop the in-memory buffer (the live file is intentionally kept)."""
|
| 81 |
-
with _lock:
|
| 82 |
-
_buffer.clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/src/core/perceive.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
| 1 |
-
"""
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
computed from these observations.
|
| 9 |
-
Design: strict purity keeps perception reproducible and cheap.
|
| 10 |
"""
|
| 11 |
|
| 12 |
from __future__ import annotations
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Perceive -- turns a WorldSnapshot into what one agent can currently observe.
|
| 3 |
|
| 4 |
+
This is a pure function of snapshot data: no LLM calls, no side effects.
|
| 5 |
+
With only one agent registered (your current single-agent phase), this
|
| 6 |
+
naturally returns an empty list every tick -- no special-casing needed to
|
| 7 |
+
"turn on" perception later, it already does the real spatial query.
|
| 8 |
|
| 9 |
+
Radius/distance logic itself lives on `WorldSnapshot` (core/snapshot.py) so
|
| 10 |
+
there's exactly one implementation of "who's nearby" in the codebase. (in snapshot.py)
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
backend/src/core/runtime_health.py
CHANGED
|
@@ -1,14 +1,4 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
Scans for stalled travel, overdue actions, position desyncs, and
|
| 4 |
-
conversation timeouts; emits reports every N ticks or immediately on
|
| 5 |
-
anomaly, feeding the dashboard debug panel and the sidecar monitor.
|
| 6 |
-
|
| 7 |
-
Architecture: called by WorldEngine at the end of each tick; consumed by
|
| 8 |
-
the frontend snapshot (health block).
|
| 9 |
-
Design: state is O(agents) and history-free, so monitoring never grows
|
| 10 |
-
with runtime.
|
| 11 |
-
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
| 14 |
|
|
|
|
| 1 |
+
"""Bounded runtime health checks for a live Valhalla simulation."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
backend/src/core/snapshot.py
CHANGED
|
@@ -1,12 +1,34 @@
|
|
| 1 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
Design: frozen data structures; agents see the same world each tick.
|
| 10 |
"""
|
| 11 |
|
| 12 |
from __future__ import annotations
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Snapshot -- point-in-time read-only view of WorldState (Immutable)
|
| 3 |
+
|
| 4 |
+
Every tick takes exactly one `WorldSnapshot` and hands the
|
| 5 |
+
*same* frozen object to every agent's tick graph via asyncio.gather(). That's
|
| 6 |
+
what makes parallel agent decisions safe -- nobody is reading a WorldState
|
| 7 |
+
that's being mutated mid-tick by someone else's action.
|
| 8 |
+
|
| 9 |
+
`WorldSnapshot` is a deliberately separate class from `WorldState`, not just
|
| 10 |
+
a deep copy of it. It exposes zero mutating methods, so there is no method
|
| 11 |
+
an agent's perceive/react/plan code could accidentally call that would
|
| 12 |
+
corrupt the resolve phase's assumptions. If you need a new read-only query
|
| 13 |
+
(e.g. "what's the nearest free table"), add it here as a method on
|
| 14 |
+
`WorldSnapshot` -- don't reach into `.agents`/`.occupancy` directly from
|
| 15 |
+
perceive.py and reimplement the same query logic in multiple places.
|
| 16 |
+
|
| 17 |
+
Usage
|
| 18 |
+
-----
|
| 19 |
+
from src.core.world_state import WorldState, Position
|
| 20 |
+
from src.core.snapshot import take_snapshot
|
| 21 |
+
|
| 22 |
+
world = WorldState()
|
| 23 |
+
world.register_agent("gurnoor", Position(x=4, y=2, location_id="dorm_room_1"))
|
| 24 |
|
| 25 |
+
snap = take_snapshot(world) # take ONCE per tick, before decide phase
|
| 26 |
+
snap.get_agent("gurnoor") # read-only query
|
| 27 |
+
snap.agents_near("gurnoor", radius=3)
|
| 28 |
+
snap.is_free("cafeteria_table_3")
|
| 29 |
|
| 30 |
+
# snap.tick = 999 <- raises, frozen model
|
| 31 |
+
# snap.agents["x"] = ... <- raises, frozen model
|
|
|
|
| 32 |
"""
|
| 33 |
|
| 34 |
from __future__ import annotations
|
backend/src/core/tick_graph.py
CHANGED
|
@@ -1,13 +1,20 @@
|
|
| 1 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
write_back_memory subgraph and a day-planning CLI.
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
directly, so this module serves as the reference single-agent pipeline.
|
| 9 |
-
Design: kept as the canonical per-agent graph for experiments and
|
| 10 |
-
debugging; its memory-stream integration is intentionally pluggable.
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agent -- per-agent LangGraph subgraph + standalone CLI debug tool.
|
| 3 |
+
|
| 4 |
+
Production pipeline (used by WorldEngine via build_tick_graph):
|
| 5 |
+
|
| 6 |
+
perceive -> retrieve_memories -> react --[replan]--> day_planner -> write_back_memory
|
| 7 |
+
\\_[continue]__> keep_current /
|
| 8 |
+
|
| 9 |
+
Only agents where scheduler.py's `agents_ready_for_decision()` returns
|
| 10 |
+
True are invoked each tick -- mid-action agents are skipped entirely.
|
| 11 |
+
|
| 12 |
+
Standalone CLI debug mode (python Agent.py <persona>):
|
| 13 |
|
| 14 |
+
retrieve_memories from Short_term -> call day_planner.run() -> print plan table
|
|
|
|
| 15 |
|
| 16 |
+
Useful for testing a single persona's day plan without standing up the
|
| 17 |
+
full tick loop.
|
|
|
|
|
|
|
|
|
|
| 18 |
"""
|
| 19 |
|
| 20 |
from __future__ import annotations
|
backend/src/core/world_engine.py
CHANGED
|
@@ -1,15 +1,16 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
| 13 |
"""
|
| 14 |
|
| 15 |
from __future__ import annotations
|
|
@@ -17,7 +18,6 @@ from __future__ import annotations
|
|
| 17 |
import asyncio
|
| 18 |
import hashlib
|
| 19 |
import json
|
| 20 |
-
import math
|
| 21 |
import random
|
| 22 |
import sys
|
| 23 |
import time as _time
|
|
@@ -126,10 +126,6 @@ class WorldEngine:
|
|
| 126 |
# Decisions are advisory; a slow provider response must not freeze the
|
| 127 |
# simulation clock or WebSocket snapshots at an action boundary.
|
| 128 |
self._decision_tasks: Dict[str, asyncio.Task] = {}
|
| 129 |
-
# Advisory throttle: last tick an "unscheduled downtime" recovery
|
| 130 |
-
# replan was attempted per agent. Not checkpointed — on restore a
|
| 131 |
-
# fresh attempt is harmless.
|
| 132 |
-
self._downtime_replan_tick: Dict[str, int] = {}
|
| 133 |
|
| 134 |
@staticmethod
|
| 135 |
def _conversation_key(first_id: str, second_id: str) -> str:
|
|
@@ -353,41 +349,69 @@ class WorldEngine:
|
|
| 353 |
baseline -= 0.04
|
| 354 |
return max(0.56, min(0.86, baseline))
|
| 355 |
|
| 356 |
-
def _action_wellbeing_deltas(self, state: AgentRuntimeState, action: Any
|
| 357 |
-
"""
|
| 358 |
-
|
| 359 |
-
When the day planner declares an energy_target for the action, the
|
| 360 |
-
runtime glides the agent's energy from its current level toward that
|
| 361 |
-
declared cumulative target (the LLM owns every value; this is only a
|
| 362 |
-
smooth, deterministic path to it). The exponential progress factor
|
| 363 |
-
means short actions barely move energy while long ones converge, and
|
| 364 |
-
because it depends only on stored state and the plan it stays
|
| 365 |
-
checkpoint-reproducible.
|
| 366 |
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
|
|
|
|
|
|
| 370 |
"""
|
| 371 |
description = (getattr(action, "description", "") or "").lower()
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
|
| 384 |
variation = _cfg.SIM_WELLBEING_VARIABILITY
|
| 385 |
token = f"{state.agent_id}|{getattr(action, 'start_time', '')}|{getattr(action, 'end_time', '')}|{description}"
|
| 386 |
digest = hashlib.blake2s(token.encode("utf-8"), digest_size=4).digest()
|
| 387 |
jitter = (int.from_bytes(digest, "big") / 0xFFFFFFFF) * 2.0 - 1.0
|
| 388 |
-
energy += jitter * 0.035 * variation
|
| 389 |
-
emotion += jitter * 0.045 * variation
|
| 390 |
-
return energy, emotion
|
| 391 |
|
| 392 |
def _memory_context(self, persona_name, persona, before_date=None, query_hint=""):
|
| 393 |
"""Build (relevant_memories, rolling_summary) for a day-planner call.
|
|
@@ -508,8 +532,6 @@ class WorldEngine:
|
|
| 508 |
"persona_name": name,
|
| 509 |
"mode": "full_day",
|
| 510 |
"current_location_id": hostel,
|
| 511 |
-
"energy_level": self._energy_baseline(persona),
|
| 512 |
-
"emotion_state": self._emotion_baseline(persona),
|
| 513 |
"upcoming_events": self.event_manager.snapshot(self.sim_start_date, self.sim_start_hhmm).get("upcoming", []),
|
| 514 |
},
|
| 515 |
),
|
|
@@ -679,26 +701,6 @@ class WorldEngine:
|
|
| 679 |
return_exceptions=True,
|
| 680 |
)
|
| 681 |
|
| 682 |
-
# ══════ PHASE 5b: Unscheduled downtime recovery (parallel, deterministic) ══════
|
| 683 |
-
# A force-accepted fallback plan strands the agent on "Unscheduled
|
| 684 |
-
# downtime" for the rest of the day. The LLM decide path may never
|
| 685 |
-
# fire for such an agent, so detect it here and replan the remaining
|
| 686 |
-
# whole day explicitly.
|
| 687 |
-
downtime_agents = [
|
| 688 |
-
s for s in agent_states if self._has_unscheduled_downtime(s, current_tick)
|
| 689 |
-
]
|
| 690 |
-
if downtime_agents:
|
| 691 |
-
for s in downtime_agents:
|
| 692 |
-
self._downtime_replan_tick[s.agent_id] = current_tick
|
| 693 |
-
logger.info(
|
| 694 |
-
"[WorldEngine] agent '%s' stuck on unscheduled downtime — replanning remaining day",
|
| 695 |
-
s.persona_name,
|
| 696 |
-
)
|
| 697 |
-
await asyncio.gather(
|
| 698 |
-
*[self._phase_replan(s, current_tick, hhmm) for s in downtime_agents],
|
| 699 |
-
return_exceptions=True,
|
| 700 |
-
)
|
| 701 |
-
|
| 702 |
# ══════ PHASE 6: Resolve (sequential) ══════
|
| 703 |
await self._check_last_action_triggers(current_tick, hhmm)
|
| 704 |
self._apply_finished_event_effects(self.sim_start_date, hhmm)
|
|
@@ -885,8 +887,6 @@ class WorldEngine:
|
|
| 885 |
"persona_name": state.persona_name,
|
| 886 |
"mode": "remaining",
|
| 887 |
"current_location_id": state.position.location_id,
|
| 888 |
-
"energy_level": state.energy_level,
|
| 889 |
-
"emotion_state": state.emotion_state,
|
| 890 |
"upcoming_events": self.event_manager.snapshot(self.sim_start_date, hhmm).get("upcoming", []),
|
| 891 |
},
|
| 892 |
),
|
|
@@ -913,38 +913,6 @@ class WorldEngine:
|
|
| 913 |
"[WorldEngine] replan failed for '%s': %s", state.persona_name, e,
|
| 914 |
)
|
| 915 |
|
| 916 |
-
def _has_unscheduled_downtime(self, state: AgentRuntimeState, tick: int) -> bool:
|
| 917 |
-
"""Detect agents stranded on the deterministic fallback schedule.
|
| 918 |
-
|
| 919 |
-
The LLM decide path is gated (novelty, energy/emotion, cooldown,
|
| 920 |
-
budget), so a force-accepted fallback day can leave an agent stuck on
|
| 921 |
-
"Unscheduled downtime" for hours with no replan ever firing. This
|
| 922 |
-
backstop scans the remaining plan every tick and flags it."""
|
| 923 |
-
if state.paused:
|
| 924 |
-
return False
|
| 925 |
-
if state.day_archived:
|
| 926 |
-
return False
|
| 927 |
-
if state.manager is None:
|
| 928 |
-
return False
|
| 929 |
-
if state.replan_count >= _cfg.MAX_REPLANS_PER_AGENT_PER_DAY:
|
| 930 |
-
return False
|
| 931 |
-
if (
|
| 932 |
-
tick - self._downtime_replan_tick.get(state.agent_id, -10**9)
|
| 933 |
-
< _cfg.DOWNTIME_REPLAN_COOLDOWN_TICKS
|
| 934 |
-
):
|
| 935 |
-
return False
|
| 936 |
-
now_minutes = tick % (24 * 60)
|
| 937 |
-
if now_minutes >= 24 * 60 - _cfg.DOWNTIME_REPLAN_MIN_HORIZON:
|
| 938 |
-
# Too little of the day remains to justify a replan.
|
| 939 |
-
return False
|
| 940 |
-
for action in state.day_plan:
|
| 941 |
-
end = self._hhmm_to_minutes(str(action.get("end", "")))
|
| 942 |
-
if end > now_minutes and "unscheduled downtime" in str(
|
| 943 |
-
action.get("action", "")
|
| 944 |
-
).lower():
|
| 945 |
-
return True
|
| 946 |
-
return False
|
| 947 |
-
|
| 948 |
async def _run_agent_act(
|
| 949 |
self, state: AgentRuntimeState, tick: int, hhmm: str
|
| 950 |
) -> None:
|
|
@@ -1064,11 +1032,15 @@ class WorldEngine:
|
|
| 1064 |
end_min = self._hhmm_to_minutes(action.end_time)
|
| 1065 |
duration = max(1, end_min - start_min)
|
| 1066 |
tick_step = _cfg.SIM_MINUTES_PER_TICK
|
| 1067 |
-
action_energy_change, action_emotion_change = self._action_wellbeing_deltas(state, action
|
| 1068 |
energy_tick = (action_energy_change / duration) * tick_step
|
| 1069 |
emotion_tick = (action_emotion_change / duration) * tick_step
|
| 1070 |
-
state.energy_level = max(0.
|
| 1071 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1072 |
except Exception:
|
| 1073 |
pass
|
| 1074 |
|
|
@@ -1511,14 +1483,22 @@ class WorldEngine:
|
|
| 1511 |
self.relationship_matrix.update(b.agent_id, a.agent_id, conv_result.relationship_delta)
|
| 1512 |
self.relationship_matrix.save()
|
| 1513 |
# Conversations affect the people having them, not only their stored
|
| 1514 |
-
# relationship score.
|
| 1515 |
-
#
|
| 1516 |
-
|
| 1517 |
-
|
| 1518 |
-
|
| 1519 |
-
|
| 1520 |
-
|
| 1521 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1522 |
logger.info(
|
| 1523 |
"[WorldEngine] conversation '%s' <-> '%s' active until tick %d",
|
| 1524 |
a.persona_name, b.persona_name, self.world.tick + conv_result.duration_minutes,
|
|
@@ -1546,8 +1526,6 @@ class WorldEngine:
|
|
| 1546 |
"persona_name": state.persona_name,
|
| 1547 |
"mode": "remaining",
|
| 1548 |
"current_location_id": state.position.location_id,
|
| 1549 |
-
"energy_level": state.energy_level,
|
| 1550 |
-
"emotion_state": state.emotion_state,
|
| 1551 |
},
|
| 1552 |
)
|
| 1553 |
return plan_result.get("day_plan", [])
|
|
@@ -1802,10 +1780,7 @@ class WorldEngine:
|
|
| 1802 |
f"The previous day ended while the agent was {action_text} at {location}. "
|
| 1803 |
f"Energy is {state.energy_level:.2f}/1.0 and emotion is "
|
| 1804 |
f"{state.emotion_state:.2f}/1.0. Continue naturally from this "
|
| 1805 |
-
"physical and emotional state; do not abruptly relocate them.
|
| 1806 |
-
"When assigning energy_change/emotion_change for the new day, "
|
| 1807 |
-
"keep the cumulative energy and mood totals between 0.0 and "
|
| 1808 |
-
"1.0 at all times."
|
| 1809 |
)
|
| 1810 |
|
| 1811 |
async def _plan_next_day(state: AgentRuntimeState) -> tuple[AgentRuntimeState, list]:
|
|
@@ -1828,8 +1803,6 @@ class WorldEngine:
|
|
| 1828 |
"mode": "next_day",
|
| 1829 |
"current_location_id": state.position.location_id,
|
| 1830 |
"handoff_context": _handoff_context(state),
|
| 1831 |
-
"energy_level": state.energy_level,
|
| 1832 |
-
"emotion_state": state.emotion_state,
|
| 1833 |
"upcoming_events": self.event_manager.snapshot(next_date, "00:00").get("upcoming", []),
|
| 1834 |
},
|
| 1835 |
)
|
|
@@ -1858,7 +1831,6 @@ class WorldEngine:
|
|
| 1858 |
self._recent_convs.clear()
|
| 1859 |
self._in_range.clear()
|
| 1860 |
self._last_decision_tick.clear()
|
| 1861 |
-
self._downtime_replan_tick.clear()
|
| 1862 |
self._last_obs.clear()
|
| 1863 |
self._tick_observations.clear()
|
| 1864 |
self._applied_event_effects.clear()
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
WorldEngine — the main simulation orchestrator.
|
| 3 |
+
|
| 4 |
+
Controls the tick loop: advances time, runs agent actions in parallel,
|
| 5 |
+
detects proximity for conversations, handles end-of-day transitions,
|
| 6 |
+
and keeps WorldState in sync with the agent registry.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
from src.core.world_engine import WorldEngine
|
| 10 |
+
|
| 11 |
+
engine = WorldEngine()
|
| 12 |
+
await engine.initialize()
|
| 13 |
+
await engine.run(max_ticks=1440) # one full day at 1 tick/sec
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
|
|
|
| 18 |
import asyncio
|
| 19 |
import hashlib
|
| 20 |
import json
|
|
|
|
| 21 |
import random
|
| 22 |
import sys
|
| 23 |
import time as _time
|
|
|
|
| 126 |
# Decisions are advisory; a slow provider response must not freeze the
|
| 127 |
# simulation clock or WebSocket snapshots at an action boundary.
|
| 128 |
self._decision_tasks: Dict[str, asyncio.Task] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
@staticmethod
|
| 131 |
def _conversation_key(first_id: str, second_id: str) -> str:
|
|
|
|
| 349 |
baseline -= 0.04
|
| 350 |
return max(0.56, min(0.86, baseline))
|
| 351 |
|
| 352 |
+
def _action_wellbeing_deltas(self, state: AgentRuntimeState, action: Any) -> tuple[float, float]:
|
| 353 |
+
"""Compute a deterministic total wellbeing effect for one action.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
|
| 355 |
+
LLM-supplied deltas are useful hints, but are normally very small. A
|
| 356 |
+
shared local activity model therefore gives classes, travel, rest, and
|
| 357 |
+
social time their ordinary human cost or benefit. The small stable
|
| 358 |
+
variation is keyed by agent/action, rather than sampled each tick, so
|
| 359 |
+
replaying a checkpoint remains reproducible.
|
| 360 |
"""
|
| 361 |
description = (getattr(action, "description", "") or "").lower()
|
| 362 |
+
action_type = str(getattr(action, "action_type", "")).lower()
|
| 363 |
+
# The planner can add personality-specific flavour, but it must not
|
| 364 |
+
# turn an otherwise restorative meal or quiet break into a day-long
|
| 365 |
+
# energy drain. The local physical activity model is authoritative.
|
| 366 |
+
planner_energy = max(-0.08, min(0.08, float(getattr(action, "energy_change", 0.0))))
|
| 367 |
+
planner_emotion = max(-0.12, min(0.12, float(getattr(action, "emotion_change", 0.0))))
|
| 368 |
+
energy, emotion = 0.0, 0.0
|
| 369 |
+
|
| 370 |
+
if action_type.endswith("move") or any(word in description for word in ("walk", "travel", "commute", "go to")):
|
| 371 |
+
energy, emotion = -0.075, -0.008
|
| 372 |
+
elif "sleep" in description:
|
| 373 |
+
energy, emotion = 0.50, 0.025
|
| 374 |
+
elif any(word in description for word in ("nap", "rest", "recharge", "lie down")):
|
| 375 |
+
energy, emotion = 0.20, 0.020
|
| 376 |
+
elif any(word in description for word in (
|
| 377 |
+
"meme", "memes", "scroll", "social media", "youtube", "video",
|
| 378 |
+
"reading for pleasure", "quiet reading", "reading quietly", "bench", "downtime",
|
| 379 |
+
"free time", "relax", "relaxing", "wind-down", "wind down",
|
| 380 |
+
)):
|
| 381 |
+
energy, emotion = 0.090, 0.025
|
| 382 |
+
elif any(word in description for word in ("class", "lecture", "lab", "tutorial", "study", "assignment", "coding", "project", "exam")):
|
| 383 |
+
energy, emotion = -0.070, -0.025
|
| 384 |
+
elif any(word in description for word in ("gym", "sport", "run", "football", "basketball", "badminton", "workout", "cardio", "weightlift", "training")):
|
| 385 |
+
energy, emotion = -0.180, 0.075
|
| 386 |
+
elif any(word in description for word in ("breakfast", "lunch", "dinner", "meal", "food", "tea", "chai", "eat", "eating")):
|
| 387 |
+
energy, emotion = 0.130, 0.025
|
| 388 |
+
elif any(word in description for word in ("friends", "club", "music", "open mic", "game", "movie", "social", "hangout")):
|
| 389 |
+
energy, emotion = 0.015, 0.075
|
| 390 |
+
elif any(word in description for word in ("laundry", "clean", "errand", "admin", "queue", "chore")):
|
| 391 |
+
energy, emotion = -0.080, -0.025
|
| 392 |
+
elif any(word in description for word in ("stand", "standing", "wait", "waiting")):
|
| 393 |
+
energy, emotion = -0.040, -0.005
|
| 394 |
+
else:
|
| 395 |
+
# Neutral, seated or low-intensity tasks should not silently push
|
| 396 |
+
# every agent toward exhaustion merely because their wording was
|
| 397 |
+
# not anticipated above.
|
| 398 |
+
energy, emotion = -0.005, 0.0
|
| 399 |
+
|
| 400 |
+
# Introverted students generally enjoy a good conversation but spend
|
| 401 |
+
# more energy on it; this keeps personality visible without judging it.
|
| 402 |
+
traits = " ".join(str(state.persona.get(key, "")) for key in ("innate", "lifestyle", "learned")).lower()
|
| 403 |
+
if any(word in description for word in ("friends", "club", "social", "hangout")) and any(
|
| 404 |
+
marker in traits for marker in ("introverted", "quiet", "reserved")
|
| 405 |
+
):
|
| 406 |
+
energy -= 0.03
|
| 407 |
|
| 408 |
variation = _cfg.SIM_WELLBEING_VARIABILITY
|
| 409 |
token = f"{state.agent_id}|{getattr(action, 'start_time', '')}|{getattr(action, 'end_time', '')}|{description}"
|
| 410 |
digest = hashlib.blake2s(token.encode("utf-8"), digest_size=4).digest()
|
| 411 |
jitter = (int.from_bytes(digest, "big") / 0xFFFFFFFF) * 2.0 - 1.0
|
| 412 |
+
energy += planner_energy + jitter * 0.035 * variation
|
| 413 |
+
emotion += planner_emotion + jitter * 0.045 * variation
|
| 414 |
+
return max(-0.28, min(0.30, energy)), max(-0.18, min(0.16, emotion))
|
| 415 |
|
| 416 |
def _memory_context(self, persona_name, persona, before_date=None, query_hint=""):
|
| 417 |
"""Build (relevant_memories, rolling_summary) for a day-planner call.
|
|
|
|
| 532 |
"persona_name": name,
|
| 533 |
"mode": "full_day",
|
| 534 |
"current_location_id": hostel,
|
|
|
|
|
|
|
| 535 |
"upcoming_events": self.event_manager.snapshot(self.sim_start_date, self.sim_start_hhmm).get("upcoming", []),
|
| 536 |
},
|
| 537 |
),
|
|
|
|
| 701 |
return_exceptions=True,
|
| 702 |
)
|
| 703 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 704 |
# ══════ PHASE 6: Resolve (sequential) ══════
|
| 705 |
await self._check_last_action_triggers(current_tick, hhmm)
|
| 706 |
self._apply_finished_event_effects(self.sim_start_date, hhmm)
|
|
|
|
| 887 |
"persona_name": state.persona_name,
|
| 888 |
"mode": "remaining",
|
| 889 |
"current_location_id": state.position.location_id,
|
|
|
|
|
|
|
| 890 |
"upcoming_events": self.event_manager.snapshot(self.sim_start_date, hhmm).get("upcoming", []),
|
| 891 |
},
|
| 892 |
),
|
|
|
|
| 913 |
"[WorldEngine] replan failed for '%s': %s", state.persona_name, e,
|
| 914 |
)
|
| 915 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 916 |
async def _run_agent_act(
|
| 917 |
self, state: AgentRuntimeState, tick: int, hhmm: str
|
| 918 |
) -> None:
|
|
|
|
| 1032 |
end_min = self._hhmm_to_minutes(action.end_time)
|
| 1033 |
duration = max(1, end_min - start_min)
|
| 1034 |
tick_step = _cfg.SIM_MINUTES_PER_TICK
|
| 1035 |
+
action_energy_change, action_emotion_change = self._action_wellbeing_deltas(state, action)
|
| 1036 |
energy_tick = (action_energy_change / duration) * tick_step
|
| 1037 |
emotion_tick = (action_emotion_change / duration) * tick_step
|
| 1038 |
+
state.energy_level = max(0.08, min(0.97, state.energy_level + energy_tick))
|
| 1039 |
+
baseline = state.emotion_baseline
|
| 1040 |
+
# Mood has a weak pull towards personality baseline, but day
|
| 1041 |
+
# events are allowed to remain visible for several actions.
|
| 1042 |
+
recovery = (baseline - state.emotion_state) * min(0.015, 0.0005 * tick_step)
|
| 1043 |
+
state.emotion_state = max(0.10, min(0.90, state.emotion_state + emotion_tick + recovery))
|
| 1044 |
except Exception:
|
| 1045 |
pass
|
| 1046 |
|
|
|
|
| 1483 |
self.relationship_matrix.update(b.agent_id, a.agent_id, conv_result.relationship_delta)
|
| 1484 |
self.relationship_matrix.save()
|
| 1485 |
# Conversations affect the people having them, not only their stored
|
| 1486 |
+
# relationship score. A warm chat is a modest lift; an awkward one is
|
| 1487 |
+
# draining. The effect is applied once per completed conversation.
|
| 1488 |
+
relationship_delta = max(-0.20, min(0.20, conv_result.relationship_delta))
|
| 1489 |
+
sentiment = (getattr(conv_result, "sentiment", "neutral") or "neutral").lower()
|
| 1490 |
+
for state in (a, b):
|
| 1491 |
+
social_cost = 0.045 if any(marker in " ".join(
|
| 1492 |
+
str(state.persona.get(key, "")) for key in ("innate", "lifestyle", "learned")
|
| 1493 |
+
).lower() for marker in ("introverted", "quiet", "reserved")) else 0.025
|
| 1494 |
+
state.energy_level = max(0.08, min(0.97, state.energy_level - social_cost))
|
| 1495 |
+
if sentiment in ("positive", "warm", "friendly"):
|
| 1496 |
+
mood_delta = 0.035 + max(0.0, relationship_delta) * 0.25
|
| 1497 |
+
elif sentiment in ("negative", "tense", "awkward"):
|
| 1498 |
+
mood_delta = -0.035 + min(0.0, relationship_delta) * 0.25
|
| 1499 |
+
else:
|
| 1500 |
+
mood_delta = relationship_delta * 0.08
|
| 1501 |
+
state.emotion_state = max(0.10, min(0.90, state.emotion_state + mood_delta))
|
| 1502 |
logger.info(
|
| 1503 |
"[WorldEngine] conversation '%s' <-> '%s' active until tick %d",
|
| 1504 |
a.persona_name, b.persona_name, self.world.tick + conv_result.duration_minutes,
|
|
|
|
| 1526 |
"persona_name": state.persona_name,
|
| 1527 |
"mode": "remaining",
|
| 1528 |
"current_location_id": state.position.location_id,
|
|
|
|
|
|
|
| 1529 |
},
|
| 1530 |
)
|
| 1531 |
return plan_result.get("day_plan", [])
|
|
|
|
| 1780 |
f"The previous day ended while the agent was {action_text} at {location}. "
|
| 1781 |
f"Energy is {state.energy_level:.2f}/1.0 and emotion is "
|
| 1782 |
f"{state.emotion_state:.2f}/1.0. Continue naturally from this "
|
| 1783 |
+
"physical and emotional state; do not abruptly relocate them."
|
|
|
|
|
|
|
|
|
|
| 1784 |
)
|
| 1785 |
|
| 1786 |
async def _plan_next_day(state: AgentRuntimeState) -> tuple[AgentRuntimeState, list]:
|
|
|
|
| 1803 |
"mode": "next_day",
|
| 1804 |
"current_location_id": state.position.location_id,
|
| 1805 |
"handoff_context": _handoff_context(state),
|
|
|
|
|
|
|
| 1806 |
"upcoming_events": self.event_manager.snapshot(next_date, "00:00").get("upcoming", []),
|
| 1807 |
},
|
| 1808 |
)
|
|
|
|
| 1831 |
self._recent_convs.clear()
|
| 1832 |
self._in_range.clear()
|
| 1833 |
self._last_decision_tick.clear()
|
|
|
|
| 1834 |
self._last_obs.clear()
|
| 1835 |
self._tick_observations.clear()
|
| 1836 |
self._applied_event_effects.clear()
|
backend/src/core/world_events.py
CHANGED
|
@@ -1,13 +1,10 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
consumes places/personas/relationship matrix and edits day plans.
|
| 9 |
-
Design: events only replace entirely flexible time windows (never classes
|
| 10 |
-
or sleep), keeping the calendar safe to apply automatically.
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
|
|
| 1 |
+
"""Deterministic, data-driven campus events and safe plan opportunities.
|
| 2 |
|
| 3 |
+
The event calendar is deliberately independent of the LLM. It makes an
|
| 4 |
+
attendance decision from a persona, its social context, schedule conflicts,
|
| 5 |
+
and a seeded tie-breaker, then edits only an entirely-flexible time window.
|
| 6 |
+
This keeps festivals and interruptions lively without allowing them to erase
|
| 7 |
+
classes, meals, sleep, exams, or an in-progress route.
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
backend/src/core/world_state.py
CHANGED
|
@@ -1,13 +1,39 @@
|
|
| 1 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
manager, and the engine's sync step.
|
| 9 |
-
Design: mutation is restricted by convention — the engine mirrors the
|
| 10 |
-
registry into WorldState exactly twice per tick.
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
World State -- the single source of truth for the simulation.
|
| 3 |
+
|
| 4 |
+
`WorldState` holds everything that is true about the world at a given tick:
|
| 5 |
+
where every agent is, what they're currently doing, and who/what currently
|
| 6 |
+
holds any contested resource (a chair, an NPC's attention, a location slot).
|
| 7 |
+
|
| 8 |
+
Ownership rule:
|
| 9 |
+
Only `WorldEngine`'s resolve phase should ever call the mutating methods
|
| 10 |
+
on this class directly (`set_agent_action`, `move_agent`, `occupy`, ...).
|
| 11 |
+
|
| 12 |
+
Every agent tick graph (perceive -> retrieve -> react -> day_planner ->
|
| 13 |
+
act) must only ever see a frozen copy produced by `core/snapshot.py`.
|
| 14 |
+
|
| 15 |
+
That separation is what keeps the decide phase safely parallelizable
|
| 16 |
+
with asyncio.gather() -- nobody is reading a WorldState that something
|
| 17 |
+
else is mutating mid-tick.
|
| 18 |
+
|
| 19 |
+
Usage
|
| 20 |
+
-----
|
| 21 |
+
from src.core.world_state import WorldState, Position
|
| 22 |
+
|
| 23 |
+
world = WorldState()
|
| 24 |
+
world.register_agent("gurnoor", Position(x=4, y=2, location_id="dorm_room_1"))
|
| 25 |
+
world.register_resource("cafeteria_table_3")
|
| 26 |
|
| 27 |
+
# inside the resolve phase, after an agent's tick graph proposed an action:
|
| 28 |
+
world.occupy("cafeteria_table_3", "gurnoor")
|
| 29 |
+
world.set_agent_action("gurnoor", CurrentAction(
|
| 30 |
+
description="eating breakfast",
|
| 31 |
+
start_tick=world.tick,
|
| 32 |
+
end_tick=world.tick + 20,
|
| 33 |
+
target_object_id="cafeteria_table_3",
|
| 34 |
+
))
|
| 35 |
|
| 36 |
+
world.advance_tick(minutes=10)
|
|
|
|
|
|
|
|
|
|
| 37 |
"""
|
| 38 |
|
| 39 |
from __future__ import annotations
|
backend/src/llm/gemini_client.py
CHANGED
|
@@ -1,15 +1,8 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
tries
|
| 5 |
-
|
| 6 |
-
simulation cleanly when every key fails.
|
| 7 |
-
|
| 8 |
-
Architecture: called by day_planner.py, brain.py, conversation.py,
|
| 9 |
-
vector_memory.py, and the roster generator; records spend with the budget
|
| 10 |
-
governor.
|
| 11 |
-
Design: deliberately no retries or timeouts in this module — resilience
|
| 12 |
-
lives in the engine's checkpoint/resume path and the budget governor.
|
| 13 |
"""
|
| 14 |
|
| 15 |
from __future__ import annotations
|
|
@@ -23,13 +16,7 @@ from google import genai
|
|
| 23 |
from google.genai import types
|
| 24 |
from pydantic import BaseModel
|
| 25 |
|
| 26 |
-
from src.config import
|
| 27 |
-
API_KEYS,
|
| 28 |
-
GEMINI_MODEL,
|
| 29 |
-
GEMINI_MODEL_FALLBACK,
|
| 30 |
-
MEMORY_EMBEDDING_MODEL,
|
| 31 |
-
TEMPERATURE,
|
| 32 |
-
)
|
| 33 |
from src.core.log import get_logger
|
| 34 |
|
| 35 |
logger = get_logger(__name__)
|
|
@@ -136,29 +123,28 @@ def call_gemini(
|
|
| 136 |
complexity: str = "default",
|
| 137 |
temperature: float = TEMPERATURE,
|
| 138 |
) -> BaseModel:
|
| 139 |
-
"""
|
| 140 |
errors: list[Exception] = []
|
| 141 |
for node in _new_ring().traverse_from_head():
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
logger.warning("[gemini] model=%s key_index=%d failed (%s); advancing", model, node.index, type(exc).__name__)
|
| 162 |
raise _quota_exhausted(GEMINI_MODEL, errors)
|
| 163 |
|
| 164 |
|
|
|
|
| 1 |
+
"""Gemini access through a deterministic, head-first key ring.
|
| 2 |
+
|
| 3 |
+
Every API call starts with the first configured key. On *any* exception it
|
| 4 |
+
tries the next node exactly once. There are deliberately no delays, retries,
|
| 5 |
+
timeouts, cooldowns, key reservations, or model changes in this module.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
|
|
|
| 16 |
from google.genai import types
|
| 17 |
from pydantic import BaseModel
|
| 18 |
|
| 19 |
+
from src.config import API_KEYS, GEMINI_MODEL, MEMORY_EMBEDDING_MODEL, TEMPERATURE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
from src.core.log import get_logger
|
| 21 |
|
| 22 |
logger = get_logger(__name__)
|
|
|
|
| 123 |
complexity: str = "default",
|
| 124 |
temperature: float = TEMPERATURE,
|
| 125 |
) -> BaseModel:
|
| 126 |
+
"""Call exactly one model, moving through the ring on any failure."""
|
| 127 |
errors: list[Exception] = []
|
| 128 |
for node in _new_ring().traverse_from_head():
|
| 129 |
+
try:
|
| 130 |
+
response = _get_client(node.key).models.generate_content(
|
| 131 |
+
model=GEMINI_MODEL,
|
| 132 |
+
contents=user_prompt,
|
| 133 |
+
config=types.GenerateContentConfig(
|
| 134 |
+
system_instruction=system_prompt,
|
| 135 |
+
response_mime_type="application/json",
|
| 136 |
+
response_schema=schema,
|
| 137 |
+
temperature=temperature,
|
| 138 |
+
thinking_config=types.ThinkingConfig(thinking_level="medium"),
|
| 139 |
+
),
|
| 140 |
+
)
|
| 141 |
+
result = response.parsed if getattr(response, "parsed", None) is not None else schema.model_validate(json.loads(response.text))
|
| 142 |
+
logger.info("[gemini] model=%s key_index=%d ok", GEMINI_MODEL, node.index)
|
| 143 |
+
_record_success(complexity)
|
| 144 |
+
return result
|
| 145 |
+
except Exception as exc:
|
| 146 |
+
errors.append(exc)
|
| 147 |
+
logger.warning("[gemini] model=%s key_index=%d failed (%s); advancing", GEMINI_MODEL, node.index, type(exc).__name__)
|
|
|
|
| 148 |
raise _quota_exhausted(GEMINI_MODEL, errors)
|
| 149 |
|
| 150 |
|
backend/test_gemini_client.py
DELETED
|
@@ -1,60 +0,0 @@
|
|
| 1 |
-
from pydantic import BaseModel
|
| 2 |
-
|
| 3 |
-
from src.llm import gemini_client
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
class _Result(BaseModel):
|
| 7 |
-
value: str
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
class _Response:
|
| 11 |
-
parsed = _Result(value="ok")
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
class _Models:
|
| 15 |
-
def __init__(self, calls, failures):
|
| 16 |
-
self._calls = calls
|
| 17 |
-
self._failures = failures
|
| 18 |
-
|
| 19 |
-
def generate_content(self, *, model, **_kwargs):
|
| 20 |
-
self._calls.append(model)
|
| 21 |
-
if model in self._failures:
|
| 22 |
-
raise RuntimeError(model)
|
| 23 |
-
return _Response()
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
class _Client:
|
| 27 |
-
def __init__(self, calls, failures):
|
| 28 |
-
self.models = _Models(calls, failures)
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def test_fallback_model_runs_on_same_key_before_next_key(monkeypatch):
|
| 32 |
-
calls = []
|
| 33 |
-
monkeypatch.setattr(gemini_client, "API_KEYS", ["key-1", "key-2"])
|
| 34 |
-
monkeypatch.setattr(gemini_client, "GEMINI_MODEL", "primary")
|
| 35 |
-
monkeypatch.setattr(gemini_client, "GEMINI_MODEL_FALLBACK", "fallback")
|
| 36 |
-
monkeypatch.setattr(gemini_client, "_get_client", lambda key: _Client(calls, {"primary"}))
|
| 37 |
-
monkeypatch.setattr(gemini_client, "_record_success", lambda _complexity: None)
|
| 38 |
-
|
| 39 |
-
result = gemini_client.call_gemini("system", "user", _Result)
|
| 40 |
-
|
| 41 |
-
assert result.value == "ok"
|
| 42 |
-
assert calls == ["primary", "fallback"]
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def test_key_advances_only_after_both_models_fail(monkeypatch):
|
| 46 |
-
calls = []
|
| 47 |
-
monkeypatch.setattr(gemini_client, "API_KEYS", ["key-1", "key-2"])
|
| 48 |
-
monkeypatch.setattr(gemini_client, "GEMINI_MODEL", "primary")
|
| 49 |
-
monkeypatch.setattr(gemini_client, "GEMINI_MODEL_FALLBACK", "fallback")
|
| 50 |
-
monkeypatch.setattr(
|
| 51 |
-
gemini_client,
|
| 52 |
-
"_get_client",
|
| 53 |
-
lambda key: _Client(calls, {"primary", "fallback"} if key == "key-1" else set()),
|
| 54 |
-
)
|
| 55 |
-
monkeypatch.setattr(gemini_client, "_record_success", lambda _complexity: None)
|
| 56 |
-
|
| 57 |
-
result = gemini_client.call_gemini("system", "user", _Result)
|
| 58 |
-
|
| 59 |
-
assert result.value == "ok"
|
| 60 |
-
assert calls == ["primary", "fallback", "primary"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/tools/sidecar_monitor.py
CHANGED
|
@@ -1,13 +1,11 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
|
| 7 |
-
|
| 8 |
-
or control
|
| 9 |
-
Design: read-only by construction so monitoring can never perturb the
|
| 10 |
-
simulation it observes.
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
|
|
| 1 |
+
"""Read-only, non-LLM overnight monitor for a running Valhalla simulation.
|
| 2 |
|
| 3 |
+
It polls the same state endpoint used by the frontend and writes two files:
|
| 4 |
+
* ``*.jsonl``: every sampled snapshot and every detected finding (machine-readable)
|
| 5 |
+
* ``*.txt``: a concise, chronological report suitable for morning review
|
| 6 |
|
| 7 |
+
The monitor never imports simulation modules, calls an LLM, writes checkpoints,
|
| 8 |
+
or invokes any control endpoint. Stop it with Ctrl+C; it flushes a final summary.
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
frontend/src/App.jsx
CHANGED
|
@@ -1,17 +1,3 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* App — root layout and state composer of the dashboard.
|
| 3 |
-
*
|
| 4 |
-
* Owns auth + simulation snapshot state, renders the map canvas, the
|
| 5 |
-
* agent windows, and all side panels, and handles error banners and
|
| 6 |
-
* day-handoff/provider-failure packets from the backend.
|
| 7 |
-
*
|
| 8 |
-
* Architecture: consumed by main.jsx; renders SimCanvas, InfoBar,
|
| 9 |
-
* ConversationFeed, EventsPanel, DebugPanel, RosterManager, LoginButton.
|
| 10 |
-
*
|
| 11 |
-
* Design: one component per concern, all fed from a single WebSocket
|
| 12 |
-
* snapshot hook (useSimState).
|
| 13 |
-
*/
|
| 14 |
-
|
| 15 |
import { useState, useEffect, useCallback } from "react";
|
| 16 |
import useSimState from "./hooks/useSimState";
|
| 17 |
import { AuthProvider, useAuth } from "./hooks/useAuth";
|
|
@@ -23,7 +9,6 @@ import EventsPanel from "./components/EventsPanel";
|
|
| 23 |
import DebugPanel from "./components/DebugPanel";
|
| 24 |
import RosterManager from "./components/RosterManager";
|
| 25 |
import LoginButton from "./components/LoginButton";
|
| 26 |
-
import LogTerminal from "./components/LogTerminal";
|
| 27 |
import { apiUrl } from "./utils/api";
|
| 28 |
import "./App.css";
|
| 29 |
|
|
@@ -31,6 +16,7 @@ function compactTabPosition(index) {
|
|
| 31 |
const side = index % 2;
|
| 32 |
const row = Math.floor(index / 2);
|
| 33 |
return {
|
|
|
|
| 34 |
// right-hand card can then expand without its controls leaving the view.
|
| 35 |
x: side ? Math.max(16, window.innerWidth - 276) : 16,
|
| 36 |
y: 66 + row * 92,
|
|
@@ -57,7 +43,6 @@ function AppContent() {
|
|
| 57 |
const [controlError, setControlError] = useState(null);
|
| 58 |
const [simulationRunning, setSimulationRunning] = useState(true);
|
| 59 |
const [rosterOpen, setRosterOpen] = useState(false);
|
| 60 |
-
const [showTerminal, setShowTerminal] = useState(false);
|
| 61 |
|
| 62 |
useEffect(() => {
|
| 63 |
if (!snapshot) return;
|
|
@@ -155,7 +140,6 @@ function AppContent() {
|
|
| 155 |
<EventsPanel events={snapshot?.events} />
|
| 156 |
{showDebug && <DebugPanel health={snapshot?.health} />}
|
| 157 |
<RosterManager open={rosterOpen} onClose={() => setRosterOpen(false)} simulationRunning={simulationRunning} onError={setControlError} isAuthenticated={isAuthenticated} />
|
| 158 |
-
{isAuthenticated && showTerminal && <LogTerminal onClose={() => setShowTerminal(false)} />}
|
| 159 |
|
| 160 |
{agentIds.map((id, index) => {
|
| 161 |
const expanded = expandedAgentIds.has(id);
|
|
@@ -188,8 +172,6 @@ function AppContent() {
|
|
| 188 |
}}
|
| 189 |
onToggleRoster={() => setRosterOpen((value) => !value)}
|
| 190 |
isAuthenticated={isAuthenticated}
|
| 191 |
-
terminalOpen={showTerminal}
|
| 192 |
-
onToggleTerminal={() => setShowTerminal((value) => !value)}
|
| 193 |
/>
|
| 194 |
<LoginButton />
|
| 195 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { useState, useEffect, useCallback } from "react";
|
| 2 |
import useSimState from "./hooks/useSimState";
|
| 3 |
import { AuthProvider, useAuth } from "./hooks/useAuth";
|
|
|
|
| 9 |
import DebugPanel from "./components/DebugPanel";
|
| 10 |
import RosterManager from "./components/RosterManager";
|
| 11 |
import LoginButton from "./components/LoginButton";
|
|
|
|
| 12 |
import { apiUrl } from "./utils/api";
|
| 13 |
import "./App.css";
|
| 14 |
|
|
|
|
| 16 |
const side = index % 2;
|
| 17 |
const row = Math.floor(index / 2);
|
| 18 |
return {
|
| 19 |
+
// Reserve the full inspector width even while this card is compact. A
|
| 20 |
// right-hand card can then expand without its controls leaving the view.
|
| 21 |
x: side ? Math.max(16, window.innerWidth - 276) : 16,
|
| 22 |
y: 66 + row * 92,
|
|
|
|
| 43 |
const [controlError, setControlError] = useState(null);
|
| 44 |
const [simulationRunning, setSimulationRunning] = useState(true);
|
| 45 |
const [rosterOpen, setRosterOpen] = useState(false);
|
|
|
|
| 46 |
|
| 47 |
useEffect(() => {
|
| 48 |
if (!snapshot) return;
|
|
|
|
| 140 |
<EventsPanel events={snapshot?.events} />
|
| 141 |
{showDebug && <DebugPanel health={snapshot?.health} />}
|
| 142 |
<RosterManager open={rosterOpen} onClose={() => setRosterOpen(false)} simulationRunning={simulationRunning} onError={setControlError} isAuthenticated={isAuthenticated} />
|
|
|
|
| 143 |
|
| 144 |
{agentIds.map((id, index) => {
|
| 145 |
const expanded = expandedAgentIds.has(id);
|
|
|
|
| 172 |
}}
|
| 173 |
onToggleRoster={() => setRosterOpen((value) => !value)}
|
| 174 |
isAuthenticated={isAuthenticated}
|
|
|
|
|
|
|
| 175 |
/>
|
| 176 |
<LoginButton />
|
| 177 |
</div>
|
frontend/src/components/ActionDetail.jsx
CHANGED
|
@@ -1,14 +1,3 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* ActionDetail — renders an agent's current action description.
|
| 3 |
-
*
|
| 4 |
-
* Shows the action text with its time range and route progress for
|
| 5 |
-
* movement actions.
|
| 6 |
-
*
|
| 7 |
-
* Architecture: rendered inside AgentWindow's expanded inspector.
|
| 8 |
-
*
|
| 9 |
-
* Design: purely presentational; receives the action object as props.
|
| 10 |
-
*/
|
| 11 |
-
|
| 12 |
export default function ActionDetail({ action }) {
|
| 13 |
if (!action) {
|
| 14 |
return (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
export default function ActionDetail({ action }) {
|
| 2 |
if (!action) {
|
| 3 |
return (
|
frontend/src/components/AgentWindow.jsx
CHANGED
|
@@ -1,17 +1,3 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* AgentWindow — per-agent draggable inspector card.
|
| 3 |
-
*
|
| 4 |
-
* Compact card by default; expanded view shows location, current action
|
| 5 |
-
* (via ActionDetail), energy/emotion gauges, pause state, and the agent's
|
| 6 |
-
* live conversation transcript (ChatPanel).
|
| 7 |
-
*
|
| 8 |
-
* Architecture: one instance per agent, laid out by App.jsx; fed from the
|
| 9 |
-
* shared simulation snapshot.
|
| 10 |
-
*
|
| 11 |
-
* Design: windows are draggable (react-draggable) so multiple agents can
|
| 12 |
-
* be inspected simultaneously.
|
| 13 |
-
*/
|
| 14 |
-
|
| 15 |
import { useRef, useState, useEffect } from "react";
|
| 16 |
import Draggable from "react-draggable";
|
| 17 |
import WindowHeader from "./WindowHeader";
|
|
@@ -40,6 +26,7 @@ export default function AgentWindow({ agentId, data, speed, defaultPosition, exp
|
|
| 40 |
const conversationId = conversation
|
| 41 |
? `${conversation.partner_id || conversation.partner_name}_${conversation.started_tick ?? "pending"}`
|
| 42 |
: null;
|
|
|
|
| 43 |
// card's lifetime. The backend clears this state when the simulated chat
|
| 44 |
// finishes; rendering only an active/generating conversation automatically
|
| 45 |
// collapses the chat panel as the agent starts their next task.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { useRef, useState, useEffect } from "react";
|
| 2 |
import Draggable from "react-draggable";
|
| 3 |
import WindowHeader from "./WindowHeader";
|
|
|
|
| 26 |
const conversationId = conversation
|
| 27 |
? `${conversation.partner_id || conversation.partner_name}_${conversation.started_tick ?? "pending"}`
|
| 28 |
: null;
|
| 29 |
+
// A transcript belongs to the live conversation state, not to the agent
|
| 30 |
// card's lifetime. The backend clears this state when the simulated chat
|
| 31 |
// finishes; rendering only an active/generating conversation automatically
|
| 32 |
// collapses the chat panel as the agent starts their next task.
|
frontend/src/components/ChatBubble.jsx
CHANGED
|
@@ -1,14 +1,3 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* ChatBubble — a single message bubble in a conversation transcript.
|
| 3 |
-
*
|
| 4 |
-
* Aligns right for the focused agent (self) and left for the partner,
|
| 5 |
-
* colored by the speaker's agent color.
|
| 6 |
-
*
|
| 7 |
-
* Architecture: rendered by ChatPanel for each revealed message.
|
| 8 |
-
*
|
| 9 |
-
* Design: purely presentational; no state.
|
| 10 |
-
*/
|
| 11 |
-
|
| 12 |
export default function ChatBubble({ text, isSelf, color }) {
|
| 13 |
return (
|
| 14 |
<div style={{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
export default function ChatBubble({ text, isSelf, color }) {
|
| 2 |
return (
|
| 3 |
<div style={{
|
frontend/src/components/ChatPanel.jsx
CHANGED
|
@@ -1,16 +1,3 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* ChatPanel — read-only live transcript of one agent-to-agent conversation.
|
| 3 |
-
*
|
| 4 |
-
* Reveals messages one by one to mirror the backend's staged generation,
|
| 5 |
-
* and auto-scrolls to the newest message.
|
| 6 |
-
*
|
| 7 |
-
* Architecture: rendered inside AgentWindow; fed the conversation object
|
| 8 |
-
* and the revealed-count from the simulation snapshot.
|
| 9 |
-
*
|
| 10 |
-
* Design: observation-only — there is deliberately no input to talk to
|
| 11 |
-
* agents from the UI.
|
| 12 |
-
*/
|
| 13 |
-
|
| 14 |
import { useEffect, useRef } from "react";
|
| 15 |
import ChatBubble from "./ChatBubble";
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { useEffect, useRef } from "react";
|
| 2 |
import ChatBubble from "./ChatBubble";
|
| 3 |
|
frontend/src/components/ConversationFeed.jsx
CHANGED
|
@@ -1,16 +1,3 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* ConversationFeed — draggable log of recent campus conversations.
|
| 3 |
-
*
|
| 4 |
-
* Lists recent conversations with sentiment color dots, participants,
|
| 5 |
-
* simulation time, and location, with auto-refresh on new entries.
|
| 6 |
-
*
|
| 7 |
-
* Architecture: rendered by App.jsx from the snapshot's
|
| 8 |
-
* recent_conversations block.
|
| 9 |
-
*
|
| 10 |
-
* Design: the sentiment dot is derived from the conversation's structured
|
| 11 |
-
* sentiment field, not guessed from text.
|
| 12 |
-
*/
|
| 13 |
-
|
| 14 |
import { useEffect, useRef, useState } from "react";
|
| 15 |
import Draggable from "react-draggable";
|
| 16 |
|
|
@@ -28,6 +15,7 @@ export default function ConversationFeed({ conversations, minimized = false, onT
|
|
| 28 |
y: 16,
|
| 29 |
}));
|
| 30 |
|
|
|
|
| 31 |
useEffect(() => {
|
| 32 |
const clampToViewport = () => {
|
| 33 |
const node = nodeRef.current;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { useEffect, useRef, useState } from "react";
|
| 2 |
import Draggable from "react-draggable";
|
| 3 |
|
|
|
|
| 15 |
y: 16,
|
| 16 |
}));
|
| 17 |
|
| 18 |
+
// Keep the panel usable after resizing or minimizing, matching agent cards.
|
| 19 |
useEffect(() => {
|
| 20 |
const clampToViewport = () => {
|
| 21 |
const node = nodeRef.current;
|
frontend/src/components/DebugPanel.jsx
CHANGED
|
@@ -1,15 +1,3 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* DebugPanel — engine health readout for researchers.
|
| 3 |
-
*
|
| 4 |
-
* Shows tick, agent/moving/paused counts, background task counts, and
|
| 5 |
-
* per-agent anomalies reported by the backend health monitor.
|
| 6 |
-
*
|
| 7 |
-
* Architecture: toggled from InfoBar; fed by the snapshot's health block.
|
| 8 |
-
*
|
| 9 |
-
* Design: keeps runtime anomalies visible without cluttering the main
|
| 10 |
-
* dashboard.
|
| 11 |
-
*/
|
| 12 |
-
|
| 13 |
export default function DebugPanel({ health }) {
|
| 14 |
if (!health) return null;
|
| 15 |
return (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
export default function DebugPanel({ health }) {
|
| 2 |
if (!health) return null;
|
| 3 |
return (
|
frontend/src/components/EventsPanel.jsx
CHANGED
|
@@ -1,15 +1,3 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* EventsPanel — "campus pulse" list of live and upcoming events.
|
| 3 |
-
*
|
| 4 |
-
* Renders active events as LIVE and upcoming events with time range,
|
| 5 |
-
* category color, and attendance count vs capacity.
|
| 6 |
-
*
|
| 7 |
-
* Architecture: rendered by App.jsx from the snapshot's events block.
|
| 8 |
-
*
|
| 9 |
-
* Design: read-only; event data originates in the deterministic event
|
| 10 |
-
* calendar on the backend.
|
| 11 |
-
*/
|
| 12 |
-
|
| 13 |
const CATEGORY_COLOR = {
|
| 14 |
"technical-cultural": "#5b9bd5",
|
| 15 |
sports: "#51cf66",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
const CATEGORY_COLOR = {
|
| 2 |
"technical-cultural": "#5b9bd5",
|
| 3 |
sports: "#51cf66",
|
frontend/src/components/InfoBar.jsx
CHANGED
|
@@ -1,19 +1,6 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* InfoBar — status bar and timeline controls.
|
| 3 |
-
*
|
| 4 |
-
* Shows tick/time/day/pace and agent counts, plus auth-gated controls:
|
| 5 |
-
* rewind (ticks or hours), fast-forward, slow-down, stop/start, and the
|
| 6 |
-
* roster button.
|
| 7 |
-
*
|
| 8 |
-
* Architecture: rendered by App.jsx; posts to the backend's sim-control
|
| 9 |
-
* endpoints with the admin bearer token.
|
| 10 |
-
*
|
| 11 |
-
* Design: controls are hidden for unauthenticated viewers.
|
| 12 |
-
*/
|
| 13 |
-
|
| 14 |
import { useState } from "react";
|
| 15 |
|
| 16 |
-
export default function InfoBar({ snapshot, showDebug, onToggleDebug, onFastForward, onSlowDown, onRewind, simulationRunning, onToggleSimulation, onToggleRoster, isAuthenticated
|
| 17 |
const [rewindAmount, setRewindAmount] = useState("10");
|
| 18 |
const [rewindUnit, setRewindUnit] = useState("ticks");
|
| 19 |
if (!snapshot) return null;
|
|
@@ -140,20 +127,11 @@ export default function InfoBar({ snapshot, showDebug, onToggleDebug, onFastForw
|
|
| 140 |
{showDebug ? "DEBUG ON" : "DEBUG"}
|
| 141 |
</button>
|
| 142 |
{isAuthenticated && (
|
| 143 |
-
<
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
}} title="Add, retire, or rename agents while the simulation is stopped">ROSTER</button>
|
| 149 |
-
<button onClick={onToggleTerminal} style={{
|
| 150 |
-
pointerEvents: "auto", border: "1px solid rgba(91,155,213,.3)", borderRadius: 3,
|
| 151 |
-
background: terminalOpen ? "rgba(91,155,213,.22)" : "transparent", color: "#8fbbe8", padding: "2px 5px",
|
| 152 |
-
fontFamily: "'Space Mono', monospace", fontSize: 8, cursor: "pointer",
|
| 153 |
-
}} title="Open the live backend log terminal">
|
| 154 |
-
{terminalOpen ? "TERMINAL ON" : "TERMINAL"}
|
| 155 |
-
</button>
|
| 156 |
-
</>
|
| 157 |
)}
|
| 158 |
</div>
|
| 159 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { useState } from "react";
|
| 2 |
|
| 3 |
+
export default function InfoBar({ snapshot, showDebug, onToggleDebug, onFastForward, onSlowDown, onRewind, simulationRunning, onToggleSimulation, onToggleRoster, isAuthenticated }) {
|
| 4 |
const [rewindAmount, setRewindAmount] = useState("10");
|
| 5 |
const [rewindUnit, setRewindUnit] = useState("ticks");
|
| 6 |
if (!snapshot) return null;
|
|
|
|
| 127 |
{showDebug ? "DEBUG ON" : "DEBUG"}
|
| 128 |
</button>
|
| 129 |
{isAuthenticated && (
|
| 130 |
+
<button onClick={onToggleRoster} style={{
|
| 131 |
+
pointerEvents: "auto", border: "1px solid rgba(212,160,74,.3)", borderRadius: 3,
|
| 132 |
+
background: "rgba(212,160,74,.08)", color: "#e7bd70", padding: "2px 5px",
|
| 133 |
+
fontFamily: "'Space Mono', monospace", fontSize: 8, cursor: "pointer",
|
| 134 |
+
}} title="Add, retire, or rename agents while the simulation is stopped">ROSTER</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
)}
|
| 136 |
</div>
|
| 137 |
);
|
frontend/src/components/Legend.jsx
CHANGED
|
@@ -1,16 +1,4 @@
|
|
| 1 |
-
/
|
| 2 |
-
* Legend — color-to-name roster legend (unused, kept for reference).
|
| 3 |
-
*
|
| 4 |
-
* Maps each agent's dot color to their name and lets the user focus the
|
| 5 |
-
* camera by clicking a name.
|
| 6 |
-
*
|
| 7 |
-
* Architecture: not currently rendered — SimCanvas draws labels directly;
|
| 8 |
-
* retained as the planned clickable roster.
|
| 9 |
-
*
|
| 10 |
-
* Design: superseded by on-canvas labels; left in the tree until the
|
| 11 |
-
* focus UX is finalized.
|
| 12 |
-
*/
|
| 13 |
-
|
| 14 |
export default function Legend({ agents, focusedId, onFocus }) {
|
| 15 |
if (!agents) return null;
|
| 16 |
const entries = Object.entries(agents);
|
|
|
|
| 1 |
+
// Color legend: maps each agent's dot color to their name. Click to focus.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
export default function Legend({ agents, focusedId, onFocus }) {
|
| 3 |
if (!agents) return null;
|
| 4 |
const entries = Object.entries(agents);
|
frontend/src/components/LogTerminal.jsx
DELETED
|
@@ -1,189 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* LogTerminal — admin-only live view of the backend log relay.
|
| 3 |
-
*
|
| 4 |
-
* Polls GET /api/logs?since=<cursor> with the admin bearer token and
|
| 5 |
-
* renders the captured logger output as a read-only terminal.
|
| 6 |
-
*
|
| 7 |
-
* Architecture: rendered by App.jsx only for authenticated users; toggled
|
| 8 |
-
* by the TERMINAL button in InfoBar.
|
| 9 |
-
*
|
| 10 |
-
* Design: 2s REST polling reuses the existing bearer-token flow instead of
|
| 11 |
-
* adding WebSocket authentication; auto-scroll halts while the pointer is
|
| 12 |
-
* over the terminal so lines can be read mid-stream.
|
| 13 |
-
*/
|
| 14 |
-
|
| 15 |
-
import { useEffect, useRef, useState, useCallback } from "react";
|
| 16 |
-
import { useAuth } from "../hooks/useAuth";
|
| 17 |
-
import { apiUrl } from "../utils/api";
|
| 18 |
-
|
| 19 |
-
const POLL_MS = 2000;
|
| 20 |
-
const LEVEL_COLORS = {
|
| 21 |
-
DEBUG: "#6b6b78",
|
| 22 |
-
INFO: "#8fbbe8",
|
| 23 |
-
WARNING: "#e7bd70",
|
| 24 |
-
ERROR: "#ff8b8b",
|
| 25 |
-
CRITICAL: "#ff6b6b",
|
| 26 |
-
};
|
| 27 |
-
|
| 28 |
-
export default function LogTerminal({ onClose }) {
|
| 29 |
-
const { token } = useAuth();
|
| 30 |
-
const [lines, setLines] = useState([]);
|
| 31 |
-
const [cursor, setCursor] = useState(0);
|
| 32 |
-
const [error, setError] = useState(null);
|
| 33 |
-
const [paused, setPaused] = useState(false);
|
| 34 |
-
const bodyRef = useRef(null);
|
| 35 |
-
const pausedRef = useRef(false);
|
| 36 |
-
|
| 37 |
-
const fetchNew = useCallback(async () => {
|
| 38 |
-
try {
|
| 39 |
-
const res = await fetch(apiUrl(`/api/logs?since=${cursor}`), {
|
| 40 |
-
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
| 41 |
-
});
|
| 42 |
-
if (!res.ok) throw new Error(`Log fetch failed (${res.status})`);
|
| 43 |
-
const data = await res.json();
|
| 44 |
-
if (data.lines?.length) {
|
| 45 |
-
setLines((previous) => [...previous.slice(-2000), ...data.lines]);
|
| 46 |
-
}
|
| 47 |
-
if (typeof data.next === "number") setCursor(data.next);
|
| 48 |
-
setError(null);
|
| 49 |
-
} catch (err) {
|
| 50 |
-
setError(err.message);
|
| 51 |
-
}
|
| 52 |
-
}, [cursor, token]);
|
| 53 |
-
|
| 54 |
-
useEffect(() => {
|
| 55 |
-
fetchNew();
|
| 56 |
-
const timer = setInterval(fetchNew, POLL_MS);
|
| 57 |
-
return () => clearInterval(timer);
|
| 58 |
-
}, [fetchNew]);
|
| 59 |
-
|
| 60 |
-
useEffect(() => {
|
| 61 |
-
const el = bodyRef.current;
|
| 62 |
-
if (el && !pausedRef.current) el.scrollTop = el.scrollHeight;
|
| 63 |
-
}, [lines]);
|
| 64 |
-
|
| 65 |
-
async function clearLogs() {
|
| 66 |
-
try {
|
| 67 |
-
const res = await fetch(apiUrl("/api/logs/clear"), {
|
| 68 |
-
method: "POST",
|
| 69 |
-
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
| 70 |
-
});
|
| 71 |
-
if (!res.ok) throw new Error(`Clear failed (${res.status})`);
|
| 72 |
-
setLines([]);
|
| 73 |
-
} catch (err) {
|
| 74 |
-
setError(err.message);
|
| 75 |
-
}
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
async function copyLogs() {
|
| 79 |
-
try {
|
| 80 |
-
await navigator.clipboard.writeText(lines.map((line) => line.text).join("\n"));
|
| 81 |
-
} catch {
|
| 82 |
-
/* clipboard unavailable — ignore */
|
| 83 |
-
}
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
const headerButton = {
|
| 87 |
-
background: "rgba(91,155,213,.08)",
|
| 88 |
-
border: "1px solid rgba(91,155,213,.30)",
|
| 89 |
-
borderRadius: 3,
|
| 90 |
-
color: "#8fbbe8",
|
| 91 |
-
padding: "2px 6px",
|
| 92 |
-
fontFamily: "'Space Mono', monospace",
|
| 93 |
-
fontSize: 8,
|
| 94 |
-
cursor: "pointer",
|
| 95 |
-
};
|
| 96 |
-
|
| 97 |
-
return (
|
| 98 |
-
<aside
|
| 99 |
-
style={{
|
| 100 |
-
position: "fixed",
|
| 101 |
-
right: 16,
|
| 102 |
-
top: 66,
|
| 103 |
-
bottom: 96,
|
| 104 |
-
width: 460,
|
| 105 |
-
maxWidth: "calc(100vw - 32px)",
|
| 106 |
-
display: "flex",
|
| 107 |
-
flexDirection: "column",
|
| 108 |
-
background: "rgba(8,10,12,0.95)",
|
| 109 |
-
backdropFilter: "blur(8px)",
|
| 110 |
-
border: "1px solid rgba(91,155,213,0.30)",
|
| 111 |
-
borderRadius: 6,
|
| 112 |
-
boxShadow: "0 14px 44px rgba(0,0,0,0.6)",
|
| 113 |
-
fontFamily: "'Space Mono', monospace",
|
| 114 |
-
zIndex: 1300,
|
| 115 |
-
}}
|
| 116 |
-
>
|
| 117 |
-
<header
|
| 118 |
-
style={{
|
| 119 |
-
display: "flex",
|
| 120 |
-
alignItems: "center",
|
| 121 |
-
gap: 8,
|
| 122 |
-
padding: "8px 12px",
|
| 123 |
-
borderBottom: "1px solid rgba(255,255,255,0.07)",
|
| 124 |
-
}}
|
| 125 |
-
>
|
| 126 |
-
<span style={{ color: "#8fbbe8", fontWeight: 700, fontSize: 10, letterSpacing: "0.14em" }}>
|
| 127 |
-
LOG TERMINAL
|
| 128 |
-
</span>
|
| 129 |
-
<span style={{ fontSize: 9, color: "#6b6b78" }}>{lines.length} lines</span>
|
| 130 |
-
<span style={{ flex: 1 }} />
|
| 131 |
-
<button onClick={copyLogs} style={headerButton} title="Copy all relayed lines to the clipboard">
|
| 132 |
-
COPY
|
| 133 |
-
</button>
|
| 134 |
-
<button onClick={clearLogs} style={headerButton} title="Clear the in-memory relay buffer">
|
| 135 |
-
CLEAR
|
| 136 |
-
</button>
|
| 137 |
-
<button onClick={onClose} style={headerButton} title="Close the log terminal">
|
| 138 |
-
X
|
| 139 |
-
</button>
|
| 140 |
-
</header>
|
| 141 |
-
|
| 142 |
-
<div
|
| 143 |
-
ref={bodyRef}
|
| 144 |
-
onMouseEnter={() => {
|
| 145 |
-
pausedRef.current = true;
|
| 146 |
-
setPaused(true);
|
| 147 |
-
}}
|
| 148 |
-
onMouseLeave={() => {
|
| 149 |
-
pausedRef.current = false;
|
| 150 |
-
setPaused(false);
|
| 151 |
-
}}
|
| 152 |
-
style={{
|
| 153 |
-
flex: 1,
|
| 154 |
-
overflowY: "auto",
|
| 155 |
-
padding: "10px 12px",
|
| 156 |
-
fontSize: 9,
|
| 157 |
-
lineHeight: 1.65,
|
| 158 |
-
}}
|
| 159 |
-
>
|
| 160 |
-
{lines.length === 0 && (
|
| 161 |
-
<div style={{ color: "#6b6b78" }}>no lines yet — the relay captures log output once the simulation starts</div>
|
| 162 |
-
)}
|
| 163 |
-
{lines.map((entry) => (
|
| 164 |
-
<div
|
| 165 |
-
key={entry.seq}
|
| 166 |
-
style={{
|
| 167 |
-
color: LEVEL_COLORS[entry.level] || "#d0d0da",
|
| 168 |
-
whiteSpace: "pre-wrap",
|
| 169 |
-
wordBreak: "break-word",
|
| 170 |
-
}}
|
| 171 |
-
>
|
| 172 |
-
{entry.text}
|
| 173 |
-
</div>
|
| 174 |
-
))}
|
| 175 |
-
</div>
|
| 176 |
-
|
| 177 |
-
<footer
|
| 178 |
-
style={{
|
| 179 |
-
padding: "6px 12px",
|
| 180 |
-
borderTop: "1px solid rgba(255,255,255,0.07)",
|
| 181 |
-
fontSize: 9,
|
| 182 |
-
color: error ? "#ff8b8b" : paused ? "#e7bd70" : "#6b6b78",
|
| 183 |
-
}}
|
| 184 |
-
>
|
| 185 |
-
{error ? `ERROR: ${error}` : paused ? "PAUSED — move the pointer away to resume auto-scroll" : "TAILING — 2s poll"}
|
| 186 |
-
</footer>
|
| 187 |
-
</aside>
|
| 188 |
-
);
|
| 189 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/LoginButton.jsx
CHANGED
|
@@ -1,15 +1,3 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* LoginButton — auth entry point for admin controls.
|
| 3 |
-
*
|
| 4 |
-
* Shows a LOGIN pill when unauthenticated (opens LoginModal) and a user
|
| 5 |
-
* dropdown with logout when a session is active.
|
| 6 |
-
*
|
| 7 |
-
* Architecture: rendered by App.jsx; uses the useAuth context shared with
|
| 8 |
-
* InfoBar and RosterManager.
|
| 9 |
-
*
|
| 10 |
-
* Design: anonymous users can watch; only admins get controls.
|
| 11 |
-
*/
|
| 12 |
-
|
| 13 |
import { useState } from "react";
|
| 14 |
import { useAuth } from "../hooks/useAuth";
|
| 15 |
import LoginModal from "./LoginModal";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { useState } from "react";
|
| 2 |
import { useAuth } from "../hooks/useAuth";
|
| 3 |
import LoginModal from "./LoginModal";
|