2026-06-19 11:28:04 +03:00
""" 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 " ,
2026-06-19 16:31:45 +03:00
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. "
) ,
2026-06-19 11:28:04 +03:00
params = {
" type " : " object " ,
" properties " : {
2026-06-19 16:31:45 +03:00
" 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). " ,
} ,
2026-06-19 11:28:04 +03:00
} ,
" required " : [ " fire_at " , " description " ] ,
} ,
)
ADVANCE_TIME_SCHEMA = build_tool_schema (
name = " advance_time " ,
2026-06-19 16:31:45 +03:00
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. "
) ,
2026-06-19 11:28:04 +03:00
params = {
" type " : " object " ,
" properties " : {
" days " : { " type " : " integer " , " default " : 0 } ,
" hours " : { " type " : " integer " , " default " : 0 } ,
" minutes " : { " type " : " integer " , " default " : 0 } ,
2026-06-19 16:31:45 +03:00
" reason " : { " type " : " string " , " description " : " Why time advances (logged for debugging). " } ,
2026-06-19 11:28:04 +03:00
} ,
} ,
)
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 " ] ,
} ,
)
2026-06-19 16:31:45 +03:00
# === 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)
2026-06-19 11:28:04 +03:00
ALL_TOOL_SCHEMAS = [
DICE_ROLL_SCHEMA ,
UPDATE_STATE_SCHEMA ,
RAG_QUERY_SCHEMA ,
RAG_ADD_SCHEMA ,
SCHEDULE_TRIGGER_SCHEMA ,
ADVANCE_TIME_SCHEMA ,
RUN_SUBAGENT_SCHEMA ,
2026-06-19 16:31:45 +03:00
SUBMIT_PLAN_SCHEMA ,
2026-06-19 11:28:04 +03:00
]
2026-06-19 16:31:45 +03:00
# 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 ]
2026-06-19 11:28:04 +03:00
# === 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 )