initial
This commit is contained in:
295
backend/app/engine/tools/tools.py
Normal file
295
backend/app/engine/tools/tools.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""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 world time. When world time reaches fire_at, the system will fire it.",
|
||||
params={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fire_at": {"type": "string", "description": "World time string in same format as world.current_time, e.g. 'day_3_hour_14'"},
|
||||
"description": {"type": "string", "description": "What should happen"},
|
||||
"payload": {"type": "object", "description": "Arbitrary structured payload for the trigger runner"},
|
||||
},
|
||||
"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 action takes time.",
|
||||
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"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
ALL_TOOL_SCHEMAS = [
|
||||
DICE_ROLL_SCHEMA,
|
||||
UPDATE_STATE_SCHEMA,
|
||||
RAG_QUERY_SCHEMA,
|
||||
RAG_ADD_SCHEMA,
|
||||
SCHEDULE_TRIGGER_SCHEMA,
|
||||
ADVANCE_TIME_SCHEMA,
|
||||
RUN_SUBAGENT_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)
|
||||
Reference in New Issue
Block a user