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

1
app/engine/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Engine layer — game logic (orchestrator, world builder/editor, tools, context)."""

180
app/engine/context.py Normal file
View File

@@ -0,0 +1,180 @@
"""Context manager — builds the LLM message list per stage.
Implements the compression strategy from §10.3 of the TDD:
- If history > threshold, prepend the latest summary as a system message.
- Truncate to last N guaranteed messages.
- Optionally include RAG results.
"""
from __future__ import annotations
import json
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logging import get_logger
from app.core.settings_service import get_setting
from app.core.time_utils import summarize_schemas
from app.models import StoryEntry, Step, World
from app.prompts.registry import get_prompt
_logger = get_logger(__name__)
def _scene_text_truncate(text: str, max_tokens: int) -> str:
"""Crude truncation: ~4 chars per token."""
max_chars = max_tokens * 4
if len(text) <= max_chars:
return text
return text[:max_chars] + ""
async def build_orchestrator_phase1_context(
*,
db: AsyncSession,
world: World,
player_action: str,
settings: dict[str, Any],
) -> list[dict[str, Any]]:
"""Build messages list for orchestrator Phase 1."""
guaranteed = int(settings.get("context.guaranteed_messages", 10))
threshold = int(settings.get("context.compression_threshold_messages", 20))
scene_trunc = int(settings.get("context.scene_text_truncate_tokens", 500))
# Fetch recent steps (most recent first)
recent_steps = list(
reversed(
(
await db.execute(
select(Step)
.where(Step.world_id == world.id, Step.deleted_at.is_(None))
.order_by(Step.sequence_number.desc())
.limit(max(threshold, guaranteed) + 1)
)
).scalars().all()
)
)
# Pull latest summary if available
summary_text: str | None = None
if len(recent_steps) > threshold:
latest_summary = (
await db.execute(
select(StoryEntry)
.where(
StoryEntry.world_id == world.id,
StoryEntry.entry_type == "event",
StoryEntry.metadata_["type"].as_string() == "summary",
)
.order_by(StoryEntry.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
if latest_summary:
summary_text = latest_summary.content
# Build the message list
sys_prompt = get_prompt("orchestrator_phase1", "en").format(
world_name=world.name,
rules="\n".join(f"- {r}" for r in (world.rules or [])),
schemas_summary=summarize_schemas(world.schemas or []),
environment_json=json.dumps(world.environment or {}, ensure_ascii=False, indent=2),
plot_rails_json=json.dumps(world.plot_rails or {}, ensure_ascii=False, indent=2),
current_time=world.current_time,
recent_history=_format_recent_history(
recent_steps[-guaranteed:], scene_trunc
),
max_substeps=settings.get("game.max_substeps_per_iteration", 8),
language=world.language,
player_action=player_action,
)
messages: list[dict[str, Any]] = [{"role": "system", "content": sys_prompt}]
if summary_text:
messages.append({
"role": "system",
"content": f"Summary of earlier events:\n{summary_text}",
})
# Recent steps as user/assistant pairs
for s in recent_steps[-guaranteed:]:
messages.append({"role": "user", "content": s.player_action})
if s.scene_text:
messages.append({"role": "assistant", "content": s.scene_text})
# Current action
messages.append({"role": "user", "content": player_action})
return messages
def _format_recent_history(steps: list[Step], scene_trunc: int) -> str:
if not steps:
return "(no recent history)"
lines: list[str] = []
for s in steps[-5:]: # only show last 5 in the prompt
text = _scene_text_truncate(s.scene_text or "(no scene)", scene_trunc)
lines.append(f"[step {s.sequence_number}] {s.player_action}\n{text}")
return "\n".join(lines)
async def build_orchestrator_phase2_context(
*,
db: AsyncSession,
world: World,
player_action: str,
plan: str,
summary: list[dict[str, Any]],
settings: dict[str, Any],
) -> list[dict[str, Any]]:
"""Build messages list for orchestrator Phase 2 (writer)."""
sys_prompt = get_prompt("orchestrator_phase2", "en").format(
world_name=world.name,
world_description=world.description or "",
language=world.language,
current_time=world.current_time,
player_action=player_action,
plan=plan,
summary_json=json.dumps(summary, ensure_ascii=False, indent=2),
environment_json=json.dumps(world.environment or {}, ensure_ascii=False, indent=2),
)
return [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": "Write the scene and call submit_step."},
]
async def build_orchestrator_phase3_suggest_context(
*,
db: AsyncSession,
world: World,
scene_text: str,
settings: dict[str, Any],
) -> list[dict[str, Any]]:
sys_prompt = get_prompt("orchestrator_phase3_suggest", "en").format(
language=world.language,
scene_text=scene_text[:2000],
current_goals=", ".join((world.plot_rails or {}).get("current_goals", []) or ["(none)"]),
)
return [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": "Suggest 1-3 next actions."},
]
async def build_summary_context(
*,
db: AsyncSession,
world: World,
old_steps: list[Step],
settings: dict[str, Any],
) -> list[dict[str, Any]]:
"""Build messages list for the summary LLM call."""
messages_json = json.dumps(
[{"action": s.player_action, "scene": s.scene_text} for s in old_steps],
ensure_ascii=False,
indent=2,
)
sys_prompt = get_prompt("summary", "en").format(messages_json=messages_json)
return [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": "Summarize."},
]

314
app/engine/game_master.py Normal file
View File

@@ -0,0 +1,314 @@
"""Game Master (orchestrator) — three-phase iteration engine.
Phase 1: Planner + Executor (tool-calling loop until submit_plan)
Phase 2: Writer (single LLM call with submit_step tool)
Phase 3: Persist + Deferred triggers + Summary + Suggest actions
"""
from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.llm import LlmClient, MockLlmClient
from app.core.logging import get_logger
from app.core.rag import rag_add
from app.core.settings_service import get_all_settings
from app.core.time_utils import advance_time, summarize_schemas
from app.engine.context import (
build_orchestrator_phase1_context,
build_orchestrator_phase2_context,
build_orchestrator_phase3_suggest_context,
build_summary_context,
)
from app.engine.sse import SseEmitter
from app.engine.tools.base import ToolContext, get_registry
from app.engine.world_builder import _run_tool_loop
from app.models import DeferredTrigger, Step, StoryEntry, World
_logger = get_logger(__name__)
async def run_iteration(
*,
db: AsyncSession,
world: World,
step: Step,
llm: LlmClient | MockLlmClient,
sse: SseEmitter,
) -> None:
"""Run the full three-phase orchestrator iteration for a single step."""
settings = await get_all_settings(db)
try:
# ============ Phase 1 ============
await sse.emit("phase_start", {"phase": 1, "name": "planner_executor"})
messages = await build_orchestrator_phase1_context(
db=db, world=world, player_action=step.player_action, settings=settings,
)
phase1_result = await _run_tool_loop(
db=db, world=world, llm=llm, sse=sse,
stage="orchestrator_phase1",
system_prompt=messages[0]["content"],
terminal_tool="submit_plan",
max_substeps=int(settings.get("game.max_substeps_per_iteration", 8)),
settings=settings,
)
await sse.emit("phase_end", {"phase": 1, "duration_ms": 0})
if not phase1_result or not phase1_result.get("ok"):
# Force-completion: synthesize a minimal plan
phase1_result = {
"ok": True,
"data": {
"plan": "The action was processed but no explicit plan was submitted.",
"summary": [],
"offscreen_events": [],
},
}
plan = phase1_result["data"].get("plan", "")
summary = phase1_result["data"].get("summary", [])
offscreen_events = phase1_result["data"].get("offscreen_events", [])
# Persist tool_calls_summary on the step
step.tool_calls_summary = summary
await db.commit()
# ============ Phase 2: Writer ============
await sse.emit("phase_start", {"phase": 2, "name": "writer"})
messages = await build_orchestrator_phase2_context(
db=db, world=world, player_action=step.player_action,
plan=plan, summary=summary, settings=settings,
)
registry = get_registry()
ctx = ToolContext(db=db, world=world, step_id=step.id, stage="orchestrator_phase2",
sse_emitter=sse.emit)
tools = registry.to_openai_format("orchestrator_phase2")
phase2_msg: dict[str, Any] = {}
for retry in range(3):
resp = await llm.complete(
stage="orchestrator_phase2",
messages=messages,
tools=tools,
temperature=float(settings.get("llm.temperature_writer", 0.85)),
max_tokens=int(settings.get("llm.max_tokens", 2048)),
world_id=world.id, step_id=step.id, session=db,
)
phase2_msg = resp.get("message", {})
tcs = phase2_msg.get("tool_calls") or []
if tcs:
# Execute submit_step
for tc in tcs:
fn = tc.get("function", {})
if fn.get("name") == "submit_step":
try:
args = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
args = {}
result = await registry.execute("submit_step", args, ctx)
if result.ok:
scene_text = result.data.get("scene_text", "")
delta_time = result.data.get("delta_time", "hours_1")
step.scene_text = scene_text
step.scene_delta_time = delta_time
await sse.emit("scene_complete", {
"text": scene_text, "delta_time": delta_time,
})
break
if step.scene_text:
break
# Retry
messages.append(phase2_msg)
messages.append({
"role": "user",
"content": "You MUST call submit_step with scene_text and delta_time.",
})
else:
await sse.error("writer_no_submit", "Writer failed to call submit_step after 3 retries")
step.status = "failed"
await db.commit()
return
await sse.emit("phase_end", {"phase": 2, "duration_ms": 0})
# ============ Phase 3 ============
await sse.emit("phase_start", {"phase": 3, "name": "persist_triggers_summary_suggest"})
# 3.0 Persist
step.status = "completed"
world.last_played_at = datetime.now(timezone.utc)
world.current_time = advance_time(
world.current_time, step.scene_delta_time or "hours_1", world.time_schema
)
await db.commit()
# 3.1 Deferred triggers
if settings.get("game.deferred_triggers_enabled", True):
await _process_deferred_triggers(
db=db, world=world, step=step, llm=llm, sse=sse, settings=settings,
)
# 3.2 Summary (if history is too long)
await _maybe_generate_summary(
db=db, world=world, step=step, llm=llm, sse=sse, settings=settings,
)
# 3.3 Suggest actions
suggest_msgs = await build_orchestrator_phase3_suggest_context(
db=db, world=world, scene_text=step.scene_text or "", settings=settings,
)
suggest_tools = registry.to_openai_format("orchestrator_phase3_suggest")
for retry in range(2):
resp = await llm.complete(
stage="orchestrator_phase3_suggest",
messages=suggest_msgs,
tools=suggest_tools,
temperature=0.8,
max_tokens=512,
world_id=world.id, step_id=step.id, session=db,
)
msg = resp.get("message", {})
tcs = msg.get("tool_calls") or []
for tc in tcs:
fn = tc.get("function", {})
if fn.get("name") == "suggest_actions":
try:
args = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
args = {}
result = await registry.execute("suggest_actions", args, ctx)
if result.ok:
step.suggested_actions = result.data.get("actions", [])
await sse.emit("suggested_actions", {"actions": step.suggested_actions})
break
if step.suggested_actions:
break
suggest_msgs.append(msg)
suggest_msgs.append({"role": "user", "content": "Call suggest_actions with 1-3 actions."})
await db.commit()
await sse.emit("iteration_complete", {
"step_id": str(step.id), "sequence_number": step.sequence_number,
})
await sse.done({"step_id": str(step.id), "status": "completed"})
except Exception as e: # noqa: BLE001
_logger.exception("orchestrator_failed", step_id=str(step.id), error=str(e))
step.status = "failed"
await db.commit()
await sse.error("internal_error", str(e))
async def _process_deferred_triggers(
*,
db: AsyncSession,
world: World,
step: Step,
llm: LlmClient | MockLlmClient,
sse: SseEmitter,
settings: dict[str, Any],
) -> None:
"""Fire all deferred triggers whose fire_at <= current_time."""
from app.core.time_utils import time_le
triggers = (
await db.execute(
select(DeferredTrigger).where(
DeferredTrigger.world_id == world.id,
DeferredTrigger.is_fired.is_(False),
)
)
).scalars().all()
fired = 0
for trig in triggers:
try:
if not time_le(trig.fire_at, world.current_time):
continue
except Exception: # noqa: BLE001
continue
# Simple firing: append a note to scene_text
summary = f"\n\n[Offscreen event: {trig.event_type} — payload: {json.dumps(trig.payload, ensure_ascii=False)}]"
if step.scene_text:
step.scene_text += summary
else:
step.scene_text = summary
trig.is_fired = True
trig.fired_at = datetime.now(timezone.utc)
await db.flush()
await sse.emit("trigger_fired", {
"trigger_id": str(trig.id), "event_type": trig.event_type,
"summary": summary.strip(),
})
fired += 1
# Persist the trigger event as a story entry
await rag_add(
db=db, world_id=world.id,
content=f"Deferred trigger fired: {trig.event_type} at {trig.fire_at}",
entry_type="event",
metadata={"trigger_id": str(trig.id), "step_id": str(step.id)},
step_id=step.id,
)
if fired:
await db.commit()
async def _maybe_generate_summary(
*,
db: AsyncSession,
world: World,
step: Step,
llm: LlmClient | MockLlmClient,
sse: SseEmitter,
settings: dict[str, Any],
) -> None:
"""Generate a summary if recent step count exceeds the threshold."""
threshold = int(settings.get("context.compression_threshold_messages", 20))
guaranteed = int(settings.get("context.guaranteed_messages", 10))
recent_steps = list(
reversed(
(
await db.execute(
select(Step)
.where(Step.world_id == world.id, Step.deleted_at.is_(None))
.order_by(Step.sequence_number.desc())
.limit(threshold + 1)
)
).scalars().all()
)
)
if len(recent_steps) <= threshold:
return
old_steps = recent_steps[:-guaranteed]
if not old_steps:
return
messages = await build_summary_context(
db=db, world=world, old_steps=old_steps, settings=settings,
)
resp = await llm.complete(
stage="orchestrator_phase3_summary",
messages=messages,
temperature=0.3,
max_tokens=1024,
world_id=world.id, step_id=step.id, session=db,
)
summary_text = resp.get("message", {}).get("content", "")
if not summary_text:
return
# Store as a story entry
se = StoryEntry(
world_id=world.id,
content=summary_text,
entry_type="event",
metadata_={
"type": "summary",
"step_range": [old_steps[0].sequence_number, old_steps[-1].sequence_number],
},
embedding_status="pending",
)
db.add(se)
await db.commit()
await sse.emit("summary_generated", {
"summary_id": str(se.id),
"message_range": [old_steps[0].sequence_number, old_steps[-1].sequence_number],
})

84
app/engine/sse.py Normal file
View File

@@ -0,0 +1,84 @@
"""SSE event emitter — wraps sse-starlette to emit typed events."""
from __future__ import annotations
import asyncio
import json
import uuid
from collections.abc import AsyncIterator
from typing import Any
from app.core.logging import get_logger
_logger = get_logger(__name__)
class SseEmitter:
"""Async queue-based SSE emitter.
Usage:
emitter = SseEmitter()
async with emitter.stream() as stream:
async for event in stream:
yield event
In a producer task:
await emitter.emit("tool_call", {...})
await emitter.done({"result": "ok"})
"""
def __init__(self) -> None:
self._queue: asyncio.Queue[tuple[str, str, str] | None] = asyncio.Queue()
# (event_type, data_json, event_id)
self._event_counter = 0
self._closed = False
async def emit(self, event_type: str, data: Any) -> None:
if self._closed:
return
self._event_counter += 1
event_id = f"evt_{self._event_counter}"
try:
data_str = json.dumps(data, ensure_ascii=False, default=str)
except (TypeError, ValueError):
data_str = json.dumps({"error": "serialization_failed"})
await self._queue.put((event_type, data_str, event_id))
async def ping(self) -> None:
await self.emit("ping", {"ts": _now_iso()})
async def done(self, result: Any = None) -> None:
await self.emit("done", result if result is not None else {})
await self._queue.put(None) # sentinel
self._closed = True
async def error(self, code: str, message: str, details: Any = None) -> None:
payload: dict[str, Any] = {"code": code, "message": message}
if details is not None:
payload["details"] = details
await self.emit("error", payload)
await self._queue.put(None)
self._closed = True
async def stream(self) -> AsyncIterator[dict[str, str]]:
"""Yield SSE-formatted dicts until the emitter is closed."""
try:
while True:
item = await self._queue.get()
if item is None:
break
event_type, data_str, event_id = item
yield {
"event": event_type,
"data": data_str,
"id": event_id,
}
except asyncio.CancelledError:
_logger.info("sse_stream_cancelled")
raise
def _now_iso() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat()

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}")

301
app/engine/world_builder.py Normal file
View File

@@ -0,0 +1,301 @@
"""World Builder — generates a new world from a preset or form, then intro scene.
Flow (see §9.1 of TDD):
1. Receive template (preset or form).
2. Generate schemas + environment_schema + rules + time_schema.
3. Generate initial environment (player + current_location + plot_rails).
4. Generate initial entities (locations, NPCs, items).
5. Generate intro scene + suggested actions.
6. Mark world status='ready'.
"""
from __future__ import annotations
import json
import uuid
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.llm import LlmClient, MockLlmClient
from app.core.logging import get_logger
from app.core.state_validator import validate_world
from app.core.time_utils import summarize_schemas
from app.engine.sse import SseEmitter
from app.engine.tools.base import ToolContext, get_registry
from app.models import World, WorldPreset
from app.prompts.registry import get_prompt
_logger = get_logger(__name__)
async def run_world_builder(
*,
db: AsyncSession,
world: World,
player_name: str,
notes: str | None,
llm: LlmClient | MockLlmClient,
sse: SseEmitter,
preset: WorldPreset | None = None,
) -> None:
"""Run the full world_builder flow for a draft world.
Emits SSE events and updates the world row in place. On error, emits `error`
and returns (the world stays in status='draft').
"""
try:
# ---- Step 1: Generate schemas / rules / time_schema / environment_schema
await sse.emit("step", {"step": "generating_schema", "message": "Generating world schema..."})
schema_prompt = get_prompt("world_builder_schema", "en").format(
mode="preset" if preset else "form",
form_data=json.dumps({}, ensure_ascii=False),
preset_name=preset.name if preset else "",
player_name=player_name,
language=world.language,
notes=notes or "",
)
# If we have a preset, use its schemas directly instead of calling LLM
if preset and preset.schemas:
world.schemas = preset.schemas
world.environment_schema = preset.environment_schema
world.rules = preset.rules
world.time_schema = preset.time_schema
world.environment = dict(preset.environment_initial)
else:
resp = await llm.complete(
stage="world_builder_schema",
messages=[{"role": "system", "content": schema_prompt}],
temperature=0.5,
max_tokens=4096,
world_id=world.id,
session=db,
)
try:
content = resp["message"].get("content", "")
# Strip markdown fences if present
content = _strip_code_fence(content)
schema_data = json.loads(content)
except (json.JSONDecodeError, KeyError) as e:
await sse.error("schema_generation_failed", f"Invalid JSON from LLM: {e}")
return
world.schemas = schema_data.get("schemas", [])
world.environment_schema = schema_data.get("environment_schema", [])
world.rules = schema_data.get("rules", [])
world.time_schema = schema_data.get("time_schema", {"hours_in_day": 24, "initial_date": "day_1_hour_8"})
world.environment = schema_data.get("environment_initial", {})
# Ensure player name is set
env = dict(world.environment or {})
if isinstance(env.get("player"), dict):
env["player"]["name"] = player_name
else:
env["player"] = {"name": player_name}
world.environment = env
await db.commit()
await sse.emit("world_schema_generated", {
"schemas": world.schemas, "environment_schema": world.environment_schema,
})
# ---- Step 2: Generate environment (skip if preset provided one)
if not preset or not preset.environment_initial:
await sse.emit("step", {"step": "generating_environment", "message": "Generating environment..."})
env_prompt = get_prompt("world_builder_env", "en").format(
world_name=world.name,
world_description=world.description or "",
language=world.language,
rules="\n".join(f"- {r}" for r in (world.rules or [])),
schemas_summary=summarize_schemas(world.schemas or []),
environment_schema_json=json.dumps(world.environment_schema, ensure_ascii=False, indent=2),
player_name=player_name,
)
resp = await llm.complete(
stage="world_builder_env",
messages=[{"role": "system", "content": env_prompt}],
temperature=0.6,
max_tokens=2048,
world_id=world.id,
session=db,
)
try:
content = _strip_code_fence(resp["message"].get("content", ""))
env_data = json.loads(content)
env_data.setdefault("player", {}).setdefault("name", player_name)
world.environment = env_data
except (json.JSONDecodeError, KeyError) as e:
await sse.error("env_generation_failed", f"Invalid env JSON: {e}")
return
await db.commit()
await sse.emit("environment_generated", {"environment": world.environment})
# Validate world
ok, errors = validate_world({
"name": world.name, "language": world.language,
"schemas": world.schemas, "environment_schema": world.environment_schema,
"environment": world.environment, "plot_rails": world.plot_rails,
"current_time": world.current_time,
})
if not ok:
await sse.error("world_invalid", "World validation failed", details=errors)
return
# ---- Step 3: Generate entities via tool-calling loop
await sse.emit("step", {"step": "generating_entities", "message": "Generating entities..."})
await _run_tool_loop(
db=db, world=world, llm=llm, sse=sse,
stage="world_builder_entities",
system_prompt=get_prompt("world_builder_entities", "en").format(
world_name=world.name,
world_description=world.description or "",
language=world.language,
schemas_summary=summarize_schemas(world.schemas or []),
environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2),
max_substeps=12,
),
terminal_tool="submit_plan",
max_substeps=12,
settings={}, # world_builder uses fixed defaults
)
await db.commit()
await sse.emit("entities_generated", {"world_id": str(world.id)})
# ---- Step 4: Generate intro scene
await sse.emit("step", {"step": "generating_intro", "message": "Generating intro scene..."})
from sqlalchemy import select
from app.models import Entity
entities = (
await db.execute(
select(Entity).where(
Entity.world_id == world.id, Entity.deleted_at.is_(None)
)
)
).scalars().all()
entities_summary = "\n".join(
f"- {e.entity_type}: {e.name}" for e in entities[:20]
)
intro_prompt = get_prompt("intro_scene", "en").format(
world_name=world.name,
world_description=world.description or "",
language=world.language,
current_time=world.current_time,
environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2),
plot_rails_json=json.dumps(world.plot_rails, ensure_ascii=False, indent=2),
entities_summary=entities_summary,
)
# Phase 2: scene_text + delta_time
scene_result = await _run_tool_loop(
db=db, world=world, llm=llm, sse=sse,
stage="intro_scene",
system_prompt=intro_prompt,
terminal_tool="submit_step",
max_substeps=3,
settings={},
)
scene_text = ""
delta_time = "hours_1"
if scene_result and scene_result.get("ok"):
scene_text = scene_result.get("data", {}).get("scene_text", "")
delta_time = scene_result.get("data", {}).get("delta_time", "hours_1")
world.intro_scene = scene_text
from app.core.time_utils import advance_time
world.current_time = advance_time(world.current_time, delta_time, world.time_schema)
await db.commit()
await sse.emit("intro_scene_complete", {
"text": scene_text, "delta_time": delta_time, "current_time": world.current_time,
})
# Mark ready
world.status = "ready"
await db.commit()
await sse.done({"world_id": str(world.id), "status": "ready"})
except Exception as e: # noqa: BLE001
_logger.exception("world_builder_failed", world_id=str(world.id), error=str(e))
await sse.error("internal_error", str(e))
def _strip_code_fence(text: str) -> str:
"""Remove ```json ... ``` fences if present."""
s = text.strip()
if s.startswith("```"):
# Remove first line (``` or ```json)
s = s.split("\n", 1)[1] if "\n" in s else s
if s.endswith("```"):
s = s[:-3]
return s.strip()
async def _run_tool_loop(
*,
db: AsyncSession,
world: World,
llm: LlmClient | MockLlmClient,
sse: SseEmitter,
stage: str,
system_prompt: str,
terminal_tool: str,
max_substeps: int,
settings: dict[str, Any],
) -> dict[str, Any] | None:
"""Generic tool-calling loop. Returns the result of the terminal tool call."""
registry = get_registry()
ctx = ToolContext(
db=db, world=world, stage=stage,
sse_emitter=sse.emit,
)
messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Begin {stage}."},
]
tools = registry.to_openai_format(stage)
last_terminal_result: dict[str, Any] | None = None
for substep in range(max_substeps):
await sse.emit("llm_call_start", {"stage": stage, "model": getattr(llm, "_model", "mock")})
resp = await llm.complete(
stage=stage,
messages=messages,
tools=tools,
temperature=0.7,
max_tokens=2048,
world_id=world.id,
session=db,
)
await sse.emit("llm_call_end", {
"stage": stage, "latency_ms": resp.get("latency_ms", 0),
"tokens": (resp.get("prompt_tokens") or 0) + (resp.get("completion_tokens") or 0),
})
msg = resp.get("message", {})
tool_calls = msg.get("tool_calls") or []
if not tool_calls:
# No tool calls — append assistant message and ask again
messages.append({"role": "assistant", "content": msg.get("content", "")})
messages.append({
"role": "user",
"content": "You must call a tool. Available terminal tool: " + terminal_tool,
})
continue
messages.append(msg)
for tc in tool_calls:
fn = tc.get("function", {})
tname = fn.get("name", "")
try:
targs = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
targs = {}
result = await registry.execute(tname, targs, ctx)
# Tool result as a tool message
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", ""),
"name": tname,
"content": json.dumps(result.to_dict(), ensure_ascii=False),
})
if tname == terminal_tool:
last_terminal_result = result.to_dict()
return last_terminal_result
# If we exhausted substeps without terminal, return None
return last_terminal_result

148
app/engine/world_editor.py Normal file
View File

@@ -0,0 +1,148 @@
"""World Editor — chat-based editing of an existing world."""
from __future__ import annotations
import json
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.llm import LlmClient, MockLlmClient
from app.core.logging import get_logger
from app.core.time_utils import summarize_schemas
from app.engine.sse import SseEmitter
from app.engine.tools.base import ToolContext, get_registry
from app.models import Entity, World
from app.prompts.registry import get_prompt
_logger = get_logger(__name__)
async def run_world_editor(
*,
db: AsyncSession,
world: World,
instruction: str,
llm: LlmClient | MockLlmClient,
sse: SseEmitter,
max_iterations: int = 8,
) -> None:
"""Run a world_editor iteration: instruction → propose_changes → done.
Simplified (vs §9.2): no `ask_user` blocking — the LLM gets one shot at
producing a `propose_changes` (or applies tool calls directly if simple).
"""
try:
registry = get_registry()
ctx = ToolContext(db=db, world=world, stage="world_editor", sse_emitter=sse.emit)
# Snapshot current entities for the prompt
entities = (
await db.execute(
select(Entity).where(
Entity.world_id == world.id, Entity.deleted_at.is_(None)
).limit(30)
)
).scalars().all()
entities_summary = "\n".join(
f"- {e.entity_type}: {e.name}" for e in entities
)
sys_prompt = get_prompt("world_editor", "en").format(
world_name=world.name,
world_description=world.description or "",
language=world.language,
schemas_summary=summarize_schemas(world.schemas or []),
environment_json=json.dumps(world.environment, ensure_ascii=False, indent=2),
entities_summary=entities_summary,
instruction=instruction,
)
messages: list[dict[str, Any]] = [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": instruction},
]
tools = registry.to_openai_format("world_editor")
for _ in range(max_iterations):
resp = await llm.complete(
stage="world_editor",
messages=messages,
tools=tools,
temperature=0.5,
max_tokens=2048,
world_id=world.id,
session=db,
)
msg = resp.get("message", {})
tcs = msg.get("tool_calls") or []
if not tcs:
# Done
await sse.emit("comment", {"text": msg.get("content", "")})
break
messages.append(msg)
done = False
for tc in tcs:
fn = tc.get("function", {})
tname = fn.get("name", "")
try:
targs = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
targs = {}
if tname == "ask_user":
# Non-interactive: emit clarification and stop
await sse.emit("clarification", {
"question": targs.get("question"),
"options": targs.get("options"),
})
await sse.done({"status": "needs_clarification"})
return
if tname == "propose_changes":
await sse.emit("change_proposed", {
"diff": targs.get("diff", []),
"comment": targs.get("comment", ""),
})
# Apply changes directly (simplified: auto-accept)
await _apply_diff(world, targs.get("diff", []))
await db.commit()
await sse.emit("apply_changes", {})
done = True
break
# Execute tool
result = await registry.execute(tname, targs, ctx)
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", ""),
"name": tname,
"content": json.dumps(result.to_dict(), ensure_ascii=False),
})
if done:
break
await sse.done({"status": "completed"})
except Exception as e: # noqa: BLE001
_logger.exception("world_editor_failed", world_id=str(world.id), error=str(e))
await sse.error("internal_error", str(e))
async def _apply_diff(world: World, diff: list[dict[str, Any]]) -> None:
"""Apply a propose_changes diff to the world.
Supports paths into environment and basic field operations.
"""
from app.core.state_validator import apply_patch
env_patch: dict[str, Any] = {}
schemas_patch: dict[str, Any] = {}
for d in diff:
path = d.get("path", "")
op = d.get("op", "replace")
new = d.get("new")
if path.startswith("environment."):
field = path[len("environment."):]
env_patch[field] = new
elif path.startswith("schemas."):
# For simplicity, replace entire schemas if any schema patch present
schemas_patch[path] = new
if env_patch:
new_env, errors = apply_patch(dict(world.environment or {}), env_patch)
if not errors:
world.environment = new_env