This commit is contained in:
Mikan
2026-06-20 19:13:05 +03:00
parent 32575e217e
commit 8514c63ec6
193 changed files with 22105 additions and 11660 deletions

View File

@@ -0,0 +1 @@
"""Tools package — game tools, interaction tools, schema tools."""

231
app/engine/tools/base.py Normal file
View File

@@ -0,0 +1,231 @@
"""Base types for tool system: Tool, ToolContext, ToolResult, ToolRegistry."""
from __future__ import annotations
import abc
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logging import get_logger
from app.core.state_validator import apply_patch
from app.models import Entity, StepToolCall, World
_logger = get_logger(__name__)
# --------------------------------------------------------------------------- #
# Context & Result
# --------------------------------------------------------------------------- #
@dataclass
class ToolContext:
"""Per-iteration context passed to every tool call."""
db: AsyncSession
world: World
user_id: uuid.UUID | None = None
step_id: uuid.UUID | None = None
stage: str = ""
sse_emitter: Any = None # callable: async (event, data) -> None
pending_state_changes: dict[str, Any] = field(default_factory=dict)
@dataclass
class ToolResult:
ok: bool
data: dict[str, Any] = field(default_factory=dict)
message: str = ""
error_code: str = ""
error_message: str = ""
def to_dict(self) -> dict[str, Any]:
if self.ok:
return {"ok": True, "data": self.data, "message": self.message}
return {
"ok": False,
"error": {"code": self.error_code, "message": self.error_message},
}
# --------------------------------------------------------------------------- #
# Tool base class
# --------------------------------------------------------------------------- #
class Tool(abc.ABC):
"""Abstract base for all tools."""
name: str = ""
category: str = "game" # game | interaction | schema
stages: set[str] = set() # which stages can use this tool
description: str = ""
parameters_schema: dict[str, Any] = {}
@abc.abstractmethod
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
"""Run the tool. Must be idempotent within a single transaction."""
def to_openai_format(self) -> dict[str, Any]:
"""Serialize to OpenAI tools format."""
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters_schema,
},
}
# --------------------------------------------------------------------------- #
# Registry
# --------------------------------------------------------------------------- #
class ToolRegistry:
"""Holds all registered tools and dispatches calls."""
def __init__(self) -> None:
self._tools: dict[str, Tool] = {}
def register(self, tool: Tool) -> None:
if not tool.name:
raise ValueError("Tool name is required")
if tool.name in self._tools:
raise ValueError(f"Tool {tool.name} already registered")
self._tools[tool.name] = tool
def get(self, name: str) -> Tool | None:
return self._tools.get(name)
def list_for_stage(self, stage: str) -> list[Tool]:
"""Return all tools available at the given stage."""
return [t for t in self._tools.values() if stage in t.stages or "*" in t.stages]
def to_openai_format(self, stage: str) -> list[dict[str, Any]]:
return [t.to_openai_format() for t in self.list_for_stage(stage)]
async def execute(
self,
name: str,
arguments: dict[str, Any],
ctx: ToolContext,
) -> ToolResult:
"""Execute a tool by name. Logs to step_tool_calls and emits SSE."""
from sqlalchemy import select as sa_select
tool = self.get(name)
executed_at = datetime.now(timezone.utc)
if tool is None:
result = ToolResult(
ok=False,
error_code="unknown_tool",
error_message=f"Tool {name!r} is not registered",
)
else:
try:
result = await tool.execute(arguments, ctx)
except Exception as e: # noqa: BLE001
_logger.exception("tool_execution_failed", tool=name, error=str(e))
result = ToolResult(
ok=False,
error_code="tool_exception",
error_message=str(e),
)
# Log to step_tool_calls (if we have a step_id)
if ctx.step_id is not None:
try:
ctx.db.add(
StepToolCall(
step_id=ctx.step_id,
tool_name=name,
arguments=arguments,
result=result.to_dict(),
is_success=result.ok,
executed_at=executed_at,
)
)
await ctx.db.flush()
except Exception as e: # noqa: BLE001
_logger.error("tool_log_failed", tool=name, error=str(e))
# Emit SSE
if ctx.sse_emitter is not None:
try:
await ctx.sse_emitter(
"tool_call",
{
"tool": name,
"arguments": arguments,
"result": result.to_dict(),
"is_success": result.ok,
},
)
except Exception as e: # noqa: BLE001
_logger.warning("sse_tool_call_failed", tool=name, error=str(e))
return result
# --------------------------------------------------------------------------- #
# Helpers used by entity_* tools
# --------------------------------------------------------------------------- #
async def get_entity_by_query(
db: AsyncSession, world_id: uuid.UUID, query: Any
) -> Entity | None:
"""Resolve entity by UUID string or by {entity_type, name}."""
from sqlalchemy import select as sa_select
if isinstance(query, str):
try:
eid = uuid.UUID(query)
except ValueError:
return None
return (
await db.execute(
sa_select(Entity).where(
Entity.id == eid, Entity.world_id == world_id
)
)
).scalar_one_or_none()
elif isinstance(query, dict):
et = query.get("entity_type")
nm = query.get("name")
if not et or not nm:
return None
return (
await db.execute(
sa_select(Entity).where(
Entity.world_id == world_id,
Entity.entity_type == et,
Entity.name == nm,
Entity.deleted_at.is_(None),
)
)
).scalar_one_or_none()
return None
def apply_env_patch(environment: dict, patch: dict) -> tuple[dict, list[str]]:
"""Wrapper around state_validator.apply_patch for environment dicts."""
return apply_patch(environment, patch)
# Singleton registry (instantiated in `app.engine.tools.__init__`)
_registry: ToolRegistry | None = None
def get_registry() -> ToolRegistry:
global _registry
if _registry is None:
from app.engine.tools.register_all import build_default_registry
_registry = build_default_registry()
return _registry
def reset_registry() -> None:
"""Reset the cached registry — used in tests."""
global _registry
_registry = None

993
app/engine/tools/game.py Normal file
View File

@@ -0,0 +1,993 @@
"""Game tools — entity CRUD, environment manipulation, RAG, triggers, calc, etc."""
from __future__ import annotations
import random
import re
import uuid
from typing import Any
from sqlalchemy import select
from app.core.logging import get_logger
from app.core.state_validator import apply_patch, validate_state
from app.engine.tools.base import Tool, ToolContext, ToolResult, get_entity_by_query
from app.models import Entity
_logger = get_logger(__name__)
# --------------------------------------------------------------------------- #
# entity_create
# --------------------------------------------------------------------------- #
class EntityCreateTool(Tool):
name = "entity_create"
category = "game"
stages = {"world_builder", "world_editor", "orchestrator_phase1", "subagent", "intro_scene"}
description = (
"Create a new entity in the current world. The entity_type must exist in "
"world.schemas. The data must conform to the schema for that type."
)
parameters_schema = {
"type": "object",
"required": ["entity_type", "name", "data"],
"properties": {
"entity_type": {
"type": "string",
"description": "Type from world.schemas (character, item, location, ...)",
},
"name": {
"type": "string",
"description": "Entity name (unique within (world_id, entity_type))",
},
"data": {
"type": "object",
"description": "Full entity data per schema",
},
"add_to_environment": {
"type": "boolean",
"default": False,
"description": "Add to environment for fast LLM access",
},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
et = arguments.get("entity_type")
nm = arguments.get("name")
data = arguments.get("data") or {}
add_env = arguments.get("add_to_environment", False)
if not et or not nm:
return ToolResult(
ok=False,
error_code="validation_error",
error_message="entity_type and name are required",
)
# Validate type exists in schemas
schemas = ctx.world.schemas or []
type_names = {s.get("type") for s in schemas}
if et not in type_names:
return ToolResult(
ok=False,
error_code="unknown_entity_type",
error_message=f"Entity type {et!r} not in world.schemas",
)
# Name uniqueness within (world, type)
existing = (
await ctx.db.execute(
select(Entity).where(
Entity.world_id == ctx.world.id,
Entity.entity_type == et,
Entity.name == nm,
Entity.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if existing is not None:
return ToolResult(
ok=False,
error_code="name_conflict",
error_message=f"Entity {et}/{nm!r} already exists",
)
entity = Entity(
world_id=ctx.world.id,
entity_type=et,
name=nm,
data=data,
is_in_environment=add_env,
embedding_status="pending",
)
ctx.db.add(entity)
await ctx.db.flush()
if add_env:
env = dict(ctx.world.environment or {})
env.setdefault("entities", []).append(
{"id": str(entity.id), "entity_type": et, "name": nm}
)
ctx.world.environment = env
return ToolResult(
ok=True,
data={"entity_id": str(entity.id)},
message=f"Created {et} {nm!r}",
)
# --------------------------------------------------------------------------- #
# entity_get
# --------------------------------------------------------------------------- #
class EntityGetTool(Tool):
name = "entity_get"
category = "game"
stages = {
"world_builder",
"world_editor",
"orchestrator_phase1",
"subagent",
"intro_scene",
}
description = "Get an entity by id or by {entity_type, name}."
parameters_schema = {
"type": "object",
"required": ["query"],
"properties": {
"query": {
"oneOf": [
{"type": "string", "description": "entity_id (UUID)"},
{
"type": "object",
"properties": {
"entity_type": {"type": "string"},
"name": {"type": "string"},
},
"required": ["entity_type", "name"],
},
]
}
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
q = arguments.get("query")
ent = await get_entity_by_query(ctx.db, ctx.world.id, q)
if ent is None or ent.deleted_at is not None:
return ToolResult(
ok=False,
error_code="not_found",
error_message="Entity not found",
)
return ToolResult(
ok=True,
data={
"id": str(ent.id),
"entity_type": ent.entity_type,
"name": ent.name,
"data": ent.data,
"is_in_environment": ent.is_in_environment,
},
message=f"Got {ent.entity_type} {ent.name!r}",
)
# --------------------------------------------------------------------------- #
# entity_list
# --------------------------------------------------------------------------- #
class EntityListTool(Tool):
name = "entity_list"
category = "game"
stages = {
"world_builder",
"world_editor",
"orchestrator_phase1",
"subagent",
"intro_scene",
}
description = "List entities in the world, optionally filtered."
parameters_schema = {
"type": "object",
"properties": {
"entity_type": {"type": "string"},
"in_environment_only": {"type": "boolean", "default": False},
"name_contains": {"type": "string"},
"limit": {"type": "integer", "default": 50, "max": 200},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
stmt = select(Entity).where(
Entity.world_id == ctx.world.id, Entity.deleted_at.is_(None)
)
et = arguments.get("entity_type")
if et:
stmt = stmt.where(Entity.entity_type == et)
if arguments.get("in_environment_only"):
stmt = stmt.where(Entity.is_in_environment.is_(True))
nc = arguments.get("name_contains")
if nc:
stmt = stmt.where(Entity.name.ilike(f"%{nc}%"))
limit = min(arguments.get("limit", 50), 200)
stmt = stmt.limit(limit)
rows = (await ctx.db.execute(stmt)).scalars().all()
return ToolResult(
ok=True,
data={
"items": [
{
"id": str(r.id),
"entity_type": r.entity_type,
"name": r.name,
"data": r.data,
"is_in_environment": r.is_in_environment,
}
for r in rows
],
"count": len(rows),
},
message=f"Listed {len(rows)} entities",
)
# --------------------------------------------------------------------------- #
# entity_update
# --------------------------------------------------------------------------- #
class EntityUpdateTool(Tool):
name = "entity_update"
category = "game"
stages = {"world_editor", "orchestrator_phase1", "subagent"}
description = "Update entity fields via JSON-patch."
parameters_schema = {
"type": "object",
"required": ["entity_id", "patch"],
"properties": {
"entity_id": {"type": "string"},
"patch": {
"type": "object",
"description": "JSON-patch: {field_path: new_value | {op, by}}",
},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
eid = arguments.get("entity_id")
patch = arguments.get("patch") or {}
try:
ent_uuid = uuid.UUID(eid)
except (ValueError, TypeError):
return ToolResult(
ok=False, error_code="validation_error", error_message="Invalid entity_id"
)
ent = (
await ctx.db.execute(
select(Entity).where(
Entity.id == ent_uuid,
Entity.world_id == ctx.world.id,
Entity.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if ent is None:
return ToolResult(
ok=False, error_code="not_found", error_message="Entity not found"
)
new_data, errors = apply_patch(ent.data or {}, patch)
if errors:
return ToolResult(
ok=False,
error_code="validation_error",
error_message="; ".join(errors),
)
ent.data = new_data
await ctx.db.flush()
return ToolResult(
ok=True,
data={"applied_paths": list(patch.keys())},
message=f"Updated {ent.entity_type} {ent.name!r}",
)
# --------------------------------------------------------------------------- #
# entity_delete
# --------------------------------------------------------------------------- #
class EntityDeleteTool(Tool):
name = "entity_delete"
category = "game"
stages = {"world_editor", "orchestrator_phase1", "subagent"}
description = "Soft-delete an entity."
parameters_schema = {
"type": "object",
"required": ["entity_id"],
"properties": {
"entity_id": {"type": "string"},
"reason": {"type": "string"},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
eid = arguments.get("entity_id")
try:
ent_uuid = uuid.UUID(eid)
except (ValueError, TypeError):
return ToolResult(
ok=False, error_code="validation_error", error_message="Invalid entity_id"
)
ent = (
await ctx.db.execute(
select(Entity).where(
Entity.id == ent_uuid, Entity.world_id == ctx.world.id
)
)
).scalar_one_or_none()
if ent is None:
return ToolResult(
ok=False, error_code="not_found", error_message="Entity not found"
)
from datetime import datetime, timezone
ent.deleted_at = datetime.now(timezone.utc)
await ctx.db.flush()
return ToolResult(
ok=True,
data={"entity_id": str(ent.id)},
message=f"Soft-deleted {ent.entity_type} {ent.name!r}",
)
# --------------------------------------------------------------------------- #
# env_update / env_get
# --------------------------------------------------------------------------- #
class EnvUpdateTool(Tool):
name = "env_update"
category = "game"
stages = {
"world_builder",
"world_editor",
"orchestrator_phase1",
"subagent",
"intro_scene",
}
description = (
"Apply a JSON-patch to environment. Patch is validated by state_validator."
)
parameters_schema = {
"type": "object",
"required": ["patch"],
"properties": {
"patch": {
"type": "object",
"description": "Map field_path -> new_value | {op, by/value}. Ops: set, inc, dec, append, remove.",
}
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
patch = arguments.get("patch") or {}
new_env, errors = apply_patch(dict(ctx.world.environment or {}), patch)
if errors:
return ToolResult(
ok=False,
error_code="validation_error",
error_message="; ".join(errors),
)
# Validate against environment_schema
ok, verrors = validate_state(new_env, ctx.world.environment_schema or [])
if not ok:
return ToolResult(
ok=False,
error_code="schema_violation",
error_message="; ".join(verrors),
)
ctx.world.environment = new_env
await ctx.db.flush()
return ToolResult(
ok=True,
data={"applied_paths": list(patch.keys())},
message="Environment updated",
)
class EnvGetTool(Tool):
name = "env_get"
category = "game"
stages = {
"world_builder",
"world_editor",
"orchestrator_phase1",
"subagent",
"intro_scene",
}
description = "Get current environment value (or sub-path)."
parameters_schema = {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "e.g. 'player.stats' or 'plot_rails.current_goals'",
}
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
path = arguments.get("path")
env = ctx.world.environment or {}
if not path:
return ToolResult(ok=True, data=env, message="Full environment")
# Walk path
cur: Any = env
for part in path.split("."):
if isinstance(cur, dict) and part in cur:
cur = cur[part]
else:
return ToolResult(
ok=False,
error_code="not_found",
error_message=f"Path {path!r} not found in environment",
)
return ToolResult(ok=True, data={"value": cur}, message=f"Value at {path!r}")
# --------------------------------------------------------------------------- #
# update_plot_rails
# --------------------------------------------------------------------------- #
class UpdatePlotRailsTool(Tool):
name = "update_plot_rails"
category = "game"
stages = {
"world_builder",
"world_editor",
"orchestrator_phase1",
"intro_scene",
}
description = "Add/remove hooks and goals in plot_rails."
parameters_schema = {
"type": "object",
"required": ["operation"],
"properties": {
"operation": {
"type": "string",
"enum": ["add_hook", "remove_hook", "add_goal", "remove_goal", "complete_goal"],
},
"value": {"type": "string"},
"index": {"type": "integer"},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
op = arguments.get("operation")
val = arguments.get("value")
idx = arguments.get("index")
pr = dict(ctx.world.plot_rails or {})
pr.setdefault("hooks", [])
pr.setdefault("current_goals", [])
pr.setdefault("completed_goals", [])
if op == "add_hook":
if not val:
return ToolResult(ok=False, error_code="validation_error",
error_message="value required for add_hook")
pr["hooks"] = list(pr["hooks"]) + [val]
elif op == "remove_hook":
if idx is None or idx >= len(pr["hooks"]):
return ToolResult(ok=False, error_code="validation_error",
error_message="invalid index")
pr["hooks"] = [h for i, h in enumerate(pr["hooks"]) if i != idx]
elif op == "add_goal":
if not val:
return ToolResult(ok=False, error_code="validation_error",
error_message="value required for add_goal")
pr["current_goals"] = list(pr["current_goals"]) + [val]
elif op == "remove_goal":
if idx is None or idx >= len(pr["current_goals"]):
return ToolResult(ok=False, error_code="validation_error",
error_message="invalid index")
pr["current_goals"] = [g for i, g in enumerate(pr["current_goals"]) if i != idx]
elif op == "complete_goal":
if idx is None or idx >= len(pr["current_goals"]):
return ToolResult(ok=False, error_code="validation_error",
error_message="invalid index")
goal = pr["current_goals"][idx]
pr["current_goals"] = [g for i, g in enumerate(pr["current_goals"]) if i != idx]
pr["completed_goals"] = list(pr["completed_goals"]) + [goal]
else:
return ToolResult(
ok=False,
error_code="validation_error",
error_message=f"Unknown operation {op!r}",
)
ctx.world.plot_rails = pr
await ctx.db.flush()
return ToolResult(ok=True, data=pr, message=f"plot_rails.{op} applied")
# --------------------------------------------------------------------------- #
# advance_time
# --------------------------------------------------------------------------- #
class AdvanceTimeTool(Tool):
name = "advance_time"
category = "game"
stages = {"orchestrator_phase1", "subagent"}
description = "Advance world time by a delta."
parameters_schema = {
"type": "object",
"required": ["delta"],
"properties": {
"delta": {
"type": "string",
"description": "Format: [year_Y][days_D][hours_H][min_M]",
}
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
from app.core.time_utils import advance_time
delta = arguments.get("delta")
try:
new_time = advance_time(ctx.world.current_time, delta, ctx.world.time_schema)
except ValueError as e:
return ToolResult(ok=False, error_code="validation_error", error_message=str(e))
ctx.world.current_time = new_time
await ctx.db.flush()
return ToolResult(
ok=True,
data={"new_time": new_time},
message=f"Time advanced by {delta} to {new_time}",
)
# --------------------------------------------------------------------------- #
# schedule_trigger
# --------------------------------------------------------------------------- #
class ScheduleTriggerTool(Tool):
name = "schedule_trigger"
category = "game"
stages = {"orchestrator_phase1", "subagent"}
description = "Schedule a deferred trigger to fire at a specific game time."
parameters_schema = {
"type": "object",
"required": ["fire_at", "event_type", "payload"],
"properties": {
"fire_at": {"type": "string", "description": "Format: [year_Y_]day_D_hour_H[_min_M]"},
"event_type": {
"type": "string",
"enum": ["spawn_enemy", "weather_change", "quest_update", "npc_action", "custom"],
},
"payload": {"type": "object"},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
from app.models import DeferredTrigger
fa = arguments.get("fire_at")
et = arguments.get("event_type")
pl = arguments.get("payload") or {}
if not fa or not et:
return ToolResult(
ok=False, error_code="validation_error",
error_message="fire_at and event_type required",
)
trig = DeferredTrigger(
world_id=ctx.world.id, fire_at=fa, event_type=et, payload=pl
)
ctx.db.add(trig)
await ctx.db.flush()
return ToolResult(
ok=True,
data={"trigger_id": str(trig.id)},
message=f"Scheduled {et} at {fa}",
)
# --------------------------------------------------------------------------- #
# calc
# --------------------------------------------------------------------------- #
_DICE_RE = re.compile(r"(\d*)d(\d+)")
_SAFE_RE = re.compile(r"^[0-9+\-*/%().,\s\wd]+$")
class CalcTool(Tool):
name = "calc"
category = "game"
stages = {"orchestrator_phase1", "subagent"}
description = (
"Evaluate a math expression with dice support. Allowed: + - * / %, "
"min(), max(), round(), and dice notation like 2d6+3."
)
parameters_schema = {
"type": "object",
"required": ["expression"],
"properties": {
"expression": {"type": "string", "example": "max(1, 2d6+3 - enemy.armor)"},
"variables": {
"type": "object",
"description": "Variable substitutions, e.g. {\"enemy.armor\": 5}",
},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
expr = arguments.get("expression", "")
variables = arguments.get("variables") or {}
# Substitute variables
trace_parts: list[str] = []
for var, val in variables.items():
expr = expr.replace(var, str(val))
trace_parts.append(f"{var}={val}")
# Dice rolls
rolls: list[int] = []
def _roll(match: re.Match) -> str:
count = int(match.group(1) or "1")
sides = int(match.group(2))
if sides < 1 or count < 1 or count > 100:
return "0"
results = [random.randint(1, sides) for _ in range(count)]
rolls.extend(results)
return str(sum(results))
expr_with_rolls = _DICE_RE.sub(_roll, expr)
if not _SAFE_RE.match(expr_with_rolls):
return ToolResult(
ok=False,
error_code="validation_error",
error_message="Expression contains disallowed characters",
)
# Replace min/max/round with safe builtins
try:
result = eval( # noqa: S307
expr_with_rolls,
{"__builtins__": {}},
{"min": min, "max": max, "round": round, "abs": abs},
)
if isinstance(result, float) and result.is_integer():
result = int(result)
except Exception as e:
return ToolResult(
ok=False, error_code="evaluation_error", error_message=str(e)
)
return ToolResult(
ok=True,
data={"result": result, "rolls": rolls, "trace": "; ".join(trace_parts)},
message=f"= {result}",
)
# --------------------------------------------------------------------------- #
# random_choice
# --------------------------------------------------------------------------- #
class RandomChoiceTool(Tool):
name = "random_choice"
category = "game"
stages = {"orchestrator_phase1", "subagent"}
description = "Pick an option deterministically (seeded by world+step)."
parameters_schema = {
"type": "object",
"required": ["options"],
"properties": {
"options": {"type": "array", "items": {}, "minItems": 2},
"weights": {"type": "array", "items": {"type": "number"}},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
opts = arguments.get("options") or []
weights = arguments.get("weights")
if len(opts) < 2:
return ToolResult(
ok=False,
error_code="validation_error",
error_message="Need at least 2 options",
)
seed_str = f"{ctx.world.id}:{ctx.step_id or 'noid'}"
rng = random.Random(hash(seed_str))
if weights:
if len(weights) != len(opts):
return ToolResult(
ok=False, error_code="validation_error",
error_message="options and weights length mismatch",
)
pick = rng.choices(opts, weights=weights, k=1)[0]
else:
pick = rng.choice(opts)
return ToolResult(ok=True, data={"choice": pick}, message=f"Picked: {pick!r}")
# --------------------------------------------------------------------------- #
# rag_query / rag_add (deferred to app.core.rag)
# --------------------------------------------------------------------------- #
class RagQueryTool(Tool):
name = "rag_query"
category = "game"
stages = {
"world_builder",
"world_editor",
"orchestrator_phase1",
"subagent",
"intro_scene",
}
description = (
"Semantic search over entities and story entries. Use when you need to recall "
"past details, NPC names, world facts. Do NOT rely on memory."
)
parameters_schema = {
"type": "object",
"required": ["query"],
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer", "default": 5, "max": 20},
"filter_type": {
"type": "string",
"enum": ["all", "entities", "story_entries"],
"default": "all",
},
"min_score": {"type": "number", "default": 0.7},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
from app.core.rag import rag_query
try:
results = await rag_query(
db=ctx.db,
world_id=ctx.world.id,
query=arguments.get("query", ""),
limit=arguments.get("limit", 5),
filter_type=arguments.get("filter_type", "all"),
min_score=arguments.get("min_score", 0.0),
)
except Exception as e: # noqa: BLE001
_logger.warning("rag_query_tool_failed", error=str(e))
return ToolResult(
ok=True,
data={"results": []},
message="RAG unavailable, returning empty results",
)
return ToolResult(
ok=True,
data={"results": results},
message=f"Found {len(results)} matches",
)
class RagAddTool(Tool):
name = "rag_add"
category = "game"
stages = {
"world_builder",
"orchestrator_phase1",
"subagent",
"intro_scene",
}
description = (
"Persist a fact/event as a story entry and index it for semantic search. "
"Use when the player learns a persistent fact (NPC secret, lore, quest outcome)."
)
parameters_schema = {
"type": "object",
"required": ["content", "entry_type"],
"properties": {
"content": {"type": "string"},
"entry_type": {
"type": "string",
"enum": ["fact", "event", "relationship", "secret"],
},
"metadata": {"type": "object"},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
from app.core.rag import rag_add
entry = await rag_add(
db=ctx.db,
world_id=ctx.world.id,
content=arguments.get("content", ""),
entry_type=arguments.get("entry_type", "fact"),
metadata=arguments.get("metadata"),
step_id=ctx.step_id,
)
return ToolResult(
ok=True,
data={"id": str(entry.id), "status": entry.embedding_status},
message=f"Added story entry ({entry.entry_type})",
)
# --------------------------------------------------------------------------- #
# submit_plan / submit_step / suggest_actions — terminal tools
# --------------------------------------------------------------------------- #
class SubmitPlanTool(Tool):
name = "submit_plan"
category = "game"
stages = {"world_builder", "orchestrator_phase1"}
description = "End Phase 1. Pass the plan + summary to Phase 2 writer."
parameters_schema = {
"type": "object",
"required": ["plan", "summary"],
"properties": {
"plan": {"type": "string"},
"summary": {"type": "array", "items": {"type": "object"}},
"offscreen_events": {"type": "array", "items": {"type": "string"}},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
# Signal handled by orchestrator loop — just echo back
return ToolResult(
ok=True,
data={
"plan": arguments.get("plan", ""),
"summary": arguments.get("summary", []),
"offscreen_events": arguments.get("offscreen_events", []),
},
message="Phase 1 complete",
)
class SubmitStepTool(Tool):
name = "submit_step"
category = "game"
stages = {"orchestrator_phase2", "intro_scene"}
description = "End Phase 2. Writer returns the final narrative + time delta."
parameters_schema = {
"type": "object",
"required": ["scene_text", "delta_time"],
"properties": {
"scene_text": {"type": "string", "minLength": 100, "maxLength": 4000},
"delta_time": {"type": "string"},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
text = arguments.get("scene_text", "")
if len(text) < 100:
return ToolResult(
ok=False,
error_code="validation_error",
error_message=f"scene_text must be >= 100 chars (got {len(text)})",
)
return ToolResult(
ok=True,
data={"scene_text": text, "delta_time": arguments.get("delta_time", "hours_1")},
message="Phase 2 complete",
)
class SuggestActionsTool(Tool):
name = "suggest_actions"
category = "game"
stages = {"orchestrator_phase3_suggest", "intro_scene"}
description = "Generate 1-3 next actions for the player."
parameters_schema = {
"type": "object",
"required": ["actions"],
"properties": {
"actions": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 3}
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
actions = arguments.get("actions") or []
if not actions or len(actions) > 3:
return ToolResult(
ok=False,
error_code="validation_error",
error_message="Need 1-3 actions",
)
return ToolResult(ok=True, data={"actions": actions}, message="Suggestions ready")
# --------------------------------------------------------------------------- #
# Interaction tools (used in world_builder / world_editor)
# --------------------------------------------------------------------------- #
class AskUserTool(Tool):
name = "ask_user"
category = "interaction"
stages = {"world_builder", "world_editor"}
description = "Ask the player a clarification question. Blocks until answer."
parameters_schema = {
"type": "object",
"required": ["question"],
"properties": {
"question": {"type": "string"},
"options": {"type": "array", "items": {"type": "string"}},
"allow_free_text": {"type": "boolean", "default": True},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
# The orchestrator/world_builder loop must intercept this tool before execution
# and emit a clarification SSE event; here we just echo back the question.
return ToolResult(
ok=True,
data={
"question": arguments.get("question"),
"options": arguments.get("options"),
"allow_free_text": arguments.get("allow_free_text", True),
"_blocking": True,
},
message="Awaiting user answer",
)
class ProposeChangesTool(Tool):
name = "propose_changes"
category = "interaction"
stages = {"world_editor"}
description = "Propose a diff to the player for accept/reject."
parameters_schema = {
"type": "object",
"required": ["diff"],
"properties": {
"diff": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {"type": "string"},
"op": {"type": "string", "enum": ["add", "remove", "replace"]},
"old": {},
"new": {},
},
},
},
"comment": {"type": "string"},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
return ToolResult(
ok=True,
data={"diff": arguments.get("diff", []), "comment": arguments.get("comment", "")},
message="Proposed changes",
)
class CommentToUserTool(Tool):
name = "comment_to_user"
category = "interaction"
stages = {"world_builder", "world_editor"}
description = "Send a text comment to the user (no answer expected)."
parameters_schema = {
"type": "object",
"required": ["text"],
"properties": {"text": {"type": "string"}},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
return ToolResult(ok=True, data={"text": arguments.get("text", "")}, message="Comment sent")
# --------------------------------------------------------------------------- #
# run_subagent
# --------------------------------------------------------------------------- #
class RunSubagentTool(Tool):
name = "run_subagent"
category = "game"
stages = {"orchestrator_phase1"}
description = "Run an offscreen sub-LLM call for background events."
parameters_schema = {
"type": "object",
"required": ["task", "tools"],
"properties": {
"task": {"type": "string"},
"tools": {"type": "array", "items": {"type": "string"}},
"context": {"type": "object"},
"max_iterations": {"type": "integer", "default": 5, "max": 10},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
# Full implementation in app.engine.subagent
return ToolResult(
ok=True,
data={
"task": arguments.get("task"),
"tools": arguments.get("tools"),
"context": arguments.get("context"),
"max_iterations": arguments.get("max_iterations", 5),
"_deferred": True,
},
message="Subagent requested (executor handles)",
)

View File

@@ -0,0 +1,56 @@
"""Build the default tool registry — instantiates and registers all tools."""
from __future__ import annotations
from app.engine.tools.base import ToolRegistry
from app.engine.tools.game import (
AdvanceTimeTool,
AskUserTool,
CalcTool,
CommentToUserTool,
EnvGetTool,
EnvUpdateTool,
EntityCreateTool,
EntityDeleteTool,
EntityGetTool,
EntityListTool,
EntityUpdateTool,
ProposeChangesTool,
RagAddTool,
RagQueryTool,
RandomChoiceTool,
RunSubagentTool,
ScheduleTriggerTool,
SubmitPlanTool,
SubmitStepTool,
SuggestActionsTool,
UpdatePlotRailsTool,
)
from app.engine.tools.schema_tools import (
SchemaAddFieldTool,
SchemaAddTypeTool,
SchemaModifyFieldTool,
SchemaRemoveFieldTool,
)
def build_default_registry() -> ToolRegistry:
"""Construct and return a ToolRegistry with all built-in tools registered."""
reg = ToolRegistry()
# Game tools
for cls in [
EntityCreateTool, EntityGetTool, EntityListTool, EntityUpdateTool,
EntityDeleteTool, EnvUpdateTool, EnvGetTool, UpdatePlotRailsTool,
AdvanceTimeTool, ScheduleTriggerTool, CalcTool, RandomChoiceTool,
RagQueryTool, RagAddTool, RunSubagentTool,
SubmitPlanTool, SubmitStepTool, SuggestActionsTool,
]:
reg.register(cls())
# Interaction tools
for cls in [AskUserTool, ProposeChangesTool, CommentToUserTool]:
reg.register(cls())
# Schema tools
for cls in [SchemaAddTypeTool, SchemaAddFieldTool,
SchemaRemoveFieldTool, SchemaModifyFieldTool]:
reg.register(cls())
return reg

View File

@@ -0,0 +1,146 @@
"""Schema tools for world_editor — add/modify/remove entity types and fields."""
from __future__ import annotations
from typing import Any
from app.engine.tools.base import Tool, ToolContext, ToolResult
def _find_schema(world_schemas: list[dict], type_name: str) -> dict | None:
for s in world_schemas:
if s.get("type") == type_name:
return s
return None
class SchemaAddTypeTool(Tool):
name = "schema_add_type"
category = "schema"
stages = {"world_builder", "world_editor"}
description = "Add a new entity type to world.schemas."
parameters_schema = {
"type": "object",
"required": ["type", "verbose", "plural", "properties"],
"properties": {
"type": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"},
"verbose": {"type": "string"},
"plural": {"type": "string"},
"properties": {"type": "array", "items": {"type": "object"}},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
type_name = arguments.get("type")
schemas = list(ctx.world.schemas or [])
if _find_schema(schemas, type_name):
return ToolResult(
ok=False, error_code="name_conflict",
error_message=f"Type {type_name!r} already exists",
)
schemas.append({
"type": type_name,
"verbose": arguments.get("verbose"),
"plural": arguments.get("plural"),
"properties": arguments.get("properties") or [],
})
ctx.world.schemas = schemas
await ctx.db.flush()
return ToolResult(ok=True, data={"type": type_name}, message=f"Type {type_name!r} added")
class SchemaAddFieldTool(Tool):
name = "schema_add_field"
category = "schema"
stages = {"world_builder", "world_editor"}
description = "Add a field to an existing entity type."
parameters_schema = {
"type": "object",
"required": ["entity_type", "field"],
"properties": {
"entity_type": {"type": "string"},
"field": {"type": "object"},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
et = arguments.get("entity_type")
field = arguments.get("field") or {}
schemas = list(ctx.world.schemas or [])
s = _find_schema(schemas, et)
if s is None:
return ToolResult(ok=False, error_code="not_found",
error_message=f"Type {et!r} not found")
props = list(s.get("properties") or [])
if any(p.get("name") == field.get("name") for p in props):
return ToolResult(ok=False, error_code="name_conflict",
error_message=f"Field {field.get('name')!r} already exists")
props.append(field)
s["properties"] = props
ctx.world.schemas = schemas
await ctx.db.flush()
return ToolResult(ok=True, data={"type": et, "field": field.get("name")},
message=f"Field added to {et!r}")
class SchemaRemoveFieldTool(Tool):
name = "schema_remove_field"
category = "schema"
stages = {"world_builder", "world_editor"}
description = "Remove a field from an entity type."
parameters_schema = {
"type": "object",
"required": ["entity_type", "field_name"],
"properties": {
"entity_type": {"type": "string"},
"field_name": {"type": "string"},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
et = arguments.get("entity_type")
fn = arguments.get("field_name")
schemas = list(ctx.world.schemas or [])
s = _find_schema(schemas, et)
if s is None:
return ToolResult(ok=False, error_code="not_found",
error_message=f"Type {et!r} not found")
props = [p for p in (s.get("properties") or []) if p.get("name") != fn]
s["properties"] = props
ctx.world.schemas = schemas
await ctx.db.flush()
return ToolResult(ok=True, data={"removed": fn}, message=f"Field {fn!r} removed from {et!r}")
class SchemaModifyFieldTool(Tool):
name = "schema_modify_field"
category = "schema"
stages = {"world_builder", "world_editor"}
description = "Modify an existing field of an entity type."
parameters_schema = {
"type": "object",
"required": ["entity_type", "field_name", "changes"],
"properties": {
"entity_type": {"type": "string"},
"field_name": {"type": "string"},
"changes": {"type": "object"},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
et = arguments.get("entity_type")
fn = arguments.get("field_name")
changes = arguments.get("changes") or {}
schemas = list(ctx.world.schemas or [])
s = _find_schema(schemas, et)
if s is None:
return ToolResult(ok=False, error_code="not_found",
error_message=f"Type {et!r} not found")
for p in (s.get("properties") or []):
if p.get("name") == fn:
p.update(changes)
ctx.world.schemas = schemas
await ctx.db.flush()
return ToolResult(ok=True, data=p, message=f"Field {fn!r} modified")
return ToolResult(ok=False, error_code="not_found",
error_message=f"Field {fn!r} not found in {et!r}")