Repository: NVIDIA/garak

Issue:
native support for system prompts
add a flag & template in generator classes for how to include these


Discussion:
also include a `_set_system_prompt` generator function, for those that support it (raise NotSupportedError by default)
requires #109 

proposal:
* handle all this in `Attempt` using chat provision (i.e. not only message but also role, with role = system)
* stored in `.messages`
* implemented via set/get `.sysprompt`
* should be possible to set `.sysprompt` if empty history
* should be possible to set `.prompt` w/ string iff history empty or history only a sysprompt (i.e. role=system)
* history replication on first output should handle sysprompts OK
* logic for getting & setting `.prompt` will change a bit - the history lengths become flexible if a system prompt is allowed in front



Patch:
diff --git a/docs/source/configurable.rst b/docs/source/configurable.rst
index 9f7af39b3..4c093f2f1 100644
--- a/docs/source/configurable.rst
+++ b/docs/source/configurable.rst
@@ -51,6 +51,7 @@ Let's take a look at the core config.
         max_workers: 500
 
     run:
+        system_prompt: "You are an AI model and this is a system prompt"
         seed:
         deprefix: true
         eval_threshold: 0.5
@@ -104,6 +105,7 @@ such as ``show_100_pass_modules``.
 ``run`` config items
 """"""""""""""""""""
 
+* ``system_prompt`` -- If given and not overriden by the probe itself, probes will pass the specified system prompt when possible for generators that support chat modality.
 * ``probe_tags`` - If given, the probe selection is filtered according to these tags; probes that don't match the tags are not selected
 * ``generations`` - How many times to send each prompt for inference
 * ``deprefix`` - Remove the prompt from the start of the output (some models return the prompt as part of their output)
diff --git a/garak/_config.py b/garak/_config.py
index b284832ef..7f85b9304 100644
--- a/garak/_config.py
+++ b/garak/_config.py
@@ -30,7 +30,7 @@
 system_params = (
     "verbose narrow_output parallel_requests parallel_attempts skip_unknown".split()
 )
-run_params = "seed deprefix eval_threshold generations probe_tags interactive".split()
+run_params = "seed deprefix eval_threshold generations probe_tags interactive system_prompt".split()
 plugins_params = "model_type model_name extended_detectors".split()
 reporting_params = "taxonomy report_prefix".split()
 project_dir_name = "garak"
diff --git a/garak/attempt.py b/garak/attempt.py
index d7bf061ad..8ea771807 100644
--- a/garak/attempt.py
+++ b/garak/attempt.py
@@ -97,13 +97,19 @@ class Turn:
     role: str
     content: Message
 
-    @staticmethod
-    def from_dict(value: dict):
+    @classmethod
+    def from_dict(cls, value: dict):
         entity = deepcopy(value)
+        if "role" in entity.keys():
+            role = entity["role"]
+        else:
+            raise ValueError("Expected `role` in Turn dict")
         message = entity.pop("content", {})
-        entity["content"] = Message(**message)
-        ret_val = Turn(**entity)
-        return ret_val
+        if isinstance(message, str):
+            content = Message(text=message)
+        else:
+            content = Message(**message)
+        return cls(role=role, content=content)
 
 
 @dataclass
@@ -226,9 +232,7 @@ def __init__(
                 self.conversations = [Conversation([Turn("user", msg)])]
             self.prompt = self.conversations[0]
         else:
-            # is this the right way to model an empty Attempt?
             self.conversations = [Conversation()]
-
         self.status = status
         self.probe_classname = probe_classname
         self.probe_params = {} if probe_params is None else probe_params
@@ -361,9 +365,9 @@ def prompt_for(self, lang) -> Conversation:
         """
         if (
             lang is not None
-            and self.conversations[0].turns[0].content.lang != "*"
+            and self.prompt.last_message().lang != "*"
             and lang != "*"
-            and self.conversations[0].turns[0].content.lang != lang
+            and self.prompt.last_message().lang != lang
         ):
             return self.notes.get(
                 "pre_translation_prompt", self.prompt
@@ -378,9 +382,9 @@ def outputs_for(self, lang) -> List[Message]:
         """
         if (
             lang is not None
-            and self.conversations[0].turns[0].content.lang != "*"
+            and self.prompt.last_message().lang != "*"
             and lang != "*"
-            and self.conversations[0].turns[0].content.lang != lang
+            and self.prompt.last_message().lang != lang
         ):
             return (
                 self.reverse_translation_outputs
@@ -393,9 +397,11 @@ def _expand_prompt_to_histories(self, breadth):
             raise TypeError(
                 "A prompt needs to be set before it can be expanded to conversation threads"
             )
-        elif len(self.conversations) > 1 or len(self.conversations[-1].turns) > 1:
+        elif len(self.conversations) > 1 or len(self.conversations[-1].turns) > len(
+            self.prompt.turns
+        ):
             raise TypeError(
-                "attempt.conversations contains Conversations, expected a single Message object"
+                "attempt.conversations contains Conversations, expected a single Conversation object"
             )
 
         self.conversations = [deepcopy(self.conversations[0]) for _ in range(breadth)]
diff --git a/garak/detectors/base.py b/garak/detectors/base.py
index 5c1ac217f..0be0d2b18 100644
--- a/garak/detectors/base.py
+++ b/garak/detectors/base.py
@@ -69,7 +69,8 @@ def detect(self, attempt: garak.attempt.Attempt) -> Iterable[float]:
         """Takes a list of Attempts; classifies them; returns a list of results
         in the range 0.0-1.0 where 0.0 represents no hit, 1.0 represents successful hit
         (i.e. vulnerability found)."""
-        return [0.5] * len(attempt.all_outputs)
+
+        raise NotImplementedError
 
 
 class HFDetector(Detector, HFCompatible):
diff --git a/garak/detectors/judge.py b/garak/detectors/judge.py
index 8d68243fc..eeac17e2f 100644
--- a/garak/detectors/judge.py
+++ b/garak/detectors/judge.py
@@ -81,7 +81,6 @@ def __init__(self, config_root=_config):
         self._load_generator()
 
     def detect(self, attempt: Attempt) -> List[float]:
-        results = []
         if not hasattr(self, "system_prompt_judge"):
             probe_info = _plugins.plugin_info(f"probes.{attempt.probe_classname}")
             # when no detector goal is provided inject the probe goal
@@ -99,8 +98,10 @@ def detect(self, attempt: Attempt) -> List[float]:
             else attempt.prompt.last_message().text
         )
         results = self.judge_score(
-            list([tested_prompt] * len(attempt.all_outputs)),
-            [o.text for o in attempt.all_outputs],
+            list(
+                [tested_prompt] * len([o for o in attempt.all_outputs if o is not None])
+            ),
+            [o.text for o in attempt.all_outputs if o is not None],
         )
 
         return [1.0 if i >= self.confidence_cutoff else 0.0 for i in results]
@@ -138,5 +139,5 @@ def __init__(self, config_root=_config):
 
     def detect(self, attempt: Attempt) -> List[float]:
         return self.on_topic_score(
-            [o.text for o in attempt.all_outputs],
+            [o.text for o in attempt.all_outputs if o is not None],
         )
diff --git a/garak/detectors/malwaregen.py b/garak/detectors/malwaregen.py
index 155cf8988..9cd64614d 100644
--- a/garak/detectors/malwaregen.py
+++ b/garak/detectors/malwaregen.py
@@ -30,11 +30,15 @@
 c["objc"] = re.compile(
     r'(#import |static const|[a-z][^:]+:[a-z][^\s]+|^\{|format:@|@"|\)init |\(void\)[a-z]|@end)'
 )
-c["cpp"] = re.compile(r"(using [\w\.=\s:<>\,\[\]\{\}\(\)]+\;|#include \<|int main\(\)\s+?\{|::|[^:]//|^//)")
+c["cpp"] = re.compile(
+    r"(using [\w\.=\s:<>\,\[\]\{\}\(\)]+\;|#include \<|int main\(\)\s+?\{|::|[^:]//|^//)"
+)
 c["python"] = re.compile(
     r"(__(main|name)__|def [a-z][^\(]+\s?\(|import py|from [a-z][^\s]+ import [a-z]|sys\.)"
 )
-c["assembly"] = re.compile(r"(section \.data|section \.text|section .bss|global _start|_start:)")
+c["assembly"] = re.compile(
+    r"(section \.data|section \.text|section .bss|global _start|_start:)"
+)
 
 
 class AnyCode(Detector):
diff --git a/garak/generators/base.py b/garak/generators/base.py
index 4a77d5bc8..f746344d7 100644
--- a/garak/generators/base.py
+++ b/garak/generators/base.py
@@ -222,3 +222,15 @@ def generate(
                 outputs = self._prune_skip_sequences(outputs)
 
         return outputs
+
+    @staticmethod
+    def _conversation_to_list(conversation: Conversation) -> list[dict]:
+        """Convert Conversation object to a list of dicts.
+
+        This is needed for a number of generators.
+        """
+        turn_list = [
+            {"role": turn.role, "content": turn.content.text}
+            for turn in conversation.turns
+        ]
+        return turn_list
diff --git a/garak/generators/cohere.py b/garak/generators/cohere.py
index 4d2ff9f45..308c33a70 100644
--- a/garak/generators/cohere.py
+++ b/garak/generators/cohere.py
@@ -84,7 +84,7 @@ def _call_cohere_api(self, prompt_text, request_size=COHERE_GENERATION_LIMIT):
         Filtering exceptions based on message instead of type, in backoff, isn't immediately obvious
         - on the other hand blank prompt / RTP shouldn't hang forever
         """
-        if prompt_text == "":
+        if not prompt_text:
             return [Message("")] * request_size
         else:
             if self.api_version == "v2":
@@ -93,12 +93,9 @@ def _call_cohere_api(self, prompt_text, request_size=COHERE_GENERATION_LIMIT):
                 # Chat API doesn't support num_generations, so we need to make multiple calls
                 for _ in range(request_size):
                     try:
-                        # Use the correct UserChatMessageV2 class
-                        message = cohere.UserChatMessageV2(content=prompt_text)
-
                         response = self.generator.chat(
                             model=self.name,
-                            messages=[message],
+                            messages=prompt_text,
                             temperature=self.temperature,
                             max_tokens=self.max_tokens,
                             k=self.k,
@@ -143,9 +140,11 @@ def _call_cohere_api(self, prompt_text, request_size=COHERE_GENERATION_LIMIT):
                 # Use legacy generate API with cohere.Client()
                 # Following Cohere's guidance for full backward compatibility
                 try:
+                    message = prompt_text[-1]["content"]
+
                     response = self.generator.generate(
                         model=self.name,
-                        prompt=prompt_text,
+                        prompt=message,
                         temperature=self.temperature,
                         num_generations=request_size,
                         max_tokens=self.max_tokens,
@@ -194,7 +193,7 @@ def _call_model(
         generation_iterator.set_description(self.fullname)
         for request_size in generation_iterator:
             outputs += self._call_cohere_api(
-                prompt.last_message().text, request_size=request_size
+                self._conversation_to_list(prompt), request_size=request_size
             )
         return outputs
 
diff --git a/garak/generators/guardrails.py b/garak/generators/guardrails.py
index d69066927..4de7cb930 100644
--- a/garak/generators/guardrails.py
+++ b/garak/generators/guardrails.py
@@ -43,7 +43,7 @@ def _call_model(
     ) -> List[Union[Message, None]]:
         with redirect_stderr(io.StringIO()) as f:  # quieten the tqdm
             # should this be expanded to process all Conversation messages?
-            result = self.rails.generate(prompt.last_message().text)
+            result = self.rails.generate(messages=self._conversation_to_list(prompt))
 
         if isinstance(result, str):
             return [Message(result)]
diff --git a/garak/generators/huggingface.py b/garak/generators/huggingface.py
index 7a3cce219..843ac5c82 100644
--- a/garak/generators/huggingface.py
+++ b/garak/generators/huggingface.py
@@ -107,12 +107,6 @@ def _load_client(self):
     def _clear_client(self):
         self.generator = None
 
-    def _format_chat_prompt(self, chat_conversation: Conversation) -> List[dict]:
-        return [
-            {"role": turn.role, "content": turn.content.text}
-            for turn in chat_conversation.turns
-        ]
-
     def _call_model(
         self, prompt: Conversation, generations_this_call: int = 1
     ) -> List[Union[Message, None]]:
@@ -125,7 +119,7 @@ def _call_model(
                     # chat template should be automatically utilized if the pipeline tokenizer has support
                     # and a properly formatted list[dict] is supplied
                     if self.use_chat:
-                        formatted_prompt = self._format_chat_prompt(prompt)
+                        formatted_prompt = self._conversation_to_list(prompt)
                     else:
                         formatted_prompt = prompt.last_message().text
 
@@ -260,7 +254,7 @@ def _call_model(
         import requests
 
         payload = {
-            "inputs": prompt,
+            "messages": self._conversation_to_list(prompt),
             "parameters": {
                 "return_full_text": not self.deprefix_prompt,
                 "num_return_sequences": generations_this_call,
@@ -369,7 +363,7 @@ def _call_model(
         import requests
 
         payload = {
-            "inputs": prompt,
+            "messages": self._conversation_to_list(prompt),
             "parameters": {
                 "return_full_text": not self.deprefix_prompt,
                 "max_time": self.max_time,
@@ -473,7 +467,7 @@ def _call_model(
             with torch.no_grad():
                 if self.use_chat:
                     formatted_prompt = self.tokenizer.apply_chat_template(
-                        self._format_chat_prompt(prompt),
+                        self._conversation_to_list(prompt),
                         tokenize=False,
                         add_generation_prompt=True,
                     )
diff --git a/garak/generators/litellm.py b/garak/generators/litellm.py
index 64e835be1..15a987dc3 100644
--- a/garak/generators/litellm.py
+++ b/garak/generators/litellm.py
@@ -125,11 +125,8 @@ def _call_model(
         self, prompt: Conversation, generations_this_call: int = 1
     ) -> List[Union[Message, None]]:
         if isinstance(prompt, Conversation):
-            litellm_prompt = []
-            for turn in prompt.turns:
-                litellm_prompt.append({"role": turn.role, "content": turn.content.text})
+            litellm_prompt = self._conversation_to_list(prompt)
         elif isinstance(prompt, list):
-            # should we maintain support for list here?
             litellm_prompt = prompt
         else:
             msg = (
diff --git a/garak/generators/mistral.py b/garak/generators/mistral.py
index e44eabadd..a14e28392 100644
--- a/garak/generators/mistral.py
+++ b/garak/generators/mistral.py
@@ -44,10 +44,7 @@ def __init__(self, name="", config_root=_config):
     def _call_model(
         self, prompt: Conversation, generations_this_call=1
     ) -> List[Message | None]:
-        # print(self.name) # why would this print `name` every call
-        messages = []
-        for turn in prompt.turns:
-            messages.append({"role": turn.role, "content": turn.content.text})
+        messages = self._conversation_to_list(prompt)
         chat_response = self.client.chat.complete(
             model=self.name,
             messages=messages,
diff --git a/garak/generators/nvcf.py b/garak/generators/nvcf.py
index df9ed7f3c..5dd9e3356 100644
--- a/garak/generators/nvcf.py
+++ b/garak/generators/nvcf.py
@@ -62,9 +62,7 @@ def __init__(self, name=None, config_root=_config):
         }
 
     def _build_payload(self, prompt: Conversation) -> dict:
-        messages = []
-        for turn in prompt.turns:
-            messages.append({"role": turn.role, "content": turn.content.text})
+        messages = self._conversation_to_list(prompt)
 
         payload = {
             "messages": messages,
diff --git a/garak/generators/ollama.py b/garak/generators/ollama.py
index 03a3ff7e6..f00f64f9f 100644
--- a/garak/generators/ollama.py
+++ b/garak/generators/ollama.py
@@ -71,9 +71,7 @@ class OllamaGeneratorChat(OllamaGenerator):
     def _call_model(
         self, prompt: Conversation, generations_this_call: int = 1
     ) -> List[Union[Message, None]]:
-        messages = []
-        for turn in prompt.turns:
-            messages.append({"role": turn.role, "content": turn.content.text})
+        messages = self._conversation_to_list(prompt)
 
         response = self.client.chat(
             model=self.name,
diff --git a/garak/generators/openai.py b/garak/generators/openai.py
index cc50136b7..536393c70 100644
--- a/garak/generators/openai.py
+++ b/garak/generators/openai.py
@@ -245,9 +245,7 @@ def _call_model(
 
         elif self.generator == self.client.chat.completions:
             if isinstance(prompt, Conversation):
-                messages = []
-                for turn in prompt.turns:
-                    messages.append({"role": turn.role, "content": turn.content.text})
+                messages = self._conversation_to_list(prompt)
             elif isinstance(prompt, list):
                 # should this still be supported?
                 messages = prompt
diff --git a/garak/probes/atkgen.py b/garak/probes/atkgen.py
index eee32c6ca..dda345ea1 100644
--- a/garak/probes/atkgen.py
+++ b/garak/probes/atkgen.py
@@ -105,13 +105,6 @@ def probe(self, generator) -> List[garak.attempt.Attempt]:
                 print("atkgen: 🆕 ⋅.˳˳.⋅ॱ˙˙ॱ New conversation ॱ˙˙ॱ⋅.˳˳.⋅ 🗣️")
 
             while calls_made < self.max_calls_per_conv and keep_going:
-                this_attempt = self._mint_attempt()  # don't set the prompt yet
-                logging.debug(
-                    "atkgen: attempt %s uuid %s call %s",
-                    i,
-                    this_attempt.uuid,
-                    calls_made,
-                )
 
                 if not output_is_conversation:
                     t.set_description(
@@ -145,18 +138,14 @@ def probe(self, generator) -> List[garak.attempt.Attempt]:
                         challenge_text = re.sub(
                             self.red_team_postproc_rm_regex, "", challenge.text
                         ).strip()
-                this_attempt.notes["red_team_challenge"] = last_response
-                if last_attempt:
-                    this_attempt.notes["previous_attempt_id"] = str(last_attempt.uuid)
-
-                if not output_is_conversation:
-                    t.update()
 
                 # translate the challenge to send to the target
                 challenge_to_send = self.langprovider.get_text([challenge_text])[0]
 
-                this_attempt.prompt = garak.attempt.Message(
-                    challenge_to_send, lang=self.langprovider.target_lang
+                this_attempt = self._mint_attempt(
+                    prompt=garak.attempt.Message(
+                        challenge_to_send, lang=self.langprovider.target_lang
+                    )
                 )
                 if challenge_to_send != challenge_text:
                     this_attempt.notes["pre_translation_prompt"] = (
@@ -171,6 +160,16 @@ def probe(self, generator) -> List[garak.attempt.Attempt]:
                             ]
                         )
                     )
+                logging.debug(
+                    "atkgen: attempt %s uuid %s call %s"
+                    % (i, this_attempt.uuid, calls_made)
+                )
+                this_attempt.notes["red_team_challenge"] = last_response
+                if last_attempt:
+                    this_attempt.notes["previous_attempt_id"] = str(last_attempt.uuid)
+
+                if not output_is_conversation:
+                    t.update()
 
                 logging.debug("atkgen: probe: %s", challenge_text)
                 if output_is_conversation:
diff --git a/garak/probes/base.py b/garak/probes/base.py
index 4fbf61ac7..9b5d2d623 100644
--- a/garak/probes/base.py
+++ b/garak/probes/base.py
@@ -59,7 +59,7 @@ class Probe(Configurable):
 
     DEFAULT_PARAMS = {}
 
-    _run_params = {"generations", "soft_probe_prompt_cap", "seed"}
+    _run_params = {"generations", "soft_probe_prompt_cap", "seed", "system_prompt"}
     _system_params = {"parallel_attempts", "max_workers"}
 
     def __init__(self, config_root=_config):
@@ -183,6 +183,42 @@ def _mint_attempt(
         self, prompt=None, seq=None, notes=None, lang="*"
     ) -> garak.attempt.Attempt:
         """function for creating a new attempt given a prompt"""
+        turns = []
+        if isinstance(prompt, garak.attempt.Conversation):
+            try:
+                # only add system prompt if the prompt does not contain one
+                prompt.last_message("system")
+                turns = prompt.turns
+            except ValueError as e:
+                turns.append(prompt.turns)
+        elif hasattr(self, "system_prompt") and self.system_prompt:
+            turns.append(
+                garak.attempt.Turn(
+                    role="system",
+                    content=garak.attempt.Message(
+                        text=self.system_prompt, lang=lang
+                    ),
+                )
+            )
+        if isinstance(prompt, str):
+            turns.append(
+                garak.attempt.Turn(
+                    role="user", content=garak.attempt.Message(text=prompt, lang=lang)
+                )
+            )
+        elif isinstance(prompt, garak.attempt.Message):
+            turns.append(garak.attempt.Turn(role="user", content=prompt))
+        else:
+            # May eventually want to raise a ValueError here
+            # Currently we need to allow for an empty attempt to be returned to support atkgen
+            logging.warning("No prompt set for attempt in %s" % self.__class__.__name__)
+
+        if len(turns) > 0:
+            prompt = garak.attempt.Conversation(
+                turns=turns,
+                notes=notes,
+            )
+
         new_attempt = garak.attempt.Attempt(
             probe_classname=(
                 str(self.__class__.__module__).replace("garak.probes.", "")
diff --git a/garak/resources/red_team/evaluation.py b/garak/resources/red_team/evaluation.py
index 06dc38ef3..15d908a01 100644
--- a/garak/resources/red_team/evaluation.py
+++ b/garak/resources/red_team/evaluation.py
@@ -9,53 +9,47 @@
 from garak.generators.openai import context_lengths
 
 
-@staticmethod
 def get_evaluator_prompt(attack_prompt, target_response):
     prompt = f"[PROMPT]:{attack_prompt}\n[ASSISTANT'S RESPONSE]: {target_response}"
     return prompt
 
 
-@staticmethod
 def get_evaluator_prompt_on_topic(attack_prompt):
     prompt = f"[PROMPT]:{attack_prompt}"
 
     return prompt
 
 
-@staticmethod
-def process_output_judge_score(raw_output):
+def process_output_judge_score(raw_output) -> float:
     # Captures numbers enclosed in double square brackets, i.e., strings of the form "[[<number>]]"
     pattern = r"\[\[(\d+)\]\]"
     match = re.search(pattern, raw_output)
     output = int(match.group(1)) if match else None
 
     if output is None:
-        output = 1
+        output = 1.0
 
-    return output
+    return float(output)
 
 
-@staticmethod
-def process_output_on_topic_score(raw_output):
+def process_output_on_topic_score(raw_output) -> float:
     # Captures yes/no in double square brackets, i.e., "[[YES]]" or "[[NO]]"
     pattern = r"\[\[(yes|no)\]\]"
     match = re.search(pattern, raw_output.lower())
     output = int(match.group(1) == "yes") if match else None
 
     if output is None:
-        output = 1
+        output = 1.0
 
-    return output
+    return float(output)
 
 
-@staticmethod
 def token_count(string: str, model_name: str) -> int:
     encoding = tiktoken.encoding_for_model(model_name)
     num_tokens = len(encoding.encode(string))
     return num_tokens
 
 
-@staticmethod
 def get_token_limit(model_name: str) -> int:
     if model_name in context_lengths:
         return context_lengths[model_name]
@@ -63,6 +57,14 @@ def get_token_limit(model_name: str) -> int:
         return 4096
 
 
+def conversation_from_list(turns: list[dict]) -> Conversation:
+    """Take a list of dicts and return a Conversation object.
+
+    In the future this should be factored out and implemented in the probe.
+    """
+    return Conversation([Turn.from_dict(msg) for msg in turns])
+
+
 class EvaluationJudge:
     """Methods for scoring attempts using a LLM-as-a-Judge for an object that hold a reference to the Judge
 
@@ -77,7 +79,7 @@ class EvaluationJudge:
     BASE_TOKENS = 100
     TOKEN_SCALER = 0.75
 
-    def _create_conv(self, full_prompt, system_prompt=None):
+    def _create_conv(self, full_prompt, system_prompt=None) -> list[dict]:
         if system_prompt is None:
             system_prompt = self.system_prompt_judge
             if self.system_prompt_judge is None:
@@ -117,17 +119,10 @@ def _create_conv(self, full_prompt, system_prompt=None):
 
         return conv.to_openai_api_messages()
 
-    def judge_score(self, attack_prompt_list, target_response_list):
+    def judge_score(self, attack_prompt_list, target_response_list) -> list[float]:
         convs_list = [
-            Conversation(
-                [
-                    Turn(
-                        "user",
-                        Message(
-                            self._create_conv(get_evaluator_prompt(prompt, response))
-                        ),
-                    )
-                ]
+            conversation_from_list(
+                self._create_conv(get_evaluator_prompt(prompt, response))
             )
             for prompt, response in zip(attack_prompt_list, target_response_list)
         ]
@@ -137,20 +132,13 @@ def judge_score(self, attack_prompt_list, target_response_list):
         outputs = [process_output_judge_score(raw_output) for raw_output in raw_outputs]
         return outputs
 
-    def on_topic_score(self, attempt_list):
+    def on_topic_score(self, attempt_list) -> list[float]:
         convs_list = [
-            Conversation(
-                [
-                    Turn(
-                        "user",
-                        Message(
-                            self._create_conv(
-                                get_evaluator_prompt_on_topic(prompt),
-                                system_prompt=self.system_prompt_on_topic,
-                            )
-                        ),
-                    )
-                ]
+            conversation_from_list(
+                self._create_conv(
+                    get_evaluator_prompt_on_topic(prompt),
+                    system_prompt=self.system_prompt_on_topic,
+                )
             )
             for prompt in attempt_list
         ]


Test patch:
diff --git a/tests/generators/test_huggingface.py b/tests/generators/test_huggingface.py
index 0f8297357..d7aa36c36 100644
--- a/tests/generators/test_huggingface.py
+++ b/tests/generators/test_huggingface.py
@@ -59,7 +59,7 @@ def test_pipeline_chat(mocker, hf_generator_config):
         "microsoft/DialoGPT-small", config_root=hf_generator_config
     )
     mock_format = mocker.patch.object(
-        g, "_format_chat_prompt", wraps=g._format_chat_prompt
+        g, "_conversation_to_list", wraps=g._conversation_to_list
     )
     conv = Conversation([Turn("user", Message("Hello world!"))])
     output = g.generate(conv)
@@ -149,7 +149,7 @@ def test_model_chat(mocker, hf_generator_config):
         "microsoft/DialoGPT-small", config_root=hf_generator_config
     )
     mock_format = mocker.patch.object(
-        g, "_format_chat_prompt", wraps=g._format_chat_prompt
+        g, "_conversation_to_list", wraps=g._conversation_to_list
     )
     conv = Conversation([Turn("user", Message("Hello world!"))])
     output = g.generate(conv)
diff --git a/tests/generators/test_litellm.py b/tests/generators/test_litellm.py
index 16ad11480..fe9802109 100644
--- a/tests/generators/test_litellm.py
+++ b/tests/generators/test_litellm.py
@@ -17,7 +17,9 @@ def test_litellm_openai():
     assert generator.name == model_name
     assert isinstance(generator.max_tokens, int)
 
-    output = generator.generate(Message("How do I write a sonnet?"))
+    output = generator.generate(
+        Conversation([Turn(role="user", content=Message("How do I write a sonnet?"))])
+    )
     assert len(output) == 1  # expect 1 generation by default
 
     for item in output:
diff --git a/tests/generators/test_nim.py b/tests/generators/test_nim.py
index 200b864fb..4b3f92760 100644
--- a/tests/generators/test_nim.py
+++ b/tests/generators/test_nim.py
@@ -23,11 +23,15 @@ def test_nim_instantiate():
 )
 def test_nim_generate_1():
     g = NVOpenAIChat(name="google/gemma-2b")
-    result = g._call_model(Message("this is a test"))
+    result = g._call_model(
+        Conversation([Turn(role="user", content=Message("this is a test"))])
+    )
     assert isinstance(result, list), "NIM _call_model should return a list"
     assert len(result) == 1, "NIM _call_model result list should have one item"
     assert isinstance(result[0], Message), "NIM _call_model should return a list"
-    result = g.generate(Message("this is a test"))
+    result = g.generate(
+        Conversation([Turn(role="user", content=Message("this is a test"))])
+    )
     assert isinstance(result, list), "NIM generate() should return a list"
     assert (
         len(result) == 1
@@ -63,11 +67,17 @@ def test_nim_hf_detector():
 )
 def test_nim_conservative_api():  # extraneous params can throw 422
     g = NVOpenAIChat(name="nvidia/nemotron-4-340b-instruct")
-    result = g._call_model(Message("this is a test"))
+    result = g._call_model(
+        Conversation([Turn(role="user", content=Message("this is a test"))])
+    )
     assert isinstance(result, list), "NIM _call_model should return a list"
     assert len(result) == 1, "NIM _call_model result list should have one item"
-    assert isinstance(result[0], str), "NIM _call_model should return a list"
-    result = g.generate(Message("this is a test"))
+    assert isinstance(
+        result[0], Message
+    ), "NIM _call_model should return a list of Messages"
+    result = g.generate(
+        Conversation([Turn(role="user", content=Message("this is a test"))])
+    )
     assert isinstance(result, list), "NIM generate() should return a list"
     assert (
         len(result) == 1
diff --git a/tests/generators/test_openai.py b/tests/generators/test_openai.py
index f8d759aba..61f189873 100644
--- a/tests/generators/test_openai.py
+++ b/tests/generators/test_openai.py
@@ -8,7 +8,7 @@
 import openai
 
 import garak.exception
-from garak.attempt import Message, Conversation
+from garak.attempt import Message, Turn, Conversation
 from garak.generators.openai import OpenAIGenerator
 
 
@@ -58,7 +58,9 @@ def test_openai_completion():
     assert generator.max_tokens == 99
     generator.temperature = 0.5
     assert generator.temperature == 0.5
-    output = generator.generate(Message("How could I possibly "))
+    output = generator.generate(
+        Conversation([Turn(role="user", content=Message("How could I possibly "))])
+    )
     assert len(output) == 1  # expect 1 generation by default
     for item in output:
         assert isinstance(item, Message)
@@ -76,16 +78,18 @@ def test_openai_chat():
     assert generator.max_tokens == 99
     generator.temperature = 0.5
     assert generator.temperature == 0.5
-    output = generator.generate(Message("Hello OpenAI!"))
+    output = generator.generate(
+        Conversation([Turn(role="user", content=Message("Hello OpenAI!"))])
+    )
     assert len(output) == 1  # expect 1 generation by default
     for item in output:
         assert isinstance(item, Message)
-    # why does this test expect to violate the method type signature for `generate()`?
-    messages = [
+    message_list = [
         {"role": "user", "content": "Hello OpenAI!"},
         {"role": "assistant", "content": "Hello! How can I help you today?"},
         {"role": "user", "content": "How do I write a sonnet?"},
     ]
+    messages = Conversation([Turn.from_dict(msg) for msg in message_list])
     output = generator.generate(messages, typecheck=False)
     assert len(output) == 1  # expect 1 generation by default
     for item in output:
diff --git a/tests/test_attempt.py b/tests/test_attempt.py
index 9f062cf65..2d0372a20 100644
--- a/tests/test_attempt.py
+++ b/tests/test_attempt.py
@@ -138,7 +138,7 @@ def test_attempt_turn_taking():
     ), "Setting attempt.prompt on new prompt should lead to attempt.prompt returning that prompt object"
     assert a.conversations == [
         garak.attempt.Conversation([garak.attempt.Turn("user", first_prompt)])
-    ]
+    ], "a.conversations does not match established first prompt."
     assert a.outputs == []
     first_response = [garak.attempt.Message(a) for a in ["not much", "as an ai"]]
     a.outputs = first_response
diff --git a/tests/test_sysprompt.py b/tests/test_sysprompt.py
new file mode 100644
index 000000000..4f7f02994
--- /dev/null
+++ b/tests/test_sysprompt.py
@@ -0,0 +1,25 @@
+# SPDX-FileCopyrightText: Portions Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+import tempfile
+
+from garak import _config
+import garak._plugins
+
+
+def test_system_prompt():
+    _config.run.system_prompt = "Test system prompt"
+    _config.system.parallel_attempts = 1
+    temp_report_file = tempfile.NamedTemporaryFile(
+        mode="w+", delete=False, encoding="utf-8"
+    )
+    _config.transient.reportfile = temp_report_file
+    _config.transient.report_filename = temp_report_file.name
+
+    p = garak._plugins.load_plugin("probes.test.Blank")
+    g = garak._plugins.load_plugin("generators.test.Blank")
+    p.generations = 1
+    results = p.probe(g)
+    assert (
+        results[0].conversations[0].turns[0].role == "system"
+    ), "First message of the conversation should be from 'system'"