Skip to content

Commit dbbc8fa

Browse files
FIX: Release the leading TAP branch as soon as the lead changes
Two defects, both found by diffing this branch against main rather than by a test failing. TAP records every branch as PRUNED while the run is in flight, and the leading one was taken back out only while building the result. A run that raises never builds one, so its own conversation stayed in both places: it was the error result's conversation_id and a pruned entry at the same time. attack_service.list_attacks adds the main conversation's message count to the pruned ones and sums a list rather than a set, so that run's messages were counted twice in the backend, and the markdown and pretty printers rendered the conversation twice. The release now happens wherever the lead is recomputed, which is the last step of every iteration, so the invariant holds at every instant instead of only once a result exists. A branch that led and then lost it is an abandoned branch again, so it goes back. The fallback that picks a conversation when no node completed was setting the lead without releasing it; it goes through the same path now. That makes the release at result-build time unreachable, since the lead is always recomputed last, so it is gone rather than left as dead code. Second, _resolve_live_conversation_id had grown a hasattr dispatch that changed what the error-result builder does for a TAPAttackContext with no nodes and no best branch: main falls through to session.conversation_id, this returned None and the caller minted a fresh uuid. Neither id names anything real, but that is #2322's code and this PR was not asked to change it. It is back to main's exact lookup, verified by computing both over all six concrete context types: zero divergences. Towards #1247
1 parent 548157e commit dbbc8fa

4 files changed

Lines changed: 96 additions & 29 deletions

File tree

doc/code/targets/0_prompt_targets.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ on the objective target for every conversation the run used. That includes conve
6969

7070
The base implementation does nothing, so a target that keeps no state between calls does not need to override it. `RealtimeTarget` and `WebsocketTarget` override it to close the websocket each caches per conversation. If you write a target that holds something similar, override it and release that state there. Do not raise for a conversation id you do not recognize, since the attack calls this while it is tearing down and treats it as best effort.
7171

72-
The reset runs from the attack's teardown, which is in the `finally` of the execution lifecycle, so it covers runs that succeed, runs that raise and runs that are cancelled.
72+
The reset runs from the attack's teardown, which is in the `finally` of the execution lifecycle, so it covers runs that succeed, runs that raise and runs that are cancelled. An error from your implementation is logged and swallowed, but a `CancelledError` is not: cancelling a run while it is releasing stops the release, and whatever is left is `cleanup_target_async`'s job.
7373

7474
Two things are deliberately out of scope. **Only the objective target is reset.** An attack can also drive an adversarial chat target, a scorer target and converter targets; those have their own lifetimes and are not released here, which is why the adversarial conversations an attack records are skipped. And **closing the target as a whole** is a different lifetime from releasing one conversation, so it stays where it is rather than moving into this hook.
7575

pyrit/executor/attack/core/attack_strategy.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -173,22 +173,24 @@ def _resolve_live_conversation_id(*, context: AttackContext[Any]) -> str | None:
173173
"""
174174
Return the objective-target conversation the run is currently using.
175175
176-
A context that declares ``conversation_id`` answers for itself, including when
177-
the answer is that it has none: ``TAPAttackContext`` reports the best branch
178-
and has nothing to report before the first one is chosen. Only a context
179-
without the attribute falls through to its conversation session, which is
180-
where multi-turn contexts keep it.
176+
Single-turn contexts expose it directly and multi-turn contexts keep it on
177+
their conversation session. ``TAPAttackContext`` overrides
178+
``conversation_id`` to report the best branch, so the first lookup covers it.
179+
180+
This is the lookup #2322 gave the error-result builder, moved here so that
181+
builder and the teardown reset resolve a run's conversation the same way
182+
rather than walking the context twice.
181183
182184
Args:
183185
context (AttackContext[Any]): The context for the attack.
184186
185187
Returns:
186-
str | None: The conversation id, or ``None`` when the context has none.
188+
str | None: The conversation id, or ``None`` when the context exposes
189+
neither layout.
187190
"""
188-
if hasattr(context, "conversation_id"):
189-
candidate = getattr(context, "conversation_id", None)
190-
else:
191-
candidate = getattr(getattr(context, "session", None), "conversation_id", None)
191+
candidate = getattr(context, "conversation_id", None) or getattr(
192+
getattr(context, "session", None), "conversation_id", None
193+
)
192194
return candidate if isinstance(candidate, str) and candidate else None
193195

194196

@@ -762,9 +764,13 @@ async def _teardown_async(self, *, context: AttackStrategyContextT) -> None:
762764
converter targets have their own lifetimes and are not released here.
763765
764766
This runs in the ``finally`` of the execution lifecycle, so it covers
765-
runs that succeed, runs that raise and runs that are cancelled, and a
766-
target that raises here is logged rather than allowed to replace
767-
whatever error the attack was already reporting.
767+
runs that succeed, runs that raise and runs that are cancelled. An
768+
``Exception`` from a target is logged rather than allowed to replace
769+
whatever error the attack was already reporting. Cancellation is not
770+
caught: if the run is cancelled while this is releasing, it propagates
771+
and the conversations after it are left to ``cleanup_target_async``,
772+
because swallowing a ``CancelledError`` to finish a cleanup loop is
773+
worse than not finishing it.
768774
769775
Subclasses that need their own teardown should override this and call
770776
``await super()._teardown_async(context=context)``.

pyrit/executor/attack/multi_turn/tree_of_attacks.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1921,18 +1921,29 @@ def record(conversation_id: str) -> None:
19211921

19221922
return record
19231923

1924-
def _release_best_conversation(self, context: TAPAttackContext) -> None:
1924+
def _release_best_conversation(self, context: TAPAttackContext, *, previous_best: str | None = None) -> None:
19251925
"""
1926-
Stop reporting the winning branch as pruned.
1926+
Stop reporting the current best branch as pruned.
19271927
19281928
Every conversation is recorded while the run is in flight, before there is
1929-
any way to know which branch will win. The one that does becomes
1930-
``result.conversation_id``, so leaving it in ``related_conversations`` would
1931-
report it twice.
1929+
any way to know which branch will lead. Whichever one does becomes
1930+
``result.conversation_id``, so leaving it in ``related_conversations``
1931+
would report it twice: the backend adds the main conversation's message
1932+
count to the pruned ones, and the report printers list it in both places.
1933+
1934+
Called whenever the lead is recomputed, which is the last step of every
1935+
iteration, so the invariant holds at every instant rather than only once a
1936+
result exists. A run that raises never builds a result and would otherwise
1937+
report its own conversation twice. A branch that led and then lost it is an
1938+
abandoned branch again, so it goes back.
19321939
19331940
Args:
19341941
context (TAPAttackContext): The attack context.
1942+
previous_best (str | None): The branch that was leading before, if the
1943+
lead just changed.
19351944
"""
1945+
if previous_best and previous_best != context.best_conversation_id:
1946+
self._make_objective_conversation_recorder(context=context)(previous_best)
19361947
if not context.best_conversation_id:
19371948
return
19381949
context.related_conversations.discard(
@@ -2193,6 +2204,7 @@ def _update_best_performing_node(self, context: TAPAttackContext) -> None:
21932204
# but we ensure it is sorted to avoid making any assumptions
21942205
# about the order of nodes in context.nodes.
21952206
completed_nodes = self._get_completed_nodes_sorted_by_score(context.nodes)
2207+
previous_best = context.best_conversation_id
21962208

21972209
if completed_nodes:
21982210
best_node = completed_nodes[0]
@@ -2210,6 +2222,8 @@ def _update_best_performing_node(self, context: TAPAttackContext) -> None:
22102222
context.best_adversarial_conversation_id = node.adversarial_chat_conversation_id
22112223
break
22122224

2225+
self._release_best_conversation(context, previous_best=previous_best)
2226+
22132227
def _create_attack_node(
22142228
self,
22152229
*,
@@ -2436,10 +2450,6 @@ def _create_attack_result(
24362450
from the top node, calculates tree statistics, and populates all TAP-specific
24372451
metadata fields.
24382452
2439-
Drops the winning branch from ``context.related_conversations`` before
2440-
copying it onto the result, since which branch wins is only known once the
2441-
run is over. Both endings come through here, so that happens exactly once.
2442-
24432453
Args:
24442454
context (TAPAttackContext): The attack context containing the final state
24452455
after execution, including best conversation ID, score, and tree visualization.
@@ -2451,8 +2461,6 @@ def _create_attack_result(
24512461
about the attack execution, including conversation ID, objective, outcome,
24522462
outcome reason, executed turns, last response, last score, and additional metadata.
24532463
"""
2454-
self._release_best_conversation(context)
2455-
24562464
last_response = self._get_result_response(
24572465
conversation_id=context.best_conversation_id,
24582466
score=context.best_objective_score,

tests/unit/executor/attack/multi_turn/test_tree_of_attacks.py

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1620,6 +1620,19 @@ def test_node_duplicate_creates_child(self, node_components):
16201620
assert child_node.parent_id == parent_node.node_id
16211621
assert child_node.completed is False
16221622

1623+
def test_node_duplicate_keeps_reporting_its_conversations(self, node_components):
1624+
"""A child sends on its own conversation, so it needs the same recorder as its parent."""
1625+
reported: list[str] = []
1626+
components = {**node_components, "report_objective_conversation": reported.append}
1627+
parent_node = _TreeOfAttacksNode(**components)
1628+
1629+
with patch.object(parent_node._memory, "duplicate_conversation", return_value="new_conv_id"):
1630+
child_node = parent_node.duplicate()
1631+
1632+
child_node._report_objective_conversation(child_node.objective_target_conversation_id)
1633+
1634+
assert reported == ["new_conv_id"]
1635+
16231636
def _node_with_schema(self, node_components, schema):
16241637
"""Build a real node whose adversarial system prompt advertises ``schema``.
16251638
@@ -3321,16 +3334,16 @@ def test_the_base_lookup_does_not_use_the_unused_session_conversation_id(self, b
33213334
assert context.session.conversation_id not in ids
33223335

33233336
def test_a_conversation_is_recorded_as_soon_as_it_is_sent_on(self, basic_attack):
3324-
context = self._context_with_nodes()
3337+
context = self._context_with_nodes("node-a")
33253338
record = basic_attack._make_objective_conversation_recorder(context=context)
33263339

33273340
record("turn-1")
33283341
record("turn-2")
33293342

33303343
# Recorded while the run is still going, so a run that raises or is
3331-
# cancelled can still name them. related_conversations is a set, so the
3332-
# order the lookup returns them in is not meaningful.
3333-
assert set(basic_attack._get_objective_conversation_ids(context=context)) == {"turn-1", "turn-2"}
3344+
# cancelled can still name them.
3345+
ids = set(basic_attack._get_objective_conversation_ids(context=context))
3346+
assert {"turn-1", "turn-2"} <= ids
33343347

33353348
def test_recording_the_same_conversation_twice_records_it_once(self, basic_attack):
33363349
context = self._context_with_nodes()
@@ -3500,6 +3513,20 @@ async def test_the_context_and_the_result_agree(self, attack_builder):
35003513

35013514
assert set(attack._get_objective_conversation_ids(context=context)) == result.get_active_conversation_ids()
35023515

3516+
async def test_a_branched_node_reports_its_own_conversations(self, attack_builder):
3517+
"""A child gets its own conversation from duplicate(), and must report on it too."""
3518+
attack, served = self._run_and_collect(
3519+
attack_builder=attack_builder, supports_multi_turn=True, depth=3, width=1, branching=2
3520+
)
3521+
context = TAPAttackContext(params=AttackParameters(objective="Test objective"))
3522+
3523+
result = await self._execute(attack, context)
3524+
3525+
# width=1 keeps one node per level, so every conversation beyond the first
3526+
# belongs to a branch, and nothing else records those for us.
3527+
assert len(set(served)) > 1, "branching has to have produced more than one conversation"
3528+
assert set(served) <= result.get_active_conversation_ids()
3529+
35033530
async def test_the_winning_conversation_is_not_also_reported_as_pruned(self, attack_builder):
35043531
attack, served = self._run_and_collect(
35053532
attack_builder=attack_builder, supports_multi_turn=True, depth=3, width=2, branching=2
@@ -3514,6 +3541,32 @@ async def test_the_winning_conversation_is_not_also_reported_as_pruned(self, att
35143541
assert result.conversation_id not in result.get_pruned_conversation_ids()
35153542
assert result.conversation_id in result.get_active_conversation_ids()
35163543

3544+
async def test_a_run_that_raises_does_not_report_its_own_conversation_as_pruned(self, attack_builder):
3545+
"""The backend adds the main conversation's messages to the pruned ones, so it cannot be in both."""
3546+
attack, served = self._run_and_collect(
3547+
attack_builder=attack_builder, supports_multi_turn=True, depth=3, width=2, branching=1
3548+
)
3549+
context = TAPAttackContext(params=AttackParameters(objective="Test objective"))
3550+
3551+
iterations = {"count": 0}
3552+
prepare = type(attack)._prepare_nodes_for_iteration_async
3553+
3554+
async def fail_on_the_second_iteration(self, context):
3555+
iterations["count"] += 1
3556+
if iterations["count"] >= 2:
3557+
raise ValueError("blew up mid-run")
3558+
await prepare(self, context=context)
3559+
3560+
with patch.object(type(attack), "_prepare_nodes_for_iteration_async", new=fail_on_the_second_iteration):
3561+
with pytest.raises(ValueError):
3562+
await self._execute(attack, context)
3563+
3564+
# No result is built on this path, so the invariant has to already hold on
3565+
# the context the error result is assembled from.
3566+
assert context.best_conversation_id, "a branch has to have taken the lead"
3567+
pruned = {ref.conversation_id for ref in context.related_conversations}
3568+
assert context.best_conversation_id not in pruned
3569+
35173570
async def test_a_run_that_raises_still_names_everything_it_served(self, attack_builder):
35183571
"""The path that matters most, because a run that blew up is the one holding connections."""
35193572
attack, served = self._run_and_collect(

0 commit comments

Comments
 (0)