Changes block from prose llm

This commit is contained in:
Dejvino 2026-06-28 17:49:43 +02:00
parent e74dd07699
commit a7e6d5540f
2 changed files with 97 additions and 23 deletions

View File

@ -110,6 +110,20 @@ PROSE_PROMPT = Template("""You are the DM for "The Chaos". Narrate in 2nd person
A die is cast at the start of each turn incorporate it into your narrative. A die is cast at the start of each turn incorporate it into your narrative.
End your response with a `### Changes` block listing what changed:
### Changes
- Current Health: 3
- Cash: 45 silver
- Added to inventory: Silver key
- Removed from inventory: Torches (10)
- Replaced gear: Mace (1d6+1) Mace (1d6+2)
- Note: Found a hidden passage
- Journal done: Defeat the demon
- Journal add: Investigate the mine
Only include lines for things that actually changed. Omit unused lines entirely.
## State ## State
### Character ### Character
@ -658,6 +672,9 @@ class GameEngine:
try: try:
return fn(args) return fn(args)
except Exception as e: except Exception as e:
import traceback
tb = traceback.format_exc()
self._append_llm_log(f"\n--- TOOL ERROR ({tool_name}) ---\n{tb}")
return f"Tool error ({tool_name}): {e}" return f"Tool error ({tool_name}): {e}"
@staticmethod @staticmethod
@ -734,11 +751,13 @@ class GameEngine:
except json.JSONDecodeError: except json.JSONDecodeError:
return None return None
def _call_llm(self, messages: list[dict], *, label: str = "", max_tokens: int | None = None) -> str | None: def _call_llm(self, messages: list[dict], *, label: str = "", max_tokens: int | None = None, on_debug: callable = None) -> str | None:
"""Make a single LLM call. Returns content text or None on error.""" """Make a single LLM call. Returns content text or None on error."""
try: try:
import litellm import litellm
except ImportError: except ImportError:
if on_debug:
on_debug("llm_error", {"label": label, "error": "litellm not installed"})
return None return None
try: try:
response = litellm.completion( response = litellm.completion(
@ -753,7 +772,10 @@ class GameEngine:
self._append_llm_log(f"\n--- {label} ---\n{text}") self._append_llm_log(f"\n--- {label} ---\n{text}")
return text return text
except Exception as e: except Exception as e:
self._append_llm_log(f"\n--- LLM ERROR ({label}) ---\n{e}") err_msg = f"{type(e).__name__}: {e}"
self._append_llm_log(f"\n--- LLM ERROR ({label}) ---\n{err_msg}")
if on_debug:
on_debug("llm_error", {"label": label, "error": err_msg})
return None return None
def generate_with_tools( def generate_with_tools(
@ -793,6 +815,7 @@ class GameEngine:
on_debug("phase", {"phase": 1, "name": "prose", "status": "start", "dice": die_roll}) on_debug("phase", {"phase": 1, "name": "prose", "status": "start", "dice": die_roll})
book_log = None book_log = None
changes_block = ""
for attempt in range(3): for attempt in range(3):
system = PROSE_PROMPT.substitute( system = PROSE_PROMPT.substitute(
character=self._read_file(CHAR_PATH) or "*No character sheet.*", character=self._read_file(CHAR_PATH) or "*No character sheet.*",
@ -809,16 +832,24 @@ class GameEngine:
text = self._call_llm([ text = self._call_llm([
{"role": "system", "content": system}, {"role": "system", "content": system},
{"role": "user", "content": user}, {"role": "user", "content": user},
], label=f"Prose attempt {attempt + 1}", max_tokens=1024) ], label=f"Prose attempt {attempt + 1}", max_tokens=1024, on_debug=on_debug)
if not text or not text.strip(): if not text or not text.strip():
if on_debug: if on_debug:
on_debug("phase", {"phase": 1, "status": "empty", "attempt": attempt + 1}) on_debug("phase", {"phase": 1, "status": "empty", "attempt": attempt + 1})
continue continue
book_log = text.strip() raw = text.strip()
# Split narrative from ### Changes block
changes_block = ""
if "### Changes" in raw:
parts = raw.split("### Changes", 1)
book_log = parts[0].strip()
changes_block = "### Changes" + parts[1]
else:
book_log = raw
if on_debug: if on_debug:
preview = book_log[:150].replace("\n", "\\n") preview = book_log[:150].replace("\n", "\\n")
on_debug("phase", {"phase": 1, "status": "done", "chars": len(book_log), "preview": preview}) on_debug("phase", {"phase": 1, "status": "done", "chars": len(book_log), "changes": bool(changes_block), "preview": preview})
break break
if not book_log: if not book_log:
@ -833,13 +864,16 @@ class GameEngine:
log_context = self._read_recent_log() log_context = self._read_recent_log()
log_entry = None log_entry = None
for attempt in range(2): for attempt in range(2):
context = book_log
if changes_block:
context += f"\n\n{changes_block}"
text = self._call_llm([ text = self._call_llm([
{"role": "user", "content": {"role": "user", "content":
f"Given the session log so far, summarize the new story in one line. " 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"Focus on who was involved (character and NPC names):\n\n"
f"## Session Log\n{log_context}\n\n" f"## Session Log\n{log_context}\n\n"
f"## New Story\n{book_log}"} f"## New Story\n{context}"}
], label=f"Summarize attempt {attempt + 1}") ], label=f"Summarize attempt {attempt + 1}", on_debug=on_debug)
if text and text.strip(): if text and text.strip():
log_entry = text.strip().split("\n")[0][:120] log_entry = text.strip().split("\n")[0][:120]
if on_debug: if on_debug:
@ -864,13 +898,25 @@ class GameEngine:
current_world = self._truncate_world(self._read_file(WORLD_PATH) or "") or "*No world.*" current_world = self._truncate_world(self._read_file(WORLD_PATH) or "") or "*No world.*"
for attempt in range(3): for attempt in range(3):
text = self._call_llm([ phase3_prompt = (
{"role": "user", "content": f"## Current Character\n{current_char}\n\n"
f"## Current World\n{current_world}\n\n"
f"## Story\n{book_log}\n\n"
)
if changes_block.strip():
phase3_prompt += (
f"## Changes to apply\n{changes_block}\n\n"
f"Convert the listed changes into tool calls:\n\n"
)
else:
phase3_prompt += (
f"Read the story and compare with current state. Output tool calls for any changes:\n\n" f"Read the story and compare with current state. Output tool calls for any changes:\n\n"
f"## Current Character\n{current_char}\n\n" )
f"## Current World\n{current_world}\n\n" phase3_prompt += (
f"## Story\n{book_log}\n\n" f"Output ```tool blocks for changes only. Examples:\n\n"
f"Output ```tool blocks for changes only. Examples:\n\n" )
text = self._call_llm([
{"role": "user", "content": phase3_prompt +
f"```tool\n{{\"tool\": \"modify_vitals\", \"args\": {{\"current_hp\": 5, \"cash\": 45}}}}\n```\n" f"```tool\n{{\"tool\": \"modify_vitals\", \"args\": {{\"current_hp\": 5, \"cash\": 45}}}}\n```\n"
f"```tool\n{{\"tool\": \"modify_traits\", \"args\": {{\"dex\": 15}}}}\n```\n" f"```tool\n{{\"tool\": \"modify_traits\", \"args\": {{\"dex\": 15}}}}\n```\n"
f"```tool\n{{\"tool\": \"add_to_inventory\", \"args\": {{\"item\": \"Silver key\"}}}}\n```\n" f"```tool\n{{\"tool\": \"add_to_inventory\", \"args\": {{\"item\": \"Silver key\"}}}}\n```\n"
@ -882,7 +928,7 @@ class GameEngine:
f"```tool\n{{\"tool\": \"journal_update\", \"args\": {{\"add\": [\"Investigate the mine\"], \"done\": [\"Defeat the demon\"]}}}}\n```\n" f"```tool\n{{\"tool\": \"journal_update\", \"args\": {{\"add\": [\"Investigate the mine\"], \"done\": [\"Defeat the demon\"]}}}}\n```\n"
f"```tool\n{{\"tool\": \"finalize_turn\", \"args\": {{\"user_prompt\": \"What do you do?\", \"ambience\": \"dungeon\"}}}}\n```\n\n" f"```tool\n{{\"tool\": \"finalize_turn\", \"args\": {{\"user_prompt\": \"What do you do?\", \"ambience\": \"dungeon\"}}}}\n```\n\n"
f"Only output tools for things that actually changed. Omit unchanged fields."} f"Only output tools for things that actually changed. Omit unchanged fields."}
], label=f"Extract attempt {attempt + 1}") ], label=f"Extract attempt {attempt + 1}", on_debug=on_debug)
if not text or not text.strip(): if not text or not text.strip():
if on_debug: if on_debug:
@ -933,6 +979,8 @@ class GameEngine:
if on_debug: if on_debug:
on_debug("phase", {"phase": 3, "status": "errors", "errors": errors, "attempt": attempt + 1}) on_debug("phase", {"phase": 3, "status": "errors", "errors": errors, "attempt": attempt + 1})
if errors and on_debug:
on_debug("phase", {"phase": 3, "status": "exhausted", "errors": errors})
if on_action: if on_action:
on_action("Turn complete") on_action("Turn complete")
if on_debug: if on_debug:

View File

@ -756,6 +756,7 @@ class ChaosTUI(App):
def _run_generation(self, player_action: str | None) -> None: def _run_generation(self, player_action: str | None) -> None:
"""Worker thread: calls engine.generate_with_tools() and posts result back.""" """Worker thread: calls engine.generate_with_tools() and posts result back."""
import traceback
last_prompt = self._last_prompt if self._last_prompt else None last_prompt = self._last_prompt if self._last_prompt else None
def on_thought(thought: str) -> None: def on_thought(thought: str) -> None:
@ -767,14 +768,19 @@ class ChaosTUI(App):
def on_debug(event_type: str, data: dict) -> None: def on_debug(event_type: str, data: dict) -> None:
self.call_from_thread(self._on_debug, event_type, data) self.call_from_thread(self._on_debug, event_type, data)
result = self.engine.generate_with_tools( try:
player_action=player_action, result = self.engine.generate_with_tools(
last_prompt=last_prompt, player_action=player_action,
on_thought=on_thought, last_prompt=last_prompt,
on_action=on_action, on_thought=on_thought,
on_player_roll=self._on_player_roll, on_action=on_action,
on_debug=on_debug, on_player_roll=self._on_player_roll,
) on_debug=on_debug,
)
except Exception as e:
tb = traceback.format_exc()
self.call_from_thread(self._on_generation_error, e, tb)
return
self.call_from_thread(self._on_generation_done, result, player_action) self.call_from_thread(self._on_generation_done, result, player_action)
@ -859,6 +865,11 @@ class ChaosTUI(App):
for e in errs: for e in errs:
self._append_debug(f"{e}") self._append_debug(f"{e}")
self._append_debug(f" ⟳ retry (attempt {data.get('attempt', '?')})") self._append_debug(f" ⟳ retry (attempt {data.get('attempt', '?')})")
elif status == "exhausted":
errs = data.get("errors", [])
self._append_debug(f" ✖ Phase 3 exhausted all retries — state changes may be missing!")
for e in errs:
self._append_debug(f" {e}")
elif event_type == "phase_done": elif event_type == "phase_done":
self._append_debug(f" ✔ turn complete — book_log: {data.get('book_log_chars', 0)} chars") self._append_debug(f" ✔ turn complete — book_log: {data.get('book_log_chars', 0)} chars")
if data.get("log_entry"): if data.get("log_entry"):
@ -878,7 +889,9 @@ class ChaosTUI(App):
elif event_type == "parse_error": elif event_type == "parse_error":
self._append_debug(f" ⚠ bad tool block: {data.get('content', '')}") self._append_debug(f" ⚠ bad tool block: {data.get('content', '')}")
elif event_type == "llm_error": elif event_type == "llm_error":
self._append_debug(f" ✖ LLM error: {data.get('error', '')}") label = data.get("label", "")
err = data.get("error", "")
self._append_debug(f" ✖ LLM error [{label}]: {err}")
def _on_player_roll(self, dice: str, reason: str) -> str: def _on_player_roll(self, dice: str, reason: str) -> str:
"""Called from worker thread. Shows roll popup, blocks until player responds.""" """Called from worker thread. Shows roll popup, blocks until player responds."""
@ -950,6 +963,19 @@ class ChaosTUI(App):
self._last_result = result self._last_result = result
self._append_debug("✔ turn complete") self._append_debug("✔ turn complete")
def _on_generation_error(
self, error: Exception, traceback_str: str
) -> None:
"""Handle an unhandled exception from the worker thread."""
import traceback
self._is_processing = False
self._hide_thinking()
err_msg = f"{type(error).__name__}: {error}"
self._append_debug(f"✖ UNHANDLED EXCEPTION: {err_msg}")
for line in traceback_str.rstrip().split("\n")[-10:]:
self._append_debug(f" {line}")
self._show_error(err_msg, traceback_str)
def _display_scene(self, result: TurnResult) -> None: def _display_scene(self, result: TurnResult) -> None:
"""Update the UI with the last story entry followed by the DM prompt.""" """Update the UI with the last story entry followed by the DM prompt."""
parts = [] parts = []