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
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user