231 lines
8.9 KiB
Plaintext
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,
|
|
)
|
|
|