diff --git a/pyrit/executor/attack/component/conversation_manager.py b/pyrit/executor/attack/component/conversation_manager.py index 439f93ac49..e48faa6666 100644 --- a/pyrit/executor/attack/component/conversation_manager.py +++ b/pyrit/executor/attack/component/conversation_manager.py @@ -308,9 +308,10 @@ async def initialize_context_async( - All messages get new UUIDs For non-chat PromptTarget: - - If `config.non_chat_target_behavior="normalize_first_turn"`: normalizes - conversation to string and prepends to context.next_message - - If `config.non_chat_target_behavior="raise"`: raises ValueError + - Normalizes the prepended conversation to a string and prepends it to + ``context.next_message`` (using ``config.message_normalizer`` when provided). + - If the deprecated ``config.non_chat_target_behavior="raise"`` is set, + raises ValueError instead. This option is deprecated and will be removed in v0.16.0. Args: context: The attack context to initialize. @@ -390,11 +391,11 @@ async def _handle_non_chat_target_async( if config.non_chat_target_behavior == "raise": raise ValueError( "prepended_conversation requires the objective target to support multi-turn " - "conversations with editable history. The current target does not. " - "Use PrependedConversationConfig with non_chat_target_behavior='normalize_first_turn' " - "to normalize the conversation into the first message instead." + "conversations with editable history. The current target does not. Note that " + "the non_chat_target_behavior parameter is deprecated and will be removed in " + "v0.16.0; non-chat targets will then always normalize the prepended conversation " + "into the first turn." ) - # Normalize conversation to string normalizer = config.get_message_normalizer() normalized_context = await normalizer.normalize_string_async(prepended_conversation) diff --git a/pyrit/executor/attack/component/prepended_conversation_config.py b/pyrit/executor/attack/component/prepended_conversation_config.py index fddeae5371..13c236cef6 100644 --- a/pyrit/executor/attack/component/prepended_conversation_config.py +++ b/pyrit/executor/attack/component/prepended_conversation_config.py @@ -3,9 +3,11 @@ from __future__ import annotations +import warnings from dataclasses import dataclass, field -from typing import Literal, Optional, get_args +from typing import Literal, get_args +from pyrit.common.deprecation import print_deprecation_message from pyrit.message_normalizer import ( ConversationContextNormalizer, MessageStringNormalizer, @@ -34,16 +36,25 @@ class PrependedConversationConfig: # Must implement MessageStringNormalizer (e.g., TokenizerTemplateNormalizer or ConversationContextNormalizer). # When None and normalization is needed (e.g., for non-chat targets), a default # ConversationContextNormalizer is used that produces "Turn N: User/Assistant" format. - message_normalizer: Optional[MessageStringNormalizer] = None - - # Behavior when the target is a PromptTarget but not a chat-capable PromptTarget: - # - "normalize_first_turn": Normalize the prepended conversation into a string and - # store it in ConversationState.normalized_prepended_context. This context will be - # prepended to the first message sent to the target. Uses objective_target_context_normalizer - # if provided, otherwise falls back to ConversationContextNormalizer. - # - "raise": Raise a ValueError. Use this when prepended conversation history must be - # maintained by the target (i.e., target must be a chat-capable PromptTarget). - non_chat_target_behavior: Literal["normalize_first_turn", "raise"] = "normalize_first_turn" + message_normalizer: MessageStringNormalizer | None = None + + # Deprecated: this option will be removed in v0.16.0. Setting this field to any + # non-None value emits a DeprecationWarning. In this release, ``"raise"`` still + # raises ValueError on non-chat targets; ``"normalize_first_turn"`` and ``None`` + # both normalize the prepended conversation into the first turn (via + # ``message_normalizer``; default: ConversationContextNormalizer). In v0.16.0 + # non-chat targets will always normalize; there is no replacement for the + # ``"raise"`` behavior. + non_chat_target_behavior: Literal["normalize_first_turn", "raise"] | None = None + + def __post_init__(self) -> None: + """Emit a DeprecationWarning when the deprecated ``non_chat_target_behavior`` field is set.""" + if self.non_chat_target_behavior is not None: + print_deprecation_message( + old_item="PrependedConversationConfig(non_chat_target_behavior=...)", + new_item="PrependedConversationConfig() (non-chat targets always normalize the prepended conversation)", + removed_in="0.16.0", + ) def get_message_normalizer(self) -> MessageStringNormalizer: """ @@ -58,30 +69,44 @@ def get_message_normalizer(self) -> MessageStringNormalizer: @classmethod def default(cls) -> PrependedConversationConfig: """ - Create a default configuration with converters applied to all roles. + Return a deprecated configuration with ``non_chat_target_behavior="raise"``. + + .. deprecated:: + ``default()`` is deprecated and will be removed in v0.16.0. Use + ``PrependedConversationConfig()`` instead. In this release the returned + configuration still raises on non-chat targets; in v0.16.0 the ``"raise"`` + branch is removed and non-chat targets will always normalize the prepended + conversation into the first turn. Returns: - A configuration that applies converters to all prepended messages, - raising an error for non-chat targets. + A configuration equivalent to ``PrependedConversationConfig(non_chat_target_behavior="raise")``. """ - return cls( - apply_converters_to_roles=list(get_args(ChatMessageRole)), - message_normalizer=None, - non_chat_target_behavior="raise", + print_deprecation_message( + old_item="PrependedConversationConfig.default()", + new_item="PrependedConversationConfig() (non-chat targets always normalize the prepended conversation)", + removed_in="0.16.0", ) + # Suppress the __post_init__ deprecation warning so callers see exactly + # one warning (the one for default()) rather than two for a single deprecated call. + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + return cls(non_chat_target_behavior="raise") @classmethod def for_non_chat_target( cls, *, - message_normalizer: Optional[MessageStringNormalizer] = None, - apply_converters_to_roles: Optional[list[ChatMessageRole]] = None, + message_normalizer: MessageStringNormalizer | None = None, + apply_converters_to_roles: list[ChatMessageRole] | None = None, ) -> PrependedConversationConfig: """ Create a configuration for use with non-chat targets. - This configuration normalizes the prepended conversation into a text block - that will be prepended to the first message sent to the target. + .. deprecated:: + ``for_non_chat_target()`` is deprecated and will be removed in v0.16.0. + Non-chat targets always normalize the prepended conversation into the + first turn, so this factory is equivalent to ``PrependedConversationConfig(...)`` + with the same arguments. Use the default constructor instead. Args: message_normalizer: Normalizer for formatting the prepended conversation into a string. @@ -92,10 +117,21 @@ def for_non_chat_target( Returns: A configuration that normalizes the prepended conversation for non-chat targets. """ - return cls( - apply_converters_to_roles=( - apply_converters_to_roles if apply_converters_to_roles is not None else list(get_args(ChatMessageRole)) - ), - message_normalizer=message_normalizer, - non_chat_target_behavior="normalize_first_turn", + print_deprecation_message( + old_item="PrependedConversationConfig.for_non_chat_target()", + new_item="PrependedConversationConfig() (non-chat targets always normalize the prepended conversation)", + removed_in="0.16.0", ) + # Suppress the __post_init__ deprecation warning so callers see exactly one + # warning (the one for for_non_chat_target()) rather than two. + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + return cls( + apply_converters_to_roles=( + apply_converters_to_roles + if apply_converters_to_roles is not None + else list(get_args(ChatMessageRole)) + ), + message_normalizer=message_normalizer, + non_chat_target_behavior="normalize_first_turn", + ) diff --git a/tests/unit/executor/attack/component/test_conversation_manager.py b/tests/unit/executor/attack/component/test_conversation_manager.py index c0bae706b5..a83bbe1968 100644 --- a/tests/unit/executor/attack/component/test_conversation_manager.py +++ b/tests/unit/executor/attack/component/test_conversation_manager.py @@ -838,7 +838,7 @@ async def test_normalizes_for_non_chat_target_when_configured( context.prepended_conversation = sample_conversation context.next_message = Message.from_prompt(prompt="Next message", role="user") - config = PrependedConversationConfig(non_chat_target_behavior="normalize_first_turn") + config = PrependedConversationConfig() await manager.initialize_context_async( context=context, @@ -1088,7 +1088,8 @@ async def test_non_chat_target_behavior_raise_explicit( context = _TestAttackContext(params=AttackParameters(objective="Test objective")) context.prepended_conversation = sample_conversation - config = PrependedConversationConfig(non_chat_target_behavior="raise") + with pytest.warns(DeprecationWarning, match="non_chat_target_behavior"): + config = PrependedConversationConfig(non_chat_target_behavior="raise") with pytest.raises( ValueError, @@ -1115,7 +1116,7 @@ async def test_non_chat_target_behavior_normalize_first_turn_creates_next_messag context.prepended_conversation = sample_conversation context.next_message = None - config = PrependedConversationConfig(non_chat_target_behavior="normalize_first_turn") + config = PrependedConversationConfig() await manager.initialize_context_async( context=context, @@ -1142,7 +1143,7 @@ async def test_non_chat_target_behavior_normalize_first_turn_prepends_to_existin context.prepended_conversation = sample_conversation context.next_message = Message.from_prompt(prompt="My question", role="user") - config = PrependedConversationConfig(non_chat_target_behavior="normalize_first_turn") + config = PrependedConversationConfig() await manager.initialize_context_async( context=context, @@ -1170,7 +1171,7 @@ async def test_non_chat_target_behavior_normalize_returns_empty_state( context = _TestAttackContext(params=AttackParameters(objective="Test objective")) context.prepended_conversation = sample_conversation - config = PrependedConversationConfig(non_chat_target_behavior="normalize_first_turn") + config = PrependedConversationConfig() state = await manager.initialize_context_async( context=context, @@ -1314,7 +1315,7 @@ async def test_message_normalizer_default_uses_conversation_context_normalizer( context.prepended_conversation = sample_conversation context.next_message = None - config = PrependedConversationConfig(non_chat_target_behavior="normalize_first_turn") + config = PrependedConversationConfig() await manager.initialize_context_async( context=context, @@ -1348,7 +1349,6 @@ async def test_message_normalizer_custom_normalizer_is_used( context.next_message = None config = PrependedConversationConfig( - non_chat_target_behavior="normalize_first_turn", message_normalizer=mock_normalizer, ) @@ -1372,7 +1372,8 @@ async def test_message_normalizer_custom_normalizer_is_used( def test_default_factory_creates_raise_behavior(self) -> None: """Test that PrependedConversationConfig.default() creates raise behavior.""" - config = PrependedConversationConfig.default() + with pytest.warns(DeprecationWarning, match="PrependedConversationConfig.default\\(\\) is deprecated"): + config = PrependedConversationConfig.default() assert config.non_chat_target_behavior == "raise" assert config.message_normalizer is None @@ -1383,7 +1384,10 @@ def test_default_factory_creates_raise_behavior(self) -> None: def test_for_non_chat_target_factory_creates_normalize_behavior(self) -> None: """Test that for_non_chat_target() creates normalize_first_turn behavior.""" - config = PrependedConversationConfig.for_non_chat_target() + with pytest.warns( + DeprecationWarning, match="PrependedConversationConfig.for_non_chat_target\\(\\) is deprecated" + ): + config = PrependedConversationConfig.for_non_chat_target() assert config.non_chat_target_behavior == "normalize_first_turn" @@ -1392,14 +1396,20 @@ def test_for_non_chat_target_with_custom_normalizer(self) -> None: from pyrit.message_normalizer import MessageStringNormalizer mock_normalizer = MagicMock(spec=MessageStringNormalizer) - config = PrependedConversationConfig.for_non_chat_target(message_normalizer=mock_normalizer) + with pytest.warns( + DeprecationWarning, match="PrependedConversationConfig.for_non_chat_target\\(\\) is deprecated" + ): + config = PrependedConversationConfig.for_non_chat_target(message_normalizer=mock_normalizer) assert config.message_normalizer == mock_normalizer assert config.non_chat_target_behavior == "normalize_first_turn" def test_for_non_chat_target_with_custom_roles(self) -> None: """Test that for_non_chat_target() accepts custom apply_converters_to_roles.""" - config = PrependedConversationConfig.for_non_chat_target(apply_converters_to_roles=["user"]) + with pytest.warns( + DeprecationWarning, match="PrependedConversationConfig.for_non_chat_target\\(\\) is deprecated" + ): + config = PrependedConversationConfig.for_non_chat_target(apply_converters_to_roles=["user"]) assert config.apply_converters_to_roles == ["user"] assert config.non_chat_target_behavior == "normalize_first_turn" @@ -1421,7 +1431,8 @@ async def test_chat_target_ignores_non_chat_target_behavior( context.prepended_conversation = sample_conversation # Even with raise behavior, chat targets should work - config = PrependedConversationConfig(non_chat_target_behavior="raise") + with pytest.warns(DeprecationWarning, match="non_chat_target_behavior"): + config = PrependedConversationConfig(non_chat_target_behavior="raise") state = await manager.initialize_context_async( context=context, diff --git a/tests/unit/executor/attack/component/test_prepended_conversation_config.py b/tests/unit/executor/attack/component/test_prepended_conversation_config.py index fd6ed9038e..2b08a26c51 100644 --- a/tests/unit/executor/attack/component/test_prepended_conversation_config.py +++ b/tests/unit/executor/attack/component/test_prepended_conversation_config.py @@ -4,6 +4,8 @@ from typing import get_args from unittest.mock import MagicMock +import pytest + from pyrit.executor.attack.component.prepended_conversation_config import PrependedConversationConfig from pyrit.message_normalizer import ConversationContextNormalizer from pyrit.models import ChatMessageRole @@ -21,7 +23,7 @@ def test_default_init_message_normalizer_is_none(): def test_default_init_non_chat_target_behavior(): config = PrependedConversationConfig() - assert config.non_chat_target_behavior == "normalize_first_turn" + assert config.non_chat_target_behavior is None def test_get_message_normalizer_returns_default_when_none(): @@ -37,14 +39,52 @@ def test_get_message_normalizer_returns_custom(): def test_default_class_method(): - config = PrependedConversationConfig.default() + with pytest.warns(DeprecationWarning, match="PrependedConversationConfig.default\\(\\) is deprecated"): + config = PrependedConversationConfig.default() assert config.apply_converters_to_roles == list(get_args(ChatMessageRole)) assert config.message_normalizer is None assert config.non_chat_target_behavior == "raise" +def test_explicit_raise_emits_deprecation_warning(): + with pytest.warns(DeprecationWarning, match="non_chat_target_behavior"): + config = PrependedConversationConfig(non_chat_target_behavior="raise") + assert config.non_chat_target_behavior == "raise" + + +def test_explicit_normalize_first_turn_emits_deprecation_warning(): + with pytest.warns(DeprecationWarning, match="non_chat_target_behavior"): + config = PrependedConversationConfig(non_chat_target_behavior="normalize_first_turn") + assert config.non_chat_target_behavior == "normalize_first_turn" + + +def test_default_init_does_not_emit_deprecation_warning(recwarn): + PrependedConversationConfig() + deprecation_warnings = [w for w in recwarn.list if issubclass(w.category, DeprecationWarning)] + assert deprecation_warnings == [] + + +def test_explicit_none_does_not_emit_deprecation_warning(recwarn): + PrependedConversationConfig(non_chat_target_behavior=None) + deprecation_warnings = [w for w in recwarn.list if issubclass(w.category, DeprecationWarning)] + assert deprecation_warnings == [] + + +def test_default_factory_emits_single_deprecation_warning(recwarn): + PrependedConversationConfig.default() + deprecation_warnings = [w for w in recwarn.list if issubclass(w.category, DeprecationWarning)] + assert len(deprecation_warnings) == 1 + + +def test_for_non_chat_target_emits_single_deprecation_warning(recwarn): + PrependedConversationConfig.for_non_chat_target() + deprecation_warnings = [w for w in recwarn.list if issubclass(w.category, DeprecationWarning)] + assert len(deprecation_warnings) == 1 + + def test_for_non_chat_target_defaults(): - config = PrependedConversationConfig.for_non_chat_target() + with pytest.warns(DeprecationWarning, match="PrependedConversationConfig.for_non_chat_target\\(\\) is deprecated"): + config = PrependedConversationConfig.for_non_chat_target() assert config.apply_converters_to_roles == list(get_args(ChatMessageRole)) assert config.message_normalizer is None assert config.non_chat_target_behavior == "normalize_first_turn" @@ -52,18 +92,21 @@ def test_for_non_chat_target_defaults(): def test_for_non_chat_target_with_custom_normalizer(): mock_normalizer = MagicMock() - config = PrependedConversationConfig.for_non_chat_target(message_normalizer=mock_normalizer) + with pytest.warns(DeprecationWarning, match="PrependedConversationConfig.for_non_chat_target\\(\\) is deprecated"): + config = PrependedConversationConfig.for_non_chat_target(message_normalizer=mock_normalizer) assert config.message_normalizer is mock_normalizer assert config.non_chat_target_behavior == "normalize_first_turn" def test_for_non_chat_target_with_specific_roles(): - config = PrependedConversationConfig.for_non_chat_target(apply_converters_to_roles=["user"]) + with pytest.warns(DeprecationWarning, match="PrependedConversationConfig.for_non_chat_target\\(\\) is deprecated"): + config = PrependedConversationConfig.for_non_chat_target(apply_converters_to_roles=["user"]) assert config.apply_converters_to_roles == ["user"] def test_default_vs_init_differ_in_behavior(): - default_config = PrependedConversationConfig.default() + with pytest.warns(DeprecationWarning): + default_config = PrependedConversationConfig.default() init_config = PrependedConversationConfig() assert default_config.non_chat_target_behavior == "raise" - assert init_config.non_chat_target_behavior == "normalize_first_turn" + assert init_config.non_chat_target_behavior is None