Skip to content

FIX Scope request converters by prepended message role - #2359

Draft
Roman Lutz (romanlutz) wants to merge 15 commits into
microsoft:mainfrom
romanlutz:romanlutz-fix-converter-scoping-692
Draft

FIX Scope request converters by prepended message role#2359
Roman Lutz (romanlutz) wants to merge 15 commits into
microsoft:mainfrom
romanlutz:romanlutz-fix-converter-scoping-692

Conversation

@romanlutz

@romanlutz Roman Lutz (romanlutz) commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Description

Request converters could rewrite simulated assistant history when an attack supplied a prepended conversation. This was especially easy to trigger when the AIRT Jailbreak scenario attached its template converter to conversation-based techniques such as context_compliance.

This change makes prepended conversion role-aware and moves non-editable-history adaptation into the target pipeline:

  • Prepended request conversion defaults to user-role messages. Assistant conversion requires explicit opt-in.
  • All prepended copies are role-mapped, converted, and validated before any are written to memory.
  • Prepended history remains structured logical messages in memory for every target.
  • Attacks explicitly pass an ephemeral, per-conversation TargetNormalizationContext with the first live request when the target cannot edit history.
  • FirstTurnHistoryNormalizer renders prepended history and the converted live request together, then the target runs its ordinary capability normalizers and provider serialization.
  • Stateful targets receive the adapted history exactly once. Later sends carry only the current target-facing request, so retained history is not replayed.
  • When a multi-turn attack rotates a single-turn target to a new conversation, copied system framing receives a fresh normalization context instead of being replayed as a separate turn.
  • Original and converted target views are rendered independently. Tokenizer generation prompts follow the live request, multimodal live pieces keep their order, and converted non-text prepended history is rejected when flattening would be lossy.
  • Jailbreak template conversion remains restricted to compatible, composable delivery paths. Incompatible simulated and multi-turn techniques such as context_compliance are excluded.

The exact first-send order is:

  1. Convert and validate structured prepended messages.
  2. Persist the structured prepended messages.
  3. Convert and hash the live request.
  4. Acquire the target normalization context and load the structured history.
  5. Apply first-turn history normalization.
  6. Run the target's ordinary normalization pipeline.
  7. Validate the provider-facing request, consume the context, and invoke the provider.

The context has explicit PENDING, PREPARING, and CONSUMED states. Pre-provider failures and cancellation restore it for retry, concurrent first sends are rejected, and provider invocation consumes it so attack-level retries cannot replay prepended history accidentally.

Memory remains the logical, converter-level transcript. The target-adapted request is ephemeral, matching the existing contract for system squashing, history squashing, role mapping, and provider serialization.

Tests and Documentation

  • 795 focused Python tests across message normalization, conversation management, prepended configuration, prompt normalization and sending, target lifecycle and concurrency, affected attacks, single-turn target rotation, factory compatibility, and Jailbreak.
  • Changed-file Ruff, formatting, type checks, diff checks, and all applicable repository commit hooks.
  • Updated the framework normalizer ownership and processing-order documentation.
  • JupyText: N/A (no documentation notebooks changed).

Copilot AI added 9 commits August 7, 2026 07:25
Default prepended request conversion to user history and restrict Jailbreak composition to explicitly compatible direct techniques.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Integrate the original request converter scoping fix on current main while preserving its history.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Apply request converters to role-separated prepended history before flattening, while preventing the resulting request from being converted twice.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6776aa95-0924-4967-a056-e5a228af3faa
Explain which factories opt in, what callers append, and why constructor support alone does not guarantee safe converter composition.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6776aa95-0924-4967-a056-e5a228af3faa
Fail clearly when role-scoped converters produce non-text prepended history that string normalization cannot preserve.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6776aa95-0924-4967-a056-e5a228af3faa
Reuse prepared non-chat requests across retries and preserve converter piece indexes when applying role-scoped prepended conversion.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6776aa95-0924-4967-a056-e5a228af3faa
Limit inline jailbreak-template conversion to the scenario-owned prompt_sending delivery. Keep native system-prompt delivery separate and remove the now-unnecessary cross-technique composition capability.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6776aa95-0924-4967-a056-e5a228af3faa
Integrate the latest mainline changes while preserving the converter-scoping branch history and behavior.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6776aa95-0924-4967-a056-e5a228af3faa
Restore the exact origin/main blob after the merge's line-ending check normalized this unrelated file.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6776aa95-0924-4967-a056-e5a228af3faa
Comment thread pyrit/executor/attack/component/conversation_manager.py Outdated
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6776aa95-0924-4967-a056-e5a228af3faa
Comment thread pyrit/models/messages/message.py Outdated
Document why non-chat history is converted before flattening, why original and wire views stay separate, and how retry and piece-index safeguards work.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6776aa95-0924-4967-a056-e5a228af3faa
Roman Lutz (romanlutz) pushed a commit to romanlutz/PyRIT that referenced this pull request Aug 12, 2026
Build the reusable Bijection technique foundation on top of PR microsoft#2359's request-converter scoping and non-chat flattening behavior. Remove the parallel convert_new_user_prompts_only flag and express Bijection-style prepended-context preservation through PrependedConversationConfig(apply_converters_to_roles=[]).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6776aa95-0924-4967-a056-e5a228af3faa

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.

Overall I think we can make this a lot smaller. The actual bug is narrow: request converters can rewrite simulated assistant history when an attack supplies a prepended conversation. It's easy to hit with context_compliance, where the jailbreak's TextJailbreakConverter ends up rewriting the fake assistant turns. So to me this is about which messages we convert, not new machinery — and I don't think we need to touch ConversationManager or put state on Message.

Here's the direction I'd like instead:

  • PrependedConversationConfig stays the sole policy for prepended history. It already picks which roles get converters (apply_converters_to_roles) and which normalizer flattens history. If we default that to ["user"], the assistant-history bug is basically fixed on its own — assistant history is simulated target output, so you have to opt in with ["assistant"] to touch it. If we ever need more control over prepended history, we add it here.
  • StrategyConverterConfig gets a way to scope a converter to the first turn. This decides which messages a converter applies to, exactly like apply_converters_to_roles already does. And because it's declarative it rides in the attack identifier, so resume/eval stay deterministic for free.
  • Optional implementation detail We can derive "first turn" straight from memory if we want — without threading anything new through the attacks. The normalizer already has the conversation_id and a memory handle, so it can just ask: has the target actually replied yet? Define first-turn as "no real assistant message in this conversation" (prepended turns are stored as simulated_assistant, so they don't count). The nice thing is every case falls out correctly, including the tricky ones — e.g. on a prompt_sending retry we spin up a fresh conversation, so the jailbreak is correctly re-applied, whereas a naive per-send counter would drop it. Multi-turn continuations (Crescendo, TAP nodes copied from a parent) already have a real assistant reply, so they're correctly not re-wrapped.
  • Jailbreak scenario: I still like prompt_sending and jailbreak_system_prompt as the defaults. We could add one or two more to show off the scoping (e.g. role play with the jailbreak on the system prompt, or context_compliance with the jailbreak only on the first live turn). But I don't think we should just include all techniques (like how they were before this PR) — they're all a bit different, and the jailbreak locus isn't the same for each. If we do role play, is the jailbreak on the system prompt, the first turn, or every turn? That should be a deliberate choice per technique, which is exactly why the scope belongs on the config.

Net: ConversationManager keeps applying the converter list it's given (role-filtered), no eager conversion, and no _request_converters_applied marker on Message. For non-chat targets we go back to today's behavior — flatten history, prepend it, let the live send convert the block. Unless I'm missing something, that's the whole change.

@romanlutz

Copy link
Copy Markdown
Contributor Author

Richard Lundeen (@rlundeen2) I like FIRST_LIVE_TURN as a declarative converter scope, but it does not preserve role scoping for non-chat targets. ConversationManager flattens prepended user/assistant history and the live request into one string before PromptNormalizer runs converters. At that point the converter sees the entire joined block. The turn scope can decide whether the converter runs, but not prevent the assistant portion from being rewritten.

The proposal would work if converter scope were applied while the conversation was still structured, before target normalization/flattening. That requires either the bridge in this PR or a larger pipeline change that introduces structured history adaptation and applies origin/role/turn scope before normalization. So I think FIRST_LIVE_TURN is useful follow-up API design, but it is not by itself a replacement for the current non-chat handling.

@rlundeen2

Richard Lundeen (rlundeen2) commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Good catch, I think ConversationManager existed before target capabilities and it shouldn't do message normalization anymore. Agreed with the issue with ConversationManager flattening things. But I don't agree with this: "a larger pipeline change that introduces structured history adaptation" because it's already built and wired. I think the following would be tighter/less error-prone and not significantly more complicated than the current approach of adding converters to the conversation manager.

PromptTarget._get_normalized_conversation_async pulls conversation history from memory, appends the live message, and runs configuration.normalize_async — all after converters have run. The pipeline is capability-gated
and already registers the normalizer that does this exact job

So the shape I'd like to land on:

  1. Have initialize_context_async treat non-chat targets like chat targets — write the prepended conversation to memory, structured, with roles.
  2. Drop _handle_non_chat_target_async.
  3. Gate the squash on missing EDITABLE_HISTORY in the capability→normalizer table (change to ADAPT)
  4. Map PrependedConversationConfig.message_normalizer onto normalizer_overrides so callers keep format control.

One wrinkle is multi turn (but not editable) targets like playwright will need to squash but not squash previous turns. But that's the idea that FIRST_TURN could identify.

Also worth noting: _handle_non_chat_target_async only mutates context.next_message and never persists, so prepended history isn't recorded as conversation history for these targets today. Moving the squash into the pipeline fixes that too.

@romanlutz

Copy link
Copy Markdown
Contributor Author

But this means normalization happens in the target, and history represents something different, right?

History may show 8 turns of prompt/response but it's perhaps a non-editable history target so everything gets squashed into a single prompt. History should reflect that. Unless I'm misunderstanding.

Persist prepended conversations structurally for every target, then adapt them only for the first live request when editable history is unavailable.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6776aa95-0924-4967-a056-e5a228af3faa
Copilot AI added 2 commits August 19, 2026 07:06
Replace shared formatter registration with an explicit one-shot target normalization context. Keep prepended history structured in memory, stage role-scoped conversion before persistence, and adapt history with the first live request immediately before provider invocation.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6776aa95-0924-4967-a056-e5a228af3faa
Create a fresh target normalization context when single-turn target rotation carries system messages into a new conversation. This keeps the next request single-message while retaining its system framing.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6776aa95-0924-4967-a056-e5a228af3faa
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.

4 participants