rebase
This commit is contained in:
116
app/api/admin.py
116
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 <tool_call>name{args}</tool_call> or name(args)
|
||||
import re as _re
|
||||
|
||||
_TOOL_CALL_PATTERNS = [
|
||||
# call:name{json_args}
|
||||
_re.compile(r"call:(\w+)\s*\{([^}]*)\}"),
|
||||
# <tool_call>name{args}</tool_call>
|
||||
_re.compile(r"<tool_call>\s*(\w+)\s*\{([^}]*)\}\s*</tool_call>"),
|
||||
# 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"}
|
||||
- <tool_call>calc{"expression": "2+2"}</tool_call>
|
||||
- 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)),
|
||||
|
||||
@@ -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
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -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*[\{\(]([^}\)]*)[\}\)]"),
|
||||
# <tool_call>name{args}</tool_call> or <tool_call>\n{"name": ..., "arguments": ...}\n</tool_call>
|
||||
_re.compile(r"<tool_call>\s*(\w+)\s*\{([^}]*)\}\s*</tool_call>"),
|
||||
# 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"}
|
||||
- <tool_call>calc{"expression": "2+2"}</tool_call>
|
||||
- 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
|
||||
# "<tool_call>name{args}</tool_call>"), 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(
|
||||
|
||||
@@ -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.<field_path>` → 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)
|
||||
|
||||
@@ -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...").
|
||||
""",
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
""",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user