debate-bots/src/main.py
2025-11-11 19:49:58 -07:00

509 lines
16 KiB
Python

"""Main entry point for debate bots application."""
import sys
import argparse
from pathlib import Path
from rich.prompt import Prompt, Confirm
from .config import Config
from .providers import OpenRouterProvider, LMStudioProvider
from .agent import DebateAgent
from .debate import DebateOrchestrator
from . import ui
from .constants import (
DEFAULT_EXCHANGES_PER_ROUND,
DEFAULT_CONFIG_FILE,
LMSTUDIO_DEFAULT_BASE_URL,
)
from .exceptions import ConfigError, ProviderError, DebateError
from .logger import setup_logging, get_logger
# Initialize logger (will be configured in main)
logger = get_logger(__name__)
def format_model_name(model: str) -> str:
"""
Format model name for display as agent name.
Examples:
'anthropic/claude-3-haiku' -> 'Claude-3-Haiku'
'meta-llama/llama-3.1-8b-instruct' -> 'Llama-3.1-8B-Instruct'
'gemini-pro' -> 'Gemini-Pro'
Args:
model: Full model identifier
Returns:
Formatted model name suitable for display
"""
# Extract model name after provider prefix (if any)
if '/' in model:
model = model.split('/')[-1]
# Capitalize words and replace common separators
parts = model.replace('-', ' ').replace('_', ' ').split()
formatted_parts = []
for part in parts:
# Keep version numbers and sizes as-is (e.g., "3.1", "8b")
if any(char.isdigit() for char in part):
formatted_parts.append(part.upper() if part.endswith('b') else part)
else:
formatted_parts.append(part.capitalize())
return '-'.join(formatted_parts)
def create_provider(config: Config, agent_key: str):
"""
Create an LLM provider from configuration.
Args:
config: Configuration object
agent_key: Agent configuration key
Returns:
LLM provider instance
Raises:
ConfigError: If provider configuration is invalid
"""
provider_type = config.get(f"{agent_key}.provider")
model = config.get(f"{agent_key}.model")
logger.debug(f"Creating provider for {agent_key}: type={provider_type}, model={model}")
if provider_type == "openrouter":
api_key = config.get(f"{agent_key}.api_key")
if not api_key:
raise ConfigError(
f"API key not found for {agent_key}. "
"Set OPENROUTER_API_KEY environment variable or add to config."
)
return OpenRouterProvider(model=model, api_key=api_key)
elif provider_type == "lmstudio":
base_url = config.get(f"{agent_key}.base_url", LMSTUDIO_DEFAULT_BASE_URL)
return LMStudioProvider(model=model, base_url=base_url)
else:
raise ConfigError(f"Unknown provider type: {provider_type}")
def create_agent(config: Config, agent_key: str, name: str = None, max_memory_tokens: int = None) -> DebateAgent:
"""
Create a debate agent from configuration.
Args:
config: Configuration object
agent_key: Agent configuration key
name: Optional agent name (defaults to formatted model name)
max_memory_tokens: Optional max memory tokens (from CLI or config)
Returns:
DebateAgent instance
Raises:
ConfigError: If agent configuration is invalid
"""
provider = create_provider(config, agent_key)
system_prompt = config.get(f"{agent_key}.system_prompt")
# Use model name if no name provided
if name is None:
model = config.get(f"{agent_key}.model")
name = format_model_name(model)
# Get max_memory_tokens from config if not provided
if max_memory_tokens is None:
max_memory_tokens = config.get(f"{agent_key}.max_memory_tokens")
agent_kwargs = {
"name": name,
"provider": provider,
"system_prompt": system_prompt,
}
if max_memory_tokens is not None:
agent_kwargs["max_memory_tokens"] = max_memory_tokens
logger.info(f"Creating agent: {name} (provider={provider.__class__.__name__})")
return DebateAgent(**agent_kwargs)
def setup_configuration(config_path: str = DEFAULT_CONFIG_FILE) -> Config:
"""
Set up configuration from file or interactive prompts.
Args:
config_path: Path to configuration file
Returns:
Config object
Raises:
ConfigError: If configuration is invalid
"""
logger.info(f"Loading configuration from: {config_path}")
# Check for config file
if not Path(config_path).exists():
ui.print_info(
f"No config file found at {config_path}. "
"Starting interactive setup..."
)
config = Config()
else:
config = Config(config_path)
# Define required configuration keys
required_keys = {
"agent1.provider": {
"prompt": "Agent 1 provider (openrouter/lmstudio)",
"default": "openrouter",
},
"agent1.model": {
"prompt": "Agent 1 model",
"default": "anthropic/claude-3-haiku",
},
"agent1.system_prompt": {
"prompt": "Agent 1 system prompt",
"default": "You are a skilled debater who argues with logic and evidence.",
},
"agent2.provider": {
"prompt": "Agent 2 provider (openrouter/lmstudio)",
"default": "openrouter",
},
"agent2.model": {
"prompt": "Agent 2 model",
"default": "anthropic/claude-3-haiku",
},
"agent2.system_prompt": {
"prompt": "Agent 2 system prompt",
"default": "You are a persuasive debater who uses rhetoric and reasoning.",
},
}
# Prompt for missing values
config.prompt_for_missing(required_keys)
# Prompt for API keys if using OpenRouter
if config.get("agent1.provider") == "openrouter":
if not config.get("agent1.api_key"):
config.prompt_for_missing({
"agent1.api_key": {
"prompt": "Agent 1 OpenRouter API key",
"secret": True,
}
})
if config.get("agent2.provider") == "openrouter":
if not config.get("agent2.api_key"):
config.prompt_for_missing({
"agent2.api_key": {
"prompt": "Agent 2 OpenRouter API key",
"secret": True,
}
})
# Prompt for LMStudio URLs if needed
if config.get("agent1.provider") == "lmstudio":
if not config.get("agent1.base_url"):
config.prompt_for_missing({
"agent1.base_url": {
"prompt": "Agent 1 LMStudio URL",
"default": "http://localhost:1234/v1",
}
})
if config.get("agent2.provider") == "lmstudio":
if not config.get("agent2.base_url"):
config.prompt_for_missing({
"agent2.base_url": {
"prompt": "Agent 2 LMStudio URL",
"default": "http://localhost:1234/v1",
}
})
# Validate configuration
try:
config.validate_agent_config("agent1")
config.validate_agent_config("agent2")
logger.info("Configuration validated successfully")
except ConfigError as e:
ui.print_error(f"Configuration error: {str(e)}")
logger.error(f"Configuration validation failed: {e}")
sys.exit(1)
# Ask if user wants to save config
if not Path(config_path).exists():
if Confirm.ask("Save this configuration?", default=True):
config.save_to_file(config_path)
return config
def run_debate_loop(orchestrator: DebateOrchestrator, agent_for, agent_against, auto_save: bool = True):
"""
Run the debate loop with user interaction.
Args:
orchestrator: DebateOrchestrator instance
agent_for: Agent arguing 'for'
agent_against: Agent arguing 'against'
auto_save: Whether to auto-save after each round
Raises:
DebateError: If debate encounters an error
"""
while True:
# Run a round of exchanges
ui.print_info(
f"Starting round with {orchestrator.exchanges_per_round} exchanges..."
)
try:
# Run the round (exchanges are displayed as they happen)
orchestrator.run_round(agent_for, agent_against)
# Auto-save after each round if enabled
if auto_save:
try:
filepath = orchestrator.save_debate()
logger.debug(f"Auto-saved debate to {filepath}")
except Exception as e:
logger.warning(f"Auto-save failed: {e}")
# Don't break the debate for auto-save failures
except ProviderError as e:
ui.print_error(f"Provider error during debate: {str(e)}")
logger.error(f"Provider error: {e}", exc_info=True)
# Try to save before breaking
if auto_save:
try:
filepath = orchestrator.save_debate()
ui.print_info(f"Debate auto-saved to {filepath}")
except Exception:
pass
break
except Exception as e:
ui.print_error(f"Error during debate: {str(e)}")
logger.error(f"Unexpected error during debate: {e}", exc_info=True)
# Try to save before breaking
if auto_save:
try:
filepath = orchestrator.save_debate()
ui.print_info(f"Debate auto-saved to {filepath}")
except Exception:
pass
break
# Round complete - pause for user input
ui.print_round_complete(orchestrator.current_exchange)
# Show round statistics
stats = orchestrator.get_statistics()
ui.print_round_statistics(stats)
action = ui.get_user_action()
if action == "continue":
ui.print_info("Continuing debate...")
continue
elif action == "settle":
conclusion = ui.get_custom_instruction()
ui.print_info(f"Debate settled: {conclusion}")
break
elif action == "instruct":
instruction = ui.get_custom_instruction()
ui.print_info(f"Custom instruction: {instruction}")
# Add instruction to both agents
agent_for.add_message("user", instruction)
agent_against.add_message("user", instruction)
continue
elif action == "quit":
break
# Show final statistics
ui.print_separator()
stats = orchestrator.get_statistics()
ui.print_statistics(stats)
# Ask if user wants to save the debate (or it's already auto-saved)
if not auto_save:
if Confirm.ask("\nSave debate history?", default=True):
try:
filepath = orchestrator.save_debate()
ui.print_success(f"Debate saved to {filepath}")
except Exception as e:
ui.print_error(f"Failed to save debate: {str(e)}")
else:
ui.print_info("Debate has been automatically saved.")
def parse_args():
"""
Parse command line arguments.
Returns:
Parsed arguments
"""
parser = argparse.ArgumentParser(
description="Debate Bots - LLM-powered debate application",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s # Interactive mode with config.yaml
%(prog)s --config myconfig.yaml # Use custom config file
%(prog)s --topic "AI is beneficial" # Start debate with specific topic
%(prog)s --exchanges 5 # Run 5 exchanges per round
%(prog)s --no-auto-save # Disable automatic saving
%(prog)s --log-level DEBUG # Enable debug logging
%(prog)s --log-file debate.log # Log to file
"""
)
parser.add_argument(
"--config",
"-c",
default=DEFAULT_CONFIG_FILE,
help=f"Path to configuration file (default: {DEFAULT_CONFIG_FILE})"
)
parser.add_argument(
"--topic",
"-t",
help="Debate topic (if not provided, will prompt)"
)
parser.add_argument(
"--exchanges",
"-e",
type=int,
default=DEFAULT_EXCHANGES_PER_ROUND,
help=f"Number of exchanges per round (default: {DEFAULT_EXCHANGES_PER_ROUND})"
)
parser.add_argument(
"--no-auto-save",
action="store_true",
help="Disable automatic debate saving after each round"
)
parser.add_argument(
"--log-level",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Logging level (default: from environment or INFO)"
)
parser.add_argument(
"--log-file",
help="Log to file (default: console only)"
)
parser.add_argument(
"--max-memory-tokens",
type=int,
help="Maximum tokens to keep in agent memory"
)
return parser.parse_args()
def main():
"""Main application entry point."""
# Parse command line arguments
args = parse_args()
# Set up logging
setup_logging(log_level=args.log_level, log_file=args.log_file)
logger.info("=" * 50)
logger.info("Starting Debate Bots application")
logger.info(f"Config file: {args.config}")
logger.info(f"Exchanges per round: {args.exchanges}")
logger.info(f"Auto-save: {not args.no_auto_save}")
logger.info("=" * 50)
ui.print_header()
# Set up configuration
try:
config = setup_configuration(args.config)
except ConfigError as e:
ui.print_error(f"Configuration error: {str(e)}")
logger.error(f"Failed to load configuration: {e}")
sys.exit(1)
# Create agents (names automatically derived from model names)
try:
ui.print_info("Creating debate agents...")
agent1 = create_agent(config, "agent1", max_memory_tokens=args.max_memory_tokens)
agent2 = create_agent(config, "agent2", max_memory_tokens=args.max_memory_tokens)
ui.print_success(f"Agents created: {agent1.name} vs {agent2.name}")
except ConfigError as e:
ui.print_error(f"Configuration error: {str(e)}")
logger.error(f"Failed to create agents: {e}")
sys.exit(1)
except ProviderError as e:
ui.print_error(f"Provider error: {str(e)}")
logger.error(f"Provider error during agent creation: {e}")
sys.exit(1)
except Exception as e:
ui.print_error(f"Failed to create agents: {str(e)}")
logger.error(f"Unexpected error creating agents: {e}", exc_info=True)
sys.exit(1)
# Create orchestrator
orchestrator = DebateOrchestrator(
agent1=agent1,
agent2=agent2,
exchanges_per_round=args.exchanges,
)
# Get debate topic
ui.print_separator()
if args.topic:
topic = args.topic
logger.info(f"Using topic from command line: {topic}")
else:
topic = Prompt.ask("\n[bold cyan]Enter the debate topic[/bold cyan]")
# Start debate
ui.print_topic(topic)
ui.print_info("Assigning positions (coin flip)...")
try:
agent_for, agent_against = orchestrator.start_debate(topic)
ui.print_position_assignment(agent_for.name, agent_against.name)
# Run the debate loop
auto_save = not args.no_auto_save
run_debate_loop(orchestrator, agent_for, agent_against, auto_save=auto_save)
except Exception as e:
ui.print_error(f"Error during debate: {str(e)}")
logger.error(f"Error during debate: {e}", exc_info=True)
# Try to save debate even on error
try:
filepath = orchestrator.save_debate()
ui.print_info(f"Debate saved to {filepath}")
except Exception:
pass
sys.exit(1)
# Farewell
ui.print_separator()
ui.print_info("Thanks for using Debate Bots!")
logger.info("Debate Bots session ended")
if __name__ == "__main__":
main()