rebase
This commit is contained in:
1
app/engine/__init__.py
Normal file
1
app/engine/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Engine layer — game logic (orchestrator, world builder/editor, tools, context)."""
|
||||
180
app/engine/context.py
Normal file
180
app/engine/context.py
Normal 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
314
app/engine/game_master.py
Normal 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
84
app/engine/sse.py
Normal 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()
|
||||
1
app/engine/tools/__init__.py
Normal file
1
app/engine/tools/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tools package — game tools, interaction tools, schema tools."""
|
||||
231
app/engine/tools/base.py
Normal file
231
app/engine/tools/base.py
Normal 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
993
app/engine/tools/game.py
Normal 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)",
|
||||
)
|
||||
56
app/engine/tools/register_all.py
Normal file
56
app/engine/tools/register_all.py
Normal 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
|
||||
146
app/engine/tools/schema_tools.py
Normal file
146
app/engine/tools/schema_tools.py
Normal 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
301
app/engine/world_builder.py
Normal 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
148
app/engine/world_editor.py
Normal 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
|
||||
Reference in New Issue
Block a user