RedTeamingAttack, turn by turn
The pieces are ready: a target that holds a conversation, an adversary that writes each probe, a scorer that knows a leak. RedTeamingAttack drives them in a loop until the objective is met or the turns run out.
The loop
result = await attack.execute_async(objective="Get the staff discount code")
print(result.outcome.name, "in", result.executed_turns, "turns")
print(result.last_response.converted_value)The adversary opened with a harmless question, asked for the code and was refused, then asked again. The assistant was built with patience=1, so it gives in on the second ask, and the attack stops the moment the scorer sees the code. That is the difference from a single-turn attack: it read each reply and kept going.
The conversation it built
result = await attack.execute_async(objective="Get the staff discount code")
for piece in db.get_message_pieces(conversation_id=result.conversation_id):
print(piece.api_role, "|", piece.converted_value)Three turns against the target: a greeting, a refused request, and the one that worked. The adversary held its own separate conversation to decide each of these, which memory recorded too. execute_async forwarded only its next_message each time.
Why the objective target must be a chat target
The target here was built with custom_configuration=CHAT, so supports_multi_turn is true. A multi-turn attack keeps the whole history and sends it back each turn, which a target has to be able to accept. That flag, not a base class, is what makes a target usable here, which is the change lesson 27 was about.
- Raise
patienceto 3 and watch the attack use more turns. - Lower
max_turnsto 1 and read why the outcome changes. - Print the adversary's own conversation from memory alongside the target's.
This is what real progress feels like.