mindroom 0.0.0__py3-none-any.whl → 0.1.0__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.
Files changed (155) hide show
  1. mindroom/__init__.py +3 -0
  2. mindroom/agent_prompts.py +963 -0
  3. mindroom/agents.py +248 -0
  4. mindroom/ai.py +421 -0
  5. mindroom/api/__init__.py +1 -0
  6. mindroom/api/credentials.py +137 -0
  7. mindroom/api/google_integration.py +355 -0
  8. mindroom/api/google_tools_helper.py +40 -0
  9. mindroom/api/homeassistant_integration.py +421 -0
  10. mindroom/api/integrations.py +189 -0
  11. mindroom/api/main.py +506 -0
  12. mindroom/api/matrix_operations.py +219 -0
  13. mindroom/api/tools.py +94 -0
  14. mindroom/background_tasks.py +87 -0
  15. mindroom/bot.py +2470 -0
  16. mindroom/cli.py +86 -0
  17. mindroom/commands.py +377 -0
  18. mindroom/config.py +343 -0
  19. mindroom/config_commands.py +324 -0
  20. mindroom/config_confirmation.py +411 -0
  21. mindroom/constants.py +52 -0
  22. mindroom/credentials.py +146 -0
  23. mindroom/credentials_sync.py +134 -0
  24. mindroom/custom_tools/__init__.py +8 -0
  25. mindroom/custom_tools/config_manager.py +765 -0
  26. mindroom/custom_tools/gmail.py +92 -0
  27. mindroom/custom_tools/google_calendar.py +92 -0
  28. mindroom/custom_tools/google_sheets.py +92 -0
  29. mindroom/custom_tools/homeassistant.py +341 -0
  30. mindroom/error_handling.py +35 -0
  31. mindroom/file_watcher.py +49 -0
  32. mindroom/interactive.py +313 -0
  33. mindroom/logging_config.py +207 -0
  34. mindroom/matrix/__init__.py +1 -0
  35. mindroom/matrix/client.py +782 -0
  36. mindroom/matrix/event_info.py +173 -0
  37. mindroom/matrix/identity.py +149 -0
  38. mindroom/matrix/large_messages.py +267 -0
  39. mindroom/matrix/mentions.py +141 -0
  40. mindroom/matrix/message_builder.py +94 -0
  41. mindroom/matrix/message_content.py +209 -0
  42. mindroom/matrix/presence.py +178 -0
  43. mindroom/matrix/rooms.py +311 -0
  44. mindroom/matrix/state.py +77 -0
  45. mindroom/matrix/typing.py +91 -0
  46. mindroom/matrix/users.py +217 -0
  47. mindroom/memory/__init__.py +21 -0
  48. mindroom/memory/config.py +137 -0
  49. mindroom/memory/functions.py +396 -0
  50. mindroom/py.typed +0 -0
  51. mindroom/response_tracker.py +128 -0
  52. mindroom/room_cleanup.py +139 -0
  53. mindroom/routing.py +107 -0
  54. mindroom/scheduling.py +758 -0
  55. mindroom/stop.py +207 -0
  56. mindroom/streaming.py +203 -0
  57. mindroom/teams.py +749 -0
  58. mindroom/thread_utils.py +318 -0
  59. mindroom/tools/__init__.py +520 -0
  60. mindroom/tools/agentql.py +64 -0
  61. mindroom/tools/airflow.py +57 -0
  62. mindroom/tools/apify.py +49 -0
  63. mindroom/tools/arxiv.py +64 -0
  64. mindroom/tools/aws_lambda.py +41 -0
  65. mindroom/tools/aws_ses.py +57 -0
  66. mindroom/tools/baidusearch.py +87 -0
  67. mindroom/tools/brightdata.py +116 -0
  68. mindroom/tools/browserbase.py +62 -0
  69. mindroom/tools/cal_com.py +98 -0
  70. mindroom/tools/calculator.py +112 -0
  71. mindroom/tools/cartesia.py +84 -0
  72. mindroom/tools/composio.py +166 -0
  73. mindroom/tools/config_manager.py +44 -0
  74. mindroom/tools/confluence.py +73 -0
  75. mindroom/tools/crawl4ai.py +101 -0
  76. mindroom/tools/csv.py +104 -0
  77. mindroom/tools/custom_api.py +106 -0
  78. mindroom/tools/dalle.py +85 -0
  79. mindroom/tools/daytona.py +180 -0
  80. mindroom/tools/discord.py +81 -0
  81. mindroom/tools/docker.py +73 -0
  82. mindroom/tools/duckdb.py +124 -0
  83. mindroom/tools/duckduckgo.py +99 -0
  84. mindroom/tools/e2b.py +121 -0
  85. mindroom/tools/eleven_labs.py +77 -0
  86. mindroom/tools/email.py +74 -0
  87. mindroom/tools/exa.py +246 -0
  88. mindroom/tools/fal.py +50 -0
  89. mindroom/tools/file.py +80 -0
  90. mindroom/tools/financial_datasets_api.py +112 -0
  91. mindroom/tools/firecrawl.py +124 -0
  92. mindroom/tools/gemini.py +85 -0
  93. mindroom/tools/giphy.py +49 -0
  94. mindroom/tools/github.py +376 -0
  95. mindroom/tools/gmail.py +102 -0
  96. mindroom/tools/google_calendar.py +55 -0
  97. mindroom/tools/google_maps.py +112 -0
  98. mindroom/tools/google_sheets.py +86 -0
  99. mindroom/tools/googlesearch.py +83 -0
  100. mindroom/tools/groq.py +77 -0
  101. mindroom/tools/hackernews.py +54 -0
  102. mindroom/tools/jina.py +108 -0
  103. mindroom/tools/jira.py +70 -0
  104. mindroom/tools/linear.py +103 -0
  105. mindroom/tools/linkup.py +65 -0
  106. mindroom/tools/lumalabs.py +71 -0
  107. mindroom/tools/mem0.py +82 -0
  108. mindroom/tools/modelslabs.py +85 -0
  109. mindroom/tools/moviepy_video_tools.py +62 -0
  110. mindroom/tools/newspaper4k.py +63 -0
  111. mindroom/tools/openai.py +143 -0
  112. mindroom/tools/openweather.py +89 -0
  113. mindroom/tools/oxylabs.py +54 -0
  114. mindroom/tools/pandas.py +35 -0
  115. mindroom/tools/pubmed.py +64 -0
  116. mindroom/tools/python.py +120 -0
  117. mindroom/tools/reddit.py +155 -0
  118. mindroom/tools/replicate.py +56 -0
  119. mindroom/tools/resend.py +55 -0
  120. mindroom/tools/scrapegraph.py +87 -0
  121. mindroom/tools/searxng.py +120 -0
  122. mindroom/tools/serpapi.py +55 -0
  123. mindroom/tools/serper.py +81 -0
  124. mindroom/tools/shell.py +46 -0
  125. mindroom/tools/slack.py +80 -0
  126. mindroom/tools/sleep.py +38 -0
  127. mindroom/tools/spider.py +62 -0
  128. mindroom/tools/sql.py +138 -0
  129. mindroom/tools/tavily.py +104 -0
  130. mindroom/tools/telegram.py +54 -0
  131. mindroom/tools/todoist.py +103 -0
  132. mindroom/tools/trello.py +121 -0
  133. mindroom/tools/twilio.py +97 -0
  134. mindroom/tools/web_browser_tools.py +37 -0
  135. mindroom/tools/webex.py +63 -0
  136. mindroom/tools/website.py +45 -0
  137. mindroom/tools/whatsapp.py +81 -0
  138. mindroom/tools/wikipedia.py +45 -0
  139. mindroom/tools/x.py +97 -0
  140. mindroom/tools/yfinance.py +121 -0
  141. mindroom/tools/youtube.py +81 -0
  142. mindroom/tools/zendesk.py +62 -0
  143. mindroom/tools/zep.py +107 -0
  144. mindroom/tools/zoom.py +62 -0
  145. mindroom/tools_metadata.json +7643 -0
  146. mindroom/tools_metadata.py +220 -0
  147. mindroom/topic_generator.py +153 -0
  148. mindroom/voice_handler.py +266 -0
  149. mindroom-0.1.0.dist-info/METADATA +425 -0
  150. mindroom-0.1.0.dist-info/RECORD +152 -0
  151. {mindroom-0.0.0.dist-info → mindroom-0.1.0.dist-info}/WHEEL +1 -2
  152. mindroom-0.1.0.dist-info/entry_points.txt +2 -0
  153. mindroom-0.0.0.dist-info/METADATA +0 -24
  154. mindroom-0.0.0.dist-info/RECORD +0 -4
  155. mindroom-0.0.0.dist-info/top_level.txt +0 -1
mindroom/bot.py ADDED
@@ -0,0 +1,2470 @@
1
+ """Multi-agent bot implementation where each agent has its own Matrix user account."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from contextlib import suppress
7
+ from dataclasses import dataclass, field
8
+ from functools import cached_property
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ import nio
13
+ from tenacity import RetryCallState, retry, stop_after_attempt, wait_exponential
14
+
15
+ from . import config_confirmation, interactive, voice_handler
16
+ from .agents import create_agent, get_rooms_for_entity
17
+ from .ai import ai_response, stream_agent_response
18
+ from .background_tasks import create_background_task, wait_for_background_tasks
19
+ from .commands import (
20
+ Command,
21
+ CommandType,
22
+ command_parser,
23
+ get_command_help,
24
+ handle_widget_command,
25
+ )
26
+ from .config import Config
27
+ from .config_commands import handle_config_command
28
+ from .constants import ENABLE_STREAMING, MATRIX_HOMESERVER, ROUTER_AGENT_NAME, VOICE_PREFIX
29
+ from .credentials_sync import sync_env_to_credentials
30
+ from .file_watcher import watch_file
31
+ from .logging_config import emoji, get_logger, setup_logging
32
+ from .matrix.client import (
33
+ _latest_thread_event_id,
34
+ check_and_set_avatar,
35
+ edit_message,
36
+ fetch_thread_history,
37
+ get_joined_rooms,
38
+ get_latest_thread_event_id_if_needed,
39
+ get_room_members,
40
+ invite_to_room,
41
+ join_room,
42
+ leave_room,
43
+ send_message,
44
+ )
45
+ from .matrix.event_info import EventInfo
46
+ from .matrix.identity import (
47
+ MatrixID,
48
+ extract_agent_name,
49
+ extract_server_name_from_homeserver,
50
+ )
51
+ from .matrix.mentions import format_message_with_mentions
52
+ from .matrix.presence import build_agent_status_message, is_user_online, set_presence_status, should_use_streaming
53
+ from .matrix.rooms import ensure_all_rooms_exist, ensure_user_in_rooms, is_dm_room, load_rooms, resolve_room_aliases
54
+ from .matrix.state import MatrixState
55
+ from .matrix.typing import typing_indicator
56
+ from .matrix.users import AgentMatrixUser, create_agent_user, login_agent_user
57
+ from .memory import store_conversation_memory
58
+ from .response_tracker import ResponseTracker
59
+ from .room_cleanup import cleanup_all_orphaned_bots
60
+ from .routing import suggest_agent_for_message
61
+ from .scheduling import (
62
+ cancel_all_scheduled_tasks,
63
+ cancel_scheduled_task,
64
+ list_scheduled_tasks,
65
+ restore_scheduled_tasks,
66
+ schedule_task,
67
+ )
68
+ from .stop import StopManager
69
+ from .streaming import (
70
+ IN_PROGRESS_MARKER,
71
+ ReplacementStreamingResponse,
72
+ StreamingResponse,
73
+ send_streaming_response,
74
+ )
75
+ from .teams import (
76
+ TeamMode,
77
+ decide_team_formation,
78
+ select_model_for_team,
79
+ team_response,
80
+ team_response_stream,
81
+ )
82
+ from .thread_utils import (
83
+ check_agent_mentioned,
84
+ create_session_id,
85
+ get_agents_in_thread,
86
+ get_all_mentioned_agents_in_thread,
87
+ get_available_agents_in_room,
88
+ get_configured_agents_for_room,
89
+ has_user_responded_after_message,
90
+ is_authorized_sender,
91
+ should_agent_respond,
92
+ )
93
+
94
+ if TYPE_CHECKING:
95
+ import structlog
96
+ from agno.agent import Agent
97
+
98
+ logger = get_logger(__name__)
99
+
100
+
101
+ # Constants
102
+ SYNC_TIMEOUT_MS = 30000
103
+
104
+
105
+ def _create_task_wrapper(callback: object) -> object:
106
+ """Create a wrapper that runs the callback as a background task.
107
+
108
+ This ensures the sync loop is never blocked by event processing,
109
+ allowing the bot to handle new events (like stop reactions) while
110
+ processing messages.
111
+ """
112
+
113
+ async def wrapper(*args: object, **kwargs: object) -> None:
114
+ # Create the task but don't await it - let it run in background
115
+ async def error_handler() -> None:
116
+ try:
117
+ await callback(*args, **kwargs) # type: ignore[operator]
118
+ except asyncio.CancelledError:
119
+ # Task was cancelled, this is expected during shutdown
120
+ pass
121
+ except Exception:
122
+ # Log the exception with full traceback
123
+ logger.exception("Error in event callback")
124
+
125
+ # Create task with error handling
126
+ _task = asyncio.create_task(error_handler()) # noqa: RUF006
127
+
128
+ return wrapper
129
+
130
+
131
+ def _format_agent_description(agent_name: str, config: Config) -> str:
132
+ """Format a concise agent description for the welcome message."""
133
+ if agent_name in config.agents:
134
+ agent_config = config.agents[agent_name]
135
+ desc_parts = []
136
+
137
+ # Add role first
138
+ if agent_config.role:
139
+ desc_parts.append(agent_config.role)
140
+
141
+ # Add tools with better formatting
142
+ if agent_config.tools:
143
+ # Wrap each tool name in backticks
144
+ formatted_tools = [f"`{tool}`" for tool in agent_config.tools[:3]]
145
+ tools_str = ", ".join(formatted_tools)
146
+ if len(agent_config.tools) > 3:
147
+ tools_str += f" +{len(agent_config.tools) - 3} more"
148
+ desc_parts.append(f"(🔧 {tools_str})")
149
+
150
+ return " ".join(desc_parts) if desc_parts else ""
151
+
152
+ if agent_name in config.teams:
153
+ team_config = config.teams[agent_name]
154
+ team_desc = f"Team of {len(team_config.agents)} agents"
155
+ if team_config.role:
156
+ return f"{team_config.role} ({team_desc})"
157
+ return team_desc
158
+
159
+ return ""
160
+
161
+
162
+ def _generate_welcome_message(room_id: str, config: Config) -> str:
163
+ """Generate the welcome message text for a room."""
164
+ # Get list of configured agents for this room
165
+ configured_agents = get_configured_agents_for_room(room_id, config)
166
+
167
+ # Build agent list for the welcome message
168
+ agent_list = []
169
+ for agent_id in configured_agents:
170
+ agent_name = agent_id.agent_name(config)
171
+ if not agent_name or agent_name == ROUTER_AGENT_NAME:
172
+ continue
173
+
174
+ description = _format_agent_description(agent_name, config)
175
+ # Always show the agent, with or without description
176
+ # Use the username with mindroom_ prefix (but without domain) for proper mention parsing
177
+ agent_entry = f"• **@{agent_id.username}**"
178
+ if description:
179
+ agent_entry += f": {description}"
180
+ agent_list.append(agent_entry)
181
+
182
+ # Create welcome message
183
+ welcome_msg = (
184
+ "🎉 **Welcome to MindRoom!**\n\n"
185
+ "I'm your routing assistant, here to help coordinate our team of specialized AI agents. 🤖\n\n"
186
+ )
187
+
188
+ if agent_list:
189
+ welcome_msg += "🧠 **Available agents in this room:**\n"
190
+ welcome_msg += "\n".join(agent_list)
191
+ welcome_msg += "\n\n"
192
+
193
+ welcome_msg += (
194
+ "💬 **How to interact:**\n"
195
+ "• Mention an agent with @ to get their attention (e.g., @mindroom_assistant)\n"
196
+ "• Use `!help` to see available commands\n"
197
+ "• Agents respond in threads to keep conversations organized\n"
198
+ "• Multiple agents can collaborate when you mention them together\n"
199
+ "• 🎤 Voice messages are automatically transcribed and work perfectly!\n\n"
200
+ "⚡ **Quick commands:**\n"
201
+ "• `!hi` - Show this welcome message again\n"
202
+ "• `!widget` - Add configuration widget to this room\n"
203
+ "• `!schedule <time> <message>` - Schedule tasks and reminders\n"
204
+ "• `!help [topic]` - Get detailed help\n\n"
205
+ "✨ Feel free to ask any agent for help or start a conversation!"
206
+ )
207
+
208
+ return welcome_msg
209
+
210
+
211
+ def _should_skip_mentions(event_source: dict) -> bool:
212
+ """Check if mentions in this message should be ignored for agent responses.
213
+
214
+ This is used for messages like scheduling confirmations that contain mentions
215
+ but should not trigger agent responses.
216
+
217
+ Args:
218
+ event_source: The Matrix event source dict
219
+
220
+ Returns:
221
+ True if mentions should be ignored, False otherwise
222
+
223
+ """
224
+ content = event_source.get("content", {})
225
+ return bool(content.get("com.mindroom.skip_mentions", False))
226
+
227
+
228
+ def create_bot_for_entity(
229
+ entity_name: str,
230
+ agent_user: AgentMatrixUser,
231
+ config: Config,
232
+ storage_path: Path,
233
+ ) -> AgentBot | TeamBot | None:
234
+ """Create appropriate bot instance for an entity (agent, team, or router).
235
+
236
+ Args:
237
+ entity_name: Name of the entity to create a bot for
238
+ agent_user: Matrix user for the bot
239
+ config: Configuration object
240
+ storage_path: Path for storing agent data
241
+
242
+ Returns:
243
+ Bot instance or None if entity not found in config
244
+
245
+ """
246
+ enable_streaming = ENABLE_STREAMING
247
+
248
+ if entity_name == ROUTER_AGENT_NAME:
249
+ all_room_aliases = config.get_all_configured_rooms()
250
+ rooms = resolve_room_aliases(list(all_room_aliases))
251
+ return AgentBot(agent_user, storage_path, config, rooms, enable_streaming=enable_streaming)
252
+
253
+ if entity_name in config.teams:
254
+ team_config = config.teams[entity_name]
255
+ rooms = resolve_room_aliases(team_config.rooms)
256
+ # Convert agent names to MatrixID objects
257
+ team_matrix_ids = [MatrixID.from_username(agent_name, config.domain) for agent_name in team_config.agents]
258
+ return TeamBot(
259
+ agent_user=agent_user,
260
+ storage_path=storage_path,
261
+ config=config,
262
+ rooms=rooms,
263
+ team_agents=team_matrix_ids,
264
+ team_mode=team_config.mode,
265
+ team_model=team_config.model,
266
+ enable_streaming=True,
267
+ )
268
+
269
+ if entity_name in config.agents:
270
+ agent_config = config.agents[entity_name]
271
+ rooms = resolve_room_aliases(agent_config.rooms)
272
+ return AgentBot(agent_user, storage_path, config, rooms, enable_streaming=enable_streaming)
273
+
274
+ msg = f"Entity '{entity_name}' not found in configuration."
275
+ raise ValueError(msg)
276
+
277
+
278
+ @dataclass
279
+ class MessageContext:
280
+ """Context extracted from a Matrix message event."""
281
+
282
+ am_i_mentioned: bool
283
+ is_thread: bool
284
+ thread_id: str | None
285
+ thread_history: list[dict]
286
+ mentioned_agents: list[MatrixID]
287
+
288
+
289
+ @dataclass
290
+ class AgentBot:
291
+ """Represents a single agent bot with its own Matrix account."""
292
+
293
+ agent_user: AgentMatrixUser
294
+ storage_path: Path
295
+ config: Config
296
+ rooms: list[str] = field(default_factory=list)
297
+
298
+ client: nio.AsyncClient | None = field(default=None, init=False)
299
+ running: bool = field(default=False, init=False)
300
+ enable_streaming: bool = field(default=True) # Enable/disable streaming responses
301
+ orchestrator: MultiAgentOrchestrator = field(init=False) # Reference to orchestrator
302
+
303
+ @property
304
+ def agent_name(self) -> str:
305
+ """Get the agent name from username."""
306
+ return self.agent_user.agent_name
307
+
308
+ @cached_property
309
+ def logger(self) -> structlog.stdlib.BoundLogger:
310
+ """Get a logger with agent context bound."""
311
+ return logger.bind(agent=emoji(self.agent_name))
312
+
313
+ @cached_property
314
+ def matrix_id(self) -> MatrixID:
315
+ """Get the Matrix ID for this agent bot."""
316
+ return self.agent_user.matrix_id
317
+
318
+ @property # Not cached_property because Team mutates it!
319
+ def agent(self) -> Agent:
320
+ """Get the Agno Agent instance for this bot."""
321
+ return create_agent(agent_name=self.agent_name, config=self.config)
322
+
323
+ @cached_property
324
+ def response_tracker(self) -> ResponseTracker:
325
+ """Get or create the response tracker for this agent."""
326
+ # Use the tracking subdirectory, not the root storage path
327
+ tracking_dir = self.storage_path / "tracking"
328
+ return ResponseTracker(self.agent_name, base_path=tracking_dir)
329
+
330
+ @cached_property
331
+ def stop_manager(self) -> StopManager:
332
+ """Get or create the StopManager for this agent."""
333
+ return StopManager()
334
+
335
+ async def join_configured_rooms(self) -> None:
336
+ """Join all rooms this agent is configured for."""
337
+ assert self.client is not None
338
+ for room_id in self.rooms:
339
+ if await join_room(self.client, room_id):
340
+ self.logger.info("Joined room", room_id=room_id)
341
+ # Only the router agent should restore scheduled tasks
342
+ # to avoid duplicate task instances after restart
343
+ if self.agent_name == ROUTER_AGENT_NAME:
344
+ # Restore scheduled tasks
345
+ restored_tasks = await restore_scheduled_tasks(self.client, room_id, self.config)
346
+ if restored_tasks > 0:
347
+ self.logger.info(f"Restored {restored_tasks} scheduled tasks in room {room_id}")
348
+
349
+ # Restore pending config confirmations
350
+ restored_configs = await config_confirmation.restore_pending_changes(self.client, room_id)
351
+ if restored_configs > 0:
352
+ self.logger.info(f"Restored {restored_configs} pending config changes in room {room_id}")
353
+
354
+ # Send welcome message if room is empty
355
+ await self._send_welcome_message_if_empty(room_id)
356
+ else:
357
+ self.logger.warning("Failed to join room", room_id=room_id)
358
+
359
+ async def leave_unconfigured_rooms(self) -> None:
360
+ """Leave any rooms this agent is no longer configured for."""
361
+ assert self.client is not None
362
+
363
+ # Get all rooms we're currently in
364
+ joined_rooms = await get_joined_rooms(self.client)
365
+ if joined_rooms is None:
366
+ return
367
+
368
+ current_rooms = set(joined_rooms)
369
+ configured_rooms = set(self.rooms)
370
+
371
+ # Leave rooms we're no longer configured for
372
+ for room_id in current_rooms - configured_rooms:
373
+ if await is_dm_room(self.client, room_id):
374
+ self.logger.debug(f"Preserving DM room {room_id} during cleanup")
375
+ continue
376
+ success = await leave_room(self.client, room_id)
377
+ if success:
378
+ self.logger.info(f"Left unconfigured room {room_id}")
379
+ else:
380
+ self.logger.error(f"Failed to leave unconfigured room {room_id}")
381
+
382
+ async def ensure_user_account(self) -> None:
383
+ """Ensure this agent has a Matrix user account.
384
+
385
+ This method makes the agent responsible for its own user account creation,
386
+ moving this responsibility from the orchestrator to the agent itself.
387
+ """
388
+ # If we already have a user_id (e.g., provided by tests or config), assume account exists
389
+ if getattr(self.agent_user, "user_id", ""):
390
+ return
391
+ # Create or retrieve the Matrix user account
392
+ self.agent_user = await create_agent_user(
393
+ MATRIX_HOMESERVER,
394
+ self.agent_name,
395
+ self.agent_user.display_name, # Use existing display name if available
396
+ )
397
+ self.logger.info(f"Ensured Matrix user account: {self.agent_user.user_id}")
398
+
399
+ async def _set_avatar_if_available(self) -> None:
400
+ """Set avatar for the agent if an avatar file exists."""
401
+ if not self.client:
402
+ return
403
+
404
+ entity_type = "teams" if self.agent_name in self.config.teams else "agents"
405
+ avatar_path = Path(__file__).parent.parent.parent / "avatars" / entity_type / f"{self.agent_name}.png"
406
+
407
+ if avatar_path.exists():
408
+ try:
409
+ success = await check_and_set_avatar(self.client, avatar_path)
410
+ if success:
411
+ self.logger.info(f"Successfully set avatar for {self.agent_name}")
412
+ else:
413
+ self.logger.warning(f"Failed to set avatar for {self.agent_name}")
414
+ except Exception as e:
415
+ self.logger.warning(f"Failed to set avatar: {e}")
416
+
417
+ async def _set_presence_with_model_info(self) -> None:
418
+ """Set presence status with model information."""
419
+ if self.client is None:
420
+ return
421
+
422
+ status_msg = build_agent_status_message(self.agent_name, self.config)
423
+ await set_presence_status(self.client, status_msg)
424
+
425
+ async def ensure_rooms(self) -> None:
426
+ """Ensure agent is in the correct rooms based on configuration.
427
+
428
+ This consolidates room management into a single method that:
429
+ 1. Joins configured rooms
430
+ 2. Leaves unconfigured rooms
431
+ """
432
+ await self.join_configured_rooms()
433
+ await self.leave_unconfigured_rooms()
434
+
435
+ async def start(self) -> None:
436
+ """Start the agent bot with user account setup (but don't join rooms yet)."""
437
+ await self.ensure_user_account()
438
+ self.client = await login_agent_user(MATRIX_HOMESERVER, self.agent_user)
439
+ await self._set_avatar_if_available()
440
+ await self._set_presence_with_model_info()
441
+
442
+ # Register event callbacks - wrap them to run as background tasks
443
+ # This ensures the sync loop is never blocked, allowing stop reactions to work
444
+ self.client.add_event_callback(_create_task_wrapper(self._on_invite), nio.InviteEvent)
445
+ self.client.add_event_callback(_create_task_wrapper(self._on_message), nio.RoomMessageText)
446
+ self.client.add_event_callback(_create_task_wrapper(self._on_reaction), nio.ReactionEvent)
447
+
448
+ # Register voice message callbacks (only for router agent to avoid duplicates)
449
+ if self.agent_name == ROUTER_AGENT_NAME:
450
+ self.client.add_event_callback(_create_task_wrapper(self._on_voice_message), nio.RoomMessageAudio)
451
+ self.client.add_event_callback(_create_task_wrapper(self._on_voice_message), nio.RoomEncryptedAudio)
452
+
453
+ self.running = True
454
+
455
+ # Router bot has additional responsibilities
456
+ if self.agent_name == ROUTER_AGENT_NAME:
457
+ try:
458
+ await cleanup_all_orphaned_bots(self.client, self.config)
459
+ except Exception as e:
460
+ self.logger.warning(f"Could not cleanup orphaned bots (non-critical): {e}")
461
+
462
+ # Note: Room joining is deferred until after invitations are handled
463
+ self.logger.info(f"Agent setup complete: {self.agent_user.user_id}")
464
+
465
+ async def try_start(self) -> bool:
466
+ """Try to start the agent bot with smart retry logic.
467
+
468
+ Uses tenacity to retry transient failures (network, timeouts) but not
469
+ permanent ones (auth failures).
470
+
471
+ Returns:
472
+ True if the bot started successfully, False otherwise.
473
+
474
+ """
475
+
476
+ def should_retry_error(retry_state: RetryCallState) -> bool:
477
+ """Determine if we should retry based on the exception.
478
+
479
+ Don't retry on auth failures (M_FORBIDDEN, M_USER_DEACTIVATED, etc)
480
+ which come as ValueError with those strings in the message.
481
+ """
482
+ if retry_state.outcome is None:
483
+ return True
484
+ exception = retry_state.outcome.exception()
485
+ if exception is None:
486
+ return False
487
+
488
+ # Don't retry auth failures
489
+ if isinstance(exception, ValueError):
490
+ error_msg = str(exception)
491
+ # Matrix auth error codes that shouldn't be retried
492
+ permanent_errors = ["M_FORBIDDEN", "M_USER_DEACTIVATED", "M_UNKNOWN_TOKEN", "M_INVALID_USERNAME"]
493
+ return not any(err in error_msg for err in permanent_errors)
494
+
495
+ # Retry other exceptions (network errors, timeouts, etc)
496
+ return True
497
+
498
+ @retry(
499
+ stop=stop_after_attempt(3),
500
+ wait=wait_exponential(multiplier=1, min=2, max=10),
501
+ retry=should_retry_error,
502
+ reraise=True,
503
+ )
504
+ async def _start_with_retry() -> None:
505
+ await self.start()
506
+
507
+ try:
508
+ await _start_with_retry()
509
+ return True # noqa: TRY300
510
+ except Exception:
511
+ logger.exception(f"Failed to start agent {self.agent_name}")
512
+ return False
513
+
514
+ async def cleanup(self) -> None:
515
+ """Clean up the agent by leaving all rooms and stopping.
516
+
517
+ This method ensures clean shutdown when an agent is removed from config.
518
+ """
519
+ assert self.client is not None
520
+ # Leave all rooms
521
+ try:
522
+ joined_rooms = await get_joined_rooms(self.client)
523
+ if joined_rooms:
524
+ for room_id in joined_rooms:
525
+ if await is_dm_room(self.client, room_id):
526
+ self.logger.debug(f"Preserving DM room {room_id} during cleanup")
527
+ continue
528
+
529
+ success = await leave_room(self.client, room_id)
530
+ if success:
531
+ self.logger.info(f"Left room {room_id} during cleanup")
532
+ else:
533
+ self.logger.error(f"Failed to leave room {room_id} during cleanup")
534
+ except Exception:
535
+ self.logger.exception("Error leaving rooms during cleanup")
536
+
537
+ # Stop the bot
538
+ await self.stop()
539
+
540
+ async def stop(self) -> None:
541
+ """Stop the agent bot."""
542
+ self.running = False
543
+
544
+ # Wait for any pending background tasks (like memory saves) to complete
545
+ try:
546
+ await wait_for_background_tasks(timeout=5.0) # 5 second timeout
547
+ self.logger.info("Background tasks completed")
548
+ except Exception as e:
549
+ self.logger.warning(f"Some background tasks did not complete: {e}")
550
+
551
+ if self.client is not None:
552
+ self.logger.warning("Client is not None in stop()")
553
+ await self.client.close()
554
+ self.logger.info("Stopped agent bot")
555
+
556
+ async def _send_welcome_message_if_empty(self, room_id: str) -> None:
557
+ """Send a welcome message if the room has no messages yet.
558
+
559
+ Only called by the router agent when joining a room.
560
+ """
561
+ assert self.client is not None
562
+
563
+ # Check if room has any messages
564
+ response = await self.client.room_messages(
565
+ room_id,
566
+ limit=2, # Get 2 messages to check if we already sent welcome
567
+ message_filter={"types": ["m.room.message"]},
568
+ )
569
+
570
+ # nio returns error types on failure - this is necessary
571
+ if not isinstance(response, nio.RoomMessagesResponse):
572
+ self.logger.error("Failed to check room messages", room_id=room_id, error=str(response))
573
+ return
574
+
575
+ # Only send welcome message if room is empty or only has our own welcome message
576
+ if not response.chunk:
577
+ # Room is completely empty
578
+ self.logger.info("Room is empty, sending welcome message", room_id=room_id)
579
+
580
+ # Generate and send the welcome message
581
+ welcome_msg = _generate_welcome_message(room_id, self.config)
582
+ await self._send_response(
583
+ room_id=room_id,
584
+ reply_to_event_id=None,
585
+ response_text=welcome_msg,
586
+ thread_id=None,
587
+ skip_mentions=True,
588
+ )
589
+ self.logger.info("Welcome message sent", room_id=room_id)
590
+ elif len(response.chunk) == 1:
591
+ # Check if the only message is our welcome message
592
+ msg = response.chunk[0]
593
+ if (
594
+ hasattr(msg, "sender")
595
+ and msg.sender == self.agent_user.user_id
596
+ and hasattr(msg, "body")
597
+ and "Welcome to MindRoom" in msg.body
598
+ ):
599
+ self.logger.debug("Welcome message already sent", room_id=room_id)
600
+ return
601
+ # Otherwise, room has a different message, don't send welcome
602
+ # Room has other messages, don't send welcome
603
+
604
+ async def sync_forever(self) -> None:
605
+ """Run the sync loop for this agent."""
606
+ assert self.client is not None
607
+ await self.client.sync_forever(timeout=SYNC_TIMEOUT_MS, full_state=True)
608
+
609
+ async def _on_invite(self, room: nio.MatrixRoom, event: nio.InviteEvent) -> None:
610
+ assert self.client is not None
611
+ self.logger.info("Received invite", room_id=room.room_id, sender=event.sender)
612
+ if await join_room(self.client, room.room_id):
613
+ self.logger.info("Joined room", room_id=room.room_id)
614
+ # If this is the router agent and the room is empty, send a welcome message
615
+ if self.agent_name == ROUTER_AGENT_NAME:
616
+ await self._send_welcome_message_if_empty(room.room_id)
617
+ else:
618
+ self.logger.error("Failed to join room", room_id=room.room_id)
619
+
620
+ async def _on_message(self, room: nio.MatrixRoom, event: nio.RoomMessageText) -> None: # noqa: C901, PLR0911, PLR0912
621
+ self.logger.info("Received message", event_id=event.event_id, room_id=room.room_id, sender=event.sender)
622
+ assert self.client is not None
623
+ if event.body.endswith(IN_PROGRESS_MARKER):
624
+ return
625
+
626
+ # Skip our own messages (unless voice transcription from router)
627
+ if event.sender == self.matrix_id.full_id and not event.body.startswith(VOICE_PREFIX):
628
+ return
629
+
630
+ event_info = EventInfo.from_event(event.source)
631
+
632
+ # Check if sender is authorized to interact with agents
633
+ is_authorized = is_authorized_sender(event.sender, self.config, room.room_id)
634
+ self.logger.debug(
635
+ f"Authorization check for {event.sender}: authorized={is_authorized}, room={room.room_id}",
636
+ )
637
+ if not is_authorized:
638
+ # Mark as seen even though we're not responding (prevents reprocessing after permission changes)
639
+ # Only mark non-edit events as responded
640
+ if not event_info.is_edit:
641
+ self.response_tracker.mark_responded(event.event_id)
642
+ self.logger.debug(f"Ignoring message from unauthorized sender: {event.sender}")
643
+ return
644
+
645
+ # Handle edit events
646
+ if event_info.is_edit:
647
+ await self._handle_message_edit(room, event, event_info)
648
+ return
649
+
650
+ # Check if we've already seen this message (prevents reprocessing after restart)
651
+ if self.response_tracker.has_responded(event.event_id):
652
+ self.logger.debug("Already seen message", event_id=event.event_id)
653
+ return
654
+
655
+ # We only receive events from rooms we're in - no need to check access
656
+ _is_dm_room = await is_dm_room(self.client, room.room_id)
657
+
658
+ await interactive.handle_text_response(self.client, room, event, self.agent_name)
659
+
660
+ # Router handles commands exclusively
661
+ command = command_parser.parse(event.body)
662
+ if command:
663
+ if self.agent_name == ROUTER_AGENT_NAME:
664
+ # Router always handles commands, even in single-agent rooms
665
+ # Commands like !schedule, !help, etc. need to work regardless
666
+ await self._handle_command(room, event, command)
667
+ return
668
+
669
+ context = await self._extract_message_context(room, event)
670
+
671
+ # Check if the sender is an agent
672
+ sender_agent_name = extract_agent_name(event.sender, self.config)
673
+
674
+ # Skip messages from agents unless:
675
+ # 1. We're mentioned
676
+ # 2. It's a voice transcription from router (treated as user message)
677
+ is_router_voice = sender_agent_name == ROUTER_AGENT_NAME and event.body.startswith(VOICE_PREFIX)
678
+ if sender_agent_name and not context.am_i_mentioned and not is_router_voice:
679
+ self.logger.debug("Ignoring message from other agent (not mentioned)")
680
+ return
681
+
682
+ # Get agents in thread (excludes router)
683
+ agents_in_thread = get_agents_in_thread(context.thread_history, self.config)
684
+
685
+ # Router: Route when no specific agent mentioned and no agents in thread
686
+ if self.agent_name == ROUTER_AGENT_NAME:
687
+ # Only perform AI routing when:
688
+ # 1. No specific agent is mentioned
689
+ # 2. No agents are already in the thread
690
+ # 3. There's more than one agent available (routing makes sense)
691
+ if not context.mentioned_agents and not agents_in_thread:
692
+ available_agents = get_available_agents_in_room(room, self.config)
693
+ if len(available_agents) == 1:
694
+ # Skip routing in single-agent rooms - the agent will handle it directly
695
+ self.logger.info("Skipping routing: only one agent present")
696
+ else:
697
+ # Multiple agents available - perform AI routing
698
+ await self._handle_ai_routing(room, event, context.thread_history)
699
+ # Router's job is done after routing/command handling/voice transcription
700
+ return
701
+
702
+ # Check for team formation
703
+ all_mentioned_in_thread = get_all_mentioned_agents_in_thread(context.thread_history, self.config)
704
+ form_team = await decide_team_formation(
705
+ self.matrix_id,
706
+ context.mentioned_agents,
707
+ agents_in_thread,
708
+ all_mentioned_in_thread,
709
+ room=room,
710
+ message=event.body,
711
+ config=self.config,
712
+ is_dm_room=_is_dm_room,
713
+ is_thread=context.is_thread,
714
+ )
715
+
716
+ # Handle team formation (only first agent alphabetically)
717
+ if form_team.should_form_team and self.matrix_id in form_team.agents:
718
+ # Determine if this agent should lead the team response
719
+ # Use the same ordering as decide_team_formation (by full_id)
720
+ first_agent = min(form_team.agents, key=lambda x: x.full_id)
721
+ if self.matrix_id != first_agent:
722
+ return
723
+
724
+ # Use the shared team response helper
725
+ response_event_id = await self._generate_team_response_helper(
726
+ room_id=room.room_id,
727
+ reply_to_event_id=event.event_id,
728
+ thread_id=context.thread_id,
729
+ message=event.body,
730
+ team_agents=form_team.agents,
731
+ team_mode=form_team.mode,
732
+ thread_history=context.thread_history,
733
+ requester_user_id=event.sender,
734
+ existing_event_id=None,
735
+ )
736
+
737
+ self.response_tracker.mark_responded(event.event_id, response_event_id)
738
+ return
739
+
740
+ # Check if we should respond individually
741
+ should_respond = should_agent_respond(
742
+ agent_name=self.agent_name,
743
+ am_i_mentioned=context.am_i_mentioned,
744
+ is_thread=context.is_thread,
745
+ room=room,
746
+ thread_history=context.thread_history,
747
+ config=self.config,
748
+ mentioned_agents=context.mentioned_agents,
749
+ )
750
+
751
+ if not should_respond:
752
+ return
753
+
754
+ # Log if responding without mention
755
+ if not context.am_i_mentioned:
756
+ self.logger.info("Will respond: only agent in thread")
757
+
758
+ # Generate and send response
759
+ self.logger.info("Processing", event_id=event.event_id)
760
+ response_event_id = await self._generate_response(
761
+ room_id=room.room_id,
762
+ prompt=event.body,
763
+ reply_to_event_id=event.event_id,
764
+ thread_id=context.thread_id,
765
+ thread_history=context.thread_history,
766
+ user_id=event.sender,
767
+ )
768
+ self.response_tracker.mark_responded(event.event_id, response_event_id)
769
+
770
+ async def _on_reaction(self, room: nio.MatrixRoom, event: nio.ReactionEvent) -> None:
771
+ """Handle reaction events for interactive questions, stop functionality, and config confirmations."""
772
+ assert self.client is not None
773
+
774
+ # Check if sender is authorized to interact with agents
775
+ if not is_authorized_sender(event.sender, self.config, room.room_id):
776
+ self.logger.debug(f"Ignoring reaction from unauthorized sender: {event.sender}")
777
+ return
778
+
779
+ # Check if this is a stop button reaction for a message currently being generated
780
+ # Only process stop functionality if:
781
+ # 1. The reaction is 🛑
782
+ # 2. The sender is not an agent (users only)
783
+ # 3. The message is currently being generated by this agent
784
+ if event.key == "🛑":
785
+ # Check if this is from a bot/agent
786
+ sender_agent_name = extract_agent_name(event.sender, self.config)
787
+ # Only handle stop from users, not agents, and only if tracking this message
788
+ if not sender_agent_name and await self.stop_manager.handle_stop_reaction(event.reacts_to):
789
+ self.logger.info(
790
+ "Stopped generation for message",
791
+ message_id=event.reacts_to,
792
+ stopped_by=event.sender,
793
+ )
794
+ # Remove the stop button immediately for user feedback
795
+ await self.stop_manager.remove_stop_button(self.client, event.reacts_to)
796
+ # Send a confirmation message
797
+ await self._send_response(room.room_id, event.reacts_to, "✅ Generation stopped", None)
798
+ return
799
+ # Message is not being generated - let the reaction be handled for other purposes
800
+ # (e.g., interactive questions). Don't return here so it can fall through!
801
+ # Agent reactions with 🛑 also fall through to other handlers
802
+
803
+ # Then check if this is a config confirmation reaction
804
+ pending_change = config_confirmation.get_pending_change(event.reacts_to)
805
+
806
+ if pending_change and self.agent_name == ROUTER_AGENT_NAME:
807
+ # Only router handles config confirmations
808
+ await config_confirmation.handle_confirmation_reaction(self, room, event, pending_change)
809
+ return
810
+
811
+ # Otherwise handle as interactive question
812
+ result = await interactive.handle_reaction(self.client, event, self.agent_name, self.config)
813
+
814
+ if result:
815
+ selected_value, thread_id = result
816
+ # User selected an option from an interactive question
817
+
818
+ # Check if we should process this reaction
819
+ thread_history = []
820
+ if thread_id:
821
+ thread_history = await fetch_thread_history(self.client, room.room_id, thread_id)
822
+ if has_user_responded_after_message(thread_history, event.reacts_to, self.matrix_id):
823
+ self.logger.info(
824
+ "Ignoring reaction - agent already responded after this question",
825
+ reacted_to=event.reacts_to,
826
+ )
827
+ return
828
+
829
+ # Send immediate acknowledgment
830
+ ack_text = f"You selected: {event.key} {selected_value}\n\nProcessing your response..."
831
+ # Matrix doesn't allow reply relations to events that already have relations (reactions)
832
+ # In threads, omit reply_to_event_id; the thread_id ensures correct placement
833
+ ack_event_id = await self._send_response(
834
+ room.room_id,
835
+ None if thread_id else event.reacts_to,
836
+ ack_text,
837
+ thread_id,
838
+ )
839
+
840
+ if not ack_event_id:
841
+ self.logger.error("Failed to send acknowledgment for reaction")
842
+ return
843
+
844
+ # Generate the response, editing the acknowledgment message
845
+ # Note: existing_event_id is only used for interactive questions to edit the acknowledgment
846
+ prompt = f"The user selected: {selected_value}"
847
+ response_event_id = await self._generate_response(
848
+ room_id=room.room_id,
849
+ prompt=prompt,
850
+ reply_to_event_id=event.reacts_to,
851
+ thread_id=thread_id,
852
+ thread_history=thread_history,
853
+ existing_event_id=ack_event_id, # Edit the acknowledgment instead of creating new message
854
+ user_id=event.sender,
855
+ )
856
+ # Mark the original interactive question as responded
857
+ self.response_tracker.mark_responded(event.reacts_to, response_event_id)
858
+
859
+ async def _on_voice_message(
860
+ self,
861
+ room: nio.MatrixRoom,
862
+ event: nio.RoomMessageAudio | nio.RoomEncryptedAudio,
863
+ ) -> None:
864
+ """Handle voice message events for transcription and processing."""
865
+ # Only process if voice handler is enabled
866
+ if not self.config.voice.enabled:
867
+ return
868
+
869
+ # Don't process our own voice messages
870
+ if event.sender == self.matrix_id.full_id:
871
+ return
872
+
873
+ # Check if we've already seen this voice message (prevents reprocessing after restart)
874
+ if self.response_tracker.has_responded(event.event_id):
875
+ self.logger.debug("Already processed voice message", event_id=event.event_id)
876
+ return
877
+
878
+ # Check if sender is authorized to interact with agents
879
+ if not is_authorized_sender(event.sender, self.config, room.room_id):
880
+ # Mark as seen even though we're not responding
881
+ self.response_tracker.mark_responded(event.event_id)
882
+ self.logger.debug(f"Ignoring voice message from unauthorized sender: {event.sender}")
883
+ return
884
+
885
+ self.logger.info("Processing voice message", event_id=event.event_id, sender=event.sender)
886
+
887
+ transcribed_message = await voice_handler.handle_voice_message(self.client, room, event, self.config)
888
+
889
+ if transcribed_message:
890
+ event_info = EventInfo.from_event(event.source)
891
+ response_event_id = await self._send_response(
892
+ room_id=room.room_id,
893
+ reply_to_event_id=event.event_id,
894
+ response_text=transcribed_message,
895
+ thread_id=event_info.thread_id,
896
+ )
897
+ self.response_tracker.mark_responded(event.event_id, response_event_id)
898
+ else:
899
+ # Mark as responded to avoid reprocessing
900
+ self.response_tracker.mark_responded(event.event_id)
901
+
902
+ async def _extract_message_context(self, room: nio.MatrixRoom, event: nio.RoomMessageText) -> MessageContext:
903
+ assert self.client is not None
904
+
905
+ # Check if mentions should be ignored for this message
906
+ skip_mentions = _should_skip_mentions(event.source)
907
+
908
+ if skip_mentions:
909
+ # Don't detect mentions if the message has skip_mentions metadata
910
+ mentioned_agents: list[MatrixID] = []
911
+ am_i_mentioned = False
912
+ else:
913
+ mentioned_agents, am_i_mentioned = check_agent_mentioned(event.source, self.matrix_id, self.config)
914
+
915
+ if am_i_mentioned:
916
+ self.logger.info("Mentioned", event_id=event.event_id, room_name=room.name)
917
+
918
+ event_info = EventInfo.from_event(event.source)
919
+
920
+ thread_history = []
921
+ if event_info.thread_id:
922
+ thread_history = await fetch_thread_history(self.client, room.room_id, event_info.thread_id)
923
+
924
+ return MessageContext(
925
+ am_i_mentioned=am_i_mentioned,
926
+ is_thread=event_info.is_thread,
927
+ thread_id=event_info.thread_id,
928
+ thread_history=thread_history,
929
+ mentioned_agents=mentioned_agents,
930
+ )
931
+
932
+ async def _generate_team_response_helper(
933
+ self,
934
+ room_id: str,
935
+ reply_to_event_id: str,
936
+ thread_id: str | None,
937
+ message: str,
938
+ team_agents: list[MatrixID],
939
+ team_mode: str,
940
+ thread_history: list[dict],
941
+ requester_user_id: str,
942
+ existing_event_id: str | None = None,
943
+ ) -> str | None:
944
+ """Generate a team response (shared between preformed teams and TeamBot).
945
+
946
+ Returns the initial message ID if created, None otherwise.
947
+ """
948
+ assert self.client is not None
949
+
950
+ # Get the appropriate model for this team and room
951
+ model_name = select_model_for_team(self.agent_name, room_id, self.config)
952
+
953
+ # Decide streaming based on presence
954
+ use_streaming = self.enable_streaming and await should_use_streaming(
955
+ self.client,
956
+ room_id,
957
+ requester_user_id=requester_user_id,
958
+ )
959
+
960
+ # Convert mode string to TeamMode enum
961
+ mode = TeamMode.COORDINATE if team_mode == "coordinate" else TeamMode.COLLABORATE
962
+
963
+ # Convert MatrixID list to agent names for non-streaming APIs
964
+ agent_names = [mid.agent_name(self.config) or mid.username for mid in team_agents]
965
+
966
+ # Create async function for team response generation that takes message_id as parameter
967
+ async def generate_team_response(message_id: str | None) -> None:
968
+ if use_streaming and not existing_event_id:
969
+ # Show typing indicator while team generates streaming response
970
+ async with typing_indicator(self.client, room_id):
971
+ response_stream = team_response_stream(
972
+ agent_ids=team_agents,
973
+ message=message,
974
+ orchestrator=self.orchestrator,
975
+ mode=mode,
976
+ thread_history=thread_history,
977
+ model_name=model_name,
978
+ )
979
+
980
+ event_id, accumulated = await send_streaming_response(
981
+ self.client,
982
+ room_id,
983
+ reply_to_event_id,
984
+ thread_id,
985
+ self.matrix_id.domain,
986
+ self.config,
987
+ response_stream,
988
+ streaming_cls=ReplacementStreamingResponse,
989
+ header=None,
990
+ existing_event_id=message_id,
991
+ )
992
+
993
+ # Handle interactive questions in team responses
994
+ await self._handle_interactive_question(
995
+ event_id,
996
+ accumulated,
997
+ room_id,
998
+ thread_id,
999
+ reply_to_event_id,
1000
+ agent_name="team",
1001
+ )
1002
+ else:
1003
+ # Show typing indicator while team generates non-streaming response
1004
+ async with typing_indicator(self.client, room_id):
1005
+ response_text = await team_response(
1006
+ agent_names=agent_names,
1007
+ mode=mode,
1008
+ message=message,
1009
+ orchestrator=self.orchestrator,
1010
+ thread_history=thread_history,
1011
+ model_name=model_name,
1012
+ )
1013
+
1014
+ # Either edit the thinking message or send new
1015
+ if message_id:
1016
+ await self._edit_message(room_id, message_id, response_text, thread_id)
1017
+ else:
1018
+ assert self.client is not None
1019
+ event_id = await self._send_response(
1020
+ room_id,
1021
+ reply_to_event_id,
1022
+ response_text,
1023
+ thread_id,
1024
+ )
1025
+ # Handle interactive questions in non-streaming team responses
1026
+ if event_id:
1027
+ await self._handle_interactive_question(
1028
+ event_id,
1029
+ response_text,
1030
+ room_id,
1031
+ thread_id,
1032
+ reply_to_event_id,
1033
+ agent_name="team",
1034
+ )
1035
+
1036
+ # Use unified handler for cancellation support
1037
+ # Always send thinking message unless we're editing an existing message
1038
+ thinking_msg = None
1039
+ if not existing_event_id:
1040
+ thinking_msg = "🤝 Team Response: Thinking..."
1041
+
1042
+ return await self._run_cancellable_response(
1043
+ room_id=room_id,
1044
+ reply_to_event_id=reply_to_event_id,
1045
+ thread_id=thread_id,
1046
+ response_function=generate_team_response,
1047
+ thinking_message=thinking_msg,
1048
+ existing_event_id=existing_event_id,
1049
+ user_id=requester_user_id,
1050
+ )
1051
+
1052
+ async def _run_cancellable_response(
1053
+ self,
1054
+ room_id: str,
1055
+ reply_to_event_id: str,
1056
+ thread_id: str | None,
1057
+ response_function: object, # Function that generates the response (takes message_id)
1058
+ thinking_message: str | None = None, # None means don't send thinking message
1059
+ existing_event_id: str | None = None,
1060
+ user_id: str | None = None, # User ID for presence check
1061
+ ) -> str | None:
1062
+ """Run a response generation function with cancellation support.
1063
+
1064
+ This unified handler provides:
1065
+ - Optional "Thinking..." message
1066
+ - Task cancellation via stop button (when user is online)
1067
+ - Proper cleanup on completion or cancellation
1068
+
1069
+ Args:
1070
+ room_id: The room to send to
1071
+ reply_to_event_id: Event to reply to
1072
+ thread_id: Thread ID if in thread
1073
+ response_function: Async function that generates the response (takes message_id parameter)
1074
+ thinking_message: Thinking message to show (only used when existing_event_id is None)
1075
+ existing_event_id: ID of existing message to edit (for interactive questions)
1076
+ user_id: User ID for checking if they're online (for stop button decision)
1077
+
1078
+ Returns:
1079
+ The initial message ID if created, None otherwise
1080
+
1081
+ Note: In practice, either thinking_message or existing_event_id is provided, never both.
1082
+
1083
+ """
1084
+ assert self.client is not None
1085
+
1086
+ # Validate the mutual exclusivity constraint
1087
+ assert not (thinking_message and existing_event_id), (
1088
+ "thinking_message and existing_event_id are mutually exclusive"
1089
+ )
1090
+
1091
+ # Send initial thinking message if not editing an existing message
1092
+ initial_message_id = None
1093
+ if thinking_message:
1094
+ assert not existing_event_id # Redundant but makes the logic clear
1095
+ initial_message_id = await self._send_response(
1096
+ room_id,
1097
+ reply_to_event_id,
1098
+ f"{thinking_message} {IN_PROGRESS_MARKER}",
1099
+ thread_id,
1100
+ )
1101
+
1102
+ # Determine which message ID to use
1103
+ message_id = existing_event_id or initial_message_id
1104
+
1105
+ # Create cancellable task by calling the function with the message ID
1106
+ task: asyncio.Task[None] = asyncio.create_task(response_function(message_id)) # type: ignore[operator]
1107
+
1108
+ # Track for stop button (only if we have a message to track)
1109
+ message_to_track = existing_event_id or initial_message_id
1110
+ show_stop_button = False # Default to not showing
1111
+
1112
+ if message_to_track:
1113
+ self.stop_manager.set_current(message_to_track, room_id, task, None)
1114
+
1115
+ # Add stop button if configured AND user is online
1116
+ # This uses the same logic as streaming to determine if user is online
1117
+ show_stop_button = self.config.defaults.show_stop_button
1118
+ if show_stop_button and user_id:
1119
+ # Check if user is online - same logic as streaming decision
1120
+ user_is_online = await is_user_online(self.client, user_id)
1121
+ show_stop_button = user_is_online
1122
+ self.logger.info(
1123
+ "Stop button decision",
1124
+ message_id=message_to_track,
1125
+ user_online=user_is_online,
1126
+ show_button=show_stop_button,
1127
+ )
1128
+
1129
+ if show_stop_button:
1130
+ self.logger.info("Adding stop button", message_id=message_to_track)
1131
+ await self.stop_manager.add_stop_button(self.client, room_id, message_to_track)
1132
+
1133
+ try:
1134
+ await task
1135
+ except asyncio.CancelledError:
1136
+ self.logger.info("Response cancelled by user", message_id=message_to_track)
1137
+ except Exception as e:
1138
+ self.logger.exception("Error during response generation", error=str(e))
1139
+ raise
1140
+ finally:
1141
+ if message_to_track:
1142
+ tracked = self.stop_manager.tracked_messages.get(message_to_track)
1143
+ button_already_removed = tracked is None or tracked.reaction_event_id is None
1144
+
1145
+ self.stop_manager.clear_message(
1146
+ message_to_track,
1147
+ client=self.client,
1148
+ remove_button=show_stop_button and not button_already_removed,
1149
+ )
1150
+
1151
+ return initial_message_id
1152
+
1153
+ async def _process_and_respond(
1154
+ self,
1155
+ room_id: str,
1156
+ prompt: str,
1157
+ reply_to_event_id: str,
1158
+ thread_id: str | None,
1159
+ thread_history: list[dict],
1160
+ existing_event_id: str | None = None,
1161
+ ) -> str | None:
1162
+ """Process a message and send a response (non-streaming)."""
1163
+ if not prompt.strip():
1164
+ return None
1165
+
1166
+ session_id = create_session_id(room_id, thread_id)
1167
+
1168
+ try:
1169
+ # Show typing indicator while generating response
1170
+ async with typing_indicator(self.client, room_id):
1171
+ response_text = await ai_response(
1172
+ agent_name=self.agent_name,
1173
+ prompt=prompt,
1174
+ session_id=session_id,
1175
+ storage_path=self.storage_path,
1176
+ config=self.config,
1177
+ thread_history=thread_history,
1178
+ room_id=room_id,
1179
+ )
1180
+ except asyncio.CancelledError:
1181
+ # Handle cancellation - send a message showing it was stopped
1182
+ self.logger.info("Non-streaming response cancelled by user", message_id=existing_event_id)
1183
+ if existing_event_id:
1184
+ cancelled_text = "**[Response cancelled by user]**"
1185
+ await self._edit_message(room_id, existing_event_id, cancelled_text, thread_id)
1186
+ raise
1187
+ except Exception as e:
1188
+ self.logger.exception("Error in non-streaming response", error=str(e))
1189
+ raise
1190
+
1191
+ if existing_event_id:
1192
+ # Edit the existing message
1193
+ await self._edit_message(room_id, existing_event_id, response_text, thread_id)
1194
+ return existing_event_id
1195
+
1196
+ response = interactive.parse_and_format_interactive(response_text, extract_mapping=True)
1197
+ event_id = await self._send_response(room_id, reply_to_event_id, response.formatted_text, thread_id)
1198
+ if event_id and response.option_map and response.options_list:
1199
+ # For interactive questions, use the same thread root that _send_response uses:
1200
+ # - If already in a thread, use that thread_id
1201
+ # - If not in a thread, use reply_to_event_id (the user's message) as thread root
1202
+ # This ensures consistency with how the bot creates threads
1203
+ thread_root_for_registration = thread_id if thread_id else reply_to_event_id
1204
+ interactive.register_interactive_question(
1205
+ event_id,
1206
+ room_id,
1207
+ thread_root_for_registration,
1208
+ response.option_map,
1209
+ self.agent_name,
1210
+ )
1211
+ await interactive.add_reaction_buttons(self.client, room_id, event_id, response.options_list)
1212
+
1213
+ return event_id
1214
+
1215
+ async def _handle_interactive_question(
1216
+ self,
1217
+ event_id: str | None,
1218
+ content: str,
1219
+ room_id: str,
1220
+ thread_id: str | None,
1221
+ reply_to_event_id: str,
1222
+ agent_name: str | None = None,
1223
+ ) -> None:
1224
+ """Handle interactive question registration and reactions if present.
1225
+
1226
+ Args:
1227
+ event_id: The message event ID
1228
+ content: The message content to check for interactive questions
1229
+ room_id: The Matrix room ID
1230
+ thread_id: Thread ID if in a thread
1231
+ reply_to_event_id: Event being replied to
1232
+ agent_name: Name of agent (for registration)
1233
+
1234
+ """
1235
+ if not event_id or not self.client:
1236
+ return
1237
+
1238
+ if interactive.should_create_interactive_question(content):
1239
+ response = interactive.parse_and_format_interactive(content, extract_mapping=True)
1240
+ if response.option_map and response.options_list:
1241
+ thread_root_for_registration = thread_id if thread_id else reply_to_event_id
1242
+ interactive.register_interactive_question(
1243
+ event_id,
1244
+ room_id,
1245
+ thread_root_for_registration,
1246
+ response.option_map,
1247
+ agent_name or self.agent_name,
1248
+ )
1249
+ await interactive.add_reaction_buttons(
1250
+ self.client,
1251
+ room_id,
1252
+ event_id,
1253
+ response.options_list,
1254
+ )
1255
+
1256
+ async def _process_and_respond_streaming(
1257
+ self,
1258
+ room_id: str,
1259
+ prompt: str,
1260
+ reply_to_event_id: str,
1261
+ thread_id: str | None,
1262
+ thread_history: list[dict],
1263
+ existing_event_id: str | None = None,
1264
+ ) -> str | None:
1265
+ """Process a message and send a response (streaming)."""
1266
+ assert self.client is not None
1267
+ if not prompt.strip():
1268
+ return None
1269
+
1270
+ session_id = create_session_id(room_id, thread_id)
1271
+
1272
+ try:
1273
+ # Show typing indicator while generating response
1274
+ async with typing_indicator(self.client, room_id):
1275
+ response_stream = stream_agent_response(
1276
+ agent_name=self.agent_name,
1277
+ prompt=prompt,
1278
+ session_id=session_id,
1279
+ storage_path=self.storage_path,
1280
+ config=self.config,
1281
+ thread_history=thread_history,
1282
+ room_id=room_id,
1283
+ )
1284
+
1285
+ event_id, accumulated = await send_streaming_response(
1286
+ self.client,
1287
+ room_id,
1288
+ reply_to_event_id,
1289
+ thread_id,
1290
+ self.matrix_id.domain,
1291
+ self.config,
1292
+ response_stream,
1293
+ streaming_cls=StreamingResponse,
1294
+ existing_event_id=existing_event_id,
1295
+ )
1296
+
1297
+ # Handle interactive questions if present
1298
+ await self._handle_interactive_question(
1299
+ event_id,
1300
+ accumulated,
1301
+ room_id,
1302
+ thread_id,
1303
+ reply_to_event_id,
1304
+ )
1305
+
1306
+ except asyncio.CancelledError:
1307
+ # Handle cancellation - send a message showing it was stopped
1308
+ self.logger.info("Streaming cancelled by user", message_id=existing_event_id)
1309
+ if existing_event_id:
1310
+ cancelled_text = "**[Response cancelled by user]**"
1311
+ await self._edit_message(room_id, existing_event_id, cancelled_text, thread_id)
1312
+ raise
1313
+ except Exception as e:
1314
+ self.logger.exception("Error in streaming response", error=str(e))
1315
+ # Don't mark as responded if streaming failed
1316
+ return None
1317
+ else:
1318
+ return event_id
1319
+
1320
+ async def _generate_response(
1321
+ self,
1322
+ room_id: str,
1323
+ prompt: str,
1324
+ reply_to_event_id: str,
1325
+ thread_id: str | None,
1326
+ thread_history: list[dict],
1327
+ existing_event_id: str | None = None,
1328
+ user_id: str | None = None,
1329
+ ) -> str | None:
1330
+ """Generate and send/edit a response using AI.
1331
+
1332
+ Args:
1333
+ room_id: The room to send the response to
1334
+ prompt: The prompt to send to the AI
1335
+ reply_to_event_id: The event to reply to
1336
+ thread_id: Thread ID if in a thread
1337
+ thread_history: Thread history for context
1338
+ existing_event_id: If provided, edit this message instead of sending a new one
1339
+ (only used for interactive question responses)
1340
+ user_id: User ID of the sender for identifying user messages in history
1341
+
1342
+ Returns:
1343
+ Event ID of the response message, or None if failed
1344
+
1345
+ """
1346
+ assert self.client is not None
1347
+
1348
+ # Prepare session id for memory storage (store after sending response)
1349
+ session_id = create_session_id(room_id, thread_id)
1350
+
1351
+ # Dynamically determine whether to use streaming based on user presence
1352
+ # Only check presence if streaming is globally enabled
1353
+ use_streaming = self.enable_streaming
1354
+ if use_streaming:
1355
+ # Check if the user is online to decide whether to stream
1356
+ use_streaming = await should_use_streaming(self.client, room_id, requester_user_id=user_id)
1357
+
1358
+ # Create async function for generation that takes message_id as parameter
1359
+ async def generate(message_id: str | None) -> None:
1360
+ if use_streaming:
1361
+ await self._process_and_respond_streaming(
1362
+ room_id,
1363
+ prompt,
1364
+ reply_to_event_id,
1365
+ thread_id,
1366
+ thread_history,
1367
+ message_id, # Edit the thinking message or existing
1368
+ )
1369
+ else:
1370
+ await self._process_and_respond(
1371
+ room_id,
1372
+ prompt,
1373
+ reply_to_event_id,
1374
+ thread_id,
1375
+ thread_history,
1376
+ message_id, # Edit the thinking message or existing
1377
+ )
1378
+
1379
+ # Use unified handler for cancellation support
1380
+ # Always send "Thinking..." message unless we're editing an existing message
1381
+ thinking_msg = None
1382
+ if not existing_event_id:
1383
+ thinking_msg = "Thinking..."
1384
+
1385
+ event_id = await self._run_cancellable_response(
1386
+ room_id=room_id,
1387
+ reply_to_event_id=reply_to_event_id,
1388
+ thread_id=thread_id,
1389
+ response_function=generate,
1390
+ thinking_message=thinking_msg,
1391
+ existing_event_id=existing_event_id,
1392
+ user_id=user_id,
1393
+ )
1394
+
1395
+ # Store memory after response generation; ignore errors in tests/mocks
1396
+ # TODO: Remove try-except and fix tests
1397
+ try:
1398
+ create_background_task(
1399
+ store_conversation_memory(
1400
+ prompt,
1401
+ self.agent_name,
1402
+ self.storage_path,
1403
+ session_id,
1404
+ self.config,
1405
+ room_id,
1406
+ thread_history,
1407
+ user_id,
1408
+ ),
1409
+ name=f"memory_save_{self.agent_name}_{session_id}",
1410
+ )
1411
+ except Exception: # pragma: no cover
1412
+ self.logger.debug("Skipping memory storage due to configuration error")
1413
+
1414
+ return event_id
1415
+
1416
+ async def _send_response(
1417
+ self,
1418
+ room_id: str,
1419
+ reply_to_event_id: str | None,
1420
+ response_text: str,
1421
+ thread_id: str | None,
1422
+ reply_to_event: nio.RoomMessageText | None = None,
1423
+ skip_mentions: bool = False,
1424
+ ) -> str | None:
1425
+ """Send a response message to a room.
1426
+
1427
+ Args:
1428
+ room_id: The room id to send to
1429
+ reply_to_event_id: The event ID to reply to (can be None when in a thread)
1430
+ response_text: The text to send
1431
+ thread_id: The thread ID if already in a thread
1432
+ reply_to_event: Optional event object for the message we're replying to (used to check for safe thread root)
1433
+ skip_mentions: If True, add metadata to indicate mentions should not trigger responses
1434
+
1435
+ Returns:
1436
+ Event ID if message was sent successfully, None otherwise.
1437
+
1438
+ """
1439
+ sender_id = self.matrix_id
1440
+ sender_domain = sender_id.domain
1441
+
1442
+ # Always ensure we have a thread_id - use the original message as thread root if needed
1443
+ # This ensures agents always respond in threads, even when mentioned in main room
1444
+ event_info = EventInfo.from_event(reply_to_event.source if reply_to_event else None)
1445
+ effective_thread_id = thread_id or event_info.safe_thread_root or reply_to_event_id
1446
+
1447
+ # Get the latest message in thread for MSC3440 fallback compatibility
1448
+ latest_thread_event_id = await get_latest_thread_event_id_if_needed(
1449
+ self.client,
1450
+ room_id,
1451
+ effective_thread_id,
1452
+ reply_to_event_id,
1453
+ )
1454
+
1455
+ content = format_message_with_mentions(
1456
+ self.config,
1457
+ response_text,
1458
+ sender_domain=sender_domain,
1459
+ thread_event_id=effective_thread_id,
1460
+ reply_to_event_id=reply_to_event_id,
1461
+ latest_thread_event_id=latest_thread_event_id,
1462
+ )
1463
+
1464
+ # Add metadata to indicate mentions should be ignored for responses
1465
+ if skip_mentions:
1466
+ content["com.mindroom.skip_mentions"] = True
1467
+
1468
+ assert self.client is not None
1469
+ event_id = await send_message(self.client, room_id, content)
1470
+ if event_id:
1471
+ self.logger.info("Sent response", event_id=event_id, room_id=room_id)
1472
+ return event_id
1473
+ self.logger.error("Failed to send response to room", room_id=room_id)
1474
+ return None
1475
+
1476
+ async def _edit_message(self, room_id: str, event_id: str, new_text: str, thread_id: str | None) -> bool:
1477
+ """Edit an existing message.
1478
+
1479
+ Returns:
1480
+ True if edit was successful, False otherwise.
1481
+
1482
+ """
1483
+ sender_id = self.matrix_id
1484
+ sender_domain = sender_id.domain
1485
+
1486
+ # For edits in threads, we need to get the latest thread event ID for MSC3440 compliance
1487
+ # When editing, we still need the latest thread event for the fallback behavior
1488
+ # So we fetch it directly rather than using get_latest_thread_event_id_if_needed
1489
+ latest_thread_event_id = None
1490
+ if thread_id:
1491
+ assert self.client is not None
1492
+ # For edits, we always need the latest thread event ID
1493
+ # We can use the event being edited as the fallback if we can't get the latest
1494
+ latest_thread_event_id = await _latest_thread_event_id(self.client, room_id, thread_id)
1495
+ # If we couldn't get the latest, use the event being edited as fallback
1496
+ if latest_thread_event_id is None:
1497
+ latest_thread_event_id = event_id
1498
+
1499
+ content = format_message_with_mentions(
1500
+ self.config,
1501
+ new_text,
1502
+ sender_domain=sender_domain,
1503
+ thread_event_id=thread_id,
1504
+ latest_thread_event_id=latest_thread_event_id,
1505
+ )
1506
+
1507
+ assert self.client is not None
1508
+ response = await edit_message(self.client, room_id, event_id, content, new_text)
1509
+
1510
+ if isinstance(response, nio.RoomSendResponse):
1511
+ self.logger.info("Edited message", event_id=event_id)
1512
+ return True
1513
+ self.logger.error("Failed to edit message", event_id=event_id, error=str(response))
1514
+ return False
1515
+
1516
+ async def _handle_ai_routing(
1517
+ self,
1518
+ room: nio.MatrixRoom,
1519
+ event: nio.RoomMessageText,
1520
+ thread_history: list[dict],
1521
+ ) -> None:
1522
+ # Only router agent should handle routing
1523
+ assert self.agent_name == ROUTER_AGENT_NAME
1524
+
1525
+ # Use configured agents only - router should not suggest random agents
1526
+ available_agents = get_configured_agents_for_room(room.room_id, self.config)
1527
+ if not available_agents:
1528
+ self.logger.debug("No configured agents to route to in this room")
1529
+ return
1530
+
1531
+ self.logger.info("Handling AI routing", event_id=event.event_id)
1532
+
1533
+ event_info = EventInfo.from_event(event.source)
1534
+ suggested_agent = await suggest_agent_for_message(
1535
+ event.body,
1536
+ available_agents,
1537
+ self.config,
1538
+ thread_history,
1539
+ )
1540
+
1541
+ if not suggested_agent:
1542
+ # Send error message when routing fails
1543
+ response_text = "⚠️ I couldn't determine which agent should help with this. Please try mentioning an agent directly with @ or rephrase your request."
1544
+ self.logger.warning("Router failed to determine agent")
1545
+ else:
1546
+ # Router mentions the suggested agent and asks them to help
1547
+ response_text = f"@{suggested_agent} could you help with this?"
1548
+ sender_id = self.matrix_id
1549
+ sender_domain = sender_id.domain
1550
+
1551
+ # If no thread exists, create one with the original message as root
1552
+ thread_event_id = event_info.thread_id
1553
+ if not thread_event_id:
1554
+ # Check if the current event can be a thread root
1555
+ thread_event_id = event_info.safe_thread_root or event.event_id
1556
+
1557
+ # Get latest thread event for MSC3440 compliance when no specific reply
1558
+ # Note: We use event.event_id as reply_to for routing suggestions
1559
+ latest_thread_event_id = await get_latest_thread_event_id_if_needed(
1560
+ self.client,
1561
+ room.room_id,
1562
+ thread_event_id,
1563
+ event.event_id,
1564
+ )
1565
+
1566
+ content = format_message_with_mentions(
1567
+ self.config,
1568
+ response_text,
1569
+ sender_domain=sender_domain,
1570
+ thread_event_id=thread_event_id,
1571
+ reply_to_event_id=event.event_id,
1572
+ latest_thread_event_id=latest_thread_event_id,
1573
+ )
1574
+
1575
+ assert self.client is not None
1576
+ event_id = await send_message(self.client, room.room_id, content)
1577
+ if event_id:
1578
+ self.logger.info("Routed to agent", suggested_agent=suggested_agent)
1579
+ self.response_tracker.mark_responded(event.event_id)
1580
+ else:
1581
+ self.logger.error("Failed to route to agent", agent=suggested_agent)
1582
+
1583
+ async def _handle_message_edit(
1584
+ self,
1585
+ room: nio.MatrixRoom,
1586
+ event: nio.RoomMessageText,
1587
+ event_info: EventInfo,
1588
+ ) -> None:
1589
+ """Handle an edited message by regenerating the agent's response.
1590
+
1591
+ Args:
1592
+ room: The Matrix room
1593
+ event: The edited message event
1594
+ event_info: Information about the edit event
1595
+
1596
+ """
1597
+ if not event_info.original_event_id:
1598
+ self.logger.debug("Edit event has no original event ID")
1599
+ return
1600
+
1601
+ # Skip edits from other agents
1602
+ sender_agent_name = extract_agent_name(event.sender, self.config)
1603
+ if sender_agent_name:
1604
+ self.logger.debug(f"Ignoring edit from other agent: {sender_agent_name}")
1605
+ return
1606
+
1607
+ response_event_id = self.response_tracker.get_response_event_id(event_info.original_event_id)
1608
+ if not response_event_id:
1609
+ self.logger.debug(f"No previous response found for edited message {event_info.original_event_id}")
1610
+ return
1611
+
1612
+ self.logger.info(
1613
+ "Regenerating response for edited message",
1614
+ original_event_id=event_info.original_event_id,
1615
+ response_event_id=response_event_id,
1616
+ )
1617
+
1618
+ context = await self._extract_message_context(room, event)
1619
+
1620
+ # Check if we should respond to the edited message
1621
+ # KNOWN LIMITATION: This doesn't work correctly for the router suggestion case.
1622
+ # When: User asks question → Router suggests agent → Agent responds → User edits
1623
+ # The agent won't regenerate because it's not mentioned in the edited message.
1624
+ # Proper fix would require tracking response chains (user → router → agent).
1625
+ should_respond = should_agent_respond(
1626
+ agent_name=self.agent_name,
1627
+ am_i_mentioned=context.am_i_mentioned,
1628
+ is_thread=context.is_thread,
1629
+ room=room,
1630
+ thread_history=context.thread_history,
1631
+ config=self.config,
1632
+ mentioned_agents=context.mentioned_agents,
1633
+ )
1634
+
1635
+ if not should_respond:
1636
+ self.logger.debug("Agent should not respond to edited message")
1637
+ return
1638
+
1639
+ # These keys must be present according to MSC2676
1640
+ # https://github.com/matrix-org/matrix-spec-proposals/blob/main/proposals/2676-message-editing.md
1641
+ edited_content = event.source["content"]["m.new_content"]["body"]
1642
+
1643
+ # Generate new response
1644
+ await self._generate_response(
1645
+ room_id=room.room_id,
1646
+ prompt=edited_content,
1647
+ reply_to_event_id=event_info.original_event_id,
1648
+ thread_id=context.thread_id,
1649
+ thread_history=context.thread_history,
1650
+ existing_event_id=response_event_id,
1651
+ user_id=event.sender,
1652
+ )
1653
+
1654
+ # Update the response tracker
1655
+ self.response_tracker.mark_responded(event_info.original_event_id, response_event_id)
1656
+ self.logger.info("Successfully regenerated response for edited message")
1657
+
1658
+ async def _handle_command(self, room: nio.MatrixRoom, event: nio.RoomMessageText, command: Command) -> None: # noqa: C901, PLR0912
1659
+ self.logger.info("Handling command", command_type=command.type.value)
1660
+
1661
+ event_info = EventInfo.from_event(event.source)
1662
+
1663
+ # Widget command modifies room state, so it doesn't need a thread
1664
+ if command.type == CommandType.WIDGET:
1665
+ assert self.client is not None
1666
+ url = command.args.get("url")
1667
+ response_text = await handle_widget_command(client=self.client, room_id=room.room_id, url=url)
1668
+ # Send response in thread if in thread, otherwise in main room
1669
+ await self._send_response(room.room_id, event.event_id, response_text, event_info.thread_id)
1670
+ return
1671
+
1672
+ # For commands that need thread context, use the existing thread or the event will start a new one
1673
+ # The _send_response method will automatically create a thread if needed
1674
+ effective_thread_id = event_info.thread_id or event_info.safe_thread_root or event.event_id
1675
+
1676
+ response_text = ""
1677
+
1678
+ if command.type == CommandType.HELP:
1679
+ topic = command.args.get("topic")
1680
+ response_text = get_command_help(topic)
1681
+
1682
+ elif command.type == CommandType.HI:
1683
+ # Generate the welcome message for this room
1684
+ response_text = _generate_welcome_message(room.room_id, self.config)
1685
+
1686
+ elif command.type == CommandType.SCHEDULE:
1687
+ full_text = command.args["full_text"]
1688
+
1689
+ # Get mentioned agents from the command text
1690
+ mentioned_agents, _ = check_agent_mentioned(event.source, None, self.config)
1691
+
1692
+ assert self.client is not None
1693
+ task_id, response_text = await schedule_task(
1694
+ client=self.client,
1695
+ room_id=room.room_id,
1696
+ thread_id=effective_thread_id,
1697
+ scheduled_by=event.sender,
1698
+ full_text=full_text,
1699
+ config=self.config,
1700
+ room=room,
1701
+ mentioned_agents=mentioned_agents,
1702
+ )
1703
+
1704
+ elif command.type == CommandType.LIST_SCHEDULES:
1705
+ assert self.client is not None
1706
+ response_text = await list_scheduled_tasks(
1707
+ client=self.client,
1708
+ room_id=room.room_id,
1709
+ thread_id=effective_thread_id,
1710
+ config=self.config,
1711
+ )
1712
+
1713
+ elif command.type == CommandType.CANCEL_SCHEDULE:
1714
+ assert self.client is not None
1715
+ cancel_all = command.args.get("cancel_all", False)
1716
+
1717
+ if cancel_all:
1718
+ # Cancel all scheduled tasks
1719
+ response_text = await cancel_all_scheduled_tasks(
1720
+ client=self.client,
1721
+ room_id=room.room_id,
1722
+ )
1723
+ else:
1724
+ # Cancel specific task
1725
+ task_id = command.args["task_id"]
1726
+ response_text = await cancel_scheduled_task(
1727
+ client=self.client,
1728
+ room_id=room.room_id,
1729
+ task_id=task_id,
1730
+ )
1731
+
1732
+ elif command.type == CommandType.CONFIG:
1733
+ # Handle config command
1734
+ args_text = command.args.get("args_text", "")
1735
+ response_text, change_info = await handle_config_command(args_text)
1736
+
1737
+ # If we have change_info, this is a config set that needs confirmation
1738
+ if change_info:
1739
+ # Send the preview message
1740
+ event_id = await self._send_response(
1741
+ room.room_id,
1742
+ event.event_id,
1743
+ response_text,
1744
+ event_info.thread_id,
1745
+ reply_to_event=event,
1746
+ skip_mentions=True,
1747
+ )
1748
+
1749
+ if event_id:
1750
+ # Register the pending change
1751
+ config_confirmation.register_pending_change(
1752
+ event_id=event_id,
1753
+ room_id=room.room_id,
1754
+ thread_id=event_info.thread_id,
1755
+ config_path=change_info["config_path"],
1756
+ old_value=change_info["old_value"],
1757
+ new_value=change_info["new_value"],
1758
+ requester=event.sender,
1759
+ )
1760
+
1761
+ # Get the pending change we just registered
1762
+ pending_change = config_confirmation.get_pending_change(event_id)
1763
+
1764
+ # Store in Matrix state for persistence
1765
+ if pending_change:
1766
+ await config_confirmation.store_pending_change_in_matrix(
1767
+ self.client,
1768
+ event_id,
1769
+ pending_change,
1770
+ )
1771
+
1772
+ # Add reaction buttons
1773
+ await config_confirmation.add_confirmation_reactions(self.client, room.room_id, event_id)
1774
+
1775
+ self.response_tracker.mark_responded(event.event_id)
1776
+ return # Exit early since we've handled the response
1777
+
1778
+ elif command.type == CommandType.UNKNOWN:
1779
+ # Handle unknown commands
1780
+ response_text = "❌ Unknown command. Try !help for available commands."
1781
+
1782
+ if response_text:
1783
+ await self._send_response(
1784
+ room.room_id,
1785
+ event.event_id,
1786
+ response_text,
1787
+ event_info.thread_id,
1788
+ reply_to_event=event,
1789
+ skip_mentions=True,
1790
+ )
1791
+ self.response_tracker.mark_responded(event.event_id)
1792
+
1793
+
1794
+ @dataclass
1795
+ class TeamBot(AgentBot):
1796
+ """A bot that represents a team of agents working together."""
1797
+
1798
+ team_agents: list[MatrixID] = field(default_factory=list)
1799
+ team_mode: str = field(default="coordinate")
1800
+ team_model: str | None = field(default=None)
1801
+
1802
+ @cached_property
1803
+ def agent(self) -> Agent | None: # type: ignore[override]
1804
+ """Teams don't have individual agents, return None."""
1805
+ return None
1806
+
1807
+ async def _generate_response(
1808
+ self,
1809
+ room_id: str,
1810
+ prompt: str,
1811
+ reply_to_event_id: str,
1812
+ thread_id: str | None,
1813
+ thread_history: list[dict],
1814
+ existing_event_id: str | None = None,
1815
+ user_id: str | None = None,
1816
+ ) -> None:
1817
+ """Generate a team response instead of individual agent response."""
1818
+ if not prompt.strip():
1819
+ return
1820
+
1821
+ assert self.client is not None
1822
+
1823
+ # Store memory once for the entire team (avoids duplicate LLM processing)
1824
+ session_id = create_session_id(room_id, thread_id)
1825
+ # Convert MatrixID list to agent names for memory storage
1826
+ agent_names = [mid.agent_name(self.config) or mid.username for mid in self.team_agents]
1827
+ create_background_task(
1828
+ store_conversation_memory(
1829
+ prompt,
1830
+ agent_names, # Pass list of agent names for team storage
1831
+ self.storage_path,
1832
+ session_id,
1833
+ self.config,
1834
+ room_id,
1835
+ thread_history,
1836
+ user_id,
1837
+ ),
1838
+ name=f"memory_save_team_{session_id}",
1839
+ )
1840
+ self.logger.info(f"Storing memory for team: {agent_names}")
1841
+
1842
+ # Use the shared team response helper
1843
+ await self._generate_team_response_helper(
1844
+ room_id=room_id,
1845
+ reply_to_event_id=reply_to_event_id,
1846
+ thread_id=thread_id,
1847
+ message=prompt,
1848
+ team_agents=self.team_agents,
1849
+ team_mode=self.team_mode,
1850
+ thread_history=thread_history,
1851
+ requester_user_id=user_id or "",
1852
+ existing_event_id=existing_event_id,
1853
+ )
1854
+
1855
+
1856
+ @dataclass
1857
+ class MultiAgentOrchestrator:
1858
+ """Orchestrates multiple agent bots."""
1859
+
1860
+ storage_path: Path
1861
+ agent_bots: dict[str, AgentBot | TeamBot] = field(default_factory=dict, init=False)
1862
+ running: bool = field(default=False, init=False)
1863
+ config: Config | None = field(default=None, init=False)
1864
+ _sync_tasks: dict[str, asyncio.Task] = field(default_factory=dict, init=False)
1865
+
1866
+ async def _ensure_user_account(self) -> None:
1867
+ """Ensure a user account exists, creating one if necessary.
1868
+
1869
+ This reuses the same create_agent_user function that agents use,
1870
+ treating the user as a special "agent" named "user".
1871
+ """
1872
+ # The user account is just another "agent" from the perspective of account management
1873
+ user_account = await create_agent_user(
1874
+ MATRIX_HOMESERVER,
1875
+ "user", # Special agent name for the human user
1876
+ "Mindroom User", # Display name
1877
+ )
1878
+ logger.info(f"User account ready: {user_account.user_id}")
1879
+
1880
+ async def initialize(self) -> None:
1881
+ """Initialize all agent bots with self-management.
1882
+
1883
+ Each agent is now responsible for ensuring its own user account and rooms.
1884
+ """
1885
+ logger.info("Initializing multi-agent system...")
1886
+
1887
+ # Ensure user account exists first
1888
+ await self._ensure_user_account()
1889
+
1890
+ config = Config.from_yaml()
1891
+ self.config = config
1892
+
1893
+ # Create bots for all configured entities
1894
+ # Make Router the first so that it can manage room invitations
1895
+ all_entities = [ROUTER_AGENT_NAME, *list(config.agents.keys()), *list(config.teams.keys())]
1896
+
1897
+ for entity_name in all_entities:
1898
+ # Create a temporary agent user object (will be updated by ensure_user_account)
1899
+ if entity_name == ROUTER_AGENT_NAME:
1900
+ temp_user = AgentMatrixUser(
1901
+ agent_name=ROUTER_AGENT_NAME,
1902
+ user_id="", # Will be set by ensure_user_account
1903
+ display_name="RouterAgent",
1904
+ password="", # Will be set by ensure_user_account
1905
+ )
1906
+ elif entity_name in config.agents:
1907
+ temp_user = AgentMatrixUser(
1908
+ agent_name=entity_name,
1909
+ user_id="",
1910
+ display_name=config.agents[entity_name].display_name,
1911
+ password="",
1912
+ )
1913
+ elif entity_name in config.teams:
1914
+ temp_user = AgentMatrixUser(
1915
+ agent_name=entity_name,
1916
+ user_id="",
1917
+ display_name=config.teams[entity_name].display_name,
1918
+ password="",
1919
+ )
1920
+ else:
1921
+ continue
1922
+
1923
+ bot = create_bot_for_entity(entity_name, temp_user, config, self.storage_path)
1924
+ if bot is None:
1925
+ logger.warning(f"Could not create bot for {entity_name}")
1926
+ continue
1927
+
1928
+ bot.orchestrator = self
1929
+ self.agent_bots[entity_name] = bot
1930
+
1931
+ logger.info("Initialized agent bots", count=len(self.agent_bots))
1932
+
1933
+ async def start(self) -> None:
1934
+ """Start all agent bots."""
1935
+ if not self.agent_bots:
1936
+ await self.initialize()
1937
+
1938
+ # Start each agent bot (this registers callbacks and logs in, but doesn't join rooms)
1939
+ start_tasks = [bot.try_start() for bot in self.agent_bots.values()]
1940
+ results = await asyncio.gather(*start_tasks)
1941
+
1942
+ # Check for failures
1943
+ failed_agents = [bot.agent_name for bot, success in zip(self.agent_bots.values(), results) if not success]
1944
+
1945
+ if len(failed_agents) == len(self.agent_bots):
1946
+ msg = "All agents failed to start - cannot proceed"
1947
+ raise RuntimeError(msg)
1948
+ if failed_agents:
1949
+ logger.warning(
1950
+ f"System starting in degraded mode. "
1951
+ f"Failed agents: {', '.join(failed_agents)} "
1952
+ f"({len(self.agent_bots) - len(failed_agents)}/{len(self.agent_bots)} operational)",
1953
+ )
1954
+ else:
1955
+ logger.info("All agent bots started successfully")
1956
+
1957
+ self.running = True
1958
+
1959
+ # Setup rooms and have all bots join them
1960
+ await self._setup_rooms_and_memberships(list(self.agent_bots.values()))
1961
+
1962
+ # Create sync tasks for each bot with automatic restart on failure
1963
+ for entity_name, bot in self.agent_bots.items():
1964
+ # Create a task for each bot's sync loop with restart wrapper
1965
+ sync_task = asyncio.create_task(_sync_forever_with_restart(bot))
1966
+ # Store the task reference for later cancellation
1967
+ self._sync_tasks[entity_name] = sync_task
1968
+
1969
+ # Run all sync tasks
1970
+ await asyncio.gather(*tuple(self._sync_tasks.values()))
1971
+
1972
+ async def update_config(self) -> bool: # noqa: C901, PLR0912
1973
+ """Update configuration with simplified self-managing agents.
1974
+
1975
+ Each agent handles its own user account creation and room management.
1976
+
1977
+ Returns:
1978
+ True if any agents were updated, False otherwise.
1979
+
1980
+ """
1981
+ new_config = Config.from_yaml()
1982
+
1983
+ if not self.config:
1984
+ self.config = new_config
1985
+ return False
1986
+
1987
+ # Identify what changed - we can keep using the existing helper functions
1988
+ entities_to_restart = await _identify_entities_to_restart(self.config, new_config, self.agent_bots)
1989
+
1990
+ # Also check for new entities that didn't exist before
1991
+ all_new_entities = set(new_config.agents.keys()) | set(new_config.teams.keys()) | {ROUTER_AGENT_NAME}
1992
+ existing_entities = set(self.agent_bots.keys())
1993
+ new_entities = all_new_entities - existing_entities
1994
+
1995
+ # Always update the orchestrator's config first
1996
+ self.config = new_config
1997
+
1998
+ # Always update config for ALL existing bots (even those being restarted will get new config when recreated)
1999
+ logger.info(
2000
+ f"Updating config. New authorization: {new_config.authorization.global_users}",
2001
+ )
2002
+ for entity_name, bot in self.agent_bots.items():
2003
+ if entity_name not in entities_to_restart:
2004
+ bot.config = new_config
2005
+ await bot._set_presence_with_model_info()
2006
+ logger.debug(f"Updated config for {entity_name}")
2007
+
2008
+ if not entities_to_restart and not new_entities:
2009
+ # No entities to restart or create, we're done
2010
+ return False
2011
+
2012
+ # Stop entities that need restarting
2013
+ if entities_to_restart:
2014
+ await _stop_entities(entities_to_restart, self.agent_bots, self._sync_tasks)
2015
+
2016
+ # Recreate entities that need restarting using self-management
2017
+ for entity_name in entities_to_restart:
2018
+ if entity_name in all_new_entities:
2019
+ # Create temporary user object (will be updated by ensure_user_account)
2020
+ temp_user = _create_temp_user(entity_name, new_config)
2021
+ bot = create_bot_for_entity(entity_name, temp_user, new_config, self.storage_path) # type: ignore[assignment]
2022
+ if bot:
2023
+ bot.orchestrator = self
2024
+ self.agent_bots[entity_name] = bot
2025
+ # Agent handles its own setup (but doesn't join rooms yet)
2026
+ if await bot.try_start():
2027
+ # Start sync loop with automatic restart
2028
+ sync_task = asyncio.create_task(_sync_forever_with_restart(bot))
2029
+ self._sync_tasks[entity_name] = sync_task
2030
+ else:
2031
+ # Remove the failed bot from our registry
2032
+ del self.agent_bots[entity_name]
2033
+ # Entity was removed from config
2034
+ elif entity_name in self.agent_bots:
2035
+ del self.agent_bots[entity_name]
2036
+
2037
+ # Create new entities
2038
+ for entity_name in new_entities:
2039
+ temp_user = _create_temp_user(entity_name, new_config)
2040
+ bot = create_bot_for_entity(entity_name, temp_user, new_config, self.storage_path) # type: ignore[assignment]
2041
+ if bot:
2042
+ bot.orchestrator = self
2043
+ self.agent_bots[entity_name] = bot
2044
+ if await bot.try_start():
2045
+ sync_task = asyncio.create_task(_sync_forever_with_restart(bot))
2046
+ self._sync_tasks[entity_name] = sync_task
2047
+ else:
2048
+ # Remove the failed bot from our registry
2049
+ del self.agent_bots[entity_name]
2050
+
2051
+ # Handle removed entities (cleanup)
2052
+ removed_entities = existing_entities - all_new_entities
2053
+ for entity_name in removed_entities:
2054
+ # Cancel sync task first
2055
+ await _cancel_sync_task(entity_name, self._sync_tasks)
2056
+
2057
+ if entity_name in self.agent_bots:
2058
+ bot = self.agent_bots[entity_name]
2059
+ await bot.cleanup() # Agent handles its own cleanup
2060
+ del self.agent_bots[entity_name]
2061
+
2062
+ # Setup rooms and have new/restarted bots join them
2063
+ bots_to_setup = [
2064
+ self.agent_bots[entity_name]
2065
+ for entity_name in entities_to_restart | new_entities
2066
+ if entity_name in self.agent_bots
2067
+ ]
2068
+
2069
+ if bots_to_setup:
2070
+ await self._setup_rooms_and_memberships(bots_to_setup)
2071
+
2072
+ logger.info(f"Configuration update complete: {len(entities_to_restart) + len(new_entities)} bots affected")
2073
+ return True
2074
+
2075
+ async def stop(self) -> None:
2076
+ """Stop all agent bots."""
2077
+ self.running = False
2078
+
2079
+ # First cancel all sync tasks
2080
+ for entity_name in list(self._sync_tasks.keys()):
2081
+ await _cancel_sync_task(entity_name, self._sync_tasks)
2082
+
2083
+ # Signal all bots to stop their sync loops
2084
+ for bot in self.agent_bots.values():
2085
+ bot.running = False
2086
+
2087
+ # Now stop all bots
2088
+ stop_tasks = [bot.stop() for bot in self.agent_bots.values()]
2089
+ await asyncio.gather(*stop_tasks)
2090
+ logger.info("All agent bots stopped")
2091
+
2092
+ async def _setup_rooms_and_memberships(self, bots: list[AgentBot | TeamBot]) -> None:
2093
+ """Setup rooms and ensure all bots have correct memberships.
2094
+
2095
+ This shared method handles the common room setup flow for both
2096
+ initial startup and configuration updates.
2097
+
2098
+ Args:
2099
+ bots: Collection of bots to setup room memberships for
2100
+
2101
+ """
2102
+ # Ensure all configured rooms exist (router creates them if needed)
2103
+ await self._ensure_rooms_exist()
2104
+
2105
+ # After rooms exist, update each bot's room list to use room IDs instead of aliases
2106
+ assert self.config is not None
2107
+ for bot in bots:
2108
+ # Get the room aliases for this entity from config and resolve to IDs
2109
+ room_aliases = get_rooms_for_entity(bot.agent_name, self.config)
2110
+ bot.rooms = resolve_room_aliases(room_aliases)
2111
+
2112
+ # After rooms exist, ensure room invitations are up to date
2113
+ await self._ensure_room_invitations()
2114
+
2115
+ # Ensure user joins all rooms after being invited
2116
+ # Get all room IDs (not just newly created ones)
2117
+ all_rooms = load_rooms()
2118
+ all_room_ids = {room_key: room.room_id for room_key, room in all_rooms.items()}
2119
+ if all_room_ids:
2120
+ await ensure_user_in_rooms(MATRIX_HOMESERVER, all_room_ids)
2121
+
2122
+ # Now have bots join their configured rooms
2123
+ join_tasks = [bot.ensure_rooms() for bot in bots]
2124
+ await asyncio.gather(*join_tasks)
2125
+ logger.info("All agents have joined their configured rooms")
2126
+
2127
+ async def _ensure_rooms_exist(self) -> None:
2128
+ """Ensure all configured rooms exist, creating them if necessary.
2129
+
2130
+ This uses the router bot's client to create rooms since it has the necessary permissions.
2131
+ """
2132
+ if ROUTER_AGENT_NAME not in self.agent_bots:
2133
+ logger.warning("Router not available, cannot ensure rooms exist")
2134
+ return
2135
+
2136
+ router_bot = self.agent_bots[ROUTER_AGENT_NAME]
2137
+ if router_bot.client is None:
2138
+ logger.warning("Router client not available, cannot ensure rooms exist")
2139
+ return
2140
+
2141
+ # Directly create rooms using the router's client
2142
+ assert self.config is not None
2143
+ room_ids = await ensure_all_rooms_exist(router_bot.client, self.config)
2144
+ logger.info(f"Ensured existence of {len(room_ids)} rooms")
2145
+
2146
+ async def _ensure_room_invitations(self) -> None: # noqa: C901, PLR0912
2147
+ """Ensure all agents and the user are invited to their configured rooms.
2148
+
2149
+ This uses the router bot's client to manage room invitations,
2150
+ as the router has admin privileges in all rooms.
2151
+ """
2152
+ if ROUTER_AGENT_NAME not in self.agent_bots:
2153
+ logger.warning("Router not available, cannot ensure room invitations")
2154
+ return
2155
+
2156
+ router_bot = self.agent_bots[ROUTER_AGENT_NAME]
2157
+ if router_bot.client is None:
2158
+ logger.warning("Router client not available, cannot ensure room invitations")
2159
+ return
2160
+
2161
+ # Get the current configuration
2162
+ config = self.config
2163
+ if not config:
2164
+ logger.warning("No configuration available, cannot ensure room invitations")
2165
+ return
2166
+
2167
+ # Get all rooms the router is in
2168
+ joined_rooms = await get_joined_rooms(router_bot.client)
2169
+ if not joined_rooms:
2170
+ return
2171
+
2172
+ server_name = extract_server_name_from_homeserver(MATRIX_HOMESERVER)
2173
+
2174
+ # First, invite the user account to all rooms
2175
+ state = MatrixState.load()
2176
+ user_account = state.get_account("agent_user") # User is stored as "agent_user"
2177
+ if user_account:
2178
+ user_id = MatrixID.from_username(user_account.username, server_name).full_id
2179
+ for room_id in joined_rooms:
2180
+ room_members = await get_room_members(router_bot.client, room_id)
2181
+ if user_id not in room_members:
2182
+ success = await invite_to_room(router_bot.client, room_id, user_id)
2183
+ if success:
2184
+ logger.info(f"Invited user {user_id} to room {room_id}")
2185
+ else:
2186
+ logger.warning(f"Failed to invite user {user_id} to room {room_id}")
2187
+
2188
+ for room_id in joined_rooms:
2189
+ # Get who should be in this room based on configuration
2190
+ configured_bots = config.get_configured_bots_for_room(room_id)
2191
+
2192
+ if not configured_bots:
2193
+ continue
2194
+
2195
+ # Get current members of the room
2196
+ current_members = await get_room_members(router_bot.client, room_id)
2197
+
2198
+ # Invite missing bots
2199
+ for bot_username in configured_bots:
2200
+ bot_user_id = MatrixID.from_username(bot_username, server_name).full_id
2201
+
2202
+ if bot_user_id not in current_members:
2203
+ # Bot should be in room but isn't - invite them
2204
+ success = await invite_to_room(router_bot.client, room_id, bot_user_id)
2205
+ if success:
2206
+ logger.info(f"Invited {bot_username} to room {room_id}")
2207
+ else:
2208
+ logger.warning(f"Failed to invite {bot_username} to room {room_id}")
2209
+
2210
+ logger.info("Ensured room invitations for all configured agents")
2211
+
2212
+
2213
+ async def _identify_entities_to_restart(
2214
+ config: Config | None,
2215
+ new_config: Config,
2216
+ agent_bots: dict[str, Any],
2217
+ ) -> set[str]:
2218
+ """Identify entities that need restarting due to config changes."""
2219
+ agents_to_restart = _get_changed_agents(config, new_config, agent_bots)
2220
+ teams_to_restart = _get_changed_teams(config, new_config, agent_bots)
2221
+
2222
+ entities_to_restart = agents_to_restart | teams_to_restart
2223
+
2224
+ if _router_needs_restart(config, new_config):
2225
+ entities_to_restart.add(ROUTER_AGENT_NAME)
2226
+
2227
+ return entities_to_restart
2228
+
2229
+
2230
+ def _get_changed_agents(config: Config | None, new_config: Config, agent_bots: dict[str, Any]) -> set[str]:
2231
+ if not config:
2232
+ return set()
2233
+
2234
+ changed = set()
2235
+ all_agents = set(config.agents.keys()) | set(new_config.agents.keys())
2236
+
2237
+ for agent_name in all_agents:
2238
+ old_agent = config.agents.get(agent_name)
2239
+ new_agent = new_config.agents.get(agent_name)
2240
+
2241
+ # Compare agents using model_dump with exclude_none=True to match how configs are saved
2242
+ # This prevents false positives when None values are involved
2243
+ if old_agent and new_agent:
2244
+ # Both exist - compare their non-None values (matching save_to_yaml behavior)
2245
+ old_dict = old_agent.model_dump(exclude_none=True)
2246
+ new_dict = new_agent.model_dump(exclude_none=True)
2247
+ agents_differ = old_dict != new_dict
2248
+ else:
2249
+ # One is None - they definitely differ
2250
+ agents_differ = old_agent != new_agent
2251
+
2252
+ # Only restart if this specific agent's configuration has changed
2253
+ # (not just global config changes like authorization)
2254
+ if agents_differ and (agent_name in agent_bots or new_agent is not None):
2255
+ if old_agent and new_agent:
2256
+ logger.debug(f"Agent {agent_name} configuration changed, will restart")
2257
+ elif new_agent:
2258
+ logger.info(f"Agent {agent_name} is new, will start")
2259
+ else:
2260
+ logger.info(f"Agent {agent_name} was removed, will stop")
2261
+ changed.add(agent_name)
2262
+
2263
+ return changed
2264
+
2265
+
2266
+ def _get_changed_teams(config: Config | None, new_config: Config, agent_bots: dict[str, Any]) -> set[str]:
2267
+ if not config:
2268
+ return set()
2269
+
2270
+ changed = set()
2271
+ all_teams = set(config.teams.keys()) | set(new_config.teams.keys())
2272
+
2273
+ for team_name in all_teams:
2274
+ old_team = config.teams.get(team_name)
2275
+ new_team = new_config.teams.get(team_name)
2276
+
2277
+ # Compare teams using model_dump with exclude_none=True to match how configs are saved
2278
+ if old_team and new_team:
2279
+ old_dict = old_team.model_dump(exclude_none=True)
2280
+ new_dict = new_team.model_dump(exclude_none=True)
2281
+ teams_differ = old_dict != new_dict
2282
+ else:
2283
+ teams_differ = old_team != new_team
2284
+
2285
+ if teams_differ and (team_name in agent_bots or new_team is not None):
2286
+ changed.add(team_name)
2287
+
2288
+ return changed
2289
+
2290
+
2291
+ def _router_needs_restart(config: Config | None, new_config: Config) -> bool:
2292
+ """Check if router needs restart due to room changes."""
2293
+ if not config:
2294
+ return False
2295
+
2296
+ old_rooms = config.get_all_configured_rooms()
2297
+ new_rooms = new_config.get_all_configured_rooms()
2298
+ return old_rooms != new_rooms
2299
+
2300
+
2301
+ def _create_temp_user(entity_name: str, config: Config) -> AgentMatrixUser:
2302
+ """Create a temporary user object that will be updated by ensure_user_account."""
2303
+ if entity_name == ROUTER_AGENT_NAME:
2304
+ display_name = "RouterAgent"
2305
+ elif entity_name in config.agents:
2306
+ display_name = config.agents[entity_name].display_name
2307
+ elif entity_name in config.teams:
2308
+ display_name = config.teams[entity_name].display_name
2309
+ else:
2310
+ display_name = entity_name
2311
+
2312
+ return AgentMatrixUser(
2313
+ agent_name=entity_name,
2314
+ user_id="", # Will be set by ensure_user_account
2315
+ display_name=display_name,
2316
+ password="", # Will be set by ensure_user_account
2317
+ )
2318
+
2319
+
2320
+ async def _cancel_sync_task(entity_name: str, sync_tasks: dict[str, asyncio.Task]) -> None:
2321
+ """Cancel and remove a sync task for an entity."""
2322
+ if entity_name in sync_tasks:
2323
+ task = sync_tasks[entity_name]
2324
+ task.cancel()
2325
+ with suppress(asyncio.CancelledError):
2326
+ await task
2327
+ del sync_tasks[entity_name]
2328
+
2329
+
2330
+ async def _stop_entities(
2331
+ entities_to_restart: set[str],
2332
+ agent_bots: dict[str, Any],
2333
+ sync_tasks: dict[str, asyncio.Task],
2334
+ ) -> None:
2335
+ # Cancel sync tasks to prevent duplicates
2336
+ for entity_name in entities_to_restart:
2337
+ await _cancel_sync_task(entity_name, sync_tasks)
2338
+
2339
+ stop_tasks = []
2340
+ for entity_name in entities_to_restart:
2341
+ if entity_name in agent_bots:
2342
+ bot = agent_bots[entity_name]
2343
+ stop_tasks.append(bot.stop())
2344
+
2345
+ if stop_tasks:
2346
+ await asyncio.gather(*stop_tasks)
2347
+
2348
+ for entity_name in entities_to_restart:
2349
+ agent_bots.pop(entity_name, None)
2350
+
2351
+
2352
+ async def _sync_forever_with_restart(bot: AgentBot | TeamBot, max_retries: int = -1) -> None:
2353
+ """Run sync_forever with automatic restart on failure.
2354
+
2355
+ Args:
2356
+ bot: The bot to run sync for
2357
+ max_retries: Maximum number of retries (-1 for infinite)
2358
+
2359
+ """
2360
+ retry_count = 0
2361
+ while bot.running and (max_retries < 0 or retry_count < max_retries):
2362
+ try:
2363
+ logger.info(f"Starting sync loop for {bot.agent_name}")
2364
+ await bot.sync_forever()
2365
+ # If sync_forever returns normally, the bot was stopped intentionally
2366
+ break
2367
+ except asyncio.CancelledError:
2368
+ # Task was cancelled, exit gracefully
2369
+ logger.info(f"Sync task for {bot.agent_name} was cancelled")
2370
+ break
2371
+ except Exception:
2372
+ retry_count += 1
2373
+ logger.exception(f"Sync loop failed for {bot.agent_name} (retry {retry_count})")
2374
+
2375
+ if not bot.running:
2376
+ # Bot was stopped, don't restart
2377
+ break
2378
+
2379
+ if max_retries >= 0 and retry_count >= max_retries:
2380
+ logger.exception(f"Max retries ({max_retries}) reached for {bot.agent_name}, giving up")
2381
+ break
2382
+
2383
+ # Wait a bit before restarting to avoid rapid restarts
2384
+ wait_time = min(60, 5 * retry_count) # Exponential backoff, max 60 seconds
2385
+ logger.info(f"Restarting sync loop for {bot.agent_name} in {wait_time} seconds...")
2386
+ await asyncio.sleep(wait_time)
2387
+
2388
+
2389
+ async def _handle_config_change(orchestrator: MultiAgentOrchestrator, stop_watching: asyncio.Event) -> None:
2390
+ """Handle configuration file changes."""
2391
+ logger.info("Configuration file changed, checking for updates...")
2392
+ if orchestrator.running:
2393
+ updated = await orchestrator.update_config()
2394
+ if updated:
2395
+ logger.info("Configuration update applied to affected agents")
2396
+ else:
2397
+ logger.info("No agent changes detected in configuration update")
2398
+ if not orchestrator.running:
2399
+ stop_watching.set()
2400
+
2401
+
2402
+ async def _watch_config_task(config_path: Path, orchestrator: MultiAgentOrchestrator) -> None:
2403
+ """Watch config file for changes."""
2404
+ stop_watching = asyncio.Event()
2405
+
2406
+ async def on_config_change() -> None:
2407
+ await _handle_config_change(orchestrator, stop_watching)
2408
+
2409
+ await watch_file(config_path, on_config_change, stop_watching)
2410
+
2411
+
2412
+ async def main(log_level: str, storage_path: Path) -> None:
2413
+ """Main entry point for the multi-agent bot system.
2414
+
2415
+ Args:
2416
+ log_level: The logging level to use (DEBUG, INFO, WARNING, ERROR)
2417
+ storage_path: The base directory for storing agent data
2418
+
2419
+ """
2420
+ # Set up logging with the specified level
2421
+ setup_logging(level=log_level)
2422
+
2423
+ # Sync API keys from environment to CredentialsManager
2424
+ logger.info("Syncing API keys from environment to CredentialsManager...")
2425
+ sync_env_to_credentials()
2426
+
2427
+ # Create storage directory if it doesn't exist
2428
+ storage_path.mkdir(parents=True, exist_ok=True)
2429
+
2430
+ # Get config file path
2431
+ config_path = Path("config.yaml")
2432
+
2433
+ # Create and start orchestrator
2434
+ logger.info("Starting orchestrator...")
2435
+ orchestrator = MultiAgentOrchestrator(storage_path=storage_path)
2436
+
2437
+ try:
2438
+ # Create task to run the orchestrator
2439
+ orchestrator_task = asyncio.create_task(orchestrator.start())
2440
+
2441
+ # Create task to watch config file for changes
2442
+ watcher_task = asyncio.create_task(_watch_config_task(config_path, orchestrator))
2443
+
2444
+ # Wait for either orchestrator or watcher to complete
2445
+ done, pending = await asyncio.wait({orchestrator_task, watcher_task}, return_when=asyncio.FIRST_COMPLETED)
2446
+
2447
+ # Check if any completed task had an exception
2448
+ for task in done:
2449
+ try:
2450
+ task.result() # This will raise if the task had an exception
2451
+ except asyncio.CancelledError:
2452
+ logger.info("Task was cancelled")
2453
+ except Exception:
2454
+ logger.exception("Task failed with exception")
2455
+ # Don't re-raise - let cleanup happen gracefully
2456
+
2457
+ # Cancel any pending tasks
2458
+ for task in pending:
2459
+ task.cancel()
2460
+ with suppress(asyncio.CancelledError):
2461
+ await task
2462
+
2463
+ except KeyboardInterrupt:
2464
+ logger.info("Multi-agent bot system stopped by user")
2465
+ except Exception:
2466
+ logger.exception("Error in orchestrator")
2467
+ finally:
2468
+ # Final cleanup
2469
+ if orchestrator is not None:
2470
+ await orchestrator.stop()