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,1400 @@
1
+ from fastapi import APIRouter, Depends, HTTPException, status, Request
2
+ from fastapi.responses import StreamingResponse
3
+ from sqlalchemy.orm import Session
4
+ from sqlalchemy.exc import IntegrityError
5
+ from typing import List, Union, Dict, Any, Optional
6
+ from datetime import datetime
7
+ from enum import Enum
8
+ import structlog
9
+ import uuid
10
+
11
+ from control_plane_api.app.database import get_db
12
+ from control_plane_api.app.models.team import Team
13
+ from control_plane_api.app.models.agent import Agent
14
+ from control_plane_api.app.middleware.auth import get_current_organization
15
+ from control_plane_api.app.lib.supabase import get_supabase
16
+ from control_plane_api.app.lib.temporal_client import get_temporal_client
17
+ from control_plane_api.app.workflows.agent_execution import AgentExecutionWorkflow, TeamExecutionInput
18
+ from control_plane_api.app.workflows.team_execution import TeamExecutionWorkflow
19
+ from control_plane_api.app.routers.projects import get_default_project_id
20
+ from control_plane_api.app.routers.agents_v2 import ExecutionEnvironment
21
+ from pydantic import BaseModel, Field
22
+
23
+ logger = structlog.get_logger()
24
+
25
+ router = APIRouter()
26
+
27
+
28
+ # Team status enum
29
+ class TeamStatus(str, Enum):
30
+ """Team status enumeration"""
31
+ ACTIVE = "active"
32
+ INACTIVE = "inactive"
33
+ ARCHIVED = "archived"
34
+
35
+
36
+ def get_entity_skills(client, organization_id: str, entity_type: str, entity_id: str) -> List[dict]:
37
+ """Get skills associated with an entity"""
38
+ # Get associations
39
+ result = (
40
+ client.table("skill_associations")
41
+ .select("skill_id, configuration_override, skills(*)")
42
+ .eq("organization_id", organization_id)
43
+ .eq("entity_type", entity_type)
44
+ .eq("entity_id", entity_id)
45
+ .execute()
46
+ )
47
+
48
+ skills = []
49
+ for item in result.data:
50
+ skill_data = item.get("skills")
51
+ if skill_data and skill_data.get("enabled", True):
52
+ # Merge configuration with override
53
+ config = skill_data.get("configuration", {})
54
+ override = item.get("configuration_override")
55
+ if override:
56
+ config = {**config, **override}
57
+
58
+ skills.append({
59
+ "id": skill_data["id"],
60
+ "name": skill_data["name"],
61
+ "type": skill_data["skill_type"],
62
+ "description": skill_data.get("description"),
63
+ "enabled": skill_data.get("enabled", True),
64
+ "configuration": config,
65
+ })
66
+
67
+ return skills
68
+
69
+
70
+ def get_team_projects(db, team_id: str) -> list[dict]:
71
+ """Get all projects a team belongs to"""
72
+ try:
73
+ from control_plane_api.app.lib.supabase import get_supabase
74
+ client = get_supabase()
75
+
76
+ # Query project_teams join table
77
+ result = (
78
+ client.table("project_teams")
79
+ .select("project_id, projects(id, name, key, description)")
80
+ .eq("team_id", team_id)
81
+ .execute()
82
+ )
83
+
84
+ projects = []
85
+ for item in result.data:
86
+ project_data = item.get("projects")
87
+ if project_data:
88
+ projects.append({
89
+ "id": project_data["id"],
90
+ "name": project_data["name"],
91
+ "key": project_data["key"],
92
+ "description": project_data.get("description"),
93
+ })
94
+
95
+ return projects
96
+ except Exception as e:
97
+ logger.warning("failed_to_fetch_team_projects", error=str(e), team_id=team_id)
98
+ return []
99
+
100
+
101
+ # Enhanced Pydantic schemas aligned with Agno Team capabilities
102
+
103
+ class ReasoningConfig(BaseModel):
104
+ """Reasoning configuration for the team"""
105
+ enabled: bool = Field(False, description="Enable reasoning for the team")
106
+ model: Optional[str] = Field(None, description="Model to use for reasoning")
107
+ agent_id: Optional[str] = Field(None, description="Agent ID to use for reasoning")
108
+ min_steps: Optional[int] = Field(1, description="Minimum reasoning steps", ge=1)
109
+ max_steps: Optional[int] = Field(10, description="Maximum reasoning steps", ge=1, le=100)
110
+
111
+
112
+ class LLMConfig(BaseModel):
113
+ """LLM configuration for the team"""
114
+ model: Optional[str] = Field(None, description="Default model for the team")
115
+ temperature: Optional[float] = Field(None, description="Temperature for generation", ge=0.0, le=2.0)
116
+ max_tokens: Optional[int] = Field(None, description="Maximum tokens to generate", ge=1)
117
+ top_p: Optional[float] = Field(None, description="Top-p sampling", ge=0.0, le=1.0)
118
+ top_k: Optional[int] = Field(None, description="Top-k sampling", ge=0)
119
+ stop: Optional[List[str]] = Field(None, description="Stop sequences")
120
+ frequency_penalty: Optional[float] = Field(None, description="Frequency penalty", ge=-2.0, le=2.0)
121
+ presence_penalty: Optional[float] = Field(None, description="Presence penalty", ge=-2.0, le=2.0)
122
+
123
+
124
+ class SessionConfig(BaseModel):
125
+ """Session configuration for the team"""
126
+ user_id: Optional[str] = Field(None, description="User ID for the session")
127
+ session_id: Optional[str] = Field(None, description="Session ID")
128
+ auto_save: bool = Field(True, description="Auto-save session state")
129
+ persist: bool = Field(True, description="Persist session across runs")
130
+
131
+
132
+ class TeamConfiguration(BaseModel):
133
+ """
134
+ Comprehensive team configuration aligned with Agno's Team capabilities.
135
+ This allows full control over team behavior, reasoning, tools, and LLM settings.
136
+ """
137
+ # Members
138
+ member_ids: List[str] = Field(default_factory=list, description="List of agent IDs in the team")
139
+
140
+ # Instructions
141
+ instructions: Union[str, List[str]] = Field(
142
+ default="",
143
+ description="Instructions for the team - can be a single string or list of instructions"
144
+ )
145
+
146
+ # Reasoning
147
+ reasoning: Optional[ReasoningConfig] = Field(None, description="Reasoning configuration")
148
+
149
+ # LLM Configuration
150
+ llm: Optional[LLMConfig] = Field(None, description="LLM configuration for the team")
151
+
152
+ # Tools & Knowledge
153
+ tools: List[Dict[str, Any]] = Field(
154
+ default_factory=list,
155
+ description="Tools available to the team - list of tool configurations"
156
+ )
157
+ knowledge_base: Optional[Dict[str, Any]] = Field(
158
+ None,
159
+ description="Knowledge base configuration (vector store, embeddings, etc.)"
160
+ )
161
+
162
+ # Session & State
163
+ session: Optional[SessionConfig] = Field(None, description="Session configuration")
164
+ dependencies: Dict[str, Any] = Field(
165
+ default_factory=dict,
166
+ description="External dependencies (databases, APIs, services)"
167
+ )
168
+
169
+ # Advanced Options
170
+ markdown: bool = Field(True, description="Enable markdown formatting in responses")
171
+ add_datetime_to_instructions: bool = Field(
172
+ False,
173
+ description="Automatically add current datetime to instructions"
174
+ )
175
+ structured_outputs: bool = Field(False, description="Enable structured outputs")
176
+ response_model: Optional[str] = Field(None, description="Response model schema name")
177
+
178
+ # Monitoring & Debugging
179
+ debug_mode: bool = Field(False, description="Enable debug mode with verbose logging")
180
+ monitoring: bool = Field(False, description="Enable monitoring and telemetry")
181
+
182
+ # Custom Metadata
183
+ metadata: Dict[str, Any] = Field(
184
+ default_factory=dict,
185
+ description="Additional custom metadata for the team"
186
+ )
187
+
188
+
189
+ class TeamCreate(BaseModel):
190
+ """Create a new team with full Agno capabilities"""
191
+ name: str = Field(..., description="Team name", min_length=1, max_length=255)
192
+ description: Optional[str] = Field(None, description="Team description")
193
+ configuration: TeamConfiguration = Field(
194
+ default_factory=TeamConfiguration,
195
+ description="Team configuration aligned with Agno Team"
196
+ )
197
+ skill_ids: list[str] = Field(default_factory=list, description="Tool set IDs to associate with this team")
198
+ skill_configurations: dict[str, dict] = Field(default_factory=dict, description="Tool set configurations keyed by skill ID")
199
+ execution_environment: ExecutionEnvironment | None = Field(None, description="Execution environment: env vars, secrets, integrations")
200
+
201
+
202
+ class TeamUpdate(BaseModel):
203
+ """Update an existing team"""
204
+ name: Optional[str] = Field(None, description="Team name", min_length=1, max_length=255)
205
+ description: Optional[str] = Field(None, description="Team description")
206
+ status: Optional[TeamStatus] = Field(None, description="Team status")
207
+ runtime: Optional[str] = Field(None, description="Runtime type: 'default' (Agno) or 'claude_code' (Claude Code SDK)")
208
+ configuration: Optional[TeamConfiguration] = Field(None, description="Team configuration")
209
+ skill_ids: list[str] | None = None
210
+ skill_configurations: dict[str, dict] | None = None
211
+ environment_ids: list[str] | None = None
212
+ execution_environment: ExecutionEnvironment | None = None
213
+
214
+
215
+ class TeamResponse(BaseModel):
216
+ """Team response with structured configuration"""
217
+ id: str
218
+ organization_id: str
219
+ name: str
220
+ description: Optional[str]
221
+ status: TeamStatus
222
+ configuration: TeamConfiguration
223
+ created_at: datetime
224
+ updated_at: datetime
225
+ projects: List[dict] = Field(default_factory=list, description="Projects this team belongs to")
226
+ skill_ids: Optional[List[str]] = Field(default_factory=list, description="IDs of associated skills")
227
+ skills: Optional[List[dict]] = Field(default_factory=list, description="Associated skills with details")
228
+ execution_environment: ExecutionEnvironment | None = None
229
+
230
+ class Config:
231
+ from_attributes = True
232
+
233
+
234
+ class TeamWithAgentsResponse(TeamResponse):
235
+ """Team response including member agents"""
236
+ agents: List[dict]
237
+
238
+
239
+ class TeamExecutionRequest(BaseModel):
240
+ prompt: str = Field(..., description="The prompt/task to execute")
241
+ system_prompt: str | None = Field(None, description="Optional system prompt for team coordination")
242
+ stream: bool = Field(False, description="Whether to stream the response")
243
+ worker_queue_id: str = Field(..., description="Worker queue ID (UUID) to route execution to - REQUIRED")
244
+ user_metadata: dict | None = Field(None, description="User attribution metadata (optional, auto-filled from token)")
245
+
246
+
247
+ class TeamExecutionResponse(BaseModel):
248
+ execution_id: str
249
+ workflow_id: str
250
+ status: str
251
+ message: str
252
+
253
+
254
+ @router.post("", response_model=TeamResponse, status_code=status.HTTP_201_CREATED)
255
+ def create_team(
256
+ team_data: TeamCreate,
257
+ request: Request,
258
+ db: Session = Depends(get_db),
259
+ organization: dict = Depends(get_current_organization),
260
+ ):
261
+ """
262
+ Create a new team with full Agno capabilities.
263
+
264
+ Supports comprehensive configuration including:
265
+ - Member agents
266
+ - Instructions and reasoning
267
+ - Tools and knowledge bases
268
+ - LLM settings
269
+ - Session management
270
+ """
271
+ try:
272
+ logger.info(
273
+ "create_team_request",
274
+ team_name=team_data.name,
275
+ org_id=organization["id"],
276
+ org_name=organization.get("name"),
277
+ member_count=len(team_data.configuration.member_ids) if team_data.configuration.member_ids else 0,
278
+ skill_count=len(team_data.skill_ids) if team_data.skill_ids else 0,
279
+ )
280
+
281
+ # Check if team name already exists in this organization
282
+ existing_team = db.query(Team).filter(
283
+ Team.name == team_data.name,
284
+ Team.organization_id == organization["id"]
285
+ ).first()
286
+ if existing_team:
287
+ logger.warning(
288
+ "team_name_already_exists",
289
+ team_name=team_data.name,
290
+ org_id=organization["id"],
291
+ )
292
+ raise HTTPException(status_code=400, detail="Team with this name already exists in your organization")
293
+
294
+ # Validate member_ids if provided
295
+ if team_data.configuration.member_ids:
296
+ client = get_supabase()
297
+ logger.info(
298
+ "validating_team_members",
299
+ member_ids=team_data.configuration.member_ids,
300
+ org_id=organization["id"],
301
+ )
302
+ for agent_id in team_data.configuration.member_ids:
303
+ try:
304
+ # Query Supabase instead of local DB for consistency with GET /agents
305
+ result = (
306
+ client.table("agents")
307
+ .select("id")
308
+ .eq("id", agent_id)
309
+ .eq("organization_id", organization["id"])
310
+ .maybe_single()
311
+ .execute()
312
+ )
313
+
314
+ logger.debug(
315
+ "agent_validation_result",
316
+ agent_id=agent_id,
317
+ result_type=type(result).__name__,
318
+ has_data=hasattr(result, 'data') if result else False,
319
+ )
320
+
321
+ if not result or not hasattr(result, 'data') or not result.data:
322
+ logger.warning(
323
+ "agent_not_found",
324
+ agent_id=agent_id,
325
+ org_id=organization["id"],
326
+ result_is_none=result is None,
327
+ )
328
+ raise HTTPException(
329
+ status_code=400,
330
+ detail=f"Agent with ID '{agent_id}' not found. Please create the agent first."
331
+ )
332
+ except HTTPException:
333
+ raise
334
+ except Exception as e:
335
+ logger.error(
336
+ "agent_validation_failed",
337
+ agent_id=agent_id,
338
+ error=str(e),
339
+ error_type=type(e).__name__,
340
+ org_id=organization["id"],
341
+ )
342
+ raise HTTPException(
343
+ status_code=500,
344
+ detail=f"Failed to validate agent '{agent_id}': {str(e)}"
345
+ )
346
+
347
+ # Convert TeamConfiguration to dict for JSON storage
348
+ configuration_dict = team_data.configuration.model_dump(exclude_none=True)
349
+
350
+ team = Team(
351
+ organization_id=organization["id"],
352
+ name=team_data.name,
353
+ description=team_data.description,
354
+ configuration=configuration_dict,
355
+ skill_ids=team_data.skill_ids,
356
+ execution_environment=team_data.execution_environment.model_dump() if team_data.execution_environment else {},
357
+ )
358
+ db.add(team)
359
+ db.commit()
360
+ db.refresh(team)
361
+
362
+ logger.info(
363
+ "team_created",
364
+ team_id=str(team.id),
365
+ team_name=team.name,
366
+ org_id=organization["id"],
367
+ )
368
+ except HTTPException:
369
+ raise
370
+ except IntegrityError as e:
371
+ db.rollback()
372
+ logger.error(
373
+ "database_integrity_error",
374
+ error=str(e),
375
+ team_name=team_data.name,
376
+ org_id=organization["id"],
377
+ )
378
+ raise HTTPException(
379
+ status_code=400,
380
+ detail=f"Database constraint violation: {str(e.orig) if hasattr(e, 'orig') else str(e)}"
381
+ )
382
+ except Exception as e:
383
+ db.rollback()
384
+ logger.error(
385
+ "team_creation_failed",
386
+ error=str(e),
387
+ error_type=type(e).__name__,
388
+ team_name=team_data.name,
389
+ org_id=organization["id"],
390
+ )
391
+ raise HTTPException(
392
+ status_code=500,
393
+ detail=f"Failed to create team: {str(e)}"
394
+ )
395
+
396
+ # Sync agent.team_id relationship for initial members in Supabase
397
+ if team_data.configuration.member_ids:
398
+ client = get_supabase()
399
+ for agent_id in team_data.configuration.member_ids:
400
+ try:
401
+ client.table("agents").update({"team_id": str(team.id)}).eq("id", agent_id).execute()
402
+ except Exception as e:
403
+ logger.warning(
404
+ "failed_to_sync_agent_team_id",
405
+ error=str(e),
406
+ agent_id=agent_id,
407
+ team_id=str(team.id)
408
+ )
409
+
410
+ # Automatically assign team to the default project
411
+ default_project_id = get_default_project_id(organization)
412
+ if default_project_id:
413
+ try:
414
+ client = get_supabase()
415
+ project_team_record = {
416
+ "id": str(uuid.uuid4()),
417
+ "project_id": default_project_id,
418
+ "team_id": str(team.id),
419
+ "role": None,
420
+ "added_at": datetime.utcnow().isoformat(),
421
+ "added_by": organization.get("user_id"),
422
+ }
423
+ client.table("project_teams").insert(project_team_record).execute()
424
+ logger.info(
425
+ "team_added_to_default_project",
426
+ team_id=str(team.id),
427
+ project_id=default_project_id,
428
+ org_id=organization["id"]
429
+ )
430
+ except Exception as e:
431
+ logger.warning(
432
+ "failed_to_add_team_to_default_project",
433
+ error=str(e),
434
+ team_id=str(team.id),
435
+ org_id=organization["id"]
436
+ )
437
+
438
+ # Create skill associations if skills were provided
439
+ if team_data.skill_ids:
440
+ try:
441
+ client = get_supabase()
442
+ now = datetime.utcnow().isoformat()
443
+
444
+ for skill_id in team_data.skill_ids:
445
+ association_id = str(uuid.uuid4())
446
+ config_override = team_data.skill_configurations.get(skill_id, {})
447
+
448
+ association_record = {
449
+ "id": association_id,
450
+ "organization_id": organization["id"],
451
+ "skill_id": skill_id,
452
+ "entity_type": "team",
453
+ "entity_id": str(team.id),
454
+ "configuration_override": config_override,
455
+ "created_at": now,
456
+ }
457
+
458
+ client.table("skill_associations").insert(association_record).execute()
459
+
460
+ logger.info(
461
+ "team_skills_associated",
462
+ team_id=str(team.id),
463
+ skill_count=len(team_data.skill_ids),
464
+ org_id=organization["id"]
465
+ )
466
+ except Exception as e:
467
+ logger.warning(
468
+ "failed_to_associate_team_skills",
469
+ error=str(e),
470
+ team_id=str(team.id),
471
+ org_id=organization["id"]
472
+ )
473
+
474
+ # Parse configuration back to TeamConfiguration for response
475
+ response_team = TeamResponse(
476
+ id=str(team.id),
477
+ organization_id=team.organization_id,
478
+ name=team.name,
479
+ description=team.description,
480
+ status=team.status,
481
+ configuration=TeamConfiguration(**team.configuration),
482
+ created_at=team.created_at,
483
+ updated_at=team.updated_at,
484
+ projects=get_team_projects(db, str(team.id)),
485
+ )
486
+ return response_team
487
+
488
+
489
+ @router.get("", response_model=List[TeamWithAgentsResponse])
490
+ def list_teams(
491
+ skip: int = 0,
492
+ limit: int = 100,
493
+ status_filter: Optional[TeamStatus] = None,
494
+ db: Session = Depends(get_db),
495
+ organization: dict = Depends(get_current_organization),
496
+ ):
497
+ """
498
+ List all teams with their configurations and member agents.
499
+
500
+ Supports filtering by status and pagination.
501
+ Only returns teams belonging to the current organization.
502
+ """
503
+ try:
504
+ query = db.query(Team).filter(Team.organization_id == organization["id"])
505
+ if status_filter:
506
+ query = query.filter(Team.status == status_filter)
507
+ teams = query.offset(skip).limit(limit).all()
508
+
509
+ if not teams:
510
+ return []
511
+
512
+ client = get_supabase()
513
+ team_ids = [str(team.id) for team in teams]
514
+
515
+ # BATCH 1: Fetch all projects for all teams in one query
516
+ try:
517
+ projects_result = (
518
+ client.table("project_teams")
519
+ .select("team_id, projects(id, name, key, description)")
520
+ .in_("team_id", team_ids)
521
+ .execute()
522
+ )
523
+ except Exception as project_error:
524
+ logger.error("failed_to_fetch_projects", error=str(project_error), org_id=organization["id"])
525
+ projects_result = type('obj', (object,), {'data': []}) # Empty result object
526
+
527
+ # Group projects by team_id
528
+ projects_by_team = {}
529
+ for item in projects_result.data or []:
530
+ team_id = item["team_id"]
531
+ project_data = item.get("projects")
532
+ if project_data:
533
+ if team_id not in projects_by_team:
534
+ projects_by_team[team_id] = []
535
+ projects_by_team[team_id].append({
536
+ "id": project_data["id"],
537
+ "name": project_data["name"],
538
+ "key": project_data["key"],
539
+ "description": project_data.get("description"),
540
+ })
541
+
542
+ # BATCH 2: Fetch all skill associations for all teams in one query
543
+ try:
544
+ skills_result = (
545
+ client.table("skill_associations")
546
+ .select("entity_id, skill_id, configuration_override, skills(*)")
547
+ .eq("organization_id", organization["id"])
548
+ .eq("entity_type", "team")
549
+ .in_("entity_id", team_ids)
550
+ .execute()
551
+ )
552
+ except Exception as skill_error:
553
+ logger.error("failed_to_fetch_skills", error=str(skill_error), org_id=organization["id"])
554
+ skills_result = type('obj', (object,), {'data': []}) # Empty result object
555
+
556
+ # Group skills by team_id
557
+ skills_by_team = {}
558
+ for item in skills_result.data or []:
559
+ team_id = item["entity_id"]
560
+ skill_data = item.get("skills")
561
+ if skill_data and skill_data.get("enabled", True):
562
+ if team_id not in skills_by_team:
563
+ skills_by_team[team_id] = []
564
+
565
+ # Merge configuration with override
566
+ config = skill_data.get("configuration", {})
567
+ override = item.get("configuration_override")
568
+ if override:
569
+ config = {**config, **override}
570
+
571
+ skills_by_team[team_id].append({
572
+ "id": skill_data["id"],
573
+ "name": skill_data["name"],
574
+ "type": skill_data["skill_type"],
575
+ "description": skill_data.get("description"),
576
+ "enabled": skill_data.get("enabled", True),
577
+ "configuration": config,
578
+ })
579
+
580
+ # BATCH 3: Collect all unique agent IDs from all teams
581
+ all_agent_ids = set()
582
+ for team in teams:
583
+ try:
584
+ team_config = TeamConfiguration(**(team.configuration or {}))
585
+ if team_config.member_ids:
586
+ all_agent_ids.update(team_config.member_ids)
587
+ except Exception as config_error:
588
+ logger.warning("failed_to_parse_team_config", error=str(config_error), team_id=str(team.id))
589
+
590
+ # Fetch all agents in one query
591
+ agents_by_id = {}
592
+ if all_agent_ids:
593
+ try:
594
+ db_agents = db.query(Agent).filter(Agent.id.in_(list(all_agent_ids))).all()
595
+ agents_by_id = {
596
+ str(agent.id): {
597
+ "id": str(agent.id),
598
+ "name": agent.name,
599
+ "status": agent.status,
600
+ "capabilities": agent.capabilities,
601
+ "description": agent.description,
602
+ }
603
+ for agent in db_agents
604
+ }
605
+ except Exception as agent_error:
606
+ logger.error("failed_to_fetch_agents", error=str(agent_error), org_id=organization["id"])
607
+
608
+ # Build response for each team
609
+ result = []
610
+ for team in teams:
611
+ try:
612
+ team_id = str(team.id)
613
+ team_config = TeamConfiguration(**(team.configuration or {}))
614
+
615
+ # Get agents for this team from the batched data
616
+ agents = []
617
+ if team_config.member_ids:
618
+ agents = [agents_by_id[agent_id] for agent_id in team_config.member_ids if agent_id in agents_by_id]
619
+
620
+ # Get skills from batched data
621
+ skills = skills_by_team.get(team_id, [])
622
+ skill_ids = [ts["id"] for ts in skills]
623
+
624
+ result.append(TeamWithAgentsResponse(
625
+ id=team_id,
626
+ organization_id=team.organization_id,
627
+ name=team.name,
628
+ description=team.description,
629
+ status=team.status,
630
+ configuration=team_config,
631
+ created_at=team.created_at,
632
+ updated_at=team.updated_at,
633
+ projects=projects_by_team.get(team_id, []),
634
+ agents=agents,
635
+ skill_ids=skill_ids,
636
+ skills=skills,
637
+ ))
638
+ except Exception as team_error:
639
+ logger.error("failed_to_build_team_response", error=str(team_error), team_id=str(team.id))
640
+ # Skip this team and continue with others
641
+
642
+ logger.info(
643
+ "teams_listed_successfully",
644
+ count=len(result),
645
+ org_id=organization["id"],
646
+ )
647
+
648
+ return result
649
+
650
+ except Exception as e:
651
+ logger.error(
652
+ "teams_list_failed",
653
+ error=str(e),
654
+ error_type=type(e).__name__,
655
+ org_id=organization["id"]
656
+ )
657
+ raise HTTPException(
658
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
659
+ detail=f"Failed to list teams: {str(e)}"
660
+ )
661
+
662
+
663
+ @router.get("/{team_id}", response_model=TeamWithAgentsResponse)
664
+ def get_team(
665
+ team_id: str,
666
+ db: Session = Depends(get_db),
667
+ organization: dict = Depends(get_current_organization),
668
+ ):
669
+ """
670
+ Get a specific team by ID with full configuration and member agents.
671
+
672
+ Returns the team with structured configuration and list of member agents.
673
+ Only returns teams belonging to the current organization.
674
+ """
675
+ team = db.query(Team).filter(
676
+ Team.id == team_id,
677
+ Team.organization_id == organization["id"]
678
+ ).first()
679
+ if not team:
680
+ raise HTTPException(status_code=404, detail="Team not found")
681
+
682
+ # Parse configuration
683
+ team_config = TeamConfiguration(**(team.configuration or {}))
684
+
685
+ # Get agents from configuration.member_ids (source of truth)
686
+ # instead of team.agents relationship to avoid ghost agents
687
+ member_ids = team_config.member_ids
688
+ agents = []
689
+ if member_ids:
690
+ # Query agents that actually exist in the database
691
+ db_agents = db.query(Agent).filter(Agent.id.in_(member_ids)).all()
692
+ agents = [
693
+ {
694
+ "id": str(agent.id),
695
+ "name": agent.name,
696
+ "status": agent.status,
697
+ "capabilities": agent.capabilities,
698
+ "description": agent.description,
699
+ }
700
+ for agent in db_agents
701
+ ]
702
+
703
+ # Get skills for this team
704
+ client = get_supabase()
705
+ skills = get_entity_skills(client, organization["id"], "team", team_id)
706
+ skill_ids = [ts["id"] for ts in skills]
707
+
708
+ # Include agents in response
709
+ return TeamWithAgentsResponse(
710
+ id=str(team.id),
711
+ organization_id=team.organization_id,
712
+ name=team.name,
713
+ description=team.description,
714
+ status=team.status,
715
+ configuration=team_config,
716
+ created_at=team.created_at,
717
+ updated_at=team.updated_at,
718
+ projects=get_team_projects(db, team_id),
719
+ agents=agents,
720
+ skill_ids=skill_ids,
721
+ skills=skills,
722
+ )
723
+
724
+
725
+ @router.patch("/{team_id}", response_model=TeamResponse)
726
+ def update_team(
727
+ team_id: str,
728
+ team_data: TeamUpdate,
729
+ db: Session = Depends(get_db),
730
+ organization: dict = Depends(get_current_organization),
731
+ ):
732
+ """
733
+ Update a team's configuration, name, description, or status.
734
+
735
+ Supports partial updates - only provided fields are updated.
736
+ Validates member_ids if configuration is being updated.
737
+ Only allows updating teams belonging to the current organization.
738
+ """
739
+ team = db.query(Team).filter(
740
+ Team.id == team_id,
741
+ Team.organization_id == organization["id"]
742
+ ).first()
743
+ if not team:
744
+ raise HTTPException(status_code=404, detail="Team not found")
745
+
746
+ update_data = team_data.model_dump(exclude_unset=True)
747
+
748
+ # Extract skill data before processing
749
+ skill_ids = update_data.pop("skill_ids", None)
750
+ skill_configurations = update_data.pop("skill_configurations", None)
751
+
752
+ # Extract environment data before processing (many-to-many via junction table)
753
+ environment_ids = update_data.pop("environment_ids", None)
754
+
755
+ # Handle execution_environment - convert to dict if present
756
+ if "execution_environment" in update_data and update_data["execution_environment"]:
757
+ if isinstance(update_data["execution_environment"], ExecutionEnvironment):
758
+ update_data["execution_environment"] = update_data["execution_environment"].model_dump()
759
+ # If None, keep as None to preserve existing value
760
+
761
+ logger.info(
762
+ "team_update_request",
763
+ team_id=team_id,
764
+ has_skill_ids=skill_ids is not None,
765
+ skill_count=len(skill_ids) if skill_ids else 0,
766
+ skill_ids=skill_ids,
767
+ )
768
+
769
+ # Check if name is being updated and if it already exists
770
+ if "name" in update_data and update_data["name"] != team.name:
771
+ existing_team = db.query(Team).filter(Team.name == update_data["name"]).first()
772
+ if existing_team:
773
+ raise HTTPException(status_code=400, detail="Team with this name already exists")
774
+
775
+ # Handle configuration update specially
776
+ if "configuration" in update_data and update_data["configuration"]:
777
+ new_config = update_data["configuration"]
778
+
779
+ # new_config is already a dict from model_dump(exclude_unset=True)
780
+ # Validate member_ids if provided and sync the agent.team_id relationship
781
+ if isinstance(new_config, dict) and 'member_ids' in new_config:
782
+ new_member_ids = set(new_config.get('member_ids', []))
783
+ client = get_supabase()
784
+
785
+ # Validate all agent IDs exist in Supabase
786
+ for agent_id in new_member_ids:
787
+ result = (
788
+ client.table("agents")
789
+ .select("id")
790
+ .eq("id", agent_id)
791
+ .eq("organization_id", organization["id"])
792
+ .maybe_single()
793
+ .execute()
794
+ )
795
+ if not result.data:
796
+ raise HTTPException(
797
+ status_code=400,
798
+ detail=f"Agent with ID '{agent_id}' not found. Please create the agent first."
799
+ )
800
+
801
+ # Sync the agent.team_id relationship in Supabase
802
+ # Get current team members from configuration
803
+ current_config = TeamConfiguration(**(team.configuration or {}))
804
+ current_member_ids = set(current_config.member_ids or [])
805
+
806
+ # Remove agents that are no longer in the team
807
+ agents_to_remove = current_member_ids - new_member_ids
808
+ for agent_id in agents_to_remove:
809
+ try:
810
+ client.table("agents").update({"team_id": None}).eq("id", agent_id).execute()
811
+ except Exception as e:
812
+ logger.warning("failed_to_remove_agent_from_team", error=str(e), agent_id=agent_id)
813
+
814
+ # Add agents that are newly added to the team
815
+ agents_to_add = new_member_ids - current_member_ids
816
+ for agent_id in agents_to_add:
817
+ try:
818
+ client.table("agents").update({"team_id": team_id}).eq("id", agent_id).execute()
819
+ except Exception as e:
820
+ logger.warning("failed_to_add_agent_to_team", error=str(e), agent_id=agent_id)
821
+
822
+ # new_config is already a dict, just assign it
823
+ team.configuration = new_config
824
+ del update_data["configuration"]
825
+
826
+ # Update other fields
827
+ for field, value in update_data.items():
828
+ if hasattr(team, field):
829
+ setattr(team, field, value)
830
+
831
+ # Update skill_ids if provided
832
+ if skill_ids is not None:
833
+ team.skill_ids = skill_ids
834
+
835
+ team.updated_at = datetime.utcnow()
836
+ db.commit()
837
+ db.refresh(team)
838
+
839
+ # Update skill associations if skill_ids was provided
840
+ if skill_ids is not None:
841
+ try:
842
+ client = get_supabase()
843
+
844
+ # Delete existing associations
845
+ client.table("skill_associations").delete().eq("entity_type", "team").eq("entity_id", team_id).execute()
846
+
847
+ # Create new associations
848
+ now = datetime.utcnow().isoformat()
849
+ for skill_id in skill_ids:
850
+ association_id = str(uuid.uuid4())
851
+ config_override = (skill_configurations or {}).get(skill_id, {})
852
+
853
+ association_record = {
854
+ "id": association_id,
855
+ "organization_id": organization["id"],
856
+ "skill_id": skill_id,
857
+ "entity_type": "team",
858
+ "entity_id": team_id,
859
+ "configuration_override": config_override,
860
+ "created_at": now,
861
+ }
862
+
863
+ client.table("skill_associations").insert(association_record).execute()
864
+
865
+ logger.info(
866
+ "team_skills_updated",
867
+ team_id=team_id,
868
+ skill_count=len(skill_ids),
869
+ org_id=organization["id"]
870
+ )
871
+ except Exception as e:
872
+ logger.warning(
873
+ "failed_to_update_team_skills",
874
+ error=str(e),
875
+ team_id=team_id,
876
+ org_id=organization["id"]
877
+ )
878
+
879
+ # Update environment associations if environment_ids was provided
880
+ if environment_ids is not None:
881
+ try:
882
+ client = get_supabase()
883
+
884
+ # Delete existing environment associations
885
+ client.table("team_environments").delete().eq("team_id", team_id).execute()
886
+
887
+ # Create new environment associations
888
+ for environment_id in environment_ids:
889
+ env_association_record = {
890
+ "id": str(uuid.uuid4()),
891
+ "team_id": team_id,
892
+ "environment_id": environment_id,
893
+ "organization_id": organization["id"],
894
+ "assigned_at": datetime.utcnow().isoformat(),
895
+ }
896
+ client.table("team_environments").insert(env_association_record).execute()
897
+
898
+ logger.info(
899
+ "team_environments_updated",
900
+ team_id=team_id,
901
+ environment_count=len(environment_ids),
902
+ org_id=organization["id"]
903
+ )
904
+ except Exception as e:
905
+ logger.warning(
906
+ "failed_to_update_team_environments",
907
+ error=str(e),
908
+ team_id=team_id,
909
+ org_id=organization["id"]
910
+ )
911
+
912
+ # Return with parsed configuration
913
+ return TeamResponse(
914
+ id=str(team.id),
915
+ organization_id=team.organization_id,
916
+ name=team.name,
917
+ description=team.description,
918
+ status=team.status,
919
+ configuration=TeamConfiguration(**(team.configuration or {})),
920
+ created_at=team.created_at,
921
+ updated_at=team.updated_at,
922
+ projects=get_team_projects(db, team_id),
923
+ )
924
+
925
+
926
+ @router.delete("/{team_id}", status_code=status.HTTP_204_NO_CONTENT)
927
+ def delete_team(
928
+ team_id: str,
929
+ db: Session = Depends(get_db),
930
+ organization: dict = Depends(get_current_organization),
931
+ ):
932
+ """Delete a team - only if it belongs to the current organization"""
933
+ team = db.query(Team).filter(
934
+ Team.id == team_id,
935
+ Team.organization_id == organization["id"]
936
+ ).first()
937
+ if not team:
938
+ raise HTTPException(status_code=404, detail="Team not found")
939
+
940
+ db.delete(team)
941
+ db.commit()
942
+ return None
943
+
944
+
945
+ @router.post("/{team_id}/agents/{agent_id}", response_model=TeamWithAgentsResponse)
946
+ def add_agent_to_team(
947
+ team_id: str,
948
+ agent_id: str,
949
+ db: Session = Depends(get_db),
950
+ organization: dict = Depends(get_current_organization),
951
+ ):
952
+ """
953
+ Add an agent to a team.
954
+
955
+ This sets the agent's team_id foreign key. You can also manage members
956
+ through the team's configuration.member_ids field.
957
+ Only allows adding agents to teams belonging to the current organization.
958
+ """
959
+ team = db.query(Team).filter(
960
+ Team.id == team_id,
961
+ Team.organization_id == organization["id"]
962
+ ).first()
963
+ if not team:
964
+ raise HTTPException(status_code=404, detail="Team not found")
965
+
966
+ agent = db.query(Agent).filter(
967
+ Agent.id == agent_id,
968
+ Agent.organization_id == organization["id"]
969
+ ).first()
970
+ if not agent:
971
+ raise HTTPException(status_code=404, detail="Agent not found")
972
+
973
+ agent.team_id = team_id
974
+ db.commit()
975
+ db.refresh(team)
976
+
977
+ # Parse configuration
978
+ team_config = TeamConfiguration(**(team.configuration or {}))
979
+
980
+ # Get agents from configuration.member_ids (source of truth)
981
+ member_ids = team_config.member_ids
982
+ agents = []
983
+ if member_ids:
984
+ db_agents = db.query(Agent).filter(Agent.id.in_(member_ids)).all()
985
+ agents = [
986
+ {
987
+ "id": str(a.id),
988
+ "name": a.name,
989
+ "status": a.status,
990
+ "capabilities": a.capabilities,
991
+ "description": a.description,
992
+ }
993
+ for a in db_agents
994
+ ]
995
+
996
+ # Return team with agents
997
+ return TeamWithAgentsResponse(
998
+ id=str(team.id),
999
+ organization_id=team.organization_id,
1000
+ name=team.name,
1001
+ description=team.description,
1002
+ status=team.status,
1003
+ configuration=team_config,
1004
+ created_at=team.created_at,
1005
+ updated_at=team.updated_at,
1006
+ agents=agents,
1007
+ )
1008
+
1009
+
1010
+ @router.delete("/{team_id}/agents/{agent_id}", response_model=TeamWithAgentsResponse)
1011
+ def remove_agent_from_team(
1012
+ team_id: str,
1013
+ agent_id: str,
1014
+ db: Session = Depends(get_db),
1015
+ organization: dict = Depends(get_current_organization),
1016
+ ):
1017
+ """
1018
+ Remove an agent from a team.
1019
+
1020
+ This clears the agent's team_id foreign key.
1021
+ Only allows removing agents from teams belonging to the current organization.
1022
+ """
1023
+ team = db.query(Team).filter(
1024
+ Team.id == team_id,
1025
+ Team.organization_id == organization["id"]
1026
+ ).first()
1027
+ if not team:
1028
+ raise HTTPException(status_code=404, detail="Team not found")
1029
+
1030
+ agent = db.query(Agent).filter(
1031
+ Agent.id == agent_id,
1032
+ Agent.team_id == team_id,
1033
+ Agent.organization_id == organization["id"]
1034
+ ).first()
1035
+ if not agent:
1036
+ raise HTTPException(status_code=404, detail="Agent not found in this team")
1037
+
1038
+ agent.team_id = None
1039
+ db.commit()
1040
+ db.refresh(team)
1041
+
1042
+ # Parse configuration
1043
+ team_config = TeamConfiguration(**(team.configuration or {}))
1044
+
1045
+ # Get agents from configuration.member_ids (source of truth)
1046
+ member_ids = team_config.member_ids
1047
+ agents = []
1048
+ if member_ids:
1049
+ db_agents = db.query(Agent).filter(Agent.id.in_(member_ids)).all()
1050
+ agents = [
1051
+ {
1052
+ "id": str(a.id),
1053
+ "name": a.name,
1054
+ "status": a.status,
1055
+ "capabilities": a.capabilities,
1056
+ "description": a.description,
1057
+ }
1058
+ for a in db_agents
1059
+ ]
1060
+
1061
+ # Return team with agents
1062
+ return TeamWithAgentsResponse(
1063
+ id=str(team.id),
1064
+ organization_id=team.organization_id,
1065
+ name=team.name,
1066
+ description=team.description,
1067
+ status=team.status,
1068
+ configuration=team_config,
1069
+ created_at=team.created_at,
1070
+ updated_at=team.updated_at,
1071
+ agents=agents,
1072
+ )
1073
+
1074
+
1075
+ @router.post("/{team_id}/execute", response_model=TeamExecutionResponse)
1076
+ async def execute_team(
1077
+ team_id: str,
1078
+ execution_request: TeamExecutionRequest,
1079
+ request: Request,
1080
+ db: Session = Depends(get_db),
1081
+ organization: dict = Depends(get_current_organization),
1082
+ ):
1083
+ """
1084
+ Execute a team task by submitting to Temporal workflow.
1085
+
1086
+ This creates an execution record and starts a Temporal workflow.
1087
+ The actual execution happens asynchronously on the Temporal worker.
1088
+
1089
+ The runner_name should come from the Composer UI where user selects
1090
+ from available runners (fetched from Kubiya API /api/v1/runners).
1091
+ """
1092
+ try:
1093
+ # Get team details from local DB
1094
+ team = db.query(Team).filter(
1095
+ Team.id == team_id,
1096
+ Team.organization_id == organization["id"]
1097
+ ).first()
1098
+
1099
+ if not team:
1100
+ raise HTTPException(status_code=404, detail="Team not found")
1101
+
1102
+ # Parse team configuration
1103
+ team_config = TeamConfiguration(**(team.configuration or {}))
1104
+
1105
+ # Validate and get worker queue
1106
+ worker_queue_id = execution_request.worker_queue_id
1107
+
1108
+ client = get_supabase()
1109
+ queue_result = (
1110
+ client.table("worker_queues")
1111
+ .select("*")
1112
+ .eq("id", worker_queue_id)
1113
+ .eq("organization_id", organization["id"])
1114
+ .maybe_single()
1115
+ .execute()
1116
+ )
1117
+
1118
+ if not queue_result.data:
1119
+ raise HTTPException(
1120
+ status_code=status.HTTP_404_NOT_FOUND,
1121
+ detail=f"Worker queue '{worker_queue_id}' not found. Please select a valid worker queue."
1122
+ )
1123
+
1124
+ worker_queue = queue_result.data
1125
+
1126
+ # Check if queue has active workers
1127
+ if worker_queue.get("status") != "active":
1128
+ raise HTTPException(
1129
+ status_code=status.HTTP_400_BAD_REQUEST,
1130
+ detail=f"Worker queue '{worker_queue.get('name')}' is not active"
1131
+ )
1132
+
1133
+ # Extract user metadata - ALWAYS use JWT-decoded organization data as source of truth
1134
+ user_metadata = execution_request.user_metadata or {}
1135
+ # Override with JWT data (user can't spoof their identity)
1136
+ user_metadata["user_id"] = organization.get("user_id")
1137
+ user_metadata["user_email"] = organization.get("user_email")
1138
+ user_metadata["user_name"] = organization.get("user_name")
1139
+ # Keep user_avatar from request if provided (not in JWT)
1140
+ if not user_metadata.get("user_avatar"):
1141
+ user_metadata["user_avatar"] = None
1142
+
1143
+ logger.info(
1144
+ "execution_user_metadata",
1145
+ user_id=user_metadata.get("user_id"),
1146
+ user_name=user_metadata.get("user_name"),
1147
+ user_email=user_metadata.get("user_email"),
1148
+ org_id=organization.get("id"),
1149
+ )
1150
+
1151
+ # Create execution record in Supabase
1152
+ execution_id = str(uuid.uuid4())
1153
+ now = datetime.utcnow().isoformat()
1154
+
1155
+ execution_record = {
1156
+ "id": execution_id,
1157
+ "organization_id": organization["id"],
1158
+ "execution_type": "TEAM",
1159
+ "entity_id": team_id,
1160
+ "entity_name": team.name,
1161
+ "prompt": execution_request.prompt,
1162
+ "system_prompt": execution_request.system_prompt,
1163
+ "status": "PENDING",
1164
+ "worker_queue_id": worker_queue_id,
1165
+ "runner_name": worker_queue.get("name"), # Store queue name for display
1166
+ "user_id": user_metadata.get("user_id"),
1167
+ "user_name": user_metadata.get("user_name"),
1168
+ "user_email": user_metadata.get("user_email"),
1169
+ "user_avatar": user_metadata.get("user_avatar"),
1170
+ "usage": {},
1171
+ "execution_metadata": {
1172
+ "kubiya_org_id": organization["id"],
1173
+ "kubiya_org_name": organization["name"],
1174
+ "worker_queue_name": worker_queue.get("display_name") or worker_queue.get("name"),
1175
+ "team_execution": True,
1176
+ },
1177
+ "created_at": now,
1178
+ "updated_at": now,
1179
+ }
1180
+
1181
+ exec_result = client.table("executions").insert(execution_record).execute()
1182
+
1183
+ if not exec_result.data:
1184
+ raise HTTPException(
1185
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1186
+ detail="Failed to create execution record"
1187
+ )
1188
+
1189
+ # Add creator as the first participant (owner role) for multiplayer support
1190
+ user_id = user_metadata.get("user_id")
1191
+ if user_id:
1192
+ try:
1193
+ import uuid as uuid_lib
1194
+ client.table("execution_participants").insert({
1195
+ "id": str(uuid_lib.uuid4()),
1196
+ "execution_id": execution_id,
1197
+ "organization_id": organization["id"],
1198
+ "user_id": user_id,
1199
+ "user_name": user_metadata.get("user_name"),
1200
+ "user_email": user_metadata.get("user_email"),
1201
+ "user_avatar": user_metadata.get("user_avatar"),
1202
+ "role": "owner",
1203
+ }).execute()
1204
+ logger.info(
1205
+ "owner_participant_added",
1206
+ execution_id=execution_id,
1207
+ user_id=user_id,
1208
+ )
1209
+ except Exception as participant_error:
1210
+ logger.warning(
1211
+ "failed_to_add_owner_participant",
1212
+ error=str(participant_error),
1213
+ execution_id=execution_id,
1214
+ )
1215
+ # Don't fail execution creation if participant tracking fails
1216
+
1217
+ # Extract MCP servers from team configuration if available
1218
+ mcp_servers = team_config.metadata.get("mcpServers", {}) if team_config.metadata else {}
1219
+
1220
+ # Use LLM config from team configuration if available
1221
+ model_id = team_config.llm.model if team_config.llm and team_config.llm.model else "kubiya/claude-sonnet-4"
1222
+
1223
+ # Build model config from LLM configuration
1224
+ model_config = {}
1225
+ if team_config.llm:
1226
+ if team_config.llm.temperature is not None:
1227
+ model_config["temperature"] = team_config.llm.temperature
1228
+ if team_config.llm.max_tokens is not None:
1229
+ model_config["max_tokens"] = team_config.llm.max_tokens
1230
+ if team_config.llm.top_p is not None:
1231
+ model_config["top_p"] = team_config.llm.top_p
1232
+ if team_config.llm.stop is not None:
1233
+ model_config["stop"] = team_config.llm.stop
1234
+ if team_config.llm.frequency_penalty is not None:
1235
+ model_config["frequency_penalty"] = team_config.llm.frequency_penalty
1236
+ if team_config.llm.presence_penalty is not None:
1237
+ model_config["presence_penalty"] = team_config.llm.presence_penalty
1238
+
1239
+ # Submit to Temporal workflow
1240
+ # Task queue is the worker queue UUID
1241
+ task_queue = worker_queue_id
1242
+
1243
+ # Get Temporal client
1244
+ temporal_client = await get_temporal_client()
1245
+
1246
+ # Start workflow
1247
+ # Use team instructions as fallback system prompt if not provided in request
1248
+ system_prompt = execution_request.system_prompt
1249
+ if not system_prompt and team_config.instructions:
1250
+ # Convert instructions to string if it's a list
1251
+ if isinstance(team_config.instructions, list):
1252
+ system_prompt = "\n".join(team_config.instructions)
1253
+ else:
1254
+ system_prompt = team_config.instructions
1255
+
1256
+ # Get API key from Authorization header
1257
+ auth_header = request.headers.get("authorization", "")
1258
+ api_key = auth_header.replace("UserKey ", "").replace("Bearer ", "") if auth_header else None
1259
+
1260
+ # Get control plane URL from request
1261
+ control_plane_url = str(request.base_url).rstrip("/")
1262
+
1263
+ workflow_input = TeamExecutionInput(
1264
+ execution_id=execution_id,
1265
+ team_id=team_id,
1266
+ organization_id=organization["id"],
1267
+ prompt=execution_request.prompt,
1268
+ system_prompt=system_prompt,
1269
+ model_id=model_id,
1270
+ model_config=model_config,
1271
+ team_config=team.configuration,
1272
+ mcp_servers=mcp_servers,
1273
+ user_metadata=user_metadata,
1274
+ runtime_type=team.runtime.value if team.runtime else "default",
1275
+ control_plane_url=control_plane_url,
1276
+ api_key=api_key,
1277
+ )
1278
+
1279
+ workflow_handle = await temporal_client.start_workflow(
1280
+ TeamExecutionWorkflow.run,
1281
+ workflow_input, # Pass TeamExecutionInput directly
1282
+ id=f"team-execution-{execution_id}",
1283
+ task_queue=task_queue,
1284
+ )
1285
+
1286
+ logger.info(
1287
+ "team_execution_submitted",
1288
+ execution_id=execution_id,
1289
+ team_id=team_id,
1290
+ workflow_id=workflow_handle.id,
1291
+ task_queue=task_queue,
1292
+ worker_queue_id=worker_queue_id,
1293
+ worker_queue_name=worker_queue.get("name"),
1294
+ org_id=organization["id"],
1295
+ org_name=organization["name"],
1296
+ )
1297
+
1298
+ return TeamExecutionResponse(
1299
+ execution_id=execution_id,
1300
+ workflow_id=workflow_handle.id,
1301
+ status="PENDING",
1302
+ message=f"Execution submitted to worker queue: {worker_queue.get('name')}",
1303
+ )
1304
+
1305
+ except HTTPException:
1306
+ raise
1307
+ except Exception as e:
1308
+ logger.error(
1309
+ "team_execution_failed",
1310
+ error=str(e),
1311
+ team_id=team_id,
1312
+ org_id=organization["id"]
1313
+ )
1314
+ raise HTTPException(
1315
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
1316
+ detail=f"Failed to execute team: {str(e)}"
1317
+ )
1318
+
1319
+
1320
+ @router.post("/{team_id}/execute/stream")
1321
+ def execute_team_stream(
1322
+ team_id: str,
1323
+ execution_request: TeamExecutionRequest,
1324
+ db: Session = Depends(get_db),
1325
+ ):
1326
+ """
1327
+ Execute a team task with streaming response.
1328
+
1329
+ The team leader coordinates and delegates the task to appropriate team members.
1330
+ Results are streamed back in real-time.
1331
+ """
1332
+ from control_plane_api.app.services.litellm_service import litellm_service
1333
+
1334
+ team = db.query(Team).filter(Team.id == team_id).first()
1335
+ if not team:
1336
+ raise HTTPException(status_code=404, detail="Team not found")
1337
+
1338
+ # Get team agents
1339
+ agents = team.agents
1340
+ if not agents:
1341
+ raise HTTPException(
1342
+ status_code=400,
1343
+ detail="Team has no agents. Add agents to the team before executing tasks."
1344
+ )
1345
+
1346
+ # Build team coordination prompt
1347
+ agent_descriptions = []
1348
+ for agent in agents:
1349
+ caps = ", ".join(agent.capabilities) if agent.capabilities else "general"
1350
+ agent_descriptions.append(
1351
+ f"- {agent.name}: {agent.description or 'No description'} (Capabilities: {caps})"
1352
+ )
1353
+
1354
+ # Create a coordination system prompt
1355
+ coordination_prompt = f"""You are a Team Coordinator managing a team with the following agents:
1356
+
1357
+ {chr(10).join(agent_descriptions)}
1358
+
1359
+ Your task is to:
1360
+ 1. Analyze the user's request
1361
+ 2. Determine which agent(s) are best suited for the task
1362
+ 3. Delegate or route the task appropriately
1363
+ 4. Synthesize and present the results
1364
+
1365
+ User Request: {execution_request.prompt}
1366
+
1367
+ Please coordinate the team to complete this request effectively."""
1368
+
1369
+ # Parse team configuration
1370
+ team_config = TeamConfiguration(**(team.configuration or {}))
1371
+
1372
+ # Use LLM config from team configuration if available
1373
+ model = team_config.llm.model if team_config.llm and team_config.llm.model else "kubiya/claude-sonnet-4"
1374
+
1375
+ # Build LLM kwargs from configuration
1376
+ llm_kwargs = {}
1377
+ if team_config.llm:
1378
+ if team_config.llm.temperature is not None:
1379
+ llm_kwargs["temperature"] = team_config.llm.temperature
1380
+ if team_config.llm.max_tokens is not None:
1381
+ llm_kwargs["max_tokens"] = team_config.llm.max_tokens
1382
+ if team_config.llm.top_p is not None:
1383
+ llm_kwargs["top_p"] = team_config.llm.top_p
1384
+ if team_config.llm.stop is not None:
1385
+ llm_kwargs["stop"] = team_config.llm.stop
1386
+ if team_config.llm.frequency_penalty is not None:
1387
+ llm_kwargs["frequency_penalty"] = team_config.llm.frequency_penalty
1388
+ if team_config.llm.presence_penalty is not None:
1389
+ llm_kwargs["presence_penalty"] = team_config.llm.presence_penalty
1390
+
1391
+ # Execute coordination using LiteLLM (streaming)
1392
+ return StreamingResponse(
1393
+ litellm_service.execute_agent_stream(
1394
+ prompt=coordination_prompt,
1395
+ model=model,
1396
+ system_prompt=execution_request.system_prompt or "You are an expert team coordinator. Delegate tasks efficiently and synthesize results clearly.",
1397
+ **llm_kwargs,
1398
+ ),
1399
+ media_type="text/event-stream",
1400
+ )