389 lines
12 KiB
Python
389 lines
12 KiB
Python
"""
|
|
Simulation IPC communication module
|
|
Used for inter-process communication between the Flask backend and the simulation script
|
|
|
|
Implements a simple command/response pattern via the file system:
|
|
1. Flask writes commands to the commands/ directory
|
|
2. The simulation script polls the command directory, executes commands, and writes responses to the responses/ directory
|
|
3. Flask polls the response directory to get results
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import time
|
|
import uuid
|
|
from typing import Dict, Any, Optional, List
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from enum import Enum
|
|
|
|
from ..utils.logger import get_logger
|
|
from ..utils.locale import t
|
|
|
|
logger = get_logger("mirofish.simulation_ipc")
|
|
|
|
|
|
class CommandType(str, Enum):
|
|
"""Command type"""
|
|
|
|
INTERVIEW = "interview" # Single Agent interview
|
|
BATCH_INTERVIEW = "batch_interview" # Batch interview
|
|
CLOSE_ENV = "close_env" # Close environment
|
|
|
|
|
|
class CommandStatus(str, Enum):
|
|
"""Command status"""
|
|
|
|
PENDING = "pending"
|
|
PROCESSING = "processing"
|
|
COMPLETED = "completed"
|
|
FAILED = "failed"
|
|
|
|
|
|
@dataclass
|
|
class IPCCommand:
|
|
"""IPC command"""
|
|
|
|
command_id: str
|
|
command_type: CommandType
|
|
args: Dict[str, Any]
|
|
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"command_id": self.command_id,
|
|
"command_type": self.command_type.value,
|
|
"args": self.args,
|
|
"timestamp": self.timestamp,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> "IPCCommand":
|
|
return cls(
|
|
command_id=data["command_id"],
|
|
command_type=CommandType(data["command_type"]),
|
|
args=data.get("args", {}),
|
|
timestamp=data.get("timestamp", datetime.now().isoformat()),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class IPCResponse:
|
|
"""IPC response"""
|
|
|
|
command_id: str
|
|
status: CommandStatus
|
|
result: Optional[Dict[str, Any]] = None
|
|
error: Optional[str] = None
|
|
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"command_id": self.command_id,
|
|
"status": self.status.value,
|
|
"result": self.result,
|
|
"error": self.error,
|
|
"timestamp": self.timestamp,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, data: Dict[str, Any]) -> "IPCResponse":
|
|
return cls(
|
|
command_id=data["command_id"],
|
|
status=CommandStatus(data["status"]),
|
|
result=data.get("result"),
|
|
error=data.get("error"),
|
|
timestamp=data.get("timestamp", datetime.now().isoformat()),
|
|
)
|
|
|
|
|
|
class SimulationIPCClient:
|
|
"""
|
|
Simulation IPC client (used on the Flask side)
|
|
|
|
Used to send commands to the simulation process and wait for responses
|
|
"""
|
|
|
|
def __init__(self, simulation_dir: str):
|
|
"""
|
|
Initialize the IPC client
|
|
|
|
Args:
|
|
simulation_dir: Simulation data directory
|
|
"""
|
|
self.simulation_dir = simulation_dir
|
|
self.commands_dir = os.path.join(simulation_dir, "ipc_commands")
|
|
self.responses_dir = os.path.join(simulation_dir, "ipc_responses")
|
|
|
|
# Ensure directories exist
|
|
os.makedirs(self.commands_dir, exist_ok=True)
|
|
os.makedirs(self.responses_dir, exist_ok=True)
|
|
|
|
def send_command(
|
|
self,
|
|
command_type: CommandType,
|
|
args: Dict[str, Any],
|
|
timeout: float = 60.0,
|
|
poll_interval: float = 0.5,
|
|
) -> IPCResponse:
|
|
"""
|
|
Send a command and wait for a response
|
|
|
|
Args:
|
|
command_type: Command type
|
|
args: Command arguments
|
|
timeout: Timeout duration (seconds)
|
|
poll_interval: Polling interval (seconds)
|
|
|
|
Returns:
|
|
IPCResponse
|
|
|
|
Raises:
|
|
TimeoutError: Timed out waiting for a response
|
|
"""
|
|
command_id = str(uuid.uuid4())
|
|
command = IPCCommand(
|
|
command_id=command_id, command_type=command_type, args=args
|
|
)
|
|
|
|
# Write command file
|
|
command_file = os.path.join(self.commands_dir, f"{command_id}.json")
|
|
with open(command_file, "w", encoding="utf-8") as f:
|
|
json.dump(command.to_dict(), f, ensure_ascii=False, indent=2)
|
|
|
|
logger.info(
|
|
f"Sending IPC command: {command_type.value}, command_id={command_id}"
|
|
)
|
|
|
|
# Wait for response
|
|
response_file = os.path.join(self.responses_dir, f"{command_id}.json")
|
|
start_time = time.time()
|
|
|
|
while time.time() - start_time < timeout:
|
|
if os.path.exists(response_file):
|
|
try:
|
|
with open(response_file, "r", encoding="utf-8") as f:
|
|
response_data = json.load(f)
|
|
response = IPCResponse.from_dict(response_data)
|
|
|
|
# Clean up command and response files
|
|
try:
|
|
os.remove(command_file)
|
|
os.remove(response_file)
|
|
except OSError:
|
|
pass
|
|
|
|
logger.info(
|
|
f"Received IPC response: command_id={command_id}, status={response.status.value}"
|
|
)
|
|
return response
|
|
except (json.JSONDecodeError, KeyError) as e:
|
|
logger.warning(f"Failed to parse response: {e}")
|
|
|
|
time.sleep(poll_interval)
|
|
|
|
# Timeout
|
|
logger.error(f"Timed out waiting for IPC response: command_id={command_id}")
|
|
|
|
# Clean up command file
|
|
try:
|
|
os.remove(command_file)
|
|
except OSError:
|
|
pass
|
|
|
|
raise TimeoutError(t("api.ipcTimeout", timeout=timeout))
|
|
|
|
def send_interview(
|
|
self, agent_id: int, prompt: str, platform: str = None, timeout: float = 60.0
|
|
) -> IPCResponse:
|
|
"""
|
|
Send a single-Agent interview command
|
|
|
|
Args:
|
|
agent_id: Agent ID
|
|
prompt: Interview question
|
|
platform: Specified platform (optional)
|
|
- "twitter": only interview the Twitter platform
|
|
- "reddit": only interview the Reddit platform
|
|
- None: in dual-platform simulation, interview both platforms simultaneously; in single-platform simulation, interview that platform
|
|
timeout: Timeout duration
|
|
|
|
Returns:
|
|
IPCResponse, the result field contains the interview result
|
|
"""
|
|
args = {"agent_id": agent_id, "prompt": prompt}
|
|
if platform:
|
|
args["platform"] = platform
|
|
|
|
return self.send_command(
|
|
command_type=CommandType.INTERVIEW, args=args, timeout=timeout
|
|
)
|
|
|
|
def send_batch_interview(
|
|
self,
|
|
interviews: List[Dict[str, Any]],
|
|
platform: str = None,
|
|
timeout: float = 120.0,
|
|
) -> IPCResponse:
|
|
"""
|
|
Send a batch interview command
|
|
|
|
Args:
|
|
interviews: Interview list, each element contains {"agent_id": int, "prompt": str, "platform": str (optional)}
|
|
platform: Default platform (optional, overridden by each interview item's platform)
|
|
- "twitter": default to only interview the Twitter platform
|
|
- "reddit": default to only interview the Reddit platform
|
|
- None: in dual-platform simulation, each Agent is interviewed on both platforms simultaneously
|
|
timeout: Timeout duration
|
|
|
|
Returns:
|
|
IPCResponse, the result field contains all interview results
|
|
"""
|
|
args = {"interviews": interviews}
|
|
if platform:
|
|
args["platform"] = platform
|
|
|
|
return self.send_command(
|
|
command_type=CommandType.BATCH_INTERVIEW, args=args, timeout=timeout
|
|
)
|
|
|
|
def send_close_env(self, timeout: float = 30.0) -> IPCResponse:
|
|
"""
|
|
Send a close-environment command
|
|
|
|
Args:
|
|
timeout: Timeout duration
|
|
|
|
Returns:
|
|
IPCResponse
|
|
"""
|
|
return self.send_command(
|
|
command_type=CommandType.CLOSE_ENV, args={}, timeout=timeout
|
|
)
|
|
|
|
def check_env_alive(self) -> bool:
|
|
"""
|
|
Check whether the simulation environment is alive
|
|
|
|
Determined by checking the env_status.json file
|
|
"""
|
|
status_file = os.path.join(self.simulation_dir, "env_status.json")
|
|
if not os.path.exists(status_file):
|
|
return False
|
|
|
|
try:
|
|
with open(status_file, "r", encoding="utf-8") as f:
|
|
status = json.load(f)
|
|
return status.get("status") == "alive"
|
|
except (json.JSONDecodeError, OSError):
|
|
return False
|
|
|
|
|
|
class SimulationIPCServer:
|
|
"""
|
|
Simulation IPC server (used on the simulation script side)
|
|
|
|
Polls the command directory, executes commands, and returns responses
|
|
"""
|
|
|
|
def __init__(self, simulation_dir: str):
|
|
"""
|
|
Initialize the IPC server
|
|
|
|
Args:
|
|
simulation_dir: Simulation data directory
|
|
"""
|
|
self.simulation_dir = simulation_dir
|
|
self.commands_dir = os.path.join(simulation_dir, "ipc_commands")
|
|
self.responses_dir = os.path.join(simulation_dir, "ipc_responses")
|
|
|
|
# Ensure directories exist
|
|
os.makedirs(self.commands_dir, exist_ok=True)
|
|
os.makedirs(self.responses_dir, exist_ok=True)
|
|
|
|
# Environment status
|
|
self._running = False
|
|
|
|
def start(self):
|
|
"""Mark the server as running"""
|
|
self._running = True
|
|
self._update_env_status("alive")
|
|
|
|
def stop(self):
|
|
"""Mark the server as stopped"""
|
|
self._running = False
|
|
self._update_env_status("stopped")
|
|
|
|
def _update_env_status(self, status: str):
|
|
"""Update the environment status file"""
|
|
status_file = os.path.join(self.simulation_dir, "env_status.json")
|
|
with open(status_file, "w", encoding="utf-8") as f:
|
|
json.dump(
|
|
{"status": status, "timestamp": datetime.now().isoformat()},
|
|
f,
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
|
|
def poll_commands(self) -> Optional[IPCCommand]:
|
|
"""
|
|
Poll the command directory and return the first pending command
|
|
|
|
Returns:
|
|
IPCCommand or None
|
|
"""
|
|
if not os.path.exists(self.commands_dir):
|
|
return None
|
|
|
|
# Sort command files 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:
|
|
data = json.load(f)
|
|
return IPCCommand.from_dict(data)
|
|
except (json.JSONDecodeError, KeyError, OSError) as e:
|
|
logger.warning(f"Failed to read command file: {filepath}, {e}")
|
|
continue
|
|
|
|
return None
|
|
|
|
def send_response(self, response: IPCResponse):
|
|
"""
|
|
Send a response
|
|
|
|
Args:
|
|
response: IPC response
|
|
"""
|
|
response_file = os.path.join(self.responses_dir, f"{response.command_id}.json")
|
|
with open(response_file, "w", encoding="utf-8") as f:
|
|
json.dump(response.to_dict(), f, ensure_ascii=False, indent=2)
|
|
|
|
# Delete command file
|
|
command_file = os.path.join(self.commands_dir, f"{response.command_id}.json")
|
|
try:
|
|
os.remove(command_file)
|
|
except OSError:
|
|
pass
|
|
|
|
def send_success(self, command_id: str, result: Dict[str, Any]):
|
|
"""Send a success response"""
|
|
self.send_response(
|
|
IPCResponse(
|
|
command_id=command_id, status=CommandStatus.COMPLETED, result=result
|
|
)
|
|
)
|
|
|
|
def send_error(self, command_id: str, error: str):
|
|
"""Send an error response"""
|
|
self.send_response(
|
|
IPCResponse(command_id=command_id, status=CommandStatus.FAILED, error=error)
|
|
)
|