Undo deleted. It removed the trailing AI action and the player action in front of it, pruned the memories covering them, and let the tip fall back to whatever survived. That made it the one operation in the application that destroyed accepted story, and it was why there was no Redo: the turns to move forward into no longer existed. Phase 0B demonstrated the head-cursor alternative in a disposable spike; this is that concept as production code. backend/app/head.py is the whole of it. Three questions that used to be one — where the story is being read, how far it is retained, and where it opens — are now three functions, and every caller that moves the head or asks about it goes through this module. The spike put the fork check in the write path and left Retry and Add-take on the old one; sharing the rules is what stops that divergence coming back. lineage.Path now caps every entry at the head, so hiding the retained future costs nothing at the call sites: the transcript, the context builder, attempts.preceding and memory retrieval already funnelled through path_of and narrow together. Path.uncapped() is the deliberate exception, and only Redo and the fork check may use it. The memory bank needs no pruning for the same reason — a memory carries the coordinate of the node its block ends on, so one derived past the head falls outside the capped clause and becomes retrievable again on Redo without having been deleted and re-embedded. Undo alone does not fork. Moving the head is not a decision to abandon anything, since the user may be reading or about to Redo; the first write below the head is where the story states which continuation it means. A head already at the tip forks nothing, so a story that is never undone forks exactly as often as it did before and the branch table does not fill up with one branch per turn. Redo follows the lineage rather than choosing among branches, which is what invalidates it after a divergence with no flag to set or clear. Migrations 78 and 79 give a branch superseded_at and superseded_depth. Nothing reads them to decide behaviour — Redo is decided by the lineage, so a stale or hand-edited value here cannot make the story wrong. They exist so the cleanup and discarded-history features left to a later version have something to select on, and so a divergence is observable in a test. Deleting an action no longer drags a moved-back head forward to the recomputed tip, which would have silently redone the story. can_undo and can_redo ride on AdventureOut and ActionPage because the client can work out neither for itself: the campaign opening may be off the top of the loaded window, and the retained future is never sent to it. This is a checkpoint, not the finished milestone. 601 backend tests pass. Five still assert the destructive contract — they count rows after an undo and expect the story to be shorter — and need rewriting against the new one; the world-state assertions inside them already pass. Export and import do not yet carry the head coordinate, so a bundle still reopens at the deepest node and can silently redo an undone story, which is the Phase 0B finding this milestone exists to close. The browser has no Redo control yet. None of the M3 acceptance coverage (D01-D10, E01-E04, I01-I03, I07, L01-L02) is written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QF5TcoB86QADgjHz1GZe8u
352 lines
14 KiB
Python
352 lines
14 KiB
Python
"""Playing a turn: the model call, the SSE stream, and the one-turn-at-a-time lock.
|
|
|
|
Everything a test needs to intercept lives here, and other modules reach it as
|
|
`turns.<name>` rather than importing it by value. That matters twice. The turn
|
|
lock guards one set only while one module owns it. And a test that replaces
|
|
`OpenAICompatibleProvider` or `generate_turn` patches this module, which every
|
|
caller reads through.
|
|
"""
|
|
import threading
|
|
|
|
from fastapi import Depends, HTTPException, Request
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ... import (
|
|
attempts, head, limits, memorybank, models, schemas, tree, worldstate,
|
|
)
|
|
from ...context import build_context, cursors
|
|
from ...database import get_db
|
|
from ...providers import OpenAICompatibleProvider, PromptParts, ProviderError
|
|
from ...sse import SSE_HEADERS, sse, turn_error
|
|
from ..settings import get_settings
|
|
|
|
from .deps import CurrentUser, current_adventure, router
|
|
from .nodes import _move_to_after, next_depth
|
|
from .paging import annotate_takes
|
|
|
|
|
|
def world_delta_of(snapshot: dict | None) -> dict | None:
|
|
"""Returns the bulk-read slice of a context snapshot, for `Action.world_delta`.
|
|
|
|
`context_snapshot` is deferred because it holds the whole assembled prompt.
|
|
The parts that every action needs get their own small column instead: the
|
|
world-change chips, the emit block replayed into history, and the refusal
|
|
note fed back to the model. Update this function wherever a snapshot is
|
|
written.
|
|
|
|
Carry all three report lists, not just `applied`. `Action.world_changes`
|
|
marks a chip from `clamped` and builds its refusal chips from `rejected`,
|
|
and `worldstate.refusals` reads both. Storing `applied` alone left every
|
|
consumer unable to tell a refused change from one that worked, which is the
|
|
distinction this column exists to carry. The two extra lists are subsets of
|
|
one turn's block, so they cost a few hundred bytes per action at most.
|
|
"""
|
|
ws = (snapshot or {}).get("world_state")
|
|
if not isinstance(ws, dict):
|
|
return None
|
|
report = ws.get("report") or {}
|
|
return {
|
|
"delta": ws.get("delta") or {},
|
|
"applied": report.get("applied") or [],
|
|
"clamped": report.get("clamped") or [],
|
|
"rejected": report.get("rejected") or [],
|
|
}
|
|
|
|
|
|
# One turn at a time per adventure. The set lives in memory, which is enough for
|
|
# a single-process local app. Sync endpoints run in a threadpool, so the
|
|
# check-and-add needs a lock. The check also has to run during the request
|
|
# rather than when the SSE generator first runs. Otherwise two rapid requests
|
|
# both pass the check and generate concurrently.
|
|
_active_turns: set[int] = set()
|
|
_active_turns_guard = threading.Lock()
|
|
|
|
|
|
def acquire_turn_lock(adventure_id: int):
|
|
"""Claims the adventure's turn slot atomically. `with_turn_lock` releases it."""
|
|
with _active_turns_guard:
|
|
if adventure_id in _active_turns:
|
|
raise HTTPException(409, "A turn is already generating for this adventure.")
|
|
_active_turns.add(adventure_id)
|
|
|
|
|
|
async def with_turn_lock(adventure_id: int, gen):
|
|
"""Wraps an SSE generator so that it releases the `acquire_turn_lock` lock."""
|
|
try:
|
|
async for event in gen:
|
|
yield event
|
|
finally:
|
|
_active_turns.discard(adventure_id)
|
|
|
|
|
|
def format_player_input(action_type: str, text: str) -> str:
|
|
"""Formats player input the way AI Dungeon does."""
|
|
text = text.strip()
|
|
if action_type == "say":
|
|
text = text.strip('"')
|
|
if text and text[-1] not in ".!?…":
|
|
text += "."
|
|
return f'> You say "{text}"'
|
|
if action_type == "do":
|
|
if text.lower().startswith("you "):
|
|
text = text[4:]
|
|
if text and text[-1] not in ".!?…":
|
|
text += "."
|
|
return f"> You {text}"
|
|
return text # The "story" type is appended as raw text.
|
|
|
|
|
|
def action_json(action: models.Action, db: Session | None = None) -> dict:
|
|
"""Serializes one action for the wire.
|
|
|
|
Passing `db` fills in the pager numbers, and a turn that was just played must
|
|
pass it. The attempt that turn created is often the second one at its
|
|
coordinate, so the message needs a pager that the client cannot infer from a
|
|
count of one. Without `db`, a retry showed no pager until the page reloaded.
|
|
The adventure GET has the same requirement and builds its window a third way.
|
|
"""
|
|
if db is not None:
|
|
annotate_takes(db, action.adventure_id, [action])
|
|
return schemas.ActionOut.model_validate(action).model_dump(mode="json")
|
|
|
|
|
|
async def generate_turn(
|
|
adventure: models.Adventure,
|
|
db: Session,
|
|
user: models.User,
|
|
retry_of: models.Action | None = None,
|
|
):
|
|
"""Streams the AI continuation as SSE, then stores the result.
|
|
|
|
If `retry_of` is set, the result is stored as a sibling of that AI action, at
|
|
the same turn and the same coordinate, and the discarded attempt stays where
|
|
it was written. Before calling, the caller must roll the adventure back to
|
|
the state before that turn. See `retry_action`. If this generator ends
|
|
without saving, the rollback is undone, so the state cannot diverge from the
|
|
text on screen.
|
|
"""
|
|
saved = False
|
|
try:
|
|
async for event in _generate_turn(adventure, db, user, retry_of):
|
|
if event is _SAVED:
|
|
saved = True
|
|
continue
|
|
yield event
|
|
finally:
|
|
if retry_of is not None and not saved:
|
|
# The turn failed with a provider error, an empty reply, or a
|
|
# disconnected client. No sibling was written, so the
|
|
# attempt on screen is still the live one. Restore the state it
|
|
# produced.
|
|
attempts.restore_state(adventure, retry_of)
|
|
db.commit()
|
|
|
|
|
|
# `_generate_turn` yields this sentinel once the action is committed. It tells
|
|
# the wrapper above to leave the rollback in place rather than reverse it.
|
|
_SAVED = object()
|
|
|
|
|
|
async def _generate_turn(
|
|
adventure: models.Adventure,
|
|
db: Session,
|
|
user: models.User,
|
|
retry_of: models.Action | None = None,
|
|
):
|
|
settings = get_settings(db, user)
|
|
# On a retry, the attempt being replaced is still the live node of its turn,
|
|
# because it stays live until a replacement exists. Filter it out of the
|
|
# context. Otherwise the model reads the attempt it is replacing as
|
|
# established story and writes a sequel to it.
|
|
replacing_id = retry_of.id if retry_of is not None else None
|
|
memories = await memorybank.retrieve_memories(
|
|
adventure, settings, update_stats=True, exclude_action_id=replacing_id
|
|
)
|
|
system_text, story_text, snapshot = build_context(
|
|
adventure, settings, memories, exclude_action_id=replacing_id
|
|
)
|
|
|
|
parts = PromptParts(system=system_text, story=story_text)
|
|
|
|
provider = OpenAICompatibleProvider(
|
|
settings.endpoint_url, settings.model, settings.api_mode,
|
|
settings.model_timeout_seconds,
|
|
)
|
|
chunks: list[str] = []
|
|
reasoning_chunks: list[str] = []
|
|
try:
|
|
async for kind, chunk in provider.generate(
|
|
parts, temperature=settings.temperature, max_tokens=settings.max_output_tokens
|
|
):
|
|
if kind == "reasoning":
|
|
reasoning_chunks.append(chunk)
|
|
yield sse({"type": "reasoning", "text": chunk})
|
|
else:
|
|
chunks.append(chunk)
|
|
yield sse({"type": "chunk", "text": chunk})
|
|
except ProviderError as exc:
|
|
yield turn_error(str(exc))
|
|
return
|
|
|
|
text = "".join(chunks).strip()
|
|
# The model's literal reply, kept for the Insights "Raw AI output" view. It
|
|
# still contains the world-state block, which the code below strips.
|
|
raw_output = text
|
|
if not text:
|
|
# The model streamed reasoning but no story text, so it spent its whole
|
|
# budget on reasoning. Report that rather than "empty response".
|
|
if reasoning_chunks:
|
|
detail = (
|
|
"The model used its entire token budget on reasoning and returned no "
|
|
'story text. Raise "Max output tokens" in Settings, set a "Reasoning '
|
|
'max tokens" cap, or switch to a non-reasoning model.'
|
|
)
|
|
else:
|
|
detail = "The AI returned an empty response."
|
|
yield turn_error(detail)
|
|
return
|
|
|
|
# RPG world state (Phase 12): read the AI's state delta out of the reply,
|
|
# apply it through the engine, and strip the block from the displayed text.
|
|
#
|
|
# A retry re-runs the same turn, so it is played at that turn's depth. The
|
|
# cooldown rules run on a position in the story, and a second attempt at turn
|
|
# 12 is still turn 12. This was `retry_of.index`, which held the same number
|
|
# until SP4. Depth stays correct once a branch has its own numbering.
|
|
ai_depth = retry_of.depth if retry_of is not None else next_depth(adventure)
|
|
stat_schema = adventure.scenario.stat_schema if adventure.scenario else None
|
|
if worldstate.has_schema(stat_schema):
|
|
text, delta = worldstate.extract_delta(text)
|
|
if not text.strip():
|
|
yield turn_error("The AI returned only a state update and no story text.")
|
|
return
|
|
new_world_state, ws_report = worldstate.apply_delta(
|
|
adventure.world_state, stat_schema, delta, ai_depth
|
|
)
|
|
adventure.world_state = new_world_state
|
|
snapshot["world_state"] = {"delta": delta, "report": ws_report, "state": new_world_state}
|
|
|
|
snapshot["raw_output"] = raw_output
|
|
# The cost the endpoint reports for the call, including how much of the
|
|
# prompt came from cache rather than being billed in full. This is recorded
|
|
# per attempt, next to the prompt it priced.
|
|
snapshot["usage"] = provider.last_usage
|
|
|
|
reasoning = "".join(reasoning_chunks).strip() or None
|
|
ai_action = models.Action(
|
|
adventure_id=adventure.id,
|
|
depth=ai_depth,
|
|
type="ai",
|
|
text=text,
|
|
reasoning=reasoning,
|
|
context_snapshot=snapshot,
|
|
world_delta=world_delta_of(snapshot),
|
|
)
|
|
attempts.snapshot_outcome(adventure, ai_action)
|
|
if retry_of is not None:
|
|
attempts.add_attempt(db, adventure, retry_of, ai_action)
|
|
db.add(ai_action)
|
|
# The text at this coordinate changed, so anything derived from it no
|
|
# longer describes the story. Withdraw the memory attached to the node
|
|
# and return that stretch to both passes. Before SP4 this code was
|
|
# unreachable, because the summarizer held the newest action back until
|
|
# a turn landed on top of it. `memorybank.SETTLE_SLACK` keeps a memory
|
|
# off the tip again, for cost rather than for correctness, so this is
|
|
# now the rare case: undo or delete can carry a summarized node back to
|
|
# the tip, and then a retry of it lands here. See `memorybank`.
|
|
memorybank.forget_node(db, adventure, retry_of)
|
|
cursors.rewind_all(adventure, retry_of.branch_id, ai_depth - 1)
|
|
# Flush so the new attempt has an id. The session does not autoflush,
|
|
# and attempts page in id order, so a read taken before this point puts
|
|
# the newest attempt nowhere.
|
|
db.flush()
|
|
else:
|
|
tree.place_action(db, adventure, ai_action)
|
|
db.add(ai_action)
|
|
adventure.updated_at = models.utcnow()
|
|
db.commit()
|
|
db.refresh(ai_action)
|
|
yield _SAVED
|
|
yield sse({"type": "done", "action": action_json(ai_action, db)})
|
|
# Phase 6: schedule summarization and embedding without waiting for them.
|
|
# The task opens its own database session.
|
|
memorybank.schedule_post_turn(adventure)
|
|
|
|
|
|
|
|
async def run_player_turn(
|
|
adventure: models.Adventure,
|
|
db: Session,
|
|
payload: schemas.ActionCreate,
|
|
user: models.User,
|
|
preformatted: bool = False,
|
|
):
|
|
"""Plays a player's turn: their action, then the reply to it.
|
|
|
|
`preformatted` means the text already carries the `> You ...` conventions and
|
|
is written as-is. That applies when the player retakes a turn they already
|
|
played (SP9). The editor is seeded with the stored text, which is already
|
|
formatted, and a plain edit puts that same text in the box and writes it back
|
|
verbatim. Formatting it a second time produces `> You > You ...`.
|
|
"""
|
|
# An empty do, say, or story action behaves as a continue.
|
|
if payload.type != "continue" and payload.text.strip():
|
|
formatted = (
|
|
payload.text.strip() if preformatted
|
|
else format_player_input(payload.type, payload.text)
|
|
)
|
|
player_action = models.Action(
|
|
adventure_id=adventure.id,
|
|
depth=next_depth(adventure),
|
|
type=payload.type,
|
|
text=formatted,
|
|
)
|
|
# The state this node leaves behind. The AI turn after it starts here,
|
|
# and a retry of that turn rolls back to here.
|
|
attempts.snapshot_outcome(adventure, player_action)
|
|
tree.place_action(db, adventure, player_action)
|
|
db.add(player_action)
|
|
db.commit()
|
|
db.refresh(player_action)
|
|
# The new action was added through its foreign key, so the loaded
|
|
# `adventure.actions` collection is stale. Without this expire,
|
|
# `build_context` for the AI action does not see the player action
|
|
# that was just saved.
|
|
db.expire(adventure, ["actions"])
|
|
yield sse({"type": "player", "action": action_json(player_action, db)})
|
|
|
|
async for event in generate_turn(adventure, db, user):
|
|
yield event
|
|
|
|
|
|
@router.post("/{adventure_id}/actions")
|
|
def create_action(
|
|
adventure_id: int,
|
|
payload: schemas.ActionCreate,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
user: models.User = CurrentUser,
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
limits.check_row_cap("actions", db, user, adventure=adventure)
|
|
acquire_turn_lock(adventure_id)
|
|
try:
|
|
_move_to_after(db, adventure, payload.after_id)
|
|
# The first write below a moved-back head is where a divergence happens
|
|
# (M3). Undo alone does not fork — the user may be reading, or about to
|
|
# Redo — so this is the moment the story states which continuation it
|
|
# means. The displaced future keeps its rows on the branch being left.
|
|
# A head already at the tip, which is every ordinary turn, forks nothing.
|
|
if head.fork_if_behind_head(db, adventure):
|
|
db.commit()
|
|
db.refresh(adventure)
|
|
except BaseException:
|
|
_active_turns.discard(adventure_id)
|
|
raise
|
|
return StreamingResponse(
|
|
with_turn_lock(adventure_id, run_player_turn(adventure, db, payload, user)),
|
|
media_type="text/event-stream",
|
|
headers=SSE_HEADERS,
|
|
)
|