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
+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)