splinter-keep/tools/engine.py.tmp
2026-06-29 22:59:45 +02:00

231 lines
8.9 KiB
Plaintext

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