FEAT Beam Search for OpenAIResponseTarget#1346
Conversation
riedgar-ms
left a comment
There was a problem hiding this comment.
This is ready for preliminary review; there aren't any docs or (proper) tests yet. I'd like to make sure that I'm manipulating the database correctly before delving into those.
| target = self._get_target_for_beam(beam) | ||
|
|
||
| current_context = copy.deepcopy(self._start_context) | ||
| await self._setup_async(context=current_context) |
There was a problem hiding this comment.
I'm not certain I'm handling the context correctly here. I end up making lots of copies of things, which is going to be filling up the database with fragmentary responses. Each time one is extended, it ends up being cloned and a new conversation started.
| Args: | ||
| context (SingleTurnAttackContext): The attack context containing attack parameters. | ||
| """ | ||
| self._start_context = copy.deepcopy(context) |
There was a problem hiding this comment.
See note below. I duplicate the context and the message for each beam on each iteration. I'm not certain that this is the best way to use the database.
|
Ready for review, but I will need help running the notebook prior to merge. |
romanlutz
left a comment
There was a problem hiding this comment.
Thanks for your patience with this one! I'm busy with the v1 release until EOM but hoping to get this in as soon as v1 is out (~mid-week). Some of these comments are questions/concerns that came up from reviewing with GHCP. Nothing is set in stone, of course, and I'm happy to be convinced otherwise.
| self._logger.debug(f"Beam {i} score: {beam.score}") | ||
|
|
||
| # Sort the list of beams | ||
| beams = sorted(beams, key=lambda b: b.score, reverse=True) |
There was a problem hiding this comment.
The final candidate is selected only by auxiliary score, and then only that beam's objective score determines the outcome. A lower-ranked beam can have achieved the objective while this returns FAILURE and marks that successful conversation as pruned. Please prefer an objective-success beam (and ideally stop once one succeeds), using auxiliary score only to rank ties or unsuccessful candidates.
There was a problem hiding this comment.
The problem I see is that the objective_scorer is marked as a TrueFalseScorer. To do the hill climbing implicit in beam search, you need a floating point output, so you can do things more gradually, hence my use of the auxiliary scorers.
| raise ValueError("BeamSearchAttack requires all auxiliary scorers to be instances of FloatScaleScorer") | ||
|
|
||
| self._auxiliary_scorers = attack_scoring_config.auxiliary_scorers | ||
| self._objective_scorer = attack_scoring_config.objective_scorer |
There was a problem hiding this comment.
This attack uses scoring but does not override get_attack_scoring_config, and its identifier omits the beam count, iteration count, continuation size, and reviewer policy. Substantially different beam-search configurations therefore produce the same identifier. Please include the scoring configuration and behavior-defining beam parameters so persisted results remain attributable and reproducible.
There was a problem hiding this comment.
Addressed in f927b6de. BeamSearchAttack now exposes its scoring configuration and includes the beam count, iteration count, continuation size, and an identifiable reviewer policy in its identifier. Tests verify that changing the search or reviewer configuration changes the identifier.
| for piece in model_response.message_pieces | ||
| if isinstance(getattr(piece, "converted_value", None), str) | ||
| ] | ||
| beam.text = "".join( |
There was a problem hiding this comment.
PromptNormalizer has already applied response converters here. Feeding converted_value back as the next raw grammar prefix means Base64, ROT13, translation, and similar converters constrain the model to continue the transformed representation rather than its original output. Please build the continuation prefix from original_value, or reject response converters for this attack.
There was a problem hiding this comment.
Addressed in f927b6de. Beam continuation prefixes are now built from each response piece's original_value, while the converted response remains available for scoring and persistence. I also added a regression test covering differing original and converted values.
| objective=context.objective, | ||
| ) | ||
|
|
||
| aux_scores = scoring_results["auxiliary_scores"] |
There was a problem hiding this comment.
Auxiliary scorers are defined as additional metrics/custom evaluations; adding one should not change attack control flow. Here their values are summed and used as the beam-search policy, so an observability scorer changes which candidates survive and which result is returned. Arbitrary float scorers can also have incompatible scales and semantics, making the unweighted sum ill-defined. Is it possible to follow the TAP pattern (?): introduce a beam-search scoring config whose objective scorer is a FloatScaleThresholdScorer, use its underlying float value to rank partial beams and its thresholded result to determine success, and keep auxiliary scores telemetry-only.
There was a problem hiding this comment.
That might be the solution to the issue I pointed out above.
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (2)
pyrit/executor/attack/single_turn/beam_search.py:268
_setup_asyncis invoked once by the strategy lifecycle, but it is also called from_propagate_beam_asyncfor each beam. As written, it overwritesself._start_contextevery time, which can be clobbered by concurrent beam propagation and change the starting context mid-attack.
Only capture _start_context on the initial setup.
self._start_context = copy.deepcopy(context)
pyrit/executor/attack/single_turn/beam_search.py:376
beam.idis updated before the model call succeeds. If the call fails, the exception handler leavesbeam.response_message/beam.textfrom a prior iteration but with a newbeam.id, which breaks traceability (conversation_id no longer matches the stored response) and can cause stale responses to be treated as current.
Update beam.id only after a successful response is obtained.
message = self._get_message(current_context)
beam.id = current_context.conversation_id
try:
with execution_context(
- Files reviewed: 9/9 changed files
- Comments generated: 2
- Review effort level: Low
| @pytest.mark.asyncio | ||
| async def test_build_input_for_multi_modal_async_filters_reasoning(target: OpenAIResponseTarget): |
There was a problem hiding this comment.
Addressed in f927b6de. Removed @pytest.mark.asyncio and now rely on the repository's automatic asyncio discovery.
| Args: | ||
| extra_body_parameters (Optional[dict[str, Any]]): Optional overrides for the | ||
| extra body parameters of the new instance. | ||
| grammar_name (Optional[str]): Optional override for the grammar name of the | ||
| new instance. |
There was a problem hiding this comment.
Addressed in f927b6de. Updated the fresh_instance docstring to use dict[str, Any] | None and str | None.
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (4)
pyrit/executor/attack/single_turn/beam_search.py:144
- TopKBeamReviewer uses deepcopy() to create replacement beams, which preserves prior iteration state (score, objective_score, response_message). If propagation fails in a later iteration, these copied beams can be (re)scored using stale response_message/objective_score that no longer matches the (possibly truncated) text, producing incorrect beam selection.
for i in range(_extra_beam_count - len(new_beams)):
nxt = copy.deepcopy(new_beams[i % self.k])
if self.drop_chars > 0 and len(nxt.text) > self.drop_chars:
nxt.text = nxt.text[: -self.drop_chars]
new_beams.append(nxt)
pyrit/executor/attack/single_turn/beam_search.py:371
- _propagate_beam_async calls _setup_async for each beam while beams are propagated concurrently (asyncio.gather). _setup_async mutates shared instance state (self._start_context) and the passed context.conversation_id, which can cause race conditions and cross-talk between beams. Each beam should initialize its own context without re-running the strategy-level setup.
if self._start_context is None:
raise ValueError("Start context must be set before propagating beams")
target = self._get_target_for_beam(beam)
current_context = copy.deepcopy(self._start_context)
await self._setup_async(context=current_context)
pyrit/prompt_target/openai/openai_response_target.py:233
- fresh_instance docstring uses legacy Optional[...] type syntax in the Args section. The style guide requires modern
X | None/ built-in generics in docstrings and comments as well.
Args:
extra_body_parameters (Optional[dict[str, Any]]): Optional overrides for the
extra body parameters of the new instance.
grammar_name (Optional[str]): Optional override for the grammar name of the
new instance.
tests/unit/prompt_target/target/test_openai_response_target.py:722
- Project test instructions explicitly discourage using
@pytest.mark.asynciobecauseasyncio_mode = "auto"is configured repo-wide. This decorator should be removed to match the established test convention.
@pytest.mark.asyncio
async def test_build_input_for_multi_modal_async_filters_reasoning(target: OpenAIResponseTarget):
- Files reviewed: 9/9 changed files
- Comments generated: 2
- Review effort level: Low
| lark_grammar = beam.get_grammar(n_chars=self._num_chars_per_step) | ||
|
|
||
| grammar_tool = { | ||
| "type": "custom", | ||
| "name": "ContinuationGrammar", | ||
| "description": "Forces continuation of the given prefix.", | ||
| "format": { | ||
| "type": "grammar", | ||
| "syntax": "lark", | ||
| "definition": lark_grammar, | ||
| }, | ||
| } | ||
|
|
||
| reasoning = {"effort": "minimal"} | ||
|
|
||
| ebp = { | ||
| "reasoning": reasoning, | ||
| "tools": [grammar_tool], | ||
| "tool_choice": "required", | ||
| } | ||
|
|
||
| return self._objective_target.fresh_instance(extra_body_parameters=ebp, grammar_name=str(grammar_tool["name"])) |
| init_args: dict[str, Any] = deepcopy(self._init_args) | ||
| if extra_body_parameters is not None: | ||
| init_args["extra_body_parameters"] = deepcopy(extra_body_parameters) | ||
| result = OpenAIResponseTarget(**init_args) | ||
| if grammar_name is not None: | ||
| result._grammar_name = grammar_name | ||
| return result |
There was a problem hiding this comment.
Addressed in f927b6de. A grammar_name override now updates the grammar tool itself before constructing the fresh target, keeping the tool name and _grammar_name synchronized. It also raises an explicit error unless exactly one grammar tool is present.
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (4)
tests/unit/prompt_target/target/test_openai_response_target.py:721
@pytest.mark.asyncioshould not be used in this repo becauseasyncio_mode = "auto"is configured globally; the decorator is redundant and conflicts with the testing guidelines. Remove the marker and keep the test as a plainasync def.
@pytest.mark.asyncio
pyrit/prompt_target/openai/openai_response_target.py:233
- The
fresh_instancedocstring uses legacyOptional[...]type syntax, but this repo’s style guide requires modernX | Nonetypes in docstrings/comments as well. Update these arg type annotations to match the signature.
Args:
extra_body_parameters (Optional[dict[str, Any]]): Optional overrides for the
extra body parameters of the new instance.
grammar_name (Optional[str]): Optional override for the grammar name of the
new instance.
pyrit/executor/attack/single_turn/beam_search.py:374
_propagate_beam_asyncupdatesbeam.idbefore the model call but does not clearbeam.response_message/beam.scoreon failure. If a later propagation fails, the beam can retain stale response/score from a previous iteration while pointing at a new conversation_id, which breaks traceability and can cause the sorter to pick a beam with no valid response. Reset per-beam state before attempting propagation.
message = self._get_message(current_context)
beam.id = current_context.conversation_id
pyrit/executor/attack/single_turn/init.py:6
- This import line is likely to exceed the project’s line-length limit and will trip format/lint checks. Split it into a parenthesized multi-line import to keep the module ruff/isort compliant.
from pyrit.executor.attack.single_turn.beam_search import Beam, BeamReviewer, BeamSearchAttack, TopKBeamReviewer
- Files reviewed: 9/9 changed files
- Comments generated: 2
- Review effort level: Low
| _extra_beam_count = self.desired_beam_count or len(beams) | ||
|
|
||
| for i in range(_extra_beam_count - len(new_beams)): | ||
| nxt = copy.deepcopy(new_beams[i % self.k]) | ||
| if self.drop_chars > 0 and len(nxt.text) > self.drop_chars: |
There was a problem hiding this comment.
Addressed in f927b6de. Beam replication now uses the number of retained beams rather than k, preventing an IndexError when fewer than k beams are available. A boundary regression test was added.
| attack_converter_config (Optional[AttackConverterConfig]): Configuration for prompt converters. | ||
| attack_scoring_config (Optional[AttackScoringConfig]): Configuration for scoring components. | ||
| prompt_normalizer (Optional[PromptNormalizer]): The prompt normalizer to use. | ||
| params_type (Type[AttackParamsT]): The type of attack parameters to use. | ||
| prepended_conversation_config (Optional[PrependedConversationConfig]): Configuration for prepended |
There was a problem hiding this comment.
Review details
Comments suppressed due to low confidence (6)
pyrit/prompt_target/openai/openai_response_target.py:233
- The docstring uses legacy
Optional[...]type syntax, but the project style guide requires modern union syntax in docstrings too (e.g.,dict[str, Any] | None).
Args:
extra_body_parameters (Optional[dict[str, Any]]): Optional overrides for the
extra body parameters of the new instance.
grammar_name (Optional[str]): Optional override for the grammar name of the
new instance.
pyrit/executor/attack/single_turn/beam_search.py:142
TopKBeamReviewer.reviewcan raiseIndexErrorwhenkis larger than the number of beams (e.g.,new_beamsis shorter thanself.k, but the code indexesnew_beams[i % self.k]). This should degrade gracefully by clampingkto the available beam count.
_extra_beam_count = self.desired_beam_count or len(beams)
for i in range(_extra_beam_count - len(new_beams)):
nxt = copy.deepcopy(new_beams[i % self.k])
if self.drop_chars > 0 and len(nxt.text) > self.drop_chars:
pyrit/executor/attack/single_turn/beam_search.py:373
_propagate_beam_asynccalls_setup_async, which mutates shared attack state (self._start_context) and is invoked concurrently viaasyncio.gather. This creates a race condition and can cause beams to stomp each other's start context. Instead, initialize a per-beam context without modifyingself._start_context.
current_context = copy.deepcopy(self._start_context)
await self._setup_async(context=current_context)
message = self._get_message(current_context)
beam.id = current_context.conversation_id
pyrit/executor/attack/single_turn/beam_search.py:104
- This docstring uses
Optional[...]type syntax; the project style guide requires modern union syntax in docstrings (e.g.,int | None).
desired_beam_count (Optional[int]): The desired total number of beams after review.
If None, it will be set to the supplied number of beams.
pyrit/executor/attack/single_turn/beam_search.py:182
- This docstring uses
Optional[...]type syntax; the project style guide requires modern union syntax in docstrings (e.g.,AttackConverterConfig | None).
attack_converter_config (Optional[AttackConverterConfig]): Configuration for prompt converters.
attack_scoring_config (Optional[AttackScoringConfig]): Configuration for scoring components.
prompt_normalizer (Optional[PromptNormalizer]): The prompt normalizer to use.
pyrit/executor/attack/single_turn/beam_search.py:185
- This docstring uses
Optional[...]type syntax; the project style guide requires modern union syntax in docstrings (e.g.,PrependedConversationConfig | None).
prepended_conversation_config (Optional[PrependedConversationConfig]): Configuration for prepended
conversation.
- Files reviewed: 9/9 changed files
- Comments generated: 2
- Review effort level: Low
| Returns: | ||
| tuple[AttackOutcome, Optional[str]]: A tuple of (outcome, outcome_reason). |
| # Copyright (c) Microsoft Corporation. | ||
| # Licensed under the MIT license. | ||
|
|
||
| """Singe turn attack strategies module.""" |
Address concurrency/state-sharing review comments on the beam-search attack: - Remove the shared self._start_context field; _perform_async now builds a per-execution base_context local and passes it into _propagate_beam_async, so concurrent executions of the reused strategy instance no longer clobber each other's objective. - fresh_instance now merges extra_body_parameters onto the stored values instead of replacing them, preserving base settings (store, metadata, routing) while overriding only beam-specific keys. - Clear per-iteration beam result state before each send and narrow the swallowed exception to PyritException so a failed propagation cannot win or report success with a stale response, and unexpected config/target errors propagate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Description
Use the Lark grammar feature of the
OpenAIResponseTargetto create a beam search for PyRIT. This is a single turn attack, where a collection of candidate responses (the beams) are maintained. On each iteration, the model's response is allowed to extend a little for each beam. The beams are scored, with the worst performing ones discarded, and replaced with copies of higher scoring beams.Tests and Documentation
Have basic unit tests of the classes added, but since this requires features only currently in the
OpenAIResponseTargetthere didn't seem much point in mocking that. There is a notebook which runs everything E2E.