mindroom 0.0.0__py3-none-any.whl → 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mindroom/__init__.py +3 -0
- mindroom/agent_prompts.py +963 -0
- mindroom/agents.py +248 -0
- mindroom/ai.py +421 -0
- mindroom/api/__init__.py +1 -0
- mindroom/api/credentials.py +137 -0
- mindroom/api/google_integration.py +355 -0
- mindroom/api/google_tools_helper.py +40 -0
- mindroom/api/homeassistant_integration.py +421 -0
- mindroom/api/integrations.py +189 -0
- mindroom/api/main.py +506 -0
- mindroom/api/matrix_operations.py +219 -0
- mindroom/api/tools.py +94 -0
- mindroom/background_tasks.py +87 -0
- mindroom/bot.py +2470 -0
- mindroom/cli.py +86 -0
- mindroom/commands.py +377 -0
- mindroom/config.py +343 -0
- mindroom/config_commands.py +324 -0
- mindroom/config_confirmation.py +411 -0
- mindroom/constants.py +52 -0
- mindroom/credentials.py +146 -0
- mindroom/credentials_sync.py +134 -0
- mindroom/custom_tools/__init__.py +8 -0
- mindroom/custom_tools/config_manager.py +765 -0
- mindroom/custom_tools/gmail.py +92 -0
- mindroom/custom_tools/google_calendar.py +92 -0
- mindroom/custom_tools/google_sheets.py +92 -0
- mindroom/custom_tools/homeassistant.py +341 -0
- mindroom/error_handling.py +35 -0
- mindroom/file_watcher.py +49 -0
- mindroom/interactive.py +313 -0
- mindroom/logging_config.py +207 -0
- mindroom/matrix/__init__.py +1 -0
- mindroom/matrix/client.py +782 -0
- mindroom/matrix/event_info.py +173 -0
- mindroom/matrix/identity.py +149 -0
- mindroom/matrix/large_messages.py +267 -0
- mindroom/matrix/mentions.py +141 -0
- mindroom/matrix/message_builder.py +94 -0
- mindroom/matrix/message_content.py +209 -0
- mindroom/matrix/presence.py +178 -0
- mindroom/matrix/rooms.py +311 -0
- mindroom/matrix/state.py +77 -0
- mindroom/matrix/typing.py +91 -0
- mindroom/matrix/users.py +217 -0
- mindroom/memory/__init__.py +21 -0
- mindroom/memory/config.py +137 -0
- mindroom/memory/functions.py +396 -0
- mindroom/py.typed +0 -0
- mindroom/response_tracker.py +128 -0
- mindroom/room_cleanup.py +139 -0
- mindroom/routing.py +107 -0
- mindroom/scheduling.py +758 -0
- mindroom/stop.py +207 -0
- mindroom/streaming.py +203 -0
- mindroom/teams.py +749 -0
- mindroom/thread_utils.py +318 -0
- mindroom/tools/__init__.py +520 -0
- mindroom/tools/agentql.py +64 -0
- mindroom/tools/airflow.py +57 -0
- mindroom/tools/apify.py +49 -0
- mindroom/tools/arxiv.py +64 -0
- mindroom/tools/aws_lambda.py +41 -0
- mindroom/tools/aws_ses.py +57 -0
- mindroom/tools/baidusearch.py +87 -0
- mindroom/tools/brightdata.py +116 -0
- mindroom/tools/browserbase.py +62 -0
- mindroom/tools/cal_com.py +98 -0
- mindroom/tools/calculator.py +112 -0
- mindroom/tools/cartesia.py +84 -0
- mindroom/tools/composio.py +166 -0
- mindroom/tools/config_manager.py +44 -0
- mindroom/tools/confluence.py +73 -0
- mindroom/tools/crawl4ai.py +101 -0
- mindroom/tools/csv.py +104 -0
- mindroom/tools/custom_api.py +106 -0
- mindroom/tools/dalle.py +85 -0
- mindroom/tools/daytona.py +180 -0
- mindroom/tools/discord.py +81 -0
- mindroom/tools/docker.py +73 -0
- mindroom/tools/duckdb.py +124 -0
- mindroom/tools/duckduckgo.py +99 -0
- mindroom/tools/e2b.py +121 -0
- mindroom/tools/eleven_labs.py +77 -0
- mindroom/tools/email.py +74 -0
- mindroom/tools/exa.py +246 -0
- mindroom/tools/fal.py +50 -0
- mindroom/tools/file.py +80 -0
- mindroom/tools/financial_datasets_api.py +112 -0
- mindroom/tools/firecrawl.py +124 -0
- mindroom/tools/gemini.py +85 -0
- mindroom/tools/giphy.py +49 -0
- mindroom/tools/github.py +376 -0
- mindroom/tools/gmail.py +102 -0
- mindroom/tools/google_calendar.py +55 -0
- mindroom/tools/google_maps.py +112 -0
- mindroom/tools/google_sheets.py +86 -0
- mindroom/tools/googlesearch.py +83 -0
- mindroom/tools/groq.py +77 -0
- mindroom/tools/hackernews.py +54 -0
- mindroom/tools/jina.py +108 -0
- mindroom/tools/jira.py +70 -0
- mindroom/tools/linear.py +103 -0
- mindroom/tools/linkup.py +65 -0
- mindroom/tools/lumalabs.py +71 -0
- mindroom/tools/mem0.py +82 -0
- mindroom/tools/modelslabs.py +85 -0
- mindroom/tools/moviepy_video_tools.py +62 -0
- mindroom/tools/newspaper4k.py +63 -0
- mindroom/tools/openai.py +143 -0
- mindroom/tools/openweather.py +89 -0
- mindroom/tools/oxylabs.py +54 -0
- mindroom/tools/pandas.py +35 -0
- mindroom/tools/pubmed.py +64 -0
- mindroom/tools/python.py +120 -0
- mindroom/tools/reddit.py +155 -0
- mindroom/tools/replicate.py +56 -0
- mindroom/tools/resend.py +55 -0
- mindroom/tools/scrapegraph.py +87 -0
- mindroom/tools/searxng.py +120 -0
- mindroom/tools/serpapi.py +55 -0
- mindroom/tools/serper.py +81 -0
- mindroom/tools/shell.py +46 -0
- mindroom/tools/slack.py +80 -0
- mindroom/tools/sleep.py +38 -0
- mindroom/tools/spider.py +62 -0
- mindroom/tools/sql.py +138 -0
- mindroom/tools/tavily.py +104 -0
- mindroom/tools/telegram.py +54 -0
- mindroom/tools/todoist.py +103 -0
- mindroom/tools/trello.py +121 -0
- mindroom/tools/twilio.py +97 -0
- mindroom/tools/web_browser_tools.py +37 -0
- mindroom/tools/webex.py +63 -0
- mindroom/tools/website.py +45 -0
- mindroom/tools/whatsapp.py +81 -0
- mindroom/tools/wikipedia.py +45 -0
- mindroom/tools/x.py +97 -0
- mindroom/tools/yfinance.py +121 -0
- mindroom/tools/youtube.py +81 -0
- mindroom/tools/zendesk.py +62 -0
- mindroom/tools/zep.py +107 -0
- mindroom/tools/zoom.py +62 -0
- mindroom/tools_metadata.json +7643 -0
- mindroom/tools_metadata.py +220 -0
- mindroom/topic_generator.py +153 -0
- mindroom/voice_handler.py +266 -0
- mindroom-0.1.1.dist-info/METADATA +425 -0
- mindroom-0.1.1.dist-info/RECORD +152 -0
- {mindroom-0.0.0.dist-info → mindroom-0.1.1.dist-info}/WHEEL +1 -2
- mindroom-0.1.1.dist-info/entry_points.txt +2 -0
- mindroom-0.0.0.dist-info/METADATA +0 -24
- mindroom-0.0.0.dist-info/RECORD +0 -4
- 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()
|