Stop retried and deleted actions from corrupting story context and memories

Three fallout bugs from keeping the retried action row alive (906ba42),
plus two long-standing cursor bugs the same investigation turned up.

Retry context leak: the row being regenerated is still attached to the
adventure, so it was replayed as established story and the model wrote a
continuation of the attempt it was meant to replace — the story visibly
blended both takes. It leaked into four places, not one: history replay,
story-card trigger matching, in-scene NPC detection, and the memory-bank
similarity query. Adds a shared context.story_actions(exclude_action_id),
threaded through build_context and retrieve_memories.

Memory holdback: a memory could summarize the just-generated turn; retry
rewrites Action.text but memory_cursor has already advanced, so the memory
was never regenerated and went on describing narration no longer in the
story. settled_story_actions() holds the newest action back one turn —
only the last action is retryable, so that makes it unreachable. The
settled list is always a prefix, so cursors stay valid and nothing is
skipped. The run_post_turn clamp deliberately still uses the full count:
clamping to settled rewinds legacy adventures a step and double-covers an
action.

Cursor bookkeeping: memory_cursor is a position into story_actions() while
Memory.source_* are Action.index values, and the two diverge as soon as
anything is deleted. Deleting a middle action slid a never-summarized
action into the covered range, skipping it forever; and pruning a memory
left the actions it covered stranded behind the cursor. Adds
note_action_removed() (called before the delete in delete_action and
undo_turn) and a rewind in prune_dangling_memories. delete_action also
now prunes at all, which it never did.

Not addressed: editing an already-summarized action still leaves its
memory stale, and the cumulative story summary can't have one fact
un-mixed from it.

117 backend tests pass, including new test_memory_settling.py (12) and
two retry-context regression tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UeQVy5bEjLhfgWNc27Efet
This commit is contained in:
parththakkar106
2026-08-03 15:23:59 +05:30
co-authored by Claude Opus 5
parent 906ba423d8
commit 7c538c8235
6 changed files with 434 additions and 20 deletions
+2 -2
View File
@@ -1,3 +1,3 @@
from .builder import build_context, count_tokens, truncate_to_last_tokens
from .builder import build_context, count_tokens, story_actions, truncate_to_last_tokens
__all__ = ["build_context", "count_tokens", "truncate_to_last_tokens"]
__all__ = ["build_context", "count_tokens", "story_actions", "truncate_to_last_tokens"]
+21 -5
View File
@@ -74,10 +74,24 @@ def _history_text(action: models.Action) -> str:
return text
def _visible_npcs(adventure: models.Adventure, stat_schema: dict) -> dict[str, str]:
def story_actions(
adventure: models.Adventure, exclude_action_id: int | None = None
) -> list[models.Action]:
"""The adventure's non-empty actions, as the model should see them.
`exclude_action_id` drops one action from the story — used by retry, where
the row being regenerated is still attached to the adventure (it holds the
variant history) but must not appear in the context assembled to replace it.
"""
return [
a for a in adventure.actions
if a.text.strip() and (exclude_action_id is None or a.id != exclude_action_id)
]
def _visible_npcs(actions: list[models.Action], stat_schema: dict) -> dict[str, str]:
"""Defined NPCs whose trigger words appear in the recent story — the ones
"in scene", so only their stats get injected. Maps npc id -> display name."""
actions = [a for a in adventure.actions if a.text.strip()]
recent = SEPARATOR.join(a.text for a in actions[-6:]).lower()
visible: dict[str, str] = {}
for npc_key, ndef in (stat_schema.get("npcs") or {}).items():
@@ -107,10 +121,13 @@ def build_context(
adventure: models.Adventure,
settings: models.Settings,
memory_bank: dict | None = None,
exclude_action_id: int | None = None,
) -> tuple[str, str, dict]:
"""Returns (system_text, story_text, context_report). `memory_bank` is the
result of memorybank.retrieve_memories (None when the bank is off)."""
result of memorybank.retrieve_memories (None when the bank is off);
`exclude_action_id` omits one action from the story (see story_actions)."""
script_mem = _script_memory(adventure)
actions = story_actions(adventure, exclude_action_id)
# ----- Always-included components -----
system_sections: list[Section] = [Section("narrator", settings.narrator_prompt.strip())]
@@ -123,7 +140,7 @@ def build_context(
if guide:
system_sections.append(Section("world_state_guide", guide))
block = worldstate.render_state_section(
adventure.world_state, stat_schema, _visible_npcs(adventure, stat_schema)
adventure.world_state, stat_schema, _visible_npcs(actions, stat_schema)
)
if block:
system_sections.append(Section("world_state", block))
@@ -163,7 +180,6 @@ def build_context(
available = max(256, settings.context_token_budget - reserved)
# ----- Story cards: triggered by recent story text (the window history could fill) -----
actions = [a for a in adventure.actions if a.text.strip()]
trigger_window = truncate_to_last_tokens(SEPARATOR.join(a.text for a in actions), available)
triggered = _match_cards(adventure.story_cards, trigger_window)
+76 -10
View File
@@ -4,7 +4,9 @@
After each turn, a fire-and-forget task (`run_post_turn`) runs with its own DB
session:
- every MEMORY_INTERVAL actions (starting at MEMORY_START), each uncovered
block of actions is summarized into a short "memory";
block of actions is summarized into a short "memory". Summarization only
ever reads *settled* actions (see settled_story_actions) — the newest action
is held back one turn because it is still retryable;
- every SUMMARY_INTERVAL actions, the Story Summary is rewritten folding in
the new memories (the user-edited text is always the base, never clobbered);
- new memories are embedded (OpenAI-compatible /v1/embeddings) and the bank
@@ -23,7 +25,7 @@ import math
from sqlalchemy.orm import Session
from . import models
from .context import truncate_to_last_tokens
from .context import story_actions, truncate_to_last_tokens
from .database import SessionLocal
from .providers import OpenAICompatibleProvider, ProviderError
@@ -86,22 +88,78 @@ def cosine(a: list[float], b: list[float]) -> float:
return dot / norm if norm else 0.0
def story_actions(adventure: models.Adventure) -> list[models.Action]:
return [a for a in adventure.actions if a.text.strip()]
def settled_story_actions(adventure: models.Adventure) -> list[models.Action]:
"""Story actions old enough to summarize: everything but the newest one.
Only the *last* action can be retried, so once an action has another action
after it, its text is final. Summarizing right up to the newest action meant
a memory could describe an attempt the player then retried away — the
memory's cursor has already advanced, so it is never regenerated, leaving a
memory (and, downstream, a story summary) describing narration that is no
longer in the story. Holding one action back costs a turn of latency and
makes that unreachable.
The result is always a prefix of story_actions(), so memory_cursor and
summary_cursor stay valid positions and no action is ever skipped.
"""
return story_actions(adventure)[:-1]
def _rewind_cursors_to_index(adventure: models.Adventure, index: int) -> None:
"""Move both cursors back to the position of Action.index `index`.
The cursors are *positions* into story_actions() while Memory.source_* are
Action.index values, so the two spaces have to be translated between (they
diverge as soon as any action is deleted).
"""
actions = story_actions(adventure)
position = next((i for i, a in enumerate(actions) if a.index >= index), len(actions))
adventure.memory_cursor = min(adventure.memory_cursor, position)
adventure.summary_cursor = min(adventure.summary_cursor, position)
def note_action_removed(adventure: models.Adventure, action: models.Action) -> None:
"""Keep the cursors pointing at the same actions when one is deleted from
*before* them. Call BEFORE the delete, while the action is still in the list.
memory_cursor counts actions from the start of the story, so removing an
earlier action slides every later one down a slot — without this, an action
that was never summarized shifts into the "already covered" range and is
skipped forever.
"""
actions = story_actions(adventure)
position = next((i for i, a in enumerate(actions) if a.id == action.id), None)
if position is None:
return
if position < adventure.memory_cursor:
adventure.memory_cursor -= 1
if position < adventure.summary_cursor:
adventure.summary_cursor -= 1
def prune_dangling_memories(adventure: models.Adventure, db: Session) -> int:
"""Delete memories that summarized actions which no longer exist (e.g. after
undo). source_start/source_end are Action.index values; a memory is dangling
if any covered action is past the current end of the story. Returns the count
removed. Cursors are self-healing in run_post_turn, so this is cleanup only."""
removed.
Throwing a memory away is not enough on its own: the actions it covered are
still behind memory_cursor, so they would read as summarized with nothing
describing them. Rewind to where the earliest discarded memory began, so
those actions are summarized again.
"""
max_index = max((a.index for a in adventure.actions), default=-1)
dangling = [
m for m in adventure.memories
if m.source_end is not None and m.source_end > max_index
]
if not dangling:
return 0
starts = [m.source_start for m in dangling if m.source_start is not None]
for m in dangling:
db.delete(m)
if starts:
_rewind_cursors_to_index(adventure, min(starts))
return len(dangling)
@@ -112,11 +170,13 @@ async def retrieve_memories(
settings: models.Settings,
*,
update_stats: bool,
exclude_action_id: int | None = None,
) -> dict | None:
"""Returns {"used": [{id, text, similarity, pinned}], "error": str|None},
or None when the memory bank is off for this adventure. `update_stats`
bumps use counters (real turns only, not Insights dry runs); the caller's
commit persists them."""
bumps use counters (real turns only, not Insights dry runs).
`exclude_action_id` drops the action being retried from the similarity
query, so the discarded attempt can't steer which memories come back."""
if not adventure.memory_bank_enabled:
return None
if not settings.embedding_model.strip():
@@ -126,7 +186,7 @@ async def retrieve_memories(
if not candidates:
return {"used": [], "error": None}
actions = story_actions(adventure)
actions = story_actions(adventure, exclude_action_id)
query = truncate_to_last_tokens(
"\n\n".join(a.text for a in actions[-4:]), RETRIEVAL_WINDOW_TOKENS
)
@@ -198,6 +258,12 @@ async def run_post_turn(adventure_id: int) -> None:
return
# Undo/retry can shrink the action list below a stored cursor, which
# would stall summarization until the story grew past it again.
# Deliberately the FULL count, not the settled one: an adventure that
# was caught up under the old rule can have a cursor equal to the action
# count, and clamping to settled would rewind it one step, re-covering
# an already-summarized action in the next block. Both consumers below
# read settled actions and bail on a negative remainder, so a cursor
# briefly sitting one past the settled end is harmless.
count = len(story_actions(adventure))
adventure.memory_cursor = min(adventure.memory_cursor, count)
adventure.summary_cursor = min(adventure.summary_cursor, count)
@@ -215,7 +281,7 @@ async def run_post_turn(adventure_id: int) -> None:
async def _create_due_memories(
adventure: models.Adventure, settings: models.Settings, db: Session
) -> None:
actions = story_actions(adventure)
actions = settled_story_actions(adventure)
provider = summary_provider(settings)
for _ in range(MAX_MEMORIES_PER_RUN):
cursor = adventure.memory_cursor
@@ -246,7 +312,7 @@ async def _create_due_memories(
async def _update_story_summary(
adventure: models.Adventure, settings: models.Settings, db: Session
) -> None:
actions = story_actions(adventure)
actions = settled_story_actions(adventure)
if len(actions) - adventure.summary_cursor < SUMMARY_INTERVAL:
return
+20 -3
View File
@@ -506,6 +506,11 @@ async def _generate_turn(
):
settings = get_settings(db, user)
cfg = auth.resolve_provider_config(settings)
# On a retry the row being regenerated is still attached to the adventure
# (it carries the variant history), so it has to be filtered out of the
# context — otherwise the model is shown the attempt it is replacing as if
# it were established story, and writes a continuation of it.
replacing_id = retry_of.id if retry_of is not None else None
if cfg.using_demo:
# No embedding/summarization calls on the server-funded key: memory
# retrieval is skipped (with a visible note when the bank is on).
@@ -515,12 +520,16 @@ async def _generate_turn(
else None
)
else:
memories = await memorybank.retrieve_memories(adventure, settings, update_stats=True)
memories = await memorybank.retrieve_memories(
adventure, settings, update_stats=True, exclude_action_id=replacing_id
)
# Scoreboard as it stands before this AI turn's context/output hooks mutate
# it — stapled onto the AI action so retry can start over from here.
state_before = snapshot_state(adventure)
world_state_before = snapshot_world_state(adventure)
system_text, story_text, snapshot = build_context(adventure, settings, memories)
system_text, story_text, snapshot = build_context(
adventure, settings, memories, exclude_action_id=replacing_id
)
# onModelContext: scripts see (and may rewrite) the whole assembled context.
combined = f"{system_text}\n\n{story_text}" if system_text else story_text
@@ -863,9 +872,11 @@ def undo_turn(
last = actions.pop()
# The earliest action removed in this turn holds the pre-turn scoreboard.
first_removed = last
memorybank.note_action_removed(adventure, last)
db.delete(last)
if last.type == "ai" and actions and actions[-1].type in ("do", "say", "story"):
first_removed = actions.pop()
memorybank.note_action_removed(adventure, first_removed)
db.delete(first_removed)
if first_removed.state_before is not None:
adventure.script_state = copy.deepcopy(first_removed.state_before)
@@ -1480,9 +1491,15 @@ def delete_action(
db: Session = Depends(get_db),
user: models.User = CurrentUser,
):
get_adventure_or_404(adventure_id, db, user)
adventure = get_adventure_or_404(adventure_id, db, user)
action = db.get(models.Action, action_id)
if action is None or action.adventure_id != adventure_id:
raise HTTPException(404, "Action not found")
# Cursor bookkeeping, same as undo: slide the cursors down if this action
# sits before them, then drop any memory left describing a deleted action.
memorybank.note_action_removed(adventure, action)
db.delete(action)
db.flush() # apply the delete so pruning sees the shrunken action list
db.expire(adventure, ["actions"])
memorybank.prune_dangling_memories(adventure, db)
db.commit()