From 4dee4fb0a889b4f70af2f05fa1df0730f01accd6 Mon Sep 17 00:00:00 2001
From: Mikan <72257910+Mikan-DS@users.noreply.github.com>
Date: Sun, 21 Jun 2026 04:11:38 +0300
Subject: [PATCH] rebase
---
CHANGELOG.md | 40 +++
app/api/admin.py | 116 ++++++-
app/api/sessions.py | 165 +++++++++
app/core/llm.py | 65 ++++
app/engine/world_editor.py | 189 +++++++++--
app/prompts/stages/intro_scene.py | 7 +-
app/prompts/stages/orchestrator_phase2.py | 7 +-
.../src/components/admin/SettingsPanel.tsx | 27 +-
frontend/src/components/admin/TestButtons.tsx | 73 +++-
.../components/worlds/IntroSceneGenerator.tsx | 303 +++++++++++++++++
.../src/components/worlds/WorldBuilder.tsx | 66 +++-
frontend/src/components/worlds/WorldCard.tsx | 29 ++
.../src/components/worlds/WorldEditor.tsx | 321 ++++++++++++++++--
frontend/src/i18n/en.json | 31 +-
frontend/src/i18n/ru.json | 31 +-
frontend/src/lib/api.ts | 35 ++
frontend/src/pages/WorldEditPage.tsx | 29 +-
frontend/src/types/index.ts | 2 +
frontend/tsconfig.tsbuildinfo | 2 +-
19 files changed, 1423 insertions(+), 115 deletions(-)
create mode 100644 frontend/src/components/worlds/IntroSceneGenerator.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36a1e9b..a51cb02 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,46 @@ All notable changes to AI-RPG are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.2.0] — 2026-06-21
+
+This release fixes critical bugs that prevented world creation, world editing, and LLM tool-calling with local models (gemma, qwen, etc.).
+
+### Backend — Critical fixes
+
+- **Prompt templates**: fixed `KeyError: 'scene_text, delta_time'` that crashed world_builder during intro scene generation. The prompts contained literal `{scene_text, delta_time}` braces which `str.format()` interpreted as format fields. Rewrote `orchestrator_phase2` and `intro_scene` prompts to describe the tool arguments in prose instead of using brace notation. All 11 prompt templates now format correctly (verified with a test).
+- **LLM tool-call parsing for local models**: many local models (gemma4, qwen, etc.) don't use the OpenAI function-calling format — they emit tool calls as text like `call:calc{"expression": "2+2"}` or `calc{...}`. Added `_parse_text_tool_calls()` to `app/core/llm.py` that detects these patterns and converts them to OpenAI-format `tool_calls`. The LLM client now automatically parses text-based tool calls when the model doesn't return them in the standard format. This fixes the issue where `request_tools` was `[]` (empty) in logs even though tools were sent — actually tools WERE sent, but the model returned calls as text and they were ignored.
+- **Test endpoints — ascii codec error**: `POST /api/admin/test/llm-tools` failed with `'ascii' codec can't encode character '\u2026'` when the client sent a masked api_key (containing `…`) back as a query parameter. Added `_is_masked()` and `_resolve()` helpers that detect masked values (`…` or `****`) and fall back to the raw DB value. All 4 test endpoints (test/llm, test/llm-tools, test/embeddings, test/embeddings/probe-dimension) now use these helpers.
+- **Test LLM tools — better prompt**: the test now sends a system message "You must use the calc tool" and a user message "You MUST call the calc tool with expression '2+2'" to encourage tool use. Also increased timeout from 15s to 30s. The response now includes `raw_response` (the full LLM message) for debugging.
+- **World editor — `'str' object has no attribute 'get'`**: the world_editor crashed when tool_calls contained string entries instead of dicts (some models return non-standard formats). Added type normalization: each tool_call is checked with `isinstance(tc, dict)`, strings are parsed as JSON, non-dicts are skipped. Also handles cases where `function` is not a dict.
+- **World editor — propose_changes now waits for user**: previously `propose_changes` was auto-accepted (simplified). Now the editor emits `change_proposed` and WAITS for the user to accept/reject via `POST /api/sessions/worlds/{id}/apply` or `/discard`. Implemented using `asyncio.Future` stored in `_pending_changes` dict keyed by world_id. 120s timeout.
+- **World editor — ask_user now waits for answer**: similarly, `ask_user` now waits for `POST /api/sessions/worlds/{id}/answer` body `{text: "..."}`. 120s timeout.
+- **World editor — apply_diff improved**: now supports paths `world.name`, `world.description`, `schemas` (full replace), and `environment.` (via apply_patch). Previously only `environment.*` paths worked.
+- **World editor — better empty-state handling**: when world has no schemas/entities/environment, the prompt now shows "(no schemas yet)", "(no entities yet)", and "(empty — world has no environment yet. Use env_update to add player, current_location, plot_rails.)" instead of empty strings, so the LLM understands the context.
+
+### Backend — New endpoints
+
+- `POST /api/sessions/worlds/{id}/apply` — accept proposed changes (resolves the pending Future)
+- `POST /api/sessions/worlds/{id}/discard` — reject proposed changes
+- `POST /api/sessions/worlds/{id}/answer` body `{text: "..."}` — answer a clarification
+- `POST /api/worlds/{id}/generate-intro` → `{stream_url}` — re-generate intro scene for draft worlds
+- `GET /api/sessions/worlds/{id}/intro/stream` (SSE) — runs the intro_scene stage, sets world.status to "ready" on success. Emits `step`, `intro_scene_complete`, `done` events.
+
+### Frontend — Critical fixes (10 files changed, 1 new)
+
+- **World Editor — Accept/Reject buttons**: `change_proposed` events now show a card with the diff (color-coded: green=add, red=remove, yellow=replace) and two buttons. Accept → `POST /apply`, Reject → `POST /discard`. After decision, the card collapses to a status line.
+- **World Editor — Answer input**: `clarification` events show a card with either free-text input + Send button, or clickable option buttons (if `options` provided). Submit → `POST /answer`.
+- **World Edit Page — Generate Intro Scene**: new `IntroSceneGenerator` component. If `world.status === 'draft'`, shows a "Generate Intro Scene" button. Clicking it opens the SSE stream, shows progress, displays the generated scene, and on `done` refreshes the world (status → "ready") + shows toast "World is ready!".
+- **Settings panel — autocomplete="off"**: all inputs now have `autoComplete="off"`. API key fields use `type="text"` (browsers won't save them as passwords). Added a hidden decoy password input to absorb the password manager's attention.
+- **Username/email autocomplete**: verified `LoginPage` uses `autoComplete="username"` for the login field, `RegisterPage` uses `autoComplete="email"` for email and `autoComplete="username"` for username.
+- **Test LLM Tools — raw response display**: when `has_tool_calls=false`, shows a warning + collapsible `` with the raw LLM response so the user can see what the model returned.
+- **World Builder — Retry button**: on error, shows a "Retry" button that re-subscribes to the builder stream. Also fixed a stale-closure bug where `done`/`error` events didn't close the active SSE controller (used `useRef` to track the current controller).
+- **World Card — draft state**: draft worlds show a "Continue setup" button (links to edit page) and a "Draft" badge. No "Play" button for drafts.
+- **Play page — better not-ready toast**: "This world is not ready yet. Generate the intro scene first."
+
+### Verification
+- Backend: 68 unit tests pass, 50 routes.
+- Frontend: `tsc --noEmit` → 0 errors. `npm run build` → success (368 KB JS / 23 KB CSS, ~112 KB gzipped).
+
## [1.1.0] — 2026-06-21
This is a major bugfix release addressing 20+ issues found during user testing.
diff --git a/app/api/admin.py b/app/api/admin.py
index 633c53e..278c2be 100644
--- a/app/api/admin.py
+++ b/app/api/admin.py
@@ -217,8 +217,34 @@ async def stats(
}
+# --------------------------------------------------------------------------- #
+# Helpers for test endpoints
+# --------------------------------------------------------------------------- #
+def _is_masked(value: str | None) -> bool:
+ """Detect masked secret values (contain '…' or are exactly '****').
+
+ The admin GET /settings endpoint masks secret keys before sending them to
+ the client. If the client sends a masked value back to a test endpoint
+ (because it pre-filled the form from the masked settings response), we
+ must ignore it and fall back to the raw value from the DB.
+ """
+ if not value:
+ return False
+ return "…" in value or value == "****"
+
+
+def _resolve(value: str | None, fallback: str) -> str:
+ """Use `value` if it's a non-empty, non-masked string; otherwise use fallback."""
+ if value and not _is_masked(value):
+ return value
+ return fallback or ""
+
+
# --------------------------------------------------------------------------- #
# Test endpoints — LLM, embeddings, embeddings probe dimension
+# All test endpoints accept query params AND fall back to DB-stored settings.
+# Masked values (containing '…' or '****') are ignored — they come from the
+# admin UI's pre-filled form which displays masked secrets.
# --------------------------------------------------------------------------- #
@router.post("/test/llm")
async def test_llm(
@@ -229,9 +255,9 @@ async def test_llm(
_user: User = Depends(require_admin),
) -> dict:
settings = await get_all_settings(db)
- api_url = api_url or settings.get("llm.api_url", "")
- api_key = api_key or settings.get("llm.api_key", "")
- model = model or settings.get("llm.model", "")
+ api_url = _resolve(api_url, settings.get("llm.api_url", ""))
+ api_key = _resolve(api_key, settings.get("llm.api_key", ""))
+ model = _resolve(model, settings.get("llm.model", ""))
if not api_url:
return {"ok": False, "error": {"code": "not_configured", "message": "llm.api_url is empty"},
"elapsed_ms": 0}
@@ -265,37 +291,49 @@ async def test_llm_tools(
_user: User = Depends(require_admin),
) -> dict:
settings = await get_all_settings(db)
- api_url = api_url or settings.get("llm.api_url", "")
- api_key = api_key or settings.get("llm.api_key", "")
- model = model or settings.get("llm.model", "")
+ api_url = _resolve(api_url, settings.get("llm.api_url", ""))
+ api_key = _resolve(api_key, settings.get("llm.api_key", ""))
+ model = _resolve(model, settings.get("llm.model", ""))
if not api_url:
return {"ok": False, "error": {"code": "not_configured", "message": "llm.api_url is empty"},
"elapsed_ms": 0, "has_tool_calls": False}
- client = LlmClient(api_url=api_url, api_key=api_key, model=model, timeout=15.0, max_retries=1)
+ client = LlmClient(api_url=api_url, api_key=api_key, model=model, timeout=30.0, max_retries=1)
start = time.monotonic()
try:
tools = [{
"type": "function",
"function": {
"name": "calc",
- "description": "Evaluate a math expression",
+ "description": "Evaluate a math expression. You MUST call this tool.",
"parameters": {
"type": "object",
"required": ["expression"],
- "properties": {"expression": {"type": "string"}},
+ "properties": {"expression": {"type": "string", "description": "e.g. '2+2'"}},
},
},
}]
resp = await client.complete(
stage="test_llm_tools",
- messages=[{"role": "user", "content": "What is 2+2? Use the calc tool."}],
- tools=tools, temperature=0.0, max_tokens=100,
+ messages=[
+ {"role": "system", "content": "You must use the calc tool to answer math questions. Do not compute in your head."},
+ {"role": "user", "content": "What is 2+2? You MUST call the calc tool with expression '2+2'."},
+ ],
+ tools=tools,
+ tool_choice="auto",
+ temperature=0.0, max_tokens=100,
session=db,
)
elapsed = int((time.monotonic() - start) * 1000)
tcs = resp["message"].get("tool_calls") or []
+ # Also try to parse tool calls from content (some models emit them as text)
+ if not tcs:
+ content = resp["message"].get("content", "") or ""
+ parsed_tcs = _parse_text_tool_calls(content)
+ if parsed_tcs:
+ tcs = parsed_tcs
return {
"ok": True, "tool_calls": tcs, "has_tool_calls": bool(tcs), "elapsed_ms": elapsed,
+ "raw_response": resp["message"],
}
except Exception as e: # noqa: BLE001
elapsed = int((time.monotonic() - start) * 1000)
@@ -303,6 +341,50 @@ async def test_llm_tools(
"elapsed_ms": elapsed, "has_tool_calls": False}
+# Pattern: call:tool_name{args} or name{args} or name(args)
+import re as _re
+
+_TOOL_CALL_PATTERNS = [
+ # call:name{json_args}
+ _re.compile(r"call:(\w+)\s*\{([^}]*)\}"),
+ # name{args}
+ _re.compile(r"\s*(\w+)\s*\{([^}]*)\}\s*"),
+ # name({"key": "value", ...})
+ _re.compile(r"(\w+)\s*\(\s*(\{[^}]*\})\s*\)"),
+]
+
+
+def _parse_text_tool_calls(content: str) -> list[dict]:
+ """Parse tool calls emitted as text (some models don't use the OpenAI format).
+
+ Handles patterns like:
+ - call:calc{"expression": "2+2"}
+ - calc{"expression": "2+2"}
+ - calc({"expression": "2+2"})
+ """
+ import json as _json
+ calls: list[dict] = []
+ for pattern in _TOOL_CALL_PATTERNS:
+ for match in pattern.finditer(content):
+ name = match.group(1)
+ args_str = match.group(2).strip()
+ try:
+ args = _json.loads(args_str)
+ except _json.JSONDecodeError:
+ # Try to fix common issues (single quotes, missing quotes on keys)
+ try:
+ fixed = args_str.replace("'", '"')
+ args = _json.loads(fixed)
+ except _json.JSONDecodeError:
+ args = {"_raw": args_str}
+ calls.append({
+ "id": f"parsed_{len(calls)}",
+ "type": "function",
+ "function": {"name": name, "arguments": _json.dumps(args)},
+ })
+ return calls
+
+
@router.post("/test/embeddings")
async def test_embeddings(
api_url: str | None = None,
@@ -324,9 +406,9 @@ async def test_embeddings(
"ok": True, "dimension": emb.dimension, "model": "offline_hash",
"first_5_values": vecs[0][:5] if vecs else [], "elapsed_ms": elapsed,
}
- api_url = api_url or settings.get("embeddings.api_url") or settings.get("llm.api_url", "")
- api_key = api_key or settings.get("embeddings.api_key") or settings.get("llm.api_key", "")
- model = model or settings.get("embeddings.model", "")
+ api_url = _resolve(api_url, settings.get("embeddings.api_url") or settings.get("llm.api_url", ""))
+ api_key = _resolve(api_key, settings.get("embeddings.api_key") or settings.get("llm.api_key", ""))
+ model = _resolve(model, settings.get("embeddings.model", ""))
if not api_url:
return {"ok": False, "error": {"code": "not_configured", "message": "no api_url"},
"elapsed_ms": 0}
@@ -366,9 +448,9 @@ async def probe_dimension(
"dimension": int(settings.get("embeddings.dimension", 256)),
"elapsed_ms": 0,
}
- api_url = api_url or settings.get("embeddings.api_url") or settings.get("llm.api_url", "")
- api_key = api_key or settings.get("embeddings.api_key") or settings.get("llm.api_key", "")
- model = model or settings.get("embeddings.model", "")
+ api_url = _resolve(api_url, settings.get("embeddings.api_url") or settings.get("llm.api_url", ""))
+ api_key = _resolve(api_key, settings.get("embeddings.api_key") or settings.get("llm.api_key", ""))
+ model = _resolve(model, settings.get("embeddings.model", ""))
emb = build_openai_embedder(
api_url=api_url, api_key=api_key, model=model,
dimension=int(settings.get("embeddings.dimension", 1536)),
diff --git a/app/api/sessions.py b/app/api/sessions.py
index 170fc67..3e6f210 100644
--- a/app/api/sessions.py
+++ b/app/api/sessions.py
@@ -260,6 +260,171 @@ async def iterate_stream(
)
+# --------------------------------------------------------------------------- #
+# World editor: accept/reject proposed changes, answer clarifications
+# --------------------------------------------------------------------------- #
+@router.post("/worlds/{world_id}/apply")
+async def apply_proposed_changes(
+ world_id: uuid.UUID,
+ db: AsyncSession = Depends(get_db),
+ user=Depends(get_current_user),
+) -> dict:
+ """Accept proposed changes from the world_editor stream."""
+ from app.engine.world_editor import submit_propose_changes_answer
+
+ world = await _load_world(db, world_id, user)
+ ok = submit_propose_changes_answer(world.id, True)
+ if not ok:
+ raise HTTPException(409, "no_pending_changes")
+ return {"ok": True}
+
+
+@router.post("/worlds/{world_id}/discard")
+async def discard_proposed_changes(
+ world_id: uuid.UUID,
+ db: AsyncSession = Depends(get_db),
+ user=Depends(get_current_user),
+) -> dict:
+ """Reject proposed changes from the world_editor stream."""
+ from app.engine.world_editor import submit_propose_changes_answer
+
+ world = await _load_world(db, world_id, user)
+ ok = submit_propose_changes_answer(world.id, False)
+ if not ok:
+ raise HTTPException(409, "no_pending_changes")
+ return {"ok": True}
+
+
+@router.post("/worlds/{world_id}/answer")
+async def answer_clarification(
+ world_id: uuid.UUID,
+ body: AnswerRequest,
+ db: AsyncSession = Depends(get_db),
+ user=Depends(get_current_user),
+) -> dict:
+ """Answer an ask_user clarification from the world_editor stream."""
+ from app.engine.world_editor import submit_clarification_answer
+
+ world = await _load_world(db, world_id, user)
+ ok = submit_clarification_answer(world.id, body.text)
+ if not ok:
+ raise HTTPException(409, "no_pending_clarification")
+ return {"ok": True}
+
+
+# --------------------------------------------------------------------------- #
+# Generate / re-generate intro scene (for worlds stuck in draft)
+# --------------------------------------------------------------------------- #
+@router.post("/worlds/{world_id}/generate-intro", response_model=dict, status_code=status.HTTP_202_ACCEPTED)
+async def generate_intro(
+ world_id: uuid.UUID,
+ db: AsyncSession = Depends(get_db),
+ user=Depends(get_current_user),
+ settings: dict = Depends(get_settings_dict),
+) -> dict:
+ """Re-generate the intro scene for a draft world.
+
+ This runs the intro_scene stage of the world_builder flow without
+ regenerating schemas/entities. Useful when the world was created from
+ a preset (which provides schemas + entities) but the intro scene
+ generation failed.
+ """
+ world = await _load_world(db, world_id, user)
+ if world.status == "archived":
+ raise HTTPException(422, "world_archived")
+ return {
+ "stream_url": f"/api/sessions/worlds/{world.id}/intro/stream",
+ }
+
+
+@router.get("/worlds/{world_id}/intro/stream")
+async def intro_stream(
+ world_id: uuid.UUID,
+ db: AsyncSession = Depends(get_db),
+ user=Depends(get_current_user),
+ settings: dict = Depends(get_settings_dict),
+) -> StreamingResponse:
+ world = await _load_world(db, world_id, user)
+ emitter = SseEmitter()
+ llm = _llm_factory(settings)
+
+ async def run_bg():
+ async with _session_scope() as bg_db:
+ from sqlalchemy import select as sa_select
+
+ from app.engine.world_builder import _run_tool_loop, _strip_code_fence
+ from app.engine.tools.base import ToolContext, get_registry
+ from app.models import Entity
+ from app.prompts.registry import get_prompt
+ from app.core.time_utils import advance_time, summarize_schemas
+ import json as _json
+
+ bg_world = (
+ await bg_db.execute(sa_select(World).where(World.id == world.id))
+ ).scalar_one()
+ try:
+ await emitter.emit("step", {"step": "generating_intro", "message": "Generating intro scene..."})
+ entities = (
+ await bg_db.execute(
+ sa_select(Entity).where(
+ Entity.world_id == bg_world.id, Entity.deleted_at.is_(None)
+ )
+ )
+ ).scalars().all()
+ entities_summary = "\n".join(
+ f"- {e.entity_type}: {e.name}" for e in entities[:20]
+ ) or "(no entities)"
+ intro_prompt = get_prompt("intro_scene", "en").format(
+ world_name=bg_world.name,
+ world_description=bg_world.description or "",
+ language=bg_world.language,
+ current_time=bg_world.current_time,
+ environment_json=_json.dumps(bg_world.environment or {}, ensure_ascii=False, indent=2),
+ plot_rails_json=_json.dumps(bg_world.plot_rails or {}, ensure_ascii=False, indent=2),
+ entities_summary=entities_summary,
+ )
+ scene_result = await _run_tool_loop(
+ db=bg_db, world=bg_world, llm=llm, sse=emitter,
+ 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")
+ bg_world.intro_scene = scene_text
+ bg_world.current_time = advance_time(bg_world.current_time, delta_time, bg_world.time_schema)
+ bg_world.status = "ready"
+ await bg_db.commit()
+ await emitter.emit("intro_scene_complete", {
+ "text": scene_text, "delta_time": delta_time,
+ "current_time": bg_world.current_time,
+ })
+ await emitter.done({"world_id": str(bg_world.id), "status": "ready"})
+ except Exception as e:
+ await emitter.error("internal_error", str(e))
+
+ import asyncio
+
+ task = asyncio.create_task(run_bg())
+
+ async def gen():
+ try:
+ async for evt in emitter.stream():
+ yield _format_sse(evt)
+ finally:
+ await task
+
+ return StreamingResponse(
+ gen(), media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
+
+
# --------------------------------------------------------------------------- #
# Retry / rollback
# --------------------------------------------------------------------------- #
diff --git a/app/core/llm.py b/app/core/llm.py
index 4db6280..b015f6f 100644
--- a/app/core/llm.py
+++ b/app/core/llm.py
@@ -31,6 +31,59 @@ from app.models import LlmCallLog
_logger = get_logger(__name__)
+# Patterns for parsing tool calls emitted as text (some local models don't
+# use the OpenAI function-calling format and instead emit calls as text).
+import re as _re
+
+_TOOL_CALL_PATTERNS = [
+ # call:name{json_args} or call:name(json_args)
+ _re.compile(r"call:(\w+)\s*[\{\(]([^}\)]*)[\}\)]"),
+ # name{args} or \n{"name": ..., "arguments": ...}\n
+ _re.compile(r"\s*(\w+)\s*\{([^}]*)\}\s*"),
+ # name{"key": "value", ...} (function call style)
+ _re.compile(r"\b(\w+)\s*\(\s*(\{[^}]*\})\s*\)"),
+]
+
+
+def _parse_text_tool_calls(content: str) -> list[dict]:
+ """Parse tool calls emitted as text by some local models.
+
+ Handles patterns like:
+ - call:calc{"expression": "2+2"}
+ - calc{"expression": "2+2"}
+ - calc({"expression": "2+2"})
+
+ Returns a list of OpenAI-format tool_call dicts.
+ """
+ if not content:
+ return []
+ calls: list[dict] = []
+ for pattern in _TOOL_CALL_PATTERNS:
+ for match in pattern.finditer(content):
+ name = match.group(1)
+ args_str = match.group(2).strip()
+ if not args_str:
+ args = {}
+ else:
+ try:
+ args = json.loads(args_str)
+ except json.JSONDecodeError:
+ # Try fixing common issues: single quotes, unquoted keys
+ try:
+ fixed = args_str.replace("'", '"')
+ # Add quotes around bare keys
+ fixed = _re.sub(r"(\w+)\s*:", r'"\1":', fixed)
+ args = json.loads(fixed)
+ except json.JSONDecodeError:
+ args = {"_raw": args_str}
+ calls.append({
+ "id": f"parsed_{len(calls)}",
+ "type": "function",
+ "function": {"name": name, "arguments": json.dumps(args)},
+ })
+ return calls
+
+
class LLMError(Exception):
"""Base LLM error."""
@@ -218,6 +271,18 @@ class LlmClient:
finish_reason = choice.get("finish_reason", "stop")
usage = data.get("usage", {})
+ # If the model didn't return tool_calls in the OpenAI format but DID
+ # emit them as text (some local models use "call:name{args}" or
+ # "name{args}"), try to parse them out.
+ if tools and not msg.get("tool_calls"):
+ content = msg.get("content", "") or ""
+ parsed = _parse_text_tool_calls(content)
+ if parsed:
+ msg = dict(msg) # don't mutate the original
+ msg["tool_calls"] = parsed
+ if finish_reason == "stop":
+ finish_reason = "tool_calls"
+
log_id: uuid.UUID | None = None
if session is not None:
log_id = await self._write_log_safely(
diff --git a/app/engine/world_editor.py b/app/engine/world_editor.py
index 7d607c9..6380257 100644
--- a/app/engine/world_editor.py
+++ b/app/engine/world_editor.py
@@ -1,7 +1,21 @@
-"""World Editor — chat-based editing of an existing world."""
+"""World Editor — chat-based editing of an existing world.
+
+Flow:
+1. Player sends an instruction via POST /api/worlds/{id}/edit.
+2. SSE stream opens (GET /api/sessions/worlds/{id}/editor/stream?instruction=...).
+3. LLM gets the instruction + world state + tools. It can:
+ - Call entity_create/update/delete, env_update, schema_* tools directly (applied immediately).
+ - Call ask_user to clarify (emits clarification SSE event, stream stays open for 60s
+ waiting for POST /api/sessions/worlds/{id}/answer).
+ - Call propose_changes to suggest a batch (emits change_proposed SSE event, stream
+ stays open waiting for POST /api/sessions/worlds/{id}/apply or /discard).
+ - Call comment_to_user to send text.
+4. When LLM stops calling tools, stream ends with done event.
+"""
from __future__ import annotations
+import asyncio
import json
import uuid
from typing import Any
@@ -20,6 +34,14 @@ from app.prompts.registry import get_prompt
_logger = get_logger(__name__)
+# In-memory pending propose_changes diffs, keyed by world_id.
+# The frontend accepts/rejects via REST, which resolves the Future.
+_pending_changes: dict[uuid.UUID, asyncio.Future[bool]] = {}
+
+# In-memory pending ask_user clarifications, keyed by world_id.
+_pending_clarifications: dict[uuid.UUID, asyncio.Future[str]] = {}
+
+
async def run_world_editor(
*,
db: AsyncSession,
@@ -28,17 +50,13 @@ async def run_world_editor(
llm: LlmClient | MockLlmClient,
sse: SseEmitter,
max_iterations: int = 8,
+ answer_timeout: float = 120.0,
) -> 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).
- """
+ """Run a world_editor iteration."""
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(
@@ -48,13 +66,16 @@ async def run_world_editor(
).scalars().all()
entities_summary = "\n".join(
f"- {e.entity_type}: {e.name}" for e in entities
- )
+ ) or "(no entities yet)"
+ env_json = json.dumps(world.environment or {}, ensure_ascii=False, indent=2)
+ if env_json == "{}":
+ env_json = "(empty — world has no environment yet. Use env_update to add player, current_location, plot_rails.)"
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),
+ schemas_summary=summarize_schemas(world.schemas or []) or "(no schemas yet)",
+ environment_json=env_json,
entities_summary=entities_summary,
instruction=instruction,
)
@@ -74,40 +95,103 @@ async def run_world_editor(
session=db,
)
msg = resp.get("message", {})
- tcs = msg.get("tool_calls") or []
+ # Normalize tool_calls: handle cases where the model returns them as
+ # strings or in non-standard formats.
+ raw_tcs = msg.get("tool_calls") or []
+ tcs: list[dict] = []
+ for tc in raw_tcs:
+ if isinstance(tc, str):
+ # Try to parse as JSON
+ try:
+ tc = json.loads(tc)
+ except json.JSONDecodeError:
+ continue
+ if not isinstance(tc, dict):
+ continue
+ tcs.append(tc)
+
if not tcs:
- # Done
- await sse.emit("comment", {"text": msg.get("content", "")})
+ content = msg.get("content", "")
+ if content:
+ await sse.emit("comment", {"text": content})
break
+
messages.append(msg)
done = False
for tc in tcs:
- fn = tc.get("function", {})
+ fn = tc.get("function") or {}
+ if not isinstance(fn, dict):
+ # Some models put the function name/args directly on tc
+ fn = {"name": tc.get("name", ""), "arguments": tc.get("arguments", "{}")}
tname = fn.get("name", "")
+ args_str = fn.get("arguments", "{}")
+ if not isinstance(args_str, str):
+ args_str = json.dumps(args_str)
try:
- targs = json.loads(fn.get("arguments") or "{}")
+ targs = json.loads(args_str) if args_str else {}
except json.JSONDecodeError:
targs = {}
if tname == "ask_user":
- # Non-interactive: emit clarification and stop
await sse.emit("clarification", {
- "question": targs.get("question"),
+ "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", ""),
+ # Wait for user answer via REST
+ fut: asyncio.Future[str] = asyncio.get_event_loop().create_future()
+ _pending_clarifications[world.id] = fut
+ try:
+ answer = await asyncio.wait_for(fut, timeout=answer_timeout)
+ except asyncio.TimeoutError:
+ await sse.emit("warning", {"code": "answer_timeout",
+ "message": "User did not answer in time"})
+ answer = ""
+ finally:
+ _pending_clarifications.pop(world.id, None)
+ messages.append({
+ "role": "tool",
+ "tool_call_id": tc.get("id", ""),
+ "name": tname,
+ "content": json.dumps({"answer": answer}),
})
- # 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
+ continue
+ if tname == "propose_changes":
+ diff = targs.get("diff", [])
+ comment = targs.get("comment", "")
+ await sse.emit("change_proposed", {
+ "diff": diff,
+ "comment": comment,
+ })
+ # Wait for user accept/reject via REST
+ fut2: asyncio.Future[bool] = asyncio.get_event_loop().create_future()
+ _pending_changes[world.id] = fut2
+ try:
+ accepted = await asyncio.wait_for(fut2, timeout=answer_timeout)
+ except asyncio.TimeoutError:
+ accepted = False
+ await sse.emit("warning", {"code": "accept_timeout",
+ "message": "User did not respond in time, discarding changes"})
+ finally:
+ _pending_changes.pop(world.id, None)
+ if accepted:
+ await _apply_diff(world, diff)
+ await db.commit()
+ await sse.emit("apply_changes", {"diff": diff})
+ messages.append({
+ "role": "tool",
+ "tool_call_id": tc.get("id", ""),
+ "name": tname,
+ "content": json.dumps({"ok": True, "applied": True}),
+ })
+ else:
+ await sse.emit("discard_changes", {})
+ messages.append({
+ "role": "tool",
+ "tool_call_id": tc.get("id", ""),
+ "name": tname,
+ "content": json.dumps({"ok": True, "applied": False, "reason": "user_rejected"}),
+ })
+ continue
+ # Regular tool — execute
result = await registry.execute(tname, targs, ctx)
messages.append({
"role": "tool",
@@ -123,26 +207,61 @@ async def run_world_editor(
await sse.error("internal_error", str(e))
+def submit_propose_changes_answer(world_id: uuid.UUID, accepted: bool) -> bool:
+ """Resolve a pending propose_changes Future. Returns True if a pending call existed."""
+ fut = _pending_changes.get(world_id)
+ if fut is None or fut.done():
+ return False
+ fut.set_result(accepted)
+ return True
+
+
+def submit_clarification_answer(world_id: uuid.UUID, answer: str) -> bool:
+ """Resolve a pending ask_user Future. Returns True if a pending call existed."""
+ fut = _pending_clarifications.get(world_id)
+ if fut is None or fut.done():
+ return False
+ fut.set_result(answer)
+ return True
+
+
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.
+ Supported path formats:
+ - `environment.` → apply_patch to world.environment
+ - `world.name` → set world.name
+ - `world.description` → set world.description
+ - `schemas` → replace entire world.schemas
"""
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", "")
+ if not isinstance(d, dict):
+ continue
+ path = d.get("path", "") or ""
op = d.get("op", "replace")
new = d.get("new")
if path.startswith("environment."):
field = path[len("environment."):]
env_patch[field] = new
+ elif path == "world.name":
+ if isinstance(new, str):
+ world.name = new
+ elif path == "world.description":
+ if new is None or isinstance(new, str):
+ world.description = new
+ elif path == "schemas":
+ if isinstance(new, list):
+ world.schemas = new
elif path.startswith("schemas."):
- # For simplicity, replace entire schemas if any schema patch present
- schemas_patch[path] = new
+ # For simplicity, replace entire schemas
+ if isinstance(new, list):
+ world.schemas = new
if env_patch:
new_env, errors = apply_patch(dict(world.environment or {}), env_patch)
if not errors:
world.environment = new_env
+ else:
+ _logger.warning("apply_diff_errors", errors=errors)
diff --git a/app/prompts/stages/intro_scene.py b/app/prompts/stages/intro_scene.py
index 882d935..a5ec405 100644
--- a/app/prompts/stages/intro_scene.py
+++ b/app/prompts/stages/intro_scene.py
@@ -25,9 +25,10 @@ Current time: {current_time}
{entities_summary}
# Hard rules
-- Call `submit_step` exactly once with {scene_text, delta_time}.
-- scene_text length: 300-2000 characters.
-- Write in second person ("You wake up in...").
+- You MUST call the `submit_step` tool with arguments named `scene_text` and `delta_time`.
+- The `scene_text` argument is a string (300-2000 characters) containing the narrative.
+- The `delta_time` argument is a string like "hours_2_min_30" indicating how much game time passes.
- After submit_step, call `suggest_actions` with 1-3 short actions in {language}.
+- Write in second person ("You wake up in...").
""",
}
diff --git a/app/prompts/stages/orchestrator_phase2.py b/app/prompts/stages/orchestrator_phase2.py
index ded1581..70876fe 100644
--- a/app/prompts/stages/orchestrator_phase2.py
+++ b/app/prompts/stages/orchestrator_phase2.py
@@ -24,12 +24,13 @@ Current time: {current_time}
{environment_json}
# Hard rules
-- Call `submit_step` exactly once with {scene_text, delta_time}.
-- scene_text length: 200-2000 characters.
+- You MUST call the `submit_step` tool with arguments named `scene_text` and `delta_time`.
+- The `scene_text` argument is a string (200-2000 characters) containing the narrative.
+- The `delta_time` argument is a string like "hours_2_min_30" indicating how much game time passes.
- Write in second person ("You enter the tavern...").
- Show, don't tell — describe sensory details.
- Do NOT reference tools, schemas, or game mechanics in the narrative.
- The narrative must be in {language}.
-- delta_time format: `[year_Y][days_D][hours_H][min_M]` (e.g. `hours_2_min_30`).
+- delta_time format examples: `hours_2`, `min_30`, `days_1_hours_4`.
""",
}
diff --git a/frontend/src/components/admin/SettingsPanel.tsx b/frontend/src/components/admin/SettingsPanel.tsx
index f051e2e..9f0d911 100644
--- a/frontend/src/components/admin/SettingsPanel.tsx
+++ b/frontend/src/components/admin/SettingsPanel.tsx
@@ -213,6 +213,21 @@ export function SettingsPanel() {
return (
+ {/*
+ Hidden decoy input to absorb the browser password manager's attention.
+ Browsers that ignore `autocomplete="off"` on visible fields will
+ still key off the first password-typed input in a form, so we plant
+ a hidden one here to prevent the api_key fields below from being
+ offered as saveable passwords.
+ */}
+