kubiya-control-plane-api 0.1.0__py3-none-any.whl → 0.3.4__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.

Potentially problematic release.


This version of kubiya-control-plane-api might be problematic. Click here for more details.

Files changed (185) hide show
  1. control_plane_api/README.md +266 -0
  2. control_plane_api/__init__.py +0 -0
  3. control_plane_api/__version__.py +1 -0
  4. control_plane_api/alembic/README +1 -0
  5. control_plane_api/alembic/env.py +98 -0
  6. control_plane_api/alembic/script.py.mako +28 -0
  7. control_plane_api/alembic/versions/1382bec74309_initial_migration_with_all_models.py +251 -0
  8. control_plane_api/alembic/versions/1f54bc2a37e3_add_analytics_tables.py +162 -0
  9. control_plane_api/alembic/versions/2e4cb136dc10_rename_toolset_ids_to_skill_ids_in_teams.py +30 -0
  10. control_plane_api/alembic/versions/31cd69a644ce_add_skill_templates_table.py +28 -0
  11. control_plane_api/alembic/versions/89e127caa47d_add_jobs_and_job_executions_tables.py +161 -0
  12. control_plane_api/alembic/versions/add_llm_models_table.py +51 -0
  13. control_plane_api/alembic/versions/b0e10697f212_add_runtime_column_to_teams_simple.py +42 -0
  14. control_plane_api/alembic/versions/ce43b24b63bf_add_execution_trigger_source_and_fix_.py +155 -0
  15. control_plane_api/alembic/versions/d4eaf16e3f8d_rename_toolsets_to_skills.py +84 -0
  16. control_plane_api/alembic/versions/efa2dc427da1_rename_metadata_to_custom_metadata.py +32 -0
  17. control_plane_api/alembic/versions/f973b431d1ce_add_workflow_executor_to_skill_types.py +44 -0
  18. control_plane_api/alembic.ini +148 -0
  19. control_plane_api/api/index.py +12 -0
  20. control_plane_api/app/__init__.py +11 -0
  21. control_plane_api/app/activities/__init__.py +20 -0
  22. control_plane_api/app/activities/agent_activities.py +379 -0
  23. control_plane_api/app/activities/team_activities.py +410 -0
  24. control_plane_api/app/activities/temporal_cloud_activities.py +577 -0
  25. control_plane_api/app/config/__init__.py +35 -0
  26. control_plane_api/app/config/api_config.py +354 -0
  27. control_plane_api/app/config/model_pricing.py +318 -0
  28. control_plane_api/app/config.py +95 -0
  29. control_plane_api/app/database.py +135 -0
  30. control_plane_api/app/exceptions.py +408 -0
  31. control_plane_api/app/lib/__init__.py +11 -0
  32. control_plane_api/app/lib/job_executor.py +312 -0
  33. control_plane_api/app/lib/kubiya_client.py +235 -0
  34. control_plane_api/app/lib/litellm_pricing.py +166 -0
  35. control_plane_api/app/lib/planning_tools/__init__.py +22 -0
  36. control_plane_api/app/lib/planning_tools/agents.py +155 -0
  37. control_plane_api/app/lib/planning_tools/base.py +189 -0
  38. control_plane_api/app/lib/planning_tools/environments.py +214 -0
  39. control_plane_api/app/lib/planning_tools/resources.py +240 -0
  40. control_plane_api/app/lib/planning_tools/teams.py +198 -0
  41. control_plane_api/app/lib/policy_enforcer_client.py +939 -0
  42. control_plane_api/app/lib/redis_client.py +436 -0
  43. control_plane_api/app/lib/supabase.py +71 -0
  44. control_plane_api/app/lib/temporal_client.py +138 -0
  45. control_plane_api/app/lib/validation/__init__.py +20 -0
  46. control_plane_api/app/lib/validation/runtime_validation.py +287 -0
  47. control_plane_api/app/main.py +128 -0
  48. control_plane_api/app/middleware/__init__.py +8 -0
  49. control_plane_api/app/middleware/auth.py +513 -0
  50. control_plane_api/app/middleware/exception_handler.py +267 -0
  51. control_plane_api/app/middleware/rate_limiting.py +384 -0
  52. control_plane_api/app/middleware/request_id.py +202 -0
  53. control_plane_api/app/models/__init__.py +27 -0
  54. control_plane_api/app/models/agent.py +79 -0
  55. control_plane_api/app/models/analytics.py +206 -0
  56. control_plane_api/app/models/associations.py +81 -0
  57. control_plane_api/app/models/environment.py +63 -0
  58. control_plane_api/app/models/execution.py +93 -0
  59. control_plane_api/app/models/job.py +179 -0
  60. control_plane_api/app/models/llm_model.py +75 -0
  61. control_plane_api/app/models/presence.py +49 -0
  62. control_plane_api/app/models/project.py +47 -0
  63. control_plane_api/app/models/session.py +38 -0
  64. control_plane_api/app/models/team.py +66 -0
  65. control_plane_api/app/models/workflow.py +55 -0
  66. control_plane_api/app/policies/README.md +121 -0
  67. control_plane_api/app/policies/approved_users.rego +62 -0
  68. control_plane_api/app/policies/business_hours.rego +51 -0
  69. control_plane_api/app/policies/rate_limiting.rego +100 -0
  70. control_plane_api/app/policies/tool_restrictions.rego +86 -0
  71. control_plane_api/app/routers/__init__.py +4 -0
  72. control_plane_api/app/routers/agents.py +364 -0
  73. control_plane_api/app/routers/agents_v2.py +1260 -0
  74. control_plane_api/app/routers/analytics.py +1014 -0
  75. control_plane_api/app/routers/context_manager.py +562 -0
  76. control_plane_api/app/routers/environment_context.py +270 -0
  77. control_plane_api/app/routers/environments.py +715 -0
  78. control_plane_api/app/routers/execution_environment.py +517 -0
  79. control_plane_api/app/routers/executions.py +1911 -0
  80. control_plane_api/app/routers/health.py +92 -0
  81. control_plane_api/app/routers/health_v2.py +326 -0
  82. control_plane_api/app/routers/integrations.py +274 -0
  83. control_plane_api/app/routers/jobs.py +1344 -0
  84. control_plane_api/app/routers/models.py +82 -0
  85. control_plane_api/app/routers/models_v2.py +361 -0
  86. control_plane_api/app/routers/policies.py +639 -0
  87. control_plane_api/app/routers/presence.py +234 -0
  88. control_plane_api/app/routers/projects.py +902 -0
  89. control_plane_api/app/routers/runners.py +379 -0
  90. control_plane_api/app/routers/runtimes.py +172 -0
  91. control_plane_api/app/routers/secrets.py +155 -0
  92. control_plane_api/app/routers/skills.py +1001 -0
  93. control_plane_api/app/routers/skills_definitions.py +140 -0
  94. control_plane_api/app/routers/task_planning.py +1256 -0
  95. control_plane_api/app/routers/task_queues.py +654 -0
  96. control_plane_api/app/routers/team_context.py +270 -0
  97. control_plane_api/app/routers/teams.py +1400 -0
  98. control_plane_api/app/routers/worker_queues.py +1545 -0
  99. control_plane_api/app/routers/workers.py +935 -0
  100. control_plane_api/app/routers/workflows.py +204 -0
  101. control_plane_api/app/runtimes/__init__.py +6 -0
  102. control_plane_api/app/runtimes/validation.py +344 -0
  103. control_plane_api/app/schemas/job_schemas.py +295 -0
  104. control_plane_api/app/services/__init__.py +1 -0
  105. control_plane_api/app/services/agno_service.py +619 -0
  106. control_plane_api/app/services/litellm_service.py +190 -0
  107. control_plane_api/app/services/policy_service.py +525 -0
  108. control_plane_api/app/services/temporal_cloud_provisioning.py +150 -0
  109. control_plane_api/app/skills/__init__.py +44 -0
  110. control_plane_api/app/skills/base.py +229 -0
  111. control_plane_api/app/skills/business_intelligence.py +189 -0
  112. control_plane_api/app/skills/data_visualization.py +154 -0
  113. control_plane_api/app/skills/docker.py +104 -0
  114. control_plane_api/app/skills/file_generation.py +94 -0
  115. control_plane_api/app/skills/file_system.py +110 -0
  116. control_plane_api/app/skills/python.py +92 -0
  117. control_plane_api/app/skills/registry.py +65 -0
  118. control_plane_api/app/skills/shell.py +102 -0
  119. control_plane_api/app/skills/workflow_executor.py +469 -0
  120. control_plane_api/app/utils/workflow_executor.py +354 -0
  121. control_plane_api/app/workflows/__init__.py +11 -0
  122. control_plane_api/app/workflows/agent_execution.py +507 -0
  123. control_plane_api/app/workflows/agent_execution_with_skills.py +222 -0
  124. control_plane_api/app/workflows/namespace_provisioning.py +326 -0
  125. control_plane_api/app/workflows/team_execution.py +399 -0
  126. control_plane_api/scripts/seed_models.py +239 -0
  127. control_plane_api/worker/__init__.py +0 -0
  128. control_plane_api/worker/activities/__init__.py +0 -0
  129. control_plane_api/worker/activities/agent_activities.py +1241 -0
  130. control_plane_api/worker/activities/approval_activities.py +234 -0
  131. control_plane_api/worker/activities/runtime_activities.py +388 -0
  132. control_plane_api/worker/activities/skill_activities.py +267 -0
  133. control_plane_api/worker/activities/team_activities.py +1217 -0
  134. control_plane_api/worker/config/__init__.py +31 -0
  135. control_plane_api/worker/config/worker_config.py +275 -0
  136. control_plane_api/worker/control_plane_client.py +529 -0
  137. control_plane_api/worker/examples/analytics_integration_example.py +362 -0
  138. control_plane_api/worker/models/__init__.py +1 -0
  139. control_plane_api/worker/models/inputs.py +89 -0
  140. control_plane_api/worker/runtimes/__init__.py +31 -0
  141. control_plane_api/worker/runtimes/base.py +789 -0
  142. control_plane_api/worker/runtimes/claude_code_runtime.py +1443 -0
  143. control_plane_api/worker/runtimes/default_runtime.py +617 -0
  144. control_plane_api/worker/runtimes/factory.py +173 -0
  145. control_plane_api/worker/runtimes/validation.py +93 -0
  146. control_plane_api/worker/services/__init__.py +1 -0
  147. control_plane_api/worker/services/agent_executor.py +422 -0
  148. control_plane_api/worker/services/agent_executor_v2.py +383 -0
  149. control_plane_api/worker/services/analytics_collector.py +457 -0
  150. control_plane_api/worker/services/analytics_service.py +464 -0
  151. control_plane_api/worker/services/approval_tools.py +310 -0
  152. control_plane_api/worker/services/approval_tools_agno.py +207 -0
  153. control_plane_api/worker/services/cancellation_manager.py +177 -0
  154. control_plane_api/worker/services/data_visualization.py +827 -0
  155. control_plane_api/worker/services/jira_tools.py +257 -0
  156. control_plane_api/worker/services/runtime_analytics.py +328 -0
  157. control_plane_api/worker/services/session_service.py +194 -0
  158. control_plane_api/worker/services/skill_factory.py +175 -0
  159. control_plane_api/worker/services/team_executor.py +574 -0
  160. control_plane_api/worker/services/team_executor_v2.py +465 -0
  161. control_plane_api/worker/services/workflow_executor_tools.py +1418 -0
  162. control_plane_api/worker/tests/__init__.py +1 -0
  163. control_plane_api/worker/tests/e2e/__init__.py +0 -0
  164. control_plane_api/worker/tests/e2e/test_execution_flow.py +571 -0
  165. control_plane_api/worker/tests/integration/__init__.py +0 -0
  166. control_plane_api/worker/tests/integration/test_control_plane_integration.py +308 -0
  167. control_plane_api/worker/tests/unit/__init__.py +0 -0
  168. control_plane_api/worker/tests/unit/test_control_plane_client.py +401 -0
  169. control_plane_api/worker/utils/__init__.py +1 -0
  170. control_plane_api/worker/utils/chunk_batcher.py +305 -0
  171. control_plane_api/worker/utils/retry_utils.py +60 -0
  172. control_plane_api/worker/utils/streaming_utils.py +373 -0
  173. control_plane_api/worker/worker.py +753 -0
  174. control_plane_api/worker/workflows/__init__.py +0 -0
  175. control_plane_api/worker/workflows/agent_execution.py +589 -0
  176. control_plane_api/worker/workflows/team_execution.py +429 -0
  177. kubiya_control_plane_api-0.3.4.dist-info/METADATA +229 -0
  178. kubiya_control_plane_api-0.3.4.dist-info/RECORD +182 -0
  179. kubiya_control_plane_api-0.3.4.dist-info/entry_points.txt +2 -0
  180. kubiya_control_plane_api-0.3.4.dist-info/top_level.txt +1 -0
  181. kubiya_control_plane_api-0.1.0.dist-info/METADATA +0 -66
  182. kubiya_control_plane_api-0.1.0.dist-info/RECORD +0 -5
  183. kubiya_control_plane_api-0.1.0.dist-info/top_level.txt +0 -1
  184. {kubiya_control_plane_api-0.1.0.dist-info/licenses → control_plane_api}/LICENSE +0 -0
  185. {kubiya_control_plane_api-0.1.0.dist-info → kubiya_control_plane_api-0.3.4.dist-info}/WHEEL +0 -0
@@ -0,0 +1,1217 @@
1
+ """Team-related Temporal activities"""
2
+
3
+ import os
4
+ import httpx
5
+ from dataclasses import dataclass
6
+ from typing import Optional, List, Any, Dict
7
+ from datetime import datetime, timezone
8
+ from temporalio import activity
9
+ import structlog
10
+ from pathlib import Path
11
+
12
+ from agno.agent import Agent
13
+ from agno.team import Team
14
+ from agno.models.litellm import LiteLLM
15
+ from agno.tools.shell import ShellTools
16
+ from agno.tools.python import PythonTools
17
+ from agno.tools.file import FileTools
18
+
19
+ from control_plane_api.worker.activities.agent_activities import update_execution_status, ActivityUpdateExecutionInput
20
+ from control_plane_api.worker.control_plane_client import get_control_plane_client
21
+
22
+ logger = structlog.get_logger()
23
+
24
+ # Global registry for active Team instances to support cancellation
25
+ # Key: execution_id, Value: {team: Team, run_id: str}
26
+ _active_teams: Dict[str, Dict[str, Any]] = {}
27
+
28
+
29
+ def instantiate_skill(skill_data: dict) -> Optional[Any]:
30
+ """
31
+ Instantiate an Agno toolkit based on skill configuration from Control Plane.
32
+
33
+ Args:
34
+ skill_data: Skill data from Control Plane API containing:
35
+ - type: Skill type (file_system, shell, python, docker, etc.)
36
+ - name: Skill name
37
+ - configuration: Dict with skill-specific config
38
+ - enabled: Whether skill is enabled
39
+
40
+ Returns:
41
+ Instantiated Agno toolkit or None if type not supported/enabled
42
+ """
43
+ if not skill_data.get("enabled", True):
44
+ print(f" āŠ— Skipping disabled skill: {skill_data.get('name')}")
45
+ return None
46
+
47
+ skill_type = skill_data.get("type", "").lower()
48
+ config = skill_data.get("configuration", {})
49
+ name = skill_data.get("name", "Unknown")
50
+
51
+ try:
52
+ # Map Control Plane skill types to Agno toolkit classes
53
+ if skill_type in ["file_system", "file", "file_generation"]:
54
+ # FileTools: file operations (read, write, list, search)
55
+ # Note: file_generation is mapped to FileTools (save_file functionality)
56
+ base_dir = config.get("base_dir")
57
+ toolkit = FileTools(
58
+ base_dir=Path(base_dir) if base_dir else None,
59
+ enable_save_file=config.get("enable_save_file", True),
60
+ enable_read_file=config.get("enable_read_file", True),
61
+ enable_list_files=config.get("enable_list_files", True),
62
+ enable_search_files=config.get("enable_search_files", True),
63
+ )
64
+ print(f" āœ“ Instantiated FileTools: {name}")
65
+ if skill_type == "file_generation":
66
+ print(f" - Type: File Generation (using FileTools.save_file)")
67
+ print(f" - Base Dir: {base_dir or 'Current directory'}")
68
+ print(f" - Read: {config.get('enable_read_file', True)}, Write: {config.get('enable_save_file', True)}")
69
+ return toolkit
70
+
71
+ elif skill_type in ["shell", "bash"]:
72
+ # ShellTools: shell command execution
73
+ base_dir = config.get("base_dir")
74
+ toolkit = ShellTools(
75
+ base_dir=Path(base_dir) if base_dir else None,
76
+ enable_run_shell_command=config.get("enable_run_shell_command", True),
77
+ )
78
+ print(f" āœ“ Instantiated ShellTools: {name}")
79
+ print(f" - Base Dir: {base_dir or 'Current directory'}")
80
+ print(f" - Run Commands: {config.get('enable_run_shell_command', True)}")
81
+ return toolkit
82
+
83
+ elif skill_type == "python":
84
+ # PythonTools: Python code execution
85
+ base_dir = config.get("base_dir")
86
+ toolkit = PythonTools(
87
+ base_dir=Path(base_dir) if base_dir else None,
88
+ safe_globals=config.get("safe_globals"),
89
+ safe_locals=config.get("safe_locals"),
90
+ )
91
+ print(f" āœ“ Instantiated PythonTools: {name}")
92
+ print(f" - Base Dir: {base_dir or 'Current directory'}")
93
+ return toolkit
94
+
95
+ elif skill_type == "docker":
96
+ # DockerTools requires docker package and running Docker daemon
97
+ try:
98
+ from agno.tools.docker import DockerTools
99
+ import docker
100
+
101
+ # Check if Docker daemon is accessible
102
+ try:
103
+ docker_client = docker.from_env()
104
+ docker_client.ping()
105
+
106
+ # Docker is available, instantiate toolkit
107
+ toolkit = DockerTools()
108
+ print(f" āœ“ Instantiated DockerTools: {name}")
109
+ print(f" - Docker daemon: Connected")
110
+ docker_client.close()
111
+ return toolkit
112
+
113
+ except Exception as docker_error:
114
+ print(f" ⚠ Docker daemon not available - skipping: {name}")
115
+ print(f" Error: {str(docker_error)}")
116
+ return None
117
+
118
+ except ImportError:
119
+ print(f" ⚠ Docker skill requires 'docker' package - skipping: {name}")
120
+ print(f" Install with: pip install docker")
121
+ return None
122
+
123
+ else:
124
+ print(f" ⚠ Unsupported skill type '{skill_type}': {name}")
125
+ return None
126
+
127
+ except Exception as e:
128
+ print(f" āŒ Error instantiating skill '{name}' (type: {skill_type}): {str(e)}")
129
+ logger.error(
130
+ f"Error instantiating skill",
131
+ extra={
132
+ "skill_name": name,
133
+ "skill_type": skill_type,
134
+ "error": str(e)
135
+ }
136
+ )
137
+ return None
138
+
139
+
140
+ @dataclass
141
+ class ActivityGetTeamAgentsInput:
142
+ """Input for get_team_agents activity"""
143
+ team_id: str
144
+ organization_id: str
145
+
146
+
147
+ @dataclass
148
+ class ActivityExecuteTeamInput:
149
+ """Input for execute_team_coordination activity"""
150
+ execution_id: str
151
+ team_id: str
152
+ organization_id: str
153
+ prompt: str
154
+ system_prompt: Optional[str] = None
155
+ agents: List[dict] = None
156
+ team_config: dict = None
157
+ mcp_servers: dict = None # MCP servers configuration
158
+ session_id: Optional[str] = None # Session ID for Agno session management
159
+ user_id: Optional[str] = None # User ID for multi-user support
160
+ # Note: control_plane_url and api_key are read from worker environment variables (CONTROL_PLANE_URL, KUBIYA_API_KEY)
161
+
162
+ def __post_init__(self):
163
+ if self.agents is None:
164
+ self.agents = []
165
+ if self.team_config is None:
166
+ self.team_config = {}
167
+ if self.mcp_servers is None:
168
+ self.mcp_servers = {}
169
+
170
+
171
+ @activity.defn
172
+ async def get_team_agents(input: ActivityGetTeamAgentsInput) -> dict:
173
+ """
174
+ Get all agents in a team via Control Plane API.
175
+
176
+ This activity fetches team details including member agents from the Control Plane.
177
+
178
+ Args:
179
+ input: Activity input with team details
180
+
181
+ Returns:
182
+ Dict with agents list
183
+ """
184
+ print(f"\n\n=== GET_TEAM_AGENTS START ===")
185
+ print(f"team_id: {input.team_id} (type: {type(input.team_id).__name__})")
186
+ print(f"organization_id: {input.organization_id} (type: {type(input.organization_id).__name__})")
187
+ print(f"================================\n")
188
+
189
+ activity.logger.info(
190
+ f"[DEBUG] Getting team agents START",
191
+ extra={
192
+ "team_id": input.team_id,
193
+ "team_id_type": type(input.team_id).__name__,
194
+ "organization_id": input.organization_id,
195
+ "organization_id_type": type(input.organization_id).__name__,
196
+ }
197
+ )
198
+
199
+ try:
200
+ # Get Control Plane URL and Kubiya API key from environment
201
+ control_plane_url = os.getenv("CONTROL_PLANE_URL")
202
+ kubiya_api_key = os.getenv("KUBIYA_API_KEY")
203
+
204
+ if not control_plane_url:
205
+ raise ValueError("CONTROL_PLANE_URL environment variable not set")
206
+ if not kubiya_api_key:
207
+ raise ValueError("KUBIYA_API_KEY environment variable not set")
208
+
209
+ print(f"Fetching team from Control Plane API: {control_plane_url}")
210
+
211
+ # Call Control Plane API to get team with agents
212
+ async with httpx.AsyncClient(timeout=30.0) as client:
213
+ response = await client.get(
214
+ f"{control_plane_url}/api/v1/teams/{input.team_id}",
215
+ headers={
216
+ "Authorization": f"Bearer {kubiya_api_key}",
217
+ "Content-Type": "application/json",
218
+ }
219
+ )
220
+
221
+ if response.status_code == 404:
222
+ print(f"Team not found!")
223
+ activity.logger.error(
224
+ f"[DEBUG] Team not found",
225
+ extra={
226
+ "team_id": input.team_id,
227
+ "organization_id": input.organization_id,
228
+ }
229
+ )
230
+ return {"agents": [], "count": 0}
231
+ elif response.status_code != 200:
232
+ raise Exception(f"Failed to get team: {response.status_code} - {response.text}")
233
+
234
+ team_data = response.json()
235
+
236
+ # Extract agents from the API response
237
+ # The API returns a TeamWithAgentsResponse which includes the agents array
238
+ agents = team_data.get("agents", [])
239
+
240
+ print(f"Query executed. Agents found: {len(agents)}")
241
+
242
+ activity.logger.info(
243
+ f"[DEBUG] Query executed, processing results",
244
+ extra={
245
+ "agents_found": len(agents),
246
+ "agent_ids": [a.get("id") for a in agents],
247
+ }
248
+ )
249
+
250
+ print(f"Agents found: {len(agents)}")
251
+ if agents:
252
+ for agent in agents:
253
+ print(f" - {agent.get('name')} (ID: {agent.get('id')})")
254
+
255
+ activity.logger.info(
256
+ f"[DEBUG] Retrieved team agents via API",
257
+ extra={
258
+ "team_id": input.team_id,
259
+ "agent_count": len(agents),
260
+ "agent_names": [a.get("name") for a in agents],
261
+ "agent_ids": [a.get("id") for a in agents],
262
+ }
263
+ )
264
+
265
+ if not agents:
266
+ print(f"\n!!! NO AGENTS FOUND - Team may have no members !!!")
267
+ activity.logger.warning(
268
+ f"[DEBUG] WARNING: No agents found for team",
269
+ extra={
270
+ "team_id": input.team_id,
271
+ "organization_id": input.organization_id,
272
+ }
273
+ )
274
+
275
+ print(f"\n=== GET_TEAM_AGENTS END: Returning {len(agents)} agents ===\n\n")
276
+ return {
277
+ "agents": agents,
278
+ "count": len(agents),
279
+ }
280
+
281
+ except Exception as e:
282
+ print(f"\n!!! EXCEPTION in get_team_agents: {type(e).__name__}: {str(e)} !!!\n")
283
+ activity.logger.error(
284
+ f"[DEBUG] EXCEPTION in get_team_agents",
285
+ extra={
286
+ "team_id": input.team_id,
287
+ "organization_id": input.organization_id,
288
+ "error": str(e),
289
+ "error_type": type(e).__name__,
290
+ }
291
+ )
292
+ raise
293
+
294
+
295
+ @activity.defn
296
+ async def execute_team_coordination(input: ActivityExecuteTeamInput) -> dict:
297
+ """
298
+ Execute team coordination using Agno Teams.
299
+
300
+ This activity creates an Agno Team with member Agents and executes
301
+ the team run, allowing Agno to handle coordination.
302
+
303
+ Args:
304
+ input: Activity input with team execution details
305
+
306
+ Returns:
307
+ Dict with aggregated response, usage, success flag
308
+ """
309
+ print("\n" + "="*80)
310
+ print("šŸš€ TEAM EXECUTION START")
311
+ print("="*80)
312
+ print(f"Execution ID: {input.execution_id}")
313
+ print(f"Team ID: {input.team_id}")
314
+ print(f"Organization: {input.organization_id}")
315
+ print(f"Agent Count: {len(input.agents)}")
316
+ print(f"MCP Servers: {len(input.mcp_servers)} configured" if input.mcp_servers else "MCP Servers: None")
317
+ print(f"Session ID: {input.session_id}")
318
+ print(f"Prompt: {input.prompt[:100]}..." if len(input.prompt) > 100 else f"Prompt: {input.prompt}")
319
+ print("="*80 + "\n")
320
+
321
+ activity.logger.info(
322
+ f"Executing team coordination with Agno Teams",
323
+ extra={
324
+ "execution_id": input.execution_id,
325
+ "team_id": input.team_id,
326
+ "organization_id": input.organization_id,
327
+ "agent_count": len(input.agents),
328
+ "has_mcp_servers": bool(input.mcp_servers),
329
+ "mcp_server_count": len(input.mcp_servers) if input.mcp_servers else 0,
330
+ "mcp_server_ids": list(input.mcp_servers.keys()) if input.mcp_servers else [],
331
+ "session_id": input.session_id,
332
+ }
333
+ )
334
+
335
+ try:
336
+ # Get Control Plane client for session management
337
+ control_plane = get_control_plane_client()
338
+
339
+ # STEP 1: Load existing session history from Control Plane (if this is a continuation)
340
+ # This enables conversation continuity across multiple execution turns
341
+ # IMPORTANT: Must be non-blocking with proper timeout/retry
342
+ session_history = []
343
+ if input.session_id:
344
+ print(f"\nšŸ“„ Loading session history from Control Plane...")
345
+
346
+ # Try up to 3 times with exponential backoff
347
+ max_retries = 3
348
+ for attempt in range(max_retries):
349
+ try:
350
+ if attempt > 0:
351
+ print(f" šŸ”„ Retry attempt {attempt + 1}/{max_retries}...")
352
+
353
+ session_data = control_plane.get_session(
354
+ execution_id=input.execution_id,
355
+ session_id=input.session_id
356
+ )
357
+ if session_data and session_data.get("messages"):
358
+ session_history = session_data["messages"]
359
+ print(f" āœ… Loaded {len(session_history)} messages from previous turns")
360
+
361
+ activity.logger.info(
362
+ "Team session history loaded from Control Plane",
363
+ extra={
364
+ "execution_id": input.execution_id,
365
+ "session_id": input.session_id,
366
+ "message_count": len(session_history),
367
+ "attempt": attempt + 1,
368
+ }
369
+ )
370
+ break
371
+ else:
372
+ print(f" ā„¹ļø No previous session found - starting new conversation")
373
+ break
374
+
375
+ except httpx.TimeoutException as e:
376
+ print(f" ā±ļø Timeout loading session (attempt {attempt + 1}/{max_retries})")
377
+ activity.logger.warning(
378
+ "Team session load timeout",
379
+ extra={"error": str(e), "execution_id": input.execution_id, "attempt": attempt + 1}
380
+ )
381
+ if attempt < max_retries - 1:
382
+ import time
383
+ time.sleep(2 ** attempt)
384
+ continue
385
+ else:
386
+ print(f" āš ļø Session load failed after {max_retries} attempts - continuing without history")
387
+
388
+ except Exception as e:
389
+ error_type = type(e).__name__
390
+ print(f" āš ļø Failed to load session history ({error_type}): {str(e)[:100]}")
391
+ activity.logger.warning(
392
+ "Failed to load team session history",
393
+ extra={
394
+ "error": str(e),
395
+ "error_type": error_type,
396
+ "execution_id": input.execution_id,
397
+ "attempt": attempt + 1
398
+ }
399
+ )
400
+ break
401
+
402
+ print(f" → Continuing with {len(session_history)} messages in context\n")
403
+
404
+ # Get LiteLLM credentials from environment (set by worker from registration)
405
+ litellm_api_base = os.getenv("LITELLM_API_BASE", "https://llm-proxy.kubiya.ai")
406
+ litellm_api_key = os.getenv("LITELLM_API_KEY")
407
+
408
+ if not litellm_api_key:
409
+ raise ValueError("LITELLM_API_KEY environment variable not set")
410
+
411
+ # Get Control Plane URL and API key from environment (worker has these set on startup)
412
+ control_plane_url = os.getenv("CONTROL_PLANE_URL")
413
+ api_key = os.getenv("KUBIYA_API_KEY")
414
+
415
+ # Fetch resolved skills from Control Plane if available
416
+ skills = []
417
+ if control_plane_url and api_key and input.team_id:
418
+ print(f"šŸ”§ Fetching skills for TEAM from Control Plane...")
419
+ try:
420
+ async with httpx.AsyncClient(timeout=30.0) as client:
421
+ response = await client.get(
422
+ f"{control_plane_url}/api/v1/skills/associations/teams/{input.team_id}/skills/resolved",
423
+ headers={"Authorization": f"Bearer {api_key}"}
424
+ )
425
+
426
+ if response.status_code == 200:
427
+ skills = response.json()
428
+ print(f"āœ… Resolved {len(skills)} skills from Control Plane for TEAM")
429
+ print(f" Skill Types: {[t.get('type') for t in skills]}")
430
+ print(f" Skill Sources: {[t.get('source') for t in skills]}")
431
+ print(f" Skill Names: {[t.get('name') for t in skills]}\n")
432
+
433
+ activity.logger.info(
434
+ f"Resolved skills for team from Control Plane",
435
+ extra={
436
+ "team_id": input.team_id,
437
+ "skill_count": len(skills),
438
+ "skill_types": [t.get("type") for t in skills],
439
+ "skill_sources": [t.get("source") for t in skills],
440
+ "skill_names": [t.get("name") for t in skills],
441
+ }
442
+ )
443
+ else:
444
+ print(f"āš ļø Failed to fetch skills for team: HTTP {response.status_code}")
445
+ print(f" Response: {response.text[:200]}\n")
446
+ activity.logger.warning(
447
+ f"Failed to fetch skills for team from Control Plane: {response.status_code}",
448
+ extra={
449
+ "status_code": response.status_code,
450
+ "response_text": response.text[:500]
451
+ }
452
+ )
453
+ except Exception as e:
454
+ print(f"āŒ Error fetching skills for team: {str(e)}\n")
455
+ activity.logger.error(
456
+ f"Error fetching skills for team from Control Plane: {str(e)}",
457
+ extra={"error": str(e)}
458
+ )
459
+ # Continue execution without skills
460
+ else:
461
+ print(f"ā„¹ļø No Control Plane URL/API key in environment for team - skipping skill resolution\n")
462
+
463
+ # Instantiate Agno toolkits from Control Plane skills
464
+ print(f"\nšŸ”§ Instantiating Skills:")
465
+ agno_toolkits = []
466
+ if skills:
467
+ for skill in skills:
468
+ toolkit = instantiate_skill(skill)
469
+ if toolkit:
470
+ agno_toolkits.append(toolkit)
471
+
472
+ if agno_toolkits:
473
+ print(f"\nāœ… Successfully instantiated {len(agno_toolkits)} skill(s)")
474
+ else:
475
+ print(f"\nā„¹ļø No skills instantiated\n")
476
+
477
+ print(f"šŸ“¦ Total Tools Available:")
478
+ print(f" MCP Servers: {len(input.mcp_servers)}")
479
+ print(f" OS-Level Skills: {len(agno_toolkits)}\n")
480
+
481
+ # Create Agno Agent objects for each team member
482
+ print("\nšŸ“‹ Creating Team Members:")
483
+ member_agents = []
484
+ for i, agent_data in enumerate(input.agents, 1):
485
+ # Get model ID (default to kubiya/claude-sonnet-4 if not specified)
486
+ model_id = agent_data.get("model_id") or "kubiya/claude-sonnet-4"
487
+
488
+ print(f" {i}. {agent_data['name']}")
489
+ print(f" Model: {model_id}")
490
+ print(f" Role: {agent_data.get('description', agent_data['name'])[:60]}...")
491
+
492
+ # Create Agno Agent with explicit LiteLLM proxy configuration
493
+ # IMPORTANT: Use openai/ prefix for custom proxy compatibility
494
+ member_agent = Agent(
495
+ name=agent_data["name"],
496
+ role=agent_data.get("description", agent_data["name"]),
497
+ model=LiteLLM(
498
+ id=f"openai/{model_id}", # e.g., "openai/kubiya/claude-sonnet-4"
499
+ api_base=litellm_api_base,
500
+ api_key=litellm_api_key,
501
+ ),
502
+ )
503
+ member_agents.append(member_agent)
504
+
505
+ activity.logger.info(
506
+ f"Created Agno Agent",
507
+ extra={
508
+ "agent_name": agent_data["name"],
509
+ "model": model_id,
510
+ }
511
+ )
512
+
513
+ # Create Agno Team with member agents and LiteLLM model for coordination
514
+ # Get coordinator model from team configuration (if specified by user in UI)
515
+ # Falls back to default if not configured
516
+ team_model = (
517
+ input.team_config.get("llm", {}).get("model")
518
+ or "kubiya/claude-sonnet-4" # Default coordinator model
519
+ )
520
+
521
+ print(f"\nšŸ¤– Creating Agno Team:")
522
+ print(f" Coordinator Model: {team_model}")
523
+ print(f" Members: {len(member_agents)}")
524
+ print(f" Skills: {len(agno_toolkits)}")
525
+
526
+ # Send heartbeat: Creating team
527
+ activity.heartbeat({"status": "Creating team with agents and skills..."})
528
+
529
+ # Track tool executions for real-time streaming
530
+ tool_execution_messages = []
531
+
532
+ # Create tool hook to capture tool execution for real-time streaming
533
+ # Agno inspects the signature and passes matching parameters
534
+ def tool_hook(name: str = None, function_name: str = None, function=None, arguments: dict = None, **kwargs):
535
+ """Hook to capture tool execution and add to messages for streaming
536
+
537
+ Agno passes these parameters based on our signature:
538
+ - name or function_name: The tool function name
539
+ - function: The callable being executed (this is the NEXT function in the chain)
540
+ - arguments: Dict of arguments passed to the tool
541
+
542
+ The hook must CALL the function and return its result.
543
+ """
544
+ # Get tool name from Agno's parameters
545
+ tool_name = name or function_name or "unknown"
546
+ tool_args = arguments or {}
547
+
548
+ # Generate unique tool execution ID (tool_name + timestamp)
549
+ import time
550
+ tool_execution_id = f"{tool_name}_{int(time.time() * 1000000)}"
551
+
552
+ print(f" šŸ”§ Tool Starting: {tool_name} (ID: {tool_execution_id})")
553
+ if tool_args:
554
+ args_preview = str(tool_args)[:200]
555
+ print(f" Args: {args_preview}{'...' if len(str(tool_args)) > 200 else ''}")
556
+
557
+ # Publish streaming event to Control Plane (real-time UI update)
558
+ control_plane.publish_event(
559
+ execution_id=input.execution_id,
560
+ event_type="tool_started",
561
+ data={
562
+ "tool_name": tool_name,
563
+ "tool_execution_id": tool_execution_id, # Unique ID for this execution
564
+ "tool_arguments": tool_args,
565
+ "message": f"šŸ”§ Executing tool: {tool_name}",
566
+ }
567
+ )
568
+
569
+ tool_execution_messages.append({
570
+ "role": "system",
571
+ "content": f"šŸ”§ Executing tool: **{tool_name}**",
572
+ "tool_name": tool_name,
573
+ "tool_event": "started",
574
+ "timestamp": datetime.now(timezone.utc).isoformat(),
575
+ })
576
+
577
+ # CRITICAL: Actually call the function and handle completion
578
+ result = None
579
+ error = None
580
+ try:
581
+ # Call the actual function (next in the hook chain)
582
+ if function and callable(function):
583
+ result = function(**tool_args) if tool_args else function()
584
+ else:
585
+ raise ValueError(f"Function not callable: {function}")
586
+
587
+ status = "success"
588
+ icon = "āœ…"
589
+ print(f" {icon} Tool Success: {tool_name}")
590
+
591
+ except Exception as e:
592
+ error = e
593
+ status = "failed"
594
+ icon = "āŒ"
595
+ print(f" {icon} Tool Failed: {tool_name} - {str(e)}")
596
+
597
+ # Publish completion event to Control Plane (real-time UI update)
598
+ control_plane.publish_event(
599
+ execution_id=input.execution_id,
600
+ event_type="tool_completed",
601
+ data={
602
+ "tool_name": tool_name,
603
+ "tool_execution_id": tool_execution_id, # Same ID to match the started event
604
+ "status": status,
605
+ "error": str(error) if error else None,
606
+ "tool_output": result if result is not None else None, # Include tool output for UI display
607
+ "message": f"{icon} Tool {status}: {tool_name}",
608
+ }
609
+ )
610
+
611
+ tool_execution_messages.append({
612
+ "role": "system",
613
+ "content": f"{icon} Tool {status}: **{tool_name}**",
614
+ "tool_name": tool_name,
615
+ "tool_event": "completed",
616
+ "tool_status": status,
617
+ "timestamp": datetime.now(timezone.utc).isoformat(),
618
+ })
619
+
620
+ # If there was an error, re-raise it so Agno knows the tool failed
621
+ if error:
622
+ raise error
623
+
624
+ # Return the result to continue the chain
625
+ return result
626
+
627
+ # Create PERSISTENT database for Team based on session_id
628
+ # This allows Agno to automatically manage conversation history across turns
629
+ # Database persists across executions within the same session
630
+ from agno.db.sqlite import SqliteDb
631
+ import tempfile
632
+
633
+ # Use session_id (not execution_id) for persistent database across conversation turns
634
+ session_id_for_db = input.session_id or input.execution_id
635
+ db_path = os.path.join(tempfile.gettempdir(), f"team_session_{session_id_for_db}.db")
636
+ team_db = SqliteDb(db_file=db_path)
637
+
638
+ print(f"šŸ“‚ Using persistent team database: {db_path}")
639
+
640
+ # Create Team with openai/ prefix for custom proxy compatibility
641
+ team = Team(
642
+ members=member_agents,
643
+ name=f"Team {input.team_id}",
644
+ model=LiteLLM(
645
+ id=f"openai/{team_model}", # e.g., "openai/kubiya/claude-sonnet-4"
646
+ api_base=litellm_api_base,
647
+ api_key=litellm_api_key,
648
+ ),
649
+ tools=agno_toolkits if agno_toolkits else None, # Add skills to team
650
+ tool_hooks=[tool_hook], # Add hook for real-time tool updates
651
+ db=team_db, # PERSISTENT database per session
652
+ add_history_to_context=True, # Agno automatically adds conversation history from DB
653
+ num_history_runs=10, # Include last 10 turns in context
654
+ share_member_interactions=True, # Members see each other's work during current run
655
+ store_member_responses=True, # Enable member responses to be stored
656
+ show_members_responses=True, # Show member responses in logs
657
+ )
658
+
659
+ print(f" šŸ“š Team configured with automatic history (last 10 runs)\n")
660
+
661
+ # Register team for cancellation support
662
+ _active_teams[input.execution_id] = {
663
+ "team": team,
664
+ "run_id": None, # Will be set when run starts
665
+ "started_at": datetime.now(timezone.utc).isoformat(),
666
+ }
667
+ print(f"āœ… Team registered for cancellation support (execution_id: {input.execution_id})\n")
668
+
669
+ activity.logger.info(
670
+ f"Created Agno Team with {len(member_agents)} members",
671
+ extra={
672
+ "coordinator_model": team_model,
673
+ "member_count": len(member_agents),
674
+ }
675
+ )
676
+
677
+ # Cache execution metadata in Redis for fast SSE lookups (avoid DB queries)
678
+ control_plane = get_control_plane_client()
679
+ control_plane.cache_metadata(input.execution_id, "TEAM")
680
+
681
+ # Execute team run with streaming in a thread pool
682
+ # This prevents blocking the async event loop in Temporal
683
+ print("\n⚔ Executing Team Run with Streaming...")
684
+ print(f" Prompt: {input.prompt}\n")
685
+
686
+ # Send heartbeat: Starting execution
687
+ activity.heartbeat({"status": "Team is processing your request..."})
688
+
689
+ import asyncio
690
+
691
+ # Stream the response and collect chunks + tool messages
692
+ response_chunks = []
693
+ full_response = ""
694
+
695
+ # Generate unique message ID for this turn (execution_id + timestamp)
696
+ import time
697
+ message_id = f"{input.execution_id}_{int(time.time() * 1000000)}"
698
+
699
+ def stream_team_run():
700
+ """Run team with streaming and collect response + member events"""
701
+ nonlocal full_response, message_id
702
+ try:
703
+ # Run with streaming enabled AND stream_events to capture member events
704
+ # Agno Team automatically loads conversation history from persistent database
705
+ # via add_history_to_context=True and num_history_runs=10
706
+ run_kwargs = {
707
+ "stream": True,
708
+ "stream_events": True, # CRITICAL: Stream ALL events including member events
709
+ "stream_member_events": True, # Stream member agent events
710
+ "session_id": input.session_id or input.execution_id,
711
+ }
712
+ if input.user_id:
713
+ run_kwargs["user_id"] = input.user_id
714
+
715
+ print(f" šŸ“– Agno will automatically load conversation history from persistent database\n")
716
+ run_response = team.run(input.prompt, **run_kwargs)
717
+
718
+ # Track member message IDs for streaming
719
+ member_message_ids = {}
720
+ active_streaming_member = None # Track which member is currently streaming
721
+ tool_execution_ids = {} # Track tool_execution_id to match start/complete events (key: tool_name_timestamp)
722
+ run_id_published = False # Track if we've captured and published run_id
723
+
724
+ # Iterate over streaming events (not just chunks)
725
+ for event in run_response:
726
+ # Capture and publish run_id from first event for cancellation support
727
+ if not run_id_published and hasattr(event, 'run_id') and event.run_id:
728
+ agno_run_id = event.run_id
729
+ print(f"\nšŸ†” Agno run_id: {agno_run_id}")
730
+
731
+ # Store run_id in registry for cancellation
732
+ if input.execution_id in _active_teams:
733
+ _active_teams[input.execution_id]["run_id"] = agno_run_id
734
+
735
+ # Publish run_id to Redis for Control Plane cancellation access
736
+ # This allows users to cancel via STOP button in UI
737
+ control_plane.publish_event(
738
+ execution_id=input.execution_id,
739
+ event_type="run_started",
740
+ data={
741
+ "run_id": agno_run_id,
742
+ "team_id": input.team_id,
743
+ "cancellable": True,
744
+ }
745
+ )
746
+ run_id_published = True
747
+
748
+ event_type = getattr(event, 'event', None)
749
+
750
+ # Handle TEAM LEADER content chunks
751
+ if event_type == "TeamRunContent":
752
+ # If a member was streaming, mark them as complete (leader took control back)
753
+ if active_streaming_member and active_streaming_member in member_message_ids:
754
+ control_plane.publish_event(
755
+ execution_id=input.execution_id,
756
+ event_type="member_message_complete",
757
+ data={
758
+ "message_id": member_message_ids[active_streaming_member],
759
+ "member_name": active_streaming_member,
760
+ "source": "team_member",
761
+ }
762
+ )
763
+ active_streaming_member = None
764
+ if hasattr(event, 'content') and event.content:
765
+ content = str(event.content)
766
+ full_response += content
767
+ response_chunks.append(content)
768
+ print(content, end='', flush=True)
769
+
770
+ # Stream team leader chunk to Control Plane
771
+ control_plane.publish_event(
772
+ execution_id=input.execution_id,
773
+ event_type="message_chunk",
774
+ data={
775
+ "role": "assistant",
776
+ "content": content,
777
+ "is_chunk": True,
778
+ "message_id": message_id, # Team leader message ID
779
+ "source": "team_leader",
780
+ }
781
+ )
782
+
783
+ # Handle MEMBER content chunks (from team members)
784
+ elif event_type == "RunContent":
785
+ # Member agent content chunk
786
+ member_name = getattr(event, 'agent_name', getattr(event, 'member_name', 'Unknown Member'))
787
+
788
+ # If switching to a different member, mark the previous one as complete
789
+ if active_streaming_member and active_streaming_member != member_name and active_streaming_member in member_message_ids:
790
+ control_plane.publish_event(
791
+ execution_id=input.execution_id,
792
+ event_type="member_message_complete",
793
+ data={
794
+ "message_id": member_message_ids[active_streaming_member],
795
+ "member_name": active_streaming_member,
796
+ "source": "team_member",
797
+ }
798
+ )
799
+
800
+ # Generate unique message ID for this member
801
+ if member_name not in member_message_ids:
802
+ member_message_ids[member_name] = f"{input.execution_id}_{member_name}_{int(time.time() * 1000000)}"
803
+ # Print member name header once when they start
804
+ print(f"\n[{member_name}] ", end='', flush=True)
805
+
806
+ # Track that this member is now actively streaming
807
+ active_streaming_member = member_name
808
+
809
+ if hasattr(event, 'content') and event.content:
810
+ content = str(event.content)
811
+ # Print content without the repeated member name prefix
812
+ print(content, end='', flush=True)
813
+
814
+ # Stream member chunk to Control Plane
815
+ control_plane.publish_event(
816
+ execution_id=input.execution_id,
817
+ event_type="member_message_chunk",
818
+ data={
819
+ "role": "assistant",
820
+ "content": content,
821
+ "is_chunk": True,
822
+ "message_id": member_message_ids[member_name],
823
+ "source": "team_member",
824
+ "member_name": member_name,
825
+ }
826
+ )
827
+
828
+ # Handle tool calls (team leader or members)
829
+ elif event_type in ["TeamToolCallStarted", "ToolCallStarted"]:
830
+ # Extract tool name properly (event.tool might be a ToolExecution object)
831
+ tool_obj = getattr(event, 'tool', None)
832
+ if tool_obj and hasattr(tool_obj, 'tool_name'):
833
+ # It's a ToolExecution object
834
+ tool_name = tool_obj.tool_name
835
+ tool_args = getattr(tool_obj, 'tool_args', {})
836
+ else:
837
+ # Fallback to string name
838
+ tool_name = str(tool_obj) if tool_obj else getattr(event, 'tool_name', 'unknown')
839
+ tool_args = {}
840
+
841
+ is_member_tool = event_type == "ToolCallStarted"
842
+ member_name = getattr(event, 'agent_name', getattr(event, 'member_name', None)) if is_member_tool else None
843
+
844
+ # Generate unique tool_execution_id and message_id
845
+ tool_execution_id = f"{tool_name}_{int(time.time() * 1000000)}"
846
+ message_id = f"{input.execution_id}_tool_{tool_execution_id}"
847
+
848
+ # Store the tool_execution_id so we can match it with the completion event
849
+ # Use a composite key to handle multiple tools with same name
850
+ tool_key = f"{member_name or 'leader'}_{tool_name}_{int(time.time())}"
851
+ tool_execution_ids[tool_key] = {
852
+ "tool_execution_id": tool_execution_id,
853
+ "message_id": message_id,
854
+ "tool_name": tool_name,
855
+ "member_name": member_name,
856
+ "parent_message_id": member_message_ids.get(member_name) if is_member_tool and member_name else None,
857
+ }
858
+
859
+ print(f"\n šŸ”§ Tool Starting: {tool_name} (ID: {tool_execution_id})")
860
+ if tool_args:
861
+ args_preview = str(tool_args)[:200]
862
+ print(f" Args: {args_preview}{'...' if len(str(tool_args)) > 200 else ''}")
863
+
864
+ control_plane.publish_event(
865
+ execution_id=input.execution_id,
866
+ event_type="tool_started" if not is_member_tool else "member_tool_started",
867
+ data={
868
+ "tool_name": tool_name,
869
+ "tool_execution_id": tool_execution_id,
870
+ "message_id": message_id,
871
+ "tool_arguments": tool_args if tool_args else None,
872
+ "source": "team_member" if is_member_tool else "team_leader",
873
+ "member_name": member_name,
874
+ "parent_message_id": member_message_ids.get(member_name) if is_member_tool and member_name else None,
875
+ "message": f"šŸ”§ Executing tool: {tool_name}",
876
+ }
877
+ )
878
+
879
+ elif event_type in ["TeamToolCallCompleted", "ToolCallCompleted", "TeamToolCallFailed", "ToolCallFailed"]:
880
+ # Extract tool name properly (event.tool might be a ToolExecution object)
881
+ tool_obj = getattr(event, 'tool', None)
882
+ if tool_obj and hasattr(tool_obj, 'tool_name'):
883
+ # It's a ToolExecution object
884
+ tool_name = tool_obj.tool_name
885
+ tool_output = getattr(tool_obj, 'result', None) or getattr(event, 'result', None)
886
+ else:
887
+ # Fallback to string name
888
+ tool_name = str(tool_obj) if tool_obj else getattr(event, 'tool_name', 'unknown')
889
+ tool_output = getattr(event, 'result', None)
890
+
891
+ is_member_tool = event_type in ["ToolCallCompleted", "ToolCallFailed"]
892
+ member_name = getattr(event, 'agent_name', getattr(event, 'member_name', None)) if is_member_tool else None
893
+
894
+ # Determine if this is a failure event
895
+ is_failure = event_type in ["TeamToolCallFailed", "ToolCallFailed"]
896
+ tool_error = getattr(event, 'error', None) if is_failure else None
897
+
898
+ # Find the stored tool info from the start event
899
+ tool_key_pattern = f"{member_name or 'leader'}_{tool_name}"
900
+ matching_tool = None
901
+ for key, tool_info in list(tool_execution_ids.items()):
902
+ if key.startswith(tool_key_pattern):
903
+ matching_tool = tool_info
904
+ # Remove from tracking dict
905
+ del tool_execution_ids[key]
906
+ break
907
+
908
+ if matching_tool:
909
+ tool_execution_id = matching_tool["tool_execution_id"]
910
+ message_id = matching_tool["message_id"]
911
+ parent_message_id = matching_tool["parent_message_id"]
912
+ else:
913
+ # Fallback if start event wasn't captured
914
+ tool_execution_id = f"{tool_name}_{int(time.time() * 1000000)}"
915
+ message_id = f"{input.execution_id}_tool_{tool_execution_id}"
916
+ parent_message_id = member_message_ids.get(member_name) if is_member_tool and member_name else None
917
+ print(f" āš ļø Warning: Tool completion without matching start event: {tool_name}")
918
+
919
+ status = "failed" if is_failure else "success"
920
+ icon = "āŒ" if is_failure else "āœ…"
921
+ print(f"\n {icon} Tool {status.capitalize()}: {tool_name}")
922
+ if tool_error:
923
+ print(f" Error: {str(tool_error)[:200]}")
924
+
925
+ control_plane.publish_event(
926
+ execution_id=input.execution_id,
927
+ event_type="tool_completed" if not is_member_tool else "member_tool_completed",
928
+ data={
929
+ "tool_name": tool_name,
930
+ "tool_execution_id": tool_execution_id,
931
+ "message_id": message_id,
932
+ "status": status,
933
+ "tool_output": str(tool_output)[:1000] if tool_output else None, # Limit output size
934
+ "tool_error": str(tool_error) if tool_error else None,
935
+ "source": "team_member" if is_member_tool else "team_leader",
936
+ "member_name": member_name,
937
+ "parent_message_id": parent_message_id,
938
+ "message": f"{icon} Tool {status}: {tool_name}",
939
+ }
940
+ )
941
+
942
+ # Rotate message_id after tool completion so subsequent responses are grouped separately
943
+ # This helps the UI show responses before and after tool execution as distinct sections
944
+ if is_member_tool and member_name and member_name in member_message_ids:
945
+ # Mark the previous message_id as complete before rotating
946
+ old_message_id = member_message_ids[member_name]
947
+ control_plane.publish_event(
948
+ execution_id=input.execution_id,
949
+ event_type="member_message_complete",
950
+ data={
951
+ "message_id": old_message_id,
952
+ "member_name": member_name,
953
+ "source": "team_member",
954
+ }
955
+ )
956
+
957
+ # Generate new message_id for this member's next response
958
+ member_message_ids[member_name] = f"{input.execution_id}_{member_name}_{int(time.time() * 1000000)}"
959
+ print(f" šŸ”„ Rotated message_id for {member_name}")
960
+
961
+ # Handle reasoning events (if model supports reasoning)
962
+ elif event_type in ["TeamReasoningStep", "ReasoningStep"]:
963
+ if hasattr(event, 'content') and event.content:
964
+ reasoning_content = str(event.content)
965
+ is_member = event_type == "ReasoningStep"
966
+ member_name = getattr(event, 'agent_name', getattr(event, 'member_name', None)) if is_member else None
967
+
968
+ print(f"\n šŸ’­ {'[' + member_name + '] ' if member_name else ''}Reasoning: {reasoning_content[:100]}...")
969
+
970
+ control_plane.publish_event(
971
+ execution_id=input.execution_id,
972
+ event_type="reasoning_step",
973
+ data={
974
+ "content": reasoning_content,
975
+ "source": "team_member" if is_member else "team_leader",
976
+ "member_name": member_name,
977
+ }
978
+ )
979
+
980
+ # Mark any remaining active member as complete (stream ended)
981
+ if active_streaming_member and active_streaming_member in member_message_ids:
982
+ control_plane.publish_event(
983
+ execution_id=input.execution_id,
984
+ event_type="member_message_complete",
985
+ data={
986
+ "message_id": member_message_ids[active_streaming_member],
987
+ "member_name": active_streaming_member,
988
+ "source": "team_member",
989
+ }
990
+ )
991
+ print(f"\n āœ“ {active_streaming_member} completed")
992
+
993
+ print() # New line after streaming
994
+
995
+ # Return the iterator's final result
996
+ return run_response
997
+ except Exception as e:
998
+ print(f"\nāŒ Streaming error: {str(e)}")
999
+ import traceback
1000
+ traceback.print_exc()
1001
+ # Fall back to non-streaming
1002
+ run_kwargs_fallback = {
1003
+ "stream": False,
1004
+ "session_id": input.session_id or input.execution_id,
1005
+ }
1006
+ if input.user_id:
1007
+ run_kwargs_fallback["user_id"] = input.user_id
1008
+ if conversation_context:
1009
+ run_kwargs_fallback["messages"] = conversation_context
1010
+ return team.run(input.prompt, **run_kwargs_fallback)
1011
+
1012
+ # Execute in thread pool (NO TIMEOUT - tasks can run as long as needed)
1013
+ # Control Plane can cancel via Agno's cancel_run API if user requests it
1014
+ result = await asyncio.to_thread(stream_team_run)
1015
+
1016
+ # Send heartbeat: Completed
1017
+ activity.heartbeat({"status": "Team execution completed, preparing response..."})
1018
+
1019
+ print("\nāœ… Team Execution Completed!")
1020
+ print(f" Response Length: {len(full_response)} chars")
1021
+
1022
+ activity.logger.info(
1023
+ f"Agno Team execution completed",
1024
+ extra={
1025
+ "execution_id": input.execution_id,
1026
+ "has_content": bool(full_response),
1027
+ }
1028
+ )
1029
+
1030
+ # Use the streamed response content
1031
+ response_content = full_response if full_response else (result.content if hasattr(result, "content") else str(result))
1032
+
1033
+ # Extract tool call messages for UI streaming
1034
+ tool_messages = []
1035
+ if hasattr(result, "messages") and result.messages:
1036
+ for msg in result.messages:
1037
+ # Check if message has tool calls
1038
+ if hasattr(msg, "tool_calls") and msg.tool_calls:
1039
+ for tool_call in msg.tool_calls:
1040
+ tool_name = getattr(tool_call, "function", {}).get("name") if hasattr(tool_call, "function") else str(tool_call)
1041
+ tool_args = getattr(tool_call, "function", {}).get("arguments") if hasattr(tool_call, "function") else {}
1042
+
1043
+ print(f" šŸ”§ Tool Call: {tool_name}")
1044
+
1045
+ tool_messages.append({
1046
+ "role": "tool",
1047
+ "content": f"Executing {tool_name}...",
1048
+ "tool_name": tool_name,
1049
+ "tool_input": tool_args,
1050
+ "timestamp": datetime.now(timezone.utc).isoformat(),
1051
+ })
1052
+
1053
+ if tool_messages:
1054
+ print(f"\nšŸ”§ Tool Calls Captured: {len(tool_messages)}")
1055
+
1056
+ # Extract usage metrics if available
1057
+ usage = {}
1058
+ if hasattr(result, "metrics") and result.metrics:
1059
+ metrics = result.metrics
1060
+ usage = {
1061
+ "input_tokens": getattr(metrics, "input_tokens", 0),
1062
+ "output_tokens": getattr(metrics, "output_tokens", 0),
1063
+ "total_tokens": getattr(metrics, "total_tokens", 0),
1064
+ }
1065
+ print(f"\nšŸ“Š Token Usage:")
1066
+ print(f" Input Tokens: {usage.get('input_tokens', 0)}")
1067
+ print(f" Output Tokens: {usage.get('output_tokens', 0)}")
1068
+ print(f" Total Tokens: {usage.get('total_tokens', 0)}")
1069
+
1070
+ print(f"\nšŸ“ Response Preview:")
1071
+ print(f" {response_content[:200]}..." if len(response_content) > 200 else f" {response_content}")
1072
+
1073
+ # CRITICAL: Persist COMPLETE session history to Control Plane API
1074
+ # This includes previous history + current turn for conversation continuity
1075
+ print("\nšŸ’¾ Persisting session history to Control Plane...")
1076
+ try:
1077
+ # Build complete session: previous history + current turn's messages
1078
+ updated_session_messages = list(session_history) # Start with loaded history
1079
+
1080
+ # Add current turn messages (user prompt + assistant response)
1081
+ # Streaming results don't have result.messages, so we manually build them
1082
+ current_turn_messages = [
1083
+ {
1084
+ "role": "user",
1085
+ "content": input.prompt,
1086
+ "timestamp": datetime.now(timezone.utc).isoformat(),
1087
+ "user_id": input.user_id,
1088
+ "user_name": getattr(input, "user_name", None),
1089
+ "user_email": getattr(input, "user_email", None),
1090
+ },
1091
+ {
1092
+ "role": "assistant",
1093
+ "content": response_content,
1094
+ "timestamp": datetime.now(timezone.utc).isoformat(),
1095
+ }
1096
+ ]
1097
+
1098
+ print(f" šŸ“ Adding {len(current_turn_messages)} messages from current turn (user + assistant)...")
1099
+ updated_session_messages.extend(current_turn_messages)
1100
+
1101
+ if updated_session_messages:
1102
+ success = control_plane.persist_session(
1103
+ execution_id=input.execution_id,
1104
+ session_id=input.session_id or input.execution_id,
1105
+ user_id=input.user_id,
1106
+ messages=updated_session_messages, # Complete conversation history
1107
+ metadata={
1108
+ "team_id": input.team_id,
1109
+ "organization_id": input.organization_id,
1110
+ "turn_count": len(updated_session_messages),
1111
+ }
1112
+ )
1113
+
1114
+ if success:
1115
+ print(f" āœ… Complete session history persisted ({len(updated_session_messages)} total messages)")
1116
+ else:
1117
+ print(f" āš ļø Session persistence failed")
1118
+ else:
1119
+ print(" ā„¹ļø No messages - skipping session persistence")
1120
+
1121
+ except Exception as session_error:
1122
+ print(f" āš ļø Session persistence error: {str(session_error)}")
1123
+ logger.warning("session_persistence_error", error=str(session_error), execution_id=input.execution_id)
1124
+ # Don't fail the execution if session persistence fails
1125
+
1126
+ print("\n" + "="*80)
1127
+ print("šŸ TEAM EXECUTION END")
1128
+ print("="*80 + "\n")
1129
+
1130
+ # Cleanup: Remove team from registry
1131
+ if input.execution_id in _active_teams:
1132
+ del _active_teams[input.execution_id]
1133
+ print(f"āœ… Team unregistered (execution_id: {input.execution_id})\n")
1134
+
1135
+ # Database persists across session for conversation history
1136
+ print(f"šŸ’¾ Database persisted for future turns: {db_path}\n")
1137
+
1138
+ return {
1139
+ "success": True,
1140
+ "response": response_content,
1141
+ "usage": usage,
1142
+ "coordination_type": "agno_team",
1143
+ "tool_messages": tool_messages, # Include tool call messages for UI
1144
+ "tool_execution_messages": tool_execution_messages, # Include real-time tool execution status
1145
+ }
1146
+
1147
+ except Exception as e:
1148
+ # Cleanup on error
1149
+ if input.execution_id in _active_teams:
1150
+ del _active_teams[input.execution_id]
1151
+
1152
+ # Database persists even on error for potential recovery and history
1153
+ print(f"šŸ’¾ Database preserved for future turns despite error\n")
1154
+
1155
+ print("\n" + "="*80)
1156
+ print("āŒ TEAM EXECUTION FAILED")
1157
+ print("="*80)
1158
+ print(f"Error: {str(e)}")
1159
+ print("="*80 + "\n")
1160
+
1161
+ activity.logger.error(
1162
+ f"Team coordination failed",
1163
+ extra={
1164
+ "execution_id": input.execution_id,
1165
+ "error": str(e),
1166
+ }
1167
+ )
1168
+ return {
1169
+ "success": False,
1170
+ "error": str(e),
1171
+ "coordination_type": "agno_team",
1172
+ "usage": {},
1173
+ }
1174
+
1175
+
1176
+ @dataclass
1177
+ class ActivityCancelTeamInput:
1178
+ execution_id: str
1179
+
1180
+
1181
+ @activity.defn(name="cancel_team_run")
1182
+ async def cancel_team_run(input: ActivityCancelTeamInput) -> dict:
1183
+ """Cancel an active team run using Agno's cancel_run API."""
1184
+ print("\n" + "="*80)
1185
+ print("šŸ›‘ CANCEL TEAM RUN")
1186
+ print("="*80)
1187
+ print(f"Execution ID: {input.execution_id}\n")
1188
+
1189
+ try:
1190
+ if input.execution_id not in _active_teams:
1191
+ print(f"āš ļø Team not found in registry - may have already completed")
1192
+ return {"success": False, "error": "Team not found or already completed", "execution_id": input.execution_id}
1193
+
1194
+ team_info = _active_teams[input.execution_id]
1195
+ team = team_info["team"]
1196
+ run_id = team_info.get("run_id")
1197
+
1198
+ if not run_id:
1199
+ print(f"āš ļø No run_id found - execution may not have started yet")
1200
+ return {"success": False, "error": "Execution not started yet", "execution_id": input.execution_id}
1201
+
1202
+ print(f"šŸ†” Found run_id: {run_id}")
1203
+ print(f"šŸ›‘ Calling team.cancel_run()...")
1204
+
1205
+ success = team.cancel_run(run_id)
1206
+
1207
+ if success:
1208
+ print(f"āœ… Team run cancelled successfully!\n")
1209
+ del _active_teams[input.execution_id]
1210
+ return {"success": True, "execution_id": input.execution_id, "run_id": run_id, "cancelled_at": datetime.now(timezone.utc).isoformat()}
1211
+ else:
1212
+ print(f"āš ļø Cancel failed - run may have already completed\n")
1213
+ return {"success": False, "error": "Cancel failed - run may be completed", "execution_id": input.execution_id, "run_id": run_id}
1214
+
1215
+ except Exception as e:
1216
+ print(f"āŒ Error cancelling run: {str(e)}\n")
1217
+ return {"success": False, "error": str(e), "execution_id": input.execution_id}