mindroom 0.0.0__py3-none-any.whl → 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. mindroom/__init__.py +3 -0
  2. mindroom/agent_prompts.py +963 -0
  3. mindroom/agents.py +248 -0
  4. mindroom/ai.py +421 -0
  5. mindroom/api/__init__.py +1 -0
  6. mindroom/api/credentials.py +137 -0
  7. mindroom/api/google_integration.py +355 -0
  8. mindroom/api/google_tools_helper.py +40 -0
  9. mindroom/api/homeassistant_integration.py +421 -0
  10. mindroom/api/integrations.py +189 -0
  11. mindroom/api/main.py +506 -0
  12. mindroom/api/matrix_operations.py +219 -0
  13. mindroom/api/tools.py +94 -0
  14. mindroom/background_tasks.py +87 -0
  15. mindroom/bot.py +2470 -0
  16. mindroom/cli.py +86 -0
  17. mindroom/commands.py +377 -0
  18. mindroom/config.py +343 -0
  19. mindroom/config_commands.py +324 -0
  20. mindroom/config_confirmation.py +411 -0
  21. mindroom/constants.py +52 -0
  22. mindroom/credentials.py +146 -0
  23. mindroom/credentials_sync.py +134 -0
  24. mindroom/custom_tools/__init__.py +8 -0
  25. mindroom/custom_tools/config_manager.py +765 -0
  26. mindroom/custom_tools/gmail.py +92 -0
  27. mindroom/custom_tools/google_calendar.py +92 -0
  28. mindroom/custom_tools/google_sheets.py +92 -0
  29. mindroom/custom_tools/homeassistant.py +341 -0
  30. mindroom/error_handling.py +35 -0
  31. mindroom/file_watcher.py +49 -0
  32. mindroom/interactive.py +313 -0
  33. mindroom/logging_config.py +207 -0
  34. mindroom/matrix/__init__.py +1 -0
  35. mindroom/matrix/client.py +782 -0
  36. mindroom/matrix/event_info.py +173 -0
  37. mindroom/matrix/identity.py +149 -0
  38. mindroom/matrix/large_messages.py +267 -0
  39. mindroom/matrix/mentions.py +141 -0
  40. mindroom/matrix/message_builder.py +94 -0
  41. mindroom/matrix/message_content.py +209 -0
  42. mindroom/matrix/presence.py +178 -0
  43. mindroom/matrix/rooms.py +311 -0
  44. mindroom/matrix/state.py +77 -0
  45. mindroom/matrix/typing.py +91 -0
  46. mindroom/matrix/users.py +217 -0
  47. mindroom/memory/__init__.py +21 -0
  48. mindroom/memory/config.py +137 -0
  49. mindroom/memory/functions.py +396 -0
  50. mindroom/py.typed +0 -0
  51. mindroom/response_tracker.py +128 -0
  52. mindroom/room_cleanup.py +139 -0
  53. mindroom/routing.py +107 -0
  54. mindroom/scheduling.py +758 -0
  55. mindroom/stop.py +207 -0
  56. mindroom/streaming.py +203 -0
  57. mindroom/teams.py +749 -0
  58. mindroom/thread_utils.py +318 -0
  59. mindroom/tools/__init__.py +520 -0
  60. mindroom/tools/agentql.py +64 -0
  61. mindroom/tools/airflow.py +57 -0
  62. mindroom/tools/apify.py +49 -0
  63. mindroom/tools/arxiv.py +64 -0
  64. mindroom/tools/aws_lambda.py +41 -0
  65. mindroom/tools/aws_ses.py +57 -0
  66. mindroom/tools/baidusearch.py +87 -0
  67. mindroom/tools/brightdata.py +116 -0
  68. mindroom/tools/browserbase.py +62 -0
  69. mindroom/tools/cal_com.py +98 -0
  70. mindroom/tools/calculator.py +112 -0
  71. mindroom/tools/cartesia.py +84 -0
  72. mindroom/tools/composio.py +166 -0
  73. mindroom/tools/config_manager.py +44 -0
  74. mindroom/tools/confluence.py +73 -0
  75. mindroom/tools/crawl4ai.py +101 -0
  76. mindroom/tools/csv.py +104 -0
  77. mindroom/tools/custom_api.py +106 -0
  78. mindroom/tools/dalle.py +85 -0
  79. mindroom/tools/daytona.py +180 -0
  80. mindroom/tools/discord.py +81 -0
  81. mindroom/tools/docker.py +73 -0
  82. mindroom/tools/duckdb.py +124 -0
  83. mindroom/tools/duckduckgo.py +99 -0
  84. mindroom/tools/e2b.py +121 -0
  85. mindroom/tools/eleven_labs.py +77 -0
  86. mindroom/tools/email.py +74 -0
  87. mindroom/tools/exa.py +246 -0
  88. mindroom/tools/fal.py +50 -0
  89. mindroom/tools/file.py +80 -0
  90. mindroom/tools/financial_datasets_api.py +112 -0
  91. mindroom/tools/firecrawl.py +124 -0
  92. mindroom/tools/gemini.py +85 -0
  93. mindroom/tools/giphy.py +49 -0
  94. mindroom/tools/github.py +376 -0
  95. mindroom/tools/gmail.py +102 -0
  96. mindroom/tools/google_calendar.py +55 -0
  97. mindroom/tools/google_maps.py +112 -0
  98. mindroom/tools/google_sheets.py +86 -0
  99. mindroom/tools/googlesearch.py +83 -0
  100. mindroom/tools/groq.py +77 -0
  101. mindroom/tools/hackernews.py +54 -0
  102. mindroom/tools/jina.py +108 -0
  103. mindroom/tools/jira.py +70 -0
  104. mindroom/tools/linear.py +103 -0
  105. mindroom/tools/linkup.py +65 -0
  106. mindroom/tools/lumalabs.py +71 -0
  107. mindroom/tools/mem0.py +82 -0
  108. mindroom/tools/modelslabs.py +85 -0
  109. mindroom/tools/moviepy_video_tools.py +62 -0
  110. mindroom/tools/newspaper4k.py +63 -0
  111. mindroom/tools/openai.py +143 -0
  112. mindroom/tools/openweather.py +89 -0
  113. mindroom/tools/oxylabs.py +54 -0
  114. mindroom/tools/pandas.py +35 -0
  115. mindroom/tools/pubmed.py +64 -0
  116. mindroom/tools/python.py +120 -0
  117. mindroom/tools/reddit.py +155 -0
  118. mindroom/tools/replicate.py +56 -0
  119. mindroom/tools/resend.py +55 -0
  120. mindroom/tools/scrapegraph.py +87 -0
  121. mindroom/tools/searxng.py +120 -0
  122. mindroom/tools/serpapi.py +55 -0
  123. mindroom/tools/serper.py +81 -0
  124. mindroom/tools/shell.py +46 -0
  125. mindroom/tools/slack.py +80 -0
  126. mindroom/tools/sleep.py +38 -0
  127. mindroom/tools/spider.py +62 -0
  128. mindroom/tools/sql.py +138 -0
  129. mindroom/tools/tavily.py +104 -0
  130. mindroom/tools/telegram.py +54 -0
  131. mindroom/tools/todoist.py +103 -0
  132. mindroom/tools/trello.py +121 -0
  133. mindroom/tools/twilio.py +97 -0
  134. mindroom/tools/web_browser_tools.py +37 -0
  135. mindroom/tools/webex.py +63 -0
  136. mindroom/tools/website.py +45 -0
  137. mindroom/tools/whatsapp.py +81 -0
  138. mindroom/tools/wikipedia.py +45 -0
  139. mindroom/tools/x.py +97 -0
  140. mindroom/tools/yfinance.py +121 -0
  141. mindroom/tools/youtube.py +81 -0
  142. mindroom/tools/zendesk.py +62 -0
  143. mindroom/tools/zep.py +107 -0
  144. mindroom/tools/zoom.py +62 -0
  145. mindroom/tools_metadata.json +7643 -0
  146. mindroom/tools_metadata.py +220 -0
  147. mindroom/topic_generator.py +153 -0
  148. mindroom/voice_handler.py +266 -0
  149. mindroom-0.1.0.dist-info/METADATA +425 -0
  150. mindroom-0.1.0.dist-info/RECORD +152 -0
  151. {mindroom-0.0.0.dist-info → mindroom-0.1.0.dist-info}/WHEEL +1 -2
  152. mindroom-0.1.0.dist-info/entry_points.txt +2 -0
  153. mindroom-0.0.0.dist-info/METADATA +0 -24
  154. mindroom-0.0.0.dist-info/RECORD +0 -4
  155. mindroom-0.0.0.dist-info/top_level.txt +0 -1
mindroom/teams.py ADDED
@@ -0,0 +1,749 @@
1
+ """Team-based collaboration for multiple agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from typing import TYPE_CHECKING, Any, Literal, NamedTuple
7
+
8
+ from agno.agent import Agent
9
+ from agno.models.message import Message
10
+ from agno.run.response import RunResponse
11
+ from agno.run.response import RunResponseContentEvent as AgentRunResponseContentEvent
12
+ from agno.run.response import ToolCallCompletedEvent as AgentToolCallCompletedEvent
13
+ from agno.run.response import ToolCallStartedEvent as AgentToolCallStartedEvent
14
+ from agno.run.team import RunResponseContentEvent as TeamRunResponseContentEvent
15
+ from agno.run.team import TeamRunResponse
16
+ from agno.run.team import ToolCallCompletedEvent as TeamToolCallCompletedEvent
17
+ from agno.run.team import ToolCallStartedEvent as TeamToolCallStartedEvent
18
+ from agno.team import Team
19
+ from pydantic import BaseModel, Field
20
+
21
+ from . import agent_prompts
22
+ from .ai import get_model_instance
23
+ from .constants import ROUTER_AGENT_NAME
24
+ from .error_handling import get_user_friendly_error_message
25
+ from .logging_config import get_logger
26
+ from .matrix.rooms import get_room_alias_from_id
27
+ from .thread_utils import get_available_agents_in_room
28
+
29
+ if TYPE_CHECKING:
30
+ from collections.abc import AsyncIterator
31
+
32
+ import nio
33
+
34
+ from .bot import MultiAgentOrchestrator
35
+ from .config import Config
36
+ from .matrix.identity import MatrixID
37
+
38
+
39
+ logger = get_logger(__name__)
40
+
41
+ # Message length limits for team context and logging
42
+ MAX_CONTEXT_MESSAGE_LENGTH = 200 # Maximum length for messages to include in thread context
43
+ MAX_LOG_MESSAGE_LENGTH = 500 # Maximum length for messages in team response logs
44
+
45
+
46
+ def _fmt_tool_started(event: AgentToolCallStartedEvent | TeamToolCallStartedEvent) -> str:
47
+ tool = getattr(event, "tool", None)
48
+ if not tool:
49
+ return ""
50
+ tool_name = getattr(tool, "tool_name", None) or "tool"
51
+ tool_args = getattr(tool, "tool_args", None) or {}
52
+ if tool_args:
53
+ args_str = ", ".join(f"{k}={v}" for k, v in tool_args.items())
54
+ return f"\n\n🔧 **Tool Call:** `{tool_name}({args_str})`\n"
55
+ return f"\n\n🔧 **Tool Call:** `{tool_name}()`\n"
56
+
57
+
58
+ def _fmt_tool_completed(event: AgentToolCallCompletedEvent | TeamToolCallCompletedEvent) -> str:
59
+ tool = getattr(event, "tool", None)
60
+ tool_name = getattr(tool, "tool_name", None) or "tool"
61
+ result = getattr(event, "content", None) or (getattr(tool, "result", None) if tool else None)
62
+ if result:
63
+ return f"✅ **`{tool_name}` result:**\n{result}\n\n"
64
+ return f"✅ **`{tool_name}`** completed\n\n"
65
+
66
+
67
+ class TeamMode(str, Enum):
68
+ """Team collaboration modes."""
69
+
70
+ COORDINATE = "coordinate" # Leader delegates and synthesizes (can be sequential OR parallel)
71
+ COLLABORATE = "collaborate" # All members work on same task in parallel
72
+
73
+
74
+ class TeamModeDecision(BaseModel):
75
+ """AI decision for team collaboration mode."""
76
+
77
+ mode: Literal["coordinate", "collaborate"] = Field(
78
+ description="coordinate for delegation and synthesis, collaborate for all working on same task",
79
+ )
80
+ reasoning: str = Field(description="Brief explanation of why this mode was chosen")
81
+
82
+
83
+ def format_team_header(agent_names: list[str]) -> str:
84
+ """Format the team response header.
85
+
86
+ Args:
87
+ agent_names: List of agent names in the team
88
+
89
+ Returns:
90
+ Formatted header string
91
+
92
+ """
93
+ return f"🤝 **Team Response** ({', '.join(agent_names)}):\n\n"
94
+
95
+
96
+ def format_member_contribution(agent_name: str, content: str, indent: int = 0) -> str:
97
+ """Format a single team member's contribution.
98
+
99
+ Args:
100
+ agent_name: Name of the agent
101
+ content: The agent's response content
102
+ indent: Indentation level
103
+
104
+ Returns:
105
+ Formatted contribution string
106
+
107
+ """
108
+ indent_str = " " * indent
109
+ return f"{indent_str}**{agent_name}**: {content}"
110
+
111
+
112
+ def format_team_consensus(consensus: str, indent: int = 0) -> list[str]:
113
+ """Format the team consensus section.
114
+
115
+ Args:
116
+ consensus: The consensus content
117
+ indent: Indentation level
118
+
119
+ Returns:
120
+ List of formatted lines for the consensus
121
+
122
+ """
123
+ indent_str = " " * indent
124
+ parts = []
125
+ if consensus:
126
+ parts.append(f"\n{indent_str}**Team Consensus**:")
127
+ parts.append(f"{indent_str}{consensus}")
128
+ return parts
129
+
130
+
131
+ def format_no_consensus_note(indent: int = 0) -> str:
132
+ """Format the note when there's no team consensus.
133
+
134
+ Args:
135
+ indent: Indentation level
136
+
137
+ Returns:
138
+ Formatted note string
139
+
140
+ """
141
+ indent_str = " " * indent
142
+ return f"\n{indent_str}*No team consensus - showing individual responses only*"
143
+
144
+
145
+ def format_team_response(response: TeamRunResponse | RunResponse) -> list[str]:
146
+ """Format a complete team response with member contributions.
147
+
148
+ Handles nested teams recursively with proper indentation.
149
+
150
+ Args:
151
+ response: The team or agent response to extract contributions from
152
+
153
+ Returns:
154
+ List of formatted contribution strings
155
+
156
+ """
157
+ return _format_contributions_recursive(response, indent=0, include_consensus=True)
158
+
159
+
160
+ def _format_contributions_recursive( # noqa: C901
161
+ response: TeamRunResponse | RunResponse,
162
+ indent: int,
163
+ include_consensus: bool,
164
+ ) -> list[str]:
165
+ """Internal recursive function for formatting contributions.
166
+
167
+ Args:
168
+ response: The response to extract from
169
+ indent: Current indentation level
170
+ include_consensus: Whether to include team consensus
171
+
172
+ Returns:
173
+ List of formatted contribution strings
174
+
175
+ """
176
+ parts = []
177
+ indent_str = " " * indent
178
+
179
+ if isinstance(response, TeamRunResponse):
180
+ if response.member_responses:
181
+ for member_resp in response.member_responses:
182
+ if isinstance(member_resp, TeamRunResponse):
183
+ team_name = member_resp.team_name or "Nested Team"
184
+ parts.append(f"{indent_str}**{team_name}** (Team):")
185
+ nested_parts = _format_contributions_recursive(
186
+ member_resp,
187
+ indent=indent + 1,
188
+ include_consensus=False, # No consensus for nested teams
189
+ )
190
+ parts.extend(nested_parts)
191
+ elif isinstance(member_resp, RunResponse):
192
+ agent_name = member_resp.agent_name or "Team Member"
193
+ content = _get_response_content(member_resp)
194
+ if content:
195
+ parts.append(format_member_contribution(agent_name, content, indent))
196
+
197
+ if include_consensus:
198
+ if response.content:
199
+ parts.extend(format_team_consensus(response.content, indent))
200
+ elif parts:
201
+ parts.append(format_no_consensus_note(indent))
202
+
203
+ elif isinstance(response, RunResponse):
204
+ agent_name = response.agent_name or "Agent"
205
+ content = _get_response_content(response)
206
+ if content:
207
+ parts.append(format_member_contribution(agent_name, content, indent))
208
+
209
+ return parts
210
+
211
+
212
+ def _get_response_content(response: TeamRunResponse | RunResponse) -> str:
213
+ """Get content from a response object.
214
+
215
+ Args:
216
+ response: The response to extract content from
217
+
218
+ Returns:
219
+ The extracted content as a string
220
+
221
+ """
222
+ if response.content:
223
+ return str(response.content)
224
+
225
+ # Note: This concatenates ALL assistant messages, which might include
226
+ # multiple turns in a conversation. Consider if you want just the
227
+ # last message or all of them.
228
+ if response.messages:
229
+ messages_list: list[Any] = response.messages
230
+ content_parts = [
231
+ str(msg.content)
232
+ for msg in messages_list
233
+ if isinstance(msg, Message) and msg.role == "assistant" and msg.content
234
+ ]
235
+
236
+ return "\n\n".join(content_parts) if content_parts else ""
237
+
238
+ return ""
239
+
240
+
241
+ class TeamFormationDecision(NamedTuple):
242
+ """Result of decide_team_formation."""
243
+
244
+ should_form_team: bool
245
+ agents: list[MatrixID]
246
+ mode: TeamMode
247
+
248
+
249
+ async def select_team_mode(
250
+ message: str,
251
+ agent_names: list[str],
252
+ config: Config,
253
+ ) -> TeamMode:
254
+ """Use AI to determine optimal team collaboration mode.
255
+
256
+ Args:
257
+ message: The user's message/task
258
+ agent_names: List of agents that will form the team
259
+ config: Application configuration for model access
260
+
261
+ Returns:
262
+ TeamMode.COORDINATE or TeamMode.COLLABORATE
263
+
264
+ """
265
+ prompt = f"""Determine the best team collaboration mode for this task.
266
+
267
+ Task: {message}
268
+ Agents: {", ".join(agent_names)}
269
+
270
+ Team Modes (from Agno documentation):
271
+ - "coordinate": Team leader delegates tasks to members and synthesizes their outputs.
272
+ The leader decides whether to send tasks sequentially or in parallel based on what's appropriate.
273
+ - "collaborate": All team members are given the SAME task and work on it simultaneously.
274
+ The leader synthesizes all their outputs into a cohesive response.
275
+
276
+ Decision Guidelines:
277
+ - Use "coordinate" when agents need to do DIFFERENT subtasks (whether sequential or parallel)
278
+ - Use "collaborate" when you want ALL agents working on the SAME problem for diverse perspectives
279
+
280
+ Examples:
281
+ - "Email me then call me" → coordinate (different tasks: email agent sends email, phone agent makes call)
282
+ - "Get weather and news" → coordinate (different tasks: weather agent gets weather, news agent gets news)
283
+ - "Research this topic and analyze the data" → coordinate (different subtasks for each agent)
284
+ - "What do you think about X?" → collaborate (all agents provide their perspective on the same question)
285
+ - "Brainstorm solutions" → collaborate (all agents work on the same brainstorming task)
286
+
287
+ Return the mode and a one-sentence reason why."""
288
+
289
+ model = get_model_instance(config, "default")
290
+ agent = Agent(
291
+ name="TeamModeDecider",
292
+ role="Determine team mode",
293
+ model=model,
294
+ response_model=TeamModeDecision,
295
+ )
296
+
297
+ try:
298
+ response = await agent.arun(prompt, session_id="team_mode_decision")
299
+ decision = response.content
300
+ if isinstance(decision, TeamModeDecision):
301
+ logger.info(f"Team mode: {decision.mode} - {decision.reasoning}")
302
+ return TeamMode.COORDINATE if decision.mode == "coordinate" else TeamMode.COLLABORATE
303
+ # Fallback if response is unexpected
304
+ logger.debug(f"Unexpected response type from AI: {type(decision).__name__}, defaulting to collaborate")
305
+ return TeamMode.COLLABORATE # noqa: TRY300
306
+ except Exception as e:
307
+ logger.debug(f"AI team mode decision failed (will use default): {e}")
308
+ return TeamMode.COLLABORATE
309
+
310
+
311
+ async def decide_team_formation(
312
+ agent: MatrixID,
313
+ tagged_agents: list[MatrixID],
314
+ agents_in_thread: list[MatrixID],
315
+ all_mentioned_in_thread: list[MatrixID],
316
+ room: nio.MatrixRoom,
317
+ message: str | None = None,
318
+ config: Config | None = None,
319
+ use_ai_decision: bool = True,
320
+ is_dm_room: bool = False,
321
+ is_thread: bool = False,
322
+ ) -> TeamFormationDecision:
323
+ """Determine if a team should form and with which mode.
324
+
325
+ Args:
326
+ agent: The agent calling this function
327
+ tagged_agents: Agents explicitly mentioned in the current message
328
+ agents_in_thread: Agents that have participated in the thread
329
+ all_mentioned_in_thread: All agents ever mentioned in the thread
330
+ room: The Matrix room object (for checking available agents)
331
+ message: The user's message (for AI decision context)
332
+ config: Application configuration (for AI model access)
333
+ use_ai_decision: Whether to use AI for mode selection
334
+ is_dm_room: Whether this is a DM room
335
+ is_thread: Whether the current message is in a thread
336
+
337
+ Returns:
338
+ TeamFormationDecision with team formation decision
339
+
340
+ """
341
+ team_agents: list[MatrixID] = []
342
+
343
+ # Case 1: Multiple agents explicitly tagged
344
+ if len(tagged_agents) > 1:
345
+ logger.info(f"Team formation needed for tagged agents: {tagged_agents}")
346
+ team_agents = tagged_agents
347
+
348
+ # Case 2: No agents tagged but multiple were mentioned before in thread
349
+ elif not tagged_agents and len(all_mentioned_in_thread) > 1:
350
+ logger.info(f"Team formation needed for previously mentioned agents: {all_mentioned_in_thread}")
351
+ team_agents = all_mentioned_in_thread
352
+
353
+ # Case 3: No agents tagged but multiple in thread
354
+ elif not tagged_agents and len(agents_in_thread) > 1:
355
+ logger.info(f"Team formation needed for thread agents: {agents_in_thread}")
356
+ team_agents = agents_in_thread
357
+
358
+ # Case 4: DM room with multiple agents and no mentions (main timeline only)
359
+ # We avoid forming a team inside an existing thread to preserve
360
+ # single-agent ownership unless the thread itself involves multiple agents
361
+ elif is_dm_room and not is_thread and not tagged_agents and room and config:
362
+ available_agents = get_available_agents_in_room(room, config)
363
+ if len(available_agents) > 1:
364
+ logger.info(f"Team formation needed for DM room with multiple agents: {available_agents}")
365
+ team_agents = available_agents
366
+
367
+ if not team_agents:
368
+ return TeamFormationDecision(
369
+ should_form_team=False,
370
+ agents=[],
371
+ mode=TeamMode.COLLABORATE,
372
+ )
373
+
374
+ is_first_agent = min(team_agents, key=lambda x: x.username) == agent
375
+ # Only do this AI call for the first agent to avoid duplication
376
+ if use_ai_decision and message and config and is_first_agent:
377
+ agent_names = [mid.agent_name(config) or mid.username for mid in team_agents]
378
+ mode = await select_team_mode(message, agent_names, config)
379
+ else:
380
+ # Fallback to hardcoded logic when AI decision is disabled or unavailable
381
+ # Use COORDINATE when agents are explicitly tagged (they likely have different roles)
382
+ # Use COLLABORATE when agents are from thread history (likely discussing same topic)
383
+ mode = TeamMode.COORDINATE if len(tagged_agents) > 1 else TeamMode.COLLABORATE
384
+ logger.info(f"Using hardcoded mode selection: {mode.value}")
385
+
386
+ return TeamFormationDecision(should_form_team=True, agents=team_agents, mode=mode)
387
+
388
+
389
+ def _build_prompt_with_context(
390
+ message: str,
391
+ thread_history: list[dict] | None = None,
392
+ ) -> str:
393
+ """Build a prompt with thread context if available.
394
+
395
+ Args:
396
+ message: The user's message
397
+ thread_history: Optional thread history for context
398
+
399
+ Returns:
400
+ Formatted prompt with context
401
+
402
+ """
403
+ if not thread_history:
404
+ return message
405
+
406
+ recent_messages = thread_history[-30:] # Last 30 messages for context
407
+ context_parts = []
408
+ for msg in recent_messages:
409
+ sender = msg.get("sender", "Unknown")
410
+ body = msg.get("content", {}).get("body", "")
411
+ if body and len(body) < MAX_CONTEXT_MESSAGE_LENGTH:
412
+ context_parts.append(f"{sender}: {body}")
413
+
414
+ if context_parts:
415
+ context = "\n".join(context_parts)
416
+ return f"Thread Context:\n{context}\n\nUser: {message}"
417
+
418
+ return message
419
+
420
+
421
+ def _get_agents_from_orchestrator(
422
+ agent_names: list[str],
423
+ orchestrator: MultiAgentOrchestrator,
424
+ ) -> list[Agent]:
425
+ """Get Agent instances from orchestrator for the given agent names.
426
+
427
+ Args:
428
+ agent_names: List of agent names to get
429
+ orchestrator: The orchestrator containing agent bots
430
+
431
+ Returns:
432
+ List of Agent instances (excluding router and missing agents)
433
+
434
+ """
435
+ agents: list[Agent] = []
436
+ for name in agent_names:
437
+ if name == ROUTER_AGENT_NAME:
438
+ continue
439
+
440
+ if name not in orchestrator.agent_bots:
441
+ logger.warning(f"Agent '{name}' not found in orchestrator - may not be in room")
442
+ continue
443
+
444
+ agent_bot = orchestrator.agent_bots[name]
445
+ if agent_bot.agent is not None:
446
+ agent = agent_bot.agent
447
+ # Remove interactive question prompts to prevent emoji conflicts in team responses
448
+ if isinstance(agent.instructions, list):
449
+ agent.instructions = [
450
+ instr for instr in agent.instructions if instr != agent_prompts.INTERACTIVE_QUESTION_PROMPT
451
+ ]
452
+ agents.append(agent)
453
+ else:
454
+ logger.warning(f"Agent bot '{name}' has no agent instance")
455
+
456
+ return agents
457
+
458
+
459
+ def _create_team_instance(
460
+ agents: list[Agent],
461
+ agent_names: list[str],
462
+ mode: TeamMode,
463
+ orchestrator: MultiAgentOrchestrator,
464
+ model_name: str | None = None,
465
+ ) -> Team:
466
+ """Create a configured Team instance.
467
+
468
+ Args:
469
+ agents: List of Agent instances for the team
470
+ agent_names: List of agent names (for team name)
471
+ mode: Team collaboration mode
472
+ orchestrator: The orchestrator containing configuration
473
+ model_name: Optional model name override
474
+
475
+ Returns:
476
+ Configured Team instance
477
+
478
+ """
479
+ assert orchestrator.config is not None
480
+ model = get_model_instance(orchestrator.config, model_name or "default")
481
+
482
+ return Team(
483
+ members=agents, # type: ignore[arg-type]
484
+ mode=mode.value,
485
+ name=f"Team-{'-'.join(agent_names)}",
486
+ model=model,
487
+ show_members_responses=True,
488
+ enable_agentic_context=True,
489
+ debug_mode=False,
490
+ # Agno will automatically list members with their names, roles, and tools
491
+ )
492
+
493
+
494
+ def select_model_for_team(team_name: str, room_id: str, config: Config) -> str:
495
+ """Get the appropriate model for a team in a specific room.
496
+
497
+ Priority:
498
+ 1. Room-specific model from room_models
499
+ 2. Team's configured model
500
+ 3. Global default model
501
+
502
+ Args:
503
+ team_name: Name of the team
504
+ room_id: Matrix room ID
505
+ config: Application configuration
506
+
507
+ Returns:
508
+ Model name to use
509
+
510
+ """
511
+ room_alias = get_room_alias_from_id(room_id)
512
+
513
+ if room_alias and room_alias in config.room_models:
514
+ model = config.room_models[room_alias]
515
+ logger.info(f"Using room-specific model for {team_name} in {room_alias}: {model}")
516
+ return model
517
+
518
+ if team_name in config.teams:
519
+ team_config = config.teams[team_name]
520
+ if team_config.model:
521
+ logger.info(f"Using team-specific model for {team_name}: {team_config.model}")
522
+ return team_config.model
523
+
524
+ logger.info(f"Using default model for {team_name}")
525
+ return "default"
526
+
527
+
528
+ NO_AGENTS_RESPONSE = "Sorry, no agents available for team collaboration."
529
+
530
+
531
+ async def team_response(
532
+ agent_names: list[str],
533
+ mode: TeamMode,
534
+ message: str,
535
+ orchestrator: MultiAgentOrchestrator,
536
+ thread_history: list[dict] | None = None,
537
+ model_name: str | None = None,
538
+ ) -> str:
539
+ """Create a team and execute response."""
540
+ agents = _get_agents_from_orchestrator(agent_names, orchestrator)
541
+
542
+ if not agents:
543
+ return NO_AGENTS_RESPONSE
544
+
545
+ prompt = _build_prompt_with_context(message, thread_history)
546
+ team = _create_team_instance(agents, agent_names, mode, orchestrator, model_name)
547
+ agent_list = ", ".join(str(a.name) for a in agents if a.name)
548
+
549
+ logger.info(f"Executing team response with {len(agents)} agents in {mode.value} mode")
550
+ logger.info(f"TEAM PROMPT: {prompt[:500]}")
551
+
552
+ try:
553
+ response = await team.arun(prompt)
554
+ except Exception as e:
555
+ logger.exception(f"Error in team response with agents {agent_list}")
556
+ # Return user-friendly error message
557
+ team_name = f"Team ({agent_list})"
558
+ return get_user_friendly_error_message(e, team_name)
559
+
560
+ if isinstance(response, TeamRunResponse):
561
+ if response.member_responses:
562
+ logger.debug(f"Team had {len(response.member_responses)} member responses")
563
+
564
+ logger.info(f"Team consensus content: {response.content[:200] if response.content else 'None'}")
565
+
566
+ parts = format_team_response(response)
567
+ team_response = "\n\n".join(parts) if parts else "No team response generated."
568
+ else:
569
+ logger.warning(f"Unexpected response type: {type(response)}", response=response)
570
+ team_response = str(response)
571
+
572
+ logger.info(f"TEAM RESPONSE ({agent_list}): {team_response[:MAX_LOG_MESSAGE_LENGTH]}")
573
+ if len(team_response) > MAX_LOG_MESSAGE_LENGTH:
574
+ logger.debug(f"TEAM RESPONSE (full): {team_response}")
575
+
576
+ # Don't use @ mentions as that would trigger the agents again
577
+ agent_names = [str(a.name) for a in agents if a.name]
578
+ team_header = format_team_header(agent_names)
579
+
580
+ return team_header + team_response
581
+
582
+
583
+ async def team_response_stream_raw(
584
+ agent_ids: list[MatrixID],
585
+ mode: TeamMode,
586
+ message: str,
587
+ orchestrator: MultiAgentOrchestrator,
588
+ thread_history: list[dict] | None = None,
589
+ model_name: str | None = None,
590
+ ) -> AsyncIterator[Any]:
591
+ """Yield raw team events (for structured live rendering). Falls back to a final response.
592
+
593
+ Returns an async iterator of Agno events when supported; otherwise yields a
594
+ single TeamRunResponse for non-streaming providers.
595
+ """
596
+ assert orchestrator.config is not None
597
+ agent_names = [mid.agent_name(orchestrator.config) or mid.username for mid in agent_ids]
598
+ agents = _get_agents_from_orchestrator(agent_names, orchestrator)
599
+
600
+ if not agents:
601
+
602
+ async def _empty() -> AsyncIterator[RunResponse]:
603
+ yield RunResponse(content=NO_AGENTS_RESPONSE)
604
+
605
+ return _empty()
606
+
607
+ prompt = _build_prompt_with_context(message, thread_history)
608
+ team = _create_team_instance(agents, agent_names, mode, orchestrator, model_name)
609
+
610
+ logger.info(f"Created team with {len(agents)} agents in {mode.value} mode")
611
+ for agent in agents:
612
+ logger.debug(f"Team member: {agent.name}")
613
+
614
+ try:
615
+ return await team.arun(prompt, stream=True)
616
+ except Exception as e:
617
+ logger.exception(f"Error in team streaming with agents {agent_names}")
618
+ team_name = f"Team ({', '.join(agent_names)})"
619
+ error_message = get_user_friendly_error_message(e, team_name)
620
+
621
+ async def _error() -> AsyncIterator[RunResponse]:
622
+ yield RunResponse(content=error_message)
623
+
624
+ return _error()
625
+
626
+
627
+ async def team_response_stream( # noqa: C901, PLR0912, PLR0915
628
+ agent_ids: list[MatrixID],
629
+ message: str,
630
+ orchestrator: MultiAgentOrchestrator,
631
+ mode: TeamMode = TeamMode.COORDINATE,
632
+ thread_history: list[dict] | None = None,
633
+ model_name: str | None = None,
634
+ ) -> AsyncIterator[str]:
635
+ """Aggregate team streaming into a non-stream-style document, live.
636
+
637
+ Renders a header and per-member sections, optionally adding a team
638
+ consensus if present. Rebuilds the entire document as new events
639
+ arrive so the final shape matches the non-stream style.
640
+ """
641
+ assert orchestrator.config is not None
642
+ agent_names: list[str] = []
643
+ display_names: list[str] = []
644
+
645
+ for mid in agent_ids:
646
+ agent_name = mid.agent_name(orchestrator.config)
647
+ assert agent_name is not None
648
+ agent_names.append(agent_name)
649
+
650
+ agent_config = orchestrator.config.agents[agent_name]
651
+ display_name = agent_config.display_name or agent_name
652
+ display_names.append(display_name)
653
+
654
+ # Buffers keyed by display names (Agno emits display name as agent_name)
655
+ per_member: dict[str, str] = dict.fromkeys(display_names, "")
656
+ consensus: str = ""
657
+
658
+ logger.info(f"Team streaming setup - agents: {agent_names}, display names: {display_names}")
659
+
660
+ # Acquire raw event stream
661
+ raw_stream = await team_response_stream_raw(
662
+ agent_ids=agent_ids,
663
+ mode=mode,
664
+ message=message,
665
+ orchestrator=orchestrator,
666
+ thread_history=thread_history,
667
+ model_name=model_name,
668
+ )
669
+
670
+ async for event in raw_stream:
671
+ # Handle error case
672
+ if isinstance(event, RunResponse):
673
+ content = _get_response_content(event)
674
+ if NO_AGENTS_RESPONSE in content:
675
+ yield content
676
+ return
677
+ logger.warning(f"Unexpected RunResponse in team stream: {content[:100]}")
678
+ continue
679
+
680
+ # Individual agent response event
681
+ elif isinstance(event, AgentRunResponseContentEvent):
682
+ agent_name = event.agent_name
683
+ if agent_name:
684
+ content = str(event.content or "")
685
+ if agent_name not in per_member:
686
+ per_member[agent_name] = ""
687
+ per_member[agent_name] += content
688
+
689
+ # Agent tool call started
690
+ elif isinstance(event, AgentToolCallStartedEvent):
691
+ agent_name = event.agent_name
692
+ tool_msg = _fmt_tool_started(event)
693
+ if agent_name and tool_msg:
694
+ if agent_name not in per_member:
695
+ per_member[agent_name] = ""
696
+ per_member[agent_name] += tool_msg
697
+
698
+ # Agent tool call completed
699
+ elif isinstance(event, AgentToolCallCompletedEvent):
700
+ agent_name = event.agent_name
701
+ tool_msg = _fmt_tool_completed(event)
702
+ if agent_name and tool_msg:
703
+ if agent_name not in per_member:
704
+ per_member[agent_name] = ""
705
+ per_member[agent_name] += tool_msg
706
+
707
+ # Team consensus content event
708
+ elif isinstance(event, TeamRunResponseContentEvent):
709
+ if event.content:
710
+ consensus += str(event.content)
711
+ else:
712
+ logger.debug("Empty team consensus event received")
713
+
714
+ # Team-level tool call events (no specific agent context)
715
+ elif isinstance(event, (TeamToolCallStartedEvent, TeamToolCallCompletedEvent)):
716
+ # Format with the same helper, both carry .tool/.content
717
+ if isinstance(event, TeamToolCallStartedEvent):
718
+ tool_msg = _fmt_tool_started(event)
719
+ else:
720
+ tool_msg = _fmt_tool_completed(event)
721
+ if tool_msg:
722
+ consensus += tool_msg
723
+
724
+ # Skip other event types
725
+ else:
726
+ logger.debug(f"Ignoring event type: {type(event).__name__}")
727
+ continue
728
+
729
+ parts: list[str] = []
730
+
731
+ # First render configured agents (display names) in order
732
+ for display in display_names:
733
+ body = per_member.get(display, "").strip()
734
+ if body:
735
+ parts.append(format_member_contribution(display, body))
736
+ # Then render any late/unknown agents that appeared during stream
737
+ for display, body in per_member.items():
738
+ if display not in display_names and body.strip():
739
+ parts.append(format_member_contribution(display, body.strip()))
740
+
741
+ if consensus.strip():
742
+ parts.extend(format_team_consensus(consensus.strip()))
743
+ elif parts:
744
+ parts.append(format_no_consensus_note())
745
+
746
+ if parts:
747
+ header = format_team_header(agent_names)
748
+ full_text = "\n\n".join(parts)
749
+ yield header + full_text