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
2627class 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 )
0 commit comments