"""Game orchestrator: runs the multi-step LLM tool-calling loop and produces a narrative step. Design (v2 — tool-calling-first): - The orchestrator LLM is given a set of game tools (dice_roll, update_state, rag_query, rag_add, schedule_trigger, advance_time, run_subagent) PLUS a `submit_plan` tool. The LLM calls game tools to execute its plan, then calls `submit_plan` to terminate the loop with structured data. - The step-writer LLM is given only a `submit_scene` tool. It calls this to return the narrative + options; its text response is ignored. - This replaces the old "return JSON in your text response" pattern which conflicted with tool use and caused the model to dump raw JSON into chat. """ 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.core.triggers import advance_world_time, fire_due_triggers from app.engine.context import ( build_orchestrator_messages, build_step_writer_messages, build_subagent_messages, ) from app.engine.tools.tools import ( ALL_TOOL_SCHEMAS, STEP_WRITER_TOOL_SCHEMAS, ToolContext, handle_tool_call, ) from app.logging_setup import get_logger from app.models import Message, Session, World 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) max_iters = 5 parsed: Dict[str, Any] = {} plan_tool_calls_log: List[Dict[str, Any]] = [] orchestrator_text_log: str = "" 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 not response.tool_calls: # No tool calls — model gave up or errored. Treat its text as the # outcome directly so the player still sees SOMETHING. log.warning("orchestrator_no_tool_calls", iteration=i, text_len=len(response.text or "")) orchestrator_text_log = response.text or "" parsed = { "assessment": "(no plan submitted)", "outcome": response.text or "", "narrative_prompt": "", "next_options": [], "state_patch": {}, "time_advance": None, "rag_facts": [], "rails_update": None, } break # Append assistant message with tool_calls orchestrator_messages.append({ "role": "assistant", "content": response.text or "", "tool_calls": response.tool_calls, }) # Check for submit_plan — if present, extract plan and break submit_plan_call = None for tc in response.tool_calls: if tc.get("function", {}).get("name") == "submit_plan": submit_plan_call = tc break if submit_plan_call: # Extract plan from the submit_plan tool call args_str = submit_plan_call.get("function", {}).get("arguments", "{}") try: parsed = json.loads(args_str) if args_str else {} except json.JSONDecodeError: log.warning("submit_plan_invalid_json", args=args_str[:200]) parsed = {} # Make sure required keys exist parsed.setdefault("assessment", "") parsed.setdefault("outcome", "") parsed.setdefault("narrative_prompt", "") parsed.setdefault("next_options", []) parsed.setdefault("state_patch", {}) parsed.setdefault("time_advance", None) parsed.setdefault("rag_facts", []) parsed.setdefault("rails_update", None) # Acknowledge the tool call so the model's history is consistent orchestrator_messages.append({ "role": "tool", "tool_call_id": submit_plan_call.get("id", ""), "name": "submit_plan", "content": json.dumps({"ok": True}), }) # Log OTHER tool calls made this iteration (for debugging) for tc in response.tool_calls: fn = tc.get("function", {}) if fn.get("name") != "submit_plan": plan_tool_calls_log.append({ "name": fn.get("name"), "args": _safe_parse_json(fn.get("arguments", "{}")), }) break # Otherwise: execute all tool calls and continue 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}} try: result_dict = await handle_tool_call(name, args, ctx) except Exception as e: result_dict = {"error": f"{type(e).__name__}: {e}"} log.error("tool_call_failed", name=name, error=str(e)) yield {"type": "tool_result", "data": {"name": name, "result": result_dict}} plan_tool_calls_log.append({"name": name, "args": args, "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() else: # Ran out of iterations without submit_plan — use a minimal fallback. log.warning("orchestrator_exhausted_iterations") parsed = parsed or { "assessment": "(iteration limit reached)", "outcome": orchestrator_text_log or action_text, "narrative_prompt": "", "next_options": [], "state_patch": {}, "time_advance": None, "rag_facts": [], "rails_update": None, } yield {"type": "status", "data": {"message": "writing_scene"}} # 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, _total, _delta = 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=(parsed.get("assessment", "") + " | " + parsed.get("outcome", ""))[:2000], payload={ "assessment": parsed.get("assessment", ""), "outcome": parsed.get("outcome", ""), "state_patch": parsed.get("state_patch", {}), "time_advance": time_advance, "tool_calls_made": plan_tool_calls_log, "scheduled_triggers": ctx.scheduled_triggers, "rag_added": ctx.rag_added, "narrative_prompt": parsed.get("narrative_prompt", ""), "next_options": parsed.get("next_options", []), }, is_pinned=False, hidden=True, ) db.add(plan_msg) # === Phase 2: Step writer (narrative scene) — uses submit_scene tool === 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, tools=STEP_WRITER_TOOL_SCHEMAS, temperature=float(cast_setting("llm.step_temperature", settings_map.get("llm.step_temperature", 0.85))), max_tokens=1200, purpose="step", user_id=user_id, session_id=session_id, db=db, ) # Extract scene from submit_scene tool call (if present); fall back to text. step_text = step_resp.text or "" step_options: List[str] = parsed.get("next_options", []) or [] for tc in (step_resp.tool_calls or []): if tc.get("function", {}).get("name") == "submit_scene": args_str = tc.get("function", {}).get("arguments", "{}") try: scene_data = json.loads(args_str) if args_str else {} if scene_data.get("narrative"): step_text = scene_data["narrative"] if scene_data.get("options") and isinstance(scene_data["options"], list): step_options = [str(o) for o in scene_data["options"]][:5] except json.JSONDecodeError: log.warning("submit_scene_invalid_json", args=args_str[:200]) break else: # No submit_scene call — try to extract JSON from text as a last resort. import re as _re json_match = _re.search(r"\{[\s\S]*\}", step_resp.text or "") 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 # If still no narrative, use the orchestrator's outcome as fallback. if not step_text.strip(): step_text = parsed.get("outcome", action_text) # 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) 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 now (fire_at <= current world time). # Triggers fire on in-game time changes, not real-time polling — see # app.core.triggers. We do this AFTER committing the narrative step so the # player sees the main scene first, then any trigger consequences. triggers_enabled = bool(cast_setting( "triggers.enabled", settings_map.get("triggers.enabled", True), )) fired_now: List[Dict[str, Any]] = [] if triggers_enabled: try: await db.refresh(world) fired_now = await fire_due_triggers( db=db, session_id=session_id, world=world, settings_map=settings_map, user_id=user_id, ) except Exception as e: log.warning("trigger_fire_failed_in_iteration", error=f"{type(e).__name__}: {e}") 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": {}} 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 _safe_parse_json(s: str) -> Any: try: return json.loads(s) if s else {} except Exception: return s