This commit is contained in:
Mikan
2026-06-19 16:31:45 +03:00
parent d0d1f003ae
commit 5a78def096
21 changed files with 1250 additions and 548 deletions

View File

@@ -1,4 +1,15 @@
"""Game orchestrator: runs the multi-step LLM tool-calling loop and produces a narrative step."""
"""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
@@ -11,15 +22,20 @@ 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, ToolContext, handle_tool_call
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 DeferredTrigger, Message, Session, World
from app.prompts.templates import get_prompt
from app.models import Message, Session, World
log = get_logger("orchestrator")
@@ -88,19 +104,22 @@ async def run_iteration(
)
return resp.text
ctx = ToolContext(db=db, world=world, session_id=session_id, user_id=user_id, subagent_runner=_subagent, settings_map=settings_map)
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]] = []
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}"}}
@@ -114,48 +133,112 @@ async def run_iteration(
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
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
if final_assistant_text is None:
# Ran out of iterations - use last text
final_assistant_text = response.text or "{}"
# 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"}}
# === 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
@@ -170,7 +253,7 @@ async def run_iteration(
# 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)
new_time, _total, _delta = advance_world_time(world.current_time, time_advance, world)
world.current_time = new_time
# Save orchestrator plan as hidden message
@@ -180,31 +263,40 @@ async def run_iteration(
seq=plan_seq,
role="assistant",
kind="orchestrator_plan",
content=final_assistant_text[:2000],
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": [tc for tc in final_tool_calls],
"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) ===
# === 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)
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)
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))
@@ -219,28 +311,46 @@ async def run_iteration(
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=800,
max_tokens=1200,
purpose="step",
user_id=user_id,
session_id=session_id,
db=db,
)
step_text = step_resp.text
# 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 []
# 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
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)
@@ -276,7 +386,6 @@ async def run_iteration(
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
@@ -316,8 +425,27 @@ async def run_iteration(
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 "")
# 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",
@@ -335,21 +463,6 @@ async def run_iteration(
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)
@@ -358,86 +471,8 @@ async def _next_seq(db: AsyncSession, session_id: uuid.UUID) -> int:
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
def _safe_parse_json(s: str) -> Any:
try:
return json.loads(s) if s else {}
except Exception:
return s