Skip to content

Commit d7b9576

Browse files
rlundeen2Copilot
andauthored
FIX: Adaptive Scenario bugs (#2152)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent e151cce commit d7b9576

13 files changed

Lines changed: 353 additions & 44 deletions

File tree

pyrit/executor/attack/multi_turn/simulated_conversation.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from __future__ import annotations
1212

1313
import logging
14+
import uuid
1415
from typing import TYPE_CHECKING
1516

1617
from pyrit.executor.attack.core.attack_config import (
@@ -211,17 +212,23 @@ async def _generate_next_message_async(
211212
conversation_context=conversation_context,
212213
)
213214

214-
# Use the adversarial chat to generate the next message
215-
# Create a simple user message asking for generation
215+
# Use the adversarial chat to generate the next message.
216+
# Scope the system prompt and the generated message to their own fresh
217+
# conversation. Message.from_prompt leaves conversation_id unset (None);
218+
# passing that to set_system_prompt would make get_conversation_messages
219+
# skip its conversation filter and return every piece in memory, which
220+
# raises once memory holds more than one conversation.
221+
conversation_id = str(uuid.uuid4())
216222
request_message = Message.from_prompt(
217223
role="user",
218224
prompt="Generate the next user message based on the instructions above.",
219225
)
226+
request_message.message_pieces[0].conversation_id = conversation_id
220227

221228
# Set the system prompt on the target
222229
adversarial_chat.set_system_prompt(
223230
system_prompt=system_prompt,
224-
conversation_id=request_message.conversation_id,
231+
conversation_id=conversation_id,
225232
)
226233

227234
responses: list[Message] = await adversarial_chat.send_prompt_async(message=request_message)

pyrit/memory/memory_interface.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,7 +958,14 @@ def get_conversation_messages(self, *, conversation_id: str) -> MutableSequence[
958958
959959
Returns:
960960
MutableSequence[Message]: A list of chat memory entries with the specified conversation ID.
961+
962+
Raises:
963+
ValueError: If conversation_id is empty or None. A falsy id would cause the underlying
964+
get_message_pieces filter to be skipped, silently returning pieces from every
965+
conversation in memory.
961966
"""
967+
if not conversation_id:
968+
raise ValueError("get_conversation_messages requires a non-empty conversation_id")
962969
message_pieces = self.get_message_pieces(conversation_id=conversation_id)
963970
return group_conversation_message_pieces_by_sequence(message_pieces=message_pieces)
964971

pyrit/scenario/core/dataset_configuration.py

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -622,16 +622,23 @@ async def _build_groups_by_dataset_async(self) -> tuple[dict[str, list[SeedAttac
622622
)
623623
return groups_by_dataset, resolved
624624

625-
async def get_seed_attack_groups_async(self) -> list[SeedAttackGroup]:
625+
async def get_seed_attack_groups_async(self, *, apply_sampling: bool = True) -> list[SeedAttackGroup]:
626626
"""
627627
Resolve the configured dataset into a flat ``list[SeedAttackGroup]``.
628628
629629
Builds attack groups (inline or from memory, auto-fetching missing datasets),
630630
validates the full resolved seed set, then samples ``max_dataset_size`` globally
631631
over all built groups.
632632
633+
Args:
634+
apply_sampling (bool): When True (default), apply ``max_dataset_size`` sampling.
635+
Pass False to resolve the full, deterministic dataset with no ``random.sample``
636+
draw -- used on resume so the persisted objective subset can be reconstructed
637+
exactly rather than intersected against a fresh (divergent) sample.
638+
633639
Returns:
634-
list[SeedAttackGroup]: The validated, sampled attack groups.
640+
list[SeedAttackGroup]: The validated attack groups (sampled when ``apply_sampling``
641+
is True, otherwise the full resolved set).
635642
636643
Raises:
637644
DatasetConstraintError: If a configured dataset yields no seeds, the resolved
@@ -640,13 +647,16 @@ async def get_seed_attack_groups_async(self) -> list[SeedAttackGroup]:
640647
groups_by_dataset, resolved = await self._build_groups_by_dataset_async()
641648
self.validate(resolved)
642649
groups = [group for groups in groups_by_dataset.values() for group in groups]
643-
groups = self._apply_max_dataset_size(groups)
650+
if apply_sampling:
651+
groups = self._apply_max_dataset_size(groups)
644652
if not groups:
645653
names = ", ".join(self._dataset_names) if self._dataset_names else "<inline>"
646654
raise DatasetConstraintError(f"Resolved attack-group dataset is empty (datasets: {names}).")
647655
return groups
648656

649-
async def get_attack_groups_by_dataset_async(self) -> dict[str, list[SeedAttackGroup]]:
657+
async def get_attack_groups_by_dataset_async(
658+
self, *, apply_sampling: bool = True
659+
) -> dict[str, list[SeedAttackGroup]]:
650660
"""
651661
Resolve attack groups keyed by dataset name, globally sampled.
652662
@@ -656,16 +666,24 @@ async def get_attack_groups_by_dataset_async(self) -> dict[str, list[SeedAttackG
656666
keyed by their originating dataset. For an independent budget per dataset, compose
657667
``CompoundDatasetAttackConfiguration.per_dataset(...)`` instead.
658668
669+
Args:
670+
apply_sampling (bool): When True (default), apply ``max_dataset_size`` sampling.
671+
Pass False to resolve the full, deterministic dataset with no ``random.sample``
672+
draw -- used on resume so the persisted objective subset can be reconstructed
673+
exactly rather than intersected against a fresh (divergent) sample.
674+
659675
Returns:
660-
dict[str, list[SeedAttackGroup]]: Dataset name -> sampled attack groups.
676+
dict[str, list[SeedAttackGroup]]: Dataset name -> attack groups (sampled when
677+
``apply_sampling`` is True, otherwise the full resolved set).
661678
662679
Raises:
663680
DatasetConstraintError: If a configured dataset yields no seeds, the resolved
664681
dataset fails validation, or no attack groups could be built.
665682
"""
666683
groups_by_dataset, resolved = await self._build_groups_by_dataset_async()
667684
self.validate(resolved)
668-
result = {name: groups for name, groups in self._sample_groups_by_dataset(groups_by_dataset).items() if groups}
685+
sampled = self._sample_groups_by_dataset(groups_by_dataset) if apply_sampling else groups_by_dataset
686+
result = {name: groups for name, groups in sampled.items() if groups}
669687
if not result:
670688
names = ", ".join(self._dataset_names) if self._dataset_names else "<inline>"
671689
raise DatasetConstraintError(f"Resolved attack-group dataset is empty (datasets: {names}).")
@@ -819,29 +837,42 @@ def update_filters(self, *, filters: dict[str, list[str]]) -> None:
819837
for child in self._configurations:
820838
child.update_filters(filters=filters)
821839

822-
async def get_seed_attack_groups_async(self) -> list[SeedAttackGroup]:
840+
async def get_seed_attack_groups_async(self, *, apply_sampling: bool = True) -> list[SeedAttackGroup]:
823841
"""
824842
Concatenate every child's flat result, then validate and apply the global cap.
825843
826844
Each child validates and samples itself; the combined result is validated against this
827845
compound's validators and capped by an optional compound ``max_dataset_size``.
828846
847+
Args:
848+
apply_sampling (bool): When True (default), sample both each child and the combined
849+
result under ``max_dataset_size``. Pass False to resolve the full, deterministic
850+
dataset with no sampling at any level -- used on resume (propagated to children).
851+
829852
Returns:
830-
list[SeedAttackGroup]: The combined, validated, capped attack groups.
853+
list[SeedAttackGroup]: The combined, validated attack groups (capped when
854+
``apply_sampling`` is True).
831855
832856
Raises:
833857
DatasetConstraintError: If a child yields nothing, or the combined result fails validation.
834858
"""
835859
groups: list[SeedAttackGroup] = []
836860
for child in self._configurations:
837-
groups.extend(await child.get_seed_attack_groups_async())
861+
groups.extend(await child.get_seed_attack_groups_async(apply_sampling=apply_sampling))
838862
self.validate(self._resolved_from_groups(groups))
839-
return self._apply_max_dataset_size(groups)
863+
return self._apply_max_dataset_size(groups) if apply_sampling else groups
840864

841-
async def get_attack_groups_by_dataset_async(self) -> dict[str, list[SeedAttackGroup]]:
865+
async def get_attack_groups_by_dataset_async(
866+
self, *, apply_sampling: bool = True
867+
) -> dict[str, list[SeedAttackGroup]]:
842868
"""
843869
Merge each child's by-dataset result, validate, then apply the global cap across the union.
844870
871+
Args:
872+
apply_sampling (bool): When True (default), sample both each child and the merged
873+
union under ``max_dataset_size``. Pass False to resolve the full, deterministic
874+
dataset with no sampling at any level -- used on resume (propagated to children).
875+
845876
Returns:
846877
dict[str, list[SeedAttackGroup]]: Combined groups keyed by dataset name.
847878
@@ -850,10 +881,11 @@ async def get_attack_groups_by_dataset_async(self) -> dict[str, list[SeedAttackG
850881
"""
851882
merged: dict[str, list[SeedAttackGroup]] = {}
852883
for child in self._configurations:
853-
for name, groups in (await child.get_attack_groups_by_dataset_async()).items():
884+
child_groups = await child.get_attack_groups_by_dataset_async(apply_sampling=apply_sampling)
885+
for name, groups in child_groups.items():
854886
merged.setdefault(name, []).extend(groups)
855887
self.validate(self._resolved_from_groups([group for groups in merged.values() for group in groups]))
856-
return self._sample_groups_by_dataset(merged)
888+
return self._sample_groups_by_dataset(merged) if apply_sampling else merged
857889

858890
def _resolved_from_groups(self, groups: list[SeedAttackGroup]) -> ResolvedDataset:
859891
"""

pyrit/scenario/core/scenario.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,13 @@ async def initialize_async(self) -> None:
612612
# into a ScenarioContext, and hand it to the subclass extension point. Baseline is
613613
# emitted centrally (from context.seed_groups) so overrides never re-resolve seeds
614614
# or hand-roll baseline emission.
615-
seed_groups_by_dataset = await self._resolve_seed_groups_by_dataset_async()
615+
#
616+
# On resume, resolve the full, deterministic dataset (no max_dataset_size sampling):
617+
# the originally-sampled subset was snapshotted into the ScenarioResult metadata and is
618+
# replayed by _apply_persisted_objectives. Re-drawing a fresh random.sample here would
619+
# diverge from the persisted hashes and abort resume whenever max_dataset_size is set.
620+
is_resume = self._scenario_result_id is not None
621+
seed_groups_by_dataset = await self._resolve_seed_groups_by_dataset_async(apply_sampling=not is_resume)
616622
context = self._build_scenario_context(seed_groups_by_dataset=seed_groups_by_dataset)
617623
self._atomic_attacks = await self._build_atomic_attacks_async(context=context)
618624

@@ -699,12 +705,12 @@ def _apply_persisted_objectives(self, *, stored_result: ScenarioResult) -> None:
699705
On resume, replay the originally-sampled objective subset.
700706
701707
When the first run used ``max_dataset_size``, the chosen subset was
702-
recorded in ``ScenarioResult.metadata["objective_hashes"]``.
703-
Restrict each atomic attack's freshly-resolved seed_groups to that set
704-
so a fresh ``random.sample`` draw on resume can't silently shift which
705-
objectives the scenario operates on. If any persisted hash is no longer
706-
present in the dataset, refuse to resume — running a smaller subset
707-
than the user committed to would silently produce different results.
708+
recorded in ``ScenarioResult.metadata["objective_hashes"]``. Resume resolves
709+
the **full, deterministic** dataset (sampling is bypassed on the resume branch of
710+
``initialize_async``), so restricting each atomic attack's seed_groups to the
711+
persisted set here reconstructs exactly the objectives the first run committed to.
712+
If any persisted hash is no longer present in the dataset, refuse to resume — that
713+
now signals the dataset itself genuinely changed, not a random resample drift.
708714
709715
Args:
710716
stored_result (ScenarioResult): The scenario result loaded from memory.
@@ -929,7 +935,9 @@ async def _get_remaining_atomic_attacks_async(self) -> list[AtomicAttack]:
929935

930936
return remaining_attacks
931937

932-
async def _resolve_seed_groups_by_dataset_async(self) -> dict[str, list[SeedAttackGroup]]:
938+
async def _resolve_seed_groups_by_dataset_async(
939+
self, *, apply_sampling: bool = True
940+
) -> dict[str, list[SeedAttackGroup]]:
933941
"""
934942
Resolve the seed groups this scenario attacks, keyed by originating dataset.
935943
@@ -941,10 +949,17 @@ async def _resolve_seed_groups_by_dataset_async(self) -> dict[str, list[SeedAtta
941949
Override to inject seeds from an alternate source (e.g. deprecated ``objectives``)
942950
or to filter the resolved groups before attacks are built.
943951
952+
Args:
953+
apply_sampling (bool): When True (default), apply ``max_dataset_size`` sampling.
954+
On resume the base passes False so the full, deterministic dataset is resolved
955+
and the persisted objective subset is reconstructed exactly (see
956+
``_apply_persisted_objectives``) rather than intersected against a fresh,
957+
divergent ``random.sample`` draw.
958+
944959
Returns:
945960
dict[str, list[SeedAttackGroup]]: Seed groups keyed by dataset name.
946961
"""
947-
return await self._dataset_config.get_attack_groups_by_dataset_async()
962+
return await self._dataset_config.get_attack_groups_by_dataset_async(apply_sampling=apply_sampling)
948963

949964
def _build_scenario_context(self, *, seed_groups_by_dataset: dict[str, list[SeedAttackGroup]]) -> ScenarioContext:
950965
"""

pyrit/scenario/scenarios/airt/psychosocial.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,9 @@ def __init__(
235235
# Store deprecated objectives for later resolution in _resolve_seed_groups_by_dataset_async
236236
self._deprecated_objectives = objectives
237237

238-
async def _resolve_seed_groups_by_dataset_async(self) -> dict[str, list[SeedAttackGroup]]:
238+
async def _resolve_seed_groups_by_dataset_async(
239+
self, *, apply_sampling: bool = True
240+
) -> dict[str, list[SeedAttackGroup]]:
239241
"""
240242
Resolve seed groups from deprecated objectives or dataset configuration.
241243
@@ -244,6 +246,12 @@ async def _resolve_seed_groups_by_dataset_async(self) -> dict[str, list[SeedAtta
244246
category. The base ``Scenario`` flattens the result into ``context.seed_groups`` and
245247
reuses it for the technique attacks and the baseline.
246248
249+
Args:
250+
apply_sampling (bool): When True (default), apply ``max_dataset_size`` sampling.
251+
On resume the base passes False so the full, deterministic dataset is resolved
252+
and the persisted objective subset is reconstructed exactly. Inline deprecated
253+
objectives are never sampled.
254+
247255
Returns:
248256
dict[str, list[SeedAttackGroup]]: Seed groups keyed by dataset (or a synthetic
249257
key for deprecated inline objectives).
@@ -267,7 +275,7 @@ async def _resolve_seed_groups_by_dataset_async(self) -> dict[str, list[SeedAtta
267275
harm_category_filter = self._extract_harm_category_filter()
268276
# Auto-fetch populates memory first; a still-empty result raises a
269277
# DatasetConstraintError naming the offending dataset, which we let propagate.
270-
seed_groups = list(await self._dataset_config.get_seed_attack_groups_async())
278+
seed_groups = list(await self._dataset_config.get_seed_attack_groups_async(apply_sampling=apply_sampling))
271279

272280
if harm_category_filter:
273281
seed_groups = self._filter_by_harm_category(

pyrit/scenario/scenarios/garak/web_injection.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,9 @@ def _scoring_config_for_technique(self, technique: WebInjectionTechnique) -> Att
498498
return self._xss_scoring_config
499499
return self._exfil_scoring_config
500500

501-
async def _resolve_seed_groups_by_dataset_async(self) -> dict[str, list[SeedAttackGroup]]:
501+
async def _resolve_seed_groups_by_dataset_async(
502+
self, *, apply_sampling: bool = True
503+
) -> dict[str, list[SeedAttackGroup]]:
502504
"""
503505
Generate the injection prompts and wrap them into seed groups, keyed by technique.
504506
@@ -507,6 +509,11 @@ async def _resolve_seed_groups_by_dataset_async(self) -> dict[str, list[SeedAtta
507509
set from the raw garak datasets. Resolving them here means the base owns the single
508510
seed sample used for both the atomic attacks and the central baseline.
509511
512+
Args:
513+
apply_sampling (bool): Accepted for base-class compatibility but unused — the
514+
synthesized seeds are already deterministic (``random.Random(self._random_seed)``),
515+
so resume reproduces the same set without a ``max_dataset_size`` sampling path.
516+
510517
Returns:
511518
dict[str, list[SeedAttackGroup]]: Seed groups keyed by technique value.
512519

0 commit comments

Comments
 (0)