MicroFish/backend/scripts/run_twitter_simulation.py

781 lines
27 KiB
Python

"""
OASIS Twitter simulation preset script
This script reads parameters from a config file to run simulations, fully automated end-to-end
Features:
- After completing the simulation, do not close the environment immediately; enter wait-for-commands mode
- Support receiving Interview commands via IPC
- Support single-Agent and batch interviews
- Support remote environment-close commands
Usage:
python run_twitter_simulation.py --config /path/to/simulation_config.json
python run_twitter_simulation.py --config /path/to/simulation_config.json --no-wait # close immediately after completion
"""
import argparse
import asyncio
import json
import logging
import os
import random
import signal
import sys
import sqlite3
from datetime import datetime
from typing import Dict, Any, List, Optional
# Global variables: for signal handling
_shutdown_event = None
_cleanup_done = False
# Add project paths
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
_backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..'))
_project_root = os.path.abspath(os.path.join(_backend_dir, '..'))
sys.path.insert(0, _scripts_dir)
sys.path.insert(0, _backend_dir)
# Load project-root .env file (contains LLM_API_KEY etc.)
from dotenv import load_dotenv
_env_file = os.path.join(_project_root, '.env')
if os.path.exists(_env_file):
load_dotenv(_env_file)
else:
_backend_env = os.path.join(_backend_dir, '.env')
if os.path.exists(_backend_env):
load_dotenv(_backend_env)
import re
class UnicodeFormatter(logging.Formatter):
"""Custom formatter that converts Unicode escape sequences into readable characters"""
UNICODE_ESCAPE_PATTERN = re.compile(r'\\u([0-9a-fA-F]{4})')
def format(self, record):
result = super().format(record)
def replace_unicode(match):
try:
return chr(int(match.group(1), 16))
except (ValueError, OverflowError):
return match.group(0)
return self.UNICODE_ESCAPE_PATTERN.sub(replace_unicode, result)
class MaxTokensWarningFilter(logging.Filter):
"""Filter out camel-ai warnings about max_tokens (we intentionally do not set max_tokens, letting the model decide)"""
def filter(self, record):
# Filter out log records containing the max_tokens warning
if "max_tokens" in record.getMessage() and "Invalid or missing" in record.getMessage():
return False
return True
# Add the filter at module load time, ensuring it takes effect before camel code runs
logging.getLogger().addFilter(MaxTokensWarningFilter())
def setup_oasis_logging(log_dir: str):
"""Configure OASIS logging using fixed-name log files"""
os.makedirs(log_dir, exist_ok=True)
# Clean up old log files
for f in os.listdir(log_dir):
old_log = os.path.join(log_dir, f)
if os.path.isfile(old_log) and f.endswith('.log'):
try:
os.remove(old_log)
except OSError:
pass
formatter = UnicodeFormatter("%(levelname)s - %(asctime)s - %(name)s - %(message)s")
loggers_config = {
"social.agent": os.path.join(log_dir, "social.agent.log"),
"social.twitter": os.path.join(log_dir, "social.twitter.log"),
"social.rec": os.path.join(log_dir, "social.rec.log"),
"oasis.env": os.path.join(log_dir, "oasis.env.log"),
"table": os.path.join(log_dir, "table.log"),
}
for logger_name, log_file in loggers_config.items():
logger = logging.getLogger(logger_name)
logger.setLevel(logging.DEBUG)
logger.handlers.clear()
file_handler = logging.FileHandler(log_file, encoding='utf-8', mode='w')
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
logger.propagate = False
try:
from camel.models import ModelFactory
from camel.types import ModelPlatformType
import oasis
from oasis import (
ActionType,
LLMAction,
ManualAction,
generate_twitter_agent_graph
)
except ImportError as e:
print(f"Error: missing dependency {e}")
print("Please install first: pip install oasis-ai camel-ai")
sys.exit(1)
# IPC-related constants
IPC_COMMANDS_DIR = "ipc_commands"
IPC_RESPONSES_DIR = "ipc_responses"
ENV_STATUS_FILE = "env_status.json"
class CommandType:
"""Command type constants"""
INTERVIEW = "interview"
BATCH_INTERVIEW = "batch_interview"
CLOSE_ENV = "close_env"
class IPCHandler:
"""IPC command handler"""
def __init__(self, simulation_dir: str, env, agent_graph):
self.simulation_dir = simulation_dir
self.env = env
self.agent_graph = agent_graph
self.commands_dir = os.path.join(simulation_dir, IPC_COMMANDS_DIR)
self.responses_dir = os.path.join(simulation_dir, IPC_RESPONSES_DIR)
self.status_file = os.path.join(simulation_dir, ENV_STATUS_FILE)
self._running = True
# Ensure the directory exists
os.makedirs(self.commands_dir, exist_ok=True)
os.makedirs(self.responses_dir, exist_ok=True)
def update_status(self, status: str):
"""Update environment status"""
with open(self.status_file, 'w', encoding='utf-8') as f:
json.dump({
"status": status,
"timestamp": datetime.now().isoformat()
}, f, ensure_ascii=False, indent=2)
def poll_command(self) -> Optional[Dict[str, Any]]:
"""Poll for pending commands"""
if not os.path.exists(self.commands_dir):
return None
# Get command files (sorted by time)
command_files = []
for filename in os.listdir(self.commands_dir):
if filename.endswith('.json'):
filepath = os.path.join(self.commands_dir, filename)
command_files.append((filepath, os.path.getmtime(filepath)))
command_files.sort(key=lambda x: x[1])
for filepath, _ in command_files:
try:
with open(filepath, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
continue
return None
def send_response(self, command_id: str, status: str, result: Dict = None, error: str = None):
"""Send response"""
response = {
"command_id": command_id,
"status": status,
"result": result,
"error": error,
"timestamp": datetime.now().isoformat()
}
response_file = os.path.join(self.responses_dir, f"{command_id}.json")
with open(response_file, 'w', encoding='utf-8') as f:
json.dump(response, f, ensure_ascii=False, indent=2)
# Delete command file
command_file = os.path.join(self.commands_dir, f"{command_id}.json")
try:
os.remove(command_file)
except OSError:
pass
async def handle_interview(self, command_id: str, agent_id: int, prompt: str) -> bool:
"""
Handle single-Agent interview command
Returns:
True on success, False on failure
"""
try:
# Get Agent
agent = self.agent_graph.get_agent(agent_id)
# Create Interview action
interview_action = ManualAction(
action_type=ActionType.INTERVIEW,
action_args={"prompt": prompt}
)
# Execute Interview
actions = {agent: interview_action}
await self.env.step(actions)
# Get result from database
result = self._get_interview_result(agent_id)
self.send_response(command_id, "completed", result=result)
print(f" Interview completed: agent_id={agent_id}")
return True
except Exception as e:
error_msg = str(e)
print(f" Interview failed: agent_id={agent_id}, error={error_msg}")
self.send_response(command_id, "failed", error=error_msg)
return False
async def handle_batch_interview(self, command_id: str, interviews: List[Dict]) -> bool:
"""
Handle batch interview command
Args:
interviews: [{"agent_id": int, "prompt": str}, ...]
"""
try:
# Build action dict
actions = {}
agent_prompts = {} # Record each agent's prompt
for interview in interviews:
agent_id = interview.get("agent_id")
prompt = interview.get("prompt", "")
try:
agent = self.agent_graph.get_agent(agent_id)
actions[agent] = ManualAction(
action_type=ActionType.INTERVIEW,
action_args={"prompt": prompt}
)
agent_prompts[agent_id] = prompt
except Exception as e:
print(f" Warning: unable to fetch Agent {agent_id}: {e}")
if not actions:
self.send_response(command_id, "failed", error="No valid agents")
return False
# Execute batch Interview
await self.env.step(actions)
# Get all results
results = {}
for agent_id in agent_prompts.keys():
result = self._get_interview_result(agent_id)
results[agent_id] = result
self.send_response(command_id, "completed", result={
"interviews_count": len(results),
"results": results
})
print(f" Batch Interview completed: {len(results)} agents")
return True
except Exception as e:
error_msg = str(e)
print(f" Batch Interview failed: {error_msg}")
self.send_response(command_id, "failed", error=error_msg)
return False
def _get_interview_result(self, agent_id: int) -> Dict[str, Any]:
"""Get the latest Interview results from the database"""
db_path = os.path.join(self.simulation_dir, "twitter_simulation.db")
result = {
"agent_id": agent_id,
"response": None,
"timestamp": None
}
if not os.path.exists(db_path):
return result
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Query the latest Interview record
cursor.execute("""
SELECT user_id, info, created_at
FROM trace
WHERE action = ? AND user_id = ?
ORDER BY created_at DESC
LIMIT 1
""", (ActionType.INTERVIEW.value, agent_id))
row = cursor.fetchone()
if row:
user_id, info_json, created_at = row
try:
info = json.loads(info_json) if info_json else {}
result["response"] = info.get("response", info)
result["timestamp"] = created_at
except json.JSONDecodeError:
result["response"] = info_json
conn.close()
except Exception as e:
print(f" Failed to read Interview results: {e}")
return result
async def process_commands(self) -> bool:
"""
Handle all pending commands
Returns:
True to continue running, False to exit
"""
command = self.poll_command()
if not command:
return True
command_id = command.get("command_id")
command_type = command.get("command_type")
args = command.get("args", {})
print(f"\nReceived IPC command: {command_type}, id={command_id}")
if command_type == CommandType.INTERVIEW:
await self.handle_interview(
command_id,
args.get("agent_id", 0),
args.get("prompt", "")
)
return True
elif command_type == CommandType.BATCH_INTERVIEW:
await self.handle_batch_interview(
command_id,
args.get("interviews", [])
)
return True
elif command_type == CommandType.CLOSE_ENV:
print("Received close-environment command")
self.send_response(command_id, "completed", result={"message": "Environment is about to close"})
return False
else:
self.send_response(command_id, "failed", error=f"Unknown command type: {command_type}")
return True
class TwitterSimulationRunner:
"""Twitter simulation runner"""
# Twitter available actions (does not include INTERVIEW; INTERVIEW can only be triggered manually via ManualAction)
AVAILABLE_ACTIONS = [
ActionType.CREATE_POST,
ActionType.LIKE_POST,
ActionType.REPOST,
ActionType.FOLLOW,
ActionType.DO_NOTHING,
ActionType.QUOTE_POST,
]
def __init__(self, config_path: str, wait_for_commands: bool = True):
"""
Initialize the simulation runner
Args:
config_path: Configuration file path (simulation_config.json)
wait_for_commands: Whether to wait for commands after the simulation completes (default True)
"""
self.config_path = config_path
self.config = self._load_config()
self.simulation_dir = os.path.dirname(config_path)
self.wait_for_commands = wait_for_commands
self.env = None
self.agent_graph = None
self.ipc_handler = None
def _load_config(self) -> Dict[str, Any]:
"""Load configuration file"""
with open(self.config_path, 'r', encoding='utf-8') as f:
return json.load(f)
def _get_profile_path(self) -> str:
"""Get Profile file path (OASIS Twitter uses CSV format)"""
return os.path.join(self.simulation_dir, "twitter_profiles.csv")
def _get_db_path(self) -> str:
"""Get database path"""
return os.path.join(self.simulation_dir, "twitter_simulation.db")
def _create_model(self):
"""
Create the LLM model
Use config from project-root .env file (highest priority):
- LLM_API_KEY: API key
- LLM_BASE_URL: API base URL
- LLM_MODEL_NAME: Model name
"""
# Prefer reading config from .env
llm_api_key = os.environ.get("LLM_API_KEY", "")
llm_base_url = os.environ.get("LLM_BASE_URL", "")
llm_model = os.environ.get("LLM_MODEL_NAME", "")
# If .env has no value, fall back to config
if not llm_model:
llm_model = self.config.get("llm_model", "gpt-4o-mini")
# Set environment variables required by camel-ai
if llm_api_key:
os.environ["OPENAI_API_KEY"] = llm_api_key
if not os.environ.get("OPENAI_API_KEY"):
raise ValueError("Missing API Key config, please set LLM_API_KEY in the project root .env file")
if llm_base_url:
os.environ["OPENAI_API_BASE_URL"] = llm_base_url
print(f"LLM config: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else 'default'}...")
return ModelFactory.create(
model_platform=ModelPlatformType.OPENAI,
model_type=llm_model,
)
def _get_active_agents_for_round(
self,
env,
current_hour: int,
round_num: int
) -> List:
"""
Determine which Agents to activate this round based on time and config
Args:
env: OASIS environment
current_hour: Current simulation hour (0-23)
round_num: Current round number
Returns:
List of activated Agents
"""
time_config = self.config.get("time_config", {})
agent_configs = self.config.get("agent_configs", [])
# Base activation count
base_min = time_config.get("agents_per_hour_min", 5)
base_max = time_config.get("agents_per_hour_max", 20)
# Adjust by time period
peak_hours = time_config.get("peak_hours", [9, 10, 11, 14, 15, 20, 21, 22])
off_peak_hours = time_config.get("off_peak_hours", [0, 1, 2, 3, 4, 5])
if current_hour in peak_hours:
multiplier = time_config.get("peak_activity_multiplier", 1.5)
elif current_hour in off_peak_hours:
multiplier = time_config.get("off_peak_activity_multiplier", 0.3)
else:
multiplier = 1.0
target_count = int(random.uniform(base_min, base_max) * multiplier)
# Compute activation probability from each Agent's config
candidates = []
for cfg in agent_configs:
agent_id = cfg.get("agent_id", 0)
active_hours = cfg.get("active_hours", list(range(8, 23)))
activity_level = cfg.get("activity_level", 0.5)
# Check if within active hours
if current_hour not in active_hours:
continue
# Compute probability from activity level
if random.random() < activity_level:
candidates.append(agent_id)
# Random selection
selected_ids = random.sample(
candidates,
min(target_count, len(candidates))
) if candidates else []
# Convert to Agent objects
active_agents = []
for agent_id in selected_ids:
try:
agent = env.agent_graph.get_agent(agent_id)
active_agents.append((agent_id, agent))
except Exception:
pass
return active_agents
async def run(self, max_rounds: int = None):
"""Run Twitter simulation
Args:
max_rounds: Maximum simulation rounds (optional, used to truncate overlong simulations)
"""
print("=" * 60)
print("OASIS Twitter Simulation")
print(f"Config file: {self.config_path}")
print(f"Simulation ID: {self.config.get('simulation_id', 'unknown')}")
print(f"Wait-for-commands mode: {'enabled' if self.wait_for_commands else 'disabled'}")
print("=" * 60)
# Load time config
time_config = self.config.get("time_config", {})
total_hours = time_config.get("total_simulation_hours", 72)
minutes_per_round = time_config.get("minutes_per_round", 30)
# Compute total rounds
total_rounds = (total_hours * 60) // minutes_per_round
# If a max-rounds is specified, truncate
if max_rounds is not None and max_rounds > 0:
original_rounds = total_rounds
total_rounds = min(total_rounds, max_rounds)
if total_rounds < original_rounds:
print(f"\nRounds truncated: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})")
print(f"\nSimulation parameters:")
print(f" - Total simulation duration: {total_hours} hours")
print(f" - Time per round: {minutes_per_round} minutes")
print(f" - Total rounds: {total_rounds}")
if max_rounds:
print(f" - Max rounds limit: {max_rounds}")
print(f" - Number of Agents: {len(self.config.get('agent_configs', []))}")
# Create model
print("\nInitializing LLM model...")
model = self._create_model()
# Load Agent graph
print("Loading Agent Profile...")
profile_path = self._get_profile_path()
if not os.path.exists(profile_path):
print(f"Error: Profile file does not exist: {profile_path}")
return
self.agent_graph = await generate_twitter_agent_graph(
profile_path=profile_path,
model=model,
available_actions=self.AVAILABLE_ACTIONS,
)
# Database path
db_path = self._get_db_path()
if os.path.exists(db_path):
os.remove(db_path)
print(f"Deleted old database: {db_path}")
# Create environment
print("Creating OASIS environment...")
self.env = oasis.make(
agent_graph=self.agent_graph,
platform=oasis.DefaultPlatformType.TWITTER,
database_path=db_path,
semaphore=30, # Limit max concurrent LLM requests to prevent API overload
)
await self.env.reset()
print("Environment initialization complete\n")
# Initialize IPC handler
self.ipc_handler = IPCHandler(self.simulation_dir, self.env, self.agent_graph)
self.ipc_handler.update_status("running")
# Execute initial events
event_config = self.config.get("event_config", {})
initial_posts = event_config.get("initial_posts", [])
if initial_posts:
print(f"Executing initial events ({len(initial_posts)} initial posts)...")
initial_actions = {}
for post in initial_posts:
agent_id = post.get("poster_agent_id", 0)
content = post.get("content", "")
try:
agent = self.env.agent_graph.get_agent(agent_id)
initial_actions[agent] = ManualAction(
action_type=ActionType.CREATE_POST,
action_args={"content": content}
)
except Exception as e:
print(f" Warning: unable to create initial post for Agent {agent_id}: {e}")
if initial_actions:
await self.env.step(initial_actions)
print(f" Published {len(initial_actions)} initial posts")
# Main simulation loop
print("\nStarting simulation loop...")
start_time = datetime.now()
for round_num in range(total_rounds):
# Compute current simulation time
simulated_minutes = round_num * minutes_per_round
simulated_hour = (simulated_minutes // 60) % 24
simulated_day = simulated_minutes // (60 * 24) + 1
# Get activated Agents for this round
active_agents = self._get_active_agents_for_round(
self.env, simulated_hour, round_num
)
if not active_agents:
continue
# Build actions
actions = {
agent: LLMAction()
for _, agent in active_agents
}
# Execute actions
await self.env.step(actions)
# Print progress
if (round_num + 1) % 10 == 0 or round_num == 0:
elapsed = (datetime.now() - start_time).total_seconds()
progress = (round_num + 1) / total_rounds * 100
print(f" [Day {simulated_day}, {simulated_hour:02d}:00] "
f"Round {round_num + 1}/{total_rounds} ({progress:.1f}%) "
f"- {len(active_agents)} agents active "
f"- elapsed: {elapsed:.1f}s")
total_elapsed = (datetime.now() - start_time).total_seconds()
print(f"\nSimulation loop completed!")
print(f" - Total elapsed: {total_elapsed:.1f} seconds")
print(f" - Database: {db_path}")
# Whether to enter wait-for-commands mode
if self.wait_for_commands:
print("\n" + "=" * 60)
print("Entering wait-for-commands mode - environment keeps running")
print("Supported commands: interview, batch_interview, close_env")
print("=" * 60)
self.ipc_handler.update_status("alive")
# Wait-for-commands loop (using global _shutdown_event)
try:
while not _shutdown_event.is_set():
should_continue = await self.ipc_handler.process_commands()
if not should_continue:
break
try:
await asyncio.wait_for(_shutdown_event.wait(), timeout=0.5)
break # Received exit signal
except asyncio.TimeoutError:
pass
except KeyboardInterrupt:
print("\nReceived interrupt signal")
except asyncio.CancelledError:
print("\nTask cancelled")
except Exception as e:
print(f"\nCommand processing error: {e}")
print("\nClosing environment...")
# Close the environment
self.ipc_handler.update_status("stopped")
await self.env.close()
print("Environment closed")
print("=" * 60)
async def main():
parser = argparse.ArgumentParser(description='OASIS Twitter Simulation')
parser.add_argument(
'--config',
type=str,
required=True,
help='Configuration file path (simulation_config.json)'
)
parser.add_argument(
'--max-rounds',
type=int,
default=None,
help='Maximum simulation rounds (optional, used to truncate overlong simulations)'
)
parser.add_argument(
'--no-wait',
action='store_true',
default=False,
help='Close environment immediately after simulation completes, do not enter wait-for-commands mode'
)
args = parser.parse_args()
# Create shutdown event at the start of main()
global _shutdown_event
_shutdown_event = asyncio.Event()
if not os.path.exists(args.config):
print(f"Error: configuration file does not exist: {args.config}")
sys.exit(1)
# Initialize log config (use fixed log file names, clean up old logs)
simulation_dir = os.path.dirname(args.config) or "."
setup_oasis_logging(os.path.join(simulation_dir, "log"))
runner = TwitterSimulationRunner(
config_path=args.config,
wait_for_commands=not args.no_wait
)
await runner.run(max_rounds=args.max_rounds)
def setup_signal_handlers():
"""
Set up signal handlers to ensure clean exit on SIGTERM/SIGINT
Give the program a chance to clean up resources (close DB, env, etc.)
"""
def signal_handler(signum, frame):
global _cleanup_done
sig_name = "SIGTERM" if signum == signal.SIGTERM else "SIGINT"
print(f"\nReceived {sig_name} signal, exiting...")
if not _cleanup_done:
_cleanup_done = True
if _shutdown_event:
_shutdown_event.set()
else:
# Only force exit if the signal is received again
print("Forced exit...")
sys.exit(1)
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
if __name__ == "__main__":
setup_signal_handlers()
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\nProgram interrupted")
except SystemExit:
pass
finally:
print("Simulation process exited")