546 lines
20 KiB
Python
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)
|