Add more logging. Selectable LLM strategy.
This commit is contained in:
+312
-11
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
+86
-35
@@ -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")
|
||||
|
||||
Executable
+61
@@ -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)
|
||||
Executable
+49
@@ -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)
|
||||
Reference in New Issue
Block a user