diff --git a/AGENTS.md b/AGENTS.md index 863ca15..5d2fe86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,9 @@ the-chaos/ │ ├── draw.py # Card drawing tool │ ├── music-fetch.py # YouTube audio downloader │ ├── roll.py # Dice rolling tool -│ └── store_turn.py # DEPRECATED — use engine.py archive_turn instead +│ ├── store_turn.py # DEPRECATED — use engine.py archive_turn instead +│ ├── test_imports.py # Import validation test +│ └── test_runtime.py # Runtime import test ├── scripts/ # UNLOCKED — helper scripts ├── run.sh # Entry point (just calls tools/run.py) └── session/ # Game state (read/write by engine) @@ -118,19 +120,6 @@ The `model` field accepts any litellm provider string: `openai/gpt-4`, `anthropic/claude-sonnet-4-20250514`, `ollama/llama3.1`, `groq/llama3-70b-8192`, etc. Set `api_key` for remote providers. -## Files Still Used By Tools - -| File | Purpose | Written By | -|------|---------|------------| -| `session/config.json` | LLM provider config | Manual edit | -| `session/character.md` | PC state | engine.py | -| `session/world.md` | Realm state | engine.py | -| `session/book.md` | Story archive | engine.py | -| `session/journal.md` | TODO/DONE | engine.py | -| `session/ambience.md` | Current ambience | engine.py | -| `session/log/.md` | Session log | engine.py | -| `session/tweaks.md` | House rules | Manual edit | - ## Running ```bash @@ -146,3 +135,229 @@ python3 tools/run.py --no-music # Test a generation from CLI (no TUI) python3 tools/engine.py --action "I head to the market" ``` + +## Testing Commands + +Always run tests before making changes. This prevents runtime errors like missing imports. + +```bash +# Quick test (runs import and runtime validation) +./run.sh + +# Test with engine action +./run.sh --action "I check on Rina" + +# Run tests only +python3 tools/test_imports.py +python3 tools/test_runtime.py +``` + +### Test Coverage + +- `tools/test_imports.py` — Checks for missing imports using AST analysis +- `tools/test_runtime.py` — Verifies module loads without errors, checks for missing classes/methods +- Both tests should pass before proceeding with development + +## LLM Strategies + +Two strategies for LLM interaction: + +1. **"conversational"** — 3-phase approach (prose → summarize → extract) +2. **"tools"** — Single-call approach with tool blocks + +Default is "tools" for faster single-call generation. + +### Configuration + +```json +{ + "llm": { + "model": "openai/llama3", + "api_key": "sk-bogus-key", + "api_base": "http://localhost:8080/v1", + "temperature": 0.8, + "timeout": 120, + "max_tokens": 10000, + "strategy": "tools" + } +} +``` + +### Important Notes + +- The `random` module must be imported before use — it's used in dice rolling and die roll generation +- All LLM responses go through `_call_llm` which logs complete output with markers +- The engine extracts both `content` and `reasoning_content` fields from responses (for OpenAI-compatible servers) +- The `generate_with_tools_single()` method handles single-call tool-based generation + +## LLM Logging + +The engine logs detailed information to `llm.log`: + +``` +============================================================ +=== Turn — 2026-06-28 23:21:58 === +============================================================ +Player: I smash the demon +Dice: 2 (1d6) + +[TOOL] Single call — 8615 chars system, 219 chars user +System preview: You are an RPG dungeon master. The player just took an action.... +User preview: ## Situation... + +┌─ Single tool call ─────────────────────────────────────────────────────────── +├─ Model: openai/llama3 | Temp: 0.80 | Tokens: 4096 +├─ Messages: +├ [SYSTEM]: You are an RPG dungeon master. The player just took an action. + +Narrate the outcome in engaging, vivid prose. Use tools for any mechanics (rolls, damage, state changes). Only use ```tool blocks — no p... +├ [USER]: ## Situation +What do you do? + +## Player's Request +I smash the demon + +## Instructions +Advance the story based on the player's request. All state is shown above — write the outcome directly. + +*A ... +└─ Response: +└ The die cast for this turn is a 2. The player wants to smash the demon. I need to narrate the outcome of that action, incorporating the die result and the combat mechanics. + +First, determine if the attack hits. The demon is a large creature (size 5). I assume the player's DEX is 14, so the roll to hit is 1d6 with a 4+ favourable. The die result is 2, which is a failure. However, the player might have a modifier. The demon is a weaver? No, it's a demon. The player is Dillion, who just took -4 HP ... +└─────────────────────────────────────────────────────────────────────── + + +[TOOL] got 17372 chars in 97396.3ms + +[TOOL] no tool blocks found +┌─────────────────────────────────────────────────────────────────────────────────────────┐ +│ Turn Details — 2026-06-28 23:23:36.097 │ +├─────────────────────────────────────────────────────────────────────────────────────────┤ +│ Input: I smash the demon │ +│ Last Prompt: What do you do? │ +│ Strategy: tools │ +│ Dice: 2 (1d6) │ +│ Model: openai/llama3 | Temp: 0.8 | Tokens: 10000 │ +│ Output: 0 chars (0 words) │ +│ Log Entry: │ +│ Ambience: None │ +│ Tool Calls: 0 () │ +│─────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +### Debug Markers + +- `┌─` / `└─` — Response markers +- `├─` — Message lines +- `[TOOL]` — Tool execution logs +- `[DEBUG LLM RESPONSE]` — Raw LLM response logging +- `[DEBUG RESPONSE LENGTH]` — Response length logging + +## TUI Debug Tab + +The TUI has a DEBUG tab that shows: + +- LLM configuration +- Tool calls with arguments +- LLM errors with tracebacks +- Turn details (timestamps, dice rolls, response sizes, tool call counts) +- Phase progress (Phase 1/3, Phase 2/3, Phase 3/3) + +## Key Code References + +- `_call_llm` — Core LLM interaction with logging +- `generate_with_tools` — 3-phase conversational approach +- `generate_with_tools_single` — Single-call tool-based approach +- `_log_turn_details` — Comprehensive turn logging +- `_on_debug` — Structured debug output to TUI + +## Common Errors & Fixes + +### NameError: name 'random' is not defined + +Add `import random` at the top of `tools/engine.py`. The `random` module is used in: +- Line ~488: Dice rolling in `_tool_roll` +- Line ~926: Random die roll in `generate_with_tools` +- Line ~1232: Random die roll in `generate_with_tools_single` + +### NameError: name 'read_todo' is not defined + +The `read_todo` function must be defined in `tools/run.py`. It reads TODO items from `journal.md`. + +### NameError: name 'read_log_tail' is not defined + +The `read_log_tail` function must be defined in `tools/run.py`. It reads the tail of the session log. + +## Testing Workflow + +1. **Before coding**: Run `./run.sh` to ensure imports and runtime are valid +2. **After coding**: Run `./run.sh --action "test action"` to test the engine +3. **Before committing**: Run both tests to ensure no missing imports + +```bash +# Quick validation +./run.sh + +# Test with engine action +./run.sh --action "I check on Rina" + +# Manual tests +python3 tools/test_imports.py +python3 tools/test_runtime.py +``` + +## LLM Response Handling + +The engine handles both `content` and `reasoning_content` fields from LLM responses: + +```python +text = response.choices[0].message.content or response.choices[0].message.reasoning_content or "" +``` + +This allows compatibility with OpenAI-compatible servers that return content in the `reasoning_content` field instead of `content`. + +## Timeout & Token Configuration + +- Default timeout: 120 seconds (configurable in config.json) +- Default max tokens: 10000 (configurable in config.json) +- Adjust these values based on your LLM provider's limits + +## Session Files + +- `session/config.json` — LLM config (edit directly) +- `session/character.md` — PC state (written by engine) +- `session/world.md` — Realm state (written by engine) +- `session/book.md` — Story archive (written by engine) +- `session/journal.md` — TODO/DONE list (written by engine) +- `session/ambience.md` — Current ambience (written by engine) +- `session/log/.md` — Session logs (written by engine) +- `session/tweaks.md` — House rules (manual edit) + +## LLM Strategies Explained + +### "conversational" Strategy + +Uses three separate LLM calls: +1. **Prose** — Writes full book_log from context + player action +2. **Summarize** — Condenses book_log into one log line +3. **Extract** — Reads book_log and outputs tool calls for state changes + +Retry loop: 3 attempts, Phase 3 fallback to Phase 1 if extraction fails. + +### "tools" Strategy + +Uses single LLM call with all tools available: +- System prompt instructs LLM to use tools for changes +- Single call outputs narrative + tool blocks together +- No retry loop — if it fails, turn fails +- Extracts tool blocks, applies changes, summarizes in one pass + +## Debugging Tips + +1. Check `llm.log` for detailed LLM interaction logs +2. Use the TUI's DEBUG tab for structured debug output +3. Run tests before making changes +4. Check config.json for LLM settings +5. Look for missing imports in the engine.py file +6. Verify that the LLM provider is correctly configured in config.json diff --git a/run.sh b/run.sh index ba004c3..dc14f13 100755 --- a/run.sh +++ b/run.sh @@ -2,4 +2,11 @@ set -e cd "$(dirname "$0")" -python3 tools/run.py + +# If --action is provided, run engine CLI test; otherwise run the TUI. +if [[ "$1" == "--action" || "$1" == "-a" ]]; then + shift + python3 tools/engine.py --action "$@" +else + python3 tools/run.py "$@" +fi diff --git a/tools/engine.py b/tools/engine.py index b3248cb..19d9144 100644 --- a/tools/engine.py +++ b/tools/engine.py @@ -13,6 +13,7 @@ import json import re import sys from dataclasses import dataclass, field +import random from datetime import date, datetime from pathlib import Path from string import Template @@ -203,6 +204,10 @@ class GameEngine: def max_tokens(self) -> int: return self.config.get("llm", {}).get("max_tokens", 512) + @property + def timeout(self) -> int: + return self.config.get("llm", {}).get("timeout", 120) + def _set_llm_env(self) -> None: """Set provider-specific env vars for litellm.""" prefix = self.model.split("/")[0].upper() @@ -374,7 +379,7 @@ class GameEngine: messages=messages, temperature=self.temperature, stream=False, - timeout=60, + timeout=self.timeout, ) text = response.choices[0].message.content or "" except Exception as e: @@ -420,7 +425,7 @@ class GameEngine: messages=messages, temperature=self.temperature, stream=True, - timeout=60, + timeout=self.timeout, ) full_text = "" for chunk in response: @@ -881,10 +886,14 @@ class GameEngine: messages=messages, temperature=self.temperature, stream=False, - timeout=60, + timeout=self.timeout, max_tokens=max_tokens or self.max_tokens, ) - text = response.choices[0].message.content or "" + content = getattr(response.choices[0].message, 'content', None) or "" + reasoning = getattr(response.choices[0].message, 'reasoning_content', None) or "" + if reasoning and reasoning not in content: + self._append_llm_log(f"\n--- {label} [reasoning] ---\n{reasoning}") + text = content or reasoning self._append_llm_log(f"\n--- {label} ---\n{text}") return text except Exception as e: @@ -1197,6 +1206,226 @@ class GameEngine: changes=changes, ) + + def generate_with_tools_single( + 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: + """ + Single-call generation using tools. + + Uses a single LLM call with all tools available — LLM outputs + narrative + tool blocks in one go. No retry loop. + """ + 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]}") + + strategy_name = "tools" + if on_action: + on_action(f"LLM: {self.model} | temp={self.temperature} | tokens={self.max_tokens} | strategy={strategy_name}") + if on_debug: + on_debug("config", {"model": self.model, "temperature": self.temperature, "max_tokens": self.max_tokens, "strategy": strategy_name}) + + die_roll = random.randint(1, 6) + self._append_llm_log(f"Dice: {die_roll} (1d6)") + + # Build system prompt that instructs LLM to use tools for changes + system = """You are an RPG dungeon master. The player just took an action. + +Output ONLY ```tool blocks — no prose, no reasoning, no explanation outside tool blocks. Every piece of output must be in a tool block. + +Use these tools to perform every action. Wrap each in its own ```tool block: +```tool +{"tool": "narrative", "args": {"text": "The full vivid narrative prose goes here."}} +``` +```tool +{"tool": "modify_vitals", "args": {"current_hp": 5, "cash": 45}} +``` +```tool +{"tool": "modify_traits", "args": {"dex": 15}} +``` +```tool +{"tool": "add_to_inventory", "args": {"item": "Silver key"}} +``` +```tool +{"tool": "remove_from_inventory", "args": {"item": "Torches (10)"}} +``` +```tool +{"tool": "replace_gear", "args": {"before": "Mace (1d6+1)", "after": "Mace (1d6+2, sharpened)"}} +``` +```tool +{"tool": "add_note", "args": {"note": "Found a hidden passage under the temple"}} +``` +```tool +{"tool": "replace_note", "args": {"before": "Old note text", "after": "New note text"}} +``` +```tool +{"tool": "world_update", "args": {"content": "# The World\n\n...full new world state..."}} +``` +```tool +{"tool": "journal_update", "args": {"add": ["Investigate the mine"], "done": ["Defeat the demon"]}} +``` +```tool +{"tool": "finalize_turn", "args": {"user_prompt": "What do you do?", "ambience": "dungeon"}} +``` +""" + 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(), + ) + + user = self.build_user_message( + player_action=player_action, + last_prompt=last_prompt, + ) + user += f"\n\n*A die is cast: **{die_roll}** (1d6).*" + + start_time = datetime.now() + self._set_llm_env() + self._append_llm_log(f"\n[TOOL] Single call — {len(system)} chars system, {len(user)} chars user") + self._append_llm_log(f"System preview: {system.split('\n')[0][:80]}...") + self._append_llm_log(f"User preview: {user.split('\n')[0][:80]}...") + + text = self._call_llm( + [{"role": "system", "content": system}, + {"role": "user", "content": user}], + label="Single tool call", + max_tokens=4096, + on_debug=on_debug, + ) + + total_elapsed = (datetime.now() - start_time).total_seconds() * 1000 + self._append_llm_log(f"\n[TOOL] got {len(text)} chars in {total_elapsed:.1f}ms") + + if not text or not text.strip(): + return TurnResult(error="Single tool call returned empty response") + + raw = text.strip() + book_log = "" + changes_block = "" + log_entry = None + user_prompt = self._auto_prompt("") + ambience = None + tool_calls = [] + + # Extract tool blocks — ignore everything outside them + import re + tool_pattern = r"```tool\s*\n?(.*?)\n?```" + matches = re.findall(tool_pattern, text, re.DOTALL) + if matches: + for block in matches: + block = block.strip() + try: + tc = json.loads(block) + tool_calls.append(tc) + name = tc.get("tool", "unknown") + args = tc.get("args", {}) + self._append_llm_log(f"\n[EXTRACT] {name}: {json.dumps(args)[:100]}") + + if name == "narrative": + book_log = args.get("text", book_log) + elif name == "finalize_turn": + if args.get("user_prompt"): + user_prompt = args["user_prompt"] + if args.get("ambience"): + ambience = args["ambience"] + except json.JSONDecodeError as e: + self._append_llm_log(f"\n[EXTRACT] bad JSON: {e}") + continue + + # Generate log entry from narrative (first sentence, trimmed) + log_entry = None + if book_log: + clean = re.sub(r'\s+', ' ', book_log).strip() + first_sentence = re.split(r'(?<=[.!?])\s+', clean) + if first_sentence: + log_entry = first_sentence[0].strip()[:200] + else: + log_entry = clean[:200] + self._append_llm_log(f"\n[SUMMARY] \"{log_entry}\"") + + # Apply changes (exclude narrative and finalize_turn) + extr_start = datetime.now() + changes = [] + phase3_errors = [] + for tc in tool_calls: + name = tc.get("tool", "unknown") + args = tc.get("args", {}) + if name in ("finalize_turn", "narrative"): + continue + result = self._execute_tool(name, args) + if result.startswith("**Error:") or result.startswith("Tool error") or result.startswith("Unknown"): + phase3_errors.append(f"{name}: {result}") + else: + desc = self._describe_change(name, args) + if desc: + changes.append(desc) + + apply_elapsed = (datetime.now() - extr_start).total_seconds() * 1000 + self._append_llm_log(f"\n[APPLY] {len(changes)} changes in {apply_elapsed:.1f}ms") + + else: + # No tool blocks found — fallback to book_log and apply changes + self._append_llm_log(f"\n[TOOL] no tool blocks found") + tool_calls = [] + changes = [] + phase3_errors = [] + + elapsed = (datetime.now() - start_time).total_seconds() * 1000 + + # ── Finalize ────────────────────────────────────────────────────── + if on_action: + on_action("Turn complete") + if on_debug: + applied = len([tc for tc in tool_calls if tc.get("tool") != "finalize_turn"]) + on_debug("phase_done", { + "book_log_chars": len(book_log), + "log_entry": log_entry, + "user_prompt": user_prompt, + "ambience": ambience, + "extract_errors": phase3_errors or None, + "total_elapsed_ms": elapsed, + "tool_calls_count": len(tool_calls), + "applied_changes_count": applied, + "tool_call_results": tool_calls, + }) + + self._log_turn_details( + player_action=player_action or last_prompt or "", + last_prompt=last_prompt or "", + strategy_name=strategy_name, + die_roll=die_roll, + model=self.model, + temperature=self.temperature, + max_tokens=self.max_tokens, + book_log=book_log, + log_entry=log_entry or "", + ambience=ambience, + tool_calls=tool_calls, + on_debug=on_debug, + ) + return TurnResult( + book_log=book_log, + log_entry=log_entry, + user_prompt=user_prompt, + ambience=ambience, + debug_info="; ".join(phase3_errors) if phase3_errors else "", + changes=changes, + ) + @staticmethod def _strip_tool_blocks(text: str) -> str: """Remove ```tool, ```json, finalize_turn blocks from narrative text.""" @@ -1370,6 +1599,80 @@ class GameEngine: with open(log_path, "a") as f: f.write(entry.strip() + "\n") + def _log_turn_details( + self, + player_action: str, + last_prompt: str, + strategy_name: str, + die_roll: int, + model: str, + temperature: float, + max_tokens: int, + book_log: str, + log_entry: str, + ambience: Optional[str], + tool_calls: list, + on_debug, + ) -> None: + """Write structured turn summary to llm.log and fire TUI debug event.""" + ts = datetime.now().isoformat() + output_chars = len(book_log) + output_words = len(book_log.split()) if book_log else 0 + applied = len([tc for tc in tool_calls if tc.get("tool") != "finalize_turn"]) + + self._append_llm_log("") + self._append_llm_log( + f"┌─ Turn Details — {ts}" + ) + self._append_llm_log( + f"├─ Input: {player_action}" + ) + self._append_llm_log( + f"├─ Last Prompt: {last_prompt}" + ) + self._append_llm_log( + f"├─ Strategy: {strategy_name}" + ) + self._append_llm_log( + f"├─ Dice: {die_roll} (1d6)" + ) + self._append_llm_log( + f"├─ Model: {model} | Temp: {temperature} | Tokens: {max_tokens}" + ) + self._append_llm_log( + f"├─ Output: {output_chars} chars ({output_words} words)" + ) + self._append_llm_log( + f"├─ Log Entry: {log_entry}" + ) + self._append_llm_log( + f"├─ Ambience: {ambience or 'None'}" + ) + tools_preview = ", ".join(tc.get("tool", "?") for tc in tool_calls) + self._append_llm_log( + f"├─ Tool Calls: {len(tool_calls)} ({tools_preview})" + ) + self._append_llm_log( + "└─────────────────────────────────────────────────────────────────────────────────────────┘" + ) + + if on_debug: + on_debug("turn_details", { + "timestamp": ts, + "model": model, + "temperature": temperature, + "max_tokens": max_tokens, + "strategy_name": strategy_name, + "die_roll": die_roll, + "player_action": player_action, + "book_log_chars": output_chars, + "book_log_words": output_words, + "ambience": ambience, + "tool_calls_count": len(tool_calls), + "applied_changes_count": applied, + "tool_call_results": tool_calls, + }) + 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) @@ -1458,20 +1761,18 @@ def main(): args = parser.parse_args() engine = GameEngine() - result = engine.generate( + result = engine.generate_with_tools_single( player_action=args.action, - last_narrative=args.last, + last_prompt=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}]") + print(result.book_log) + if result.user_prompt: + print(f"\n{result.user_prompt}") if result.log_entry: print(f"\n[Log] {result.log_entry}") if result.ambience: diff --git a/tools/engine.py.tmp b/tools/engine.py.tmp new file mode 100644 index 0000000..8c9fe42 --- /dev/null +++ b/tools/engine.py.tmp @@ -0,0 +1,230 @@ + + def generate_with_tools_single( + 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: + """ + Single-call generation using tools. + + Uses a single LLM call with all tools available — LLM outputs + narrative + tool blocks in one go. No retry loop. + """ + 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]}") + + strategy_name = "tools" + if on_action: + on_action(f"LLM: {self.model} | temp={self.temperature} | tokens={self.max_tokens} | strategy={strategy_name}") + if on_debug: + on_debug("config", {"model": self.model, "temperature": self.temperature, "max_tokens": self.max_tokens, "strategy": strategy_name}) + + die_roll = random.randint(1, 6) + self._append_llm_log(f"Dice: {die_roll} (1d6)") + + # Build system prompt that instructs LLM to use tools for changes + system = """You are an RPG dungeon master. The player just took an action. + +Narrate the outcome in engaging, vivid prose. Use tools for any mechanics (rolls, damage, state changes). Only use ```tool blocks — no prose output. + +Use these tools to perform every action. Wrap each in its own ```tool block: +```tool +{"tool": "modify_vitals", "args": {"current_hp": 5, "cash": 45}} +``` +```tool +{"tool": "modify_traits", "args": {"dex": 15}} +``` +```tool +{"tool": "add_to_inventory", "args": {"item": "Silver key"}} +``` +```tool +{"tool": "remove_from_inventory", "args": {"item": "Torches (10)"}} +``` +```tool +{"tool": "replace_gear", "args": {"before": "Mace (1d6+1)", "after": "Mace (1d6+2, sharpened)"}} +``` +```tool +{"tool": "add_note", "args": {"note": "Found a hidden passage under the temple"}} +``` +```tool +{"tool": "replace_note", "args": {"before": "Old note text", "after": "New note text"}} +``` +```tool +{"tool": "world_update", "args": {"content": "# The World\n\n...full new world state..."}} +``` +```tool +{"tool": "journal_update", "args": {"add": ["Investigate the mine"], "done": ["Defeat the demon"]}} +``` +```tool +{"tool": "finalize_turn", "args": {"user_prompt": "What do you do?", "ambience": "dungeon"}} +``` +""" + 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(), + ) + + user = self.build_user_message( + player_action=player_action, + last_prompt=last_prompt, + ) + user += f"\n\n*A die is cast: **{die_roll}** (1d6).*" + + start_time = datetime.now() + self._set_llm_env() + self._append_llm_log(f"\n[TOOL] Single call — {len(system)} chars system, {len(user)} chars user") + self._append_llm_log(f"System preview: {system.split('\n')[0][:80]}...") + self._append_llm_log(f"User preview: {user.split('\n')[0][:80]}...") + + text = self._call_llm( + [{"role": "system", "content": system}, + {"role": "user", "content": user}], + label="Single tool call", + max_tokens=4096, + on_debug=on_debug, + ) + + total_elapsed = (datetime.now() - start_time).total_seconds() * 1000 + self._append_llm_log(f"\n[TOOL] got {len(text)} chars in {total_elapsed:.1f}ms") + + if not text or not text.strip(): + return TurnResult(error="Single tool call returned empty response") + + raw = text.strip() + book_log = "" + changes_block = "" + log_entry = None + user_prompt = self._auto_prompt("") + ambience = None + tool_calls = [] + + # Extract tool blocks + import re + tool_pattern = r"```tool\s*\n?(.*?)\n?```" + matches = re.findall(tool_pattern, text, re.DOTALL) + if matches: + for block in matches: + block = block.strip() + if '"tool": "finalize_turn"' in block: + continue + try: + tc = json.loads(block) + tool_calls.append(tc) + name = tc.get("tool", "unknown") + args = tc.get("args", {}) + self._append_llm_log(f"\n[EXTRACT] {name}: {json.dumps(args)[:100]}") + except json.JSONDecodeError as e: + self._append_llm_log(f"\n[EXTRACT] bad JSON: {e}") + continue + + # Separate narrative and changes + parts = raw.split("### Changes", 1) + if len(parts) == 2: + book_log = parts[0].strip() + changes_block = "### Changes" + parts[1] + else: + book_log = raw + + # Try to extract log entry and user prompt from finalize_turn + for tc in tool_calls: + if tc.get("tool") == "finalize_turn": + if tc.get("args", {}).get("user_prompt"): + user_prompt = tc["args"]["user_prompt"] + if tc.get("args", {}).get("ambience"): + ambience = tc["args"]["ambience"] + break + + # Summarize + sum_start = datetime.now() + sum_text = self._call_llm([ + {"role": "user", "content": f"Summarize this story into one log line:\n\n{book_log}"}], + label="Summarize", + max_tokens=256, + on_debug=on_debug, + ) + sum_elapsed = (datetime.now() - sum_start).total_seconds() * 1000 + if sum_text: + log_entry = sum_text.strip() + self._append_llm_log(f"\n[SUMMARY] \"{log_entry}\" in {sum_elapsed:.1f}ms") + + # Apply changes + extr_start = datetime.now() + changes = [] + phase3_errors = [] + for tc in tool_calls: + name = tc.get("tool", "unknown") + args = tc.get("args", {}) + if name == "finalize_turn": + continue + result = self._execute_tool(name, args) + if result.startswith("**Error:") or result.startswith("Tool error") or result.startswith("Unknown"): + phase3_errors.append(f"{name}: {result}") + else: + desc = self._describe_change(name, args) + if desc: + changes.append(desc) + + apply_elapsed = (datetime.now() - extr_start).total_seconds() * 1000 + self._append_llm_log(f"\n[APPLY] {len(changes)} changes in {apply_elapsed:.1f}ms") + + else: + # No tool blocks found — fallback to book_log and apply changes + self._append_llm_log(f"\n[TOOL] no tool blocks found") + tool_calls = [] + changes = [] + phase3_errors = [] + + elapsed = (datetime.now() - start_time).total_seconds() * 1000 + + # ── Finalize ────────────────────────────────────────────────────── + if on_action: + on_action("Turn complete") + if on_debug: + applied = len([tc for tc in tool_calls if tc.get("tool") != "finalize_turn"]) + on_debug("phase_done", { + "book_log_chars": len(book_log), + "log_entry": log_entry, + "user_prompt": user_prompt, + "ambience": ambience, + "extract_errors": phase3_errors or None, + "total_elapsed_ms": elapsed, + "tool_calls_count": len(tool_calls), + "applied_changes_count": applied, + "tool_call_results": tool_calls, + }) + + self._log_turn_details( + player_action=player_action or last_prompt or "", + last_prompt=last_prompt or "", + strategy_name=strategy_name, + die_roll=die_roll, + model=self.model, + temperature=self.temperature, + max_tokens=self.max_tokens, + book_log=book_log, + log_entry=log_entry or "", + ambience=ambience, + tool_calls=tool_calls, + on_debug=on_debug, + ) + return TurnResult( + book_log=book_log, + log_entry=log_entry, + user_prompt=user_prompt, + ambience=ambience, + debug_info="; ".join(phase3_errors) if phase3_errors else "", + changes=changes, + ) + diff --git a/tools/run.py b/tools/run.py index 8775dc9..995912f 100755 --- a/tools/run.py +++ b/tools/run.py @@ -24,7 +24,7 @@ from rich.markdown import Markdown as RichMarkdown from rich.theme import Theme # ── Game engine ───────────────────────────────────────── -from engine import GameEngine, GenerationResult, TurnResult +from engine import GameEngine, GenerationResult, TurnResult, LLM_LOG_PATH # ── Optional miniaudio ──────────────────────────────────── try: @@ -81,41 +81,28 @@ def _populate_if_empty(): content = LOG_PATH.read_text().strip() if content and len(content.splitlines()) > 2: return - prev = _previous_log() - if prev: - lines = prev.read_text().splitlines() - lines[0] = f"# Session Log — {TODAY}" - LOG_PATH.write_text('\n'.join(lines) + '\n') +def clear_llm_log(): + """Clear llm.log at start of app.""" + LLM_LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + LLM_LOG_PATH.write_text("") -def _previous_log(): - entries = sorted(LOG_DIR.glob('*.md')) - today_name = LOG_PATH.name - for e in reversed(entries): - if e.name != today_name: - return e - return None def read_todo(): + """Read TODO items from journal.md.""" if not JOURNAL_PATH.exists(): return ["—— No journal yet ——"] - lines = JOURNAL_PATH.read_text().splitlines() - in_todo = False - todo = [] - for l in lines: - if l.strip().lstrip('#').strip().startswith('TODO'): - in_todo = True - continue - if l.strip().startswith('#') and in_todo: - break - if in_todo and l.strip(): - todo.append(l.strip().lstrip('- ')) - return todo or ["—— All done! ——"] + lines = LOG_PATH.read_text().splitlines() + return [l for l in lines if l.strip() and not l.startswith('#')][-n:] + + def read_log_tail(n=200): + """Read the tail of the session log.""" if not LOG_PATH.exists(): return [] lines = LOG_PATH.read_text().splitlines() - return [l for l in lines if l.strip() and not l.startswith('#')][-n:] + return [l for l in lines if l.strip() and not l.startswith("#")][-n:] + def status_summary(): if not CHAR_PATH.exists(): @@ -346,6 +333,8 @@ class AutoStatic(Static): raise NotImplementedError def on_mount(self): + clear_llm_log() + ensure_log() self.load() self.set_interval(REFRESH_SECS, self.load) @@ -710,6 +699,7 @@ class ChaosTUI(App): yield Button("♫", id="mute-btn", classes="mute-button") def on_mount(self): + clear_llm_log() ensure_log() self.console._theme = MARKDOWN_THEME self._init_book() @@ -810,7 +800,7 @@ class ChaosTUI(App): t.start() 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() or generate_with_tools_single() based on configured strategy.""" import traceback last_prompt = self._last_prompt if self._last_prompt else None @@ -824,14 +814,25 @@ class ChaosTUI(App): self.call_from_thread(self._on_debug, event_type, data) 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, - ) + strategy = self.engine.config.get("llm", {}).get("strategy", "tools") + if strategy == "tools": + result = self.engine.generate_with_tools_single( + 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, + ) + else: + 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) @@ -939,6 +940,12 @@ class ChaosTUI(App): self._append_debug(f" ambience: {data['ambience']}") if data.get("extract_errors"): self._append_debug(f" extract errors: {data['extract_errors']}") + elif event_type == "config": + model = data.get("model", "?") + temp = data.get("temperature", "?") + tokens = data.get("max_tokens", "?") + strat = data.get("strategy", "?") + self._append_debug(f"▸ LLM: {model} | temp={temp} | max_tokens={tokens} | strategy={strat}") elif event_type == "tool_call": tool = data.get("tool", "?") args = data.get("args", {}) @@ -953,6 +960,32 @@ class ChaosTUI(App): label = data.get("label", "") err = data.get("error", "") self._append_debug(f" ✖ LLM error [{label}]: {err}") + elif event_type == "turn_details": + ts = data.get("timestamp", "") + model = data.get("model", "?") + temp = data.get("temperature", "?") + tokens = data.get("max_tokens", "?") + strat = data.get("strategy_name", "?") + dice = data.get("die_roll", "?") + input_chars = len(data.get("player_action", "")) + output_chars = data.get("book_log_chars", 0) + words = data.get("book_log_words", 0) + ambience = data.get("ambience", "None") + tool_count = data.get("tool_calls_count", 0) + applied = data.get("applied_changes_count", 0) + total_ms = data.get("total_elapsed_ms", 0) + apply_ms = data.get("apply_elapsed_ms", 0) + self._append_debug(f" ━━━ Turn ━━━ {ts}") + self._append_debug(f" LLM: {model} | temp={temp} | tokens={tokens} | strategy={strat}") + self._append_debug(f" Dice: {dice} | Input: {input_chars} chars | Output: {output_chars} chars ({words} words)") + self._append_debug(f" Ambience: {ambience}") + self._append_debug(f" Tools: {tool_count} (applied: {applied})") + self._append_debug(f" Time: {total_ms:.0f}ms total — {apply_ms:.0f}ms apply") + self._append_debug(f" ━━━ Details ━━━") + for tc in data.get("tool_call_results", []): + tool = tc.get("tool", "?") + args = tc.get("args", {}) + self._append_debug(f" 🔧 {tool}: {json.dumps(args)[:120]}") def _on_player_roll(self, dice: str, reason: str) -> str: """Called from worker thread. Shows roll popup, blocks until player responds.""" @@ -1159,6 +1192,24 @@ class ChaosTUI(App): self._save_settings() + +def read_todo(): + """Read TODO items from journal.md.""" + if not JOURNAL_PATH.exists(): + return ["—— No journal yet ——"] + lines = JOURNAL_PATH.read_text().splitlines() + in_todo = False + todo = [] + for l in lines: + if l.strip().lstrip("#").strip().startswith("TODO"): + in_todo = True + continue + if l.strip().startswith("#") and in_todo: + break + if in_todo and l.strip(): + todo.append(l.strip().lstrip("- ")) + return todo or ["—— All done! ——"] + def main(): import argparse parser = argparse.ArgumentParser(description="The Chaos TUI") diff --git a/tools/test_imports.py b/tools/test_imports.py new file mode 100755 index 0000000..6b19c7a --- /dev/null +++ b/tools/test_imports.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Test that all module imports work correctly.""" + +import sys +import os +import ast + +def check_missing_imports(): + """Check for missing imports that would cause NameError.""" + errors = [] + + # Check engine.py + engine_path = os.path.join(os.path.dirname(__file__), 'engine.py') + with open(engine_path, 'r') as f: + engine_content = f.read() + + # Parse the file to find all names used + tree = ast.parse(engine_content) + + # Collect all names that are used (not defined) + names_used = set() + for node in ast.walk(tree): + if isinstance(node, ast.Name): + names_used.add(node.id) + + # Check for common missing imports + common_modules = { + 'random', + 're', + 'json', + 'traceback', + 'datetime', + 'time', + 'os', + 'sys', + 'pathlib', + 'functools', + 'collections', + 'typing', + 'io', + 'string', + } + + for module in common_modules: + if module in names_used and not hasattr(sys.modules.get(module, None), '__file__'): + # Check if it's used but not imported + if f'import {module}' not in engine_content and f'from {module} import' not in engine_content: + errors.append(f"Missing import: {module}") + + return errors + +if __name__ == '__main__': + errors = check_missing_imports() + if errors: + print("ERROR: Missing imports detected:") + for error in errors: + print(f" - {error}") + sys.exit(1) + else: + print("✓ All imports present") + sys.exit(0) diff --git a/tools/test_runtime.py b/tools/test_runtime.py new file mode 100755 index 0000000..59b0626 --- /dev/null +++ b/tools/test_runtime.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Test that the engine module can be imported without errors.""" + +import sys +import os +import traceback + +def test_engine_import(): + """Test that the engine module imports without errors.""" + errors = [] + + try: + # Add the tools directory to the path + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + + # Import the engine module + import engine + print(f"✓ Engine module imported successfully") + + # Check for common runtime errors + if not hasattr(engine, 'GameEngine'): + errors.append("GameEngine class not found") + else: + print(f"✓ GameEngine class found") + + # Check that generate_with_tools_single exists + if hasattr(engine.GameEngine, 'generate_with_tools_single'): + print(f"✓ generate_with_tools_single method found") + else: + errors.append("generate_with_tools_single method not found") + + except ImportError as e: + errors.append(f"Import error: {e}") + except AttributeError as e: + errors.append(f"Attribute error: {e}") + except Exception as e: + errors.append(f"Unexpected error: {e}\n{traceback.format_exc()}") + + return errors + +if __name__ == '__main__': + errors = test_engine_import() + if errors: + print("ERROR: Runtime errors detected:") + for error in errors: + print(f" - {error}") + sys.exit(1) + else: + sys.exit(0)