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/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
|