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
File without changes
@@ -0,0 +1,589 @@
1
+ """Agent execution workflow for Temporal"""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import timedelta
5
+ from typing import Optional, List, Dict, Any
6
+ from temporalio import workflow
7
+ import asyncio
8
+
9
+ with workflow.unsafe.imports_passed_through():
10
+ from control_plane_api.worker.activities.agent_activities import (
11
+ execute_agent_llm,
12
+ update_execution_status,
13
+ update_agent_status,
14
+ persist_conversation_history,
15
+ ActivityExecuteAgentInput,
16
+ ActivityUpdateExecutionInput,
17
+ ActivityUpdateAgentInput,
18
+ ActivityPersistConversationInput,
19
+ )
20
+ from control_plane_api.worker.activities.runtime_activities import (
21
+ execute_with_runtime,
22
+ ActivityRuntimeExecuteInput,
23
+ )
24
+
25
+
26
+ @dataclass
27
+ class AgentExecutionInput:
28
+ """Input for agent execution workflow"""
29
+ execution_id: str
30
+ agent_id: str
31
+ organization_id: str
32
+ prompt: str
33
+ system_prompt: Optional[str] = None
34
+ model_id: Optional[str] = None
35
+ model_config: dict = None
36
+ agent_config: dict = None
37
+ mcp_servers: dict = None # MCP servers configuration
38
+ user_metadata: dict = None
39
+ runtime_type: str = "default" # "default" (Agno) or "claude_code"
40
+
41
+ def __post_init__(self):
42
+ if self.model_config is None:
43
+ self.model_config = {}
44
+ if self.agent_config is None:
45
+ self.agent_config = {}
46
+ if self.mcp_servers is None:
47
+ self.mcp_servers = {}
48
+ if self.user_metadata is None:
49
+ self.user_metadata = {}
50
+
51
+
52
+ @dataclass
53
+ class TeamExecutionInput:
54
+ """Input for team execution workflow (uses same workflow as agent)"""
55
+ execution_id: str
56
+ team_id: str
57
+ organization_id: str
58
+ prompt: str
59
+ system_prompt: Optional[str] = None
60
+ model_id: Optional[str] = None
61
+ model_config: dict = None
62
+ team_config: dict = None
63
+ mcp_servers: dict = None # MCP servers configuration
64
+ user_metadata: dict = None
65
+ runtime_type: str = "default" # "default" (Agno) or "claude_code"
66
+
67
+ def __post_init__(self):
68
+ if self.model_config is None:
69
+ self.model_config = {}
70
+ if self.team_config is None:
71
+ self.team_config = {}
72
+ if self.mcp_servers is None:
73
+ self.mcp_servers = {}
74
+ if self.user_metadata is None:
75
+ self.user_metadata = {}
76
+
77
+ def to_agent_input(self) -> AgentExecutionInput:
78
+ """Convert TeamExecutionInput to AgentExecutionInput for workflow reuse"""
79
+ return AgentExecutionInput(
80
+ execution_id=self.execution_id,
81
+ agent_id=self.team_id, # Use team_id as agent_id
82
+ organization_id=self.organization_id,
83
+ prompt=self.prompt,
84
+ system_prompt=self.system_prompt,
85
+ model_id=self.model_id,
86
+ model_config=self.model_config,
87
+ agent_config=self.team_config,
88
+ mcp_servers=self.mcp_servers,
89
+ user_metadata=self.user_metadata,
90
+ runtime_type=self.runtime_type,
91
+ )
92
+
93
+
94
+ @dataclass
95
+ class ChatMessage:
96
+ """Represents a message in the conversation"""
97
+ role: str # "user", "assistant", "system", "tool"
98
+ content: str
99
+ timestamp: str
100
+ tool_name: Optional[str] = None
101
+ tool_input: Optional[Dict[str, Any]] = None
102
+ tool_output: Optional[Dict[str, Any]] = None
103
+
104
+
105
+ @dataclass
106
+ class ExecutionState:
107
+ """Current state of the execution for queries"""
108
+ status: str # "pending", "running", "waiting_for_input", "completed", "failed"
109
+ messages: List[ChatMessage] = field(default_factory=list)
110
+ current_response: str = ""
111
+ error_message: Optional[str] = None
112
+ usage: Dict[str, Any] = field(default_factory=dict)
113
+ metadata: Dict[str, Any] = field(default_factory=dict)
114
+ is_waiting_for_input: bool = False
115
+ should_complete: bool = False
116
+
117
+
118
+ @workflow.defn
119
+ class AgentExecutionWorkflow:
120
+ """
121
+ Workflow for executing an agent with LLM with Temporal message passing support.
122
+
123
+ This workflow:
124
+ 1. Updates execution status to running
125
+ 2. Executes the agent's LLM call
126
+ 3. Updates execution with results
127
+ 4. Updates agent status
128
+ 5. Supports queries for real-time state access
129
+ 6. Supports signals for adding followup messages
130
+ """
131
+
132
+ def __init__(self) -> None:
133
+ """Initialize workflow state"""
134
+ self._state = ExecutionState(status="pending")
135
+ self._lock = asyncio.Lock()
136
+ self._new_message_count = 0
137
+ self._processed_message_count = 0
138
+
139
+ def _messages_to_dict(self, messages: List[ChatMessage]) -> List[Dict[str, Any]]:
140
+ """
141
+ Convert ChatMessage objects to dict format for persistence.
142
+
143
+ This ensures the conversation history is in a clean, serializable format
144
+ that can be stored in the database and retrieved later.
145
+
146
+ Args:
147
+ messages: List of ChatMessage objects from workflow state
148
+
149
+ Returns:
150
+ List of message dicts ready for persistence
151
+ """
152
+ return [
153
+ {
154
+ "role": msg.role,
155
+ "content": msg.content,
156
+ "timestamp": msg.timestamp,
157
+ "tool_name": msg.tool_name,
158
+ "tool_input": msg.tool_input,
159
+ "tool_output": msg.tool_output,
160
+ }
161
+ for msg in messages
162
+ ]
163
+
164
+ @workflow.query
165
+ def get_state(self) -> ExecutionState:
166
+ """Query handler: Get current execution state including messages and status"""
167
+ return self._state
168
+
169
+ @workflow.signal
170
+ async def add_message(self, message: ChatMessage) -> None:
171
+ """
172
+ Signal handler: Add a message to the conversation.
173
+ This allows clients to send followup messages while the workflow is running.
174
+ The workflow will wake up and process this message.
175
+ """
176
+ async with self._lock:
177
+ self._state.messages.append(message)
178
+ self._new_message_count += 1
179
+ self._state.is_waiting_for_input = False
180
+ workflow.logger.info(
181
+ f"Message added to conversation",
182
+ extra={
183
+ "role": message.role,
184
+ "content_preview": message.content[:100] if message.content else "",
185
+ "total_messages": len(self._state.messages)
186
+ }
187
+ )
188
+
189
+ @workflow.signal
190
+ async def mark_as_done(self) -> None:
191
+ """
192
+ Signal handler: Mark the workflow as complete.
193
+ This signals that the user is done with the conversation and the workflow should complete.
194
+ """
195
+ async with self._lock:
196
+ self._state.should_complete = True
197
+ self._state.is_waiting_for_input = False
198
+ workflow.logger.info("Workflow marked as done by user")
199
+
200
+ @workflow.run
201
+ async def run(self, input: AgentExecutionInput) -> dict:
202
+ """
203
+ Run the agent execution workflow with Human-in-the-Loop (HITL) pattern.
204
+
205
+ This workflow implements a continuous conversation loop:
206
+ 1. Process the initial user message
207
+ 2. Execute LLM and return response
208
+ 3. Wait for user input (signals)
209
+ 4. Process followup messages in a loop
210
+ 5. Only complete when user explicitly marks as done
211
+
212
+ Args:
213
+ input: Workflow input with execution details
214
+
215
+ Returns:
216
+ Execution result dict with response, usage, etc.
217
+ """
218
+ workflow.logger.info(
219
+ f"Starting agent execution workflow with HITL pattern",
220
+ extra={
221
+ "execution_id": input.execution_id,
222
+ "agent_id": input.agent_id,
223
+ "organization_id": input.organization_id,
224
+ }
225
+ )
226
+
227
+ # Initialize state with user's initial message
228
+ self._state.messages.append(ChatMessage(
229
+ role="user",
230
+ content=input.prompt,
231
+ timestamp=workflow.now().isoformat(),
232
+ ))
233
+ self._state.status = "running"
234
+ self._new_message_count = 1 # Initial message counts as a new message
235
+ self._processed_message_count = 0 # No messages processed yet (no response)
236
+
237
+ try:
238
+ # Step 1: Update execution status to running
239
+ await workflow.execute_activity(
240
+ update_execution_status,
241
+ ActivityUpdateExecutionInput(
242
+ execution_id=input.execution_id,
243
+ status="running",
244
+ started_at=workflow.now().isoformat(),
245
+ execution_metadata={
246
+ "workflow_started": True,
247
+ "has_mcp_servers": bool(input.mcp_servers),
248
+ "mcp_server_count": len(input.mcp_servers) if input.mcp_servers else 0,
249
+ "hitl_enabled": True,
250
+ },
251
+ ),
252
+ start_to_close_timeout=timedelta(seconds=30),
253
+ )
254
+
255
+ # Step 2: Update agent status to running
256
+ await workflow.execute_activity(
257
+ update_agent_status,
258
+ ActivityUpdateAgentInput(
259
+ agent_id=input.agent_id,
260
+ organization_id=input.organization_id,
261
+ status="running",
262
+ last_active_at=workflow.now().isoformat(),
263
+ ),
264
+ start_to_close_timeout=timedelta(seconds=30),
265
+ )
266
+
267
+ # HITL Conversation Loop - Continue until user marks as done
268
+ conversation_turn = 0
269
+ while not self._state.should_complete:
270
+ conversation_turn += 1
271
+ workflow.logger.info(
272
+ f"Starting conversation turn {conversation_turn}",
273
+ extra={"turn": conversation_turn, "message_count": len(self._state.messages)}
274
+ )
275
+
276
+ # Get the latest user message (last message added)
277
+ latest_message = self._state.messages[-1] if self._state.messages else None
278
+ latest_prompt = latest_message.content if latest_message and latest_message.role == "user" else input.prompt
279
+
280
+ # Execute using RuntimeFactory (supports both "default" Agno and "claude_code")
281
+ workflow.logger.info(
282
+ f"Executing with runtime: {input.runtime_type}",
283
+ extra={"runtime_type": input.runtime_type, "turn": conversation_turn}
284
+ )
285
+
286
+ llm_result = await workflow.execute_activity(
287
+ execute_with_runtime,
288
+ ActivityRuntimeExecuteInput(
289
+ execution_id=input.execution_id,
290
+ agent_id=input.agent_id,
291
+ organization_id=input.organization_id,
292
+ prompt=latest_prompt, # Current turn's prompt
293
+ runtime_type=input.runtime_type,
294
+ system_prompt=input.system_prompt,
295
+ model_id=input.model_id,
296
+ model_config=input.model_config,
297
+ agent_config=input.agent_config,
298
+ mcp_servers=input.mcp_servers,
299
+ conversation_history=[], # Agno manages history via session_id
300
+ user_metadata=input.user_metadata,
301
+ runtime_config={
302
+ "session_id": input.execution_id, # For Agno runtime
303
+ },
304
+ stream=True, # Enable streaming for real-time updates
305
+ ),
306
+ start_to_close_timeout=timedelta(minutes=10),
307
+ )
308
+
309
+ # Add tool execution status messages (real-time updates)
310
+ if llm_result.get("tool_execution_messages"):
311
+ async with self._lock:
312
+ for tool_msg in llm_result["tool_execution_messages"]:
313
+ self._state.messages.append(ChatMessage(
314
+ role="system",
315
+ content=tool_msg.get("content", ""),
316
+ timestamp=tool_msg.get("timestamp", workflow.now().isoformat()),
317
+ tool_name=tool_msg.get("tool_name"),
318
+ ))
319
+
320
+ # Add tool messages to state (detailed tool info)
321
+ if llm_result.get("tool_messages"):
322
+ async with self._lock:
323
+ for tool_msg in llm_result["tool_messages"]:
324
+ self._state.messages.append(ChatMessage(
325
+ role="tool",
326
+ content=tool_msg.get("content", ""),
327
+ timestamp=tool_msg.get("timestamp", workflow.now().isoformat()),
328
+ tool_name=tool_msg.get("tool_name"),
329
+ tool_input=tool_msg.get("tool_input"),
330
+ ))
331
+
332
+ # Update state with assistant response
333
+ if llm_result.get("response"):
334
+ async with self._lock:
335
+ self._state.messages.append(ChatMessage(
336
+ role="assistant",
337
+ content=llm_result["response"],
338
+ timestamp=workflow.now().isoformat(),
339
+ ))
340
+ self._state.current_response = llm_result["response"]
341
+ self._processed_message_count += 1
342
+
343
+ # Update usage and metadata (accumulate across turns)
344
+ if llm_result.get("usage"):
345
+ # Accumulate token usage across conversation turns
346
+ current_usage = self._state.usage
347
+ new_usage = llm_result.get("usage", {})
348
+ self._state.usage = {
349
+ "prompt_tokens": current_usage.get("prompt_tokens", 0) + new_usage.get("prompt_tokens", 0),
350
+ "completion_tokens": current_usage.get("completion_tokens", 0) + new_usage.get("completion_tokens", 0),
351
+ "total_tokens": current_usage.get("total_tokens", 0) + new_usage.get("total_tokens", 0),
352
+ }
353
+
354
+ # Update metadata with latest turn info
355
+ self._state.metadata.update({
356
+ "model": llm_result.get("model"),
357
+ "latest_finish_reason": llm_result.get("finish_reason"),
358
+ "mcp_tools_used": self._state.metadata.get("mcp_tools_used", 0) + llm_result.get("mcp_tools_used", 0),
359
+ "latest_run_id": llm_result.get("run_id"),
360
+ "conversation_turns": conversation_turn,
361
+ })
362
+
363
+ # Extract session_id from runtime result for conversation continuity
364
+ # This enables multi-turn conversations in Claude Code runtime
365
+ llm_metadata = llm_result.get("metadata", {})
366
+ if "claude_code_session_id" in llm_metadata:
367
+ # Update input.user_metadata so next turn can resume the session
368
+ if not input.user_metadata:
369
+ input.user_metadata = {}
370
+ input.user_metadata["claude_code_session_id"] = llm_metadata["claude_code_session_id"]
371
+ workflow.logger.info(
372
+ f"Updated user_metadata with session_id for turn continuity",
373
+ extra={
374
+ "turn": conversation_turn,
375
+ "session_id": llm_metadata["claude_code_session_id"][:16]
376
+ }
377
+ )
378
+
379
+ # Persist conversation history after each turn
380
+ # This ensures conversation state is saved end-to-end, runtime-agnostic
381
+ workflow.logger.info(
382
+ f"Persisting conversation after turn {conversation_turn}",
383
+ extra={"turn": conversation_turn, "message_count": len(self._state.messages)}
384
+ )
385
+
386
+ try:
387
+ persist_result = await workflow.execute_activity(
388
+ persist_conversation_history,
389
+ ActivityPersistConversationInput(
390
+ execution_id=input.execution_id,
391
+ session_id=input.execution_id, # Use execution_id as session_id
392
+ messages=self._messages_to_dict(self._state.messages),
393
+ user_id=input.user_metadata.get("user_id") if input.user_metadata else None,
394
+ metadata={
395
+ "agent_id": input.agent_id,
396
+ "organization_id": input.organization_id,
397
+ "conversation_turn": conversation_turn,
398
+ "total_messages": len(self._state.messages),
399
+ },
400
+ ),
401
+ start_to_close_timeout=timedelta(seconds=30),
402
+ )
403
+
404
+ if persist_result.get("success"):
405
+ workflow.logger.info(
406
+ f"✅ Conversation persisted for turn {conversation_turn}",
407
+ extra={
408
+ "turn": conversation_turn,
409
+ "message_count": persist_result.get("message_count", len(self._state.messages))
410
+ }
411
+ )
412
+ else:
413
+ workflow.logger.warning(
414
+ f"⚠️ Persistence returned failure for turn {conversation_turn}",
415
+ extra={
416
+ "turn": conversation_turn,
417
+ "error": persist_result.get("error", "Unknown error")
418
+ }
419
+ )
420
+
421
+ except Exception as persist_error:
422
+ # Log but don't fail the workflow if persistence fails
423
+ # Extract useful error information
424
+ error_type = type(persist_error).__name__
425
+ error_msg = str(persist_error) if str(persist_error) else "No error message"
426
+
427
+ workflow.logger.error(
428
+ f"❌ Failed to persist conversation for turn {conversation_turn}",
429
+ extra={
430
+ "turn": conversation_turn,
431
+ "error_type": error_type,
432
+ "error": error_msg[:200], # Truncate long errors
433
+ "message_count": len(self._state.messages),
434
+ }
435
+ )
436
+
437
+ # Check if LLM call failed
438
+ if not llm_result.get("success"):
439
+ self._state.status = "failed"
440
+ self._state.error_message = llm_result.get("error")
441
+ break
442
+
443
+ # Update execution status to waiting_for_input
444
+ self._state.status = "waiting_for_input"
445
+ self._state.is_waiting_for_input = True
446
+
447
+ # Update database to reflect waiting state
448
+ await workflow.execute_activity(
449
+ update_execution_status,
450
+ ActivityUpdateExecutionInput(
451
+ execution_id=input.execution_id,
452
+ status="waiting_for_input",
453
+ response=self._state.current_response,
454
+ usage=self._state.usage,
455
+ execution_metadata={
456
+ **self._state.metadata,
457
+ "conversation_turns": conversation_turn,
458
+ "waiting_for_user": True,
459
+ },
460
+ ),
461
+ start_to_close_timeout=timedelta(seconds=30),
462
+ )
463
+
464
+ workflow.logger.info(
465
+ f"Waiting for user input after turn {conversation_turn}",
466
+ extra={"turn": conversation_turn}
467
+ )
468
+
469
+ # Wait for either:
470
+ # 1. New message from user (add_message signal)
471
+ # 2. User marks as done (mark_as_done signal)
472
+ # 3. Timeout (24 hours for long-running conversations)
473
+ await workflow.wait_condition(
474
+ lambda: self._new_message_count > self._processed_message_count or self._state.should_complete,
475
+ timeout=timedelta(hours=24)
476
+ )
477
+
478
+ # Don't update processed count here - it will be updated after we add the assistant's response
479
+
480
+ if self._state.should_complete:
481
+ workflow.logger.info("User marked workflow as done")
482
+ break
483
+
484
+ # Continue loop to process new message
485
+ self._state.status = "running"
486
+
487
+ # Conversation complete - finalize workflow
488
+ final_status = "failed" if self._state.status == "failed" else "completed"
489
+ self._state.status = final_status
490
+
491
+ await workflow.execute_activity(
492
+ update_execution_status,
493
+ ActivityUpdateExecutionInput(
494
+ execution_id=input.execution_id,
495
+ status=final_status,
496
+ completed_at=workflow.now().isoformat(),
497
+ response=self._state.current_response,
498
+ error_message=self._state.error_message,
499
+ usage=self._state.usage,
500
+ execution_metadata={
501
+ **self._state.metadata,
502
+ "workflow_completed": True,
503
+ "total_conversation_turns": conversation_turn,
504
+ },
505
+ ),
506
+ start_to_close_timeout=timedelta(seconds=30),
507
+ )
508
+
509
+ # Update agent final status
510
+ agent_final_status = "completed" if final_status == "completed" else "failed"
511
+ await workflow.execute_activity(
512
+ update_agent_status,
513
+ ActivityUpdateAgentInput(
514
+ agent_id=input.agent_id,
515
+ organization_id=input.organization_id,
516
+ status=agent_final_status,
517
+ last_active_at=workflow.now().isoformat(),
518
+ error_message=self._state.error_message if final_status == "failed" else None,
519
+ ),
520
+ start_to_close_timeout=timedelta(seconds=30),
521
+ )
522
+
523
+ workflow.logger.info(
524
+ f"Agent execution workflow completed with HITL",
525
+ extra={
526
+ "execution_id": input.execution_id,
527
+ "status": final_status,
528
+ "conversation_turns": conversation_turn,
529
+ }
530
+ )
531
+
532
+ return {
533
+ "success": final_status == "completed",
534
+ "execution_id": input.execution_id,
535
+ "status": final_status,
536
+ "response": self._state.current_response,
537
+ "usage": self._state.usage,
538
+ "conversation_turns": conversation_turn,
539
+ }
540
+
541
+ except Exception as e:
542
+ # Update state with error
543
+ self._state.status = "failed"
544
+ self._state.error_message = str(e)
545
+ self._state.metadata["error_type"] = type(e).__name__
546
+
547
+ workflow.logger.error(
548
+ f"Agent execution workflow failed",
549
+ extra={
550
+ "execution_id": input.execution_id,
551
+ "error": str(e),
552
+ }
553
+ )
554
+
555
+ # Update execution as failed
556
+ try:
557
+ await workflow.execute_activity(
558
+ update_execution_status,
559
+ ActivityUpdateExecutionInput(
560
+ execution_id=input.execution_id,
561
+ status="failed",
562
+ completed_at=workflow.now().isoformat(),
563
+ error_message=f"Workflow error: {str(e)}",
564
+ execution_metadata={
565
+ "workflow_error": True,
566
+ "error_type": type(e).__name__,
567
+ },
568
+ ),
569
+ start_to_close_timeout=timedelta(seconds=30),
570
+ )
571
+
572
+ await workflow.execute_activity(
573
+ update_agent_status,
574
+ ActivityUpdateAgentInput(
575
+ agent_id=input.agent_id,
576
+ organization_id=input.organization_id,
577
+ status="failed",
578
+ last_active_at=workflow.now().isoformat(),
579
+ error_message=str(e),
580
+ ),
581
+ start_to_close_timeout=timedelta(seconds=30),
582
+ )
583
+ except Exception as update_error:
584
+ workflow.logger.error(
585
+ f"Failed to update status after error",
586
+ extra={"error": str(update_error)}
587
+ )
588
+
589
+ raise