Simpler tool calls with examples

This commit is contained in:
Dejvino 2026-06-28 16:30:36 +02:00
parent 84b53cfd0c
commit e74dd07699

View File

@ -420,55 +420,18 @@ class GameEngine:
# ── Tool Infrastructure ──────────────────────────────────────────── # ── Tool Infrastructure ────────────────────────────────────────────
TOOL_REGISTRY: dict[str, dict] = { TOOL_REGISTRY: dict[str, dict] = {
"read_file": { "roll": {"description": "Roll dice.", "args": {"dice": "1d6", "modifier": "+1"}},
"description": "Read a game state file.", "player_roll": {"description": "Ask player to roll.", "args": {"dice": "1d6", "reason": "why"}},
"args": {"file": "character | world | book | log | journal"}, "modify_traits": {"description": "Change STR/DEX/WIL.", "args": {"str": "optional", "dex": "optional", "wil": "optional"}},
}, "modify_vitals": {"description": "Change HP, cash, weapon, armour.", "args": {"current_hp": "optional", "max_hp": "optional", "cash": "optional", "weapon": "optional", "armour": "optional"}},
"roll": { "add_to_inventory": {"description": "Add item to gear.", "args": {"item": "item name and stats"}},
"description": "Roll dice and return the outcome.", "remove_from_inventory": {"description": "Remove item from gear.", "args": {"item": "exact item text"}},
"args": {"dice": "e.g. 1d6, 2d6", "modifier": "optional +N or -N"}, "replace_gear": {"description": "Replace gear by exact match.", "args": {"before": "exact text", "after": "new text"}},
}, "add_note": {"description": "Add note to sheet.", "args": {"note": "note content"}},
"think": { "replace_note": {"description": "Replace note by exact match.", "args": {"before": "exact text", "after": "new text"}},
"description": "Internal reasoning shown in the game status bar.", "world_update": {"description": "Replace world state.", "args": {"content": "full world markdown"}},
"args": {"thought": "Your reasoning."}, "journal_update": {"description": "Update TODO/DONE.", "args": {"add": "[...]", "done": "[...]"}},
}, "finalize_turn": {"description": "End turn.", "args": {"user_prompt": "question for player", "ambience": "soundscape name"}},
"player_roll": {
"description": "Ask the player to physically roll dice and enter the result.",
"args": {"dice": "e.g. 2d6+1", "reason": "Why the roll is needed (shown to player)"},
},
"character_get": {
"description": "Read the full character sheet.",
"args": {},
},
"character_update": {
"description": "Replace the character sheet with a new full version (ONLY if HP/cash/gear/stats changed).",
"args": {"content": "Full character sheet markdown"},
},
"world_get": {
"description": "Read the full world state.",
"args": {},
},
"world_update": {
"description": "Replace the world state with a new full version (ONLY if NPCs/locations/threads changed).",
"args": {"content": "Full world state markdown"},
},
"journal_get": {
"description": "Read the journal (TODO / DONE).",
"args": {},
},
"journal_update": {
"description": "Add or complete journal entries.",
"args": {"add": "Optional: list of new TODO items", "done": "Optional: list of completed items"},
},
"finalize_turn": {
"description": "Complete the turn.",
"args": {
"book_log": "[Required] Full narrative — appended to story book (permanent record)",
"user_prompt": "[Required] Short prompt for player — NOT recorded, 1-3 sentences",
"log_entry": "[Optional] One-sentence summary",
"ambience": "[Optional] Soundscape name",
},
},
} }
def _tool_think(self, args: dict) -> str: def _tool_think(self, args: dict) -> str:
@ -510,20 +473,98 @@ class GameEngine:
mod_str = f" {'+' if mod >= 0 else ''}{mod}" if mod != 0 else "" mod_str = f" {'+' if mod >= 0 else ''}{mod}" if mod != 0 else ""
return f"Roll: {dice_str}{mod_str} → [{', '.join(str(r) for r in rolls)}] = {total}" return f"Roll: {dice_str}{mod_str} → [{', '.join(str(r) for r in rolls)}] = {total}"
def _tool_character_get(self, args: dict) -> str: def _patch_character(self, pattern: str, repl: str, count: int = 1, flags: int = 0) -> str:
return self._read_file(CHAR_PATH) or "*Character sheet is empty.*" """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 ""
def _tool_character_update(self, args: dict) -> str: def _tool_modify_traits(self, args: dict) -> str:
content = (args or {}).get("content", "") errors = []
if not content: for stat in ("str", "dex", "wil"):
return "**Error:** `content` is required." val = args.get(stat)
if not self._validate_update_size("character", content, CHAR_PATH): if val is not None:
return "**Error:** Update rejected — content is too short (likely a partial paste)." err = self._patch_character(
CHAR_PATH.write_text(content.strip() + "\n") rf"^(- \*\*{stat.upper()}:\*\*\s*)\d+", rf"\g<1>{val}", count=1, flags=re.MULTILINE
return "Character sheet updated." )
if err:
errors.append(err)
return "; ".join(errors) if errors else "Traits updated."
def _tool_world_get(self, args: dict) -> str: def _tool_modify_vitals(self, args: dict) -> str:
return self._read_file(WORLD_PATH) or "*World state is empty.*" errors = []
for field, label in [("current_hp", "Current Health"), ("max_hp", "Max Health"),
("cash", "Cash"), ("weapon", "Weapon"), ("armour", "Armour")]:
val = args.get(field)
if val is not None:
err = self._patch_character(
rf"^(- \*\*{label}:\*\*\s*).*", rf"\g<1>{val}", count=1, flags=re.MULTILINE
)
if err:
errors.append(err)
return "; ".join(errors) if errors else "Vitals updated."
def _tool_add_to_inventory(self, args: dict) -> str:
item = (args or {}).get("item", "")
if not item:
return "**Error:** `item` is required."
text = CHAR_PATH.read_text()
if item in text:
return f"Item already in inventory: {item}"
# Insert after last gear item or after "## Gear" header
gear_section = re.search(r"^## Gear\n", text, re.MULTILINE)
if gear_section:
insert_at = gear_section.end()
text = text[:insert_at] + f"- {item}\n" + text[insert_at:]
else:
text += f"\n## Gear\n- {item}\n"
CHAR_PATH.write_text(text)
return f"Added to inventory: {item}"
def _tool_remove_from_inventory(self, args: dict) -> str:
item = (args or {}).get("item", "")
if not item:
return "**Error:** `item` is required."
err = self._patch_character(rf"^- {re.escape(item)}\n?", "", count=1, flags=re.MULTILINE)
if err:
return f"**Error:** item not found: {item}"
return f"Removed from inventory: {item}"
def _tool_replace_gear(self, args: dict) -> str:
before = (args or {}).get("before", "")
after = (args or {}).get("after", "")
if not before or not after:
return "**Error:** `before` and `after` are required."
err = self._patch_character(rf"^- {re.escape(before)}", f"- {after}", count=1, flags=re.MULTILINE)
if err:
return f"**Error:** gear not found: {before}"
return f"Gear replaced: {before}{after}"
def _tool_add_note(self, args: dict) -> str:
note = (args or {}).get("note", "")
if not note:
return "**Error:** `note` is required."
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"- {note}\n" + text[notes_section.end():]
else:
text += f"\n## Notes & Scribbles\n- {note}\n"
CHAR_PATH.write_text(text)
return f"Note added: {note}"
def _tool_replace_note(self, args: dict) -> str:
before = (args or {}).get("before", "")
after = (args or {}).get("after", "")
if not before or not after:
return "**Error:** `before` and `after` are required."
err = self._patch_character(rf"^- {re.escape(before)}", f"- {after}", count=1, flags=re.MULTILINE)
if err:
return f"**Error:** note not found: {before}"
return f"Note replaced."
def _tool_world_update(self, args: dict) -> str: def _tool_world_update(self, args: dict) -> str:
content = (args or {}).get("content", "") content = (args or {}).get("content", "")
@ -534,9 +575,6 @@ class GameEngine:
WORLD_PATH.write_text(content.strip() + "\n") WORLD_PATH.write_text(content.strip() + "\n")
return "World state updated." return "World state updated."
def _tool_journal_get(self, args: dict) -> str:
return self._read_file(JOURNAL_PATH) or "*Journal is empty.*"
def _tool_journal_update(self, args: dict) -> str: def _tool_journal_update(self, args: dict) -> str:
add = (args or {}).get("add", []) add = (args or {}).get("add", [])
done = (args or {}).get("done", []) done = (args or {}).get("done", [])
@ -583,20 +621,35 @@ class GameEngine:
elif tool_name == "player_roll": elif tool_name == "player_roll":
dice = (args or {}).get("dice", "1d6") dice = (args or {}).get("dice", "1d6")
desc = f"asking you to roll {dice}" desc = f"asking you to roll {dice}"
elif tool_name == "modify_traits":
desc = "updating traits"
elif tool_name == "modify_vitals":
desc = "updating vitals"
elif tool_name == "add_to_inventory":
desc = "adding item to inventory"
elif tool_name == "remove_from_inventory":
desc = "removing item from inventory"
elif tool_name == "replace_gear":
desc = "replacing gear"
elif tool_name == "add_note":
desc = "adding note"
elif tool_name == "replace_note":
desc = "replacing note"
else: else:
desc = f"using {tool_name}" desc = f"using {tool_name}"
return f"DM is {desc}..." return f"DM is {desc}..."
def _execute_tool(self, tool_name: str, args: dict) -> str: def _execute_tool(self, tool_name: str, args: dict) -> str:
fn_map = { fn_map = {
"read_file": self._tool_read_file,
"roll": self._tool_roll, "roll": self._tool_roll,
"think": self._tool_think, "modify_traits": self._tool_modify_traits,
"character_get": self._tool_character_get, "modify_vitals": self._tool_modify_vitals,
"character_update": self._tool_character_update, "add_to_inventory": self._tool_add_to_inventory,
"world_get": self._tool_world_get, "remove_from_inventory": self._tool_remove_from_inventory,
"replace_gear": self._tool_replace_gear,
"add_note": self._tool_add_note,
"replace_note": self._tool_replace_note,
"world_update": self._tool_world_update, "world_update": self._tool_world_update,
"journal_get": self._tool_journal_get,
"journal_update": self._tool_journal_update, "journal_update": self._tool_journal_update,
} }
fn = fn_map.get(tool_name) fn = fn_map.get(tool_name)
@ -813,17 +866,22 @@ class GameEngine:
for attempt in range(3): for attempt in range(3):
text = self._call_llm([ text = self._call_llm([
{"role": "user", "content": {"role": "user", "content":
f"Read the story and compare with current state. Output tool calls for changes:\n\n" f"Read the story and compare with current state. Output tool calls for any changes:\n\n"
f"## Current Character\n{current_char}\n\n" f"## Current Character\n{current_char}\n\n"
f"## Current World\n{current_world}\n\n" f"## Current World\n{current_world}\n\n"
f"## Story\n{book_log}\n\n" f"## Story\n{book_log}\n\n"
f"Output tool blocks for changes only. Include the FULL updated content:\n" f"Output ```tool blocks for changes only. Examples:\n\n"
f"- character_update — content: full new sheet if HP/cash/gear/stats changed\n" f"```tool\n{{\"tool\": \"modify_vitals\", \"args\": {{\"current_hp\": 5, \"cash\": 45}}}}\n```\n"
f"- world_update — content: full new world if NPCs/locations/threads changed\n" f"```tool\n{{\"tool\": \"modify_traits\", \"args\": {{\"dex\": 15}}}}\n```\n"
f"- journal_update — add: [...], done: [...]\n" f"```tool\n{{\"tool\": \"add_to_inventory\", \"args\": {{\"item\": \"Silver key\"}}}}\n```\n"
f"- finalize_turn — user_prompt (question for player), ambience (soundscape)\n\n" f"```tool\n{{\"tool\": \"remove_from_inventory\", \"args\": {{\"item\": \"Torches (10)\"}}}}\n```\n"
f"Wrap each in ```tool:\n" f"```tool\n{{\"tool\": \"replace_gear\", \"args\": {{\"before\": \"Mace (1d6+1)\", \"after\": \"Mace (1d6+2, sharpened)\"}}}}\n```\n"
f"```tool\n{{\"tool\": \"character_update\", \"args\": {{\"content\": \"# Character\\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."}
], label=f"Extract attempt {attempt + 1}") ], label=f"Extract attempt {attempt + 1}")
if not text or not text.strip(): if not text or not text.strip():