|
69 | 69 | from pyrit.score.true_false.true_false_inverter_scorer import TrueFalseInverterScorer |
70 | 70 |
|
71 | 71 | if TYPE_CHECKING: |
| 72 | + from collections.abc import AsyncIterator |
72 | 73 | from pathlib import Path |
73 | 74 |
|
74 | 75 | from pyrit.models.literals import PromptDataType |
@@ -1261,6 +1262,72 @@ def __str__(self) -> str: |
1261 | 1262 | __repr__ = __str__ |
1262 | 1263 |
|
1263 | 1264 |
|
| 1265 | +class _TreeOfAttacksNodeExecutor: |
| 1266 | + """Execute independent tree nodes with bounded concurrency.""" |
| 1267 | + |
| 1268 | + def __init__( |
| 1269 | + self, |
| 1270 | + *, |
| 1271 | + batch_size: int, |
| 1272 | + logger: logging.Logger | logging.LoggerAdapter[logging.Logger], |
| 1273 | + ) -> None: |
| 1274 | + """ |
| 1275 | + Initialize the node executor. |
| 1276 | +
|
| 1277 | + Args: |
| 1278 | + batch_size (int): Maximum number of nodes to execute concurrently. |
| 1279 | + logger (logging.Logger | logging.LoggerAdapter[logging.Logger]): Logger |
| 1280 | + used for execution progress. |
| 1281 | + """ |
| 1282 | + self._batch_size = batch_size |
| 1283 | + self._logger = logger |
| 1284 | + |
| 1285 | + async def execute_nodes_async( |
| 1286 | + self, |
| 1287 | + *, |
| 1288 | + nodes: list[_TreeOfAttacksNode], |
| 1289 | + objective: str, |
| 1290 | + ) -> AsyncIterator[tuple[int, list[_TreeOfAttacksNode]]]: |
| 1291 | + """ |
| 1292 | + Execute nodes in ordered batches and yield each completed batch. |
| 1293 | +
|
| 1294 | + Node instances own all branch-specific mutable state. This executor only |
| 1295 | + schedules their existing execution protocol, so failures and cancellation |
| 1296 | + retain ``asyncio.gather`` semantics. |
| 1297 | +
|
| 1298 | + Args: |
| 1299 | + nodes (list[_TreeOfAttacksNode]): Nodes to execute. |
| 1300 | + objective (str): Objective passed to every node. |
| 1301 | +
|
| 1302 | + Yields: |
| 1303 | + tuple[int, list[_TreeOfAttacksNode]]: The batch start offset and nodes |
| 1304 | + after every node in that batch has completed. |
| 1305 | + """ |
| 1306 | + for batch_start in range(0, len(nodes), self._batch_size): |
| 1307 | + batch_nodes = nodes[batch_start : batch_start + self._batch_size] |
| 1308 | + self._log_batch_start(batch_start=batch_start, batch_nodes=batch_nodes, total_nodes=len(nodes)) |
| 1309 | + |
| 1310 | + await asyncio.gather(*(node.send_prompt_async(objective=objective) for node in batch_nodes)) |
| 1311 | + |
| 1312 | + yield batch_start, batch_nodes |
| 1313 | + |
| 1314 | + def _log_batch_start( |
| 1315 | + self, |
| 1316 | + *, |
| 1317 | + batch_start: int, |
| 1318 | + batch_nodes: list[_TreeOfAttacksNode], |
| 1319 | + total_nodes: int, |
| 1320 | + ) -> None: |
| 1321 | + """Log the batch and node dispatch order.""" |
| 1322 | + batch_end = batch_start + len(batch_nodes) |
| 1323 | + self._logger.debug( |
| 1324 | + f"Processing batch {batch_start // self._batch_size + 1} " |
| 1325 | + f"(nodes {batch_start + 1}-{batch_end} of {total_nodes})" |
| 1326 | + ) |
| 1327 | + for node_index in range(batch_start + 1, batch_end + 1): |
| 1328 | + self._logger.debug(f"Preparing prompt for node {node_index}/{total_nodes}") |
| 1329 | + |
| 1330 | + |
1264 | 1331 | class TreeOfAttacksWithPruningAttack(AttackStrategy[TAPAttackContext, TAPAttackResult]): |
1265 | 1332 | """ |
1266 | 1333 | Implement the Tree of Attacks with Pruning (TAP) attack strategy. |
@@ -1402,6 +1469,10 @@ def __init__( |
1402 | 1469 | super().__init__(objective_target=objective_target, logger=logger, context_type=TAPAttackContext) |
1403 | 1470 |
|
1404 | 1471 | self._memory = CentralMemory.get_memory_instance() |
| 1472 | + self._node_executor = _TreeOfAttacksNodeExecutor( |
| 1473 | + batch_size=self._configuration.batch_size, |
| 1474 | + logger=self._logger, |
| 1475 | + ) |
1405 | 1476 |
|
1406 | 1477 | # Initialize adversarial configuration |
1407 | 1478 | self._adversarial_chat = attack_adversarial_config.target |
@@ -1885,25 +1956,10 @@ async def _send_prompts_to_all_nodes_async(self, context: TAPAttackContext) -> N |
1885 | 1956 | context.tree_visualization.create_node(f"{context.executed_turns}: ", vis_id, parent=node._vis_node_id) |
1886 | 1957 | node._vis_node_id = vis_id |
1887 | 1958 |
|
1888 | | - # Process nodes in batches |
1889 | | - for batch_start in range(0, len(context.nodes), self._configuration.batch_size): |
1890 | | - batch_end = min(batch_start + self._configuration.batch_size, len(context.nodes)) |
1891 | | - batch_nodes = context.nodes[batch_start:batch_end] |
1892 | | - |
1893 | | - self._logger.debug( |
1894 | | - f"Processing batch {batch_start // self._configuration.batch_size + 1} " |
1895 | | - f"(nodes {batch_start + 1}-{batch_end} of {len(context.nodes)})" |
1896 | | - ) |
1897 | | - |
1898 | | - # Create tasks for parallel execution |
1899 | | - tasks = [] |
1900 | | - for node_index, node in enumerate(batch_nodes, start=batch_start + 1): |
1901 | | - self._logger.debug(f"Preparing prompt for node {node_index}/{len(context.nodes)}") |
1902 | | - task = node.send_prompt_async(objective=context.objective) |
1903 | | - tasks.append(task) |
1904 | | - |
1905 | | - await asyncio.gather(*tasks) |
1906 | | - |
| 1959 | + async for batch_start, batch_nodes in self._node_executor.execute_nodes_async( |
| 1960 | + nodes=context.nodes, |
| 1961 | + objective=context.objective, |
| 1962 | + ): |
1907 | 1963 | # Update visualization with results after batch completes |
1908 | 1964 | for node_index, node in enumerate(batch_nodes, start=batch_start + 1): |
1909 | 1965 | result_string = self._format_node_result(node) |
|
0 commit comments