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:
co-authored by
Claude Opus 5
parent
906ba423d8
commit
7c538c8235
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user