684 lines
27 KiB
Python
684 lines
27 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
strategies.py — Generation strategies for The Chaos engine.
|
|
|
|
Contains the three-phase conversational approach and the single-call
|
|
tool-based approach. All functions are standalone — no dependency on
|
|
GameEngine (config values and callbacks are passed explicitly).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import random
|
|
import re
|
|
from datetime import datetime
|
|
from typing import Iterator
|
|
|
|
from .models import GenerationResult, TurnResult
|
|
from .prompts import PROSE_PROMPT
|
|
from .llm import call_llm
|
|
from .tools_handler import (
|
|
execute_tool, describe_tool_action, describe_change,
|
|
parse_changes_block, extract_tool_calls,
|
|
)
|
|
from .context import build_system_prompt, build_user_message, build_prose_prompt
|
|
from .validation import auto_prompt, validate_narrative, validate_action
|
|
from .parsing import parse_response, log_turn_details
|
|
from . import state
|
|
|
|
|
|
# ── Synchronous (legacy) ───────────────────────────────────────────────────
|
|
|
|
def generate(
|
|
player_action: str | None = None,
|
|
last_narrative: str | None = None,
|
|
*,
|
|
model: str,
|
|
temperature: float,
|
|
timeout: int,
|
|
max_tokens: int,
|
|
) -> GenerationResult:
|
|
"""
|
|
Synchronous generation. Calls the LLM, parses the response,
|
|
and returns a GenerationResult.
|
|
"""
|
|
system = build_system_prompt()
|
|
user = build_user_message(
|
|
player_action=player_action, last_prompt=last_narrative
|
|
)
|
|
|
|
messages = [
|
|
{"role": "system", "content": system},
|
|
{"role": "user", "content": user},
|
|
]
|
|
|
|
try:
|
|
import litellm
|
|
except ImportError:
|
|
return GenerationResult(
|
|
narrative="",
|
|
error="litellm is not installed. Run: pip install litellm",
|
|
)
|
|
|
|
try:
|
|
response = litellm.completion(
|
|
model=model,
|
|
messages=messages,
|
|
temperature=temperature,
|
|
stream=False,
|
|
timeout=timeout,
|
|
)
|
|
text = response.choices[0].message.content or ""
|
|
except Exception as e:
|
|
return GenerationResult(
|
|
narrative="",
|
|
error=f"LLM call failed: {e}",
|
|
)
|
|
|
|
return parse_response(text)
|
|
|
|
|
|
# ── Streaming (legacy) ─────────────────────────────────────────────────────
|
|
|
|
def generate_stream(
|
|
player_action: str | None = None,
|
|
last_narrative: str | None = None,
|
|
*,
|
|
model: str,
|
|
temperature: float,
|
|
timeout: int,
|
|
max_tokens: int,
|
|
) -> Iterator[str]:
|
|
"""
|
|
Streaming generator. Yields text chunks as they arrive from the LLM.
|
|
On completion, the final yield is the FULL text (for parsing).
|
|
"""
|
|
system = build_system_prompt()
|
|
user = build_user_message(
|
|
player_action=player_action, last_prompt=last_narrative
|
|
)
|
|
|
|
messages = [
|
|
{"role": "system", "content": system},
|
|
{"role": "user", "content": user},
|
|
]
|
|
|
|
try:
|
|
import litellm
|
|
except ImportError:
|
|
yield json.dumps({
|
|
"error": "litellm is not installed. Run: pip install litellm"
|
|
})
|
|
return
|
|
|
|
try:
|
|
response = litellm.completion(
|
|
model=model,
|
|
messages=messages,
|
|
temperature=temperature,
|
|
stream=True,
|
|
timeout=timeout,
|
|
)
|
|
full_text = ""
|
|
for chunk in response:
|
|
delta = chunk.choices[0].delta.content or ""
|
|
if delta:
|
|
full_text += delta
|
|
yield full_text
|
|
yield full_text
|
|
except Exception as e:
|
|
yield json.dumps({"error": f"LLM call failed: {e}"})
|
|
|
|
|
|
# ── Three-phase generation ─────────────────────────────────────────────────
|
|
|
|
def generate_with_tools(
|
|
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,
|
|
*,
|
|
model: str,
|
|
temperature: float,
|
|
timeout: int,
|
|
max_tokens: int,
|
|
) -> TurnResult:
|
|
"""
|
|
Three-phase generation:
|
|
|
|
1. **Prose** — LLM writes the full book_log from context + player action.
|
|
2. **Summarize** — LLM condenses the book_log into one log line.
|
|
3. **Extract** — LLM reads the book_log and outputs tool calls for state changes.
|
|
"""
|
|
datetime_now = datetime.now()
|
|
state.append_llm_log(f"\n{'='*60}")
|
|
state.append_llm_log(f"=== Turn — {datetime_now.strftime('%Y-%m-%d %H:%M:%S')} ===")
|
|
state.append_llm_log(f"{'='*60}")
|
|
if player_action:
|
|
state.append_llm_log(f"Player: {player_action}")
|
|
elif last_prompt:
|
|
state.append_llm_log(f"Resume from: {last_prompt[:120]}")
|
|
|
|
die_roll = random.randint(1, 6)
|
|
state.append_llm_log(f"Dice: {die_roll} (1d6)")
|
|
|
|
# ── Pre-generation validation ────────────────────────────────────
|
|
if player_action:
|
|
valid, reason = validate_action(
|
|
player_action,
|
|
model=model,
|
|
timeout=timeout,
|
|
on_debug=on_debug,
|
|
)
|
|
if not valid:
|
|
state.append_llm_log(f"\n[VALIDATION REJECTED] {reason}")
|
|
fail_narrative = f"You can't do that — {reason}."
|
|
return TurnResult(
|
|
book_log=fail_narrative,
|
|
log_entry=fail_narrative,
|
|
user_prompt=auto_prompt(""),
|
|
)
|
|
|
|
book_log = None
|
|
changes_block = ""
|
|
log_entry = None
|
|
user_prompt = auto_prompt("")
|
|
ambience = None
|
|
debug_info = ""
|
|
changes: list[str] = []
|
|
|
|
for outer_attempt in range(3):
|
|
# ── Phase 1: Prose ────────────────────────────────────────────────
|
|
if on_action:
|
|
on_action(f"Phase 1/3: writing story (dice={die_roll})")
|
|
if on_debug:
|
|
on_debug("phase", {"phase": 1, "name": "prose", "status": "start", "dice": die_roll, "outer_attempt": outer_attempt + 1})
|
|
|
|
system = build_prose_prompt()
|
|
|
|
user = build_user_message(
|
|
player_action=player_action,
|
|
last_prompt=last_prompt,
|
|
)
|
|
user += f"\n\n*A die is cast: **{die_roll}** (1d6).*"
|
|
|
|
text = call_llm([
|
|
{"role": "system", "content": system},
|
|
{"role": "user", "content": user},
|
|
], model=model, temperature=temperature, timeout=timeout,
|
|
max_tokens=1024, label=f"Prose attempt {outer_attempt + 1}", on_debug=on_debug)
|
|
|
|
if not text or not text.strip():
|
|
if on_debug:
|
|
on_debug("phase", {"phase": 1, "status": "empty", "attempt": outer_attempt + 1})
|
|
continue
|
|
|
|
raw = text.strip()
|
|
changes_block = ""
|
|
if "### Changes" in raw:
|
|
parts = raw.split("### Changes", 1)
|
|
book_log = parts[0].strip()
|
|
changes_block = "### Changes" + parts[1]
|
|
else:
|
|
book_log = raw
|
|
if on_debug:
|
|
preview = book_log[:150].replace("\n", "\\n")
|
|
on_debug("phase", {"phase": 1, "status": "done", "chars": len(book_log), "changes": bool(changes_block), "preview": preview})
|
|
|
|
# ── Validation ────────────────────────────────────────────────────
|
|
if on_debug:
|
|
on_debug("phase", {"phase": 1, "name": "validation", "status": "start"})
|
|
valid, reason = validate_narrative(book_log, model=model, temperature=temperature, timeout=timeout, on_debug=on_debug)
|
|
if not valid:
|
|
if on_debug:
|
|
on_debug("phase", {"phase": 1, "status": "validation_failed", "reason": reason, "outer_attempt": outer_attempt + 1})
|
|
book_log = None
|
|
continue
|
|
|
|
# ── Phase 2: Summarize ────────────────────────────────────────────
|
|
if on_action:
|
|
on_action("Phase 2/3: summarizing story")
|
|
if on_debug:
|
|
on_debug("phase", {"phase": 2, "name": "summarize", "status": "start"})
|
|
|
|
log_context = state.read_recent_log()
|
|
log_entry = None
|
|
for p2_attempt in range(2):
|
|
context = book_log
|
|
if changes_block:
|
|
context += f"\n\n{changes_block}"
|
|
text = call_llm([
|
|
{"role": "user", "content":
|
|
f"Given the session log so far, summarize the new story in one line. "
|
|
f"Focus on who was involved (character and NPC names):\n\n"
|
|
f"## Session Log\n{log_context}\n\n"
|
|
f"## New Story\n{context}"}
|
|
], model=model, temperature=temperature, timeout=timeout,
|
|
max_tokens=max_tokens,
|
|
label=f"Summarize attempt {p2_attempt + 1}", on_debug=on_debug)
|
|
if text and text.strip():
|
|
log_entry = text.strip().split("\n")[0][:300]
|
|
if on_debug:
|
|
on_debug("phase", {"phase": 2, "status": "done", "summary": log_entry})
|
|
break
|
|
|
|
if not log_entry:
|
|
log_entry = book_log.split("\n")[0][:120]
|
|
if on_debug:
|
|
on_debug("phase", {"phase": 2, "status": "fallback", "summary": log_entry})
|
|
|
|
# ── Phase 3: Extract state changes ────────────────────────────────
|
|
if on_action:
|
|
on_action("Phase 3/3: extracting state changes")
|
|
if on_debug:
|
|
on_debug("phase", {"phase": 3, "name": "extract", "status": "start"})
|
|
|
|
user_prompt = auto_prompt(book_log)
|
|
ambience = None
|
|
phase3_errors = []
|
|
changes = []
|
|
|
|
# Step 1: Parse ### Changes block directly
|
|
if changes_block.strip():
|
|
for tc in parse_changes_block(changes_block):
|
|
name = tc["tool"]
|
|
args = tc.get("args", {})
|
|
if name == "finalize_turn":
|
|
continue
|
|
result = 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 = describe_change(name, args)
|
|
if desc:
|
|
changes.append(desc)
|
|
|
|
# Step 2: LLM Phase 3 for finalize_turn + any extra changes
|
|
previous_attempt = None
|
|
phase3_ok = False
|
|
for p3_attempt in range(5):
|
|
from paths import CHAR_PATH, WORLD_PATH
|
|
current_char = state.read_file(CHAR_PATH) or "*No character.*"
|
|
current_world = state.truncate_world(state.read_file(WORLD_PATH) or "") or "*No world.*"
|
|
|
|
phase3_prompt = (
|
|
f"## Current Character\n{current_char}\n\n"
|
|
f"## Current World\n{current_world}\n\n"
|
|
f"## Story\n{book_log}\n\n"
|
|
)
|
|
if changes_block.strip():
|
|
phase3_prompt += (
|
|
f"## Changes already applied\n{changes_block}\n\n"
|
|
f"Output the finalize_turn tool to end the turn. "
|
|
f"Add extra tool calls if you spot changes the list above missed.\n\n"
|
|
)
|
|
else:
|
|
phase3_prompt += (
|
|
f"Read the story and compare with current state. Output tool calls for any changes:\n\n"
|
|
)
|
|
phase3_prompt += (
|
|
f"Output ```tool blocks for changes only. Examples:\n\n"
|
|
)
|
|
|
|
if previous_attempt:
|
|
phase3_prompt += (
|
|
f"--- PREVIOUS ATTEMPT (had errors) ---\n"
|
|
f"{previous_attempt['output']}\n\n"
|
|
f"--- FEEDBACK ---\n"
|
|
f"{previous_attempt['feedback']}\n\n"
|
|
f"Fix the issues above. Output corrected tool calls only.\n\n"
|
|
)
|
|
|
|
text = call_llm([
|
|
{"role": "user", "content": phase3_prompt +
|
|
f"```tool\n{{\"tool\": \"modify_vitals\", \"args\": {{\"current_hp\": 5, \"cash\": 45}}}}\n```\n"
|
|
f"```tool\n{{\"tool\": \"modify_traits\", \"args\": {{\"dex\": 15}}}}\n```\n"
|
|
f"```tool\n{{\"tool\": \"add_to_inventory\", \"args\": {{\"item\": \"Silver key\"}}}}\n```\n"
|
|
f"```tool\n{{\"tool\": \"remove_from_inventory\", \"args\": {{\"item\": \"Torches (10)\"}}}}\n```\n"
|
|
f"```tool\n{{\"tool\": \"replace_gear\", \"args\": {{\"before\": \"Mace (1d6+1)\", \"after\": \"Mace (1d6+2, sharpened)\"}}}}\n```\n"
|
|
f"```tool\n{{\"tool\": \"add_note\", \"args\": {{\"note\": \"Found a hidden passage under the temple\"}}}}\n```\n"
|
|
f"```tool\n{{\"tool\": \"replace_note\", \"args\": {{\"before\": \"Old note text\", \"after\": \"New note text\"}}}}\n```\n"
|
|
f"```tool\n{{\"tool\": \"world_update\", \"args\": {{\"content\": \"# The World\\n\\n...full new world state...\"}}}}\n```\n"
|
|
f"```tool\n{{\"tool\": \"journal_update\", \"args\": {{\"add\": [\"Investigate the mine\"], \"done\": [\"Defeat the demon\"]}}}}\n```\n"
|
|
f"```tool\n{{\"tool\": \"finalize_turn\", \"args\": {{\"user_prompt\": \"What do you do?\", \"ambience\": \"dungeon\"}}}}\n```\n\n"
|
|
f"Only output tools for things that actually changed. Omit unchanged fields."}
|
|
], model=model, temperature=temperature, timeout=timeout,
|
|
max_tokens=max_tokens,
|
|
label=f"Extract attempt {p3_attempt + 1}", on_debug=on_debug)
|
|
|
|
if not text or not text.strip():
|
|
if on_debug:
|
|
on_debug("phase", {"phase": 3, "status": "empty", "attempt": p3_attempt + 1})
|
|
continue
|
|
|
|
tool_calls_list = extract_tool_calls(
|
|
text, round_num=p3_attempt + 1, on_debug=on_debug
|
|
)
|
|
if on_debug and tool_calls_list:
|
|
names = [tc.get("tool", "?") for tc in tool_calls_list if tc.get("tool") != "finalize_turn"]
|
|
fin = any(tc.get("tool") == "finalize_turn" for tc in tool_calls_list)
|
|
on_debug("phase", {"phase": 3, "status": "tools_found", "tools": names, "has_finalize": fin})
|
|
|
|
errors: list[str] = []
|
|
attempt_changes: list[str] = []
|
|
for tc in tool_calls_list:
|
|
name = tc.get("tool", "?")
|
|
args = tc.get("args", {})
|
|
if name == "finalize_turn":
|
|
if args.get("user_prompt"):
|
|
user_prompt = args["user_prompt"]
|
|
if args.get("ambience"):
|
|
ambience = args["ambience"]
|
|
continue
|
|
if on_action:
|
|
on_action(f"State: {describe_tool_action(name, args)}")
|
|
if on_debug:
|
|
on_debug("tool_call", {"round": p3_attempt + 1, "tool": name, "args": args})
|
|
|
|
if name == "player_roll" and on_player_roll:
|
|
dice = args.get("dice", "1d6")
|
|
reason = args.get("reason", "a check")
|
|
roll_val = on_player_roll(dice, reason)
|
|
result = f"Player rolled {dice} for '{reason}': {roll_val}"
|
|
else:
|
|
result = execute_tool(name, args)
|
|
|
|
if result.startswith("**Error:") or result.startswith("Tool error") or result.startswith("Unknown"):
|
|
errors.append(f"{name}: {result}")
|
|
else:
|
|
desc = describe_change(name, args)
|
|
if desc:
|
|
attempt_changes.append(desc)
|
|
if on_debug:
|
|
on_debug("tool_result", {"round": p3_attempt + 1, "tool": name, "result": result})
|
|
|
|
if not errors:
|
|
phase3_ok = True
|
|
debug_info = ""
|
|
changes.extend(attempt_changes)
|
|
if on_debug:
|
|
on_debug("phase", {"phase": 3, "status": "done", "applied": len([tc for tc in tool_calls_list if tc.get("tool") != "finalize_turn"])})
|
|
break
|
|
|
|
phase3_errors = errors
|
|
debug_info = "; ".join(errors)
|
|
if on_debug:
|
|
on_debug("phase", {"phase": 3, "status": "errors", "errors": errors, "attempt": p3_attempt + 1})
|
|
|
|
feedback_lines = ["The previous tool calls had errors:"]
|
|
for e in errors:
|
|
feedback_lines.append(f"- {e}")
|
|
feedback_lines.append("")
|
|
feedback_lines.append("Fix ALL issues above. Use correct tool names, valid JSON, and reasonable values.")
|
|
previous_attempt = {"output": text, "feedback": "\n".join(feedback_lines)}
|
|
|
|
if phase3_ok:
|
|
break
|
|
|
|
if on_debug:
|
|
on_debug("phase", {"phase": 3, "status": "exhausted", "errors": phase3_errors})
|
|
on_debug("phase", {"phase": 1, "status": "retry_after_phase3_failure", "outer_attempt": outer_attempt + 1})
|
|
book_log = None
|
|
|
|
if not book_log:
|
|
return TurnResult(error="Generation failed after exhausting all retries")
|
|
|
|
if on_action:
|
|
on_action("Turn complete")
|
|
if on_debug:
|
|
on_debug("phase_done", {
|
|
"book_log_chars": len(book_log),
|
|
"log_entry": log_entry,
|
|
"user_prompt": user_prompt,
|
|
"ambience": ambience,
|
|
"extract_errors": debug_info or None,
|
|
})
|
|
|
|
state.append_llm_log(
|
|
f"\n--- FINAL ---\n"
|
|
f"book_log: {book_log[:200]}\n"
|
|
f"log_entry: {log_entry}\n"
|
|
f"user_prompt: {user_prompt}\n"
|
|
f"ambience: {ambience}\n"
|
|
)
|
|
return TurnResult(
|
|
book_log=book_log,
|
|
log_entry=log_entry,
|
|
user_prompt=user_prompt,
|
|
ambience=ambience,
|
|
debug_info=debug_info,
|
|
changes=changes,
|
|
)
|
|
|
|
|
|
# ── Single-call generation ─────────────────────────────────────────────────
|
|
|
|
def generate_with_tools_single(
|
|
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,
|
|
*,
|
|
model: str,
|
|
temperature: float,
|
|
timeout: int,
|
|
max_tokens: int,
|
|
) -> 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.
|
|
"""
|
|
datetime_now = datetime.now()
|
|
state.append_llm_log(f"\n{'='*60}")
|
|
state.append_llm_log(f"=== Turn — {datetime_now.strftime('%Y-%m-%d %H:%M:%S')} ===")
|
|
state.append_llm_log(f"{'='*60}")
|
|
if player_action:
|
|
state.append_llm_log(f"Player: {player_action}")
|
|
elif last_prompt:
|
|
state.append_llm_log(f"Resume from: {last_prompt[:120]}")
|
|
|
|
strategy_name = "tools"
|
|
if on_action:
|
|
on_action(f"LLM: {model} | temp={temperature} | tokens={max_tokens} | strategy={strategy_name}")
|
|
if on_debug:
|
|
on_debug("config", {"model": model, "temperature": temperature, "max_tokens": max_tokens, "strategy": strategy_name})
|
|
|
|
die_roll = random.randint(1, 6)
|
|
state.append_llm_log(f"Dice: {die_roll} (1d6)")
|
|
|
|
system = """You are an RPG dungeon master. The player just took an action.
|
|
|
|
You are the sole authority over the game state. The player's action is a **proposal**, not a fact. If their action contradicts the character sheet (e.g. using an item they don't have, spending cash they don't have, claiming stats they don't have), narrate the failure with the narrative tool and do NOT call any state-changing tools.
|
|
|
|
**Inventory rule**: If the player wants to use an item, verify it's on the character sheet first. If it is, you MUST call `remove_from_inventory` for that item AND apply effects (e.g. `modify_vitals`). If it's not on the sheet, narrate the failure — do not let them use items they don't have.
|
|
|
|
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 += build_prose_prompt()
|
|
|
|
user = build_user_message(
|
|
player_action=player_action,
|
|
last_prompt=last_prompt,
|
|
)
|
|
user += f"\n\n*A die is cast: **{die_roll}** (1d6).*"
|
|
|
|
# ── Pre-generation validation ────────────────────────────────────
|
|
if player_action:
|
|
valid, reason = validate_action(
|
|
player_action,
|
|
model=model,
|
|
timeout=timeout,
|
|
on_debug=on_debug,
|
|
)
|
|
if not valid:
|
|
state.append_llm_log(f"\n[VALIDATION REJECTED] {reason}")
|
|
fail_narrative = f"You can't do that — {reason}."
|
|
return TurnResult(
|
|
book_log=fail_narrative,
|
|
log_entry=fail_narrative,
|
|
user_prompt=auto_prompt(""),
|
|
)
|
|
|
|
start_time = datetime.now()
|
|
state.append_llm_log(f"\n[TOOL] Single call — {len(system)} chars system, {len(user)} chars user")
|
|
state.append_llm_log(f"System preview: {system.split(chr(10))[0][:80]}...")
|
|
state.append_llm_log(f"User preview: {user.split(chr(10))[0][:80]}...")
|
|
|
|
text = call_llm(
|
|
[{"role": "system", "content": system},
|
|
{"role": "user", "content": user}],
|
|
model=model, temperature=temperature, timeout=timeout,
|
|
max_tokens=4096, label="Single tool call", on_debug=on_debug,
|
|
)
|
|
|
|
total_elapsed = (datetime.now() - start_time).total_seconds() * 1000
|
|
if text:
|
|
state.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 = ""
|
|
log_entry = None
|
|
user_prompt = auto_prompt("")
|
|
ambience = None
|
|
tool_calls = []
|
|
changes: list[str] = []
|
|
phase3_errors: list[str] = []
|
|
|
|
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", {})
|
|
state.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:
|
|
state.append_llm_log(f"\n[EXTRACT] bad JSON: {e}")
|
|
continue
|
|
|
|
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]
|
|
state.append_llm_log(f"\n[SUMMARY] \"{log_entry}\"")
|
|
|
|
extr_start = datetime.now()
|
|
for tc in tool_calls:
|
|
name = tc.get("tool", "unknown")
|
|
args = tc.get("args", {})
|
|
if name in ("finalize_turn", "narrative"):
|
|
continue
|
|
result = 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 = describe_change(name, args)
|
|
if desc:
|
|
changes.append(desc)
|
|
|
|
apply_elapsed = (datetime.now() - extr_start).total_seconds() * 1000
|
|
state.append_llm_log(f"\n[APPLY] {len(changes)} changes in {apply_elapsed:.1f}ms")
|
|
else:
|
|
state.append_llm_log(f"\n[TOOL] no tool blocks found")
|
|
|
|
elapsed = (datetime.now() - start_time).total_seconds() * 1000
|
|
|
|
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,
|
|
})
|
|
|
|
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=model,
|
|
temperature=temperature,
|
|
max_tokens=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,
|
|
)
|