Coverage for src / dataknobs_bots / bot / manager.py: 30%

100 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-16 10:13 -0700

1"""Bot manager for multi-tenant bot instances.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import inspect 

7import logging 

8from pathlib import Path 

9from typing import TYPE_CHECKING, Any, Callable, Protocol, runtime_checkable 

10 

11from .base import DynaBot 

12 

13if TYPE_CHECKING: 

14 from dataknobs_config import EnvironmentAwareConfig, EnvironmentConfig 

15 

16logger = logging.getLogger(__name__) 

17 

18 

19@runtime_checkable 

20class ConfigLoader(Protocol): 

21 """Protocol for configuration loaders with a load method.""" 

22 

23 def load(self, bot_id: str) -> dict[str, Any]: 

24 """Load configuration for a bot.""" 

25 ... 

26 

27 

28@runtime_checkable 

29class AsyncConfigLoader(Protocol): 

30 """Protocol for async configuration loaders.""" 

31 

32 async def load(self, bot_id: str) -> dict[str, Any]: 

33 """Load configuration for a bot asynchronously.""" 

34 ... 

35 

36 

37ConfigLoaderType = ( 

38 ConfigLoader 

39 | AsyncConfigLoader 

40 | Callable[[str], dict[str, Any]] 

41 | Callable[[str], Any] # For async callables 

42) 

43 

44 

45class BotManager: 

46 """Manages multiple DynaBot instances for multi-tenancy. 

47 

48 BotManager handles: 

49 - Bot instance creation and caching 

50 - Client-level isolation 

51 - Configuration loading and validation 

52 - Bot lifecycle management 

53 - Environment-aware resource resolution (optional) 

54 

55 Each client/tenant gets its own bot instance, which can serve multiple users. 

56 The underlying DynaBot architecture ensures conversation isolation through 

57 BotContext with different conversation_ids. 

58 

59 Attributes: 

60 bots: Cache of bot_id -> DynaBot instances 

61 config_loader: Optional configuration loader (sync or async) 

62 environment_name: Current environment name (if environment-aware) 

63 

64 Example: 

65 ```python 

66 # Basic usage with inline configuration 

67 manager = BotManager() 

68 bot = await manager.get_or_create("my-bot", config={ 

69 "llm": {"provider": "openai", "model": "gpt-4o"}, 

70 "conversation_storage": {"backend": "memory"}, 

71 }) 

72 

73 # With environment-aware configuration 

74 manager = BotManager(environment="production") 

75 bot = await manager.get_or_create("my-bot", config={ 

76 "bot": { 

77 "llm": {"$resource": "default", "type": "llm_providers"}, 

78 "conversation_storage": {"$resource": "db", "type": "databases"}, 

79 } 

80 }) 

81 

82 # With config loader function 

83 def load_config(bot_id: str) -> dict: 

84 return load_yaml(f"configs/{bot_id}.yaml") 

85 

86 manager = BotManager(config_loader=load_config) 

87 bot = await manager.get_or_create("my-bot") 

88 

89 # List active bots 

90 active_bots = manager.list_bots() 

91 ``` 

92 """ 

93 

94 def __init__( 

95 self, 

96 config_loader: ConfigLoaderType | None = None, 

97 environment: EnvironmentConfig | str | None = None, 

98 env_dir: str | Path = "config/environments", 

99 ): 

100 """Initialize BotManager. 

101 

102 Args: 

103 config_loader: Optional configuration loader. 

104 Can be: 

105 - An object with a `.load(bot_id)` method (sync or async) 

106 - A callable function: bot_id -> config_dict (sync or async) 

107 - None (configurations must be provided explicitly) 

108 environment: Environment name or EnvironmentConfig for resource resolution. 

109 If None, environment-aware features are disabled unless 

110 an EnvironmentAwareConfig is passed to get_or_create(). 

111 If a string, loads environment config from env_dir. 

112 env_dir: Directory containing environment config files. 

113 Only used if environment is a string name. 

114 """ 

115 self._bots: dict[str, DynaBot] = {} 

116 self._config_loader = config_loader 

117 self._env_dir = Path(env_dir) 

118 

119 # Load environment config if specified 

120 self._environment: EnvironmentConfig | None = None 

121 if environment is not None: 

122 try: 

123 from dataknobs_config import EnvironmentConfig 

124 

125 if isinstance(environment, str): 

126 self._environment = EnvironmentConfig.load(environment, env_dir) 

127 else: 

128 self._environment = environment 

129 logger.info(f"Initialized BotManager with environment: {self._environment.name}") 

130 except ImportError: 

131 logger.warning( 

132 "dataknobs_config not installed, environment-aware features disabled" 

133 ) 

134 else: 

135 logger.info("Initialized BotManager") 

136 

137 @property 

138 def environment_name(self) -> str | None: 

139 """Get current environment name, or None if not environment-aware.""" 

140 return self._environment.name if self._environment else None 

141 

142 @property 

143 def environment(self) -> EnvironmentConfig | None: 

144 """Get current environment config, or None if not environment-aware.""" 

145 return self._environment 

146 

147 async def get_or_create( 

148 self, 

149 bot_id: str, 

150 config: dict[str, Any] | EnvironmentAwareConfig | None = None, 

151 use_environment: bool | None = None, 

152 config_key: str = "bot", 

153 ) -> DynaBot: 

154 """Get existing bot or create new one. 

155 

156 Args: 

157 bot_id: Bot identifier (e.g., "customer-support", "sales-assistant") 

158 config: Optional bot configuration. Can be: 

159 - dict with resolved values (traditional) 

160 - dict with $resource references (requires environment) 

161 - EnvironmentAwareConfig instance 

162 If not provided and config_loader is set, will load configuration. 

163 use_environment: Whether to use environment-aware resolution. 

164 - True: Use environment for $resource resolution 

165 - False: Use config as-is (no resolution) 

166 - None (default): Auto-detect based on whether manager has 

167 an environment configured or config is EnvironmentAwareConfig 

168 config_key: Key within config containing bot configuration. 

169 Defaults to "bot". Set to None to use root config. 

170 Only used when use_environment is True. 

171 

172 Returns: 

173 DynaBot instance 

174 

175 Raises: 

176 ValueError: If config is None and no config_loader is set 

177 

178 Example: 

179 ```python 

180 # Traditional usage (no environment resolution) 

181 manager = BotManager() 

182 bot = await manager.get_or_create("support-bot", config={ 

183 "llm": {"provider": "openai", "model": "gpt-4"}, 

184 "conversation_storage": {"backend": "memory"}, 

185 }) 

186 

187 # Environment-aware usage with $resource references 

188 manager = BotManager(environment="production") 

189 bot = await manager.get_or_create("support-bot", config={ 

190 "bot": { 

191 "llm": {"$resource": "default", "type": "llm_providers"}, 

192 "conversation_storage": {"$resource": "db", "type": "databases"}, 

193 } 

194 }) 

195 

196 # Explicit environment resolution control 

197 bot = await manager.get_or_create( 

198 "support-bot", 

199 config=my_config, 

200 use_environment=True, 

201 config_key="bot" 

202 ) 

203 ``` 

204 """ 

205 # Return cached bot if exists 

206 if bot_id in self._bots: 

207 logger.debug(f"Returning cached bot: {bot_id}") 

208 return self._bots[bot_id] 

209 

210 # Load configuration if not provided 

211 if config is None: 

212 if self._config_loader is None: 

213 raise ValueError( 

214 f"No configuration provided for bot '{bot_id}' " 

215 "and no config_loader is set" 

216 ) 

217 config = await self._load_config(bot_id) 

218 

219 # Determine whether to use environment resolution 

220 is_env_aware_config = False 

221 try: 

222 from dataknobs_config import EnvironmentAwareConfig 

223 

224 is_env_aware_config = isinstance(config, EnvironmentAwareConfig) 

225 except ImportError: 

226 pass 

227 

228 should_use_environment = use_environment 

229 if should_use_environment is None: 

230 # Auto-detect: use environment if manager has one or config is EnvironmentAwareConfig 

231 should_use_environment = self._environment is not None or is_env_aware_config 

232 

233 # Create new bot 

234 logger.info(f"Creating new bot: {bot_id} (environment_aware={should_use_environment})") 

235 

236 if should_use_environment: 

237 bot = await DynaBot.from_environment_aware_config( 

238 config, 

239 environment=self._environment, 

240 env_dir=self._env_dir, 

241 config_key=config_key, 

242 ) 

243 else: 

244 # Traditional path - use config as-is 

245 bot = await DynaBot.from_config(config) 

246 

247 # Cache and return 

248 self._bots[bot_id] = bot 

249 return bot 

250 

251 async def get(self, bot_id: str) -> DynaBot | None: 

252 """Get bot without creating if doesn't exist. 

253 

254 Args: 

255 bot_id: Bot identifier 

256 

257 Returns: 

258 DynaBot instance if exists, None otherwise 

259 """ 

260 return self._bots.get(bot_id) 

261 

262 async def remove(self, bot_id: str) -> bool: 

263 """Remove bot instance. 

264 

265 Args: 

266 bot_id: Bot identifier 

267 

268 Returns: 

269 True if bot was removed, False if didn't exist 

270 """ 

271 if bot_id in self._bots: 

272 logger.info(f"Removing bot: {bot_id}") 

273 del self._bots[bot_id] 

274 return True 

275 return False 

276 

277 async def reload(self, bot_id: str) -> DynaBot: 

278 """Reload bot instance with fresh configuration. 

279 

280 Args: 

281 bot_id: Bot identifier 

282 

283 Returns: 

284 New DynaBot instance 

285 

286 Raises: 

287 ValueError: If no config_loader is set 

288 """ 

289 if self._config_loader is None: 

290 raise ValueError("Cannot reload without config_loader") 

291 

292 # Remove existing bot 

293 await self.remove(bot_id) 

294 

295 # Create new one 

296 return await self.get_or_create(bot_id) 

297 

298 def list_bots(self) -> list[str]: 

299 """List all active bot IDs. 

300 

301 Returns: 

302 List of bot identifiers 

303 """ 

304 return list(self._bots.keys()) 

305 

306 def get_bot_count(self) -> int: 

307 """Get count of active bots. 

308 

309 Returns: 

310 Number of active bot instances 

311 """ 

312 return len(self._bots) 

313 

314 async def _load_config(self, bot_id: str) -> dict[str, Any]: 

315 """Load configuration for bot using config_loader. 

316 

317 Supports both synchronous and asynchronous config loaders. 

318 Handles both callable loaders and objects with a load() method. 

319 

320 Args: 

321 bot_id: Bot identifier 

322 

323 Returns: 

324 Bot configuration dictionary 

325 """ 

326 logger.debug(f"Loading configuration for bot: {bot_id}") 

327 

328 if callable(self._config_loader): 

329 # Handle callable config loader (function) 

330 if inspect.iscoroutinefunction(self._config_loader): 

331 # Async function 

332 result = await self._config_loader(bot_id) 

333 return dict(result) if isinstance(result, dict) else {} 

334 else: 

335 # Sync function - run in executor to avoid blocking 

336 loop = asyncio.get_event_loop() 

337 result = await loop.run_in_executor(None, self._config_loader, bot_id) 

338 return dict(result) if isinstance(result, dict) else {} 

339 else: 

340 # Assume it's an object with a load method 

341 load_method = self._config_loader.load # type: ignore 

342 

343 if inspect.iscoroutinefunction(load_method): 

344 # Async method 

345 result = await load_method(bot_id) 

346 return dict(result) if isinstance(result, dict) else {} 

347 else: 

348 # Sync method - run in executor to avoid blocking 

349 loop = asyncio.get_event_loop() 

350 result = await loop.run_in_executor(None, load_method, bot_id) 

351 return dict(result) if isinstance(result, dict) else {} 

352 

353 async def clear_all(self) -> None: 

354 """Clear all bot instances. 

355 

356 Useful for testing or when restarting the service. 

357 """ 

358 logger.info("Clearing all bot instances") 

359 self._bots.clear() 

360 

361 def get_portable_config( 

362 self, 

363 config: dict[str, Any] | EnvironmentAwareConfig, 

364 ) -> dict[str, Any]: 

365 """Get portable configuration for storage. 

366 

367 Extracts portable config (with $resource references intact, 

368 environment variables unresolved) suitable for storing in 

369 registries or databases. 

370 

371 Args: 

372 config: Configuration to make portable. 

373 Can be dict or EnvironmentAwareConfig. 

374 

375 Returns: 

376 Portable configuration dictionary 

377 

378 Example: 

379 ```python 

380 manager = BotManager(environment="production") 

381 

382 # Get portable config from EnvironmentAwareConfig 

383 portable = manager.get_portable_config(env_aware_config) 

384 

385 # Store in registry (portable across environments) 

386 await registry.store(bot_id, portable) 

387 ``` 

388 """ 

389 return DynaBot.get_portable_config(config) 

390 

391 def __repr__(self) -> str: 

392 """String representation.""" 

393 bots = ", ".join(self._bots.keys()) 

394 env = f", environment={self._environment.name!r}" if self._environment else "" 

395 return f"BotManager(bots=[{bots}], count={len(self._bots)}{env})"