Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3fe8b18
Add supports_multi_turn property and adapt attacks for single-turn ta…
romanlutz Mar 2, 2026
8848c45
Add explicit supports_multi_turn=False overrides to single-turn OpenA…
romanlutz Mar 2, 2026
4adf22c
Add supports_multi_turn property and adapt attacks for single-turn ta…
romanlutz Mar 2, 2026
079751e
Add explicit supports_multi_turn=False overrides to single-turn OpenA…
romanlutz Mar 2, 2026
9afa84a
fix: suppress RET504 for strategy assignment in test
romanlutz Mar 2, 2026
66f3646
Address PR review feedback: class constants, settable per-instance, r…
romanlutz Mar 2, 2026
0bf3fbf
Merge remote-tracking branch 'origin/main' into romanlutz/supports-mu…
romanlutz Mar 2, 2026
f3a7b7a
Refactor to TargetCapabilities dataclass per review feedback
romanlutz Mar 2, 2026
2f0f8c2
Use TargetCapabilities object for overrides instead of individual con…
romanlutz Mar 2, 2026
17b56c3
Fix system prompt duplication for single-turn targets and update Cres…
romanlutz Mar 2, 2026
4a590df
Merge remote-tracking branch 'origin/main' into romanlutz/supports-mu…
romanlutz Mar 2, 2026
5dd2238
Rerun image target notebook with TargetCapabilities changes
romanlutz Mar 3, 2026
7150643
Auto-detect Entra ID auth for Azure endpoints and fix inline import
romanlutz Mar 3, 2026
71f8a6b
Rerun attack notebooks and update video notebook for Entra auth
romanlutz Mar 3, 2026
2db50fd
Remove conversation rotation from ChunkedRequestAttack
romanlutz Mar 3, 2026
c293cdb
Add Entra auth auto-detection to AzureContentFilterScorer and add Tar…
romanlutz Mar 3, 2026
17483f0
Merge remote-tracking branch 'origin/main' into romanlutz/supports-mu…
romanlutz Mar 3, 2026
4e8ea0c
Rerun video target notebook with Entra auth
romanlutz Mar 3, 2026
99e9595
Merge remote-tracking branch 'origin/main' into romanlutz/supports-mu…
romanlutz Mar 3, 2026
a1fc0ec
Handle error data type in chat target conversation history
romanlutz Mar 3, 2026
6485fc6
Merge branch 'romanlutz/supports-multi-turn' of https://github.com/ro…
romanlutz Mar 3, 2026
0a55c5d
Merge remote-tracking branch 'origin/main' into romanlutz/supports-mu…
romanlutz Mar 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,7 @@ API Reference
PromptShieldTarget
PromptTarget
RealtimeTarget
TargetCapabilities
TextTarget
WebSocketCopilotTarget

Expand Down
796 changes: 304 additions & 492 deletions doc/code/executor/attack/2_red_teaming_attack.ipynb

Large diffs are not rendered by default.

1,282 changes: 1,155 additions & 127 deletions doc/code/executor/attack/tap_attack.ipynb

Large diffs are not rendered by default.

38 changes: 19 additions & 19 deletions doc/code/targets/3_openai_image_target.ipynb

Large diffs are not rendered by default.

739 changes: 372 additions & 367 deletions doc/code/targets/4_openai_video_target.ipynb

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions pyrit/executor/attack/multi_turn/chunked_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,16 @@ async def _setup_async(self, *, context: ChunkedRequestAttackContext) -> None:

Args:
context (ChunkedRequestAttackContext): The attack context containing attack parameters.

Raises:
ValueError: If the objective target does not support multi-turn conversations.
"""
if not self._objective_target.supports_multi_turn:
raise ValueError(
"ChunkedRequestAttack requires a multi-turn target. "
"The objective target does not support multi-turn conversations."
)

# Ensure the context has a session
context.session = ConversationSession()

Expand Down
10 changes: 10 additions & 0 deletions pyrit/executor/attack/multi_turn/crescendo.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,17 @@ async def _setup_async(self, *, context: CrescendoAttackContext) -> None:
Args:
context (CrescendoAttackContext): Attack context with configuration
Raises:
ValueError: If the objective target does not support multi-turn conversations.
"""
if not self._objective_target.supports_multi_turn:
raise ValueError(
"CrescendoAttack requires a multi-turn target. Crescendo fundamentally relies on "
"multi-turn conversation history to gradually escalate prompts. "
"Use RedTeamingAttack or TreeOfAttacksWithPruning instead."
)

# Ensure the context has a session
context.session = ConversationSession()

Expand Down
9 changes: 9 additions & 0 deletions pyrit/executor/attack/multi_turn/multi_prompt_sending.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,16 @@ async def _setup_async(self, *, context: MultiTurnAttackContext[Any]) -> None:
Args:
context (MultiTurnAttackContext): The attack context containing attack parameters.
Raises:
ValueError: If the objective target does not support multi-turn conversations.
"""
if not self._objective_target.supports_multi_turn:
raise ValueError(
"MultiPromptSendingAttack requires a multi-turn target. "
"The objective target does not support multi-turn conversations."
)

# Ensure the context has a session (like red_teaming.py does)
context.session = ConversationSession()

Expand Down
58 changes: 58 additions & 0 deletions pyrit/executor/attack/multi_turn/multi_turn_attack_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
AttackStrategy,
AttackStrategyResultT,
)
from pyrit.memory import CentralMemory
from pyrit.models import ConversationReference, ConversationType

if TYPE_CHECKING:
from pyrit.models import (
Expand Down Expand Up @@ -91,3 +93,59 @@ def __init__(
params_type=params_type,
logger=logger,
)

def _rotate_conversation_for_single_turn_target(
self,
*,
context: MultiTurnAttackContext[Any],
) -> None:
"""
Create a fresh conversation_id for the objective target if it is a single-turn target.

For single-turn targets, each turn must use a separate conversation_id because the target
rejects conversations with prior messages. The prior turn's conversation_id is recorded
as a PRUNED related conversation on the attack context.

System messages (e.g., from prepended conversation) are duplicated into the new
conversation so that the target retains its system prompt context.

For multi-turn targets this method is a no-op.

This should be called before each turn (except the first) when sending prompts to the
objective target.

Args:
context: The current attack context.
"""
if self._objective_target.supports_multi_turn:
return

if context.executed_turns == 0:
return

old_conversation_id = context.session.conversation_id
context.related_conversations.add(
ConversationReference(
conversation_id=old_conversation_id,
conversation_type=ConversationType.PRUNED,
description=f"single-turn target prior turn {context.executed_turns}",
)
)

# Duplicate system messages (e.g., system prompt from prepended conversation)
# into the new conversation so the target retains its configuration.
memory = CentralMemory.get_memory_instance()
Comment on lines +135 to +137
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

_rotate_conversation_for_single_turn_target relies on an inline from pyrit.memory import CentralMemory import. This module doesn't appear to have a circular dependency with pyrit.memory, so the import should be moved to the top of the file to match the project's import-organization convention and avoid repeated imports on every rotation call.

Copilot uses AI. Check for mistakes.
messages = memory.get_conversation(conversation_id=old_conversation_id)
system_messages = [m for m in messages if m.api_role == "system"]

if system_messages:
new_conversation_id, pieces = memory.duplicate_messages(messages=system_messages)
memory.add_message_pieces_to_memory(message_pieces=pieces)
context.session.conversation_id = new_conversation_id
else:
context.session.conversation_id = str(uuid.uuid4())

self._logger.debug(
f"Rotated conversation_id for single-turn target: "
f"{old_conversation_id} -> {context.session.conversation_id}"
)
3 changes: 3 additions & 0 deletions pyrit/executor/attack/multi_turn/red_teaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,9 @@ async def _send_prompt_to_objective_target_async(
"""
logger.info(f"Sending prompt to target: {message.get_value()[:50]}...")

# For single-turn targets, rotate conversation_id so each turn starts fresh
self._rotate_conversation_for_single_turn_target(context=context)

with execution_context(
component_role=ComponentRole.OBJECTIVE_TARGET,
attack_strategy_name=self.__class__.__name__,
Expand Down
19 changes: 16 additions & 3 deletions pyrit/executor/attack/multi_turn/tree_of_attacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -777,9 +777,22 @@ def duplicate(self) -> "_TreeOfAttacksNode":
)

# Duplicate the conversations to preserve history
duplicate_node.objective_target_conversation_id = self._memory.duplicate_conversation(
conversation_id=self.objective_target_conversation_id
)
# For single-turn targets, duplicate only the system messages (e.g., system prompt
# from prepended conversation) so the target retains its configuration without
# carrying over attack turn history that would cause validation errors.
if self._objective_target.supports_multi_turn:
duplicate_node.objective_target_conversation_id = self._memory.duplicate_conversation(
conversation_id=self.objective_target_conversation_id
)
else:
messages = self._memory.get_conversation(conversation_id=self.objective_target_conversation_id)
system_messages = [m for m in messages if m.api_role == "system"]
if system_messages:
new_id, pieces = self._memory.duplicate_messages(messages=system_messages)
self._memory.add_message_pieces_to_memory(message_pieces=pieces)
duplicate_node.objective_target_conversation_id = new_id
Comment on lines +790 to +793
Copy link
Contributor Author

Choose a reason for hiding this comment

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

TODO @romanlutz to check correctness

else:
duplicate_node.objective_target_conversation_id = str(uuid.uuid4())

duplicate_node.adversarial_chat_conversation_id = self._memory.duplicate_conversation(
conversation_id=self.adversarial_chat_conversation_id
Expand Down
2 changes: 2 additions & 0 deletions pyrit/prompt_target/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from pyrit.prompt_target.azure_ml_chat_target import AzureMLChatTarget
from pyrit.prompt_target.common.prompt_chat_target import PromptChatTarget
from pyrit.prompt_target.common.prompt_target import PromptTarget
from pyrit.prompt_target.common.target_capabilities import TargetCapabilities
from pyrit.prompt_target.common.utils import limit_requests_per_minute
from pyrit.prompt_target.crucible_target import CrucibleTarget
from pyrit.prompt_target.gandalf_target import GandalfLevel, GandalfTarget
Expand Down Expand Up @@ -66,6 +67,7 @@
"PromptShieldTarget",
"PromptTarget",
"RealtimeTarget",
"TargetCapabilities",
"TextTarget",
"WebSocketCopilotTarget",
]
16 changes: 16 additions & 0 deletions pyrit/prompt_target/common/prompt_chat_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pyrit.models import MessagePiece
from pyrit.models.json_response_config import _JsonResponseConfig
from pyrit.prompt_target.common.prompt_target import PromptTarget
from pyrit.prompt_target.common.target_capabilities import TargetCapabilities


class PromptChatTarget(PromptTarget):
Expand All @@ -21,6 +22,8 @@ class PromptChatTarget(PromptTarget):
Realtime chat targets or OpenAI completions are NOT PromptChatTargets. You don't send the conversation history.
"""

_DEFAULT_CAPABILITIES: TargetCapabilities = TargetCapabilities(supports_multi_turn=True)

def __init__(
self,
*,
Expand All @@ -47,6 +50,19 @@ def __init__(
underlying_model=underlying_model,
)

@property
def supports_multi_turn(self) -> bool:
"""
Whether this target supports multi-turn conversations.

Chat targets retrieve conversation history from memory and send it
with each request, supporting true multi-turn conversations.

Returns:
bool: True for chat targets.
"""
return True
Comment on lines +62 to +64
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

PromptChatTarget.supports_multi_turn always returns True, which bypasses TargetCapabilities and makes per-instance overrides (via the capabilities setter/constructor) ineffective. This will also cause tests like test_constructor_override_supports_multi_turn (which expects an override to False) to fail. Consider removing this override entirely and relying on _DEFAULT_CAPABILITIES, or delegating to super().supports_multi_turn / self.capabilities.supports_multi_turn.

Suggested change
bool: True for chat targets.
"""
return True
bool: True for chat targets by default, unless overridden via capabilities.
"""
return self.capabilities.supports_multi_turn

Copilot uses AI. Check for mistakes.

def set_system_prompt(
self,
*,
Expand Down
40 changes: 40 additions & 0 deletions pyrit/prompt_target/common/prompt_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pyrit.identifiers import ComponentIdentifier, Identifiable
from pyrit.memory import CentralMemory, MemoryInterface
from pyrit.models import Message
from pyrit.prompt_target.common.target_capabilities import TargetCapabilities

logger = logging.getLogger(__name__)

Expand All @@ -28,13 +29,16 @@ class PromptTarget(Identifiable):

_identifier: Optional[ComponentIdentifier] = None

_DEFAULT_CAPABILITIES: TargetCapabilities = TargetCapabilities()

def __init__(
self,
verbose: bool = False,
max_requests_per_minute: Optional[int] = None,
endpoint: str = "",
model_name: str = "",
underlying_model: Optional[str] = None,
capabilities: Optional[TargetCapabilities] = None,
) -> None:
Comment on lines 34 to 42
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

PromptTarget.__init__ still allows positional arguments even though it has multiple parameters, and this PR adds another (capabilities). Most other constructors in this area are keyword-only (def __init__(self, *, ...)), which reduces accidental argument ordering bugs. Consider making this initializer keyword-only (possibly via a deprecation period) to keep the base target API consistent and safer to extend.

Copilot uses AI. Check for mistakes.
"""
Initialize the PromptTarget.
Expand All @@ -48,13 +52,18 @@ def __init__(
identification purposes. This is useful when the deployment name in Azure differs
from the actual model. If not provided, `model_name` will be used for the identifier.
Defaults to None.
capabilities (TargetCapabilities, Optional): Override the default capabilities for
this target instance. Useful for targets whose capabilities depend on deployment
configuration (e.g., Playwright, HTTP). If None, uses the class-level
``_DEFAULT_CAPABILITIES``. Defaults to None.
"""
self._memory = CentralMemory.get_memory_instance()
self._verbose = verbose
self._max_requests_per_minute = max_requests_per_minute
self._endpoint = endpoint
self._model_name = model_name
self._underlying_model = underlying_model
self._capabilities = capabilities if capabilities is not None else type(self)._DEFAULT_CAPABILITIES

if self._verbose:
logging.basicConfig(level=logging.INFO)
Expand Down Expand Up @@ -128,12 +137,43 @@ def _create_identifier(
"model_name": model_name,
"max_requests_per_minute": self._max_requests_per_minute,
"supports_conversation_history": isinstance(self, PromptChatTarget),
"supports_multi_turn": self.supports_multi_turn,
}
if params:
all_params.update(params)

return ComponentIdentifier.of(self, params=all_params, children=children)

@property
def capabilities(self) -> TargetCapabilities:
"""
The capabilities of this target instance.

Defaults to the class-level ``_DEFAULT_CAPABILITIES``. Can be overridden
per instance by setting this property, which is useful for targets whose
capabilities depend on deployment configuration (e.g., Playwright, HTTP).

Returns:
TargetCapabilities: The capabilities for this target.
"""
return self._capabilities

@capabilities.setter
def capabilities(self, value: TargetCapabilities) -> None:
self._capabilities = value
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

The capabilities setter mutates behavior-affecting state, but Identifiable.get_identifier() caches identifiers for the object lifetime. If get_identifier() was called before capabilities is reassigned, the identifier will permanently reflect the old supports_multi_turn value. Consider either (a) making capabilities immutable after construction, or (b) resetting self._identifier to None in the setter so the identifier can be rebuilt with the updated capabilities.

Suggested change
self._capabilities = value
self._capabilities = value
# Invalidate cached identifier so it can be rebuilt with updated capabilities.
self._identifier = None

Copilot uses AI. Check for mistakes.

@property
def supports_multi_turn(self) -> bool:
"""
Whether this target supports multi-turn conversations.

Convenience property that delegates to ``self.capabilities.supports_multi_turn``.

Returns:
bool: False by default. Subclasses that support multi-turn should override.
"""
return self._capabilities.supports_multi_turn

def _build_identifier(self) -> ComponentIdentifier:
"""
Build the identifier for this target.
Expand Down
22 changes: 22 additions & 0 deletions pyrit/prompt_target/common/target_capabilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

from dataclasses import dataclass


@dataclass(frozen=True)
class TargetCapabilities:
"""
Describes the capabilities of a PromptTarget so that attacks
and other components can adapt their behavior accordingly.
Each target class defines default capabilities via the _DEFAULT_CAPABILITIES
class attribute. Users can override individual capabilities per instance
through constructor parameters, which is useful for targets whose
capabilities depend on deployment configuration (e.g., Playwright, HTTP).
"""

# Whether the target natively supports multi-turn conversations
# (i.e., it accepts and uses conversation history or maintains state
# across turns via external mechanisms like WebSocket connections).
supports_multi_turn: bool = False
6 changes: 3 additions & 3 deletions pyrit/prompt_target/openai/openai_chat_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,7 @@ def _is_text_message_format(self, conversation: MutableSequence[Message]) -> boo
for turn in conversation:
if len(turn.message_pieces) != 1:
return False
if turn.message_pieces[0].converted_value_data_type != "text":
if turn.message_pieces[0].converted_value_data_type not in ("text", "error"):
return False
return True

Expand Down Expand Up @@ -535,7 +535,7 @@ def _build_chat_messages_for_text(self, conversation: MutableSequence[Message])

message_piece = message.message_pieces[0]

if message_piece.converted_value_data_type != "text":
if message_piece.converted_value_data_type not in ("text", "error"):
raise ValueError("_build_chat_messages_for_text only supports text.")

Comment on lines +538 to 540
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

The validation now allows converted_value_data_type to be "error", but the raised message still says this method “only supports text.” Consider updating the exception message (and docstring) to reflect the actual accepted types (or explicitly skip error messages instead of sending them back to the model).

Copilot uses AI. Check for mistakes.
chat_message = ChatMessage(role=message_piece.api_role, content=message_piece.converted_value)
Expand Down Expand Up @@ -581,7 +581,7 @@ async def _build_chat_messages_for_multi_modal_async(
):
continue

if message_piece.converted_value_data_type == "text":
if message_piece.converted_value_data_type in ("text", "error"):
entry = {"type": "text", "text": message_piece.converted_value}
content.append(entry)
elif message_piece.converted_value_data_type == "image_path":
Expand Down
3 changes: 3 additions & 0 deletions pyrit/prompt_target/openai/openai_completion_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
)
from pyrit.identifiers import ComponentIdentifier
from pyrit.models import Message, construct_response_from_request
from pyrit.prompt_target.common.target_capabilities import TargetCapabilities
from pyrit.prompt_target.common.utils import limit_requests_per_minute
from pyrit.prompt_target.openai.openai_target import OpenAITarget

Expand All @@ -18,6 +19,8 @@
class OpenAICompletionTarget(OpenAITarget):
"""A prompt target for OpenAI completion endpoints."""

_DEFAULT_CAPABILITIES: TargetCapabilities = TargetCapabilities(supports_multi_turn=False)

def __init__(
self,
max_tokens: Optional[int] = None,
Expand Down
2 changes: 2 additions & 0 deletions pyrit/prompt_target/openai/openai_image_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
construct_response_from_request,
data_serializer_factory,
)
from pyrit.prompt_target.common.target_capabilities import TargetCapabilities
from pyrit.prompt_target.common.utils import limit_requests_per_minute
from pyrit.prompt_target.openai.openai_target import OpenAITarget

Expand All @@ -27,6 +28,7 @@ class OpenAIImageTarget(OpenAITarget):

# Maximum number of image inputs supported by the OpenAI image API
_MAX_INPUT_IMAGES = 16
_DEFAULT_CAPABILITIES: TargetCapabilities = TargetCapabilities(supports_multi_turn=False)

def __init__(
self,
Expand Down
Loading
Loading