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
@@ -39,6 +39,7 @@ class ScriptedProvider:
|
||||
"""Streams the next canned reply each call, so successive retries differ."""
|
||||
replies: list = []
|
||||
calls = 0
|
||||
prompts: list = [] # every assembled (system, story) pair, for context assertions
|
||||
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
@@ -46,6 +47,7 @@ class ScriptedProvider:
|
||||
async def generate(self, parts: PromptParts, *, temperature, max_tokens):
|
||||
index = min(ScriptedProvider.calls, len(ScriptedProvider.replies) - 1)
|
||||
ScriptedProvider.calls += 1
|
||||
ScriptedProvider.prompts.append((parts.system, parts.story))
|
||||
reply = ScriptedProvider.replies[index]
|
||||
if isinstance(reply, Exception):
|
||||
raise reply
|
||||
@@ -79,6 +81,7 @@ def client(monkeypatch):
|
||||
|
||||
ScriptedProvider.replies = ["Attempt one."]
|
||||
ScriptedProvider.calls = 0
|
||||
ScriptedProvider.prompts = []
|
||||
monkeypatch.setattr(adventures, "OpenAICompatibleProvider", ScriptedProvider)
|
||||
monkeypatch.setattr(auth, "resolve_provider_config", lambda s: auth.ProviderConfig(
|
||||
"http://fake", "k", "test-model", False))
|
||||
@@ -146,6 +149,35 @@ def test_retry_keeps_the_discarded_attempt(client):
|
||||
assert [v["active"] for v in r.json()] == [False, True]
|
||||
|
||||
|
||||
def test_retry_context_excludes_the_attempt_being_replaced(client):
|
||||
"""The whole point of a retry is a fresh take on the *same* turn. The row
|
||||
now survives the retry (it holds the variant history), so it is still in
|
||||
`adventure.actions` while the replacement context is assembled — it must be
|
||||
filtered out, or the model is asked to continue *past* the attempt it is
|
||||
supposed to be replacing and writes a sequel that blends both."""
|
||||
ScriptedProvider.replies = ["Attempt one.", "Attempt two."]
|
||||
_play(client)
|
||||
_retry(client)
|
||||
|
||||
retry_story = ScriptedProvider.prompts[-1][1]
|
||||
assert "Attempt one." not in retry_story
|
||||
# The turn's own player action must still be there — it's what to respond to.
|
||||
assert "look around" in retry_story
|
||||
assert "You enter a cave." in retry_story
|
||||
|
||||
|
||||
def test_retry_context_keeps_earlier_ai_turns(client):
|
||||
"""Only the action being retried is dropped, not AI history in general."""
|
||||
ScriptedProvider.replies = ["First turn.", "Second turn.", "Second, again."]
|
||||
_play(client, "go north")
|
||||
_play(client, "go south")
|
||||
_retry(client)
|
||||
|
||||
retry_story = ScriptedProvider.prompts[-1][1]
|
||||
assert "First turn." in retry_story
|
||||
assert "Second turn." not in retry_story
|
||||
|
||||
|
||||
def test_never_retried_action_has_no_variants(client):
|
||||
_play(client)
|
||||
last = _actions(client)[-1]
|
||||
|
||||
Reference in New Issue
Block a user