FIX: Fixing json retry#2184
Merged
Merged
Conversation
JSON retries replayed poisoned conversation history because PromptNormalizer persists the request+response to memory before the caller validates the JSON. Add a memory-aware send_json_with_retry_async helper that rolls memory back to a baseline sequence between attempts, deleting the failed turn and recording a ConversationRetry marker, then rewire the adversarial manager and shared LLM scorer through it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 244845c9-0655-4fa8-9717-29d75faef9f2
The raise_on_invalid_json=False path forwarded unparseable adversarial model output as the next attack prompt instead of failing loudly -- a debuggability hazard -- and no shipped attack used it (default True; only a test exercised it). Removing the flag collapses the adversarial send to a single always-raise path and lets send_json_with_retry_async drop its fallback parameter. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 244845c9-0655-4fa8-9717-29d75faef9f2
Add a concise note to the framework doc that the prompt_normalizer is the single component that persists requests/responses to memory, and targets never write to memory themselves. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 244845c9-0655-4fa8-9717-29d75faef9f2
hannahwestra25
approved these changes
Jul 15, 2026
Address PR review: add dedicated tests covering the happy path, retry-then-success (asserting the poisoned turn is rolled back and a retry marker is recorded), retry exhaustion, no-response ValueError, and non-JSON exception passthrough. Also trim the helper docstring so it describes the retry contract rather than narrating the specific bug it fixed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 244845c9-0655-4fa8-9717-29d75faef9f2
# Conflicts: # pyrit/memory/memory_interface.py # pyrit/memory/memory_models.py
adrian-gavrila
left a comment
Contributor
There was a problem hiding this comment.
Looks good! One docstring thing that might be worth attention.
# Conflicts: # pyrit/score/llm_scoring.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
The bug
JSON retries were not actually getting a fresh, independent response — which means JSON retry wasn't actually really working in many cases. In the adversarial manager, it was retrying via the normalizer, so the history was added, but then retried already containing the responses (which were not removed). Each "retry" was therefore conditioned on poisoned history rather than being a clean, independent attempt, making success non-deterministic (hence the flake).
The fix
A shared, memory-aware helper
send_json_with_retry_async(inpyrit/prompt_normalizer/) that makes every attempt independent, and rewiring the two JSON-expecting call sites through it:_send_and_parse_async)_run_llm_scoring_async) — the normalizer here is injectable (emptyNone→PromptNormalizer()default) so scoring persists through the same path.Why we delete a message
The helper records a baseline sequence for the conversation (the max
MessagePiece.sequencepresent before we send). On the first attempt it deletes nothing. On each retry it rolls memory back to that baseline by deleting the failed turn's pieces (everything after the baseline sequence) before resending. This is what actually gives us a new response: the model sees a clean history identical to the first attempt, instead of its own malformed JSON plus a duplicated prompt.We chose to delete rather than branch onto a new
conversation_idso that the conversation identity stays stable for the caller and downstream consumers — only the poisoned turn is removed, everything before the baseline is untouched.Memory updates
To support the rollback cleanly and keep a record that a retry happened, memory grew two primitives plus a small amount of conversation-scoped state:
delete_conversation_pieces_after_sequence(conversation_id, sequence)— deletes the failed turn's pieces (returns whether anything was deleted).add_conversation_retry(conversation_id, sequence, reason)— records a lightweightConversationRetrymarker (reason=JSON_PARSING) so the retry is auditable even though the failed message pieces are gone. Rather than keeping the poisoned turns around, we keep just this metadata saying "this turn was retried."ConversationRetry/ConversationRetryReasonmodels, and aretrieslist on theConversationmodel (conversation-scoped metadata, not stamped onto everyMessagePiece).a1c3e5d7f9b0adds aretriesJSON column to the conversations table.The retry loop keeps the existing
@pyrit_json_retry(tenacity) decorator, so retry logging andRetryCollectorattribution (after=log_exception) are preserved.Tests and Documentation
test_adversarial_conversation_manager.py,test_crescendo.py,test_red_teaming.py, andtest_scorer.pyto cover routing through the helper and the memory rollback/retry-marker behavior.