Dumb down the turn processing for a smaller LLM

This commit is contained in:
Dejvino 2026-06-28 16:17:23 +02:00
parent 0733d178d0
commit 12a8398f9f
2 changed files with 312 additions and 428 deletions

View File

@ -62,141 +62,30 @@ class TurnResult:
# ── 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.
SYSTEM_PROMPT = Template("""You are the DM for "The Chaos". Narrate in 2nd person ("You"), vivid but concise. Player: Dillion.
## 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).
## Rules
- **Odds**: 1d6, 4+ favourable, 3- trouble.
- **Traits**: 3d6, roll UNDER trait.
- **Combat**: 1d6, 4+ hits. Damage: 1d6 + mod - armour.
- **Wounds at 0 HP**: 1d6 1-2 die, 3-4 -1 max HP, 5-6 -1 all rolls until healed.
- **Modifiers**: Favourable +1, Risky -1, Desperate -2.
## 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."}}
## Tools (action only)
Wrap in ```tool to perform an action:
```
{"tool": "roll", "args": {"dice": "1d6"}}
```
You may also show reasoning inline:
- **roll** dice, modifier
- **player_roll** dice, reason
- **character_update** content: "full sheet" (if HP/cash/gear/stats change)
- **world_update** content: "full world" (if NPCs/locations/threads change)
- **journal_update** add: [...], done: [...]
```thought
Your reasoning here
```
You have the full state above no need to look anything up. Just write the story and use tools when the player's action changes something.
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
## State
### Character
$character
@ -204,12 +93,37 @@ $character
### World
$world
### Recent Log
### Log
$log
### Recent Story (last turns from the book)
### Story
$story""")
# trailing """ is intentional — the template ends here
PROSE_PROMPT = Template("""You are the DM for "The Chaos". Narrate in 2nd person ("You"), vivid but concise. Player: Dillion.
## Rules
- **Odds**: 1d6, 4+ favourable, 3- trouble.
- **Traits**: 3d6, roll UNDER trait.
- **Combat**: 1d6, 4+ hits. Damage: 1d6 + mod - armour.
- **Wounds at 0 HP**: 1d6 1-2 die, 3-4 -1 max HP, 5-6 -1 all rolls until healed.
- **Modifiers**: Favourable +1, Risky -1, Desperate -2.
A die is cast at the start of each turn incorporate it into your narrative.
## State
### Character
$character
### World
$world
### Log
$log
### Story
$story""")
# ── Game Engine ────────────────────────────────────────────────────────────
@ -287,11 +201,10 @@ class GameEngine:
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:
def _read_recent_log(self, max_entries: int = 5) -> 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"
@ -301,7 +214,7 @@ class GameEngine:
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:
def _read_recent_book(self, max_turns: int = 1) -> str:
"""Return the last N turns from the book as context."""
text = self._read_file(BOOK_PATH)
if not text:
@ -310,6 +223,22 @@ class GameEngine:
recent = turns[-max_turns:]
return "\n## ".join(recent) if len(turns) > 1 else recent[0]
@staticmethod
def _truncate_world(text: str) -> str:
"""Extract key world context: NPCs, factions, active threads, rumours."""
if not text or text == "*No world state.*":
return text
sections = re.split(r"\n(?=## |### )", text)
parts = []
for sec in sections:
header = sec.split("\n")[0].strip() if sec else ""
if "Active Threads" in header:
parts.append(sec)
elif "Notable NPCs" in header or "Factions at Play" in header or "### Rumours" in header:
parts.append(sec)
result = "\n\n".join(parts)
return result or text[:1500] + "\n_(world truncated)_"
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)
@ -341,7 +270,7 @@ class GameEngine:
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.*"
world = self._truncate_world(self._read_file(WORLD_PATH) or "") or "*No world state.*"
log = self._read_recent_log()
story = self._read_recent_book()
return SYSTEM_PROMPT.substitute(
@ -382,10 +311,8 @@ class GameEngine:
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."
"Advance the story based on the player's request. "
"All state is shown above — write the outcome directly."
)
return "\n\n".join(parts)
@ -754,6 +681,28 @@ class GameEngine:
except json.JSONDecodeError:
return None
def _call_llm(self, messages: list[dict], *, label: str = "", max_tokens: int | None = None) -> str | None:
"""Make a single LLM call. Returns content text or None on error."""
try:
import litellm
except ImportError:
return None
try:
response = litellm.completion(
model=self.model,
messages=messages,
temperature=self.temperature,
stream=False,
timeout=60,
max_tokens=max_tokens or self.max_tokens,
)
text = response.choices[0].message.content or ""
self._append_llm_log(f"\n--- {label} ---\n{text}")
return text
except Exception as e:
self._append_llm_log(f"\n--- LLM ERROR ({label}) ---\n{e}")
return None
def generate_with_tools(
self,
player_action: str | None = None,
@ -764,39 +713,13 @@ class GameEngine:
on_debug: callable = None,
) -> TurnResult:
"""
Multi-turn generation with tool-use loop.
Three-phase generation:
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.
1. **Prose** LLM writes the full book_log from context + player action.
2. **Summarize** LLM condenses the book_log into one log line.
3. **Extract** LLM reads the book_log and outputs tool calls for state changes.
"""
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')} ===")
@ -806,246 +729,200 @@ class GameEngine:
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}) ──"]
# ── Phase 1: Prose ────────────────────────────────────────────────
import random
die_roll = random.randint(1, 6)
self._append_llm_log(f"Dice: {die_roll} (1d6)")
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_action:
on_action(f"Phase 1/3: writing story (dice={die_roll})")
if on_debug:
on_debug("phase", {"phase": 1, "name": "prose", "status": "start", "dice": die_roll})
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,
book_log = None
for attempt in range(3):
system = PROSE_PROMPT.substitute(
character=self._read_file(CHAR_PATH) or "*No character sheet.*",
world=self._truncate_world(self._read_file(WORLD_PATH) or "") or "*No world state.*",
log=self._read_recent_log(),
story=self._read_recent_book(),
)
finalize_call: dict | None = None
other_calls: list[dict] = []
user = self.build_user_message(
player_action=player_action,
last_prompt=last_prompt,
)
user += f"\n\n*A die is cast: **{die_roll}** (1d6).*"
text = self._call_llm([
{"role": "system", "content": system},
{"role": "user", "content": user},
], label=f"Prose attempt {attempt + 1}", max_tokens=1024)
if not text or not text.strip():
if on_debug:
on_debug("phase", {"phase": 1, "status": "empty", "attempt": attempt + 1})
continue
book_log = text.strip()
if on_debug:
preview = book_log[:150].replace("\n", "\\n")
on_debug("phase", {"phase": 1, "status": "done", "chars": len(book_log), "preview": preview})
break
if not book_log:
return TurnResult(error="Prose generation failed after 3 attempts")
# ── Phase 2: Summarize ────────────────────────────────────────────
if on_action:
on_action("Phase 2/3: summarizing story")
if on_debug:
on_debug("phase", {"phase": 2, "name": "summarize", "status": "start"})
log_context = self._read_recent_log()
log_entry = None
for attempt in range(2):
text = self._call_llm([
{"role": "user", "content":
f"Given the session log so far, summarize the new story in one line. "
f"Focus on who was involved (character and NPC names):\n\n"
f"## Session Log\n{log_context}\n\n"
f"## New Story\n{book_log}"}
], label=f"Summarize attempt {attempt + 1}")
if text and text.strip():
log_entry = text.strip().split("\n")[0][:120]
if on_debug:
on_debug("phase", {"phase": 2, "status": "done", "summary": log_entry})
break
if not log_entry:
log_entry = book_log.split("\n")[0][:120]
if on_debug:
on_debug("phase", {"phase": 2, "status": "fallback", "summary": log_entry})
# ── Phase 3: Extract state changes ────────────────────────────────
if on_action:
on_action("Phase 3/3: extracting state changes")
if on_debug:
on_debug("phase", {"phase": 3, "name": "extract", "status": "start"})
user_prompt = self._auto_prompt(book_log)
ambience = None
debug_info = ""
current_char = self._read_file(CHAR_PATH) or "*No character.*"
current_world = self._truncate_world(self._read_file(WORLD_PATH) or "") or "*No world.*"
for attempt in range(3):
text = self._call_llm([
{"role": "user", "content":
f"Read the story and compare with current state. Output tool calls for changes:\n\n"
f"## Current Character\n{current_char}\n\n"
f"## Current World\n{current_world}\n\n"
f"## Story\n{book_log}\n\n"
f"Output tool blocks for changes only. Include the FULL updated content:\n"
f"- character_update — content: full new sheet if HP/cash/gear/stats changed\n"
f"- world_update — content: full new world if NPCs/locations/threads changed\n"
f"- journal_update — add: [...], done: [...]\n"
f"- finalize_turn — user_prompt (question for player), ambience (soundscape)\n\n"
f"Wrap each in ```tool:\n"
f"```tool\n{{\"tool\": \"character_update\", \"args\": {{\"content\": \"# Character\\n...\"}}}}\n```"}
], label=f"Extract attempt {attempt + 1}")
if not text or not text.strip():
if on_debug:
on_debug("phase", {"phase": 3, "status": "empty", "attempt": attempt + 1})
continue
tool_calls = self._extract_tool_calls(
text, round_num=attempt + 1, on_debug=on_debug
)
if on_debug and tool_calls:
names = [tc.get("tool", "?") for tc in tool_calls if tc.get("tool") != "finalize_turn"]
fin = any(tc.get("tool") == "finalize_turn" for tc in tool_calls)
on_debug("phase", {"phase": 3, "status": "tools_found", "tools": names, "has_finalize": fin})
errors = []
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
name = tc.get("tool", "?")
args = tc.get("args", {})
if name == "finalize_turn":
if args.get("user_prompt"):
user_prompt = args["user_prompt"]
if args.get("ambience"):
ambience = args["ambience"]
continue
if on_action:
on_action(f"State: {self._describe_tool_action(name, args)}")
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"),
)
on_debug("tool_call", {"round": attempt + 1, "tool": name, "args": args})
# Execute other tools
if other_calls:
results = []
for tc in other_calls:
name = tc.get("tool", "?")
args = tc.get("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)
# 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 result.startswith("**Error:") or result.startswith("Tool error") or result.startswith("Unknown"):
errors.append(f"{name}: {result}")
if on_debug:
on_debug("empty_response", {"round": attempt})
import time
time.sleep(2)
debug_entries.append("\n".join(round_log))
continue
on_debug("tool_result", {"round": attempt + 1, "tool": name, "result": result})
# Plain-text reasoning (no ```tool/```thought blocks) — log in debug but don't show to player
round_used += 1
if not errors:
if on_debug:
on_debug("phase", {"phase": 3, "status": "done", "applied": len([tc for tc in tool_calls if tc.get("tool") != "finalize_turn"])})
break
debug_info = "; ".join(errors)
if on_debug:
on_debug("thought", {"round": attempt, "text": text.strip()})
on_debug("phase", {"phase": 3, "status": "errors", "errors": errors, "attempt": attempt + 1})
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})
if on_action:
on_action("Turn complete")
if on_debug:
on_debug("phase_done", {
"book_log_chars": len(book_log),
"log_entry": log_entry,
"user_prompt": user_prompt,
"ambience": ambience,
"extract_errors": debug_info or None,
})
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,
self._append_llm_log(
f"\n--- FINAL ---\n"
f"book_log: {book_log[:200]}\n"
f"log_entry: {log_entry}\n"
f"user_prompt: {user_prompt}\n"
f"ambience: {ambience}\n"
)
return TurnResult(
book_log=book_log,
log_entry=log_entry,
user_prompt=user_prompt,
ambience=ambience,
debug_info=debug_info,
)
@staticmethod
def _strip_tool_blocks(text: str) -> str:
"""Remove ```tool, ```json, finalize_turn blocks from narrative text."""
return re.sub(
r'```(?:tool|json|finalize_turn)\s*\n?.*?```',
'',
text,
flags=re.DOTALL,
).strip()
@staticmethod
def _auto_prompt(book_log: str) -> str:
"""Extract a player prompt from the narrative. Uses the last sentence."""
lines = book_log.strip().splitlines()
sentences = []
for line in reversed(lines):
line = line.strip()
if not line:
continue
# Take last substantive line as the prompt
return f"**What do you do?**\n\n{line}"
return "**What do you do?**"
# ── Response Parsing ────────────────────────────────────────────────

View File

@ -829,47 +829,54 @@ class ChaosTUI(App):
def _on_debug(self, event_type: str, data: dict) -> None:
"""Structured debug entry: visible description + technical detail."""
r = data.get("round", "")
if event_type == "llm_response":
text = data.get("text", "")
if text.strip():
preview = text[:200].replace("\n", "\\n").strip() + ("" if len(text) > 200 else "")
self._append_debug(f" LLM response: {preview}")
else:
self._append_debug(f" LLM response: (empty)")
elif event_type == "thought":
thought = data.get("text", "")
display = thought[:60] + "" if len(thought) > 60 else thought
self._append_debug(f" 💭 {display}")
if event_type == "phase":
p = data.get("phase", 0)
status = data.get("status", "")
if status == "start":
name = data.get("name", "")
dice = data.get("dice")
d = f" dice={dice}" if dice else ""
self._append_debug(f"▸ Phase {p}: {name} {d}")
elif status == "done":
if p == 1:
self._append_debug(f" ✔ prose: {data.get('chars', 0)} chars")
elif p == 2:
self._append_debug(f" ✔ summary: {data.get('summary', '')}")
elif p == 3:
n = data.get("applied", 0)
self._append_debug(f" ✔ extract: {n} state changes applied")
elif status == "empty":
self._append_debug(f" ⚠ phase {p} attempt {data.get('attempt', '?')} empty — retry")
elif status == "fallback":
self._append_debug(f" ⚠ phase {p} used fallback: {data.get('summary', '')}")
elif status == "tools_found":
tools = data.get("tools", [])
fin = data.get("has_finalize", False)
t = ", ".join(tools) if tools else "none"
self._append_debug(f" 🔧 tools found: {t}" + (" + finalize_turn" if fin else ""))
elif status == "errors":
errs = data.get("errors", [])
for e in errs:
self._append_debug(f"{e}")
self._append_debug(f" ⟳ retry (attempt {data.get('attempt', '?')})")
elif event_type == "phase_done":
self._append_debug(f" ✔ turn complete — book_log: {data.get('book_log_chars', 0)} chars")
if data.get("log_entry"):
self._append_debug(f" log: {data['log_entry']}")
if data.get("ambience"):
self._append_debug(f" ambience: {data['ambience']}")
if data.get("extract_errors"):
self._append_debug(f" extract errors: {data['extract_errors']}")
elif event_type == "tool_call":
tool = data.get("tool", "?")
args = data.get("args", {})
desc = args.get("dm_status", tool)
self._append_debug(f" 🔧 {desc}")
self._append_debug(f" {tool}({json.dumps(args)})")
self._append_debug(f" 🔧 {tool}({json.dumps(args)})")
elif event_type == "tool_result":
tool = data.get("tool", "?")
result = data.get("result", "")
preview = result[:80].replace("\n", " ").strip() + ("" if len(result) > 80 else "")
self._append_debug(f"{preview}")
elif event_type == "validation_error":
err_type = data.get("type", "")
if err_type == "finalize_turn":
self._append_debug(f" ✖ finalize_turn missing: {', '.join(data.get('errors', []))}")
elif err_type == "mixed_get_finalize":
tools = data.get("tools", [])
self._append_debug(f" ✖ mixed get tools {tools} with finalize_turn — rejected")
else:
tool = data.get("tool", "?")
self._append_debug(f"{tool} missing dm_status")
elif event_type == "finalize":
self._append_debug(" ✔ finalize_turn")
elif event_type == "no_tool_calls":
self._append_debug(f" ⚠ no tool calls — reminded to use tools")
elif event_type == "parse_error":
self._append_debug(f" ⚠ failed to parse tool block: {data.get('content', '')}")
elif event_type == "empty_response":
self._append_debug(" ⚠ empty response — waiting 2s, retrying without reminder")
self._append_debug(f" ⚠ bad tool block: {data.get('content', '')}")
elif event_type == "llm_error":
self._append_debug(f" ✖ LLM error: {data.get('error', '')}")