Add more logging. Selectable LLM strategy.
This commit is contained in:
parent
25fb5fd729
commit
91b1b35cfa
243
AGENTS.md
243
AGENTS.md
@ -31,7 +31,9 @@ the-chaos/
|
|||||||
│ ├── draw.py # Card drawing tool
|
│ ├── draw.py # Card drawing tool
|
||||||
│ ├── music-fetch.py # YouTube audio downloader
|
│ ├── music-fetch.py # YouTube audio downloader
|
||||||
│ ├── roll.py # Dice rolling tool
|
│ ├── 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
|
├── scripts/ # UNLOCKED — helper scripts
|
||||||
├── run.sh # Entry point (just calls tools/run.py)
|
├── run.sh # Entry point (just calls tools/run.py)
|
||||||
└── session/ # Game state (read/write by engine)
|
└── 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`,
|
`anthropic/claude-sonnet-4-20250514`, `ollama/llama3.1`, `groq/llama3-70b-8192`,
|
||||||
etc. Set `api_key` for remote providers.
|
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
|
## Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -146,3 +135,229 @@ python3 tools/run.py --no-music
|
|||||||
# Test a generation from CLI (no TUI)
|
# Test a generation from CLI (no TUI)
|
||||||
python3 tools/engine.py --action "I head to the market"
|
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
9
run.sh
@ -2,4 +2,11 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
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
|
||||||
|
|||||||
323
tools/engine.py
323
tools/engine.py
@ -13,6 +13,7 @@ import json
|
|||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
import random
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from string import Template
|
from string import Template
|
||||||
@ -203,6 +204,10 @@ class GameEngine:
|
|||||||
def max_tokens(self) -> int:
|
def max_tokens(self) -> int:
|
||||||
return self.config.get("llm", {}).get("max_tokens", 512)
|
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:
|
def _set_llm_env(self) -> None:
|
||||||
"""Set provider-specific env vars for litellm."""
|
"""Set provider-specific env vars for litellm."""
|
||||||
prefix = self.model.split("/")[0].upper()
|
prefix = self.model.split("/")[0].upper()
|
||||||
@ -374,7 +379,7 @@ class GameEngine:
|
|||||||
messages=messages,
|
messages=messages,
|
||||||
temperature=self.temperature,
|
temperature=self.temperature,
|
||||||
stream=False,
|
stream=False,
|
||||||
timeout=60,
|
timeout=self.timeout,
|
||||||
)
|
)
|
||||||
text = response.choices[0].message.content or ""
|
text = response.choices[0].message.content or ""
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -420,7 +425,7 @@ class GameEngine:
|
|||||||
messages=messages,
|
messages=messages,
|
||||||
temperature=self.temperature,
|
temperature=self.temperature,
|
||||||
stream=True,
|
stream=True,
|
||||||
timeout=60,
|
timeout=self.timeout,
|
||||||
)
|
)
|
||||||
full_text = ""
|
full_text = ""
|
||||||
for chunk in response:
|
for chunk in response:
|
||||||
@ -881,10 +886,14 @@ class GameEngine:
|
|||||||
messages=messages,
|
messages=messages,
|
||||||
temperature=self.temperature,
|
temperature=self.temperature,
|
||||||
stream=False,
|
stream=False,
|
||||||
timeout=60,
|
timeout=self.timeout,
|
||||||
max_tokens=max_tokens or self.max_tokens,
|
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}")
|
self._append_llm_log(f"\n--- {label} ---\n{text}")
|
||||||
return text
|
return text
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -1197,6 +1206,226 @@ class GameEngine:
|
|||||||
changes=changes,
|
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
|
@staticmethod
|
||||||
def _strip_tool_blocks(text: str) -> str:
|
def _strip_tool_blocks(text: str) -> str:
|
||||||
"""Remove ```tool, ```json, finalize_turn blocks from narrative text."""
|
"""Remove ```tool, ```json, finalize_turn blocks from narrative text."""
|
||||||
@ -1370,6 +1599,80 @@ class GameEngine:
|
|||||||
with open(log_path, "a") as f:
|
with open(log_path, "a") as f:
|
||||||
f.write(entry.strip() + "\n")
|
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:
|
def _append_llm_log(self, text: str) -> None:
|
||||||
"""Append raw LLM activity to llm.log for debugging."""
|
"""Append raw LLM activity to llm.log for debugging."""
|
||||||
LLM_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
LLM_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@ -1458,20 +1761,18 @@ def main():
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
engine = GameEngine()
|
engine = GameEngine()
|
||||||
result = engine.generate(
|
result = engine.generate_with_tools_single(
|
||||||
player_action=args.action,
|
player_action=args.action,
|
||||||
last_narrative=args.last,
|
last_prompt=args.last,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.error:
|
if result.error:
|
||||||
print(f"ERROR: {result.error}", file=sys.stderr)
|
print(f"ERROR: {result.error}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
print(result.narrative)
|
print(result.book_log)
|
||||||
if result.choices:
|
if result.user_prompt:
|
||||||
print("\n--- Choices ---")
|
print(f"\n{result.user_prompt}")
|
||||||
for c in result.choices:
|
|
||||||
print(f" [{c}]")
|
|
||||||
if result.log_entry:
|
if result.log_entry:
|
||||||
print(f"\n[Log] {result.log_entry}")
|
print(f"\n[Log] {result.log_entry}")
|
||||||
if result.ambience:
|
if result.ambience:
|
||||||
|
|||||||
230
tools/engine.py.tmp
Normal file
230
tools/engine.py.tmp
Normal 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,
|
||||||
|
)
|
||||||
|
|
||||||
121
tools/run.py
121
tools/run.py
@ -24,7 +24,7 @@ from rich.markdown import Markdown as RichMarkdown
|
|||||||
from rich.theme import Theme
|
from rich.theme import Theme
|
||||||
|
|
||||||
# ── Game engine ─────────────────────────────────────────
|
# ── Game engine ─────────────────────────────────────────
|
||||||
from engine import GameEngine, GenerationResult, TurnResult
|
from engine import GameEngine, GenerationResult, TurnResult, LLM_LOG_PATH
|
||||||
|
|
||||||
# ── Optional miniaudio ────────────────────────────────────
|
# ── Optional miniaudio ────────────────────────────────────
|
||||||
try:
|
try:
|
||||||
@ -81,41 +81,28 @@ def _populate_if_empty():
|
|||||||
content = LOG_PATH.read_text().strip()
|
content = LOG_PATH.read_text().strip()
|
||||||
if content and len(content.splitlines()) > 2:
|
if content and len(content.splitlines()) > 2:
|
||||||
return
|
return
|
||||||
prev = _previous_log()
|
def clear_llm_log():
|
||||||
if prev:
|
"""Clear llm.log at start of app."""
|
||||||
lines = prev.read_text().splitlines()
|
LLM_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
lines[0] = f"# Session Log — {TODAY}"
|
LLM_LOG_PATH.write_text("")
|
||||||
LOG_PATH.write_text('\n'.join(lines) + '\n')
|
|
||||||
|
|
||||||
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():
|
def read_todo():
|
||||||
|
"""Read TODO items from journal.md."""
|
||||||
if not JOURNAL_PATH.exists():
|
if not JOURNAL_PATH.exists():
|
||||||
return ["—— No journal yet ——"]
|
return ["—— No journal yet ——"]
|
||||||
lines = JOURNAL_PATH.read_text().splitlines()
|
lines = LOG_PATH.read_text().splitlines()
|
||||||
in_todo = False
|
return [l for l in lines if l.strip() and not l.startswith('#')][-n:]
|
||||||
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 read_log_tail(n=200):
|
def read_log_tail(n=200):
|
||||||
|
"""Read the tail of the session log."""
|
||||||
if not LOG_PATH.exists():
|
if not LOG_PATH.exists():
|
||||||
return []
|
return []
|
||||||
lines = LOG_PATH.read_text().splitlines()
|
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():
|
def status_summary():
|
||||||
if not CHAR_PATH.exists():
|
if not CHAR_PATH.exists():
|
||||||
@ -346,6 +333,8 @@ class AutoStatic(Static):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
|
clear_llm_log()
|
||||||
|
ensure_log()
|
||||||
self.load()
|
self.load()
|
||||||
self.set_interval(REFRESH_SECS, self.load)
|
self.set_interval(REFRESH_SECS, self.load)
|
||||||
|
|
||||||
@ -710,6 +699,7 @@ class ChaosTUI(App):
|
|||||||
yield Button("♫", id="mute-btn", classes="mute-button")
|
yield Button("♫", id="mute-btn", classes="mute-button")
|
||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
|
clear_llm_log()
|
||||||
ensure_log()
|
ensure_log()
|
||||||
self.console._theme = MARKDOWN_THEME
|
self.console._theme = MARKDOWN_THEME
|
||||||
self._init_book()
|
self._init_book()
|
||||||
@ -810,7 +800,7 @@ class ChaosTUI(App):
|
|||||||
t.start()
|
t.start()
|
||||||
|
|
||||||
def _run_generation(self, player_action: str | None) -> None:
|
def _run_generation(self, player_action: str | None) -> None:
|
||||||
"""Worker thread: calls engine.generate_with_tools() and posts result back."""
|
"""Worker thread: calls engine.generate_with_tools() or generate_with_tools_single() based on configured strategy."""
|
||||||
import traceback
|
import traceback
|
||||||
last_prompt = self._last_prompt if self._last_prompt else None
|
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)
|
self.call_from_thread(self._on_debug, event_type, data)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = self.engine.generate_with_tools(
|
strategy = self.engine.config.get("llm", {}).get("strategy", "tools")
|
||||||
player_action=player_action,
|
if strategy == "tools":
|
||||||
last_prompt=last_prompt,
|
result = self.engine.generate_with_tools_single(
|
||||||
on_thought=on_thought,
|
player_action=player_action,
|
||||||
on_action=on_action,
|
last_prompt=last_prompt,
|
||||||
on_player_roll=self._on_player_roll,
|
on_thought=on_thought,
|
||||||
on_debug=on_debug,
|
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:
|
except Exception as e:
|
||||||
tb = traceback.format_exc()
|
tb = traceback.format_exc()
|
||||||
self.call_from_thread(self._on_generation_error, e, tb)
|
self.call_from_thread(self._on_generation_error, e, tb)
|
||||||
@ -939,6 +940,12 @@ class ChaosTUI(App):
|
|||||||
self._append_debug(f" ambience: {data['ambience']}")
|
self._append_debug(f" ambience: {data['ambience']}")
|
||||||
if data.get("extract_errors"):
|
if data.get("extract_errors"):
|
||||||
self._append_debug(f" extract errors: {data['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":
|
elif event_type == "tool_call":
|
||||||
tool = data.get("tool", "?")
|
tool = data.get("tool", "?")
|
||||||
args = data.get("args", {})
|
args = data.get("args", {})
|
||||||
@ -953,6 +960,32 @@ class ChaosTUI(App):
|
|||||||
label = data.get("label", "")
|
label = data.get("label", "")
|
||||||
err = data.get("error", "")
|
err = data.get("error", "")
|
||||||
self._append_debug(f" ✖ LLM error [{label}]: {err}")
|
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:
|
def _on_player_roll(self, dice: str, reason: str) -> str:
|
||||||
"""Called from worker thread. Shows roll popup, blocks until player responds."""
|
"""Called from worker thread. Shows roll popup, blocks until player responds."""
|
||||||
@ -1159,6 +1192,24 @@ class ChaosTUI(App):
|
|||||||
self._save_settings()
|
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():
|
def main():
|
||||||
import argparse
|
import argparse
|
||||||
parser = argparse.ArgumentParser(description="The Chaos TUI")
|
parser = argparse.ArgumentParser(description="The Chaos TUI")
|
||||||
|
|||||||
61
tools/test_imports.py
Executable file
61
tools/test_imports.py
Executable 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
49
tools/test_runtime.py
Executable 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)
|
||||||
Loading…
Reference in New Issue
Block a user