econagents 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,358 @@
1
+ import asyncio
2
+ import logging
3
+ import queue
4
+ from contextvars import ContextVar
5
+ from logging.handlers import QueueHandler, QueueListener
6
+ from pathlib import Path
7
+ from typing import Optional, Type
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+ from econagents.core.manager.base import AgentManager
12
+ from econagents.core.manager.phase import PhaseManager
13
+ from econagents.core.state.game import GameState
14
+ from econagents.core.transport import AuthenticationMechanism, SimpleLoginPayloadAuth
15
+
16
+ ctx_agent_id: ContextVar[str] = ContextVar("agent_id", default="N/A")
17
+
18
+
19
+ class ContextInjectingFilter(logging.Filter):
20
+ """Filter that injects agent_id context into log records."""
21
+
22
+ def filter(self, record):
23
+ record.agent_id = ctx_agent_id.get()
24
+ return True
25
+
26
+
27
+ class GameRunnerConfig(BaseModel):
28
+ """Configuration class for GameRunner."""
29
+
30
+ # Server configuration
31
+ protocol: str = "ws"
32
+ """Protocol to use for the server"""
33
+ hostname: str
34
+ """Hostname of the server"""
35
+ path: str
36
+ """Path to the server"""
37
+ port: int
38
+
39
+ # Game configuration
40
+ game_id: int
41
+ """ID of the game"""
42
+ logs_dir: Path = Path.cwd() / "logs"
43
+ """Directory to store logs"""
44
+ log_level: int = logging.INFO
45
+ """Level of logging to use"""
46
+ prompts_dir: Path = Path.cwd() / "prompts"
47
+
48
+ # Authentication
49
+ auth_mechanism: Optional[AuthenticationMechanism] = SimpleLoginPayloadAuth()
50
+ """Authentication mechanism to use"""
51
+
52
+ # Phase transition configuration
53
+ phase_transition_event: str = "phase-transition"
54
+ """Event to use for phase transitions"""
55
+ phase_identifier_key: str = "phase"
56
+ """Key in data to use for phase identification"""
57
+
58
+ # State configuration
59
+ state_class: Optional[Type[GameState]] = None
60
+ """Class to use for the state"""
61
+
62
+
63
+ class TurnBasedGameRunnerConfig(GameRunnerConfig):
64
+ """Configuration class for TurnBasedGameRunner."""
65
+
66
+
67
+ class HybridGameRunnerConfig(GameRunnerConfig):
68
+ """Configuration class for TurnBasedGameRunner."""
69
+
70
+ continuous_phases: list[int] = Field(default_factory=list)
71
+ min_action_delay: int = Field(default=5)
72
+ max_action_delay: int = Field(default=10)
73
+
74
+
75
+ class GameRunner:
76
+ def __init__(
77
+ self,
78
+ config: GameRunnerConfig,
79
+ agents: list[PhaseManager],
80
+ ):
81
+ """
82
+ Generic game runner for managing agent connections to a game server. This can handle both turn-based and continuous games.
83
+
84
+ This class handles:
85
+
86
+ - Agent spawning and connection management
87
+
88
+ - Logging setup for game and agents
89
+
90
+ Args:
91
+ config: GameRunnerConfig instance with server and path settings
92
+ agents: List of AgentManager instances
93
+ """
94
+ self.config = config
95
+ self.agents = agents
96
+ self.game_log_queues: dict[int, queue.Queue] = {}
97
+ self.game_log_listeners: dict[int, QueueListener] = {}
98
+
99
+ # Create log directories if it doesn't exist
100
+ if self.config.logs_dir:
101
+ self.config.logs_dir.mkdir(parents=True, exist_ok=True)
102
+
103
+ def _setup_game_log_queue(self, game_id: int) -> queue.Queue:
104
+ """
105
+ Set up a logging queue for a game and its associated QueueListener.
106
+
107
+ Args:
108
+ game_id: Game identifier
109
+
110
+ Returns:
111
+ Queue used for logging
112
+ """
113
+ if game_id in self.game_log_queues:
114
+ return self.game_log_queues[game_id]
115
+
116
+ if not self.config.logs_dir:
117
+ # If no log path, just return a queue without a listener
118
+ log_queue: queue.Queue = queue.Queue()
119
+ self.game_log_queues[game_id] = log_queue
120
+ return log_queue
121
+
122
+ # Create a game-specific directory for all logs related to this game
123
+ game_dir = self.config.logs_dir / f"game_{game_id}"
124
+ game_dir.mkdir(parents=True, exist_ok=True)
125
+
126
+ game_log_file = game_dir / "all.log"
127
+ Path(game_log_file).touch()
128
+
129
+ game_queue: queue.Queue = queue.Queue()
130
+ self.game_log_queues[game_id] = game_queue
131
+
132
+ formatter = logging.Formatter("%(asctime)s [%(levelname)s] [AGENT %(agent_id)s] %(message)s")
133
+
134
+ # Create a file handler for the game log
135
+ file_handler = logging.FileHandler(game_log_file)
136
+ file_handler.setFormatter(formatter)
137
+
138
+ # Create and start the listener
139
+ listener = QueueListener(game_queue, file_handler)
140
+ listener.start()
141
+ self.game_log_listeners[game_id] = listener
142
+
143
+ return game_queue
144
+
145
+ def get_agent_logger(self, agent_id: int, game_id: int) -> logging.Logger:
146
+ """
147
+ Configure and return a logger for an agent.
148
+
149
+ Args:
150
+ agent_id (int): Agent identifier
151
+ game_id (int): Game identifier
152
+
153
+ Returns:
154
+ logging.Logger: Configured logger instance
155
+ """
156
+ if not self.config.logs_dir:
157
+ # Return a default logger if no log path is configured
158
+ logger = logging.getLogger(f"agent_{agent_id}")
159
+ logger.setLevel(self.config.log_level)
160
+ return logger
161
+
162
+ # Ensure game log queue is set up
163
+ game_log_queue = self._setup_game_log_queue(game_id)
164
+
165
+ # Create a game-specific directory for all logs
166
+ game_dir = self.config.logs_dir / f"game_{game_id}"
167
+ game_dir.mkdir(parents=True, exist_ok=True)
168
+
169
+ # Store agent logs in the game-specific directory
170
+ agent_log_file = game_dir / f"agent_{agent_id}.log"
171
+
172
+ agent_logger = logging.getLogger(f"agent_{agent_id}")
173
+ agent_logger.setLevel(self.config.log_level)
174
+
175
+ # Clear existing handlers to avoid duplicates
176
+ for handler in agent_logger.handlers[:]:
177
+ agent_logger.removeHandler(handler)
178
+
179
+ # Create or clear the agent log file
180
+ if agent_log_file.exists():
181
+ agent_log_file.unlink()
182
+ Path(agent_log_file).touch()
183
+
184
+ formatter = logging.Formatter("%(asctime)s [%(levelname)s] [AGENT %(agent_id)s] %(message)s")
185
+
186
+ # Setup file handler for agent log
187
+ file_handler = logging.FileHandler(agent_log_file)
188
+ file_handler.setFormatter(formatter)
189
+
190
+ # Add context filter
191
+ context_filter = ContextInjectingFilter()
192
+ file_handler.addFilter(context_filter)
193
+
194
+ # Console handler
195
+ console_handler = logging.StreamHandler()
196
+ console_handler.setFormatter(formatter)
197
+ console_handler.addFilter(context_filter)
198
+
199
+ # Setup queue handler for game log
200
+ queue_handler = QueueHandler(game_log_queue)
201
+ queue_handler.addFilter(context_filter)
202
+
203
+ # Add all handlers
204
+ agent_logger.addHandler(file_handler)
205
+ agent_logger.addHandler(console_handler)
206
+ agent_logger.addHandler(queue_handler) # Use queue handler instead of direct file handler
207
+
208
+ return agent_logger
209
+
210
+ def get_game_logger(self, game_id: int) -> logging.Logger:
211
+ """
212
+ Configure and return a logger for a game.
213
+
214
+ Args:
215
+ game_id (int): Game identifier
216
+
217
+ Returns:
218
+ logging.Logger: Configured logger instance
219
+ """
220
+ if not self.config.logs_dir:
221
+ # Return a default logger if no log path is configured
222
+ logger = logging.getLogger(f"game_{game_id}")
223
+ logger.setLevel(self.config.log_level)
224
+ return logger
225
+
226
+ # Ensure game log queue is set up
227
+ game_log_queue = self._setup_game_log_queue(game_id)
228
+
229
+ game_logger = logging.getLogger(f"game_{game_id}")
230
+ game_logger.setLevel(self.config.log_level)
231
+
232
+ # Clear existing handlers to avoid duplicates
233
+ for handler in game_logger.handlers[:]:
234
+ game_logger.removeHandler(handler)
235
+
236
+ formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
237
+
238
+ # Add context filter
239
+ context_filter = ContextInjectingFilter()
240
+
241
+ # Console handler
242
+ console_handler = logging.StreamHandler()
243
+ console_handler.setFormatter(formatter)
244
+ console_handler.addFilter(context_filter)
245
+
246
+ # Setup queue handler for game log
247
+ queue_handler = QueueHandler(game_log_queue)
248
+
249
+ # Add handlers
250
+ game_logger.addHandler(console_handler)
251
+ game_logger.addHandler(queue_handler)
252
+
253
+ return game_logger
254
+
255
+ def cleanup_logging(self) -> None:
256
+ """
257
+ Clean up logging resources, stopping all queue listeners.
258
+ Should be called when shutting down the game runner.
259
+ """
260
+ for game_id, listener in self.game_log_listeners.items():
261
+ try:
262
+ listener.stop()
263
+ except Exception as e:
264
+ print(f"Error stopping listener for game {game_id}: {e}")
265
+
266
+ self.game_log_listeners.clear()
267
+ self.game_log_queues.clear()
268
+
269
+ def _inject_default_config(self, agent_manager: PhaseManager) -> None:
270
+ """
271
+ Inject default configuration into an agent manager.
272
+
273
+ Args:
274
+ agent_manager (PhaseManager): Agent manager to inject configuration into
275
+ """
276
+ if not agent_manager.url:
277
+ agent_manager.url = f"{self.config.protocol}://{self.config.hostname}:{self.config.port}/{self.config.path}"
278
+ agent_manager.logger.debug(f"Injected default configuration into agent manager: {agent_manager.url}")
279
+
280
+ if not agent_manager.phase_transition_event:
281
+ agent_manager.phase_transition_event = self.config.phase_transition_event
282
+ agent_manager.logger.debug(
283
+ f"Injected default phase transition event: {agent_manager.phase_transition_event}"
284
+ )
285
+
286
+ if not agent_manager.phase_identifier_key:
287
+ agent_manager.phase_identifier_key = self.config.phase_identifier_key
288
+ agent_manager.logger.debug(f"Injected default phase identifier key: {agent_manager.phase_identifier_key}")
289
+
290
+ if not agent_manager.prompts_dir:
291
+ agent_manager.prompts_dir = self.config.prompts_dir
292
+ agent_manager.logger.debug(f"Injected default prompts directory: {agent_manager.prompts_dir}")
293
+
294
+ if not agent_manager.state and self.config.state_class:
295
+ agent_manager.state = self.config.state_class()
296
+ agent_manager.logger.debug(f"Injected default state: {agent_manager.state}")
297
+
298
+ if not agent_manager.auth_mechanism:
299
+ agent_manager.auth_mechanism = self.config.auth_mechanism
300
+ agent_manager.logger.debug(f"Injected default auth mechanism: {agent_manager.auth_mechanism}")
301
+
302
+ if isinstance(self.config, HybridGameRunnerConfig):
303
+ agent_manager.continuous_phases = set(self.config.continuous_phases)
304
+ agent_manager.min_action_delay = self.config.min_action_delay
305
+ agent_manager.max_action_delay = self.config.max_action_delay
306
+ agent_manager.logger.debug(
307
+ f"Injected default continuous phases: {agent_manager.continuous_phases}, min action delay: {agent_manager.min_action_delay}, max action delay: {agent_manager.max_action_delay}"
308
+ )
309
+
310
+ def _inject_agent_logger(self, agent_manager: PhaseManager, agent_id: int) -> None:
311
+ """
312
+ Inject a logger into an agent manager.
313
+
314
+ Args:
315
+ agent_manager (PhaseManager): Agent manager to inject logger into
316
+ agent_id (int): Agent identifier
317
+ """
318
+ agent_logger = self.get_agent_logger(agent_id, self.config.game_id)
319
+ ctx_agent_id.set(str(agent_id)) # Convert int to str for context variable
320
+
321
+ agent_manager.logger = agent_logger
322
+
323
+ async def spawn_agent(self, agent_manager: PhaseManager, agent_id: int) -> None:
324
+ """
325
+ Spawn an agent and connect it to the game.
326
+
327
+ Args:
328
+ agent_manager (PhaseManager): Agent manager to spawn
329
+ agent_id (int): Agent identifier
330
+ """
331
+ try:
332
+ self._inject_agent_logger(agent_manager, agent_id)
333
+ self._inject_default_config(agent_manager)
334
+
335
+ agent_manager.logger.info(f"Connecting to WebSocket URL: {agent_manager.url}")
336
+ await agent_manager.start()
337
+ except Exception:
338
+ agent_manager.logger.exception(f"Error in simulation for Agent {agent_id}")
339
+ raise
340
+
341
+ async def run_game(self) -> None:
342
+ """Run a game using provided game data."""
343
+
344
+ game_logger = self.get_game_logger(self.config.game_id)
345
+ game_logger.info(f"Running game with ID: {self.config.game_id}")
346
+
347
+ try:
348
+ tasks = []
349
+ game_logger.info("Starting simulations")
350
+
351
+ for i, agent_manager in enumerate(self.agents, start=1):
352
+ tasks.append(self.spawn_agent(agent_manager, i))
353
+ await asyncio.gather(*tasks)
354
+ except Exception as e:
355
+ game_logger.exception(f"Failed to run game: {e}")
356
+ raise
357
+ finally:
358
+ self.cleanup_logging()
@@ -0,0 +1,45 @@
1
+ import logging
2
+ from typing import Optional
3
+
4
+
5
+ class LoggerMixin:
6
+ """
7
+ A mixin that provides standardized logger functionality.
8
+
9
+ This mixin makes logger initialization optional and provides:
10
+ - A default logger if none is set
11
+ - Property getter/setter for logger management
12
+ - Consistent logging interface across components
13
+ """
14
+
15
+ _logger: Optional[logging.Logger] = None
16
+
17
+ @property
18
+ def logger(self) -> logging.Logger:
19
+ """
20
+ Get the logger instance. Creates a default logger if none is set.
21
+
22
+ Returns:
23
+ logging.Logger: The logger instance
24
+ """
25
+ if self._logger is None:
26
+ # Create a default logger with the class name
27
+ self._logger = logging.getLogger(self.__class__.__name__)
28
+ # Add basic handler if no handlers present
29
+ if not self._logger.handlers:
30
+ handler = logging.StreamHandler()
31
+ formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
32
+ handler.setFormatter(formatter)
33
+ self._logger.addHandler(handler)
34
+ self._logger.setLevel(logging.INFO)
35
+ return self._logger
36
+
37
+ @logger.setter
38
+ def logger(self, logger: logging.Logger) -> None:
39
+ """
40
+ Set a custom logger.
41
+
42
+ Args:
43
+ logger: The logger instance to use
44
+ """
45
+ self._logger = logger
@@ -0,0 +1,5 @@
1
+ from econagents.core.manager.base import AgentManager
2
+ from econagents.core.manager.phase import TurnBasedPhaseManager, PhaseManager
3
+ from econagents.core.manager.phase import HybridPhaseManager
4
+
5
+ __all__ = ["AgentManager", "PhaseManager", "TurnBasedPhaseManager", "HybridPhaseManager"]