Skip to content

Commit 2249d2a

Browse files
romanlutzCopilot
andauthored
MAINT: Split TAP node execution scheduling (#2382)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent cfa1770 commit 2249d2a

2 files changed

Lines changed: 178 additions & 19 deletions

File tree

pyrit/executor/attack/multi_turn/tree_of_attacks.py

Lines changed: 75 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
from pyrit.score.true_false.true_false_inverter_scorer import TrueFalseInverterScorer
7070

7171
if TYPE_CHECKING:
72+
from collections.abc import AsyncIterator
7273
from pathlib import Path
7374

7475
from pyrit.models.literals import PromptDataType
@@ -1261,6 +1262,72 @@ def __str__(self) -> str:
12611262
__repr__ = __str__
12621263

12631264

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+
12641331
class TreeOfAttacksWithPruningAttack(AttackStrategy[TAPAttackContext, TAPAttackResult]):
12651332
"""
12661333
Implement the Tree of Attacks with Pruning (TAP) attack strategy.
@@ -1402,6 +1469,10 @@ def __init__(
14021469
super().__init__(objective_target=objective_target, logger=logger, context_type=TAPAttackContext)
14031470

14041471
self._memory = CentralMemory.get_memory_instance()
1472+
self._node_executor = _TreeOfAttacksNodeExecutor(
1473+
batch_size=self._configuration.batch_size,
1474+
logger=self._logger,
1475+
)
14051476

14061477
# Initialize adversarial configuration
14071478
self._adversarial_chat = attack_adversarial_config.target
@@ -1885,25 +1956,10 @@ async def _send_prompts_to_all_nodes_async(self, context: TAPAttackContext) -> N
18851956
context.tree_visualization.create_node(f"{context.executed_turns}: ", vis_id, parent=node._vis_node_id)
18861957
node._vis_node_id = vis_id
18871958

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+
):
19071963
# Update visualization with results after batch completes
19081964
for node_index, node in enumerate(batch_nodes, start=batch_start + 1):
19091965
result_string = self._format_node_result(node)
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
import asyncio
5+
import logging
6+
from dataclasses import dataclass
7+
from unittest.mock import AsyncMock
8+
9+
import pytest
10+
11+
from pyrit.executor.attack.multi_turn.tree_of_attacks import _TreeOfAttacksNodeExecutor
12+
13+
14+
@dataclass
15+
class _NodeState:
16+
node_id: str
17+
executions: int = 0
18+
objective: str | None = None
19+
20+
21+
def _make_node(*, node_id: str, call_order: list[str]) -> tuple[_NodeState, AsyncMock]:
22+
state = _NodeState(node_id=node_id)
23+
24+
async def execute_async(objective: str) -> None:
25+
call_order.append(node_id)
26+
state.executions += 1
27+
state.objective = objective
28+
29+
return state, AsyncMock(side_effect=execute_async)
30+
31+
32+
async def test_execute_nodes_async_preserves_batch_order_and_node_state() -> None:
33+
call_order: list[str] = []
34+
states_and_methods = [_make_node(node_id=f"node-{index}", call_order=call_order) for index in range(5)]
35+
nodes = []
36+
for state, method in states_and_methods:
37+
node = AsyncMock()
38+
node.state = state
39+
node.send_prompt_async = method
40+
nodes.append(node)
41+
42+
executor = _TreeOfAttacksNodeExecutor(batch_size=2, logger=logging.getLogger(__name__))
43+
44+
completed_batches = [
45+
(batch_start, [node.state.node_id for node in batch])
46+
async for batch_start, batch in executor.execute_nodes_async(nodes=nodes, objective="objective")
47+
]
48+
49+
assert completed_batches == [(0, ["node-0", "node-1"]), (2, ["node-2", "node-3"]), (4, ["node-4"])]
50+
assert call_order == ["node-0", "node-1", "node-2", "node-3", "node-4"]
51+
assert [state.executions for state, _ in states_and_methods] == [1, 1, 1, 1, 1]
52+
assert [state.objective for state, _ in states_and_methods] == ["objective"] * 5
53+
54+
55+
async def test_execute_nodes_async_stops_before_next_batch_on_failure() -> None:
56+
first = AsyncMock()
57+
first.send_prompt_async = AsyncMock(return_value=None)
58+
failing = AsyncMock()
59+
failing.send_prompt_async = AsyncMock(side_effect=RuntimeError("node failed"))
60+
not_started = AsyncMock()
61+
not_started.send_prompt_async = AsyncMock(return_value=None)
62+
executor = _TreeOfAttacksNodeExecutor(batch_size=2, logger=logging.getLogger(__name__))
63+
64+
with pytest.raises(RuntimeError, match="node failed"):
65+
async for _ in executor.execute_nodes_async(
66+
nodes=[first, failing, not_started],
67+
objective="objective",
68+
):
69+
pass
70+
71+
first.send_prompt_async.assert_awaited_once_with(objective="objective")
72+
failing.send_prompt_async.assert_awaited_once_with(objective="objective")
73+
not_started.send_prompt_async.assert_not_awaited()
74+
75+
76+
async def test_execute_nodes_async_propagates_cancellation_to_active_nodes() -> None:
77+
started = asyncio.Event()
78+
cancelled = asyncio.Event()
79+
80+
async def wait_for_cancellation_async(objective: str) -> None:
81+
started.set()
82+
try:
83+
await asyncio.Event().wait()
84+
finally:
85+
cancelled.set()
86+
87+
node = AsyncMock()
88+
node.send_prompt_async = AsyncMock(side_effect=wait_for_cancellation_async)
89+
executor = _TreeOfAttacksNodeExecutor(batch_size=1, logger=logging.getLogger(__name__))
90+
91+
async def consume_async() -> None:
92+
async for _ in executor.execute_nodes_async(nodes=[node], objective="objective"):
93+
pass
94+
95+
task = asyncio.create_task(consume_async())
96+
await started.wait()
97+
task.cancel()
98+
99+
with pytest.raises(asyncio.CancelledError):
100+
await task
101+
102+
assert cancelled.is_set()
103+
node.send_prompt_async.assert_awaited_once_with(objective="objective")

0 commit comments

Comments
 (0)