Skip to content

Commit a00a23e

Browse files
MAINT: Make technique prompt placement explicit (#2236)
1 parent 77a738b commit a00a23e

9 files changed

Lines changed: 128 additions & 48 deletions

File tree

pyrit/models/seeds/attack_seed_group.py

Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from collections.abc import Sequence
2222

2323
from pyrit.models.seeds.attack_technique_seed_group import AttackTechniqueSeedGroup
24+
from pyrit.models.seeds.seed import Seed
2425

2526

2627
class AttackSeedGroup(SeedGroup):
@@ -144,6 +145,8 @@ def with_technique(self, *, technique: AttackTechniqueSeedGroup) -> AttackSeedGr
144145
Raises:
145146
ValueError: If the technique contains a SeedSimulatedConversation whose
146147
sequence range overlaps with existing prompt sequences.
148+
ValueError: If preserving prompt placement combines conflicting roles at
149+
the same sequence.
147150
"""
148151
# Pre-merge compatibility check with a clear error message
149152
if not self.is_compatible_with_technique(technique=technique):
@@ -157,38 +160,46 @@ def with_technique(self, *, technique: AttackTechniqueSeedGroup) -> AttackSeedGr
157160
f"overlapping the simulated conversation range are incompatible."
158161
)
159162

160-
base = list(self.seeds)
163+
base_seeds = [copy.deepcopy(seed) for seed in self.seeds]
164+
technique_seeds = [copy.deepcopy(seed) for seed in technique.seeds]
161165
idx = technique.insertion_index
162-
technique_seeds = list(technique.seeds)
163-
merged_seeds = base + technique_seeds if idx is None else base[:idx] + technique_seeds + base[idx:]
164-
165-
# ``self`` and ``technique`` may be shared across multiple ``with_technique``
166-
# calls (e.g. the dispatcher reuses one ``bundle.seed_technique`` instance
167-
# across every objective). Deepcopy first so the per-seed mutation below
168-
# and the fresh group_id assigned by ``AttackSeedGroup.__init__`` only
169-
# touch the returned group, leaving the originals untouched as the
170-
# docstring promises.
171-
merged_seeds = [copy.deepcopy(seed) for seed in merged_seeds]
166+
merged_seeds = (
167+
base_seeds + technique_seeds if idx is None else base_seeds[:idx] + technique_seeds + base_seeds[idx:]
168+
)
172169

173170
# Clear group IDs so the new group assigns a fresh one.
174171
# ``_enforce_consistent_group_id`` in the constructor will overwrite
175172
# all of them with a single new UUID.
176173
for seed in merged_seeds:
177174
seed.prompt_group_id = None
178175

179-
# Normalize prompt sequences to dense, 0-based order preserving relative
180-
# ordering. A technique whose seed leads the conversation (e.g. a system
181-
# prompt built at ``sequence=-1`` by ``from_system_prompt``) is thereby
182-
# prepended cleanly: it lands at sequence 0 and the existing turns shift
183-
# up (user 0 -> 1, assistant 1 -> 2, ...), rather than leaving a negative
184-
# or sparse sequence. This keeps the merge robust no matter how the base
185-
# group was numbered. Skipped when a simulated conversation is present,
186-
# since its ``sequence_range`` is absolute and self-consistent.
187-
has_simulated = any(isinstance(seed, SeedSimulatedConversation) for seed in merged_seeds)
188-
if not has_simulated:
189-
prompt_seeds = [seed for seed in merged_seeds if isinstance(seed, SeedPrompt)]
190-
rank_by_sequence = {seq: rank for rank, seq in enumerate(sorted({p.sequence for p in prompt_seeds}))}
191-
for seed in prompt_seeds:
192-
seed.sequence = rank_by_sequence[seed.sequence]
176+
self._normalize_prompt_sequences(
177+
base_seeds=base_seeds,
178+
technique_seeds=technique_seeds,
179+
prepend_technique=technique.prompt_placement == "prepend",
180+
)
193181

194182
return AttackSeedGroup(seeds=merged_seeds)
183+
184+
@staticmethod
185+
def _normalize_prompt_sequences(
186+
*,
187+
base_seeds: Sequence[Seed],
188+
technique_seeds: Sequence[Seed],
189+
prepend_technique: bool,
190+
) -> None:
191+
"""Normalize merged prompt sequences while preserving source-relative order."""
192+
all_seeds = [*base_seeds, *technique_seeds]
193+
# Simulated conversations reserve an absolute sequence range; renumbering only prompts
194+
# could invalidate that range or create an overlap.
195+
if any(isinstance(seed, SeedSimulatedConversation) for seed in all_seeds):
196+
return
197+
198+
seed_groups = (technique_seeds, base_seeds) if prepend_technique else (all_seeds,)
199+
next_sequence = 0
200+
for seeds in seed_groups:
201+
prompts = [seed for seed in seeds if isinstance(seed, SeedPrompt)]
202+
rank_by_sequence = {value: rank for rank, value in enumerate(sorted({p.sequence for p in prompts}))}
203+
for prompt in prompts:
204+
prompt.sequence = next_sequence + rank_by_sequence[prompt.sequence]
205+
next_sequence += len(rank_by_sequence)

pyrit/models/seeds/attack_technique_seed_group.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111

1212
from __future__ import annotations
1313

14+
from typing import Literal
15+
16+
from pydantic import Field
17+
1418
from pyrit.models.seeds.seed_group import SeedGroup
1519
from pyrit.models.seeds.seed_objective import SeedObjective
1620
from pyrit.models.seeds.seed_prompt import SeedPrompt
@@ -31,6 +35,15 @@ class AttackTechniqueSeedGroup(SeedGroup):
3135
# ``None`` (default) appends at the end; an integer inserts before that position.
3236
insertion_index: int | None = None
3337

38+
prompt_placement: Literal["preserve", "prepend"] = Field(
39+
default="preserve",
40+
description=(
41+
'"preserve" combines existing sequence relationships. During AttackSeedGroup construction, '
42+
"prompts at the same sequence are grouped when roles are the same and rejected when roles conflict. "
43+
'"prepend" places technique prompts before base prompts.'
44+
),
45+
)
46+
3447
@classmethod
3548
def from_system_prompt(cls, system_prompt: str, *, insertion_index: int | None = None) -> AttackTechniqueSeedGroup:
3649
"""
@@ -41,13 +54,9 @@ def from_system_prompt(cls, system_prompt: str, *, insertion_index: int | None =
4154
value is wrapped verbatim (``is_jinja_template=False``), so any literal
4255
``{{ ... }}`` in ``system_prompt`` is preserved rather than re-rendered.
4356
44-
The seed is built at ``sequence=-1`` as an internal "lead" marker so it orders ahead of
45-
any user turn. When merged via ``AttackSeedGroup.with_technique`` the merged sequences are
46-
normalized to dense 0-based order, so the system framing lands at sequence 0 and the
47-
objective's turns shift up (user 0 -> 1, assistant 1 -> 2, ...). Without leading it, merging
48-
onto a seed group that carries a user prompt at the default ``sequence=0`` would raise
49-
``Inconsistent roles found for sequence 0`` (one ``sequence`` maps to one ``Message``, which
50-
requires a single role).
57+
The group declares ``prompt_placement="prepend"`` so ``AttackSeedGroup.with_technique``
58+
places the system framing before the base prompts without relying on a reserved sequence
59+
value.
5160
5261
Args:
5362
system_prompt (str): The system-role instruction text.
@@ -58,10 +67,9 @@ def from_system_prompt(cls, system_prompt: str, *, insertion_index: int | None =
5867
AttackTechniqueSeedGroup: A group with a single general-technique system seed.
5968
"""
6069
return cls(
61-
seeds=[
62-
SeedPrompt(value=system_prompt, data_type="text", role="system", is_general_technique=True, sequence=-1)
63-
],
70+
seeds=[SeedPrompt(value=system_prompt, data_type="text", role="system", is_general_technique=True)],
6471
insertion_index=insertion_index,
72+
prompt_placement="prepend",
6573
)
6674

6775
def _check_invariants(self) -> None:

pyrit/scenario/core/attack_technique.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,20 +53,23 @@ def _build_identifier(self) -> ComponentIdentifier:
5353
Build the behavioral identity for this attack technique.
5454
5555
The identifier always contains the attack technique as ``children["attack"]``.
56-
When a seed technique is present, its seeds are added as
57-
``children["technique_seeds"]``.
56+
When a seed technique is present, its seeds and prompt placement are included.
5857
5958
Returns:
6059
ComponentIdentifier: The frozen identity snapshot.
6160
"""
6261
technique_seeds: list[SeedIdentifier] | None = None
62+
identifier_params: dict[str, Any] | None = None
6363
if self._seed_technique is not None:
6464
technique_seed_ids = [SeedIdentifier.from_seed(seed) for seed in self._seed_technique.seeds]
6565
if technique_seed_ids:
6666
technique_seeds = list(technique_seed_ids)
67+
if self._seed_technique.prompt_placement != "preserve":
68+
identifier_params = {"prompt_placement": self._seed_technique.prompt_placement}
6769

6870
return AttackTechniqueIdentifier.of(
6971
self,
72+
params=identifier_params,
7073
attack=self._attack.get_identifier(),
7174
technique_seeds=technique_seeds,
7275
)

pyrit/scenario/scenarios/airt/jailbreak.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from pyrit.converter import TextJailbreakConverter
1313
from pyrit.datasets import TextJailBreak
1414
from pyrit.executor.attack.single_turn.prompt_sending import PromptSendingAttack
15-
from pyrit.models import AttackTechniqueSeedGroup, Parameter, SeedPrompt
15+
from pyrit.models import AttackTechniqueSeedGroup, Parameter
1616
from pyrit.prompt_target import CapabilityName
1717
from pyrit.registry.components.attack_technique_registry import AttackTechniqueRegistry
1818
from pyrit.scenario.core.attack_technique_factory import AttackTechniqueFactory
@@ -450,12 +450,7 @@ def _build_system_prompt_factory(self, *, template_file_name: str) -> AttackTech
450450
the rendered jailbreak framing.
451451
"""
452452
framing = TextJailBreak(template_file_name=template_file_name).get_jailbreak_system_prompt()
453-
# sequence=-1 orders the system framing ahead of any user turn, so a caller-supplied seed
454-
# group carrying a user prompt at the default sequence 0 does not raise a same-sequence
455-
# role collision when this technique is merged in.
456-
seed_technique = AttackTechniqueSeedGroup(
457-
seeds=[SeedPrompt(value=framing, data_type="text", role="system", is_general_technique=True, sequence=-1)]
458-
)
453+
seed_technique = AttackTechniqueSeedGroup.from_system_prompt(framing)
459454
return AttackTechniqueFactory(
460455
name=_JAILBREAK_SYSTEM_PROMPT,
461456
attack_class=PromptSendingAttack,

tests/unit/models/test_attack_technique_seed_group.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ def test_default_insertion_index_is_none(self):
204204
seeds=[SeedPrompt(value="s", data_type="text", is_general_technique=True)],
205205
)
206206
assert group.insertion_index is None
207+
assert group.prompt_placement == "preserve"
207208

208209
def test_insertion_index_set_to_int(self):
209210
"""Test that insertion_index can be set to an integer."""
@@ -234,10 +235,11 @@ def test_builds_single_system_general_technique_seed(self):
234235
assert isinstance(seed, SeedPrompt)
235236
assert seed.value == "Follow these rules."
236237
assert seed.role == "system"
237-
assert seed.sequence == -1
238+
assert seed.sequence == 0
238239
assert seed.data_type == "text"
239240
assert seed.is_general_technique is True
240241
assert group.insertion_index is None
242+
assert group.prompt_placement == "prepend"
241243

242244
def test_preserves_literal_braces_without_rendering(self):
243245
"""Test that literal Jinja braces are preserved (is_jinja_template stays False)."""
@@ -249,6 +251,14 @@ def test_respects_insertion_index(self):
249251
group = AttackTechniqueSeedGroup.from_system_prompt("s", insertion_index=0)
250252
assert group.insertion_index == 0
251253

254+
def test_prompt_placement_survives_serialization_round_trip(self):
255+
"""Test that prepend intent is preserved when the group is serialized."""
256+
group = AttackTechniqueSeedGroup.from_system_prompt("Follow these rules.")
257+
258+
restored = AttackTechniqueSeedGroup.model_validate_json(group.model_dump_json())
259+
260+
assert restored.prompt_placement == "prepend"
261+
252262

253263
class TestAttackTechniqueSeedGroupRepr:
254264
"""Tests for AttackTechniqueSeedGroup.__repr__ method."""

tests/unit/models/test_seed_group.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,25 @@ def test_system_prompt_technique_merges_onto_user_turn_at_sequence_zero(self):
647647
("user", 3),
648648
]
649649

650+
def test_system_prompt_technique_prepends_when_base_uses_negative_sequence(self):
651+
"""Explicit prepend placement must not reserve a sequence value in the base group."""
652+
base = AttackSeedGroup(
653+
seeds=[
654+
SeedObjective(value="objective"),
655+
SeedPrompt(value="opening user turn", data_type="text", role="user", sequence=-1),
656+
SeedPrompt(value="assistant reply", data_type="text", role="assistant", sequence=4),
657+
]
658+
)
659+
technique = AttackTechniqueSeedGroup.from_system_prompt("Follow these rules.")
660+
661+
merged = base.with_technique(technique=technique)
662+
663+
assert [(p.role, p.sequence) for p in merged.prompts] == [
664+
("system", 0),
665+
("user", 1),
666+
("assistant", 2),
667+
]
668+
650669
def test_raises_when_technique_has_simulated_conversation_and_prompts_overlap(self):
651670
"""Merging a technique with SeedSimulatedConversation into a group with overlapping prompts raises."""
652671
base = AttackSeedGroup(

tests/unit/scenario/airt/test_jailbreak.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,7 @@ async def test_system_delivery_attaches_system_role_framing_seed(
525525
seed_technique = system_attack.attack_technique.seed_technique
526526
assert seed_technique is not None
527527
assert [s.role for s in seed_technique.seeds] == ["system"]
528+
assert seed_technique.prompt_placement == "prepend"
528529
assert seed_technique.seeds[0].value
529530

530531
async def test_system_delivery_uses_no_jailbreak_converter(
@@ -629,8 +630,8 @@ async def test_system_delivery_coexists_with_custom_user_prompt_seed_group(self)
629630
"""A caller-supplied seed group carrying a user prompt at the default sequence 0 must not
630631
collide with the system framing seed when native system delivery merges in.
631632
632-
The framing seed is ordered at ``sequence=-1`` precisely so this merge succeeds; without it
633-
the group would raise ``Inconsistent roles found for sequence 0`` at runtime.
633+
The framing technique declares prepend placement so this merge does not depend on the
634+
caller's sequence values.
634635
"""
635636
from pyrit.memory import CentralMemory
636637
from pyrit.score import SubStringScorer

tests/unit/scenario/core/test_attack_technique.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,38 @@ def test_technique_seeds_present_when_provided(self):
116116
assert "technique_seeds" in result.children
117117
assert len(result.children["technique_seeds"]) == 2
118118

119+
def test_prompt_placement_is_part_of_identifier(self):
120+
mock_attack = MagicMock(spec=AttackStrategy)
121+
mock_attack.get_identifier.return_value = ComponentIdentifier(
122+
class_name="PromptSendingAttack", class_module="pyrit.executor.attack"
123+
)
124+
seed_technique = AttackTechniqueSeedGroup.from_system_prompt("Follow these rules.")
125+
technique = AttackTechnique(attack=mock_attack, seed_technique=seed_technique)
126+
127+
result = technique.get_identifier()
128+
129+
assert result.params["prompt_placement"] == "prepend"
130+
131+
def test_prompt_placement_changes_identifier_hash(self):
132+
attack_id = ComponentIdentifier(class_name="PromptSendingAttack", class_module="pyrit.executor.attack")
133+
preserve_attack = MagicMock(spec=AttackStrategy)
134+
preserve_attack.get_identifier.return_value = attack_id
135+
prepend_attack = MagicMock(spec=AttackStrategy)
136+
prepend_attack.get_identifier.return_value = attack_id
137+
seeds = [SeedPrompt(value="technique", data_type="text", is_general_technique=True)]
138+
preserve = AttackTechnique(
139+
attack=preserve_attack,
140+
seed_technique=AttackTechniqueSeedGroup(seeds=seeds, prompt_placement="preserve"),
141+
)
142+
prepend = AttackTechnique(
143+
attack=prepend_attack,
144+
seed_technique=AttackTechniqueSeedGroup(seeds=seeds, prompt_placement="prepend"),
145+
)
146+
147+
assert "prompt_placement" not in preserve.get_identifier().params
148+
assert prepend.get_identifier().params["prompt_placement"] == "prepend"
149+
assert preserve.get_identifier().hash != prepend.get_identifier().hash
150+
119151
def test_identifier_is_cached(self):
120152
mock_attack = MagicMock(spec=AttackStrategy)
121153
mock_attack.get_identifier.return_value = ComponentIdentifier(

tests/unit/setup/techniques/test_core_techniques.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@ def test_factory_shape(self):
4242
assert factory.seed_technique is not None
4343
seed = factory.seed_technique.seeds[0]
4444
assert seed.role == "system"
45-
assert seed.sequence == -1
45+
assert seed.sequence == 0
4646
assert seed.is_general_technique is True
47+
assert factory.seed_technique.prompt_placement == "prepend"
4748
assert "flipping each word" in seed.value
4849

4950
def test_merges_onto_group_with_user_turn_at_sequence_zero(self):

0 commit comments

Comments
 (0)