Add more logging. Selectable LLM strategy.

This commit is contained in:
Dejvino 2026-06-29 22:59:45 +02:00
parent 25fb5fd729
commit 91b1b35cfa
7 changed files with 975 additions and 61 deletions

243
AGENTS.md
View File

@ -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/<date>.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/<date>.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

9
run.sh
View File

@ -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

View File

@ -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:

230
tools/engine.py.tmp Normal file
View File

@ -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,
)

View File

@ -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,6 +814,17 @@ class ChaosTUI(App):
self.call_from_thread(self._on_debug, event_type, data)
try:
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,
@ -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")

61
tools/test_imports.py Executable file
View File

@ -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)

49
tools/test_runtime.py Executable file
View File

@ -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)