splinter-keep/tools/engine_lib/tools_handler.py
2026-07-05 12:50:22 +02:00

413 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import json
import re
from .paths import (
AMBIENCE_PATH, CHAR_PATH, WORLD_PATH,
get_mechanics_path, get_core_rules_path,
get_character_creation_path, get_end_game_path,
)
from .state import read_file, validate_update_size, update_journal, append_llm_log, get_valid_ambiences
TOOL_REGISTRY: dict[str, dict] = {
"modify_traits": {"description": "Change STR/DEX/WIL.", "args": {"stat": "str/dex/wil", "operation": "set|diff", "value": "new value"}},
"modify_vitals": {"description": "Change HP, max_hp, weapon, armour.", "args": {"stat": "HP|max_hp|weapon|armour", "operation": "set|diff", "value": "number or string"}},
"modify_cash": {"description": "Change cash amount.", "args": {"operation": "set|diff", "value": "number"}},
"modify_inventory": {"description": "Add, remove, or replace gear. For add with quantity, item name prefix matches existing stack.", "args": {"operation": "add|remove|replace", "item": "item name", "value": "quantity (int) for add/remove, new text for replace"}},
"modify_note": {"description": "Add, remove, or replace a note.", "args": {"operation": "add|remove|replace", "stat": "existing text (for remove/replace)", "value": "note text"}},
"modify_world": {"description": "Replace full world state.", "args": {"operation": "set", "value": "full world markdown"}},
"modify_journal": {"description": "Update TODO/DONE list.", "args": {"operation": "add|done|remove", "value": "entry text"}},
"finalize_turn": {"description": "End turn.", "args": {"ambience": "soundscape name", "log_entry": "one-line summary of what happened", "meta_log": "optional behind-the-scenes mechanics explanation"}},
"read_rules": {"description": "Read a rules file by category. Categories: mechanics (full mechanics reference), core (core mechanics), character_creation, end_game (end-game closure rules). Call when you need details beyond the Core Rules in the prompt.", "args": {"category": "optional — one of: mechanics, core, character_creation, end_game (default: mechanics)"}},
}
def patch_character(pattern: str, repl: str, count: int = 1, flags: int = 0) -> str:
"""Apply a regex replacement to character.md. Returns error msg or empty string."""
text = CHAR_PATH.read_text()
new, n = re.subn(pattern, repl, text, count=count, flags=flags)
if n == 0:
return f"**Error:** pattern not found:\n{pattern}"
CHAR_PATH.write_text(new)
return ""
VITAL_LABELS = {
"HP": "Current Health",
"max_hp": "Max Health",
"weapon": "Weapon",
"armour": "Armour",
}
def _parse_current_int(label: str) -> int | None:
"""Read a numeric value from character.md by label."""
text = CHAR_PATH.read_text()
m = re.search(rf"^- \*\*{re.escape(label)}:\*\*\s*(\d+)", text, re.MULTILINE)
return int(m.group(1)) if m else None
def _apply_diff(label: str, diff: int) -> str:
"""Apply a numeric diff to a label in character.md. Returns result string."""
current = _parse_current_int(label)
if current is None:
return f"**Error:** cannot find numeric {label} in character sheet"
new_val = current + diff
err = patch_character(
rf"^(- \*\*{re.escape(label)}:\*\*\s*)\d+",
rf"\g<1>{new_val}",
count=1, flags=re.MULTILINE,
)
if err:
return err
return f"{label}: {current}{new_val} ({diff:+d})"
def tool_modify_traits(args: dict) -> str:
errors = []
for stat in ("str", "dex", "wil"):
val = args.get(stat)
if val is not None:
err = patch_character(
rf"^(- \*\*{stat.upper()}:\*\*\s*)\d+", rf"\g<1>{val}", count=1, flags=re.MULTILINE
)
if err:
errors.append(err)
return "; ".join(errors) if errors else "Traits updated."
def tool_modify_vitals(args: dict) -> str:
stat = args.get("stat", "")
op = args.get("operation", "set")
value = args.get("value")
label = VITAL_LABELS.get(stat)
if not label:
return f"**Error:** unknown stat '{stat}'. Use: HP, max_hp, weapon, armour."
if op == "set":
err = patch_character(
rf"^(- \*\*{re.escape(label)}:\*\*\s*).*", rf"\g<1>{value}",
count=1, flags=re.MULTILINE,
)
return f"{label} set to {value}." if not err else err
elif op == "diff":
if stat in ("weapon", "armour"):
return f"**Error:** diff not supported for {stat}. Use set."
try:
diff = int(value)
except (TypeError, ValueError):
return f"**Error:** value must be a number for diff, got {value!r}"
return _apply_diff(label, diff)
else:
return f"**Error:** unknown operation '{op}'. Use set or diff."
def tool_modify_cash(args: dict) -> str:
op = args.get("operation", "set")
value = args.get("value")
if op == "set":
err = patch_character(
rf"^(- \*\*Cash:\*\*\s*).*", rf"\g<1>{value}",
count=1, flags=re.MULTILINE,
)
return f"Cash set to {value}." if not err else err
elif op == "diff":
try:
diff = int(value)
except (TypeError, ValueError):
return f"**Error:** value must be a number for diff, got {value!r}"
return _apply_diff("Cash", diff)
else:
return f"**Error:** unknown operation '{op}'. Use set or diff."
def _find_gear_line(text: str, item_name: str) -> tuple[str, int, str] | None:
"""Find a gear line matching item_name (prefix match). Returns (full_line, line_start, quantity_str) or None."""
for m in re.finditer(r"^- (.+)$", text, re.MULTILINE):
line = m.group(1)
line_start = m.start(1) - 2 # start of "- "
line_lower = line.lower().strip()
name_lower = item_name.lower().strip()
if line_lower.startswith(name_lower):
qty_match = re.search(r"\((\d+)\)\s*$", line)
qty = qty_match.group(1) if qty_match else None
return line, line_start, qty
return None
def tool_modify_inventory(args: dict) -> str:
op = args.get("operation", "")
item = args.get("item", "")
value = args.get("value")
if not item:
return "**Error:** `item` is required."
text = CHAR_PATH.read_text()
if op == "add":
existing = _find_gear_line(text, item)
if existing:
full_line, line_start, qty_str = existing
if qty_str is not None and value is not None:
try:
new_qty = int(qty_str) + int(value)
except (TypeError, ValueError):
return f"**Error:** value must be a number for add with quantity, got {value!r}"
if new_qty <= 0:
new_text = text[:line_start] + text[line_start + len(full_line) + 2:]
CHAR_PATH.write_text(new_text)
return f"Removed all {item} from inventory (quantity reached 0)."
new_line = full_line.replace(f"({qty_str})", f"({new_qty})", 1)
CHAR_PATH.write_text(text.replace(full_line, new_line, 1))
return f"{item}: {qty_str}{new_qty}"
if qty_str is None:
return f"Item '{full_line}' exists but has no quantity to stack. Use replace to update it."
gear_section = re.search(r"^## Gear\n", text, re.MULTILINE)
insert_at = gear_section.end() if gear_section else len(text)
if not gear_section:
text += "\n## Gear\n"
insert_at = len(text)
val_str = f" ({value})" if value is not None else ""
new_entry = f"- {item}{val_str}\n"
text = text[:insert_at] + new_entry + text[insert_at:]
CHAR_PATH.write_text(text)
return f"Added to inventory: {item}{val_str}"
elif op == "remove":
existing = _find_gear_line(text, item)
if not existing:
return f"**Error:** item not found: {item}"
full_line, line_start, qty_str = existing
if qty_str is not None and value is not None:
try:
new_qty = int(qty_str) - int(value)
except (TypeError, ValueError):
return f"**Error:** value must be a number for remove with quantity, got {value!r}"
if new_qty <= 0:
new_text = text[:line_start] + text[line_start + len(full_line) + 2:]
CHAR_PATH.write_text(new_text)
return f"Removed all {item} from inventory (quantity reached 0)."
new_line = full_line.replace(f"({qty_str})", f"({new_qty})", 1)
CHAR_PATH.write_text(text.replace(full_line, new_line, 1))
return f"{item}: {qty_str}{new_qty}"
err = patch_character(rf"^- {re.escape(full_line)}\n?", "", count=1, flags=re.MULTILINE)
if err:
return f"**Error:** item not found: {item}"
return f"Removed from inventory: {full_line}"
elif op == "replace":
after = value or ""
if not after:
return "**Error:** `value` (new text) is required for replace."
existing = _find_gear_line(text, item)
if not existing:
return f"**Error:** item not found: {item}"
full_line = existing[0]
err = patch_character(rf"^- {re.escape(full_line)}", f"- {after}", count=1, flags=re.MULTILINE)
if err:
return f"**Error:** item not found: {item}"
return f"Gear replaced: {full_line}{after}"
return "**Error:** unknown operation. Use add, remove, or replace."
def tool_modify_note(args: dict) -> str:
op = args.get("operation", "")
stat = args.get("stat", "")
value = args.get("value", "")
if op == "add":
if not value:
return "**Error:** `value` (note text) is required for add."
text = CHAR_PATH.read_text()
notes_section = re.search(r"^## Notes & Scribbles\n", text, re.MULTILINE)
if notes_section:
text = text[:notes_section.end()] + f"- {value}\n" + text[notes_section.end():]
else:
text += f"\n## Notes & Scribbles\n- {value}\n"
CHAR_PATH.write_text(text)
return f"Note added: {value}"
elif op == "remove":
if not stat:
return "**Error:** `stat` (exact note text) is required for remove."
err = patch_character(rf"^- {re.escape(stat)}\n?", "", count=1, flags=re.MULTILINE)
if err:
return f"**Error:** note not found: {stat}"
return f"Note removed."
elif op == "replace":
if not stat or not value:
return "**Error:** `stat` (old text) and `value` (new text) are required for replace."
err = patch_character(rf"^- {re.escape(stat)}", f"- {value}", count=1, flags=re.MULTILINE)
if err:
return f"**Error:** note not found: {stat}"
return f"Note replaced."
return "**Error:** unknown operation. Use add, remove, or replace."
def tool_modify_world(args: dict) -> str:
op = args.get("operation", "set")
value = args.get("value", "")
if op != "set":
return f"**Error:** only 'set' operation is supported for world, got '{op}'."
if not value:
return "**Error:** `value` (full world markdown) is required."
if not validate_update_size("world", value, WORLD_PATH):
return "**Error:** Update rejected — content is too short (likely a partial paste)."
WORLD_PATH.write_text(value.strip() + "\n")
return "World state updated."
def tool_modify_journal(args: dict) -> str:
op = args.get("operation", "")
value = args.get("value", "")
if not value:
return "**Error:** `value` (entry text) is required."
if op == "add":
update_journal(add=[value])
return f"Journal entry added: {value}"
elif op == "done":
update_journal(done=[value])
return f"Journal entry done: {value}"
elif op == "remove":
update_journal(done=[value])
return f"Journal entry removed: {value}"
else:
return f"**Error:** unknown operation '{op}'. Use add, done, or remove."
def tool_finalize_turn(args: dict) -> str:
"""Validate ambience and write to AMBIENCE_PATH."""
raw = (args or {}).get("ambience", "").strip().lower()
if not raw:
AMBIENCE_PATH.write_text("silence\n")
return "Ambience set to silence."
valid = get_valid_ambiences()
if raw not in valid:
append_llm_log(f"\n[WARN] invalid ambience '{raw}', allowed: {sorted(valid)}")
return f"**Error:** invalid ambience '{raw}'. Allowed: {', '.join(sorted(valid))}."
AMBIENCE_PATH.write_text(raw + "\n")
return f"Ambience set to {raw}."
RULES_CATEGORIES = {
"mechanics": get_mechanics_path(),
"core": get_core_rules_path(),
"character_creation": get_character_creation_path(),
"end_game": get_end_game_path(),
}
def tool_read_rules(args: dict) -> str:
"""Read a rules file by category and return its content."""
category = (args or {}).get("category", "mechanics")
path = RULES_CATEGORIES.get(category)
if not path:
allowed = ", ".join(RULES_CATEGORIES)
return f"**Error:** unknown category '{category}'. Allowed: {allowed}."
content = read_file(path)
if not content:
return f"**Error:** {path.name} not found."
return content
def execute_tool(tool_name: str, args: dict) -> str:
"""Execute a tool by name. Returns result string."""
fn_map = {
"modify_traits": tool_modify_traits,
"modify_vitals": tool_modify_vitals,
"modify_cash": tool_modify_cash,
"modify_inventory": tool_modify_inventory,
"modify_note": tool_modify_note,
"modify_world": tool_modify_world,
"modify_journal": tool_modify_journal,
"finalize_turn": tool_finalize_turn,
"read_rules": tool_read_rules,
}
fn = fn_map.get(tool_name)
if not fn:
return f"Unknown tool: {tool_name}"
try:
return fn(args)
except Exception as e:
import traceback
tb = traceback.format_exc()
append_llm_log(f"\n--- TOOL ERROR ({tool_name}) ---\n{tb}")
return f"Tool error ({tool_name}): {e}"
def describe_change(tool_name: str, args: dict) -> str:
"""Build a compact human-readable change description from a tool call."""
if tool_name == "modify_vitals":
return f"{args.get('stat', '?')}: {args.get('operation', '?')} {args.get('value', '?')}"
elif tool_name == "modify_cash":
op = args.get("operation", "?")
val = args.get("value", "?")
return f"💰 Cash {op} {val}"
elif tool_name == "modify_traits":
parts = []
for k, v in args.items():
parts.append(f"{k.upper()}: {v}")
return f"{', '.join(parts)}"
elif tool_name == "modify_inventory":
op = args.get("operation", "?")
item = args.get("item", "?")
val = args.get("value", "")
return f"{'' if op == 'replace' else '+' if op == 'add' else ''} {item}{f' ({val})' if val else ''}"
elif tool_name == "modify_note":
op = args.get("operation", "?")
val = (args.get("value") or args.get("stat") or "?")[:60]
return f"📝 {op}: {val}{'' if len(val) >= 60 else ''}"
elif tool_name == "modify_world":
return "🌍 World updated"
elif tool_name == "modify_journal":
op = args.get("operation", "?")
val = args.get("value", "?")
return f"{'' if op == 'done' else '📋'} {val}"
elif tool_name == "finalize_turn":
a = args.get("ambience", "")
return f"{a}" if a else ""
return ""
def extract_tool_calls(text: str) -> list[dict]:
"""Extract tool calls from ```tool blocks in LLM response."""
calls = []
seen = set()
for m in re.finditer(r"```tool\s*\n?", text):
try:
decoder = json.JSONDecoder()
obj, end = decoder.raw_decode(text, m.end())
except (json.JSONDecodeError, ValueError, StopIteration):
close = text.find("```", m.end())
if close > 0:
raw = text[m.end():close].strip()
raw = re.sub(r'"(?:[^"\\]|\\.)*"', lambda x: x.group(0).replace("\n", "\\n"), raw, flags=re.DOTALL)
try:
obj = json.loads(raw)
except json.JSONDecodeError:
continue
else:
continue
if not isinstance(obj, dict) or "tool" not in obj:
continue
key = (obj["tool"], json.dumps(obj.get("args", {}), sort_keys=True))
if key not in seen:
seen.add(key)
calls.append(obj)
return calls