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