Skip to content

FIX: Fixing json retry#2184

Merged
rlundeen2 merged 6 commits into
microsoft:mainfrom
rlundeen2:rlundeen2-fix-json-retry
Jul 15, 2026
Merged

FIX: Fixing json retry#2184
rlundeen2 merged 6 commits into
microsoft:mainfrom
rlundeen2:rlundeen2-fix-json-retry

Conversation

@rlundeen2

@rlundeen2 rlundeen2 commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

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 (in pyrit/prompt_normalizer/) that makes every attempt independent, and rewiring the two JSON-expecting call sites through it:

  • Adversarial conversation manager (_send_and_parse_async)
  • Shared LLM scorer (_run_llm_scoring_async) — the normalizer here is injectable (empty NonePromptNormalizer() 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.sequence present 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_id so 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 lightweight ConversationRetry marker (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."
  • New ConversationRetry / ConversationRetryReason models, and a retries list on the Conversation model (conversation-scoped metadata, not stamped onto every MessagePiece).
  • New Alembic migration a1c3e5d7f9b0 adds a retries JSON column to the conversations table.

The retry loop keeps the existing @pyrit_json_retry (tenacity) decorator, so retry logging and RetryCollector attribution (after=log_exception) are preserved.

Tests and Documentation

  • Updated test_adversarial_conversation_manager.py, test_crescendo.py, test_red_teaming.py, and test_scorer.py to cover routing through the helper and the memory rollback/retry-marker behavior.
  • All affected unit suites pass locally (memory, models, prompt_normalizer, score, executor/attack).
  • Pre-commit passes (ruff format/check, ty type-check, async-suffix, alembic immutability, memory-migrations check run directly against the venv). No new JupyText/doc code samples were added.

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 hannahwestra25 self-assigned this Jul 15, 2026
Comment thread pyrit/prompt_normalizer/json_retry.py
Comment thread pyrit/prompt_normalizer/json_retry.py Outdated
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
@adrian-gavrila adrian-gavrila self-assigned this Jul 15, 2026
# Conflicts:
#	pyrit/memory/memory_interface.py
#	pyrit/memory/memory_models.py
@rlundeen2 rlundeen2 enabled auto-merge July 15, 2026 21:05
@rlundeen2 rlundeen2 added this pull request to the merge queue Jul 15, 2026
@github-merge-queue github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Jul 15, 2026

@adrian-gavrila adrian-gavrila left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! One docstring thing that might be worth attention.

Comment thread pyrit/score/llm_scoring.py
@rlundeen2 rlundeen2 enabled auto-merge July 15, 2026 23:20
@rlundeen2 rlundeen2 added this pull request to the merge queue Jul 15, 2026
Merged via the queue into microsoft:main with commit 233b400 Jul 15, 2026
53 checks passed
@rlundeen2 rlundeen2 deleted the rlundeen2-fix-json-retry branch July 15, 2026 23:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants