479 lines
18 KiB
Python
479 lines
18 KiB
Python
"""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
|