Skip to content

Commit 7fab73c

Browse files
committed
wip: save dirty work before disk recovery (read-only filesystem)
1 parent 113c533 commit 7fab73c

17 files changed

Lines changed: 4348 additions & 1984 deletions

File tree

packages/agents/src/monitor_agents/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
from monitor_agents.analyzer import Analyzer
7171
from monitor_agents.ingestion_pipeline import IngestionPipeline
7272
from monitor_agents.npc_voice import NPCVoice
73+
from monitor_agents.world_architect import WorldArchitect
7374
from monitor_agents.llm_registry import LLMRegistry, LLMClient
7475

7576
__all__ = [
@@ -82,6 +83,7 @@
8283
"Analyzer",
8384
"IngestionPipeline",
8485
"NPCVoice",
86+
"WorldArchitect",
8587
"LLMRegistry",
8688
"LLMClient",
8789
]

packages/agents/src/monitor_agents/loops/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
ConversationState,
2121
build_conversation_graph,
2222
)
23+
from monitor_agents.loops.world_building_loop import (
24+
WorldBuildingLoop,
25+
WorldBuildingState,
26+
build_world_building_graph,
27+
)
2328

2429
__all__ = [
2530
"SceneLoop",
@@ -34,4 +39,7 @@
3439
"ConversationLoop",
3540
"ConversationState",
3641
"build_conversation_graph",
42+
"WorldBuildingLoop",
43+
"WorldBuildingState",
44+
"build_world_building_graph",
3745
]
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
"""
2+
World Building Loop — LangGraph StateGraph for conversational world creation.
3+
4+
LAYER: 2 (agents)
5+
IMPORTS FROM: monitor_data (Layer 1), langgraph, external libs
6+
CALLED BY: chat router (Layer 3 / backend) via WorldBuildingLoop.run()
7+
8+
The World Building Loop handles the World Architect mode where users
9+
collaboratively define their setting through conversation. Unlike the
10+
Scene Loop (interactive narrative), this loop:
11+
12+
- Has NO dice/resolution mechanics
13+
- Uses WorldArchitect agent instead of Narrator
14+
- Auto-commits proposals (user is defining their world deliberately)
15+
- Tracks creation progress and suggests gaps
16+
17+
Flow:
18+
load_world_context → process_user_input → commit_proposals →
19+
format_response → (return to caller, await next input)
20+
21+
This loop is driven externally — each call to ``run()`` processes one
22+
user turn and returns the architect's response. The chat router manages
23+
the outer message loop.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
from typing import Any, Dict, List, Optional
29+
from uuid import UUID
30+
31+
from langgraph.graph import StateGraph, END
32+
from pydantic import BaseModel, Field
33+
34+
35+
# =============================================================================
36+
# STATE SCHEMA
37+
# =============================================================================
38+
39+
40+
class WorldBuildingState(BaseModel):
41+
"""State flowing through the World Building Loop nodes."""
42+
43+
# Session identifiers
44+
session_id: str
45+
universe_id: Optional[UUID] = None
46+
multiverse_id: Optional[UUID] = None
47+
48+
# Conversation context
49+
conversation_history: List[Dict[str, Any]] = Field(default_factory=list)
50+
current_user_input: str = ""
51+
52+
# World state (loaded each turn for freshness)
53+
world_summary: Dict[str, Any] = Field(default_factory=dict)
54+
55+
# Turn output
56+
response_text: str = ""
57+
extracted_proposals: List[Dict[str, Any]] = Field(default_factory=list)
58+
committed_count: int = 0
59+
commit_errors: List[str] = Field(default_factory=list)
60+
61+
# Flow control
62+
turns_count: int = 0
63+
is_first_turn: bool = True
64+
65+
66+
# =============================================================================
67+
# NODES
68+
# =============================================================================
69+
70+
71+
async def load_world_context(state: WorldBuildingState) -> Dict[str, Any]:
72+
"""
73+
Load current universe state from Neo4j for context injection.
74+
75+
This runs at the start of every turn to ensure the architect has
76+
an up-to-date view of what exists in the world.
77+
78+
Write: nothing (read-only node).
79+
"""
80+
from monitor_agents.world_architect import WorldArchitect
81+
82+
architect = WorldArchitect()
83+
world_summary = await architect._load_world_state(state.universe_id)
84+
85+
# On first turn with an empty universe, generate a welcome message
86+
is_first = state.turns_count == 0 and not state.conversation_history
87+
88+
return {
89+
"world_summary": world_summary,
90+
"is_first_turn": is_first,
91+
}
92+
93+
94+
async def process_user_input(state: WorldBuildingState) -> Dict[str, Any]:
95+
"""
96+
Process the user's message through the WorldArchitect agent.
97+
98+
This is the core node — it runs the DSPy module to generate both
99+
a conversational response and structured proposal extractions.
100+
101+
Write: nothing directly (proposals are committed in next node).
102+
"""
103+
from monitor_agents.world_architect import WorldArchitect
104+
105+
architect = WorldArchitect()
106+
107+
# If this is the very first turn and no input, generate a welcome
108+
if state.is_first_turn and not state.current_user_input:
109+
suggestion = await architect.suggest_next(state.universe_id)
110+
return {
111+
"response_text": (
112+
"Welcome to the **World Architect**! I'm here to help you build "
113+
"your setting from the ground up.\n\n"
114+
f"{suggestion}\n\n"
115+
"Describe your world however feels natural — I'll extract the key "
116+
"elements and add them to your universe."
117+
),
118+
"extracted_proposals": [],
119+
"is_first_turn": False,
120+
}
121+
122+
result = await architect.process_turn(
123+
user_message=state.current_user_input,
124+
universe_id=state.universe_id,
125+
conversation_history=state.conversation_history,
126+
)
127+
128+
return {
129+
"response_text": result["response"],
130+
"extracted_proposals": result["proposals"],
131+
"committed_count": result["committed"],
132+
"commit_errors": result["errors"],
133+
"is_first_turn": False,
134+
}
135+
136+
137+
async def format_response(state: WorldBuildingState) -> Dict[str, Any]:
138+
"""
139+
Append creation summary to the response if proposals were committed.
140+
141+
This enriches the architect's natural language response with a
142+
structured summary of what was actually added to Neo4j.
143+
144+
Write: nothing.
145+
"""
146+
response = state.response_text
147+
committed = state.committed_count
148+
proposals = state.extracted_proposals
149+
errors = state.commit_errors
150+
151+
# Add creation summary footer
152+
if committed > 0:
153+
created_items = []
154+
for p in proposals[:committed]:
155+
p_type = p.get("proposal_type", "element")
156+
name = p.get("payload", {}).get("name") or p.get("payload", {}).get("statement", "")[:50]
157+
if name:
158+
created_items.append(f" - {p_type.title()}: **{name}**")
159+
160+
if created_items:
161+
response += "\n\n---\n_Added to your world:_\n" + "\n".join(created_items)
162+
163+
if errors:
164+
response += f"\n\n_({len(errors)} element(s) could not be added — will retry on next turn)_"
165+
166+
return {
167+
"response_text": response,
168+
"turns_count": state.turns_count + 1,
169+
}
170+
171+
172+
# =============================================================================
173+
# GRAPH BUILDER
174+
# =============================================================================
175+
176+
177+
def build_world_building_graph() -> StateGraph:
178+
"""Construct the World Building Loop StateGraph."""
179+
graph = StateGraph(WorldBuildingState)
180+
181+
graph.add_node("load_world_context", load_world_context)
182+
graph.add_node("process_user_input", process_user_input)
183+
graph.add_node("format_response", format_response)
184+
185+
graph.set_entry_point("load_world_context")
186+
graph.add_edge("load_world_context", "process_user_input")
187+
graph.add_edge("process_user_input", "format_response")
188+
graph.add_edge("format_response", END)
189+
190+
return graph
191+
192+
193+
# =============================================================================
194+
# LOOP CLASS
195+
# =============================================================================
196+
197+
198+
class WorldBuildingLoop:
199+
"""
200+
High-level interface for the World Building Loop.
201+
202+
Each call to ``run()`` processes one user turn through the graph
203+
and returns the architect's response.
204+
205+
Usage:
206+
loop = WorldBuildingLoop(
207+
session_id="abc",
208+
universe_id=UUID("..."),
209+
)
210+
result = await loop.run(user_input="I want a dark fantasy world")
211+
print(result["response_text"])
212+
"""
213+
214+
def __init__(
215+
self,
216+
session_id: str,
217+
universe_id: Optional[UUID] = None,
218+
multiverse_id: Optional[UUID] = None,
219+
) -> None:
220+
self.session_id = session_id
221+
self.universe_id = universe_id
222+
self.multiverse_id = multiverse_id
223+
self._graph = build_world_building_graph().compile()
224+
225+
async def run(
226+
self,
227+
user_input: str = "",
228+
conversation_history: Optional[List[Dict[str, Any]]] = None,
229+
) -> Dict[str, Any]:
230+
"""
231+
Execute one world-building turn.
232+
233+
Args:
234+
user_input: The user's message.
235+
conversation_history: Prior messages for context.
236+
237+
Returns:
238+
Dict with response_text, extracted_proposals, committed_count, etc.
239+
"""
240+
initial_state = WorldBuildingState(
241+
session_id=self.session_id,
242+
universe_id=self.universe_id,
243+
multiverse_id=self.multiverse_id,
244+
conversation_history=conversation_history or [],
245+
current_user_input=user_input,
246+
)
247+
248+
result = await self._graph.ainvoke(initial_state.model_dump())
249+
250+
return {
251+
"response_text": result.get("response_text", ""),
252+
"extracted_proposals": result.get("extracted_proposals", []),
253+
"committed_count": result.get("committed_count", 0),
254+
"commit_errors": result.get("commit_errors", []),
255+
"turns_count": result.get("turns_count", 0),
256+
}

packages/agents/src/monitor_agents/prompts/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@
4949
NPCActorSignature,
5050
NPCActorModule,
5151
)
52+
from monitor_agents.prompts.world_architect import (
53+
WorldArchitectModule,
54+
WorldArchitectSignature,
55+
WorldGapAnalysisModule,
56+
WorldGapAnalysisSignature,
57+
)
5258

5359
__all__ = [
5460
# Narrator
@@ -83,4 +89,9 @@
8389
"NPCDirectVoiceModule",
8490
"NPCActorSignature",
8591
"NPCActorModule",
92+
# World Architect
93+
"WorldArchitectModule",
94+
"WorldArchitectSignature",
95+
"WorldGapAnalysisModule",
96+
"WorldGapAnalysisSignature",
8697
]

0 commit comments

Comments
 (0)