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
|