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,1001 @@
1
+ """
2
+ Multi-tenant skills router.
3
+
4
+ This router handles skill CRUD operations and associations with agents/teams/environments.
5
+ All operations are scoped to the authenticated organization.
6
+ """
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, status, Request
9
+ from typing import List, Optional
10
+ from datetime import datetime
11
+ from pydantic import BaseModel, Field
12
+ import structlog
13
+ import uuid
14
+
15
+ from control_plane_api.app.middleware.auth import get_current_organization
16
+ from control_plane_api.app.lib.supabase import get_supabase
17
+ from control_plane_api.app.lib.kubiya_client import get_kubiya_client
18
+ from control_plane_api.app.skills import get_all_skills, get_skill, SkillType
19
+
20
+ logger = structlog.get_logger()
21
+
22
+ router = APIRouter()
23
+
24
+
25
+ # PostgREST column aliasing pattern: new_name:database_column
26
+ # We alias skill_type as type in all queries
27
+ SKILL_COLUMNS = "id, organization_id, name, type:skill_type, description, icon, enabled, configuration, created_at, updated_at"
28
+
29
+
30
+ # Pydantic schemas
31
+ class ToolSetConfiguration(BaseModel):
32
+ """Configuration for a skill"""
33
+ # File System
34
+ base_dir: Optional[str] = None
35
+ enable_save_file: Optional[bool] = None
36
+ enable_read_file: Optional[bool] = None
37
+ enable_list_files: Optional[bool] = None
38
+ enable_search_files: Optional[bool] = None
39
+
40
+ # Shell
41
+ allowed_commands: Optional[List[str]] = None
42
+ blocked_commands: Optional[List[str]] = None
43
+ timeout: Optional[int] = None
44
+
45
+ # Docker
46
+ enable_container_management: Optional[bool] = None
47
+ enable_image_management: Optional[bool] = None
48
+ enable_volume_management: Optional[bool] = None
49
+ enable_network_management: Optional[bool] = None
50
+
51
+ # Python
52
+ enable_code_execution: Optional[bool] = None
53
+ allowed_imports: Optional[List[str]] = None
54
+ blocked_imports: Optional[List[str]] = None
55
+
56
+ # File Generation
57
+ enable_json_generation: Optional[bool] = None
58
+ enable_csv_generation: Optional[bool] = None
59
+ enable_pdf_generation: Optional[bool] = None
60
+ enable_txt_generation: Optional[bool] = None
61
+ output_directory: Optional[str] = None
62
+
63
+ # Data Visualization
64
+ max_diagram_size: Optional[int] = None
65
+ enable_flowchart: Optional[bool] = None
66
+ enable_sequence: Optional[bool] = None
67
+ enable_class_diagram: Optional[bool] = None
68
+ enable_er_diagram: Optional[bool] = None
69
+ enable_gantt: Optional[bool] = None
70
+ enable_pie_chart: Optional[bool] = None
71
+ enable_state_diagram: Optional[bool] = None
72
+ enable_git_graph: Optional[bool] = None
73
+ enable_user_journey: Optional[bool] = None
74
+ enable_quadrant_chart: Optional[bool] = None
75
+
76
+ # Workflow Executor
77
+ workflow_type: Optional[str] = Field(None, description="Workflow type: 'json' or 'python_dsl'")
78
+ workflow_definition: Optional[str] = Field(None, description="JSON workflow definition as string")
79
+ python_dsl_code: Optional[str] = Field(None, description="Python DSL code for workflow")
80
+ validation_enabled: Optional[bool] = Field(None, description="Enable workflow validation")
81
+ default_runner: Optional[str] = Field(None, description="Default runner/environment name")
82
+
83
+ # Custom
84
+ custom_class: Optional[str] = None
85
+ custom_config: Optional[dict] = None
86
+
87
+
88
+ class ToolSetCreate(BaseModel):
89
+ name: str = Field(..., description="Skill name")
90
+ type: str = Field(..., description="Skill type (file_system, shell, docker, python, etc.)")
91
+ description: Optional[str] = Field(None, description="Skill description")
92
+ icon: Optional[str] = Field("Wrench", description="Icon name")
93
+ enabled: bool = Field(True, description="Whether skill is enabled")
94
+ configuration: ToolSetConfiguration = Field(default_factory=ToolSetConfiguration)
95
+
96
+
97
+ class ToolSetUpdate(BaseModel):
98
+ name: Optional[str] = None
99
+ description: Optional[str] = None
100
+ icon: Optional[str] = None
101
+ enabled: Optional[bool] = None
102
+ configuration: Optional[ToolSetConfiguration] = None
103
+
104
+
105
+ class ToolSetResponse(BaseModel):
106
+ id: str
107
+ organization_id: str
108
+ name: str
109
+ type: str # Aliased from skill_type in SQL query
110
+ description: Optional[str]
111
+ icon: str
112
+ enabled: bool
113
+ configuration: dict
114
+ created_at: str
115
+ updated_at: str
116
+
117
+
118
+ class ToolSetAssociationCreate(BaseModel):
119
+ skill_id: str = Field(..., description="Skill ID to associate")
120
+ configuration_override: Optional[ToolSetConfiguration] = Field(None, description="Entity-specific config overrides")
121
+
122
+
123
+ class ResolvedToolSet(BaseModel):
124
+ id: str
125
+ name: str
126
+ type: str
127
+ description: Optional[str]
128
+ icon: str
129
+ enabled: bool
130
+ configuration: dict
131
+ source: str # "environment", "team", "agent"
132
+ inherited: bool
133
+
134
+
135
+ # Helper functions
136
+ def get_skill_by_id(client, organization_id: str, skill_id: str) -> dict:
137
+ """Get a skill by ID, scoped to organization"""
138
+ result = (
139
+ client.table("skills")
140
+ .select(SKILL_COLUMNS)
141
+ .eq("organization_id", organization_id)
142
+ .eq("id", skill_id)
143
+ .execute()
144
+ )
145
+
146
+ if not result.data:
147
+ raise HTTPException(status_code=404, detail=f"Skill {skill_id} not found")
148
+
149
+ return result.data[0]
150
+
151
+
152
+ def get_entity_skills(client, organization_id: str, entity_type: str, entity_id: str) -> List[dict]:
153
+ """Get skills associated with an entity"""
154
+ # Get associations with aliased skill columns
155
+ result = (
156
+ client.table("skill_associations")
157
+ .select(f"skill_id, configuration_override, skills(id, organization_id, name, type:skill_type, description, icon, enabled, configuration, created_at, updated_at)")
158
+ .eq("organization_id", organization_id)
159
+ .eq("entity_type", entity_type)
160
+ .eq("entity_id", entity_id)
161
+ .execute()
162
+ )
163
+
164
+ skills = []
165
+ for item in result.data:
166
+ skill_data = item.get("skills")
167
+ if skill_data and skill_data.get("enabled", True):
168
+ # Merge configuration with override
169
+ config = skill_data.get("configuration", {})
170
+ override = item.get("configuration_override")
171
+ if override:
172
+ config = {**config, **override}
173
+
174
+ skills.append({
175
+ **skill_data,
176
+ "configuration": config
177
+ })
178
+
179
+ return skills
180
+
181
+
182
+ def merge_configurations(base: dict, override: dict) -> dict:
183
+ """Merge two configuration dictionaries, with override taking precedence"""
184
+ result = base.copy()
185
+ for key, value in override.items():
186
+ if value is not None:
187
+ result[key] = value
188
+ return result
189
+
190
+
191
+ async def validate_workflow_runner(config: dict, token: str, org_id: str) -> None:
192
+ """
193
+ Validate that runners specified in workflow configuration exist.
194
+
195
+ Args:
196
+ config: Workflow executor configuration
197
+ token: Kubiya API token
198
+ org_id: Organization ID
199
+
200
+ Raises:
201
+ HTTPException: If runner validation fails
202
+ """
203
+ import json as json_lib
204
+
205
+ # Extract runners to validate
206
+ runners_to_check = []
207
+
208
+ # Check default_runner
209
+ if config.get("default_runner"):
210
+ runners_to_check.append(("default_runner", config["default_runner"]))
211
+
212
+ # Check workflow-level runner in JSON workflows
213
+ if config.get("workflow_type") == "json" and config.get("workflow_definition"):
214
+ try:
215
+ workflow_data = json_lib.loads(config["workflow_definition"])
216
+ if workflow_data.get("runner"):
217
+ runners_to_check.append(("workflow.runner", workflow_data["runner"]))
218
+ except json_lib.JSONDecodeError:
219
+ # Invalid JSON - will be caught by skill validation
220
+ pass
221
+
222
+ if not runners_to_check:
223
+ # No runners specified, will use default
224
+ return
225
+
226
+ # Fetch available runners from Kubiya API
227
+ try:
228
+ kubiya_client = get_kubiya_client()
229
+ available_runners = await kubiya_client.get_runners(token, org_id)
230
+
231
+ if not available_runners:
232
+ logger.warning(
233
+ "no_runners_available_skipping_validation",
234
+ org_id=org_id
235
+ )
236
+ return
237
+
238
+ # Extract runner names/IDs from the response
239
+ runner_names = set()
240
+ for runner in available_runners:
241
+ if isinstance(runner, dict):
242
+ # Add both 'name' and 'id' to the set
243
+ if runner.get("name"):
244
+ runner_names.add(runner["name"])
245
+ if runner.get("id"):
246
+ runner_names.add(runner["id"])
247
+
248
+ # Validate each runner
249
+ for field_name, runner_value in runners_to_check:
250
+ if runner_value not in runner_names:
251
+ available_list = sorted(list(runner_names))
252
+ raise HTTPException(
253
+ status_code=400,
254
+ detail=(
255
+ f"Invalid runner '{runner_value}' specified in {field_name}. "
256
+ f"Available runners: {', '.join(available_list) if available_list else 'none'}"
257
+ )
258
+ )
259
+
260
+ logger.info(
261
+ "workflow_runners_validated",
262
+ runners_checked=[r[1] for r in runners_to_check],
263
+ available_count=len(runner_names)
264
+ )
265
+
266
+ except HTTPException:
267
+ raise
268
+ except Exception as e:
269
+ logger.error(
270
+ "runner_validation_failed",
271
+ error=str(e),
272
+ org_id=org_id
273
+ )
274
+ # Don't fail skill creation if runner validation fails
275
+ # This allows offline/testing scenarios
276
+ logger.warning("skipping_runner_validation_due_to_error")
277
+
278
+
279
+ # API Endpoints
280
+
281
+ @router.post("", response_model=ToolSetResponse, status_code=status.HTTP_201_CREATED)
282
+ async def create_skill(
283
+ skill_data: ToolSetCreate,
284
+ request: Request,
285
+ organization: dict = Depends(get_current_organization),
286
+ ):
287
+ """Create a new skill in the organization"""
288
+ try:
289
+ client = get_supabase()
290
+
291
+ skill_id = str(uuid.uuid4())
292
+ now = datetime.utcnow().isoformat()
293
+
294
+ # Validate skill type
295
+ valid_types = ["file_system", "shell", "python", "docker", "sleep", "file_generation", "data_visualization", "workflow_executor", "custom"]
296
+ if skill_data.type not in valid_types:
297
+ raise HTTPException(
298
+ status_code=400,
299
+ detail=f"Invalid skill type. Must be one of: {', '.join(valid_types)}"
300
+ )
301
+
302
+ # Validate workflow_executor runner if applicable
303
+ if skill_data.type == "workflow_executor":
304
+ config_dict = skill_data.configuration.dict(exclude_none=True)
305
+ token = request.state.kubiya_token
306
+ await validate_workflow_runner(config_dict, token, organization["id"])
307
+
308
+ skill_record = {
309
+ "id": skill_id,
310
+ "organization_id": organization["id"],
311
+ "name": skill_data.name,
312
+ "skill_type": skill_data.type,
313
+ "description": skill_data.description,
314
+ "icon": skill_data.icon,
315
+ "enabled": skill_data.enabled,
316
+ "configuration": skill_data.configuration.dict(exclude_none=True),
317
+ "created_at": now,
318
+ "updated_at": now,
319
+ }
320
+
321
+ result = client.table("skills").insert(skill_record).execute()
322
+
323
+ logger.info(
324
+ "skill_created",
325
+ skill_id=skill_id,
326
+ name=skill_data.name,
327
+ type=skill_data.type,
328
+ organization_id=organization["id"]
329
+ )
330
+
331
+ # Fetch the created skill with aliased columns for response
332
+ skill = (
333
+ client.table("skills")
334
+ .select(SKILL_COLUMNS)
335
+ .eq("id", skill_id)
336
+ .single()
337
+ .execute()
338
+ )
339
+
340
+ return ToolSetResponse(**skill.data)
341
+
342
+ except Exception as e:
343
+ logger.error("skill_creation_failed", error=str(e))
344
+ raise HTTPException(status_code=500, detail=str(e))
345
+
346
+
347
+ @router.get("", response_model=List[ToolSetResponse])
348
+ async def list_skills(
349
+ organization: dict = Depends(get_current_organization),
350
+ ):
351
+ """List all skills for the organization"""
352
+ try:
353
+ client = get_supabase()
354
+
355
+ result = (
356
+ client.table("skills")
357
+ .select(SKILL_COLUMNS)
358
+ .eq("organization_id", organization["id"])
359
+ .order("created_at", desc=True)
360
+ .execute()
361
+ )
362
+
363
+ return [ToolSetResponse(**skill) for skill in result.data]
364
+
365
+ except Exception as e:
366
+ logger.error("skill_list_failed", error=str(e))
367
+ raise HTTPException(status_code=500, detail=str(e))
368
+
369
+
370
+ @router.get("/{skill_id}", response_model=ToolSetResponse)
371
+ async def get_skill(
372
+ skill_id: str,
373
+ organization: dict = Depends(get_current_organization),
374
+ ):
375
+ """Get a specific skill"""
376
+ try:
377
+ client = get_supabase()
378
+ skill = get_skill_by_id(client, organization["id"], skill_id)
379
+ return ToolSetResponse(**skill)
380
+
381
+ except HTTPException:
382
+ raise
383
+ except Exception as e:
384
+ logger.error("skill_get_failed", error=str(e), skill_id=skill_id)
385
+ raise HTTPException(status_code=500, detail=str(e))
386
+
387
+
388
+ @router.patch("/{skill_id}", response_model=ToolSetResponse)
389
+ async def update_skill(
390
+ skill_id: str,
391
+ skill_data: ToolSetUpdate,
392
+ request: Request,
393
+ organization: dict = Depends(get_current_organization),
394
+ ):
395
+ """Update a skill"""
396
+ try:
397
+ client = get_supabase()
398
+
399
+ # Verify skill exists and get its type
400
+ existing_skill = get_skill_by_id(client, organization["id"], skill_id)
401
+
402
+ # Build update dict
403
+ update_data = skill_data.dict(exclude_none=True)
404
+ if "configuration" in update_data:
405
+ update_data["configuration"] = update_data["configuration"]
406
+ update_data["updated_at"] = datetime.utcnow().isoformat()
407
+
408
+ # Validate workflow_executor runner if updating configuration
409
+ if existing_skill.get("type") == "workflow_executor" and "configuration" in update_data:
410
+ # Merge existing config with updates for complete validation
411
+ merged_config = {**existing_skill.get("configuration", {}), **update_data["configuration"]}
412
+ token = request.state.kubiya_token
413
+ await validate_workflow_runner(merged_config, token, organization["id"])
414
+
415
+ result = (
416
+ client.table("skills")
417
+ .update(update_data)
418
+ .eq("id", skill_id)
419
+ .eq("organization_id", organization["id"])
420
+ .execute()
421
+ )
422
+
423
+ logger.info("skill_updated", skill_id=skill_id, organization_id=organization["id"])
424
+
425
+ # Fetch the updated skill with proper column aliasing
426
+ updated_skill = (
427
+ client.table("skills")
428
+ .select(SKILL_COLUMNS)
429
+ .eq("id", skill_id)
430
+ .eq("organization_id", organization["id"])
431
+ .single()
432
+ .execute()
433
+ )
434
+ return ToolSetResponse(**updated_skill.data)
435
+
436
+ except HTTPException:
437
+ raise
438
+ except Exception as e:
439
+ logger.error("skill_update_failed", error=str(e), skill_id=skill_id)
440
+ raise HTTPException(status_code=500, detail=str(e))
441
+
442
+
443
+ @router.delete("/{skill_id}", status_code=status.HTTP_204_NO_CONTENT)
444
+ async def delete_skill(
445
+ skill_id: str,
446
+ organization: dict = Depends(get_current_organization),
447
+ ):
448
+ """Delete a skill"""
449
+ try:
450
+ client = get_supabase()
451
+
452
+ # Verify skill exists
453
+ get_skill_by_id(client, organization["id"], skill_id)
454
+
455
+ # Delete skill (cascade will handle associations)
456
+ client.table("skills").delete().eq("id", skill_id).eq("organization_id", organization["id"]).execute()
457
+
458
+ logger.info("skill_deleted", skill_id=skill_id, organization_id=organization["id"])
459
+
460
+ except HTTPException:
461
+ raise
462
+ except Exception as e:
463
+ logger.error("skill_delete_failed", error=str(e), skill_id=skill_id)
464
+ raise HTTPException(status_code=500, detail=str(e))
465
+
466
+
467
+ # Association endpoints for agents
468
+ @router.post("/associations/{entity_type}/{entity_id}/skills", status_code=status.HTTP_201_CREATED)
469
+ async def associate_skill(
470
+ entity_type: str,
471
+ entity_id: str,
472
+ association_data: ToolSetAssociationCreate,
473
+ organization: dict = Depends(get_current_organization),
474
+ ):
475
+ """Associate a skill with an entity (agent, team, environment)"""
476
+ try:
477
+ client = get_supabase()
478
+
479
+ # Validate entity type
480
+ if entity_type not in ["agent", "team", "environment"]:
481
+ raise HTTPException(status_code=400, detail="Invalid entity type. Must be: agent, team, or environment")
482
+
483
+ # Verify skill exists
484
+ get_skill_by_id(client, organization["id"], association_data.skill_id)
485
+
486
+ # Verify entity exists (check appropriate table)
487
+ # Note: "environment" entity type maps to "environments" table
488
+ entity_table = "environments" if entity_type == "environment" else f"{entity_type}s"
489
+ entity_result = (
490
+ client.table(entity_table)
491
+ .select("id")
492
+ .eq("organization_id", organization["id"])
493
+ .eq("id", entity_id)
494
+ .execute()
495
+ )
496
+
497
+ if not entity_result.data:
498
+ raise HTTPException(status_code=404, detail=f"{entity_type.capitalize()} {entity_id} not found")
499
+
500
+ # Create association
501
+ association_id = str(uuid.uuid4())
502
+ association_record = {
503
+ "id": association_id,
504
+ "organization_id": organization["id"],
505
+ "skill_id": association_data.skill_id,
506
+ "entity_type": entity_type,
507
+ "entity_id": entity_id,
508
+ "configuration_override": association_data.configuration_override.dict(exclude_none=True) if association_data.configuration_override else {},
509
+ "created_at": datetime.utcnow().isoformat(),
510
+ }
511
+
512
+ client.table("skill_associations").insert(association_record).execute()
513
+
514
+ # Also update denormalized skill_ids array (only for teams)
515
+ # Agents and environments don't have a skill_ids column - they only use the skill_associations junction table
516
+ # Teams have a denormalized skill_ids array for performance
517
+ if entity_type == "team":
518
+ current_entity = entity_result.data[0]
519
+ current_ids = current_entity.get("skill_ids", []) or []
520
+ if association_data.skill_id not in current_ids:
521
+ updated_ids = current_ids + [association_data.skill_id]
522
+ client.table(entity_table).update({"skill_ids": updated_ids}).eq("id", entity_id).execute()
523
+
524
+ logger.info(
525
+ "skill_associated",
526
+ skill_id=association_data.skill_id,
527
+ entity_type=entity_type,
528
+ entity_id=entity_id,
529
+ organization_id=organization["id"]
530
+ )
531
+
532
+ return {"message": "Skill associated successfully"}
533
+
534
+ except HTTPException:
535
+ raise
536
+ except Exception as e:
537
+ logger.error("skill_association_failed", error=str(e))
538
+ raise HTTPException(status_code=500, detail=str(e))
539
+
540
+
541
+ @router.get("/associations/{entity_type}/{entity_id}/skills", response_model=List[ToolSetResponse])
542
+ async def list_entity_skills(
543
+ entity_type: str,
544
+ entity_id: str,
545
+ organization: dict = Depends(get_current_organization),
546
+ ):
547
+ """List skills associated with an entity"""
548
+ try:
549
+ client = get_supabase()
550
+
551
+ if entity_type not in ["agent", "team", "environment"]:
552
+ raise HTTPException(status_code=400, detail="Invalid entity type")
553
+
554
+ skills = get_entity_skills(client, organization["id"], entity_type, entity_id)
555
+ return [ToolSetResponse(**skill) for skill in skills]
556
+
557
+ except HTTPException:
558
+ raise
559
+ except Exception as e:
560
+ logger.error("list_entity_skills_failed", error=str(e))
561
+ raise HTTPException(status_code=500, detail=str(e))
562
+
563
+
564
+ @router.delete("/associations/{entity_type}/{entity_id}/skills/{skill_id}", status_code=status.HTTP_204_NO_CONTENT)
565
+ async def dissociate_skill(
566
+ entity_type: str,
567
+ entity_id: str,
568
+ skill_id: str,
569
+ organization: dict = Depends(get_current_organization),
570
+ ):
571
+ """Remove a skill association from an entity"""
572
+ try:
573
+ client = get_supabase()
574
+
575
+ if entity_type not in ["agent", "team", "environment"]:
576
+ raise HTTPException(status_code=400, detail="Invalid entity type")
577
+
578
+ # Delete association
579
+ client.table("skill_associations").delete().eq("skill_id", skill_id).eq("entity_type", entity_type).eq("entity_id", entity_id).execute()
580
+
581
+ # Update denormalized skill_ids array (only for teams)
582
+ # Agents and environments don't have a skill_ids column - they only use the skill_associations junction table
583
+ # Teams have a denormalized skill_ids array for performance
584
+ if entity_type == "team":
585
+ entity_result = client.table("teams").select("skill_ids").eq("id", entity_id).execute()
586
+ if entity_result.data:
587
+ current_ids = entity_result.data[0].get("skill_ids", []) or []
588
+ updated_ids = [tid for tid in current_ids if tid != skill_id]
589
+ client.table("teams").update({"skill_ids": updated_ids}).eq("id", entity_id).execute()
590
+
591
+ logger.info(
592
+ "skill_dissociated",
593
+ skill_id=skill_id,
594
+ entity_type=entity_type,
595
+ entity_id=entity_id,
596
+ organization_id=organization["id"]
597
+ )
598
+
599
+ except Exception as e:
600
+ logger.error("skill_dissociation_failed", error=str(e))
601
+ raise HTTPException(status_code=500, detail=str(e))
602
+
603
+
604
+ @router.get("/associations/agents/{agent_id}/skills/resolved", response_model=List[ResolvedToolSet])
605
+ async def resolve_agent_skills(
606
+ agent_id: str,
607
+ organization: dict = Depends(get_current_organization),
608
+ ):
609
+ """
610
+ Resolve all skills for an agent (including inherited from ALL environments and team).
611
+
612
+ Inheritance order (with deduplication):
613
+ 1. All agent environments
614
+ 2. All team environments (if agent has team)
615
+ 3. Team skills
616
+ 4. Agent skills
617
+
618
+ Later layers override earlier ones if there are conflicts.
619
+ """
620
+ try:
621
+ client = get_supabase()
622
+
623
+ # Get agent details
624
+ agent_result = (
625
+ client.table("agents")
626
+ .select("id, team_id")
627
+ .eq("organization_id", organization["id"])
628
+ .eq("id", agent_id)
629
+ .execute()
630
+ )
631
+
632
+ if not agent_result.data:
633
+ raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found")
634
+
635
+ agent = agent_result.data[0]
636
+ resolved_skills = []
637
+ seen_ids = set()
638
+
639
+ # 1. Load skills from ALL agent environments (many-to-many)
640
+ agent_env_result = (
641
+ client.table("agent_environments")
642
+ .select("environment_id")
643
+ .eq("agent_id", agent_id)
644
+ .execute()
645
+ )
646
+
647
+ agent_environment_ids = [env["environment_id"] for env in (agent_env_result.data or [])]
648
+
649
+ for environment_id in agent_environment_ids:
650
+ env_skills = get_entity_skills(client, organization["id"], "environment", environment_id)
651
+ for skill in env_skills:
652
+ if skill["id"] not in seen_ids:
653
+ resolved_skills.append(ResolvedToolSet(
654
+ **skill,
655
+ source="environment",
656
+ inherited=True
657
+ ))
658
+ seen_ids.add(skill["id"])
659
+
660
+ # 2. Load skills from ALL team environments (if agent has team)
661
+ team_id = agent.get("team_id")
662
+ if team_id:
663
+ team_env_result = (
664
+ client.table("team_environments")
665
+ .select("environment_id")
666
+ .eq("team_id", team_id)
667
+ .execute()
668
+ )
669
+
670
+ team_environment_ids = [env["environment_id"] for env in (team_env_result.data or [])]
671
+
672
+ for environment_id in team_environment_ids:
673
+ env_skills = get_entity_skills(client, organization["id"], "environment", environment_id)
674
+ for skill in env_skills:
675
+ if skill["id"] not in seen_ids:
676
+ resolved_skills.append(ResolvedToolSet(
677
+ **skill,
678
+ source="environment",
679
+ inherited=True
680
+ ))
681
+ seen_ids.add(skill["id"])
682
+
683
+ # 3. Load team skills
684
+ team_skills = get_entity_skills(client, organization["id"], "team", team_id)
685
+ for skill in team_skills:
686
+ if skill["id"] not in seen_ids:
687
+ resolved_skills.append(ResolvedToolSet(
688
+ **skill,
689
+ source="team",
690
+ inherited=True
691
+ ))
692
+ seen_ids.add(skill["id"])
693
+
694
+ # 4. Load agent skills (highest priority)
695
+ agent_skills = get_entity_skills(client, organization["id"], "agent", agent_id)
696
+ for skill in agent_skills:
697
+ if skill["id"] not in seen_ids:
698
+ resolved_skills.append(ResolvedToolSet(
699
+ **skill,
700
+ source="agent",
701
+ inherited=False
702
+ ))
703
+ seen_ids.add(skill["id"])
704
+
705
+ logger.info(
706
+ "agent_skills_resolved",
707
+ agent_id=agent_id,
708
+ skill_count=len(resolved_skills),
709
+ agent_env_count=len(agent_environment_ids),
710
+ team_env_count=len(team_environment_ids) if team_id else 0,
711
+ organization_id=organization["id"]
712
+ )
713
+
714
+ return resolved_skills
715
+
716
+ except HTTPException:
717
+ raise
718
+ except Exception as e:
719
+ logger.error("resolve_agent_skills_failed", error=str(e), agent_id=agent_id)
720
+ raise HTTPException(status_code=500, detail=str(e))
721
+
722
+
723
+ @router.get("/associations/agents/{agent_id}/toolsets/resolved", response_model=List[ResolvedToolSet])
724
+ async def resolve_agent_toolsets_legacy(
725
+ agent_id: str,
726
+ organization: dict = Depends(get_current_organization),
727
+ ):
728
+ """
729
+ DEPRECATED: Legacy endpoint for backward compatibility.
730
+ Use /associations/agents/{agent_id}/skills/resolved instead.
731
+
732
+ This endpoint redirects to the new skills endpoint.
733
+ """
734
+ logger.warning(
735
+ "deprecated_toolsets_endpoint_used",
736
+ agent_id=agent_id,
737
+ endpoint="/associations/agents/{agent_id}/toolsets/resolved",
738
+ new_endpoint="/associations/agents/{agent_id}/skills/resolved"
739
+ )
740
+ return await resolve_agent_skills(agent_id, organization)
741
+
742
+
743
+ @router.get("/associations/teams/{team_id}/skills/resolved", response_model=List[ResolvedToolSet])
744
+ async def resolve_team_skills(
745
+ team_id: str,
746
+ organization: dict = Depends(get_current_organization),
747
+ ):
748
+ """
749
+ Resolve all skills for a team (including inherited from ALL environments).
750
+
751
+ Inheritance order (with deduplication):
752
+ 1. All team environments
753
+ 2. Team skills
754
+
755
+ Later layers override earlier ones if there are conflicts.
756
+ """
757
+ try:
758
+ client = get_supabase()
759
+
760
+ # Get team details
761
+ team_result = (
762
+ client.table("teams")
763
+ .select("id")
764
+ .eq("organization_id", organization["id"])
765
+ .eq("id", team_id)
766
+ .execute()
767
+ )
768
+
769
+ if not team_result.data:
770
+ raise HTTPException(status_code=404, detail=f"Team {team_id} not found")
771
+
772
+ resolved_skills = []
773
+ seen_ids = set()
774
+
775
+ # 1. Load skills from ALL team environments (many-to-many)
776
+ team_env_result = (
777
+ client.table("team_environments")
778
+ .select("environment_id")
779
+ .eq("team_id", team_id)
780
+ .execute()
781
+ )
782
+
783
+ team_environment_ids = [env["environment_id"] for env in (team_env_result.data or [])]
784
+
785
+ for environment_id in team_environment_ids:
786
+ env_skills = get_entity_skills(client, organization["id"], "environment", environment_id)
787
+ for skill in env_skills:
788
+ if skill["id"] not in seen_ids:
789
+ resolved_skills.append(ResolvedToolSet(
790
+ **skill,
791
+ source="environment",
792
+ inherited=True
793
+ ))
794
+ seen_ids.add(skill["id"])
795
+
796
+ # 2. Load team skills (highest priority)
797
+ team_skills = get_entity_skills(client, organization["id"], "team", team_id)
798
+ for skill in team_skills:
799
+ if skill["id"] not in seen_ids:
800
+ resolved_skills.append(ResolvedToolSet(
801
+ **skill,
802
+ source="team",
803
+ inherited=False
804
+ ))
805
+ seen_ids.add(skill["id"])
806
+
807
+ logger.info(
808
+ "team_skills_resolved",
809
+ team_id=team_id,
810
+ skill_count=len(resolved_skills),
811
+ team_env_count=len(team_environment_ids),
812
+ organization_id=organization["id"]
813
+ )
814
+
815
+ return resolved_skills
816
+
817
+ except HTTPException:
818
+ raise
819
+ except Exception as e:
820
+ logger.error("resolve_team_skills_failed", error=str(e), team_id=team_id)
821
+ raise HTTPException(status_code=500, detail=str(e))
822
+
823
+
824
+ @router.get("/associations/teams/{team_id}/toolsets/resolved", response_model=List[ResolvedToolSet])
825
+ async def resolve_team_toolsets_legacy(
826
+ team_id: str,
827
+ organization: dict = Depends(get_current_organization),
828
+ ):
829
+ """
830
+ DEPRECATED: Legacy endpoint for backward compatibility.
831
+ Use /associations/teams/{team_id}/skills/resolved instead.
832
+
833
+ This endpoint redirects to the new skills endpoint.
834
+ """
835
+ logger.warning(
836
+ "deprecated_toolsets_endpoint_used",
837
+ team_id=team_id,
838
+ endpoint="/associations/teams/{team_id}/toolsets/resolved",
839
+ new_endpoint="/associations/teams/{team_id}/skills/resolved"
840
+ )
841
+ return await resolve_team_skills(team_id, organization)
842
+
843
+
844
+ @router.get("/types")
845
+ async def get_skill_types():
846
+ """Get available skill types and their descriptions"""
847
+ return {
848
+ "types": [
849
+ {
850
+ "type": "file_system",
851
+ "name": "File System",
852
+ "description": "Read, write, list, and search files",
853
+ "icon": "FileText"
854
+ },
855
+ {
856
+ "type": "shell",
857
+ "name": "Shell",
858
+ "description": "Execute shell commands",
859
+ "icon": "Terminal"
860
+ },
861
+ {
862
+ "type": "docker",
863
+ "name": "Docker",
864
+ "description": "Manage containers, images, volumes, and networks",
865
+ "icon": "Container"
866
+ },
867
+ {
868
+ "type": "python",
869
+ "name": "Python",
870
+ "description": "Execute Python code",
871
+ "icon": "Code"
872
+ },
873
+ {
874
+ "type": "file_generation",
875
+ "name": "File Generation",
876
+ "description": "Generate JSON, CSV, PDF, and TXT files",
877
+ "icon": "FileOutput"
878
+ },
879
+ {
880
+ "type": "sleep",
881
+ "name": "Sleep",
882
+ "description": "Pause execution for a specified duration",
883
+ "icon": "Clock"
884
+ },
885
+ {
886
+ "type": "workflow_executor",
887
+ "name": "Workflow Executor",
888
+ "description": "Execute workflows defined via JSON or Python DSL",
889
+ "icon": "Workflow"
890
+ },
891
+ {
892
+ "type": "custom",
893
+ "name": "Custom",
894
+ "description": "User-defined custom skill",
895
+ "icon": "Wrench"
896
+ }
897
+ ]
898
+ }
899
+
900
+
901
+ @router.get("/templates")
902
+ async def get_skill_templates(
903
+ request: Request,
904
+ organization: dict = Depends(get_current_organization),
905
+ ):
906
+ """
907
+ Get detailed skill templates with variants and configuration schemas.
908
+
909
+ This endpoint returns all available skill templates including their variants,
910
+ configuration schemas, default values, and available runners for workflow executor skills.
911
+ Useful for UI forms and skill creation.
912
+ """
913
+ templates = []
914
+
915
+ # Get all registered skills from the skill system
916
+ all_skills = get_all_skills()
917
+
918
+ # Fetch available runners for workflow executor validation
919
+ runners_list = []
920
+ try:
921
+ kubiya_client = get_kubiya_client()
922
+ token = request.state.kubiya_token
923
+ available_runners = await kubiya_client.get_runners(token, organization["id"])
924
+
925
+ if available_runners:
926
+ for runner in available_runners:
927
+ if isinstance(runner, dict):
928
+ runners_list.append({
929
+ "id": runner.get("id"),
930
+ "name": runner.get("name"),
931
+ "status": runner.get("status"),
932
+ "capabilities": runner.get("capabilities", []),
933
+ })
934
+
935
+ logger.info(
936
+ "runners_fetched_for_templates",
937
+ org_id=organization["id"],
938
+ runner_count=len(runners_list)
939
+ )
940
+ except Exception as e:
941
+ logger.warning(
942
+ "failed_to_fetch_runners_for_templates",
943
+ error=str(e),
944
+ org_id=organization["id"]
945
+ )
946
+ # Continue without runners - they're optional
947
+
948
+ for skill_def in all_skills:
949
+ try:
950
+ # Get skill metadata
951
+ template = {
952
+ "type": skill_def.type.value,
953
+ "name": skill_def.name,
954
+ "description": skill_def.description,
955
+ "icon": skill_def.icon,
956
+ "icon_type": skill_def.icon_type,
957
+ "category": skill_def.get_category().value,
958
+ "default_configuration": skill_def.get_default_configuration(),
959
+ "requirements": {
960
+ "supported_os": skill_def.get_requirements().supported_os,
961
+ "min_python_version": skill_def.get_requirements().min_python_version,
962
+ "python_packages": skill_def.get_requirements().python_packages,
963
+ "required_env_vars": skill_def.get_requirements().required_env_vars,
964
+ "notes": skill_def.get_requirements().notes,
965
+ } if skill_def.get_requirements() else None,
966
+ "variants": []
967
+ }
968
+
969
+ # Add available runners for workflow executor skills
970
+ if skill_def.type.value == "workflow_executor":
971
+ template["available_runners"] = runners_list
972
+
973
+ # Get variants for this skill
974
+ variants = skill_def.get_variants()
975
+ for variant in variants:
976
+ template["variants"].append({
977
+ "id": variant.id,
978
+ "name": variant.name,
979
+ "description": variant.description,
980
+ "category": variant.category.value,
981
+ "icon": variant.icon,
982
+ "is_default": variant.is_default,
983
+ "configuration": variant.configuration,
984
+ "tags": variant.tags,
985
+ })
986
+
987
+ templates.append(template)
988
+
989
+ except Exception as e:
990
+ logger.error(
991
+ "failed_to_build_skill_template",
992
+ skill_type=skill_def.type.value if hasattr(skill_def, 'type') else 'unknown',
993
+ error=str(e)
994
+ )
995
+ continue
996
+
997
+ return {
998
+ "templates": templates,
999
+ "count": len(templates),
1000
+ "runners": runners_list # Also return at root level for easy access
1001
+ }