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

@@ -58,7 +58,10 @@ async def build_orchestrator_messages(
recent = visible_msgs[-recent_n:] if visible_msgs else []
# Build orchestrator system prompt with current state
# Build orchestrator system prompt with current state.
# NOTE: prompts are always English (system content convention). The LLM
# produces player-facing text in world.language when relevant (the
# step-writer prompt interpolates world_language explicitly).
defn = world.definition or {}
system_prompt_template = get_prompt("orchestrator", world.language)
player_state = world.state.get("player", {}) if world.state else {}
@@ -69,14 +72,14 @@ async def build_orchestrator_messages(
current_time=world.current_time or "",
player_state=json.dumps(player_state, ensure_ascii=False)[:600],
plot_rails=json.dumps(defn.get("plot_rails", {}), ensure_ascii=False)[:400],
summary=summary_text or "(нет сводки)",
summary=summary_text or "(no summary yet)",
)
messages: List[Dict[str, Any]] = [{"role": "system", "content": system_prompt}]
# Add summary as a system note if present
if summary_text:
messages.append({"role": "system", "content": f"Сводка прошлого:\n{summary_text}"})
messages.append({"role": "system", "content": f"Past summary:\n{summary_text}"})
# Add recent visible messages
for m in recent:
@@ -86,7 +89,7 @@ async def build_orchestrator_messages(
messages.append({"role": "assistant", "content": m.content})
# The current action
messages.append({"role": "user", "content": f'Действие игрока: "{action_text}"'})
messages.append({"role": "user", "content": f'Player action: "{action_text}"'})
return messages, settings_map
@@ -100,7 +103,11 @@ async def _maybe_compress(
world: World,
settings_map: Dict[str, Any],
) -> None:
"""If history exceeds threshold, summarize older messages into a single summary message."""
"""If history exceeds threshold, summarize older messages into a single summary message.
Uses the `submit_summary` tool (tool-calling-first design) to get structured
output from the summarizer LLM.
"""
visible = [m for m in all_msgs if not m.hidden]
if len(visible) <= recent_n + summary_n:
return
@@ -110,45 +117,61 @@ async def _maybe_compress(
if not to_summarize:
return
# Build summarization input
# Build summarization input (English labels — system content convention)
summary_input_lines = []
for m in to_summarize:
prefix = {
"player_action": "Игрок",
"narrative_step": "Сцена",
"summary": "Сводка",
"player_action": "Player",
"narrative_step": "Scene",
"summary": "Summary",
"orchestrator_plan": "GM",
"technical_offscreen": "За кадром",
"technical_offscreen": "Offscreen",
}.get(m.kind, m.kind)
summary_input_lines.append(f"{prefix}: {m.content[:300]}")
summary_input = "\n\n".join(summary_input_lines)
llm = LlmClient(settings_map)
system_prompt = get_prompt("summarizer", world.language)
from app.engine.tools.tools import SUMMARIZER_TOOL_SCHEMAS
response = await llm.chat(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": summary_input[:4000]},
],
tools=SUMMARIZER_TOOL_SCHEMAS,
temperature=float(cast_setting("llm.summary_temperature", settings_map.get("llm.summary_temperature", 0.3))),
max_tokens=300,
max_tokens=400,
purpose="summary",
session_id=session_id,
db=db,
)
# Parse summary response
summary_text = response.text
# Extract from submit_summary tool call; fall back to text parse.
summary_text = response.text or ""
facts: List[Dict[str, Any]] = []
import re as _re
json_match = _re.search(r"\{[\s\S]*\}", response.text)
if json_match:
try:
data = json.loads(json_match.group(0))
summary_text = data.get("summary", response.text)
facts = data.get("facts", [])
except json.JSONDecodeError:
pass
extracted = False
for tc in (response.tool_calls or []):
if tc.get("function", {}).get("name") == "submit_summary":
args_str = tc.get("function", {}).get("arguments", "{}")
try:
data = json.loads(args_str) if args_str else {}
summary_text = data.get("summary", response.text or "")
facts = data.get("facts", []) or []
extracted = True
except json.JSONDecodeError:
pass
break
if not extracted:
# Fallback: extract JSON from text response (older models).
import re as _re
json_match = _re.search(r"\{[\s\S]*\}", response.text or "")
if json_match:
try:
data = json.loads(json_match.group(0))
summary_text = data.get("summary", response.text or "")
facts = data.get("facts", []) or []
except json.JSONDecodeError:
pass
# Create summary message
next_seq = (max((m.seq for m in all_msgs), default=0)) + 1
@@ -207,7 +230,11 @@ async def build_step_writer_messages(
outcome: str,
narrative_prompt: str,
) -> List[Dict[str, Any]]:
"""Build messages for the step writer LLM call."""
"""Build messages for the step writer LLM call.
The step-writer prompt is in English (system content convention) but
instructs the LLM to produce the narrative in world.language.
"""
defn = world.definition or {}
player_state = world.state.get("player", {}) if world.state else {}
system_prompt = get_prompt("step_writer", world.language).format(
@@ -216,6 +243,7 @@ async def build_step_writer_messages(
player_state=json.dumps(player_state, ensure_ascii=False)[:400],
outcome=outcome,
narrative_prompt=narrative_prompt[:600],
world_language=world.language or "en",
)
return [{"role": "system", "content": system_prompt}]

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

View File

@@ -91,13 +91,29 @@ RAG_ADD_SCHEMA = build_tool_schema(
SCHEDULE_TRIGGER_SCHEMA = build_tool_schema(
name="schedule_trigger",
description="Schedule a deferred event tied to world time. When world time reaches fire_at, the system will fire it.",
description=(
"Schedule a deferred event tied to in-world time. When the world's "
"internal clock reaches fire_at, the engine fires the event (calls the "
"LLM with the description to produce a narrative beat and optional "
"state patch). fire_at must use the same format as world.current_time "
"('day_N_hour_H' or 'day_N_hour_H_min_M'). The world's calendar "
"(hours_per_day, minutes_per_hour) is honored when comparing times."
),
params={
"type": "object",
"properties": {
"fire_at": {"type": "string", "description": "World time string in same format as world.current_time, e.g. 'day_3_hour_14'"},
"description": {"type": "string", "description": "What should happen"},
"payload": {"type": "object", "description": "Arbitrary structured payload for the trigger runner"},
"fire_at": {
"type": "string",
"description": "In-world time when the trigger fires, e.g. 'day_3_hour_14' or 'day_3_hour_14_min_30'.",
},
"description": {
"type": "string",
"description": "What should happen when the trigger fires. Be specific — this is fed to the LLM at fire time.",
},
"payload": {
"type": "object",
"description": "Optional structured payload (e.g. who, conditions, parameters).",
},
},
"required": ["fire_at", "description"],
},
@@ -106,14 +122,22 @@ SCHEDULE_TRIGGER_SCHEMA = build_tool_schema(
ADVANCE_TIME_SCHEMA = build_tool_schema(
name="advance_time",
description="Advance the world's internal clock by days/hours/minutes. Use this when the action takes time.",
description=(
"Advance the world's internal clock by days / hours / minutes. Use "
"this when the player's action takes measurable in-world time (travel, "
"sleep, crafting, long rest). The world's calendar (hours_per_day, "
"minutes_per_hour) is honored. After time advances, any scheduled "
"triggers whose fire_at is now <= the new time will fire "
"automatically — so this is also how you 'run out the clock' on a "
"scheduled event."
),
params={
"type": "object",
"properties": {
"days": {"type": "integer", "default": 0},
"hours": {"type": "integer", "default": 0},
"minutes": {"type": "integer", "default": 0},
"reason": {"type": "string", "description": "Why time advances"},
"reason": {"type": "string", "description": "Why time advances (logged for debugging)."},
},
},
)
@@ -133,6 +157,219 @@ RUN_SUBAGENT_SCHEMA = build_tool_schema(
)
# === Submission tools (how the LLM returns structured results) ===
# These replace the old "return JSON in your text response" pattern, which
# conflicted with tool use and caused the model to dump raw JSON into chat.
SUBMIT_PLAN_SCHEMA = build_tool_schema(
name="submit_plan",
description=(
"Submit the orchestrator's final plan for this iteration. This MUST be "
"the last tool you call. After you call it, the iteration ends and the "
"step-writer takes over to produce the cinematic scene."
),
params={
"type": "object",
"properties": {
"assessment": {
"type": "string",
"description": "Brief assessment of the player's action (1-2 sentences, English).",
},
"outcome": {
"type": "string",
"description": "What concretely happened (1-3 sentences, English). Fed to the step-writer as the raw outcome.",
},
"state_patch": {
"type": "object",
"description": "JSON-patch for world state. Keys: set, unset, append, increment, remove. Empty object if no change.",
"properties": {
"set": {"type": "object"},
"unset": {"type": "array", "items": {"type": "string"}},
"append": {"type": "object"},
"increment": {"type": "object"},
"remove": {"type": "object"},
},
},
"time_advance": {
"type": "object",
"description": "How much in-world time advances. null/omitted if no time passes.",
"properties": {
"days": {"type": "integer", "default": 0},
"hours": {"type": "integer", "default": 0},
"minutes": {"type": "integer", "default": 0},
},
},
"narrative_prompt": {
"type": "string",
"description": "Facts the step-writer should know to write the scene (English). Max ~100 words.",
},
"next_options": {
"type": "array",
"items": {"type": "string"},
"description": "3 suggested next actions for the player (short, 5-12 words each).",
},
"rails_update": {
"type": "object",
"description": "Optional update to plot rails. Omit if no change.",
"properties": {
"main_goal": {"type": "string"},
"new_subgoals": {"type": "array", "items": {"type": "string"}},
"completed_subgoals": {"type": "array", "items": {"type": "string"}},
},
},
"rag_facts": {
"type": "array",
"description": "New persistent facts to add to the glossary. Empty array if none.",
"items": {
"type": "object",
"properties": {
"kind": {"type": "string", "enum": ["npc", "location", "item", "lore", "event"]},
"name": {"type": "string"},
"description": {"type": "string"},
},
"required": ["kind", "name", "description"],
},
},
},
"required": ["assessment", "outcome", "narrative_prompt", "next_options"],
},
)
SUBMIT_SCENE_SCHEMA = build_tool_schema(
name="submit_scene",
description=(
"Submit the narrative scene for this step. This is the ONLY way to "
"return the scene — your text response is ignored. The narrative "
"should be 200-400 words, cinematic, second-person ('You...'), in the "
"world's player-facing language."
),
params={
"type": "object",
"properties": {
"narrative": {
"type": "string",
"description": "200-400 words of cinematic prose describing the scene. Second-person ('You...').",
},
"options": {
"type": "array",
"items": {"type": "string"},
"description": "Exactly 3 short (5-12 words) options for the player's next action.",
},
},
"required": ["narrative", "options"],
},
)
SUBMIT_WORLD_DEFINITION_SCHEMA = build_tool_schema(
name="submit_world_definition",
description=(
"Submit a proposed world definition. Call this when you have enough "
"information to build the world. Your text response will be shown to "
"the player as your conversational reply (use it to summarize the "
"proposed world in 2-4 sentences)."
),
params={
"type": "object",
"properties": {
"setting_description": {"type": "string", "description": "Expanded setting, 1-2 paragraphs."},
"rules": {
"type": "object",
"description": "Object with keys like stats, combat, magic, time, inventory, death (whichever apply).",
},
"world_schema": {
"type": "object",
"description": "JSON Schema describing the shape of the world state.",
},
"plot_rails": {
"type": "object",
"description": "{main_goal, subgoals, hooks}.",
"properties": {
"main_goal": {"type": "string"},
"subgoals": {"type": "array", "items": {"type": "string"}},
"hooks": {"type": "array", "items": {"type": "string"}},
},
},
"initial_state": {
"type": "object",
"description": "Initial world state matching world_schema.",
},
"initial_time": {
"type": "string",
"description": "World time string e.g. 'day_1_hour_8'.",
},
"calendar": {
"type": "object",
"description": "Optional. Custom calendar. Include only if non-standard.",
"properties": {
"hours_per_day": {"type": "integer"},
"minutes_per_hour": {"type": "integer"},
"days_per_week": {"type": "integer"},
},
},
"is_final": {
"type": "boolean",
"description": "True ONLY when the player has explicitly accepted the world.",
},
},
"required": ["setting_description", "rules", "world_schema", "initial_state", "initial_time"],
},
)
SUBMIT_SUMMARY_SCHEMA = build_tool_schema(
name="submit_summary",
description="Submit the compressed summary of older session messages.",
params={
"type": "object",
"properties": {
"summary": {"type": "string", "description": "3-6 sentences, max 150 words."},
"facts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"kind": {"type": "string", "enum": ["npc", "location", "item", "lore", "event"]},
"name": {"type": "string"},
"description": {"type": "string"},
},
"required": ["kind", "name", "description"],
},
},
},
"required": ["summary", "facts"],
},
)
SUBMIT_TRIGGER_RESULT_SCHEMA = build_tool_schema(
name="submit_trigger_result",
description="Submit the result of firing a deferred trigger.",
params={
"type": "object",
"properties": {
"outcome": {"type": "string", "description": "1-2 sentences, English. For logs."},
"state_patch": {
"type": "object",
"description": "JSON-patch for world state. Empty object if no change.",
"properties": {
"set": {"type": "object"},
"unset": {"type": "array", "items": {"type": "string"}},
"append": {"type": "object"},
"increment": {"type": "object"},
"remove": {"type": "object"},
},
},
"narrative": {"type": "string", "description": "1-paragraph scene description for the player, in world.language. Empty string if offscreen."},
"should_notify_player": {"type": "boolean", "description": "True if the player should see the narrative."},
},
"required": ["outcome", "narrative", "should_notify_player"],
},
)
# Tools available to the orchestrator (game-loop tools + submit_plan)
ALL_TOOL_SCHEMAS = [
DICE_ROLL_SCHEMA,
UPDATE_STATE_SCHEMA,
@@ -141,8 +378,21 @@ ALL_TOOL_SCHEMAS = [
SCHEDULE_TRIGGER_SCHEMA,
ADVANCE_TIME_SCHEMA,
RUN_SUBAGENT_SCHEMA,
SUBMIT_PLAN_SCHEMA,
]
# Tools for the step writer (only submit_scene)
STEP_WRITER_TOOL_SCHEMAS = [SUBMIT_SCENE_SCHEMA]
# Tools for the world builder (only submit_world_definition)
WORLD_BUILDER_TOOL_SCHEMAS = [SUBMIT_WORLD_DEFINITION_SCHEMA]
# Tools for the summarizer
SUMMARIZER_TOOL_SCHEMAS = [SUBMIT_SUMMARY_SCHEMA]
# Tools for the trigger runner
TRIGGER_RUNNER_TOOL_SCHEMAS = [SUBMIT_TRIGGER_RESULT_SCHEMA]
# === Tool handlers ===

View File

@@ -1,4 +1,13 @@
"""World builder: multi-turn dialogue to produce a finalized WorldDefinition."""
"""World builder: multi-turn dialogue to produce a finalized WorldDefinition.
Design (v2 — tool-calling-first):
The world-builder LLM is given a single tool, `submit_world_definition`,
which it calls when it has enough information to propose a world. The LLM's
text response is the conversational reply shown to the player (in
world.language). This replaces the old "return JSON in your text response"
pattern which conflicted with tool use and caused raw JSON to leak into
the chat.
"""
from __future__ import annotations
import json
@@ -14,6 +23,7 @@ from app.logging_setup import get_logger
from app.models import Preset, User, World
from app.prompts.templates import get_prompt
from app.schemas import WorldBuilderReply, WorldDefinition
from app.engine.tools.tools import WORLD_BUILDER_TOOL_SCHEMAS
log = get_logger("world_builder")
@@ -36,7 +46,8 @@ async def start_world_builder(
) -> WorldBuilderReply:
"""Kick off a new world-builder dialogue. Returns the first AI reply."""
session_id = uuid.uuid4()
llm = await LlmClient.from_db(db)
settings_map = await get_all_settings(db)
llm = LlmClient(settings_map)
preset_payload: Optional[Dict[str, Any]] = None
if preset_id:
@@ -55,7 +66,9 @@ async def start_world_builder(
language=language,
)
system_prompt = get_prompt("world_builder", language)
# System prompt is English (system content convention). The LLM is told to
# produce player-facing text in `language` (interpolated as world_language).
system_prompt = get_prompt("world_builder", language).format(world_language=language)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_brief},
@@ -63,20 +76,27 @@ async def start_world_builder(
response = await llm.chat(
messages=messages,
temperature=0.7,
tools=WORLD_BUILDER_TOOL_SCHEMAS,
temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))),
purpose="world_builder",
user_id=user.id,
db=db,
)
ai_text, proposed, is_final, followups = _parse_world_builder_response(response.text)
ai_text, proposed, is_final = _extract_world_definition(response.text, response.tool_calls)
_DIALOGUES[session_id] = {
"user_id": user.id,
"world_name": world_name,
"language": language,
"preset_id": preset_id,
"messages": messages + [{"role": "assistant", "content": response.text}],
"messages": messages + [
{
"role": "assistant",
"content": response.text or "",
"tool_calls": response.tool_calls or None,
},
],
"turn": 1,
"last_proposed": proposed.model_dump() if proposed else None,
}
@@ -87,7 +107,7 @@ async def start_world_builder(
ai_message=ai_text,
proposed_definition=proposed,
is_final=is_final,
followup_questions=followups,
followup_questions=[],
)
@@ -104,20 +124,26 @@ async def continue_world_builder(
if dialogue["user_id"] != user.id:
raise ValueError("forbidden")
llm = await LlmClient.from_db(db)
settings_map = await get_all_settings(db)
llm = LlmClient(settings_map)
dialogue["messages"].append({"role": "user", "content": user_message})
dialogue["turn"] += 1
response = await llm.chat(
messages=dialogue["messages"],
temperature=0.7,
tools=WORLD_BUILDER_TOOL_SCHEMAS,
temperature=float(cast_setting("llm.temperature", settings_map.get("llm.temperature", 0.7))),
purpose="world_builder",
user_id=user.id,
db=db,
)
dialogue["messages"].append({"role": "assistant", "content": response.text})
dialogue["messages"].append({
"role": "assistant",
"content": response.text or "",
"tool_calls": response.tool_calls or None,
})
ai_text, proposed, is_final, followups = _parse_world_builder_response(response.text)
ai_text, proposed, is_final = _extract_world_definition(response.text, response.tool_calls)
if proposed:
dialogue["last_proposed"] = proposed.model_dump()
@@ -127,7 +153,7 @@ async def continue_world_builder(
ai_message=ai_text,
proposed_definition=proposed,
is_final=is_final,
followup_questions=followups,
followup_questions=[],
)
@@ -151,7 +177,7 @@ async def commit_world_builder(
world = World(
owner_id=user.id,
name=name or dialogue.get("world_name") or "New World",
language=dialogue.get("language", "ru"),
language=dialogue.get("language", "en"),
definition=definition.model_dump(),
state=definition.initial_state or {},
current_time=definition.initial_time,
@@ -176,7 +202,8 @@ def _build_user_brief(
preset_payload: Optional[Dict[str, Any]],
language: str,
) -> str:
parts = [f"=== WORLD BRIEF ({language.upper()}) ==="]
parts = [f"=== WORLD BRIEF ==="]
parts.append(f"Player-facing language: {language}")
parts.append(f"Name: {world_name}")
if preset_payload:
parts.append(f"Preset seed: {preset_payload.get('world_seed_prompt', '')}")
@@ -189,57 +216,78 @@ def _build_user_brief(
parts.append(f"Rules: {rules_brief}")
if notes:
parts.append(f"Notes: {notes}")
parts.append("\nPlease ask 2-4 clarifying questions OR build a proposed world definition.")
parts.append("\nAsk 2-4 clarifying questions OR call submit_world_definition with a proposed world.")
return "\n".join(parts)
def _parse_world_builder_response(text: str) -> tuple[str, Optional[WorldDefinition], bool, List[str]]:
"""Extract AI message text, proposed definition (if any), is_final flag, and followup questions."""
proposed = None
def _extract_world_definition(
text: str,
tool_calls: Optional[List[Dict[str, Any]]],
) -> tuple[str, Optional[WorldDefinition], bool]:
"""Extract AI message text, proposed definition (if any), and is_final flag.
Looks for a `submit_world_definition` tool call first. Falls back to
JSON-in-text parse for older models that don't honor the tool.
"""
proposed: Optional[WorldDefinition] = None
is_final = False
followups: List[str] = []
ai_text = text or ""
# Try to find a JSON block in the response
json_str = _extract_json_block(text)
if json_str:
try:
data = json.loads(json_str)
if isinstance(data, dict):
if "proposed_definition" in data:
pd = data["proposed_definition"]
if isinstance(pd, dict):
try:
proposed = WorldDefinition.model_validate(pd)
except Exception:
proposed = None
if "is_final" in data:
is_final = bool(data["is_final"])
if "followup_questions" in data and isinstance(data["followup_questions"], list):
followups = [str(q) for q in data["followup_questions"]]
if "ai_message" in data and isinstance(data["ai_message"], str):
text = data["ai_message"]
except json.JSONDecodeError:
pass
# 1) Prefer the submit_world_definition tool call (the proper way).
if tool_calls:
for tc in tool_calls:
if tc.get("function", {}).get("name") == "submit_world_definition":
args_str = tc.get("function", {}).get("arguments", "{}")
try:
data = json.loads(args_str) if args_str else {}
except json.JSONDecodeError:
data = {}
proposed = _try_build_definition(data)
is_final = bool(data.get("is_final", False))
break
# Heuristic: if response contains "готово" / "ready" and a proposed_definition — mark final
if proposed is not None:
low = text.lower()
if any(kw in low for kw in ["готово", "world is ready", "world_ready", "ready to commit"]):
# 2) Fallback: parse a JSON block from the text (older models).
if proposed is None:
json_str = _extract_json_block(text)
if json_str:
try:
data = json.loads(json_str)
if isinstance(data, dict):
if "proposed_definition" in data and isinstance(data["proposed_definition"], dict):
proposed = _try_build_definition(data["proposed_definition"])
if "is_final" in data:
is_final = bool(data["is_final"])
if "ai_message" in data and isinstance(data["ai_message"], str):
ai_text = data["ai_message"]
except json.JSONDecodeError:
pass
# 3) Heuristic: if a definition was proposed and the text mentions "ready",
# mark as final.
if proposed is not None and not is_final:
low = ai_text.lower()
if any(kw in low for kw in ["world is ready", "world_ready", "ready to commit", "мир готов"]):
is_final = True
return text, proposed, is_final, followups
return ai_text, proposed, is_final
def _try_build_definition(data: Dict[str, Any]) -> Optional[WorldDefinition]:
try:
return WorldDefinition.model_validate(data)
except Exception as e:
log.warning("world_definition_invalid", error=str(e))
return None
def _extract_json_block(text: str) -> Optional[str]:
"""Find the first JSON object/array block in text."""
"""Find the first JSON object/array block in text (fallback path only)."""
if not text:
return None
# Try fenced ```json ... ```
import re
m = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text)
if m:
return m.group(1)
# Try raw {...} (greedy from first { to matching })
start = text.find("{")
if start == -1:
return None