splinter-keep/tools/engine.py

1261 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
engine.py — The Chaos Game Engine
Owns the LLM interaction, prompt assembly, response parsing, and game state
persistence. The TUI (run.py) calls this module — they do not depend on each
other, only on the shared session/ file layout.
"""
from __future__ import annotations
import json
import re
import sys
from dataclasses import dataclass, field
from datetime import date, datetime
from pathlib import Path
from string import Template
from typing import Iterator, Optional
# ── Paths ──────────────────────────────────────────────────────────────────
BASE_DIR = Path(__file__).resolve().parent.parent
SESSION_DIR = BASE_DIR / 'session'
CONFIG_PATH = SESSION_DIR / 'config.json'
CHAR_PATH = SESSION_DIR / 'character.md'
WORLD_PATH = SESSION_DIR / 'world.md'
BOOK_PATH = SESSION_DIR / 'book.md'
JOURNAL_PATH = SESSION_DIR / 'journal.md'
AMBIENCE_PATH = SESSION_DIR / 'ambience.md'
LOG_DIR = SESSION_DIR / 'log'
LLM_LOG_PATH = SESSION_DIR / 'llm.log'
AMBIENCE_OPTIONS_PATH = SESSION_DIR / "ambience_options.md"
AUDIO_DIR = SESSION_DIR / "audio"
TODAY = date.today().isoformat()
# ── Structured output ──────────────────────────────────────────────────────
@dataclass
class GenerationResult:
"""Legacy result — kept for backward compat with CLI main()."""
narrative: str
choices: list[str] = field(default_factory=list)
log_entry: Optional[str] = None
ambience: Optional[str] = None
character_updates: Optional[str] = None
world_updates: Optional[str] = None
journal_add: list[str] = field(default_factory=list)
journal_done: list[str] = field(default_factory=list)
error: Optional[str] = None
@dataclass
class TurnResult:
"""Output of a complete turn via finalize_turn tool."""
book_log: str = ""
user_prompt: str = ""
ambience: Optional[str] = None
log_entry: Optional[str] = None
error: Optional[str] = None
debug_info: str = ""
# ── DM System Prompt Template ──────────────────────────────────────────────
SYSTEM_PROMPT = Template("""You are the Dungeon Master for **The Chaos**, a solo card-based rules-light fantasy TTRPG. Your job is to narrate an immersive, responsive story for one player character.
## Tone & Style
- Write in **second person** ("You", "Dillion") — the player is Dillion.
- Use vivid sensory descriptions — sight, sound, smell, touch.
- Keep narration cinematic. No monologues.
- Use **bold** for emphasis, *italic* for thoughts/sounds.
- NPC dialogue goes in **"quotes with bold names."**
- Meta-information stays out of the narrative, don't put it in the book. Use prompt for that.
- Never present predefined choices — the player decides freely what to do.
- **Stick to the player's intent.** Don't invent your own actions for the player unless forced by environment or circumstance (e.g., they trigger a trap, an NPC reacts, etc.).
- **Enforce rules.** Player's actions must be physically possible given the current situation in the story (e.g. if they don't have a dagger with them, they can't use it).
## Game Rules (Quick Reference)
### Core Dice
- **Odds**: 1d6, 4+ favours character, 3- is trouble.
- **Traits**: 3d6, must roll UNDER the trait score.
- **Combat hit**: 1d6 ± mods, 4+ hits.
- **Damage**: 1d6 ± weapon mod - armour reduction.
- **Initiative**: both sides roll 1d6, higher acts first.
### Combat Flow
1. Distance: 2d6 × 10 (metres/feet)
2. Surprise: 1d6
3. Grit: 2d6 for creatures (higher = more determined)
4. Initiative: 1d6
5. Turns: state intent → roll 1d6 ± mods → 4+ success, 3- take hit
### Wounds (0 HP)
1d6: 1-2 die, 3-4 lasting wound (-1 max HP), 5-6 -1 all rolls until healed
### Roll Modifiers
Favourable +1, Risky -1, Desperate -2, Well-prepared +1, Poor visibility -1, Relevant trait +1
### Exploration
6 ten-minute watches per hour. Each meaningful action advances a watch.
## How Turns Work
Each turn follows this sequence:
1. The player's action or response is given to you.
2. Think, read files, roll dice, or ask the player to roll — any number of steps. Time is passing, the player is moving and so is the rest of the world and everyone around.
3. **You MUST call `finalize_turn` to end the turn.** There is no other way to complete a turn. The loop will keep calling you until you do.
The **finalize_turn** tool produces all data for this turn:
- **book_log** `[Required]` — **The complete self-contained narrative of this turn.** Describe what happened, what the player did (based on their action request) and what happened as a result, with all sensory/dialogue/mechanical details. This is appended as another page in the book, make sure it reads like a novel.
- **user_prompt** `[Required]` — **Short prompt for the player only, NOT recorded in the book.** Ask what they do next. 1-3 sentences. Do NOT recap the action — that belongs in `book_log`.
- **log_entry** `[Optional]` — One-sentence summary of what happened (action + outcome). Keep it tight.
- **ambience** `[Optional]` — One of: silence, calm, combat, dungeon, forest, tavern, tension, town, wilds.
### How the Loop Works
Each round the system reads your ````tool` blocks, executes them, and feeds back the results. This repeats until you call `finalize_turn`. If you call tools but never call `finalize_turn`, the loop runs until it hits the round limit and the turn fails with an error.
So: call `finalize_turn` when the player needs to see the outcome and make their next decision.
**Important: Do not mix get tools with finalize_turn.** If you call `read_file`, `character_get`, `world_get`, or `journal_get` in a round, you are still gathering information — do NOT also call `finalize_turn` in that same round. Gather first, then finalize in a separate round.
### Journal & Quest Tracking
The journal is the player's quest log and TODO list. Use dedicated tools to manage it:
- **`journal_get`** — Read the full journal to review quests.
- **`journal_update`** — Add new quests/goals via `"add"` and mark completed via `"done"`.
- **Add quests** as they arise: `{"add": ["Investigate the Weeper beneath the mill"]}`
- **Mark sub-tasks** as they emerge: `{"add": ["Find a way to open the iron grate", "Question Rina about the cult"]}`
- **Mark completed** when resolved: `{"done": ["Investigate the Weeper beneath the mill"]}`
- **Keep descriptions specific** — vague entries like "Explore the dungeon" are not helpful.
- **Review the journal** regularly to maintain continuity.
- Long-term goals stay in TODO until resolved; don't re-add the same quest every turn.
### Character & World State
To read or update state files, use the dedicated tools:
- **`character_get`** / **`character_update`** — Read or replace the full character sheet. ONLY update when HP/cash/gear/stats change.
- **`world_get`** / **`world_update`** — Read or replace the full world state. ONLY update when NPCs/locations/threads change.
## Available Tools
Tool calls go in their own fenced code block (one call per block):
```tool
{"tool": "read_file", "args": {"file": "character", "dm_status": "Checking Dillion's stats."}}
```
You may also show reasoning inline:
```thought
Your reasoning here
```
Tools available:
Every tool call **must** include a `"dm_status"` string in `args` — a short, public-facing description of what the DM is doing (e.g. `"consulting the archives"`, `"examining the wound"`, `"calculating the odds"`). The player sees this in the UI. Keep it vague — never reveal what the DM is actually reading or learning.
Tool reference (`[R]` = required, `[O]` = optional):
- **read_file** — Read a game state file.
`[R] file`: "character" | "world" | "book" | "log" | "journal"
`[R] dm_status`: "..."
- **roll** — Auto-roll dice (outcome shown in status).
`[O] dice`: "2d6" (default "1d6")
`[O] modifier`: "-1" (default "0")
`[R] dm_status`: "..."
- **player_roll** — Ask the player to roll physical dice. Use when the outcome is uncertain.
`[O] dice`: "2d6" (default "1d6")
`[O] reason`: "why the roll matters"
`[R] dm_status`: "..."
- **character_get** — Read the full character sheet.
`[R] dm_status`: "..."
- **character_update** — Replace the full character sheet.
`[R] content`: "full character sheet markdown"
`[R] dm_status`: "..."
- **world_get** — Read the full world state.
`[R] dm_status`: "..."
- **world_update** — Replace the full world state.
`[R] content`: "full world state markdown"
`[R] dm_status`: "..."
- **journal_get** — Read the journal (TODO / DONE).
`[R] dm_status`: "..."
- **journal_update** — Add or complete journal entries.
`[O] add`: ["new todo item", ...]
`[O] done`: ["completed item", ...]
`[R] dm_status`: "..."
- **finalize_turn** — **REQUIRED to end the turn.** The loop will NOT stop without it. Call this ALONE — do not mix with get tools.
`[R] book_log`: "full-form narrative of what happened durint the turn, permanent story record that reads like a book"
`[R] user_prompt`: "short prompt for the player — NOT recorded, 1-3 sentences"
`[O] log_entry`: "one-sentence summary (action + outcome)"
`[O] ambience`: "soundscape name: silence|calm|combat|dungeon|forest|tavern|tension|town|wilds"
When the player makes a choice, resolve it with the dice mechanics above. Describe the action, roll dice implicitly (describe the outcome, don't say "rolling dice"), apply damage/effects, and update state. Use this to decide how the story evolves.
## Current Game State
### Character
$character
### World
$world
### Recent Log
$log
### Recent Story (last turns from the book)
$story""")
# trailing """ is intentional — the template ends here
# ── Game Engine ────────────────────────────────────────────────────────────
class GameEngine:
"""Owns the LLM interaction and game state persistence."""
def __init__(self, session_dir: str | Path = SESSION_DIR):
self.session_dir = Path(session_dir)
self.config: dict = {}
self._load_config()
# ── Config ──────────────────────────────────────────────────────────
def _load_config(self) -> None:
if not CONFIG_PATH.exists():
print(
"No session/config.json found. Creating default.\n"
"Edit the model field (e.g. 'ollama/llama3.1', 'openai/gpt-4', "
"'anthropic/claude-sonnet-4-20250514') and set api_key if needed.",
file=sys.stderr,
)
self.config = {
"llm": {
"model": "ollama/llama3.1",
"api_key": None,
"api_base": None,
"temperature": 0.8,
"max_tokens": 300,
}
}
self._save_config()
else:
raw = CONFIG_PATH.read_text()
self.config = json.loads(raw)
# Ensure api_key is None not empty string
llm = self.config.get("llm", {})
if not llm.get("api_key"):
llm["api_key"] = None
def _save_config(self) -> None:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(json.dumps(self.config, indent=2) + "\n")
@property
def model(self) -> str:
return self.config.get("llm", {}).get("model", "ollama/llama3.1")
@property
def api_key(self) -> str | None:
return self.config.get("llm", {}).get("api_key")
@property
def api_base(self) -> str | None:
return self.config.get("llm", {}).get("api_base")
@property
def temperature(self) -> float:
return self.config.get("llm", {}).get("temperature", 0.8)
@property
def max_tokens(self) -> int:
return self.config.get("llm", {}).get("max_tokens", 512)
def _set_llm_env(self) -> None:
"""Set provider-specific env vars for litellm."""
prefix = self.model.split("/")[0].upper()
import os
key = self.api_key or "sk-placeholder"
os.environ[f"{prefix}_API_KEY"] = key
if self.api_base:
os.environ[f"{prefix}_API_BASE"] = self.api_base
# ── Context Assembly ────────────────────────────────────────────────
def _read_file(self, path: Path) -> str:
return path.read_text().strip() if path.exists() else ""
def _read_recent_log(self, max_entries: int = 10) -> str:
"""Read the latest log file and return the last N entries."""
log_path = LOG_DIR / f"{TODAY}.md"
if not log_path.exists():
# Check yesterday's log
from datetime import timedelta
yesterday = (date.today() - timedelta(days=1)).isoformat()
log_path = LOG_DIR / f"{yesterday}.md"
if not log_path.exists():
return "*No recent events.*"
lines = log_path.read_text().splitlines()
entries = [l for l in lines if l.strip().startswith("- ")]
return "\n".join(entries[-max_entries:]) or "*No recent events.*"
def _read_recent_book(self, max_turns: int = 3) -> str:
"""Return the last N turns from the book as context."""
text = self._read_file(BOOK_PATH)
if not text:
return "*No prior story.*"
turns = text.split("\n## ")
recent = turns[-max_turns:]
return "\n## ".join(recent) if len(turns) > 1 else recent[0]
def _get_valid_ambiences(self) -> set[str]:
"""Parse ambience_options.md and return set of valid ambience names with associated audio files."""
valid = {"silence"} # silence always valid (stops music)
if not AMBIENCE_OPTIONS_PATH.exists():
return valid
in_table = False
for line in AMBIENCE_OPTIONS_PATH.read_text().splitlines():
s = line.strip()
if not s.startswith("|") or not s.endswith("|"):
in_table = False
continue
if in_table and all(c in "-:| " for c in s):
continue
parts = [p.strip() for p in s.split("|") if p.strip()]
if not parts:
continue
if not in_table:
in_table = True
continue
name = parts[0].lower()
files_str = parts[1] if len(parts) > 1 else ""
files = [f.strip() for f in files_str.split(",")]
# Only add if at least one file exists (or is listed)
has_files = any((AUDIO_DIR / f).exists() or f for f in files)
if has_files:
valid.add(name)
return valid
def build_system_prompt(self) -> str:
"""Assemble the system prompt with current game state."""
char = self._read_file(CHAR_PATH) or "*No character sheet.*"
world = self._read_file(WORLD_PATH) or "*No world state.*"
log = self._read_recent_log()
story = self._read_recent_book()
return SYSTEM_PROMPT.substitute(
character=char, world=world, log=log, story=story
)
def build_user_message(
self,
player_action: str | None = None,
last_prompt: str | None = None,
**kwargs: str | None,
) -> str:
"""Build the user message for this turn's LLM call."""
if kwargs:
raise TypeError(
f"build_user_message() got unexpected keyword arguments: "
f"{set(kwargs)}. Did you mean 'last_prompt' instead of one of these?"
)
parts = []
if last_prompt:
parts.append(f"## Situation\n{last_prompt}")
if player_action:
parts.append(f"## Player's Request\n{player_action}")
has_existing_story = bool(
self._read_file(BOOK_PATH).strip()
) if not last_prompt else True
if not player_action and not last_prompt:
if has_existing_story:
raise RuntimeError(f"User action is required for every turn.")
else:
parts.append(
"## Instructions\n"
"This is a new story. Welcome the player and guide them through the game setup."
)
else:
parts.append(
"## Instructions\n"
"Take the player's request and use it to advance the story."
"Think, gather information, update the state, "
"then call finalize_turn to complete the turn.\n"
"Put each tool call in its own ```tool block."
)
return "\n\n".join(parts)
# ── LLM Call ────────────────────────────────────────────────────────
def generate(
self,
player_action: str | None = None,
last_narrative: str | None = None,
) -> GenerationResult:
"""
Synchronous generation. Calls the LLM, parses the response,
and returns a GenerationResult.
The TUI calls this from a worker thread — see run.py.
"""
system = self.build_system_prompt()
user = self.build_user_message(
player_action=player_action, last_prompt=last_narrative
)
messages = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
try:
import litellm
except ImportError:
return GenerationResult(
narrative="",
error=(
"litellm is not installed. Run: pip install litellm"
),
)
# Set API key / base if provided
self._set_llm_env()
try:
response = litellm.completion(
model=self.model,
messages=messages,
temperature=self.temperature,
stream=False,
timeout=60,
)
text = response.choices[0].message.content or ""
except Exception as e:
return GenerationResult(
narrative="",
error=f"LLM call failed: {e}",
)
return self.parse_response(text)
def generate_stream(
self,
player_action: str | None = None,
last_narrative: str | None = None,
) -> Iterator[str]:
"""
Streaming generator. Yields text chunks as they arrive from the LLM.
On completion, the final yield is the FULL text (for parsing).
"""
system = self.build_system_prompt()
user = self.build_user_message(
player_action=player_action, last_prompt=last_narrative
)
messages = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
try:
import litellm
except ImportError:
yield json.dumps({
"error": "litellm is not installed. Run: pip install litellm"
})
return
self._set_llm_env()
try:
response = litellm.completion(
model=self.model,
messages=messages,
temperature=self.temperature,
stream=True,
timeout=60,
)
full_text = ""
for chunk in response:
delta = chunk.choices[0].delta.content or ""
if delta:
full_text += delta
yield full_text # partial narrative for streaming display
# Final yield is the completed text
yield full_text
except Exception as e:
yield json.dumps({"error": f"LLM call failed: {e}"})
# ── Tool Infrastructure ────────────────────────────────────────────
TOOL_REGISTRY: dict[str, dict] = {
"read_file": {
"description": "Read a game state file.",
"args": {"file": "character | world | book | log | journal"},
},
"roll": {
"description": "Roll dice and return the outcome.",
"args": {"dice": "e.g. 1d6, 2d6", "modifier": "optional +N or -N"},
},
"think": {
"description": "Internal reasoning shown in the game status bar.",
"args": {"thought": "Your reasoning."},
},
"player_roll": {
"description": "Ask the player to physically roll dice and enter the result.",
"args": {"dice": "e.g. 2d6+1", "reason": "Why the roll is needed (shown to player)"},
},
"character_get": {
"description": "Read the full character sheet.",
"args": {},
},
"character_update": {
"description": "Replace the character sheet with a new full version (ONLY if HP/cash/gear/stats changed).",
"args": {"content": "Full character sheet markdown"},
},
"world_get": {
"description": "Read the full world state.",
"args": {},
},
"world_update": {
"description": "Replace the world state with a new full version (ONLY if NPCs/locations/threads changed).",
"args": {"content": "Full world state markdown"},
},
"journal_get": {
"description": "Read the journal (TODO / DONE).",
"args": {},
},
"journal_update": {
"description": "Add or complete journal entries.",
"args": {"add": "Optional: list of new TODO items", "done": "Optional: list of completed items"},
},
"finalize_turn": {
"description": "Complete the turn.",
"args": {
"book_log": "[Required] Full narrative — appended to story book (permanent record)",
"user_prompt": "[Required] Short prompt for player — NOT recorded, 1-3 sentences",
"log_entry": "[Optional] One-sentence summary",
"ambience": "[Optional] Soundscape name",
},
},
}
def _tool_think(self, args: dict) -> str:
"""Think tool — content is displayed via dm_status in the status bar."""
return ""
def _tool_read_file(self, args: dict) -> str:
filename = (args or {}).get("file", "")
paths = {
"character": CHAR_PATH,
"world": WORLD_PATH,
"book": BOOK_PATH,
"log": LOG_DIR / f"{TODAY}.md",
"journal": JOURNAL_PATH,
}
path = paths.get(filename)
if not path:
return f"Unknown file: {filename}. Choose from: {', '.join(paths)}"
return self._read_file(path) or f"*{filename} is empty.*"
def _tool_roll(self, args: dict) -> str:
import random
dice_str = (args or {}).get("dice", "1d6")
modifier_str = (args or {}).get("modifier", "0")
try:
count, sides = dice_str.lower().split("d")
count = int(count) if count else 1
sides = int(sides)
except (ValueError, TypeError):
return f"Invalid dice: {dice_str}. Use format like '2d6'."
mod = 0
if modifier_str:
try:
mod = int(modifier_str)
except ValueError:
pass
rolls = [random.randint(1, sides) for _ in range(count)]
total = sum(rolls) + mod
mod_str = f" {'+' if mod >= 0 else ''}{mod}" if mod != 0 else ""
return f"Roll: {dice_str}{mod_str} → [{', '.join(str(r) for r in rolls)}] = {total}"
def _tool_character_get(self, args: dict) -> str:
return self._read_file(CHAR_PATH) or "*Character sheet is empty.*"
def _tool_character_update(self, args: dict) -> str:
content = (args or {}).get("content", "")
if not content:
return "**Error:** `content` is required."
if not self._validate_update_size("character", content, CHAR_PATH):
return "**Error:** Update rejected — content is too short (likely a partial paste)."
CHAR_PATH.write_text(content.strip() + "\n")
return "Character sheet updated."
def _tool_world_get(self, args: dict) -> str:
return self._read_file(WORLD_PATH) or "*World state is empty.*"
def _tool_world_update(self, args: dict) -> str:
content = (args or {}).get("content", "")
if not content:
return "**Error:** `content` is required."
if not self._validate_update_size("world", content, WORLD_PATH):
return "**Error:** Update rejected — content is too short (likely a partial paste)."
WORLD_PATH.write_text(content.strip() + "\n")
return "World state updated."
def _tool_journal_get(self, args: dict) -> str:
return self._read_file(JOURNAL_PATH) or "*Journal is empty.*"
def _tool_journal_update(self, args: dict) -> str:
add = (args or {}).get("add", [])
done = (args or {}).get("done", [])
if isinstance(add, str):
add = [add]
if isinstance(done, str):
done = [done]
if not add and not done:
return "**Error:** Provide at least one of `add` or `done`."
self._update_journal(add=add, done=done)
return "Journal updated."
@staticmethod
def _describe_tool_action(tool_name: str, args: dict) -> str:
"""Return a user-facing status message for a tool call.
Prefer the LLM-provided dm_status — otherwise fall back to a generic description."""
dm_status = (args or {}).get("dm_status")
if dm_status:
return f"DM is {dm_status}..."
read_descriptions = {
"character": "reading the character sheet",
"world": "consulting the world map",
"book": "reviewing the story so far",
"log": "checking the session log",
"journal": "scanning the journal",
}
if tool_name == "read_file":
file = (args or {}).get("file", "")
desc = read_descriptions.get(file, f"reading {file}")
elif tool_name in ("character_get", "world_get", "journal_get"):
file = tool_name.replace("_get", "")
desc = read_descriptions.get(file, f"reading {file}")
elif tool_name in ("character_update", "world_update"):
desc = "updating the records"
elif tool_name == "journal_update":
desc = "updating the journal"
elif tool_name == "roll":
dice = (args or {}).get("dice", "1d6")
mod = (args or {}).get("modifier")
desc = f"rolling {dice}"
if mod:
desc += f" {mod}"
elif tool_name == "player_roll":
dice = (args or {}).get("dice", "1d6")
desc = f"asking you to roll {dice}"
else:
desc = f"using {tool_name}"
return f"DM is {desc}..."
def _execute_tool(self, tool_name: str, args: dict) -> str:
fn_map = {
"read_file": self._tool_read_file,
"roll": self._tool_roll,
"think": self._tool_think,
"character_get": self._tool_character_get,
"character_update": self._tool_character_update,
"world_get": self._tool_world_get,
"world_update": self._tool_world_update,
"journal_get": self._tool_journal_get,
"journal_update": self._tool_journal_update,
}
fn = fn_map.get(tool_name)
if not fn:
return f"Unknown tool: {tool_name}"
try:
return fn(args)
except Exception as e:
return f"Tool error ({tool_name}): {e}"
@staticmethod
def _extract_thoughts(text: str) -> list[str]:
pattern = r"```thought\s*\n?(.*?)```"
return re.findall(pattern, text, re.DOTALL)
@staticmethod
def _extract_tool_calls(text: str, *, round_num: int = 0, on_debug: callable = None) -> list[dict]:
"""Extract tool calls from ```tool and ```json blocks.
Uses json.JSONDecoder.raw_decode for strict parsing; falls back to
heuristics if the LLM produces unescaped newlines in string values.
"""
calls = []
seen = set()
def _try_parse(raw: str) -> dict | None:
try:
obj = json.loads(raw)
if isinstance(obj, dict) and "tool" in obj:
return obj
except json.JSONDecodeError:
pass
return None
for m in re.finditer(r"```(?:tool|json|finalize_turn)\s*\n?", text):
fence_type = m.group(0).strip("``` \n\r")
# 1) Strict: raw_decode from where the JSON should start
obj = None
try:
decoder = json.JSONDecoder()
obj, end = decoder.raw_decode(text, m.end())
except (json.JSONDecodeError, ValueError, StopIteration):
pass
if obj is None:
# 2) Fallback: find closing backticks and repair unescaped newlines in strings
close = text.find("```", m.end())
if close > 0:
raw = text[m.end():close].strip()
def _escape_in_strings(s: str) -> str:
return re.sub(r'"(?:[^"\\]|\\.)*"', lambda x: x.group(0).replace("\n", "\\n"), s, flags=re.DOTALL)
repaired = _escape_in_strings(raw)
obj = _try_parse(repaired)
if obj is not None and isinstance(obj, dict):
# Normalize: fence type "finalize_turn" means the JSON is the args directly
if fence_type == "finalize_turn":
obj = {"tool": "finalize_turn", "args": obj}
# If JSON has a "tool" key, keep as-is
if "tool" not in obj:
obj = None
if obj is not None:
key = (obj["tool"], json.dumps(obj.get("args", {}), sort_keys=True))
if key not in seen:
seen.add(key)
calls.append(obj)
elif on_debug:
preview = text[m.end():m.end() + 120].replace("\n", "\\n")
on_debug("parse_error", {"round": round_num, "content": preview})
return calls
@staticmethod
def _extract_final_json(text: str) -> dict | None:
pattern = r"```json\s*\n?(.*?)```"
matches = re.findall(pattern, text, re.DOTALL)
if not matches:
return None
try:
return json.loads(matches[-1].strip())
except json.JSONDecodeError:
return None
def generate_with_tools(
self,
player_action: str | None = None,
last_prompt: str | None = None,
on_thought: callable = None,
on_action: callable = None,
on_player_roll: callable = None,
on_debug: callable = None,
) -> TurnResult:
"""
Multi-turn generation with tool-use loop.
The LLM can output ```thought blocks, call ```tool blocks, and
MUST call **finalize_turn** to complete the turn. Until then the
loop continues feeding tool results back.
`on_thought` / `on_action` / `on_debug` may be called from a worker thread —
use call_from_thread in the TUI.
"""
system = self.build_system_prompt()
user = self.build_user_message(
player_action=player_action,
last_prompt=last_prompt,
)
messages: list[dict] = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
self._set_llm_env()
try:
import litellm
except ImportError:
return TurnResult(error="litellm not installed")
max_rounds = 30
debug_entries: list[str] = []
attempt = 0
round_used = 0
reminder_count = 0
from datetime import datetime
self._append_llm_log(f"\n{'='*60}")
self._append_llm_log(f"=== Turn — {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ===")
self._append_llm_log(f"{'='*60}")
if player_action:
self._append_llm_log(f"Player: {player_action}")
elif last_prompt:
self._append_llm_log(f"Resume from: {last_prompt[:120]}")
while round_used < max_rounds:
attempt += 1
round_log: list[str] = [f"── Attempt {attempt} (round {round_used + 1}/{max_rounds}) ──"]
try:
response = litellm.completion(
model=self.model,
messages=messages,
temperature=self.temperature,
stream=False,
timeout=60,
max_tokens=self.max_tokens,
)
text = response.choices[0].message.content or ""
self._append_llm_log(
f"\n--- Attempt {attempt} ---\n{text}"
)
except Exception as e:
self._append_llm_log(f"\n--- LLM ERROR (attempt {attempt}) ---\n{e}")
if on_debug:
on_debug("llm_error", {"error": str(e)})
return TurnResult(error=f"LLM call failed: {e}")
if on_debug:
on_debug("llm_response", {"round": attempt, "text": text})
# Thoughts
thoughts = self._extract_thoughts(text)
if thoughts:
round_log.append(f" thoughts: {len(thoughts)}")
for t in thoughts:
if on_thought:
on_thought(t.strip())
if on_debug:
on_debug("thought", {"round": attempt, "text": t.strip()})
# Tool calls
tool_calls = self._extract_tool_calls(
text,
round_num=attempt,
on_debug=on_debug,
)
finalize_call: dict | None = None
other_calls: list[dict] = []
for tc in tool_calls:
if tc.get("tool") == "finalize_turn":
finalize_call = tc
else:
other_calls.append(tc)
# Log tool call summary
if tool_calls:
names = [tc.get("tool", "?") for tc in tool_calls]
round_log.append(f" tools: {', '.join(names)}")
# Guard: mixed get tools + finalize_turn → execute get tools, reject finalize
get_tools = {"read_file", "character_get", "world_get", "journal_get"}
if finalize_call and any(tc.get("tool") in get_tools for tc in other_calls):
# Execute only the get tools, drop finalize_turn
results = []
for tc in other_calls:
if tc.get("tool") not in get_tools:
continue
name = tc.get("tool", "?")
args = tc.get("args", {})
if not args.get("dm_status"):
err_msg = (
f"**Validation Error:** Tool `{name}` missing required `dm_status`. "
f"Add `\"dm_status\": \"what the DM is doing\"` to the args."
)
results.append(err_msg)
round_log.append(f" {name}: MISSING dm_status")
if on_debug:
on_debug("validation_error", {"round": attempt, "type": "tool", "tool": name, "error": "missing dm_status"})
continue
if on_action:
on_action(self._describe_tool_action(name, args))
if on_debug:
on_debug("tool_call", {"round": attempt, "tool": name, "args": args})
result = self._execute_tool(name, args)
results.append(f"**Tool:** {name}\n**Args:** {json.dumps(args)}\n**Result:** {result}")
round_log.append(f" {name}: OK")
if on_debug:
on_debug("tool_result", {"round": attempt, "tool": name, "result": result})
round_log.append(" finalize_turn ignored (mixed with get tools)")
debug_entries.append("\n".join(round_log))
# messages = messages[:2] # keep full history across rounds so LLM can learn from prior attempts
messages.append({"role": "assistant", "content": text})
messages.append({
"role": "user",
"content": "## Tool Results\n\n" + "\n\n".join(results) + "\n\n**Note:** `finalize_turn` was ignored because you called get tools in the same round. Call `finalize_turn` alone in the next round to complete the turn."
})
if on_debug:
on_debug("validation_error", {"round": attempt, "type": "mixed_get_finalize", "tools": [tc.get("tool") for tc in other_calls]})
round_used += 1
continue
# finalize_turn present → validate and return
if finalize_call:
args = finalize_call.get("args", {})
errs = []
if not args.get("book_log"):
errs.append("book_log [Required]")
if not args.get("user_prompt"):
errs.append("user_prompt [Required]")
# Validate ambience
ambience_name = args.get("ambience")
if ambience_name and ambience_name != "silence":
valid_ambiences = self._get_valid_ambiences()
if not valid_ambiences or ambience_name not in valid_ambiences:
errs.append(f"ambience '{ambience_name}' is invalid or has no associated audio files.")
if errs:
hint = (
f"Expected:\n"
f'{{"tool": "finalize_turn", "args": {{'
f'"book_log": "...", '
f'"user_prompt": "...", '
f'"log_entry": "...", '
f'"ambience": "..."'
f"}}}}\n"
f"Valid ambiences: {', '.join(valid_ambiences)}"
)
round_log.append(f" finalize_turn validation errors: {', '.join(errs)}")
debug_entries.append("\n".join(round_log))
# messages = messages[:2] # keep full history across rounds so LLM can learn from prior attempts
messages.append({"role": "assistant", "content": text})
messages.append({
"role": "user",
"content": f"## Validation Error\nMissing required field(s): {', '.join(errs)}.\n\n{hint}Please provide all required fields and call finalize_turn again."
})
if on_debug:
on_debug("validation_error", {"round": attempt, "type": "finalize_turn", "errors": errs})
round_used += 1
continue
if on_debug:
on_debug("finalize", {"round": attempt, "args": args})
round_used += 1
self._append_llm_log(
f"\n--- FINALIZE (attempt {attempt}) ---\n"
f"book_log: {args.get('book_log','')[:200]}\n"
f"user_prompt: {args.get('user_prompt','')[:200]}\n"
f"log_entry: {args.get('log_entry','')}\n"
f"ambience: {args.get('ambience','')}\n"
)
return TurnResult(
book_log=args.get("book_log", ""),
user_prompt=args.get("user_prompt", ""),
ambience=args.get("ambience"),
log_entry=args.get("log_entry"),
)
# Execute other tools
if other_calls:
results = []
for tc in other_calls:
name = tc.get("tool", "?")
args = tc.get("args", {})
# dm_status is required on every tool call
if not args.get("dm_status"):
err_msg = (
f"**Validation Error:** Tool `{name}` missing required `dm_status`. "
f"Add `\"dm_status\": \"what the DM is doing\"` to the args.\n"
f"Put each tool call in its own ```tool block."
)
results.append(err_msg)
round_log.append(f" {name}: MISSING dm_status")
if on_debug:
on_debug("validation_error", {"round": attempt, "type": "tool", "tool": name, "error": "missing dm_status"})
continue
if on_action:
on_action(self._describe_tool_action(name, args))
if on_debug:
on_debug("tool_call", {"round": attempt, "tool": name, "args": args})
if name == "player_roll" and on_player_roll:
dice = args.get("dice", "1d6")
reason = args.get("reason", "a check")
roll_val = on_player_roll(dice, reason)
result = f"Player rolled {dice} for '{reason}': {roll_val}"
else:
result = self._execute_tool(name, args)
results.append(f"**Tool:** {name}\n**Args:** {json.dumps(args)}\n**Result:** {result}")
round_log.append(f" {name}: OK")
if on_debug:
on_debug("tool_result", {"round": attempt, "tool": name, "result": result})
# messages = messages[:2] # keep full history across rounds so LLM can learn from prior attempts
messages.append({"role": "assistant", "content": text})
messages.append({
"role": "user",
"content": "## Tool Results\n\n" + "\n\n".join(results),
})
debug_entries.append("\n".join(round_log))
round_used += 1
continue
# No tools, no finalize
round_log.append(" no tool calls")
if not text.strip():
# Empty response — model may be slow. Give it time and retry without adding context.
if on_debug:
on_debug("empty_response", {"round": attempt})
import time
time.sleep(2)
debug_entries.append("\n".join(round_log))
continue
# Plain-text reasoning (no ```tool/```thought blocks) — log in debug but don't show to player
round_used += 1
if on_debug:
on_debug("thought", {"round": attempt, "text": text.strip()})
debug_entries.append("\n".join(round_log))
# messages = messages[:2] # keep full history across rounds so LLM can learn from prior attempts
messages.append({"role": "assistant", "content": text})
reminder_count += 1
if reminder_count % 3 == 0:
reminder = (
"## Instructions\n"
"Respond with tool calls or finalize_turn.\n\n"
"Put each tool call in its own ```tool block:\n"
"```tool\n{\"tool\": \"character_get\", \"args\": {\"dm_status\": \"...\"}}\n```\n\n"
"When ready, call **finalize_turn** with `book_log` and `user_prompt`."
)
else:
reminder = "Use tools to gather information or call **finalize_turn** to end the turn."
messages.append({"role": "user", "content": reminder})
if on_debug:
on_debug("no_tool_calls", {"round": attempt})
debug_text = "\n\n".join(debug_entries)
self._append_llm_log(f"\n--- LOOP EXCEEDED ({max_rounds} rounds) ---\n{debug_text}")
return TurnResult(
error=f"Turn loop exceeded max rounds ({max_rounds}). Below is a debug log of what the LLM did each round:\n\n{debug_text}",
debug_info=debug_text,
)
# ── Response Parsing ────────────────────────────────────────────────
@staticmethod
def parse_response(text: str) -> GenerationResult:
"""
Parse a full LLM response into a GenerationResult.
Extracts the JSON block and splits narrative from it.
"""
# Check for error JSON
if text.startswith('{"error":'):
try:
err = json.loads(text).get("error", "Unknown error")
except json.JSONDecodeError:
err = "Unknown error"
return GenerationResult(narrative="", error=err)
# Try to find a ```json ... ``` block
json_pattern = r"```json\s*\n?(.*?)\n?```"
matches = re.findall(json_pattern, text, re.DOTALL)
narrative = text
data = {}
if matches:
json_str = matches[-1].strip()
# Remove the JSON block from narrative
narrative = text[: text.rfind("```json")]
# Also strip any stray "book_log:" lines that may appear before the JSON block
narrative_lines = []
for line in narrative.splitlines():
if not line.lstrip().startswith('book_log:'):
narrative_lines.append(line)
narrative = "\n".join(narrative_lines).strip()
try:
data = json.loads(json_str)
except json.JSONDecodeError:
pass
else:
# Fallback: maybe the entire response is JSON (no fence)
text_stripped = text.strip()
if text_stripped.startswith("{") and text_stripped.endswith("}"):
try:
data = json.loads(text_stripped)
narrative = data.get("narrative", "")
except json.JSONDecodeError:
pass
return GenerationResult(
narrative=narrative or text,
choices=data.get("choices", []),
log_entry=data.get("log_entry"),
ambience=data.get("ambience"),
character_updates=data.get("character_updates"),
world_updates=data.get("world_updates"),
journal_add=data.get("journal_add", []),
journal_done=data.get("journal_done", []),
)
# ── State Persistence ───────────────────────────────────────────────
def _validate_update_size(self, name: str, new_content: str, path: Path) -> bool:
"""Reject updates that are more than 30% shorter than the existing file
— likely the LLM pasted a fragment instead of the full state."""
if not path.exists():
return True
old = path.read_text().strip()
if not old:
return True
ratio = len(new_content) / len(old)
if ratio < 0.7:
import sys
print(
f"WARNING: {name} update rejected ({ratio:.0%} of original size "
f"= {len(new_content)} vs {len(old)} chars) — likely a partial paste.",
file=sys.stderr,
)
return False
return True
def apply_state(self, result: TurnResult) -> None:
"""Write state changes from a TurnResult to disk."""
if result.ambience:
AMBIENCE_PATH.write_text(result.ambience.strip().lower() + "\n")
def archive_turn(self, narrative: str) -> None:
"""Append the narrative as a new turn in book.md."""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
heading = f"\n\n## Turn — {timestamp}\n\n"
BOOK_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(BOOK_PATH, "a") as f:
f.write(heading + narrative.strip() + "\n")
def append_log(self, entry: str) -> None:
"""Append a log entry to today's log file."""
LOG_DIR.mkdir(parents=True, exist_ok=True)
log_path = LOG_DIR / f"{TODAY}.md"
if not log_path.exists():
log_path.write_text(f"# Session Log — {TODAY}\n\n")
with open(log_path, "a") as f:
f.write(entry.strip() + "\n")
def _append_llm_log(self, text: str) -> None:
"""Append raw LLM activity to llm.log for debugging."""
LLM_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(LLM_LOG_PATH, "a") as f:
f.write(text + "\n")
def _update_journal(
self, add: list[str] | None = None, done: list[str] | None = None
) -> None:
"""Add or complete TODO items in journal.md."""
if not JOURNAL_PATH.exists():
JOURNAL_PATH.write_text("# Journal\n\n## TODO\n\n## DONE\n\n")
lines = JOURNAL_PATH.read_text().splitlines()
# Parse into sections: everything before TODO, TODO items, between, DONE items, after
todo_items: list[str] = []
done_items: list[str] = []
before_todo: list[str] = []
between: list[str] = []
after_done: list[str] = []
section = "before_todo"
for line in lines:
stripped = line.strip()
if stripped == "## TODO":
section = "todo"
before_todo.append(line)
elif stripped == "## DONE":
section = "done"
between.append(line)
elif section == "before_todo":
before_todo.append(line)
elif section == "todo":
if stripped.startswith("- "):
todo_items.append(stripped[2:])
else:
between.append(line)
elif section == "done":
if stripped.startswith("- "):
done_items.append(stripped[2:])
else:
after_done.append(line)
# Apply changes
if done:
done_set = set(done)
todo_items = [i for i in todo_items if i not in done_set]
new_done = [i for i in done if i not in done_items]
done_items.extend(new_done)
if add:
todo_set = set(todo_items)
new_todo = [i for i in add if i not in todo_set]
# Insert new items at the top of TODO
todo_items = new_todo + todo_items
# Reconstruct
out = list(before_todo)
for item in todo_items:
out.append(f"- {item}")
out.extend(between)
for item in done_items:
out.append(f"- {item}")
out.extend(after_done)
# Clean up: collapse multiple blank lines
cleaned = []
prev_blank = False
for line in out:
is_blank = line.strip() == ""
if is_blank and prev_blank:
continue
cleaned.append(line)
prev_blank = is_blank
# Ensure trailing newline
JOURNAL_PATH.write_text("\n".join(cleaned) + "\n")
# ── CLI entry point (for testing) ─────────────────────────────────────────
def main():
"""Generate a turn from the command line (debug/testing)."""
import argparse
parser = argparse.ArgumentParser(description="The Chaos Game Engine (CLI)")
parser.add_argument("--action", "-a", help="Player action text")
parser.add_argument("--last", "-l", help="Last narrative text")
args = parser.parse_args()
engine = GameEngine()
result = engine.generate(
player_action=args.action,
last_narrative=args.last,
)
if result.error:
print(f"ERROR: {result.error}", file=sys.stderr)
sys.exit(1)
print(result.narrative)
if result.choices:
print("\n--- Choices ---")
for c in result.choices:
print(f" [{c}]")
if result.log_entry:
print(f"\n[Log] {result.log_entry}")
if result.ambience:
print(f"[Ambience] {result.ambience}")
if __name__ == "__main__":
main()