444 lines
16 KiB
Python
444 lines
16 KiB
Python
"""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
|