Simpler tool calls with examples
This commit is contained in:
parent
84b53cfd0c
commit
e74dd07699
214
tools/engine.py
214
tools/engine.py
@ -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():
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user