"""Game orchestrator: runs the multi-step LLM tool-calling loop and produces a narrative step.""" from __future__ import annotations import json import uuid from datetime import datetime, timezone from typing import Any, AsyncIterator, Dict, List, Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.llm import LlmClient from app.core.settings_service import cast_setting, get_all_settings from app.engine.context import ( build_orchestrator_messages, build_step_writer_messages, build_subagent_messages, ) from app.engine.tools.tools import ALL_TOOL_SCHEMAS, ToolContext, handle_tool_call from app.logging_setup import get_logger from app.models import DeferredTrigger, Message, Session, World from app.prompts.templates import get_prompt log = get_logger("orchestrator") async def run_iteration( db: AsyncSession, user_id: uuid.UUID, session_id: uuid.UUID, action_text: str, ) -> AsyncIterator[Dict[str, Any]]: """Run one full iteration: plan -> tools -> step -> technical side-effects. Yields SSE-ready event dicts: {"type": "status", "data": {"message": "..."}} {"type": "plan", "data": {...}} # orchestrator plan with tool calls {"type": "tool_call", "data": {"name": ..., "args": ..., "result": ...}} {"type": "narrative_chunk", "data": {"content": "..."}} {"type": "step_complete", "data": {"message_id": ..., "options": [...], "state": ...}} {"type": "error", "data": {"message": "..."}} {"type": "done", "data": {}} """ # Load session + world result = await db.execute(select(Session).where(Session.id == session_id)) session = result.scalars().first() if not session: yield {"type": "error", "data": {"message": "session_not_found"}} return result = await db.execute(select(World).where(World.id == session.world_id)) world = result.scalars().first() if not world: yield {"type": "error", "data": {"message": "world_not_found"}} return settings_map = await get_all_settings(db) llm = LlmClient(settings_map) # Save the player's action as a message next_seq = await _next_seq(db, session_id) player_msg = Message( session_id=session_id, seq=next_seq, role="user", kind="player_action", content=action_text, payload={}, is_pinned=True, hidden=False, ) db.add(player_msg) await db.commit() await db.refresh(player_msg) yield {"type": "status", "data": {"message": "planning"}} # Subagent runner async def _subagent(task: str, context: str) -> str: sub_messages = await build_subagent_messages(world, task, context) resp = await llm.chat( messages=sub_messages, temperature=0.7, max_tokens=300, purpose="subagent", user_id=user_id, session_id=session_id, db=db, ) return resp.text ctx = ToolContext(db=db, world=world, session_id=session_id, user_id=user_id, subagent_runner=_subagent, settings_map=settings_map) # === Phase 1: Orchestrator with tool calls (max 5 iterations) === orchestrator_messages, _ = await build_orchestrator_messages(db, world, session_id, action_text) # Add a final user instruction forcing JSON output orchestrator_messages.append({ "role": "user", "content": "Используй инструменты при необходимости, затем верни финальный JSON-ответ с assessment, outcome, state_patch, time_advance, narrative_prompt, next_options, triggers, rails_update, rag_facts.", }) max_iters = 5 final_assistant_text: Optional[str] = None final_tool_calls: List[Dict[str, Any]] = [] for i in range(max_iters): yield {"type": "status", "data": {"message": f"orchestrator_turn_{i + 1}"}} response = await llm.chat( messages=orchestrator_messages, tools=ALL_TOOL_SCHEMAS, temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))), purpose="orchestrator", user_id=user_id, session_id=session_id, db=db, ) if response.tool_calls: # Append assistant message with tool_calls orchestrator_messages.append({ "role": "assistant", "content": response.text or "", "tool_calls": response.tool_calls, }) # Execute each tool call for tc in response.tool_calls: fn = tc.get("function", {}) name = fn.get("name", "") args_str = fn.get("arguments", "{}") try: args = json.loads(args_str) if args_str else {} except json.JSONDecodeError: args = {} yield {"type": "tool_call", "data": {"name": name, "args": args}} result_dict = await handle_tool_call(name, args, ctx) yield {"type": "tool_result", "data": {"name": name, "result": result_dict}} # Append tool result message orchestrator_messages.append({ "role": "tool", "tool_call_id": tc.get("id", ""), "name": name, "content": json.dumps(result_dict, ensure_ascii=False, default=str)[:800], }) await db.commit() continue # Let orchestrator continue with tool results else: # No tool calls - this is the final answer final_assistant_text = response.text break if final_assistant_text is None: # Ran out of iterations - use last text final_assistant_text = response.text or "{}" yield {"type": "status", "data": {"message": "writing_scene"}} # === Parse orchestrator final response === parsed = _parse_orchestrator_response(final_assistant_text) # Apply final state patch (if any) if parsed.get("state_patch"): from app.core.state_validator import apply_patch, validate_state new_state = apply_patch(world.state, parsed["state_patch"]) schema = world.definition.get("world_schema", {}) ok, errors = validate_state(new_state, schema) if ok: world.state = new_state else: log.warning("state_patch_invalid", errors=errors) # Advance time time_advance = parsed.get("time_advance") if time_advance and isinstance(time_advance, dict): new_time = _advance_world_time(world.current_time, time_advance, world) world.current_time = new_time # Save orchestrator plan as hidden message plan_seq = await _next_seq(db, session_id) plan_msg = Message( session_id=session_id, seq=plan_seq, role="assistant", kind="orchestrator_plan", content=final_assistant_text[:2000], payload={ "assessment": parsed.get("assessment", ""), "outcome": parsed.get("outcome", ""), "state_patch": parsed.get("state_patch", {}), "time_advance": time_advance, "tool_calls_made": [tc for tc in final_tool_calls], "scheduled_triggers": ctx.scheduled_triggers, "rag_added": ctx.rag_added, }, is_pinned=False, hidden=True, ) db.add(plan_msg) # === Phase 2: Step writer (narrative scene) === narrative_prompt_parts = [parsed.get("narrative_prompt", "")] # Add RAG context if relevant if parsed.get("outcome"): try: from app.core.rag import get_rag rag = await get_rag(settings_map) rag_results = await rag.search_glossary(world.id, parsed.get("outcome", ""), limit=3, settings_map=settings_map) if rag_results: rag_text = "\n".join(f"- {r.get('name', '?')}: {r.get('description', '')[:120]}" for r in rag_results) narrative_prompt_parts.append(f"Relevant facts from glossary:\n{rag_text}") except Exception as e: log.warning("rag_lookup_failed", error=str(e)) step_messages = await build_step_writer_messages( db=db, world=world, session_id=session_id, outcome=parsed.get("outcome", action_text), narrative_prompt="\n".join(p for p in narrative_prompt_parts if p), ) step_resp = await llm.chat( messages=step_messages, temperature=float(cast_setting("llm.step_temperature", settings_map.get("llm.step_temperature", 0.85))), max_tokens=800, purpose="step", user_id=user_id, session_id=session_id, db=db, ) step_text = step_resp.text step_options: List[str] = parsed.get("next_options", []) or [] # Try to extract structured step from JSON import re as _re json_match = _re.search(r"\{[\s\S]*\}", step_resp.text) if json_match: try: step_data = json.loads(json_match.group(0)) if "narrative" in step_data: step_text = step_data["narrative"] if "options" in step_data and isinstance(step_data["options"], list): step_options = [str(o) for o in step_data["options"]][:5] except json.JSONDecodeError: pass # Save narrative step message step_seq = await _next_seq(db, session_id) step_msg = Message( session_id=session_id, seq=step_seq, role="assistant", kind="narrative_step", content=step_text, payload={ "options": step_options, "outcome": parsed.get("outcome", ""), "world_time": world.current_time, "player_state": world.state.get("player", {}), }, is_pinned=True, hidden=False, ) db.add(step_msg) # === Phase 3: Update plot rails (if any) === rails_update = parsed.get("rails_update") if rails_update and isinstance(rails_update, dict): defn = dict(world.definition) rails = dict(defn.get("plot_rails", {})) if "main_goal" in rails_update: rails["main_goal"] = rails_update["main_goal"] if "new_subgoals" in rails_update: existing = list(rails.get("subgoals", [])) existing.extend(rails_update["new_subgoals"]) rails["subgoals"] = existing if "completed_subgoals" in rails_update: completed = set(rails.get("completed_subgoals", [])) completed.update(rails_update["completed_subgoals"]) rails["completed_subgoals"] = list(completed) # Remove completed from subgoals rails["subgoals"] = [s for s in rails.get("subgoals", []) if s not in completed] defn["plot_rails"] = rails world.definition = defn # Add RAG facts from orchestrator response rag_facts = parsed.get("rag_facts", []) or [] if rag_facts: from app.core.rag import get_rag from app.models import GlossaryEntry rag = await get_rag(settings_map) for f in rag_facts: if not isinstance(f, dict): continue entry = GlossaryEntry( world_id=world.id, session_id=session_id, kind=f.get("kind", "lore"), name=f.get("name", "unknown"), description=f.get("description", ""), payload={}, ) db.add(entry) await db.flush() await rag.upsert_glossary( world_id=world.id, entry_id=entry.id, kind=entry.kind, name=entry.name, description=entry.description, payload={}, settings_map=settings_map, ) # Update session last_played_at session.last_played_at = datetime.now(timezone.utc) await db.commit() await db.refresh(step_msg) # Check for triggers that should fire immediately (fire_at <= current_time) fired_now = await _check_due_triggers(db, session_id, world.current_time or "") yield { "type": "step_complete", "data": { "message_id": str(step_msg.id), "seq": step_msg.seq, "narrative": step_text, "options": step_options, "state": world.state, "world_time": world.current_time, "player_state": world.state.get("player", {}), "fired_triggers": fired_now, }, } yield {"type": "done", "data": {}} def _parse_orchestrator_response(text: str) -> Dict[str, Any]: """Extract the JSON object from the orchestrator's final response.""" if not text: return {} import re as _re m = _re.search(r"\{[\s\S]*\}", text) if not m: return {"outcome": text, "narrative_prompt": text, "next_options": []} try: data = json.loads(m.group(0)) return data except json.JSONDecodeError: return {"outcome": text, "narrative_prompt": text, "next_options": []} async def _next_seq(db: AsyncSession, session_id: uuid.UUID) -> int: result = await db.execute( select(Message.seq).where(Message.session_id == session_id).order_by(Message.seq.desc()).limit(1) ) row = result.first() return (row[0] + 1) if row else 1 def _advance_world_time(current_time: Optional[str], advance: Dict[str, int], world: World) -> str: """Advance world time string. Supports format like 'day_N_hour_H' or ISO datetime.""" if not current_time: # Try to use the world_state's world_time field wt = (world.state or {}).get("world_time", {}) if wt: day = int(wt.get("day", 1)) hour = int(wt.get("hour", 8)) else: day, hour = 1, 8 else: # Parse 'day_N_hour_H' or fall back to numbers import re as _re m = _re.match(r"day_(\d+)_hour_(\d+)", current_time) if m: day, hour = int(m.group(1)), int(m.group(2)) else: # Try ISO format try: from datetime import datetime as _dt, timedelta as _td dt = _dt.fromisoformat(current_time) dt = dt + _td( days=int(advance.get("days", 0)), hours=int(advance.get("hours", 0)), minutes=int(advance.get("minutes", 0)), ) return dt.isoformat() except Exception: day, hour = 1, 8 total_minutes = day * 24 * 60 + hour * 60 total_minutes += int(advance.get("days", 0)) * 24 * 60 total_minutes += int(advance.get("hours", 0)) * 60 total_minutes += int(advance.get("minutes", 0)) new_day = total_minutes // (24 * 60) new_hour = (total_minutes % (24 * 60)) // 60 new_time = f"day_{new_day}_hour_{new_hour}" # Also update world_time in state if present if world.state and "world_time" in world.state: world.state["world_time"] = { **world.state["world_time"], "day": new_day, "hour": new_hour, } return new_time async def _check_due_triggers(db: AsyncSession, session_id: uuid.UUID, current_time: str) -> List[Dict[str, Any]]: """Mark triggers as fired if their fire_at <= current_time. Returns list of fired triggers.""" import re as _re def _parse(t: str): m = _re.match(r"day_(\d+)_hour_(\d+)", t or "") if m: return int(m.group(1)) * 24 * 60 + int(m.group(2)) * 60 try: from datetime import datetime as _dt dt = _dt.fromisoformat(t) return int(dt.timestamp() // 60) except Exception: return 0 cur = _parse(current_time) result = await db.execute( select(DeferredTrigger).where( DeferredTrigger.session_id == session_id, DeferredTrigger.fired.is_(False), ) ) triggers = list(result.scalars().all()) fired: List[Dict[str, Any]] = [] for t in triggers: if _parse(t.fire_at) <= cur: t.fired = True fired.append({ "id": str(t.id), "fire_at": t.fire_at, "description": t.description, "payload": t.payload, }) if fired: await db.commit() return fired