Skip to content

Commit 044b50f

Browse files
rlundeen2Copilot
andauthored
FEAT: Better Scenario Tracking (#1758)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent be0baaa commit 044b50f

23 files changed

Lines changed: 2163 additions & 449 deletions

.github/instructions/style-guide.instructions.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,26 @@ def process_items(self, *, items: list[str]) -> list[str]:
293293
return []
294294
```
295295

296+
## Deprecations
297+
298+
When deprecating a public class, function, method, parameter, or module path, use `pyrit.common.deprecation.print_deprecation_message` — never `warnings.warn` directly. It wraps `warnings.warn(..., DeprecationWarning, stacklevel=3)` with a consistent format so filtering still works.
299+
300+
Set `removed_in` to **current version + 2 minor versions** (e.g. `0.14.x``removed_in="0.16.0"`). This gives one full release cycle of warning before removal.
301+
302+
```python
303+
from pyrit.common.deprecation import print_deprecation_message
304+
305+
def old_method(self, *, foo: str) -> None:
306+
print_deprecation_message(
307+
old_item="MyClass.old_method",
308+
new_item="MyClass.new_method",
309+
removed_in="0.16.0",
310+
)
311+
...
312+
```
313+
314+
`old_item` / `new_item` accept a class/callable (qualified name is generated) or a string.
315+
296316
## Pythonic Patterns
297317

298318
### List Comprehensions

pyrit/backend/services/scenario_run_service.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -409,9 +409,13 @@ def _build_response_from_db(self, *, scenario_result: ScenarioResult) -> Scenari
409409
error = scenario_result.error_message
410410
error_type = scenario_result.error_type
411411

412-
# Fallback: look up error from persisted error AttackResults
413-
if not error and scenario_result.error_attack_result_ids:
414-
error_ars = self._memory.get_attack_results(attack_result_ids=scenario_result.error_attack_result_ids)
412+
# Fallback: look up error from any persisted error AttackResults linked
413+
# to this scenario via the new attribution_parent_id foreign key.
414+
if not error:
415+
error_ars = self._memory.get_attack_results(
416+
scenario_result_id=scenario_result_id,
417+
outcome=AttackOutcome.ERROR,
418+
)
415419
if error_ars:
416420
error = error_ars[0].error_message
417421
error_type = error_ars[0].error_type

pyrit/executor/attack/core/attack_executor.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
)
2020

2121
from pyrit.executor.attack.core.attack_parameters import AttackParameters
22+
from pyrit.executor.attack.core.attack_result_attribution import AttackResultAttribution
2223
from pyrit.executor.attack.core.attack_strategy import (
2324
AttackStrategy,
2425
AttackStrategyContextT,
@@ -142,6 +143,7 @@ async def execute_attack_from_seed_groups_async(
142143
objective_scorer: Optional["TrueFalseScorer"] = None,
143144
field_overrides: Optional[Sequence[dict[str, Any]]] = None,
144145
return_partial_on_failure: bool = False,
146+
attribution: Optional[AttackResultAttribution] = None,
145147
**broadcast_fields: Any,
146148
) -> AttackExecutorResult[AttackStrategyResultT]:
147149
"""
@@ -163,6 +165,12 @@ async def execute_attack_from_seed_groups_async(
163165
from_seed_group() as overrides.
164166
return_partial_on_failure: If True, returns partial results when some
165167
objectives fail. If False (default), raises the first exception.
168+
attribution: Optional ``AttackResultAttribution`` stamped onto every
169+
per-task ``AttackContext`` so the persisted ``AttackResultEntry``
170+
row carries ``attribution_parent_id`` + ``attribution_data``.
171+
When ``None`` (default), no attribution is applied. The same
172+
attribution is shared across all tasks; per-task identity is
173+
reconstructed from the row's own ``objective_sha256``.
166174
**broadcast_fields: Fields applied to all seed groups (e.g., memory_labels).
167175
Per-seed-group field_overrides take precedence.
168176
@@ -205,6 +213,7 @@ async def build_params(i: int, sg: SeedAttackGroup) -> AttackParameters:
205213
attack=attack,
206214
params_list=params_list,
207215
return_partial_on_failure=return_partial_on_failure,
216+
attribution=attribution,
208217
)
209218

210219
async def execute_attack_async(
@@ -214,6 +223,7 @@ async def execute_attack_async(
214223
objectives: Sequence[str],
215224
field_overrides: Optional[Sequence[dict[str, Any]]] = None,
216225
return_partial_on_failure: bool = False,
226+
attribution: Optional[AttackResultAttribution] = None,
217227
**broadcast_fields: Any,
218228
) -> AttackExecutorResult[AttackStrategyResultT]:
219229
"""
@@ -228,6 +238,9 @@ async def execute_attack_async(
228238
must match the length of objectives.
229239
return_partial_on_failure: If True, returns partial results when some
230240
objectives fail. If False (default), raises the first exception.
241+
attribution: Optional ``AttackResultAttribution`` stamped onto every
242+
per-task ``AttackContext`` so the persistence path can record
243+
orchestrator linkage. When ``None``, no attribution is applied.
231244
**broadcast_fields: Fields applied to all objectives (e.g., memory_labels).
232245
Per-objective field_overrides take precedence.
233246
@@ -268,6 +281,7 @@ async def execute_attack_async(
268281
attack=attack,
269282
params_list=params_list,
270283
return_partial_on_failure=return_partial_on_failure,
284+
attribution=attribution,
271285
)
272286

273287
async def _execute_with_params_list_async(
@@ -276,6 +290,7 @@ async def _execute_with_params_list_async(
276290
attack: AttackStrategy[AttackStrategyContextT, AttackStrategyResultT],
277291
params_list: Sequence[AttackParameters],
278292
return_partial_on_failure: bool = False,
293+
attribution: Optional[AttackResultAttribution] = None,
279294
) -> AttackExecutorResult[AttackStrategyResultT]:
280295
"""
281296
Execute attacks in parallel with a list of pre-built parameters.
@@ -287,19 +302,23 @@ async def _execute_with_params_list_async(
287302
attack: The attack strategy to execute.
288303
params_list: List of AttackParameters, one per execution.
289304
return_partial_on_failure: If True, returns partial results on failure.
305+
attribution: Optional ``AttackResultAttribution`` stamped onto every
306+
per-task ``AttackContext`` so the persistence path can record
307+
orchestrator linkage.
290308
291309
Returns:
292310
AttackExecutorResult with completed results and any incomplete objectives.
293311
"""
294312
semaphore = asyncio.Semaphore(self._max_concurrency)
295313

296-
async def run_one(params: AttackParameters) -> AttackStrategyResultT:
314+
async def run_one(index: int, params: AttackParameters) -> AttackStrategyResultT:
297315
async with semaphore:
298-
# Create context with params
299316
context = attack._context_type(params=params)
317+
if attribution is not None:
318+
context._attribution = attribution
300319
return await attack.execute_with_context_async(context=context)
301320

302-
tasks = [run_one(p) for p in params_list]
321+
tasks = [run_one(i, p) for i, p in enumerate(params_list)]
303322
results_or_exceptions = await asyncio.gather(*tasks, return_exceptions=True)
304323

305324
return self._process_execution_results(
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""
5+
Generic attribution metadata an orchestrator stamps onto an
6+
``AttackContext`` so the persisted ``AttackResult`` carries linkage back to
7+
whatever produced it.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from dataclasses import dataclass
13+
14+
15+
@dataclass(frozen=True)
16+
class AttackResultAttribution:
17+
"""
18+
Attribution copied onto an ``AttackResult`` by the persistence path so the
19+
DB row records its lineage.
20+
21+
All fields are opaque to the attack layer; the orchestrator chooses what
22+
they mean. Together, ``(parent_collection, parent_eval_hash)`` form the
23+
"what was running" key the orchestrator can use to scope per-parent
24+
work on resume. Two ``AtomicAttack`` instances sharing a name but using
25+
different techniques (e.g. base64 vs hex encoders) get distinct
26+
``parent_eval_hash`` values, so resume bookkeeping never cross-pollinates.
27+
28+
Attributes:
29+
parent_id (str): The ID of the parent entity. Persisted to
30+
``AttackResultEntry.attribution_parent_id`` (foreign key to
31+
``ScenarioResultEntries.id``) so per-parent loading is indexed.
32+
``Scenario`` sets this to the scenario result UUID, e.g.
33+
``self._scenario_result_id``
34+
(``"8a8d17b9-b671-4a3d-8170-e65ea9b44053"``).
35+
parent_collection (str): Free-form label naming the per-parent
36+
collection this result belongs to. Persisted into
37+
``AttackResultEntry.attribution_data``. ``Scenario`` sets this
38+
to the atomic attack name, e.g. ``self.atomic_attack_name``
39+
(``"encoded_jailbreaks"``).
40+
parent_eval_hash (str | None): Optional content-addressed hash that
41+
disambiguates configurations sharing the same
42+
``parent_collection``. Persisted into
43+
``AttackResultEntry.attribution_data``. ``Scenario`` sets this
44+
to the atomic attack's technique evaluation hash, e.g.
45+
``self.technique_eval_hash`` (computed via
46+
``AtomicAttackEvaluationIdentifier``).
47+
"""
48+
49+
parent_id: str
50+
parent_collection: str
51+
parent_eval_hash: str | None = None

pyrit/executor/attack/core/attack_strategy.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636

3737
if TYPE_CHECKING:
3838
from pyrit.executor.attack.core.attack_config import AttackScoringConfig
39+
from pyrit.executor.attack.core.attack_result_attribution import AttackResultAttribution
3940
from pyrit.prompt_target import PromptTarget
4041

4142
AttackStrategyContextT = TypeVar("AttackStrategyContextT", bound="AttackContext[Any]")
@@ -70,8 +71,12 @@ class AttackContext(StrategyContext, ABC, Generic[AttackParamsT]):
7071
_prepended_conversation_override: Optional[list[Message]] = None
7172
_memory_labels_override: Optional[dict[str, str]] = None
7273

73-
# Set by the ON_ERROR handler to link error AttackResults to ScenarioResults
74-
_error_attack_result_id: str | None = None
74+
# Optional attribution from an upstream orchestrator (e.g. Scenario). When
75+
# set, the persistence path stamps attribution_parent_id + attribution_data
76+
# onto the resulting AttackResult so it can be located later for hydration
77+
# and resume. Set by AttackExecutor per-task before scheduling. Stays None
78+
# for ad-hoc/direct attack execution outside any orchestrator.
79+
_attribution: Optional[AttackResultAttribution] = None
7580

7681
# Convenience properties that delegate to params or overrides
7782
@property
@@ -223,11 +228,46 @@ async def _on_post_execute(
223228
event_data.result.retry_events = collector.events
224229
event_data.result.total_retries = len(collector.events)
225230

231+
# Stamp attribution onto the result before persistence so the
232+
# AttackResultEntry row records its lineage. Outside an orchestrator
233+
# _attribution is None and both attribution fields stay None.
234+
self._apply_attribution(context=event_data.context, result=event_data.result)
235+
226236
self._logger.debug(f"Attack execution completed in {execution_time_ms}ms")
227237

228238
self._log_attack_outcome(event_data.result)
229239
self._memory.add_attack_results_to_memory(attack_results=[event_data.result])
230240

241+
@staticmethod
242+
def _apply_attribution(
243+
*,
244+
context: AttackStrategyContextT,
245+
result: AttackResult,
246+
) -> None:
247+
"""
248+
Copy attribution from the AttackContext onto the AttackResult.
249+
250+
Reads ``context._attribution`` (an ``AttackResultAttribution`` set by
251+
the AttackExecutor when an upstream orchestrator supplied a factory).
252+
When present, writes ``attribution_parent_id`` and a fixed-schema
253+
``attribution_data`` dict onto the result so they round-trip into
254+
``AttackResultEntry``.
255+
256+
Args:
257+
context: The per-task AttackContext.
258+
result: The AttackResult that is about to be persisted.
259+
"""
260+
attribution = context._attribution
261+
if attribution is None:
262+
return
263+
result.attribution_parent_id = attribution.parent_id
264+
attribution_data: dict[str, Any] = {
265+
"parent_collection": attribution.parent_collection,
266+
}
267+
if attribution.parent_eval_hash is not None:
268+
attribution_data["parent_eval_hash"] = attribution.parent_eval_hash
269+
result.attribution_data = attribution_data
270+
231271
def _log_attack_outcome(self, result: AttackResult) -> None:
232272
"""
233273
Log the outcome of the attack.
@@ -267,9 +307,6 @@ async def _on_error_async(
267307
if not error or not context:
268308
return
269309

270-
# Clear any stale ID from a previous execution
271-
context._error_attack_result_id = None
272-
273310
# Collect retry events (visible via inherited ContextVar copy)
274311
collector = get_retry_collector()
275312
retry_events = collector.events if collector else []
@@ -295,10 +332,11 @@ async def _on_error_async(
295332
if context.start_time:
296333
error_result.execution_time_ms = int((end_time - context.start_time) * 1000)
297334

298-
# Persist first, then set the ID on the context so scenario-level code
299-
# only sees the reference if the write succeeded.
335+
# Stamp attribution onto the error result so it is locatable via the
336+
# attribution_parent_id foreign key on resume.
337+
self._apply_attribution(context=context, result=error_result)
338+
300339
self._memory.add_attack_results_to_memory(attack_results=[error_result])
301-
context._error_attack_result_id = error_result.attack_result_id
302340

303341
self._logger.error(f"Attack failed with {type(error).__name__}: {error}")
304342

pyrit/executor/core/strategy.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,7 @@
3131

3232

3333
class _StrategyRuntimeError(RuntimeError):
34-
"""RuntimeError subclass that carries an optional error_attack_result_id."""
35-
36-
def __init__(self, message: str, *, error_attack_result_id: str | None = None) -> None:
37-
super().__init__(message)
38-
self.error_attack_result_id = error_attack_result_id
34+
"""RuntimeError subclass for strategy execution failures."""
3935

4036

4137
@dataclass
@@ -386,9 +382,7 @@ async def execute_with_context_async(self, *, context: StrategyContextT) -> Stra
386382
else:
387383
error_message = f"Strategy execution failed for {self.__class__.__name__}: {str(e)}"
388384

389-
# Attach the error attack result ID if the ON_ERROR handler created one
390-
error_attack_result_id = getattr(context, "_error_attack_result_id", None)
391-
runtime_error = _StrategyRuntimeError(error_message, error_attack_result_id=error_attack_result_id)
385+
runtime_error = _StrategyRuntimeError(error_message)
392386
raise runtime_error from e
393387

394388
async def execute_async(self, **kwargs: Any) -> StrategyResultT:

0 commit comments

Comments
 (0)