diff --git a/tools/engine.py b/tools/engine.py index 9016c1c..a813d0e 100644 --- a/tools/engine.py +++ b/tools/engine.py @@ -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. +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 ### Character @@ -658,6 +672,9 @@ class GameEngine: try: return fn(args) 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}" @staticmethod @@ -734,11 +751,13 @@ class GameEngine: except json.JSONDecodeError: 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.""" try: import litellm except ImportError: + if on_debug: + on_debug("llm_error", {"label": label, "error": "litellm not installed"}) return None try: response = litellm.completion( @@ -753,7 +772,10 @@ class GameEngine: 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}") + 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 def generate_with_tools( @@ -793,6 +815,7 @@ class GameEngine: on_debug("phase", {"phase": 1, "name": "prose", "status": "start", "dice": die_roll}) book_log = None + changes_block = "" for attempt in range(3): system = PROSE_PROMPT.substitute( character=self._read_file(CHAR_PATH) or "*No character sheet.*", @@ -809,16 +832,24 @@ class GameEngine: text = self._call_llm([ {"role": "system", "content": system}, {"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 on_debug: on_debug("phase", {"phase": 1, "status": "empty", "attempt": attempt + 1}) 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: 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 if not book_log: @@ -833,13 +864,16 @@ class GameEngine: log_context = self._read_recent_log() log_entry = None for attempt in range(2): + context = book_log + if changes_block: + context += f"\n\n{changes_block}" 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}") + f"## New Story\n{context}"} + ], label=f"Summarize attempt {attempt + 1}", on_debug=on_debug) if text and text.strip(): log_entry = text.strip().split("\n")[0][:120] if on_debug: @@ -864,13 +898,25 @@ class GameEngine: 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": + phase3_prompt = ( + 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"## 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. Examples:\n\n" + ) + phase3_prompt += ( + 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_traits\", \"args\": {{\"dex\": 15}}}}\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\": \"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."} - ], label=f"Extract attempt {attempt + 1}") + ], label=f"Extract attempt {attempt + 1}", on_debug=on_debug) if not text or not text.strip(): if on_debug: @@ -933,6 +979,8 @@ class GameEngine: if on_debug: 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: on_action("Turn complete") if on_debug: diff --git a/tools/run.py b/tools/run.py index 024d70b..045cd6b 100755 --- a/tools/run.py +++ b/tools/run.py @@ -756,6 +756,7 @@ class ChaosTUI(App): def _run_generation(self, player_action: str | None) -> None: """Worker thread: calls engine.generate_with_tools() and posts result back.""" + import traceback last_prompt = self._last_prompt if self._last_prompt else None def on_thought(thought: str) -> None: @@ -767,14 +768,19 @@ class ChaosTUI(App): def on_debug(event_type: str, data: dict) -> None: self.call_from_thread(self._on_debug, event_type, data) - result = self.engine.generate_with_tools( - player_action=player_action, - last_prompt=last_prompt, - on_thought=on_thought, - on_action=on_action, - on_player_roll=self._on_player_roll, - on_debug=on_debug, - ) + try: + result = self.engine.generate_with_tools( + player_action=player_action, + last_prompt=last_prompt, + on_thought=on_thought, + on_action=on_action, + 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) @@ -859,6 +865,11 @@ class ChaosTUI(App): for e in errs: self._append_debug(f" ✖ {e}") 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": self._append_debug(f" ✔ turn complete — book_log: {data.get('book_log_chars', 0)} chars") if data.get("log_entry"): @@ -878,7 +889,9 @@ class ChaosTUI(App): elif event_type == "parse_error": self._append_debug(f" ⚠ bad tool block: {data.get('content', '')}") 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: """Called from worker thread. Shows roll popup, blocks until player responds.""" @@ -950,6 +963,19 @@ class ChaosTUI(App): self._last_result = result 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: """Update the UI with the last story entry followed by the DM prompt.""" parts = []