# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.
import textwrap
from datetime import datetime
from typing import Any
from colorama import Back, Fore, Style
from pyrit.common.display_response import display_image_response
from pyrit.executor.attack.printer.attack_result_printer import AttackResultPrinter
from pyrit.memory import CentralMemory
from pyrit.models import AttackOutcome, AttackResult, ConversationType, Score
[docs]
class ConsoleAttackResultPrinter(AttackResultPrinter):
"""
Console printer for attack results with enhanced formatting.
This printer formats attack results for console display with optional color coding,
proper indentation, text wrapping, and visual separators. Colors can be disabled
for consoles that don't support ANSI characters.
"""
[docs]
def __init__(self, *, width: int = 100, indent_size: int = 2, enable_colors: bool = True):
"""
Initialize the console printer.
Args:
width (int): Maximum width for text wrapping. Must be positive.
Defaults to 100.
indent_size (int): Number of spaces for indentation. Must be non-negative.
Defaults to 2.
enable_colors (bool): Whether to enable ANSI color output. When False,
all output will be plain text without colors. Defaults to True.
Raises:
ValueError: If width <= 0 or indent_size < 0.
"""
self._memory = CentralMemory.get_memory_instance()
self._width = width
self._indent = " " * indent_size
self._enable_colors = enable_colors
def _print_colored(self, text: str, *colors: str) -> None:
"""
Print text with color formatting if colors are enabled.
Args:
text (str): The text to print.
*colors: Variable number of colorama color constants to apply.
"""
if self._enable_colors and colors:
color_prefix = "".join(colors)
print(f"{color_prefix}{text}{Style.RESET_ALL}")
else:
print(text)
[docs]
async def print_result_async(
self,
result: AttackResult,
*,
include_auxiliary_scores: bool = False,
include_pruned_conversations: bool = False,
include_adversarial_conversation: bool = False,
) -> None:
"""
Print the complete attack result to console.
This method orchestrates the printing of all components of an attack result,
including header, summary, conversation history, metadata, and footer.
Args:
result (AttackResult): The attack result to print. Must not be None.
include_auxiliary_scores (bool): Whether to include auxiliary scores in the output.
Defaults to False.
include_pruned_conversations (bool): Whether to include pruned conversations.
For each pruned conversation, only the last message and its score are shown.
Defaults to False.
include_adversarial_conversation (bool): Whether to include the adversarial
conversation (the red teaming LLM's reasoning). Only shown for successful
attacks to avoid overwhelming output. Defaults to False.
"""
# Print header with outcome
self._print_header(result)
# Print summary information
await self.print_summary_async(result)
# Print conversation
self._print_section_header("Conversation History with Objective Target")
await self.print_conversation_async(result, include_scores=include_auxiliary_scores)
# Print pruned conversations if requested
if include_pruned_conversations:
await self._print_pruned_conversations_async(result)
# Print adversarial conversation if requested (only for successful attacks)
if include_adversarial_conversation:
await self._print_adversarial_conversation_async(result)
# Print metadata if available
if result.metadata:
self._print_metadata(result.metadata)
# Print footer
self._print_footer()
[docs]
async def print_conversation_async(
self, result: AttackResult, *, include_scores: bool = False, include_reasoning_trace: bool = False
) -> None:
"""
Print the conversation history to console with enhanced formatting.
Displays the full conversation between user and assistant, including:
- Turn numbers
- Role indicators (USER/ASSISTANT)
- Original and converted values when different
- Images if present
- Scores for each response
Args:
result (AttackResult): The attack result containing the conversation_id.
Must have a valid conversation_id attribute.
include_scores (bool): Whether to include scores in the output.
Defaults to False.
include_reasoning_trace (bool): Whether to include model reasoning trace in the output
for applicable models. Defaults to False.
"""
if not result.conversation_id:
self._print_colored(f"{self._indent} No conversation ID available", Fore.YELLOW)
return
messages = list(self._memory.get_conversation(conversation_id=result.conversation_id))
if not messages:
self._print_colored(f"{self._indent} No conversation found for ID: {result.conversation_id}", Fore.YELLOW)
return
await self.print_messages_async(
messages=messages,
include_scores=include_scores,
include_reasoning_trace=include_reasoning_trace,
)
[docs]
async def print_messages_async(
self,
messages: list[Any],
*,
include_scores: bool = False,
include_reasoning_trace: bool = False,
) -> None:
"""
Print a list of messages to console with enhanced formatting.
This method can be called directly with a list of Message objects,
without needing an AttackResult. Useful for printing prepended_conversation
or any other list of messages.
Displays:
- Turn numbers
- Role indicators (USER/ASSISTANT/SYSTEM)
- Original and converted values when different
- Images if present
- Scores for each response (if include_scores=True)
Args:
messages (list): List of Message objects to print.
include_scores (bool): Whether to include scores in the output.
Defaults to False.
include_reasoning_trace (bool): Whether to include model reasoning trace in the output
for applicable models. Defaults to False.
"""
if not messages:
self._print_colored(f"{self._indent} No messages to display.", Fore.YELLOW)
return
turn_number = 0
for message in messages:
# Increment turn number once per message with role="user"
if message.api_role == "user":
turn_number += 1
# User message header
print()
self._print_colored("─" * self._width, Fore.BLUE)
self._print_colored(f"🔹 Turn {turn_number} - USER", Style.BRIGHT, Fore.BLUE)
self._print_colored("─" * self._width, Fore.BLUE)
elif message.api_role == "system":
# System message header (not counted as a turn)
print()
self._print_colored("─" * self._width, Fore.MAGENTA)
self._print_colored("🔧 SYSTEM", Style.BRIGHT, Fore.MAGENTA)
self._print_colored("─" * self._width, Fore.MAGENTA)
else:
# Assistant or other role message header
print()
self._print_colored("─" * self._width, Fore.YELLOW)
role_label = "ASSISTANT (SIMULATED)" if message.is_simulated else message.api_role.upper()
self._print_colored(f"🔸 {role_label}", Style.BRIGHT, Fore.YELLOW)
self._print_colored("─" * self._width, Fore.YELLOW)
# Now print all pieces in this message
for piece in message.message_pieces:
# Skip reasoning traces unless explicitly requested
if piece.original_value_data_type == "reasoning" and not include_reasoning_trace:
continue
# Handle converted values for user and assistant messages
if piece.converted_value != piece.original_value:
self._print_colored(f"{self._indent} Original:", Fore.CYAN)
self._print_wrapped_text(piece.original_value, Fore.WHITE)
print()
self._print_colored(f"{self._indent} Converted:", Fore.CYAN)
self._print_wrapped_text(piece.converted_value, Fore.WHITE)
elif piece.api_role == "user":
self._print_wrapped_text(piece.converted_value, Fore.BLUE)
elif piece.api_role == "system":
self._print_wrapped_text(piece.converted_value, Fore.MAGENTA)
else:
self._print_wrapped_text(piece.converted_value, Fore.YELLOW)
# Display images if present
await display_image_response(piece)
# Print scores with better formatting (only if scores are requested)
if include_scores:
scores = self._memory.get_prompt_scores(prompt_ids=[str(piece.id)])
if scores:
print()
self._print_colored(f"{self._indent}📊 Scores:", Style.DIM, Fore.MAGENTA)
for score in scores:
self._print_score(score)
print()
self._print_colored("─" * self._width, Fore.BLUE)
[docs]
async def print_summary_async(self, result: AttackResult) -> None:
"""
Print a summary of the attack result with enhanced formatting.
Displays:
- Basic information (objective, attack type, conversation ID)
- Execution metrics (turns executed, execution time)
- Outcome information (status, reason)
- Final score if available
Args:
result (AttackResult): The attack result to summarize. Must contain
objective, attack_identifier, conversation_id, executed_turns,
execution_time_ms, outcome, and optionally outcome_reason and
last_score attributes.
"""
self._print_section_header("Attack Summary")
# Basic information
self._print_colored(f"{self._indent}📋 Basic Information", Style.BRIGHT)
self._print_colored(f"{self._indent * 2}• Objective: {result.objective}", Fore.CYAN)
# Extract attack type name from attack_identifier
attack_type = "Unknown"
if isinstance(result.attack_identifier, dict) and "__type__" in result.attack_identifier:
attack_type = result.attack_identifier["__type__"]
elif isinstance(result.attack_identifier, str):
attack_type = result.attack_identifier
self._print_colored(f"{self._indent * 2}• Attack Type: {attack_type}", Fore.CYAN)
self._print_colored(f"{self._indent * 2}• Conversation ID: {result.conversation_id}", Fore.CYAN)
# Execution metrics
print()
self._print_colored(f"{self._indent}⚡ Execution Metrics", Style.BRIGHT)
self._print_colored(f"{self._indent * 2}• Turns Executed: {result.executed_turns}", Fore.GREEN)
self._print_colored(
f"{self._indent * 2}• Execution Time: {self._format_time(result.execution_time_ms)}", Fore.GREEN
)
# Outcome information
print()
self._print_colored(f"{self._indent}🎯 Outcome", Style.BRIGHT)
outcome_icon = self._get_outcome_icon(result.outcome)
outcome_color = self._get_outcome_color(result.outcome)
self._print_colored(f"{self._indent * 2}• Status: {outcome_icon} {result.outcome.value.upper()}", outcome_color)
if result.outcome_reason:
self._print_colored(f"{self._indent * 2}• Reason: {result.outcome_reason}", Fore.WHITE)
# Final score
if result.last_score:
print()
self._print_colored(f"{self._indent} Final Score", Style.BRIGHT)
self._print_score(result.last_score, indent_level=2)
def _print_header(self, result: AttackResult) -> None:
"""
Print the header with outcome-based coloring and styling.
Creates a visually prominent header that displays the attack outcome
with appropriate color coding and icons.
Args:
result (AttackResult): The attack result containing the outcome.
Must have an outcome attribute of type AttackOutcome.
"""
color = self._get_outcome_color(result.outcome)
icon = self._get_outcome_icon(result.outcome)
print()
self._print_colored("═" * self._width, color)
# Center the header text
header_text = f"{icon} ATTACK RESULT: {result.outcome.value.upper()} {icon}"
self._print_colored(header_text.center(self._width), Style.BRIGHT, color)
self._print_colored("═" * self._width, color)
def _print_footer(self) -> None:
"""
Print a footer with timestamp.
Displays the current timestamp when the report was generated.
"""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print()
self._print_colored("─" * self._width, Style.DIM, Fore.WHITE)
footer_text = f"Report generated at: {timestamp}"
self._print_colored(footer_text.center(self._width), Style.DIM, Fore.WHITE)
def _print_section_header(self, title: str) -> None:
"""
Print a section header with consistent styling.
Creates a visually distinct section header with background color
and separator line.
Args:
title (str): The title text to display in the section header.
"""
print()
self._print_colored(f" {title} ", Style.BRIGHT, Back.BLUE, Fore.WHITE)
self._print_colored("─" * self._width, Fore.BLUE)
def _print_metadata(self, metadata: dict[str, Any]) -> None:
"""
Print metadata in a formatted way.
Displays key-value pairs from the metadata dictionary in a
consistent bullet-point format.
Args:
metadata (dict[str, Any]): Dictionary containing metadata key-value pairs.
Keys and values should be convertible to strings.
"""
self._print_section_header("Additional Metadata")
for key, value in metadata.items():
self._print_colored(f"{self._indent}• {key}: {value}", Fore.CYAN)
def _print_score(self, score: Score, indent_level: int = 3) -> None:
"""
Print a score with proper formatting.
Displays score information including type, value, and rationale
with appropriate color coding based on score type.
Args:
score (Score): Score object to be printed.
indent_level (int): Number of indent units to apply. Defaults to 3.
"""
indent = self._indent * indent_level
scorer_name = score.scorer_class_identifier.class_name
print(f"{indent}Scorer: {scorer_name}")
self._print_colored(f"{indent}• Category: {score.score_category or 'N/A'}", Fore.LIGHTMAGENTA_EX)
self._print_colored(f"{indent}• Type: {score.score_type}", Fore.CYAN)
# Determine color based on score type and value
if score.score_type == "true_false":
score_color = Fore.GREEN if score.get_value() else Fore.RED
else:
score_color = Fore.YELLOW
self._print_colored(f"{indent}• Value: {score.score_value}", score_color)
if score.score_rationale:
print(f"{indent}• Rationale:")
# Create a custom wrapper for rationale with proper indentation
rationale_wrapper = textwrap.TextWrapper(
width=self._width - len(indent) - 2, # Adjust width to account for indentation
initial_indent=indent + " ",
subsequent_indent=indent + " ",
break_long_words=False,
break_on_hyphens=False,
)
# Split by newlines first to preserve them
lines = score.score_rationale.split("\n")
for line in lines:
if line.strip(): # Only wrap non-empty lines
wrapped_lines = rationale_wrapper.wrap(line)
for wrapped_line in wrapped_lines:
self._print_colored(wrapped_line, Fore.WHITE)
else: # Print empty lines as-is to preserve formatting
self._print_colored(f"{indent} ")
def _print_wrapped_text(self, text: str, color: str) -> None:
"""
Print text with proper wrapping and indentation, preserving newlines.
Wraps long lines while preserving the original line breaks and
applying consistent indentation and coloring.
Args:
text (str): The text to print. Can contain newlines.
color (str): Colorama color constant to apply to the text
(e.g., Fore.BLUE, Fore.RED).
"""
# Create a new wrapper for each text to ensure proper width calculation
text_wrapper = textwrap.TextWrapper(
width=self._width - len(self._indent), # Adjust width to account for indentation
initial_indent="",
subsequent_indent=self._indent,
break_long_words=True, # Allow breaking long words to prevent truncation
break_on_hyphens=True,
expand_tabs=False,
replace_whitespace=False, # Preserve whitespace formatting
)
# Split by newlines first to preserve them
lines = text.split("\n")
for line_num, line in enumerate(lines):
if line.strip(): # Only wrap non-empty lines
wrapped_lines = text_wrapper.wrap(line)
for i, wrapped_line in enumerate(wrapped_lines):
if line_num == 0 and i == 0:
self._print_colored(f"{self._indent}{wrapped_line}", color)
else:
self._print_colored(f"{self._indent * 2}{wrapped_line}", color)
else: # Print empty lines as-is to preserve formatting
self._print_colored(f"{self._indent}", color)
async def _print_pruned_conversations_async(self, result: AttackResult) -> None:
"""
Print pruned conversations showing only the last message and score for each.
Pruned conversations represent branches that were abandoned during the attack.
For each pruned conversation, only the final message and its associated score
are displayed to provide context without overwhelming output.
Args:
result (AttackResult): The attack result containing related conversations.
"""
pruned_refs = result.get_conversations_by_type(ConversationType.PRUNED)
if not pruned_refs:
return
self._print_section_header(f"Pruned Conversations ({len(pruned_refs)} total)")
for idx, ref in enumerate(pruned_refs, 1):
# Print conversation header with description if available
print()
self._print_colored("─" * self._width, Fore.RED)
label = f"🗑️ PRUNED #{idx}"
if ref.description:
label += f" - {ref.description}"
self._print_colored(label, Style.BRIGHT, Fore.RED)
self._print_colored("─" * self._width, Fore.RED)
# Get the conversation messages
messages = list(self._memory.get_conversation(conversation_id=ref.conversation_id))
if not messages:
self._print_colored(
f"{self._indent}No messages found for conversation: {ref.conversation_id}", Fore.YELLOW
)
continue
# Get only the last message
last_message = messages[-1]
# Print the last message
role_label = last_message.api_role.upper()
self._print_colored(f"{self._indent}Last Message ({role_label}):", Style.BRIGHT, Fore.WHITE)
for piece in last_message.message_pieces:
self._print_wrapped_text(piece.converted_value, Fore.WHITE)
# Print associated scores
scores = self._memory.get_prompt_scores(prompt_ids=[str(piece.id)])
if scores:
print()
self._print_colored(f"{self._indent}📊 Score:", Style.DIM, Fore.MAGENTA)
for score in scores:
self._print_score(score)
print()
self._print_colored("─" * self._width, Fore.RED)
async def _print_adversarial_conversation_async(self, result: AttackResult) -> None:
"""
Print the adversarial conversation for the best-scoring attack branch.
The adversarial conversation shows the red teaming LLM's reasoning and
strategy development. For attacks with multiple adversarial conversations
(e.g., TAP), only the best-scoring branch's adversarial conversation is
shown if available.
Args:
result (AttackResult): The attack result containing related conversations.
"""
adversarial_refs = result.get_conversations_by_type(ConversationType.ADVERSARIAL)
if not adversarial_refs:
return
self._print_section_header("Adversarial Conversation (Red Team LLM)")
# Check if result has a best_adversarial_conversation_id (e.g., TAP attack)
# If so, only show that conversation instead of all adversarial conversations
best_adversarial_id = result.metadata.get("best_adversarial_conversation_id")
if best_adversarial_id:
# Filter to only the best adversarial conversation
adversarial_refs = [ref for ref in adversarial_refs if ref.conversation_id == best_adversarial_id]
if adversarial_refs:
self._print_colored(
f"{self._indent}📌 Showing best-scoring branch's adversarial conversation",
Style.DIM,
Fore.CYAN,
)
for ref in adversarial_refs:
if ref.description:
self._print_colored(f"{self._indent}📝 {ref.description}", Style.DIM, Fore.CYAN)
messages = list(self._memory.get_conversation(conversation_id=ref.conversation_id))
if not messages:
self._print_colored(
f"{self._indent}No messages found for conversation: {ref.conversation_id}", Fore.YELLOW
)
continue
await self.print_messages_async(messages=messages, include_scores=False)
def _get_outcome_color(self, outcome: AttackOutcome) -> str:
"""
Get the color for an outcome.
Maps AttackOutcome enum values to appropriate Colorama color constants.
Args:
outcome (AttackOutcome): The attack outcome enum value.
Returns:
str: Colorama color constant (Fore.GREEN, Fore.RED, Fore.YELLOW,
or Fore.WHITE for unknown outcomes).
"""
return str(
{
AttackOutcome.SUCCESS: Fore.GREEN,
AttackOutcome.FAILURE: Fore.RED,
AttackOutcome.UNDETERMINED: Fore.YELLOW,
}.get(outcome, Fore.WHITE)
)