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,310 @@
1
+ """
2
+ Approval workflow tools for human-in-the-loop approval gates.
3
+
4
+ Provides tools for workflows to request approval from authorized users
5
+ and wait for approval/rejection before continuing.
6
+ """
7
+ import os
8
+ import time
9
+ import asyncio
10
+ import httpx
11
+ import structlog
12
+ from typing import List, Optional, Dict, Any
13
+ from datetime import datetime, timedelta
14
+
15
+ logger = structlog.get_logger()
16
+
17
+
18
+ class ApprovalTools:
19
+ """
20
+ Approval workflow tools for human-in-the-loop gates.
21
+
22
+ Provides a temporal-native way to wait for approval from authorized users.
23
+ The workflow pauses execution and polls the control plane for approval status.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ control_plane_url: str,
29
+ api_key: str,
30
+ execution_id: str,
31
+ organization_id: str,
32
+ config: Optional[Dict[str, Any]] = None,
33
+ ):
34
+ """
35
+ Initialize approval tools.
36
+
37
+ Args:
38
+ control_plane_url: Control plane API base URL
39
+ api_key: API key for authentication
40
+ execution_id: Current execution ID
41
+ organization_id: Organization ID
42
+ config: Optional configuration (timeout, require_reason, etc.)
43
+ """
44
+ self.control_plane_url = control_plane_url.rstrip("/")
45
+ self.api_key = api_key
46
+ self.execution_id = execution_id
47
+ self.organization_id = organization_id
48
+ self.config = config or {}
49
+
50
+ # Configuration
51
+ self.timeout_minutes = self.config.get("timeout_minutes", 1440) # 24 hours default
52
+ self.require_approval_reason = self.config.get("require_approval_reason", False)
53
+ self.poll_interval_seconds = self.config.get("poll_interval_seconds", 5) # Poll every 5 seconds
54
+
55
+ self.client = httpx.AsyncClient(
56
+ base_url=self.control_plane_url,
57
+ headers={
58
+ "Authorization": f"Bearer {self.api_key}",
59
+ "Content-Type": "application/json",
60
+ },
61
+ timeout=30.0,
62
+ )
63
+
64
+ async def wait_for_approval(
65
+ self,
66
+ title: str,
67
+ message: Optional[str] = None,
68
+ approver_user_ids: Optional[List[str]] = None,
69
+ approver_user_emails: Optional[List[str]] = None,
70
+ approver_group_id: Optional[str] = None,
71
+ context: Optional[Dict[str, Any]] = None,
72
+ ) -> Dict[str, Any]:
73
+ """
74
+ Wait for approval from authorized users.
75
+
76
+ This function creates an approval request and polls the control plane
77
+ until the request is approved, rejected, or times out.
78
+
79
+ Args:
80
+ title: Brief title for the approval request
81
+ message: Detailed message or reason for approval
82
+ approver_user_ids: List of user IDs who can approve (optional)
83
+ approver_user_emails: List of user emails who can approve (optional)
84
+ approver_group_id: Group ID that can approve (optional)
85
+ context: Additional context data (optional)
86
+
87
+ Returns:
88
+ Dict with approval result:
89
+ {
90
+ "approved": bool,
91
+ "status": "approved" | "rejected" | "expired",
92
+ "approval_id": str,
93
+ "approved_by_email": str (if approved),
94
+ "rejection_reason": str (if rejected),
95
+ "resolved_at": str (ISO timestamp)
96
+ }
97
+
98
+ Raises:
99
+ Exception: If approval request creation fails or times out
100
+ """
101
+ logger.info(
102
+ "wait_for_approval_started",
103
+ title=title,
104
+ execution_id=self.execution_id,
105
+ approver_emails=approver_user_emails,
106
+ )
107
+
108
+ # Validate at least one approver is specified
109
+ if not approver_user_ids and not approver_user_emails and not approver_group_id:
110
+ raise ValueError("At least one of approver_user_ids, approver_user_emails, or approver_group_id must be provided")
111
+
112
+ try:
113
+ # Create approval request via control plane API
114
+ approval_request = {
115
+ "execution_id": self.execution_id,
116
+ "title": title,
117
+ "message": message,
118
+ "approver_user_ids": approver_user_ids or [],
119
+ "approver_user_emails": approver_user_emails or [],
120
+ "approver_group_id": approver_group_id,
121
+ "timeout_minutes": self.timeout_minutes,
122
+ "context": context or {},
123
+ }
124
+
125
+ response = await self.client.post(
126
+ "/api/v1/approvals",
127
+ json=approval_request,
128
+ )
129
+
130
+ if response.status_code != 201:
131
+ error_detail = response.text
132
+ logger.error(
133
+ "approval_request_creation_failed",
134
+ status_code=response.status_code,
135
+ error=error_detail
136
+ )
137
+ raise Exception(f"Failed to create approval request: {error_detail}")
138
+
139
+ approval_data = response.json()
140
+ approval_id = approval_data["id"]
141
+
142
+ logger.info(
143
+ "approval_request_created",
144
+ approval_id=approval_id,
145
+ title=title,
146
+ execution_id=self.execution_id,
147
+ )
148
+
149
+ # Calculate timeout
150
+ start_time = time.time()
151
+ timeout_seconds = self.timeout_minutes * 60
152
+ expires_at = time.time() + timeout_seconds
153
+
154
+ # Poll for approval status
155
+ poll_count = 0
156
+ while time.time() < expires_at:
157
+ poll_count += 1
158
+
159
+ # Get approval status
160
+ try:
161
+ status_response = await self.client.get(
162
+ f"/api/v1/approvals/{approval_id}"
163
+ )
164
+
165
+ if status_response.status_code == 200:
166
+ approval_status = status_response.json()
167
+
168
+ if approval_status["status"] == "approved":
169
+ elapsed_minutes = (time.time() - start_time) / 60
170
+ logger.info(
171
+ "approval_granted",
172
+ approval_id=approval_id,
173
+ approved_by=approval_status.get("approved_by_email"),
174
+ elapsed_minutes=round(elapsed_minutes, 2),
175
+ )
176
+
177
+ return {
178
+ "approved": True,
179
+ "status": "approved",
180
+ "approval_id": approval_id,
181
+ "approved_by_email": approval_status.get("approved_by_email"),
182
+ "approved_by_name": approval_status.get("approved_by_name"),
183
+ "resolved_at": approval_status.get("resolved_at"),
184
+ }
185
+
186
+ elif approval_status["status"] == "rejected":
187
+ elapsed_minutes = (time.time() - start_time) / 60
188
+ logger.info(
189
+ "approval_rejected",
190
+ approval_id=approval_id,
191
+ rejected_by=approval_status.get("approved_by_email"),
192
+ reason=approval_status.get("rejection_reason"),
193
+ elapsed_minutes=round(elapsed_minutes, 2),
194
+ )
195
+
196
+ return {
197
+ "approved": False,
198
+ "status": "rejected",
199
+ "approval_id": approval_id,
200
+ "rejected_by_email": approval_status.get("approved_by_email"),
201
+ "rejected_by_name": approval_status.get("approved_by_name"),
202
+ "rejection_reason": approval_status.get("rejection_reason"),
203
+ "resolved_at": approval_status.get("resolved_at"),
204
+ }
205
+
206
+ elif approval_status["status"] == "expired":
207
+ logger.warning(
208
+ "approval_expired",
209
+ approval_id=approval_id,
210
+ )
211
+
212
+ return {
213
+ "approved": False,
214
+ "status": "expired",
215
+ "approval_id": approval_id,
216
+ "resolved_at": approval_status.get("resolved_at"),
217
+ }
218
+
219
+ # Still pending, continue polling
220
+ if poll_count % 12 == 0: # Log every minute (12 * 5 seconds)
221
+ elapsed_minutes = (time.time() - start_time) / 60
222
+ remaining_minutes = (expires_at - time.time()) / 60
223
+ logger.debug(
224
+ "waiting_for_approval",
225
+ approval_id=approval_id,
226
+ status=approval_status["status"],
227
+ elapsed_minutes=round(elapsed_minutes, 2),
228
+ remaining_minutes=round(remaining_minutes, 2),
229
+ )
230
+
231
+ else:
232
+ logger.warning(
233
+ "approval_status_check_failed",
234
+ approval_id=approval_id,
235
+ status_code=status_response.status_code,
236
+ )
237
+
238
+ except Exception as poll_error:
239
+ logger.warning(
240
+ "approval_poll_error",
241
+ approval_id=approval_id,
242
+ error=str(poll_error),
243
+ )
244
+
245
+ # Wait before next poll
246
+ await asyncio.sleep(self.poll_interval_seconds)
247
+
248
+ # Timeout reached
249
+ logger.warning(
250
+ "approval_timeout",
251
+ approval_id=approval_id,
252
+ timeout_minutes=self.timeout_minutes,
253
+ )
254
+
255
+ return {
256
+ "approved": False,
257
+ "status": "expired",
258
+ "approval_id": approval_id,
259
+ "resolved_at": datetime.utcnow().isoformat(),
260
+ }
261
+
262
+ except Exception as e:
263
+ logger.error(
264
+ "wait_for_approval_failed",
265
+ title=title,
266
+ execution_id=self.execution_id,
267
+ error=str(e),
268
+ )
269
+ raise
270
+
271
+ finally:
272
+ await self.client.aclose()
273
+
274
+ def get_tools_schema(self) -> List[Dict[str, Any]]:
275
+ """
276
+ Get the tool schema for LLM function calling.
277
+
278
+ Returns list of tool definitions that can be provided to LLMs.
279
+ """
280
+ return [
281
+ {
282
+ "name": "wait_for_approval",
283
+ "description": "Pause workflow execution and wait for approval from authorized users before continuing. "
284
+ "Use this when you need human approval for sensitive operations, decisions, or actions. "
285
+ "The workflow will pause until an authorized user approves or rejects the request.",
286
+ "input_schema": {
287
+ "type": "object",
288
+ "properties": {
289
+ "title": {
290
+ "type": "string",
291
+ "description": "Brief title for the approval request (e.g., 'Deploy to Production', 'Delete Database')"
292
+ },
293
+ "message": {
294
+ "type": "string",
295
+ "description": "Detailed message explaining why approval is needed and what will happen if approved"
296
+ },
297
+ "approver_user_emails": {
298
+ "type": "array",
299
+ "items": {"type": "string"},
300
+ "description": "List of user email addresses who can approve this request"
301
+ },
302
+ "context": {
303
+ "type": "object",
304
+ "description": "Additional context data to help approvers make a decision"
305
+ }
306
+ },
307
+ "required": ["title", "approver_user_emails"]
308
+ }
309
+ }
310
+ ]
@@ -0,0 +1,207 @@
1
+ """
2
+ Agno-compatible approval workflow tools.
3
+
4
+ Provides wait_for_approval tool as an Agno Tool for seamless integration
5
+ with Agno agent runtimes.
6
+ """
7
+ import os
8
+ from typing import List, Optional, Dict, Any
9
+ import structlog
10
+ from control_plane_api.worker.services.approval_tools import ApprovalTools
11
+
12
+ logger = structlog.get_logger()
13
+
14
+
15
+ class ApprovalToolkit:
16
+ """
17
+ Agno toolkit for approval workflow (human-in-the-loop gates).
18
+
19
+ Provides tools for workflows to request approval from authorized users
20
+ and wait for approval/rejection before continuing.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ control_plane_client: Any,
26
+ config: Optional[Dict[str, Any]] = None,
27
+ ):
28
+ """
29
+ Initialize approval toolkit.
30
+
31
+ Args:
32
+ control_plane_client: Control plane client (provides URL and credentials)
33
+ config: Optional configuration (timeout, etc.)
34
+ """
35
+ self.control_plane_client = control_plane_client
36
+ self.config = config or {}
37
+
38
+ # Store environment variable names - will be accessed at tool execution time
39
+ self.control_plane_url = None
40
+ self.api_key = None
41
+
42
+ # execution_id and organization_id will be set at runtime
43
+ self.execution_id = None
44
+ self.organization_id = None
45
+
46
+ logger.info("approval_toolkit_initialized")
47
+
48
+ def set_execution_context(self, execution_id: str, organization_id: str):
49
+ """
50
+ Set execution context (called by runtime before tool execution).
51
+
52
+ Args:
53
+ execution_id: Current execution ID
54
+ organization_id: Organization ID
55
+ """
56
+ self.execution_id = execution_id
57
+ self.organization_id = organization_id
58
+
59
+ async def wait_for_approval(
60
+ self,
61
+ title: str,
62
+ approver_user_emails: Optional[List[str]] = None,
63
+ approver_group_id: Optional[str] = None,
64
+ message: Optional[str] = None,
65
+ context: Optional[Dict[str, Any]] = None,
66
+ ) -> str:
67
+ """
68
+ Wait for approval from authorized users or groups before continuing.
69
+
70
+ This tool creates an approval request and pauses the workflow until
71
+ an authorized user approves or rejects the request. Use this for:
72
+ - Deployments to production
73
+ - Destructive operations (deletions, etc.)
74
+ - High-value transactions
75
+ - Policy-gated actions
76
+
77
+ APPROVERS: You can specify approvers in three ways:
78
+ 1. Pass approver_user_emails parameter (list of emails)
79
+ 2. Pass approver_group_id parameter (group UUID)
80
+ 3. Use defaults from skill configuration (if configured)
81
+
82
+ If you don't specify approvers, the tool will use the default approvers
83
+ configured in the skill settings.
84
+
85
+ Args:
86
+ title: Brief title for the approval request (e.g., "Deploy to Production")
87
+ approver_user_emails: List of user email addresses who can approve (optional, uses config default if not provided)
88
+ approver_group_id: Group UUID whose members can approve (optional, uses config default if not provided)
89
+ message: Detailed message explaining why approval is needed (optional)
90
+ context: Additional context data to help approvers decide (optional)
91
+
92
+ Returns:
93
+ str: Approval result message
94
+
95
+ Examples:
96
+ ```python
97
+ # Approve by specific users
98
+ result = await wait_for_approval(
99
+ title="Deploy to Production",
100
+ message="Deploy version 2.0.0 to production environment",
101
+ approver_user_emails=["ops-lead@company.com", "cto@company.com"],
102
+ context={"version": "2.0.0", "environment": "production"}
103
+ )
104
+
105
+ # Approve by group
106
+ result = await wait_for_approval(
107
+ title="Delete Customer Data",
108
+ message="Permanently delete customer data for GDPR request",
109
+ approver_group_id="550e8400-e29b-41d4-a716-446655440000", # Admin group UUID
110
+ )
111
+
112
+ # Approve by group OR specific users
113
+ result = await wait_for_approval(
114
+ title="Emergency Hotfix",
115
+ message="Deploy critical security patch",
116
+ approver_user_emails=["security-lead@company.com"],
117
+ approver_group_id="550e8400-e29b-41d4-a716-446655440000", # Ops group UUID
118
+ )
119
+ ```
120
+ """
121
+ if not self.execution_id or not self.organization_id:
122
+ return "❌ Error: Approval toolkit not initialized with execution context"
123
+
124
+ # Get control plane URL and API key from environment at execution time
125
+ self.control_plane_url = os.getenv("CONTROL_PLANE_URL")
126
+ self.api_key = os.getenv("KUBIYA_API_KEY")
127
+
128
+ if not self.control_plane_url or not self.api_key:
129
+ return "❌ Error: CONTROL_PLANE_URL and KUBIYA_API_KEY environment variables must be set"
130
+
131
+ # Use config defaults if no approvers specified
132
+ if not approver_user_emails and not approver_group_id:
133
+ # Try to get defaults from config
134
+ approver_user_emails = self.config.get("default_approver_emails")
135
+ approver_group_id = self.config.get("default_approver_group_id")
136
+
137
+ # Still no approvers? Error
138
+ if not approver_user_emails and not approver_group_id:
139
+ return (
140
+ "❌ Error: No approvers specified. Either:\n"
141
+ "1. Pass approver_user_emails or approver_group_id to this tool call, OR\n"
142
+ "2. Configure default approvers in the skill configuration"
143
+ )
144
+
145
+ logger.info(
146
+ "wait_for_approval_tool_called",
147
+ title=title,
148
+ approver_emails=approver_user_emails,
149
+ approver_group_id=approver_group_id,
150
+ execution_id=self.execution_id,
151
+ )
152
+
153
+ try:
154
+ # Create approval tools instance for this execution
155
+ approval_tools = ApprovalTools(
156
+ control_plane_url=self.control_plane_url,
157
+ api_key=self.api_key,
158
+ execution_id=self.execution_id,
159
+ organization_id=self.organization_id,
160
+ config=self.config,
161
+ )
162
+
163
+ # Call underlying approval tools
164
+ result = await approval_tools.wait_for_approval(
165
+ title=title,
166
+ message=message,
167
+ approver_user_emails=approver_user_emails or [],
168
+ approver_group_id=approver_group_id,
169
+ context=context,
170
+ )
171
+
172
+ if result["approved"]:
173
+ approved_by = result.get("approved_by_email", "unknown")
174
+ return (
175
+ f"✅ Approval granted by {approved_by}. "
176
+ f"You may proceed with '{title}'."
177
+ )
178
+ elif result["status"] == "rejected":
179
+ rejected_by = result.get("rejected_by_email", "unknown")
180
+ reason = result.get("rejection_reason", "No reason provided")
181
+ return (
182
+ f"❌ Request rejected by {rejected_by}. "
183
+ f"Reason: {reason}. "
184
+ f"You must not proceed with '{title}'."
185
+ )
186
+ elif result["status"] == "expired":
187
+ return (
188
+ f"⏱️ Approval request expired without response. "
189
+ f"You must not proceed with '{title}' without approval."
190
+ )
191
+ else:
192
+ return (
193
+ f"⚠️ Approval request ended with status: {result['status']}. "
194
+ f"You must not proceed with '{title}' without explicit approval."
195
+ )
196
+
197
+ except Exception as e:
198
+ logger.error(
199
+ "wait_for_approval_tool_error",
200
+ error=str(e),
201
+ title=title,
202
+ execution_id=self.execution_id,
203
+ )
204
+ return (
205
+ f"❌ Failed to request approval: {str(e)}. "
206
+ f"You must not proceed with '{title}' due to approval system error."
207
+ )
@@ -0,0 +1,177 @@
1
+ """Cancellation manager - handles agent/team registry and cancellation"""
2
+
3
+ from typing import Dict, Any, Optional
4
+ from datetime import datetime, timezone
5
+ import structlog
6
+
7
+ logger = structlog.get_logger()
8
+
9
+
10
+ class CancellationManager:
11
+ """
12
+ Manages active agent/team instances for cancellation support.
13
+
14
+ Provides a centralized registry and cancellation logic that works
15
+ with Agno's cancel_run() API.
16
+ """
17
+
18
+ def __init__(self):
19
+ # Key: execution_id, Value: {agent/team, run_id, started_at}
20
+ self._registry: Dict[str, Dict[str, Any]] = {}
21
+
22
+ def register(
23
+ self,
24
+ execution_id: str,
25
+ instance: Any, # Agent or Team
26
+ instance_type: str = "agent"
27
+ ) -> None:
28
+ """
29
+ Register an agent or team for cancellation support.
30
+
31
+ Args:
32
+ execution_id: Unique execution ID
33
+ instance: Agno Agent or Team instance
34
+ instance_type: "agent" or "team"
35
+ """
36
+ self._registry[execution_id] = {
37
+ "instance": instance,
38
+ "instance_type": instance_type,
39
+ "run_id": None, # Set when run starts
40
+ "started_at": datetime.now(timezone.utc).isoformat(),
41
+ }
42
+
43
+ logger.info(
44
+ f"{instance_type}_registered_for_cancellation",
45
+ execution_id=execution_id[:8],
46
+ instance_type=instance_type
47
+ )
48
+
49
+ def set_run_id(self, execution_id: str, run_id: str) -> None:
50
+ """
51
+ Set the Agno run_id for an execution (captured from first streaming chunk).
52
+
53
+ Args:
54
+ execution_id: Execution ID
55
+ run_id: Agno run_id from streaming response
56
+ """
57
+ if execution_id in self._registry:
58
+ self._registry[execution_id]["run_id"] = run_id
59
+ logger.info(
60
+ "run_id_captured",
61
+ execution_id=execution_id[:8],
62
+ run_id=run_id[:16]
63
+ )
64
+
65
+ def cancel(self, execution_id: str) -> Dict[str, Any]:
66
+ """
67
+ Cancel an active execution using Agno's cancel_run API.
68
+
69
+ Args:
70
+ execution_id: Execution to cancel
71
+
72
+ Returns:
73
+ Dict with success status and details
74
+ """
75
+ # Check if execution exists in registry
76
+ if execution_id not in self._registry:
77
+ logger.warning(
78
+ "cancel_execution_not_found",
79
+ execution_id=execution_id[:8]
80
+ )
81
+ return {
82
+ "success": False,
83
+ "error": "Execution not found or already completed",
84
+ "execution_id": execution_id,
85
+ }
86
+
87
+ entry = self._registry[execution_id]
88
+ instance = entry["instance"]
89
+ run_id = entry.get("run_id")
90
+ instance_type = entry.get("instance_type", "agent")
91
+
92
+ # Check if run has started
93
+ if not run_id:
94
+ logger.warning(
95
+ "cancel_no_run_id",
96
+ execution_id=execution_id[:8]
97
+ )
98
+ return {
99
+ "success": False,
100
+ "error": "Execution not started yet",
101
+ "execution_id": execution_id,
102
+ }
103
+
104
+ logger.info(
105
+ f"cancelling_{instance_type}_run",
106
+ execution_id=execution_id[:8],
107
+ run_id=run_id[:16]
108
+ )
109
+
110
+ try:
111
+ # Call Agno's cancel_run API
112
+ success = instance.cancel_run(run_id)
113
+
114
+ if success:
115
+ logger.info(
116
+ f"{instance_type}_run_cancelled",
117
+ execution_id=execution_id[:8],
118
+ run_id=run_id[:16]
119
+ )
120
+
121
+ # Clean up registry
122
+ del self._registry[execution_id]
123
+
124
+ return {
125
+ "success": True,
126
+ "execution_id": execution_id,
127
+ "run_id": run_id,
128
+ "instance_type": instance_type,
129
+ "cancelled_at": datetime.now(timezone.utc).isoformat(),
130
+ }
131
+ else:
132
+ logger.warning(
133
+ f"{instance_type}_cancel_failed",
134
+ execution_id=execution_id[:8],
135
+ run_id=run_id[:16]
136
+ )
137
+ return {
138
+ "success": False,
139
+ "error": "Cancel failed - run may be completed",
140
+ "execution_id": execution_id,
141
+ "run_id": run_id,
142
+ }
143
+
144
+ except Exception as e:
145
+ logger.error(
146
+ f"{instance_type}_cancel_error",
147
+ execution_id=execution_id[:8],
148
+ error=str(e)
149
+ )
150
+ return {
151
+ "success": False,
152
+ "error": str(e),
153
+ "execution_id": execution_id,
154
+ }
155
+
156
+ def unregister(self, execution_id: str) -> None:
157
+ """
158
+ Remove an execution from the registry (called on completion).
159
+
160
+ Args:
161
+ execution_id: Execution to unregister
162
+ """
163
+ if execution_id in self._registry:
164
+ instance_type = self._registry[execution_id].get("instance_type", "agent")
165
+ del self._registry[execution_id]
166
+ logger.info(
167
+ f"{instance_type}_unregistered",
168
+ execution_id=execution_id[:8]
169
+ )
170
+
171
+ def get_active_count(self) -> int:
172
+ """Get number of active executions in registry."""
173
+ return len(self._registry)
174
+
175
+
176
+ # Global singleton instance
177
+ cancellation_manager = CancellationManager()