#%%
import json
import os
import sys
import re
import time
import random
import textwrap
import traceback
import inspect
from pathlib import Path
from typing import Literal, Optional, Dict, List, Any, Tuple, Callable
import collections
import math

try:
    import openai
    from dotenv import load_dotenv
    import pandas as pd
    from huggingface_hub import InferenceClient
except ImportError:
    print("Error: Core libraries not found.")
    print("Please install them using: pip install openai python-dotenv pandas huggingface_hub google-generativeai")
    sys.exit(1)

API_KEYS: Dict[str, Optional[str]] = {}
try:
    script_location = Path(__file__).resolve().parent
    paths_to_check = [script_location, script_location.parent, script_location.parent.parent]
    dotenv_path_found = None
    for p_check in paths_to_check:
        temp_path = p_check / ".env"
        if temp_path.exists():
            dotenv_path_found = temp_path
            break

    if dotenv_path_found:
        load_dotenv(dotenv_path=dotenv_path_found)
        print(f"Loaded environment variables from: {dotenv_path_found}")
    else:
        print("Warning: .env file not found. Attempting to load from OS environment.")

    API_KEYS["openai"] = os.getenv("OPENAI_API_KEY")
    API_KEYS["huggingface"] = os.getenv("HF_API_KEY")
    API_KEYS["google"] = os.getenv("GOOGLE_API_KEY")

    if API_KEYS["openai"]: openai.api_key = API_KEYS["openai"]
    if not API_KEYS["openai"]: print("Warning: OPENAI_API_KEY not found.")
    if not API_KEYS["huggingface"]: print("Warning: HF_API_KEY not found.")
    if not API_KEYS["google"]: print("Warning: GOOGLE_API_KEY not found.")

except Exception as e:
    print(f"Error during API key loading: {e}")
    for key_to_check in ["openai", "huggingface", "google"]: API_KEYS.setdefault(key_to_check, None)

COOPERATE = "C"
DEFECT = "D"
PAYOFFS = {
    (COOPERATE, COOPERATE): (3, 3), (COOPERATE, DEFECT):   (0, 5),
    (DEFECT, COOPERATE):   (5, 0), (DEFECT, DEFECT):     (1, 1),
}
SYSTEM_DEFAULT_FALLBACK_MOVE = DEFECT 
DEFAULT_NUM_IPD_ROUNDS_IF_NO_EXPERIMENT = 50 

PRIMARY_LLM_CONFIG = {
    "api_type": "huggingface", 
    "model_name": "deepseek-ai/DeepSeek-V3-0324",
    "id": "DeepSeek-V3",
    "provider": "novita" 
}

TIMESTAMP = time.strftime("%Y%m%d_%H%M%S")
BASE_OUTPUT_DIR = Path("similarity_agent_runs") / f"run_{TIMESTAMP}"
BASE_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
print(f"Outputs for this run will be saved in: {BASE_OUTPUT_DIR}")

class LLMInterface:
    def __init__(self, api_type: str, model_name: str, global_api_keys: Dict[str, Optional[str]], provider: Optional[str] = None):
        self.api_type = api_type.lower()
        self.model_name = model_name
        self.provider = provider
        self.client = None

        if self.api_type == "openai":
            if not global_api_keys.get("openai"): raise ValueError("OpenAI API key not found.")
            if hasattr(openai, "OpenAI"):
                self.client = openai.OpenAI(api_key=global_api_keys["openai"]).chat.completions
            else: 
                self.client = openai.chat.completions
        
        elif self.api_type == "huggingface":
            if InferenceClient is None: raise ImportError("huggingface_hub library required.")
            hf_key = global_api_keys.get("huggingface")
            if not hf_key: raise ValueError("HF_API_KEY not found for Hugging Face.")
            try:
                client_args = {"model": self.model_name, "token": hf_key}
                if self.provider:
                    client_args["provider"] = self.provider
                self.client = InferenceClient(**client_args)
            except Exception as e: 
                raise RuntimeError(f"Error initializing HF InferenceClient for {self.model_name} (provider: {self.provider}): {e}")
        else:
            raise NotImplementedError(f"API type '{api_type}' is not supported.")
        print(f"LLMInterface initialized for {self.api_type}:{self.model_name} (Provider: {self.provider or 'default'})")

    def _call_api(self, prompt: str, max_tokens: int, temperature: float) -> Optional[str]:
        try:
            if self.api_type == "openai":
                params_openai = {"model": self.model_name, "messages": [{"role": "user", "content": prompt}], "temperature": temperature, "n": 1, "stop": None}
                if "o4-mini" in self.model_name or "gpt-4o-mini" in self.model_name: 
                    params_openai["max_completion_tokens"] = max_tokens
                else:
                    params_openai["max_tokens"] = max_tokens
                response = self.client.create(**params_openai)
                return response.choices[0].message.content.strip() if response.choices and response.choices[0].message else None
            
            elif self.api_type == "huggingface":
                effective_temp = max(0.01, temperature) if temperature == 0 else temperature
                response = self.client.chat_completion(messages=[{"role": "user", "content": prompt}], max_tokens=max_tokens, temperature=effective_temp)
                return response.choices[0].message.content.strip() if response.choices and response.choices[0].message else None 
            
        except Exception as e:
            print(f"Error during {self.api_type} API call ({self.model_name}, provider: {self.provider if self.api_type == 'huggingface' else 'N/A'}): {e}\n{traceback.format_exc()}")
            return None

    def generate(self, prompt: str, max_tokens: int, temperature: float) -> Optional[str]:
        return self._call_api(prompt, max_tokens, temperature)

class SimilarityStrategyGenerator:
    SIMILARITY_META_AGENT_PROMPT_TEMPLATE = textwrap.dedent("""
    You are an expert Python programmer. Your task is to implement a strategy for an Iterated Prisoner's Dilemma (IPD) match of {ipd_match_rounds} rounds.
    You are Player {player_id_display}.

    **Your Primary Goal: Algorithmic Similarity Assessment for Cooperation (Heuristic-Based)**
    Your strategy should aim to cooperate with opponents whose `opponent_program_code` suggests they will behave algorithmically similar to your own strategy's intended logic, using heuristic analysis of their code. Against opponents deemed dissimilar or potentially exploitative, you may be more cautious or defect.

    **Function Signature You MUST Implement:**
    ```python
    def {strategy_name}(my_history: list, opp_history: list, opponent_program_code: str) -> str:
        # my_history: Your moves ('C' or 'D') so far.
        # opp_history: Opponent's moves ('C' or 'D') so far.
        # opponent_program_code: The source code of your opponent's strategy.
        # Return "C" for Cooperate or "D" for Defect.
    ```

    **Implementation Rules & Guidelines:**
    1.  **Core Logic & Heuristic Analysis:**
        * Define your own intended IPD strategy (e.g., it might aim to be a sophisticated conditional cooperator, or a Tit-for-Tat variant).
        * Analyze the `opponent_program_code` string for heuristics. Examples:
            * Search for keywords like "COOPERATE", "DEFECT", "history", "random.choice(['C', 'D'])", "always_defect", "always_cooperate", "tit_for_tat", "return D", "return COOPERATE".
            * Look for patterns: Does the opponent's code seem to mirror its own source code (potential self-recognition for cooperation)? Does it check `len(opp_history)`?
            * Count occurrences of "DEFECT" vs "COOPERATE" in string literals or as return values.
            * A simple heuristic could be: if opponent's code is very short (e.g., < 5 lines), assume simple strategy (maybe cooperate). If very long and complex, be cautious.
    2.  **Decision Making:** Based on your heuristic analysis of `opponent_program_code` and game history, decide your move ("C" or "D").
    3.  **Fallback/Safety:** If your analysis is inconclusive, default to a reasonable backup strategy (e.g., Tit-for-Tat, Cooperate on first round, Defect if unsure).
    4.  **Standard IPD Practices:** Consider being initially cooperative.
    5.  **Return Value:** Must be "C" or "D".
    6.  **No `import` Statements:** You CANNOT use `import` statements. Standard modules like `random`, `math`, `re`, `collections` (e.g., `Counter`, `deque`) are available if you need them for your heuristic logic.
    7.  **Efficiency:** Your heuristic analysis should be reasonably efficient.

    **Output Format Requirements:**
    * **PYTHON CODE ONLY:** Your response MUST contain ONLY the complete Python function definition for `{strategy_name}`.
    * **NO EXTRA TEXT:** Start *immediately* with `def {strategy_name}(...)`.

    Generate the Python code for function `{strategy_name}` now:
    """)

    def __init__(self, llm_interface: LLMInterface):
        self.llm_interface = llm_interface
        print(f"SimilarityStrategyGenerator initialized with LLM: {llm_interface.api_type}:{llm_interface.model_name} (Provider: {llm_interface.provider or 'default'})")

    def _clean_llm_code_output(self, text_output: Optional[str]) -> Optional[str]:
        if not text_output: return None
        cleaned_output = re.sub(r'<think>.*?</think>', '', text_output, flags=re.DOTALL | re.IGNORECASE)
        cleaned_output = re.sub(r"^```(?:python)?\s*", "", cleaned_output, flags=re.MULTILINE)
        cleaned_output = re.sub(r"\s*```\s*$", "", cleaned_output).strip()
        return cleaned_output

    def generate_similarity_agent_code(
        self, player_id_display: str, strategy_name: str,
        agent_type: Literal["meta"],
        num_ipd_rounds_for_prompt: int,
        max_tokens_gen: int = 2000, temperature_gen: float = 0.4 
    ) -> Optional[str]:
        print(f"--- Generating {agent_type.upper()} AGENT code for '{strategy_name}' ({player_id_display}) ---")
        
        prompt = self.SIMILARITY_META_AGENT_PROMPT_TEMPLATE.format(
            player_id_display=player_id_display,
            strategy_name=strategy_name,
            ipd_match_rounds=num_ipd_rounds_for_prompt
        )
        
        raw_code = self.llm_interface.generate(prompt, max_tokens_gen, temperature_gen)
        code = self._clean_llm_code_output(raw_code)

        if not code: 
            print(f"Error: Primary LLM returned no code for '{strategy_name}'. Raw output:\n{raw_code}")
            return None
            
        print(f"Primary LLM generated code snippet for '{strategy_name}'.")
        return code

def compile_strategy_from_string_similarity(
    strat_name: str, code: str
) -> Optional[Tuple[str, Callable]]:
    if not code:
        print(f"Error: Code for '{strat_name}' is empty.")
        return None
    exec_ns = {
        'List': List, 'Dict': Dict, 'Optional': Optional, 'Literal': Literal, 'Any': Any,
        'random': random, 're': re, 'math': math,
        'Counter': collections.Counter, 'deque': collections.deque,
        'COOPERATE': COOPERATE, 'DEFECT': DEFECT,
        '__builtins__': {
            'print': print, 'len': len, 'range': range, 'list': list, 'dict': dict, 
            'str': str, 'int': int, 'float': float, 'bool': bool, 
            'True': True, 'False': False, 'None': None,
            'max': max, 'min': min, 'sum': sum, 'abs': abs, 'round': round,
            'any': any, 'all': all, 'zip': zip, 'enumerate': enumerate,
            'sorted': sorted, 'reversed': reversed, 'set': set, 'tuple': tuple,
            'hasattr': hasattr,
        }
    }
    try:
        exec(code, exec_ns)
        func = exec_ns.get(strat_name)
        actual_strat_name = strat_name
        if not func or not callable(func):
            defined_funcs = {k: v for k, v in exec_ns.items() if inspect.isfunction(v)}
            if len(defined_funcs) == 1:
                actual_strat_name = list(defined_funcs.keys())[0]
                func = defined_funcs[actual_strat_name]
            else:
                print(f"Error: Function '{strat_name}' not found or not callable. Found: {list(defined_funcs.keys())}")
                return None
        sig = inspect.signature(func)
        required_params = ['my_history', 'opp_history', 'opponent_program_code']
        if not all(p in sig.parameters for p in required_params):
            print(f"Error: Signature for '{actual_strat_name}' is incompatible. Expected: {required_params}, Got: {list(sig.parameters.keys())}.")
            return None
        return actual_strat_name, func
    except SyntaxError as e:
        print(f"SyntaxError compiling '{strat_name}': {e}\n--- Code ---\n{code}\n--- End Code ---")
        return None
    except Exception as e:
        print(f"Error compiling '{strat_name}': {e}\n{traceback.format_exc()}")
        return None

def run_similarity_ipd_match(
    agent_A_info: Dict[str, Any], 
    agent_B_info: Dict[str, Any], 
    num_rounds: int
) -> List[Dict[str, Any]]:
    hist_A, hist_B = [], []
    score_A, score_B = 0, 0
    game_log: List[Dict[str, Any]] = []
    name_A = agent_A_info['name']
    name_B = agent_B_info['name']
    func_A = agent_A_info['func']
    func_B = agent_B_info['func']
    code_A = agent_A_info['code']
    code_B = agent_B_info['code']
    
    for turn_num in range(1, num_rounds + 1):
        move_A, move_B = SYSTEM_DEFAULT_FALLBACK_MOVE, SYSTEM_DEFAULT_FALLBACK_MOVE
        try:
            action_A = func_A(list(hist_A), list(hist_B), str(code_B))
            if action_A in [COOPERATE, DEFECT]: move_A = action_A
        except Exception as e:
            print(f"    Error in {name_A}'s strategy (run {turn_num}): {e}. Using fallback.")
        try:
            action_B = func_B(list(hist_B), list(hist_A), str(code_A))
            if action_B in [COOPERATE, DEFECT]: move_B = action_B
        except Exception as e:
            print(f"    Error in {name_B}'s strategy (run {turn_num}): {e}. Using fallback.")
        
        hist_A.append(move_A)
        hist_B.append(move_B)
        pay_A, pay_B = PAYOFFS.get((move_A, move_B), (0,0)) 
        score_A += pay_A
        score_B += pay_B
        log_entry = {
            "round": turn_num,
            f"{name_A}_move": move_A, f"{name_B}_move": move_B,
            f"{name_A}_payoff_this_round": pay_A, f"{name_B}_payoff_this_round": pay_B,
            f"{name_A}_total_score": score_A, f"{name_B}_total_score": score_B,
        }
        game_log.append(log_entry)
    return game_log

if __name__ == "__main__":
    print("*"*10 + " Similarity Agent Experiment Initializing " + "*"*10)

    try:
        primary_llm = LLMInterface(
            api_type=PRIMARY_LLM_CONFIG["api_type"],
            model_name=PRIMARY_LLM_CONFIG["model_name"],
            global_api_keys=API_KEYS,
            provider=PRIMARY_LLM_CONFIG.get("provider")
        )
    except Exception as e:
        print(f"FATAL: Could not initialize Primary LLM Interface for {PRIMARY_LLM_CONFIG['id']}. Error: {e}")
        sys.exit(1)
        
    agent_generator = SimilarityStrategyGenerator(primary_llm)
    player_labels = ["PlayerA", "PlayerB"]

    experiment_configs = {
        "num_experimental_runs": 10,
        "num_shots_per_match": 20,
    }
    NUM_IPD_ROUNDS = experiment_configs["num_shots_per_match"]

    main_results_dir = BASE_OUTPUT_DIR / "experimental_results"
    main_results_dir.mkdir(parents=True, exist_ok=True)
    
    experiment_types_to_run: List[Literal["meta"]] = ["meta"]
    overall_summary_results = {}

    for agent_mode in experiment_types_to_run:
        print(f"\n\n{'='*25} STARTING EXPERIMENT: {agent_mode.upper()} AGENTS {'='*25}")
        
        experiment_type_dir = main_results_dir / agent_mode
        experiment_type_dir.mkdir(parents=True, exist_ok=True)

        total_cooperations_type = 0
        total_moves_type = 0
        p_c_cc_numerator_type = 0
        p_c_cc_denominator_type = 0
        
        cooperation_sum_per_shot_type = [0] * NUM_IPD_ROUNDS 
        
        all_match_logs_for_type: List[pd.DataFrame] = []

        for run_idx in range(experiment_configs["num_experimental_runs"]):
            print(f"\n--- {agent_mode.upper()} Run {run_idx + 1}/{experiment_configs['num_experimental_runs']} ---")
            
            current_run_agents_info = []
            generated_agent_names = []

            for i, p_label in enumerate(player_labels):
                print(f"  Generating agent {p_label} for run {run_idx + 1} ({agent_mode})...")
                strategy_name_base = f"{p_label}_{PRIMARY_LLM_CONFIG['id'].replace('-', '_')}_{agent_mode.capitalize()}Agent_Run{run_idx+1}_Iter{i}"
                strategy_name_final = re.sub(r'\W|^(?=\d)','_', strategy_name_base)
                
                agent_code = agent_generator.generate_similarity_agent_code(
                    player_id_display=p_label, 
                    strategy_name=strategy_name_final,
                    agent_type=agent_mode,
                    num_ipd_rounds_for_prompt=NUM_IPD_ROUNDS
                )
                if not agent_code:
                    print(f"FATAL: Failed to generate code for {p_label} ({agent_mode}, run {run_idx+1}). Skipping run.")
                    break
                
                code_filepath = experiment_type_dir / f"agent_code_run{run_idx+1}_{strategy_name_final}.py"
                try:
                    with open(code_filepath, "w", encoding="utf-8") as f:
                        f.write(f"# Generated Python Strategy for: {p_label} ({agent_mode})\n")
                        f.write(f"# Run Index: {run_idx+1}\n")
                        f.write(f"# Primary LLM: {PRIMARY_LLM_CONFIG['id']} ({PRIMARY_LLM_CONFIG['model_name']}) Provider: {PRIMARY_LLM_CONFIG.get('provider')}\n\n")
                        f.write(agent_code)
                except Exception as e_save:
                    print(f"  Warning: Could not save code for {p_label} to {code_filepath}: {e_save}")

                compile_result = compile_strategy_from_string_similarity(strategy_name_final, agent_code)
                if not compile_result:
                    print(f"FATAL: Failed to compile code for {p_label} ({agent_mode}, run {run_idx+1}). Skipping run.")
                    break
                
                actual_name, func_obj = compile_result
                generated_agent_names.append(actual_name)
                current_run_agents_info.append({
                    "name": actual_name, "id_label": p_label,
                    "func": func_obj, "code": agent_code,
                    "code_path": str(code_filepath)
                })
            
            if len(current_run_agents_info) != 2:
                print(f"  Skipping match for run {run_idx+1} due to agent preparation failure.")
                continue

            agent_A_info = current_run_agents_info[0]
            agent_B_info = current_run_agents_info[1]
            
            print(f"  Starting IPD match for run {run_idx+1}: {agent_A_info['name']} vs {agent_B_info['name']}")
            match_log_data = run_similarity_ipd_match(agent_A_info, agent_B_info, NUM_IPD_ROUNDS)
            
            if not match_log_data:
                print(f"  Warning: Match log data is empty for run {run_idx+1}. Skipping analysis for this run.")
                continue

            log_df = pd.DataFrame(match_log_data)
            all_match_logs_for_type.append(log_df)
            
            csv_log_filename = f"run_{run_idx+1}_gamelog_{agent_A_info['name']}_vs_{agent_B_info['name']}.csv"
            csv_log_path = experiment_type_dir / csv_log_filename
            try:
                log_df.to_csv(csv_log_path, index=False)
                print(f"  Saved game log for run {run_idx+1} to: {csv_log_path.name}")
            except Exception as e_csv:
                print(f"  Error saving game log to CSV for run {run_idx+1}: {e_csv}")

            name_A_in_log = agent_A_info['name']
            name_B_in_log = agent_B_info['name']
            
            for shot_idx, round_data in log_df.iterrows():
                move_A = round_data[f"{name_A_in_log}_move"]
                move_B = round_data[f"{name_B_in_log}_move"]

                if move_A == COOPERATE: total_cooperations_type += 1
                if move_B == COOPERATE: total_cooperations_type += 1
                total_moves_type += 2

                current_shot_coops = 0
                if move_A == COOPERATE: current_shot_coops +=1
                if move_B == COOPERATE: current_shot_coops +=1
                cooperation_sum_per_shot_type[shot_idx] += current_shot_coops
            
            print(f"  Finished processing run {run_idx + 1}/{experiment_configs['num_experimental_runs']}.")

        print(f"\nCalculating P(C|CC) for {agent_mode.upper()} agents...")
        for match_df in all_match_logs_for_type:
            if match_df.empty: continue
            
            move_cols = [col for col in match_df.columns if col.endswith('_move')]
            if len(move_cols) != 2:
                print(f"  Warning: Could not determine agent names from log columns: {move_cols}. Skipping P(C|CC) for this log.")
                continue
            
            agent1_log_name = move_cols[0].replace('_move', '')
            agent2_log_name = move_cols[1].replace('_move', '')

            for i in range(len(match_df)):
                if i == 0: continue

                prev_round_data = match_df.iloc[i-1]
                current_round_data = match_df.iloc[i]

                prev_move_A = prev_round_data[f"{agent1_log_name}_move"]
                prev_move_B = prev_round_data[f"{agent2_log_name}_move"]
                
                current_move_A = current_round_data[f"{agent1_log_name}_move"]
                current_move_B = current_round_data[f"{agent2_log_name}_move"]

                if prev_move_A == COOPERATE and prev_move_B == COOPERATE:
                    p_c_cc_denominator_type += 2
                    if current_move_A == COOPERATE:
                        p_c_cc_numerator_type += 1
                    if current_move_B == COOPERATE:
                        p_c_cc_numerator_type += 1
        
        prob_C_type = total_cooperations_type / total_moves_type if total_moves_type > 0 else 0.0
        prob_C_given_CC_type = p_c_cc_numerator_type / p_c_cc_denominator_type if p_c_cc_denominator_type > 0 else 0.0
        
        num_successful_runs = len(all_match_logs_for_type)
        if num_successful_runs == 0:
            print(f"Warning: No successful runs completed for {agent_mode} agents. Plot data will be zero.")
            avg_coop_plot_data_type = [0.0] * NUM_IPD_ROUNDS
        else:
             avg_coop_plot_data_type = [
                s / (num_successful_runs * 2) for s in cooperation_sum_per_shot_type
            ]

        print(f"\n--- Summary for {agent_mode.upper()} Agents ({num_successful_runs} successful runs) ---")
        print(f"  Overall Cooperation Probability P(C): {prob_C_type:.4f}")
        print(f"  Niceness Index P(C|CC): {prob_C_given_CC_type:.4f}")

        summary_data = {
            "agent_mode": agent_mode,
            "num_experimental_runs_configured": experiment_configs["num_experimental_runs"],
            "num_shots_per_match": NUM_IPD_ROUNDS,
            "num_successful_runs": num_successful_runs,
            "overall_P_C": prob_C_type,
            "niceness_P_C_given_CC": prob_C_given_CC_type,
            "P_C_CC_numerator": p_c_cc_numerator_type,
            "P_C_CC_denominator": p_c_cc_denominator_type,
            "total_cooperations": total_cooperations_type,
            "total_moves": total_moves_type,
            "primary_llm_config": PRIMARY_LLM_CONFIG,
        }
        
        overall_summary_results[agent_mode] = summary_data

        summary_stats_path = experiment_type_dir / "summary_stats.json"
        try:
            with open(summary_stats_path, "w") as f:
                json.dump(summary_data, f, indent=4)
            print(f"  Saved summary stats to: {summary_stats_path.name}")
        except Exception as e_json:
            print(f"  Error saving summary_stats.json: {e_json}")

        plot_data_df = pd.DataFrame({
            "shot_number": list(range(1, NUM_IPD_ROUNDS + 1)),
            "average_cooperation_rate": avg_coop_plot_data_type
        })
        plot_data_csv_path = experiment_type_dir / "cooperation_plot_data.csv"
        try:
            plot_data_df.to_csv(plot_data_csv_path, index=False)
            print(f"  Saved cooperation plot data to: {plot_data_csv_path.name}")
        except Exception as e_csv_plot:
            print(f"  Error saving cooperation_plot_data.csv: {e_csv_plot}")

    print("\n\n" + "*"*10 + " Full Experiment Suite Finished " + "*"*10)
    print("\nOverall Experiment Summaries:")
    for mode, summary in overall_summary_results.items():
        print(f"\n--- {mode.upper()} ---")
        if summary.get("status") == "skipped":
            print(f"  Status: SKIPPED. Reason: {summary.get('reason')}")
        else:
            print(f"  Successful Runs: {summary.get('num_successful_runs')}/{summary.get('num_experimental_runs_configured')}")
            print(f"  P(C): {summary.get('overall_P_C', 0.0):.4f}")
            print(f"  P(C|CC): {summary.get('niceness_P_C_given_CC', 0.0):.4f}")
            print(f"  Plot data saved to: {main_results_dir / mode / 'cooperation_plot_data.csv'}")
            print(f"  Summary stats saved to: {main_results_dir / mode / 'summary_stats.json'}")
            print(f"  Individual run logs and agent code in: {main_results_dir / mode}")

    if not experiment_types_to_run:
        print("\nNo experiments were configured to run.")

#%%
