Files
ai-rpg/backend/app/engine/tools/tools.py
Mikan 5a78def096 fix
2026-06-19 16:31:45 +03:00

546 lines
20 KiB
Python

"""Tool definitions and handlers for the orchestrator's tool-calling loop."""
from __future__ import annotations
import json
import random
import uuid
from typing import Any, Awaitable, Callable, Dict, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.llm import build_tool_schema
from app.core.rag import get_rag
from app.core.state_validator import apply_patch, validate_state
from app.logging_setup import get_logger
from app.models import DeferredTrigger, GlossaryEntry, World
log = get_logger("tools")
# === Tool schemas (OpenAI function-calling format) ===
DICE_ROLL_SCHEMA = build_tool_schema(
name="dice_roll",
description="Roll dice. Use 'sides' (e.g. 20 for d20) and optional 'count' (default 1) and 'modifier'. Returns the rolls and total.",
params={
"type": "object",
"properties": {
"sides": {"type": "integer", "description": "Number of sides on the die, e.g. 20 for d20"},
"count": {"type": "integer", "description": "Number of dice to roll", "default": 1},
"modifier": {"type": "integer", "description": "Modifier to add to total", "default": 0},
"label": {"type": "string", "description": "What this roll represents, e.g. 'attack' or 'perception'"},
},
"required": ["sides"],
},
)
UPDATE_STATE_SCHEMA = build_tool_schema(
name="update_state",
description="Apply a patch to world state. Paths use dot notation. ops: set, unset, append, increment, remove.",
params={
"type": "object",
"properties": {
"patch": {
"type": "object",
"description": "JSON-patch object with optional keys: set, unset, append, increment, remove. Each is a dict of path->value (or list of paths for unset).",
"properties": {
"set": {"type": "object"},
"unset": {"type": "array", "items": {"type": "string"}},
"append": {"type": "object"},
"increment": {"type": "object"},
"remove": {"type": "object"},
},
}
},
"required": ["patch"],
},
)
RAG_QUERY_SCHEMA = build_tool_schema(
name="rag_query",
description="Search the glossary (NPCs, locations, items, lore) for relevant facts.",
params={
"type": "object",
"properties": {
"query": {"type": "string", "description": "Free-text search query"},
"limit": {"type": "integer", "description": "Max results", "default": 5},
},
"required": ["query"],
},
)
RAG_ADD_SCHEMA = build_tool_schema(
name="rag_add",
description="Add a new entry to the glossary (NPC, location, item, lore, event).",
params={
"type": "object",
"properties": {
"kind": {"type": "string", "enum": ["npc", "location", "item", "lore", "event", "rule"]},
"name": {"type": "string"},
"description": {"type": "string"},
"payload": {"type": "object", "description": "Optional extra fields"},
},
"required": ["kind", "name", "description"],
},
)
SCHEDULE_TRIGGER_SCHEMA = build_tool_schema(
name="schedule_trigger",
description=(
"Schedule a deferred event tied to in-world time. When the world's "
"internal clock reaches fire_at, the engine fires the event (calls the "
"LLM with the description to produce a narrative beat and optional "
"state patch). fire_at must use the same format as world.current_time "
"('day_N_hour_H' or 'day_N_hour_H_min_M'). The world's calendar "
"(hours_per_day, minutes_per_hour) is honored when comparing times."
),
params={
"type": "object",
"properties": {
"fire_at": {
"type": "string",
"description": "In-world time when the trigger fires, e.g. 'day_3_hour_14' or 'day_3_hour_14_min_30'.",
},
"description": {
"type": "string",
"description": "What should happen when the trigger fires. Be specific — this is fed to the LLM at fire time.",
},
"payload": {
"type": "object",
"description": "Optional structured payload (e.g. who, conditions, parameters).",
},
},
"required": ["fire_at", "description"],
},
)
ADVANCE_TIME_SCHEMA = build_tool_schema(
name="advance_time",
description=(
"Advance the world's internal clock by days / hours / minutes. Use "
"this when the player's action takes measurable in-world time (travel, "
"sleep, crafting, long rest). The world's calendar (hours_per_day, "
"minutes_per_hour) is honored. After time advances, any scheduled "
"triggers whose fire_at is now <= the new time will fire "
"automatically — so this is also how you 'run out the clock' on a "
"scheduled event."
),
params={
"type": "object",
"properties": {
"days": {"type": "integer", "default": 0},
"hours": {"type": "integer", "default": 0},
"minutes": {"type": "integer", "default": 0},
"reason": {"type": "string", "description": "Why time advances (logged for debugging)."},
},
},
)
RUN_SUBAGENT_SCHEMA = build_tool_schema(
name="run_subagent",
description="Spawn a sub-agent with clean context for a focused sub-task (e.g. generate NPC backstory, room description).",
params={
"type": "object",
"properties": {
"task": {"type": "string", "description": "The specific task for the sub-agent"},
"context": {"type": "string", "description": "Minimal context needed (max 200 words)"},
},
"required": ["task"],
},
)
# === Submission tools (how the LLM returns structured results) ===
# These replace the old "return JSON in your text response" pattern, which
# conflicted with tool use and caused the model to dump raw JSON into chat.
SUBMIT_PLAN_SCHEMA = build_tool_schema(
name="submit_plan",
description=(
"Submit the orchestrator's final plan for this iteration. This MUST be "
"the last tool you call. After you call it, the iteration ends and the "
"step-writer takes over to produce the cinematic scene."
),
params={
"type": "object",
"properties": {
"assessment": {
"type": "string",
"description": "Brief assessment of the player's action (1-2 sentences, English).",
},
"outcome": {
"type": "string",
"description": "What concretely happened (1-3 sentences, English). Fed to the step-writer as the raw outcome.",
},
"state_patch": {
"type": "object",
"description": "JSON-patch for world state. Keys: set, unset, append, increment, remove. Empty object if no change.",
"properties": {
"set": {"type": "object"},
"unset": {"type": "array", "items": {"type": "string"}},
"append": {"type": "object"},
"increment": {"type": "object"},
"remove": {"type": "object"},
},
},
"time_advance": {
"type": "object",
"description": "How much in-world time advances. null/omitted if no time passes.",
"properties": {
"days": {"type": "integer", "default": 0},
"hours": {"type": "integer", "default": 0},
"minutes": {"type": "integer", "default": 0},
},
},
"narrative_prompt": {
"type": "string",
"description": "Facts the step-writer should know to write the scene (English). Max ~100 words.",
},
"next_options": {
"type": "array",
"items": {"type": "string"},
"description": "3 suggested next actions for the player (short, 5-12 words each).",
},
"rails_update": {
"type": "object",
"description": "Optional update to plot rails. Omit if no change.",
"properties": {
"main_goal": {"type": "string"},
"new_subgoals": {"type": "array", "items": {"type": "string"}},
"completed_subgoals": {"type": "array", "items": {"type": "string"}},
},
},
"rag_facts": {
"type": "array",
"description": "New persistent facts to add to the glossary. Empty array if none.",
"items": {
"type": "object",
"properties": {
"kind": {"type": "string", "enum": ["npc", "location", "item", "lore", "event"]},
"name": {"type": "string"},
"description": {"type": "string"},
},
"required": ["kind", "name", "description"],
},
},
},
"required": ["assessment", "outcome", "narrative_prompt", "next_options"],
},
)
SUBMIT_SCENE_SCHEMA = build_tool_schema(
name="submit_scene",
description=(
"Submit the narrative scene for this step. This is the ONLY way to "
"return the scene — your text response is ignored. The narrative "
"should be 200-400 words, cinematic, second-person ('You...'), in the "
"world's player-facing language."
),
params={
"type": "object",
"properties": {
"narrative": {
"type": "string",
"description": "200-400 words of cinematic prose describing the scene. Second-person ('You...').",
},
"options": {
"type": "array",
"items": {"type": "string"},
"description": "Exactly 3 short (5-12 words) options for the player's next action.",
},
},
"required": ["narrative", "options"],
},
)
SUBMIT_WORLD_DEFINITION_SCHEMA = build_tool_schema(
name="submit_world_definition",
description=(
"Submit a proposed world definition. Call this when you have enough "
"information to build the world. Your text response will be shown to "
"the player as your conversational reply (use it to summarize the "
"proposed world in 2-4 sentences)."
),
params={
"type": "object",
"properties": {
"setting_description": {"type": "string", "description": "Expanded setting, 1-2 paragraphs."},
"rules": {
"type": "object",
"description": "Object with keys like stats, combat, magic, time, inventory, death (whichever apply).",
},
"world_schema": {
"type": "object",
"description": "JSON Schema describing the shape of the world state.",
},
"plot_rails": {
"type": "object",
"description": "{main_goal, subgoals, hooks}.",
"properties": {
"main_goal": {"type": "string"},
"subgoals": {"type": "array", "items": {"type": "string"}},
"hooks": {"type": "array", "items": {"type": "string"}},
},
},
"initial_state": {
"type": "object",
"description": "Initial world state matching world_schema.",
},
"initial_time": {
"type": "string",
"description": "World time string e.g. 'day_1_hour_8'.",
},
"calendar": {
"type": "object",
"description": "Optional. Custom calendar. Include only if non-standard.",
"properties": {
"hours_per_day": {"type": "integer"},
"minutes_per_hour": {"type": "integer"},
"days_per_week": {"type": "integer"},
},
},
"is_final": {
"type": "boolean",
"description": "True ONLY when the player has explicitly accepted the world.",
},
},
"required": ["setting_description", "rules", "world_schema", "initial_state", "initial_time"],
},
)
SUBMIT_SUMMARY_SCHEMA = build_tool_schema(
name="submit_summary",
description="Submit the compressed summary of older session messages.",
params={
"type": "object",
"properties": {
"summary": {"type": "string", "description": "3-6 sentences, max 150 words."},
"facts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"kind": {"type": "string", "enum": ["npc", "location", "item", "lore", "event"]},
"name": {"type": "string"},
"description": {"type": "string"},
},
"required": ["kind", "name", "description"],
},
},
},
"required": ["summary", "facts"],
},
)
SUBMIT_TRIGGER_RESULT_SCHEMA = build_tool_schema(
name="submit_trigger_result",
description="Submit the result of firing a deferred trigger.",
params={
"type": "object",
"properties": {
"outcome": {"type": "string", "description": "1-2 sentences, English. For logs."},
"state_patch": {
"type": "object",
"description": "JSON-patch for world state. Empty object if no change.",
"properties": {
"set": {"type": "object"},
"unset": {"type": "array", "items": {"type": "string"}},
"append": {"type": "object"},
"increment": {"type": "object"},
"remove": {"type": "object"},
},
},
"narrative": {"type": "string", "description": "1-paragraph scene description for the player, in world.language. Empty string if offscreen."},
"should_notify_player": {"type": "boolean", "description": "True if the player should see the narrative."},
},
"required": ["outcome", "narrative", "should_notify_player"],
},
)
# Tools available to the orchestrator (game-loop tools + submit_plan)
ALL_TOOL_SCHEMAS = [
DICE_ROLL_SCHEMA,
UPDATE_STATE_SCHEMA,
RAG_QUERY_SCHEMA,
RAG_ADD_SCHEMA,
SCHEDULE_TRIGGER_SCHEMA,
ADVANCE_TIME_SCHEMA,
RUN_SUBAGENT_SCHEMA,
SUBMIT_PLAN_SCHEMA,
]
# Tools for the step writer (only submit_scene)
STEP_WRITER_TOOL_SCHEMAS = [SUBMIT_SCENE_SCHEMA]
# Tools for the world builder (only submit_world_definition)
WORLD_BUILDER_TOOL_SCHEMAS = [SUBMIT_WORLD_DEFINITION_SCHEMA]
# Tools for the summarizer
SUMMARIZER_TOOL_SCHEMAS = [SUBMIT_SUMMARY_SCHEMA]
# Tools for the trigger runner
TRIGGER_RUNNER_TOOL_SCHEMAS = [SUBMIT_TRIGGER_RESULT_SCHEMA]
# === Tool handlers ===
class ToolContext:
"""Holds everything tools need to execute."""
def __init__(
self,
db: AsyncSession,
world: World,
session_id: uuid.UUID,
user_id: uuid.UUID,
subagent_runner: Optional[Callable[[str, str], Awaitable[str]]] = None,
settings_map: Optional[Dict[str, Any]] = None,
):
self.db = db
self.world = world
self.session_id = session_id
self.user_id = user_id
self.subagent_runner = subagent_runner
self.settings_map = settings_map or {}
# Track time advancement during this iteration
self.time_advance: Dict[str, int] = {"days": 0, "hours": 0, "minutes": 0}
# Track scheduled triggers
self.scheduled_triggers: List[Dict[str, Any]] = []
# Track rag facts added
self.rag_added: List[Dict[str, Any]] = []
async def handle_tool_call(name: str, args: Dict[str, Any], ctx: ToolContext) -> Dict[str, Any]:
if name == "dice_roll":
sides = int(args.get("sides", 20))
count = int(args.get("count", 1))
modifier = int(args.get("modifier", 0))
label = args.get("label", "")
rolls = [random.randint(1, sides) for _ in range(max(1, count))]
total = sum(rolls) + modifier
return {"rolls": rolls, "modifier": modifier, "total": total, "label": label}
if name == "update_state":
patch = args.get("patch", {})
new_state = apply_patch(ctx.world.state, patch)
schema = ctx.world.definition.get("world_schema", {})
ok, errors = validate_state(new_state, schema)
if not ok:
return {"ok": False, "errors": errors, "state_unchanged": True}
ctx.world.state = new_state
return {"ok": True, "new_state_summary": _summarize_state(new_state)}
if name == "rag_query":
query = args.get("query", "")
limit = int(args.get("limit", 5))
rag = await get_rag(ctx.settings_map)
results = await rag.search_glossary(ctx.world.id, query, limit=limit, settings_map=ctx.settings_map)
return {"results": results}
if name == "rag_add":
kind = args.get("kind", "lore")
entry_name = args.get("name", "")
desc = args.get("description", "")
extra = args.get("payload", {}) or {}
entry = GlossaryEntry(
world_id=ctx.world.id,
session_id=ctx.session_id,
kind=kind,
name=entry_name,
description=desc,
payload=extra,
)
ctx.db.add(entry)
await ctx.db.flush()
rag = await get_rag(ctx.settings_map)
await rag.upsert_glossary(
world_id=ctx.world.id,
entry_id=entry.id,
kind=kind,
name=entry_name,
description=desc,
payload=extra,
settings_map=ctx.settings_map,
)
ctx.rag_added.append({"kind": kind, "name": entry_name, "description": desc})
return {"ok": True, "entry_id": str(entry.id)}
if name == "schedule_trigger":
fire_at = args.get("fire_at", "")
description = args.get("description", "")
payload = args.get("payload", {}) or {}
trigger = DeferredTrigger(
session_id=ctx.session_id,
fire_at=fire_at,
description=description,
payload=payload,
)
ctx.db.add(trigger)
await ctx.db.flush()
ctx.scheduled_triggers.append({
"id": str(trigger.id),
"fire_at": fire_at,
"description": description,
})
return {"ok": True, "trigger_id": str(trigger.id)}
if name == "advance_time":
days = int(args.get("days", 0))
hours = int(args.get("hours", 0))
minutes = int(args.get("minutes", 0))
ctx.time_advance["days"] += days
ctx.time_advance["hours"] += hours
ctx.time_advance["minutes"] += minutes
return {
"ok": True,
"advance": {"days": days, "hours": hours, "minutes": minutes},
"reason": args.get("reason", ""),
}
if name == "run_subagent":
if ctx.subagent_runner is None:
return {"error": "subagent_runner_not_available"}
task = args.get("task", "")
context = args.get("context", "")
try:
result = await ctx.subagent_runner(task, context)
return {"result": result}
except Exception as e:
return {"error": str(e)}
return {"error": f"unknown_tool: {name}"}
def _summarize_state(state: Dict[str, Any]) -> str:
"""Quick human-readable summary of state for the LLM."""
if not state:
return "(empty)"
parts: List[str] = []
player = state.get("player", {})
if player:
name = player.get("name", "?")
stats = player.get("stats", {})
location = player.get("location", "?")
hp = stats.get("health", "?")
hp_max = stats.get("health_max", "?")
mp = stats.get("mana", "?")
parts.append(f"player={name} hp={hp}/{hp_max} mp={mp} loc={location}")
inv = player.get("inventory", []) if isinstance(player, dict) else []
if inv:
parts.append("inv=" + ", ".join(f"{i.get('name','?')}x{i.get('qty',1)}" for i in inv[:8]))
npcs = state.get("npcs", [])
if npcs:
parts.append(f"npcs={len(npcs)}")
return " | ".join(parts)