Skip to content

FEAT: Add reset_conversation_async hook to PromptTarget - #2320

Open
Mahdi Alhakim (mahdi-al-hakim) wants to merge 5 commits into
microsoft:mainfrom
mahdi-al-hakim:feat/reset-conversation-hook
Open

FEAT: Add reset_conversation_async hook to PromptTarget#2320
Mahdi Alhakim (mahdi-al-hakim) wants to merge 5 commits into
microsoft:mainfrom
mahdi-al-hakim:feat/reset-conversation-hook

Conversation

@mahdi-al-hakim

@mahdi-al-hakim Mahdi Alhakim (mahdi-al-hakim) commented Aug 3, 2026

Copy link
Copy Markdown

Description

Implements item 3 from #1247, which Roman Lutz (@romanlutz) scoped on 2026-07-03 as:

a standardized conversation-reset hook: a no-op reset_conversation_async(*, conversation_id=...) on base PromptTarget, invoked from attack _teardown_async, that targets holding external state (Playwright page, HTTP/websocket conversation_id) override.

A target that holds state per conversation has nowhere to release it, so the issue reporter monkey-patched _teardown_async to refresh a PlaywrightTarget page. RealtimeTarget and WebsocketTarget have the same shape from the other side: each caches one websocket per conversation id and grew its own cleanup_conversation_async, which nothing calls. A scenario hands one target instance to every atomic attack it runs, so those connections accumulate until someone closes the whole target.

Changes

  • PromptTarget.reset_conversation_async(*, conversation_id), a no-op on the base so stateless targets are unaffected.
  • AttackStrategy._teardown_async hands the objective target every conversation the run used: the live one plus the ones recorded as PRUNED. That is AttackResult.get_active_conversation_ids() read off the context, and a test pins the two equal. It reads the context because execute_with_context_async re-raises rather than returning, so a failed or cancelled run produces no result to read, and those are the runs that leave connections open.
  • Eight attacks had a _teardown_async that did nothing. Each would have needed await super()._teardown_async(...), and forgetting that line would have disabled the reset with a green suite, so they are deleted. All eleven concrete attacks inherit the base now.
  • RealtimeTarget and WebsocketTarget implement the hook; on both, cleanup_conversation_async delegates to it and warns, removed_in="1.3.0". cleanup_target_async is untouched, since closing the whole target is a different lifetime.
  • Only the objective target is reset. Adversarial, scorer and converter targets have their own lifetimes, which is why the adversarial conversations an attack records are skipped. Nothing is removed or renamed from the public API.

TAP kept conversations nothing could name

A PromptSendingAttack retry, a Crescendo backtrack and the rotation in MultiTurnAttackStrategy all record the id they supersede. TAP did not: against a single-turn target a node mints a fresh conversation id every turn and dropped the one it replaced, and branches still standing at the end were never recorded. On a real tree at depth 3, a single-turn target served 3 conversations with 1 still reachable. PAIRAttack subclasses TAP and had both gaps.

A node now reports each conversation as its send returns, and the attack records it then. Recording there rather than while building the result is what makes it survive a run that raises: raising in the second iteration of a depth 3 width 3 tree loses 2 of 3 conversations if you wait for the result. The leading branch is taken back out whenever the lead is recomputed, which is the last step of every iteration, so a run that raises does not report its own conversation as pruned as well.

With that fixed the base lookup covers TAP, so its override is gone and no attack overrides it.

Two things worth knowing

A TAP or PAIR result now names conversations it always opened but could not previously reach, so get_pruned_conversation_ids() returns more of them and the backend message count for such a run rises to the real total. Nothing new is sent; the counts were under-reporting.

Anyone who subclassed RealtimeTarget or WebsocketTarget and overrode cleanup_conversation_async will not have that override called by teardown, which calls reset_conversation_async. The old name still works when called directly, and it had no callers here.

Tests and Documentation

Test ids against origin/main: +38, -10. Ten additions are renames, so 28 are new. The renames: three test_teardown_async_is_noop became test_teardown_async_resets_target_conversation, and seven test_cleanup_conversation_async_* across the two websocket targets became test_reset_conversation_async_*.

They cover the id lookup against real contexts, a raising target being swallowed, and a run releasing its conversation when it succeeds, when it raises and when it is cancelled; end-to-end TAP runs with real nodes asserting that everything the target served stays reachable including when the run raises, that a branched node reports its own conversations, and that the leading branch is never also reported as pruned; and the hook on both websocket targets, including the deprecated alias warning.

Documentation: a "Releasing per-conversation state" section in doc/code/targets/0_prompt_targets.md and a matching one in .github/instructions/targets.instructions.md. Both are .md, so there is nothing to run through JupyText.

uv run pre-commit run --all-files                  18 hooks, all Passed
uv run pytest -n 4 --dist=loadfile tests/unit -q   16710 passed, 8 skipped

Tool versions match uv.lock: ruff 0.16.4, ty 0.0.74, pytest 9.1.1. Integration tests need credentials and were not run.

@mahdi-al-hakim

Copy link
Copy Markdown
Author

Hi Richard Lundeen (@rlundeen2) Roman Lutz (@romanlutz), this has been open for a few weeks now. It is a self-contained change (a reset_conversation_async hook on PromptTarget). I recently landed microsoft/RAMPART#141, so I am glad to match whatever conventions you prefer here. Is there anything I can do to help move it along, or someone I should tag for review?

@rlundeen2

Copy link
Copy Markdown
Contributor

(GHCP Generated): I think the underlying problem is real, especially for scenarios. A scenario commonly shares one RealtimeTarget across many concurrent attacks. Conversation IDs prevent state from mixing, but RealtimeTarget caches one websocket per ID. Without conversation-level cleanup, a large or long-running scenario can accumulate open connections until cleanup_target_async() runs.

I do not think AttackStrategy._teardown_async should discover conversation IDs from attack contexts, though. That introduces several concerns:

  • AttackResult already defines the canonical relationship through conversation_id, related_conversations, and get_active_conversation_ids(). _get_objective_conversation_ids() duplicates this contract with getattr checks and attack-specific overrides.
  • TAP demonstrates the fragility: it needs a custom lookup, and its single-turn path replaces a node's conversation ID each turn without recording the old ID as PRUNED, so those earlier resources still leak.
  • The current implementation handles only the objective target. Attacks can also use adversarial targets, scorer targets, and converter targets. Some of their IDs are already stored in related_conversations, but the teardown filters them out. This makes a generic attack-level cleanup guarantee incomplete.
  • Custom attacks can return a valid AttackResult while storing transient IDs in a different context shape. They will silently skip cleanup unless they know to override the new protected method.

I suggest this design:

  1. Keep a no-op conversation-level cleanup hook on PromptTarget, with RealtimeTarget implementing it. This allows a shared target to release one conversation without destroying the target or its other active conversations.
  2. Invoke objective-target cleanup at the AttackExecutor boundary after each run. The executor owns per-run execution, has the objective target, and receives the completed AttackResult.
  3. Use result.get_active_conversation_ids() instead of reconstructing IDs from context shape. Ensure error results also contain the real multi-turn session.conversation_id and all related objective conversations before cleanup runs.
  4. Make the scope explicit: this pass cleans objective-target conversations only. Define adversarial, scorer, and converter target lifecycles separately rather than implying that attack teardown handles every target.
  5. Fix TAP and other rotation paths so every replaced objective conversation is added to related_conversations; otherwise neither result-based nor context-based cleanup can release it.

This keeps the useful part of the proposal for shared realtime targets, while preserving AttackResult as the source of truth and avoiding target-lifecycle policy in each attack implementation.

@mahdi-al-hakim

Copy link
Copy Markdown
Author

Richard Lundeen (@rlundeen2) The TAP half is done. A node now reports each objective-target conversation as its send returns and the attack records it there, so the ones a single-turn rotation replaces and the branches a run walks away from stay nameable. Recording at that point rather than at result time is what makes it survive a run that raises, which otherwise loses 2 of 3 conversations on a depth 3 width 3 tree. That made the TAP override unnecessary, and no attack overrides the lookup now. WebsocketTarget has the same per-conversation cache, so it implements the hook too.

I kept the call in _teardown_async rather than the executor. execute_with_context_async re-raises, so a failed run reaches the executor as an exception rather than a result and completed_results is empty. And attack.execute_async never goes through the executor at all: doc/code/targets/realtime_target.py runs attacks three ways, two of which bypass it.

One resolver now, shared with the error-result builder from #2322. It still reads the context rather than the result, with a test pinning the two equal. Happy to move it if you would rather have it at the executor.

Targets that hold external state keyed by conversation had no standard way
to release it when an attack finished. RealtimeTarget grew its own
cleanup_conversation_async, and the issue reporter had to monkey-patch
_teardown_async to call it.

PromptTarget gains a no-op reset_conversation_async(*, conversation_id),
and AttackStrategy._teardown_async now hands it every objective-target
conversation the run used. That is the live conversation plus the ones
recorded as PRUNED, since a PromptSendingAttack retry, a Crescendo
backtrack, and the single-turn rotation in multi-turn attacks all leave
earlier conversations behind. TAP keys conversations per tree node instead,
so it overrides the lookup.

The reset runs in the lifecycle finally block, so a target that raises is
logged rather than replacing whatever error the attack was reporting.

RealtimeTarget now implements the hook, and cleanup_conversation_async
delegates to it with a deprecation warning. cleanup_target_async is left
alone since closing the whole target is a different concern.

Towards microsoft#1247
TAP kept objective-target conversations that nothing could name afterwards, so
nothing could release the target-side state behind them.

Two ways they went missing. Against a single-turn objective target a node mints
a fresh conversation id every turn and dropped the one it replaced; a node holds
no reference to the context, so the old id went nowhere. And the branches still
standing when the run ended were never recorded, so only the winner survived as
result.conversation_id. Measured on a real tree at depth 3: a single-turn target
served 3 conversations with 1 still reachable, and a multi-turn target served 6
with the result reporting 5. PAIRAttack subclasses TAP and overrides none of
this, so it had both gaps too.

A node now reports each conversation as its send returns, which is the moment the
target starts holding state for it, and the attack records it straight onto the
context. Recording it there rather than while building the result is what makes
it survive a run that raises or is cancelled, which are the runs most likely to
leave a connection open: raising in the second iteration of a depth 3 width 3
tree used to lose 2 of 3 conversations. The winning branch is taken back out when
the result is built, since it becomes result.conversation_id and would otherwise
be reported twice.

Reporting on send rather than on rotation also means a branched node does not
record the conversation it was cloned from but never sent on. Single-turn with
branching_factor 2 serves 10 conversations and records exactly those 10.

Towards microsoft#1247
Two copies of the same lookup had grown up next to each other. microsoft#2322 taught the
error-result builder to read context.conversation_id and fall back to
context.session.conversation_id; _get_objective_conversation_ids was doing the
same walk a few hundred lines away. They are one resolver now, so the
conversation an error result is filed under and the conversations teardown
releases cannot drift apart.

That commit also gave TAPAttackContext a conversation_id property, which is
what the TAP override of _get_objective_conversation_ids existed to work
around. With TAP's branch bookkeeping fixed the base lookup covers it, so the
override is gone and no attack overrides the lookup.

What is left is AttackResult.get_active_conversation_ids() read off the context
rather than the result, with a test pinning the two equal. The context is what
teardown has: execute_with_context_async re-raises rather than returning, so a
run that fails or is cancelled produces no result for anything downstream to
read, and those are the runs that leave connections open. Tests cover all three
endings.

An attack whose context keeps the live conversation somewhere else should
expose it as a conversation_id property, the way TAPAttackContext reports the
best branch, rather than overriding the lookup and putting the answer back in
two places. Written down in the docstring and in the target instructions,
alongside what the pass covers: the objective target only, since adversarial,
scorer and converter targets have their own lifetimes.

Towards microsoft#1247
WebsocketTarget has the same shape as RealtimeTarget: a PromptTarget that
caches one connection per conversation id in _existing_conversation, with its
own cleanup_conversation_async that nothing calls. Leaving it out would have
deprecated that method on one target while the identical method stayed live on
the other, and a scenario sharing a WebsocketTarget would keep accumulating
connections for exactly the reason this PR exists.

Its close path is more careful than RealtimeTarget's, holding a per-conversation
lock and shielding the close so a cancellation still finishes it, so the body is
unchanged and only the name and the keyword-only signature move. The four tests
covering it move with it, and cleanup_conversation_async keeps working through
the same deprecation shim.

Towards microsoft#1247
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
microsoft#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 microsoft#1247
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants