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