dtSpark 1.0.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.
Files changed (96) hide show
  1. dtSpark/__init__.py +0 -0
  2. dtSpark/_description.txt +1 -0
  3. dtSpark/_full_name.txt +1 -0
  4. dtSpark/_licence.txt +21 -0
  5. dtSpark/_metadata.yaml +6 -0
  6. dtSpark/_name.txt +1 -0
  7. dtSpark/_version.txt +1 -0
  8. dtSpark/aws/__init__.py +7 -0
  9. dtSpark/aws/authentication.py +296 -0
  10. dtSpark/aws/bedrock.py +578 -0
  11. dtSpark/aws/costs.py +318 -0
  12. dtSpark/aws/pricing.py +580 -0
  13. dtSpark/cli_interface.py +2645 -0
  14. dtSpark/conversation_manager.py +3050 -0
  15. dtSpark/core/__init__.py +12 -0
  16. dtSpark/core/application.py +3355 -0
  17. dtSpark/core/context_compaction.py +735 -0
  18. dtSpark/daemon/__init__.py +104 -0
  19. dtSpark/daemon/__main__.py +10 -0
  20. dtSpark/daemon/action_monitor.py +213 -0
  21. dtSpark/daemon/daemon_app.py +730 -0
  22. dtSpark/daemon/daemon_manager.py +289 -0
  23. dtSpark/daemon/execution_coordinator.py +194 -0
  24. dtSpark/daemon/pid_file.py +169 -0
  25. dtSpark/database/__init__.py +482 -0
  26. dtSpark/database/autonomous_actions.py +1191 -0
  27. dtSpark/database/backends.py +329 -0
  28. dtSpark/database/connection.py +122 -0
  29. dtSpark/database/conversations.py +520 -0
  30. dtSpark/database/credential_prompt.py +218 -0
  31. dtSpark/database/files.py +205 -0
  32. dtSpark/database/mcp_ops.py +355 -0
  33. dtSpark/database/messages.py +161 -0
  34. dtSpark/database/schema.py +673 -0
  35. dtSpark/database/tool_permissions.py +186 -0
  36. dtSpark/database/usage.py +167 -0
  37. dtSpark/files/__init__.py +4 -0
  38. dtSpark/files/manager.py +322 -0
  39. dtSpark/launch.py +39 -0
  40. dtSpark/limits/__init__.py +10 -0
  41. dtSpark/limits/costs.py +296 -0
  42. dtSpark/limits/tokens.py +342 -0
  43. dtSpark/llm/__init__.py +17 -0
  44. dtSpark/llm/anthropic_direct.py +446 -0
  45. dtSpark/llm/base.py +146 -0
  46. dtSpark/llm/context_limits.py +438 -0
  47. dtSpark/llm/manager.py +177 -0
  48. dtSpark/llm/ollama.py +578 -0
  49. dtSpark/mcp_integration/__init__.py +5 -0
  50. dtSpark/mcp_integration/manager.py +653 -0
  51. dtSpark/mcp_integration/tool_selector.py +225 -0
  52. dtSpark/resources/config.yaml.template +631 -0
  53. dtSpark/safety/__init__.py +22 -0
  54. dtSpark/safety/llm_service.py +111 -0
  55. dtSpark/safety/patterns.py +229 -0
  56. dtSpark/safety/prompt_inspector.py +442 -0
  57. dtSpark/safety/violation_logger.py +346 -0
  58. dtSpark/scheduler/__init__.py +20 -0
  59. dtSpark/scheduler/creation_tools.py +599 -0
  60. dtSpark/scheduler/execution_queue.py +159 -0
  61. dtSpark/scheduler/executor.py +1152 -0
  62. dtSpark/scheduler/manager.py +395 -0
  63. dtSpark/tools/__init__.py +4 -0
  64. dtSpark/tools/builtin.py +833 -0
  65. dtSpark/web/__init__.py +20 -0
  66. dtSpark/web/auth.py +152 -0
  67. dtSpark/web/dependencies.py +37 -0
  68. dtSpark/web/endpoints/__init__.py +17 -0
  69. dtSpark/web/endpoints/autonomous_actions.py +1125 -0
  70. dtSpark/web/endpoints/chat.py +621 -0
  71. dtSpark/web/endpoints/conversations.py +353 -0
  72. dtSpark/web/endpoints/main_menu.py +547 -0
  73. dtSpark/web/endpoints/streaming.py +421 -0
  74. dtSpark/web/server.py +578 -0
  75. dtSpark/web/session.py +167 -0
  76. dtSpark/web/ssl_utils.py +195 -0
  77. dtSpark/web/static/css/dark-theme.css +427 -0
  78. dtSpark/web/static/js/actions.js +1101 -0
  79. dtSpark/web/static/js/chat.js +614 -0
  80. dtSpark/web/static/js/main.js +496 -0
  81. dtSpark/web/static/js/sse-client.js +242 -0
  82. dtSpark/web/templates/actions.html +408 -0
  83. dtSpark/web/templates/base.html +93 -0
  84. dtSpark/web/templates/chat.html +814 -0
  85. dtSpark/web/templates/conversations.html +350 -0
  86. dtSpark/web/templates/goodbye.html +81 -0
  87. dtSpark/web/templates/login.html +90 -0
  88. dtSpark/web/templates/main_menu.html +983 -0
  89. dtSpark/web/templates/new_conversation.html +191 -0
  90. dtSpark/web/web_interface.py +137 -0
  91. dtspark-1.0.4.dist-info/METADATA +187 -0
  92. dtspark-1.0.4.dist-info/RECORD +96 -0
  93. dtspark-1.0.4.dist-info/WHEEL +5 -0
  94. dtspark-1.0.4.dist-info/entry_points.txt +3 -0
  95. dtspark-1.0.4.dist-info/licenses/LICENSE +21 -0
  96. dtspark-1.0.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1125 @@
1
+ """
2
+ Autonomous Actions API endpoints.
3
+
4
+ Provides REST API for managing autonomous actions:
5
+ - List, create, update, delete actions
6
+ - View action runs and export results
7
+ - Enable/disable actions
8
+ - Trigger manual runs
9
+
10
+
11
+ """
12
+
13
+ import logging
14
+ from typing import Optional, List
15
+ from datetime import datetime
16
+
17
+ from fastapi import APIRouter, Depends, Request, HTTPException, Query
18
+ from fastapi.responses import PlainTextResponse, HTMLResponse
19
+ from pydantic import BaseModel, Field
20
+
21
+ from ..dependencies import get_current_session
22
+
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ router = APIRouter()
27
+
28
+
29
+ def parse_datetime(dt_value):
30
+ """Parse datetime from string or return datetime object."""
31
+ if dt_value is None:
32
+ return None
33
+ if isinstance(dt_value, datetime):
34
+ return dt_value
35
+ if isinstance(dt_value, str):
36
+ try:
37
+ return datetime.fromisoformat(dt_value.replace('Z', '+00:00'))
38
+ except:
39
+ try:
40
+ return datetime.strptime(dt_value, '%Y-%m-%d %H:%M:%S.%f')
41
+ except:
42
+ return None
43
+ return None
44
+
45
+
46
+ # Pydantic Models
47
+
48
+ class ScheduleConfig(BaseModel):
49
+ """Schedule configuration for an action."""
50
+ run_date: Optional[str] = None # For one_off
51
+ cron_expression: Optional[str] = None # For recurring
52
+
53
+
54
+ class ToolPermission(BaseModel):
55
+ """Tool permission for an action."""
56
+ tool_name: str
57
+ server_name: Optional[str] = None
58
+ permission_state: str = "allowed"
59
+
60
+
61
+ class ActionCreate(BaseModel):
62
+ """Request model for creating an action."""
63
+ name: str = Field(..., min_length=1, max_length=100)
64
+ description: str = Field(..., min_length=1, max_length=500)
65
+ action_prompt: str = Field(..., min_length=1)
66
+ model_id: str = Field(..., min_length=1)
67
+ schedule_type: str = Field(..., pattern="^(one_off|recurring)$")
68
+ schedule_config: ScheduleConfig
69
+ context_mode: str = Field(default="fresh", pattern="^(fresh|cumulative)$")
70
+ max_failures: int = Field(default=3, ge=1, le=100)
71
+ tool_permissions: Optional[List[ToolPermission]] = None
72
+
73
+
74
+ class ActionUpdate(BaseModel):
75
+ """Request model for updating an action."""
76
+ name: Optional[str] = Field(None, min_length=1, max_length=100)
77
+ description: Optional[str] = Field(None, min_length=1, max_length=500)
78
+ action_prompt: Optional[str] = Field(None, min_length=1)
79
+ schedule_type: Optional[str] = Field(None, pattern="^(one_off|recurring)$")
80
+ schedule_config: Optional[ScheduleConfig] = None
81
+ context_mode: Optional[str] = Field(None, pattern="^(fresh|cumulative)$")
82
+ max_failures: Optional[int] = Field(None, ge=1, le=100)
83
+
84
+
85
+ class ActionSummary(BaseModel):
86
+ """Summary information for an action."""
87
+ id: int
88
+ name: str
89
+ description: str
90
+ model_id: str
91
+ schedule_type: str
92
+ schedule_config: dict
93
+ context_mode: str
94
+ is_enabled: bool
95
+ failure_count: int
96
+ max_failures: int
97
+ last_run_at: Optional[datetime]
98
+ next_run_at: Optional[datetime]
99
+ created_at: datetime
100
+
101
+
102
+ class ActionDetail(BaseModel):
103
+ """Detailed information for an action."""
104
+ id: int
105
+ name: str
106
+ description: str
107
+ action_prompt: str
108
+ model_id: str
109
+ schedule_type: str
110
+ schedule_config: dict
111
+ context_mode: str
112
+ is_enabled: bool
113
+ failure_count: int
114
+ max_failures: int
115
+ last_run_at: Optional[datetime]
116
+ next_run_at: Optional[datetime]
117
+ created_at: datetime
118
+ tool_permissions: List[dict]
119
+
120
+
121
+ class ActionRunSummary(BaseModel):
122
+ """Summary information for an action run."""
123
+ id: int
124
+ action_id: int
125
+ action_name: str
126
+ started_at: datetime
127
+ completed_at: Optional[datetime]
128
+ status: str
129
+ input_tokens: int
130
+ output_tokens: int
131
+
132
+
133
+ class ActionRunDetail(BaseModel):
134
+ """Detailed information for an action run."""
135
+ id: int
136
+ action_id: int
137
+ action_name: str
138
+ started_at: datetime
139
+ completed_at: Optional[datetime]
140
+ status: str
141
+ result_text: Optional[str]
142
+ result_html: Optional[str]
143
+ error_message: Optional[str]
144
+ input_tokens: int
145
+ output_tokens: int
146
+
147
+
148
+ # Endpoints
149
+
150
+ @router.get("/actions")
151
+ async def list_actions(
152
+ request: Request,
153
+ include_disabled: bool = Query(True, description="Include disabled actions"),
154
+ session_id: str = Depends(get_current_session),
155
+ ) -> List[ActionSummary]:
156
+ """
157
+ List all autonomous actions.
158
+
159
+ Args:
160
+ include_disabled: Whether to include disabled actions
161
+
162
+ Returns:
163
+ List of ActionSummary objects
164
+ """
165
+ try:
166
+ app_instance = request.app.state.app_instance
167
+ database = app_instance.database
168
+
169
+ actions = database.get_all_actions(include_disabled=include_disabled)
170
+
171
+ return [
172
+ ActionSummary(
173
+ id=action['id'],
174
+ name=action['name'],
175
+ description=action['description'],
176
+ model_id=action['model_id'],
177
+ schedule_type=action['schedule_type'],
178
+ schedule_config=action.get('schedule_config', {}),
179
+ context_mode=action['context_mode'],
180
+ is_enabled=action['is_enabled'],
181
+ failure_count=action['failure_count'],
182
+ max_failures=action['max_failures'],
183
+ last_run_at=parse_datetime(action.get('last_run_at')),
184
+ next_run_at=parse_datetime(action.get('next_run_at')),
185
+ created_at=parse_datetime(action['created_at']),
186
+ )
187
+ for action in actions
188
+ ]
189
+
190
+ except Exception as e:
191
+ logger.error(f"Error listing actions: {e}")
192
+ raise HTTPException(status_code=500, detail=str(e))
193
+
194
+
195
+ @router.get("/actions/{action_id}")
196
+ async def get_action(
197
+ action_id: int,
198
+ request: Request,
199
+ session_id: str = Depends(get_current_session),
200
+ ) -> ActionDetail:
201
+ """
202
+ Get detailed information about an action.
203
+
204
+ Args:
205
+ action_id: ID of the action
206
+
207
+ Returns:
208
+ ActionDetail object
209
+ """
210
+ try:
211
+ app_instance = request.app.state.app_instance
212
+ database = app_instance.database
213
+
214
+ action = database.get_action(action_id)
215
+ if not action:
216
+ raise HTTPException(status_code=404, detail="Action not found")
217
+
218
+ tool_permissions = database.get_action_tool_permissions(action_id)
219
+
220
+ return ActionDetail(
221
+ id=action['id'],
222
+ name=action['name'],
223
+ description=action['description'],
224
+ action_prompt=action['action_prompt'],
225
+ model_id=action['model_id'],
226
+ schedule_type=action['schedule_type'],
227
+ schedule_config=action.get('schedule_config', {}),
228
+ context_mode=action['context_mode'],
229
+ is_enabled=action['is_enabled'],
230
+ failure_count=action['failure_count'],
231
+ max_failures=action['max_failures'],
232
+ last_run_at=parse_datetime(action.get('last_run_at')),
233
+ next_run_at=parse_datetime(action.get('next_run_at')),
234
+ created_at=parse_datetime(action['created_at']),
235
+ tool_permissions=tool_permissions,
236
+ )
237
+
238
+ except HTTPException:
239
+ raise
240
+ except Exception as e:
241
+ logger.error(f"Error getting action {action_id}: {e}")
242
+ raise HTTPException(status_code=500, detail=str(e))
243
+
244
+
245
+ @router.post("/actions")
246
+ async def create_action(
247
+ action_data: ActionCreate,
248
+ request: Request,
249
+ session_id: str = Depends(get_current_session),
250
+ ) -> ActionDetail:
251
+ """
252
+ Create a new autonomous action.
253
+
254
+ Args:
255
+ action_data: Action creation data
256
+
257
+ Returns:
258
+ ActionDetail for the created action
259
+ """
260
+ try:
261
+ app_instance = request.app.state.app_instance
262
+ database = app_instance.database
263
+
264
+ # Check for duplicate name
265
+ existing = database.get_action_by_name(action_data.name)
266
+ if existing:
267
+ raise HTTPException(status_code=400, detail="An action with this name already exists")
268
+
269
+ # Create action
270
+ action_id = database.create_action(
271
+ name=action_data.name,
272
+ description=action_data.description,
273
+ action_prompt=action_data.action_prompt,
274
+ model_id=action_data.model_id,
275
+ schedule_type=action_data.schedule_type,
276
+ schedule_config=action_data.schedule_config.dict(),
277
+ context_mode=action_data.context_mode,
278
+ max_failures=action_data.max_failures,
279
+ )
280
+
281
+ # Set tool permissions if provided
282
+ if action_data.tool_permissions:
283
+ permissions = [p.dict() for p in action_data.tool_permissions]
284
+ database.set_action_tool_permissions_batch(action_id, permissions)
285
+
286
+ # Schedule the action if scheduler is available
287
+ if hasattr(app_instance, 'action_scheduler') and app_instance.action_scheduler:
288
+ app_instance.action_scheduler.schedule_action(
289
+ action_id=action_id,
290
+ action_name=action_data.name,
291
+ schedule_type=action_data.schedule_type,
292
+ schedule_config=action_data.schedule_config.dict(),
293
+ user_guid=database.user_guid
294
+ )
295
+
296
+ # Return the created action
297
+ return await get_action(action_id, request, session_id)
298
+
299
+ except HTTPException:
300
+ raise
301
+ except Exception as e:
302
+ logger.error(f"Error creating action: {e}")
303
+ raise HTTPException(status_code=500, detail=str(e))
304
+
305
+
306
+ @router.put("/actions/{action_id}")
307
+ async def update_action(
308
+ action_id: int,
309
+ action_data: ActionUpdate,
310
+ request: Request,
311
+ session_id: str = Depends(get_current_session),
312
+ ) -> ActionDetail:
313
+ """
314
+ Update an action.
315
+
316
+ Args:
317
+ action_id: ID of the action to update
318
+ action_data: Update data
319
+
320
+ Returns:
321
+ Updated ActionDetail
322
+ """
323
+ try:
324
+ app_instance = request.app.state.app_instance
325
+ database = app_instance.database
326
+
327
+ action = database.get_action(action_id)
328
+ if not action:
329
+ raise HTTPException(status_code=404, detail="Action not found")
330
+
331
+ # Build updates dict
332
+ updates = {}
333
+ if action_data.name is not None:
334
+ updates['name'] = action_data.name
335
+ if action_data.description is not None:
336
+ updates['description'] = action_data.description
337
+ if action_data.action_prompt is not None:
338
+ updates['action_prompt'] = action_data.action_prompt
339
+ if action_data.schedule_type is not None:
340
+ updates['schedule_type'] = action_data.schedule_type
341
+ if action_data.schedule_config is not None:
342
+ updates['schedule_config'] = action_data.schedule_config.dict()
343
+ if action_data.context_mode is not None:
344
+ updates['context_mode'] = action_data.context_mode
345
+ if action_data.max_failures is not None:
346
+ updates['max_failures'] = action_data.max_failures
347
+
348
+ if updates:
349
+ database.update_action(action_id, updates)
350
+
351
+ # Reschedule if schedule changed
352
+ if 'schedule_type' in updates or 'schedule_config' in updates:
353
+ if hasattr(app_instance, 'action_scheduler') and app_instance.action_scheduler:
354
+ updated_action = database.get_action(action_id)
355
+ app_instance.action_scheduler.schedule_action(
356
+ action_id=action_id,
357
+ action_name=updated_action['name'],
358
+ schedule_type=updated_action['schedule_type'],
359
+ schedule_config=updated_action['schedule_config'],
360
+ user_guid=database.user_guid
361
+ )
362
+
363
+ return await get_action(action_id, request, session_id)
364
+
365
+ except HTTPException:
366
+ raise
367
+ except Exception as e:
368
+ logger.error(f"Error updating action {action_id}: {e}")
369
+ raise HTTPException(status_code=500, detail=str(e))
370
+
371
+
372
+ @router.delete("/actions/{action_id}")
373
+ async def delete_action(
374
+ action_id: int,
375
+ request: Request,
376
+ session_id: str = Depends(get_current_session),
377
+ ) -> dict:
378
+ """
379
+ Delete an action.
380
+
381
+ Args:
382
+ action_id: ID of the action to delete
383
+
384
+ Returns:
385
+ Status message
386
+ """
387
+ try:
388
+ app_instance = request.app.state.app_instance
389
+ database = app_instance.database
390
+
391
+ action = database.get_action(action_id)
392
+ if not action:
393
+ raise HTTPException(status_code=404, detail="Action not found")
394
+
395
+ # Unschedule the action
396
+ if hasattr(app_instance, 'action_scheduler') and app_instance.action_scheduler:
397
+ app_instance.action_scheduler.unschedule_action(action_id)
398
+
399
+ # Delete from database
400
+ database.delete_action(action_id)
401
+
402
+ return {
403
+ "status": "success",
404
+ "message": f"Action '{action['name']}' deleted successfully",
405
+ }
406
+
407
+ except HTTPException:
408
+ raise
409
+ except Exception as e:
410
+ logger.error(f"Error deleting action {action_id}: {e}")
411
+ raise HTTPException(status_code=500, detail=str(e))
412
+
413
+
414
+ @router.post("/actions/{action_id}/enable")
415
+ async def enable_action(
416
+ action_id: int,
417
+ request: Request,
418
+ session_id: str = Depends(get_current_session),
419
+ ) -> dict:
420
+ """
421
+ Enable a disabled action.
422
+
423
+ Args:
424
+ action_id: ID of the action
425
+
426
+ Returns:
427
+ Status message
428
+ """
429
+ try:
430
+ app_instance = request.app.state.app_instance
431
+ database = app_instance.database
432
+
433
+ action = database.get_action(action_id)
434
+ if not action:
435
+ raise HTTPException(status_code=404, detail="Action not found")
436
+
437
+ database.enable_action(action_id)
438
+
439
+ # Reschedule the action
440
+ if hasattr(app_instance, 'action_scheduler') and app_instance.action_scheduler:
441
+ app_instance.action_scheduler.schedule_action(
442
+ action_id=action_id,
443
+ action_name=action['name'],
444
+ schedule_type=action['schedule_type'],
445
+ schedule_config=action['schedule_config'],
446
+ user_guid=database.user_guid
447
+ )
448
+
449
+ return {
450
+ "status": "success",
451
+ "message": f"Action '{action['name']}' enabled",
452
+ }
453
+
454
+ except HTTPException:
455
+ raise
456
+ except Exception as e:
457
+ logger.error(f"Error enabling action {action_id}: {e}")
458
+ raise HTTPException(status_code=500, detail=str(e))
459
+
460
+
461
+ @router.post("/actions/{action_id}/disable")
462
+ async def disable_action(
463
+ action_id: int,
464
+ request: Request,
465
+ session_id: str = Depends(get_current_session),
466
+ ) -> dict:
467
+ """
468
+ Disable an action.
469
+
470
+ Args:
471
+ action_id: ID of the action
472
+
473
+ Returns:
474
+ Status message
475
+ """
476
+ try:
477
+ app_instance = request.app.state.app_instance
478
+ database = app_instance.database
479
+
480
+ action = database.get_action(action_id)
481
+ if not action:
482
+ raise HTTPException(status_code=404, detail="Action not found")
483
+
484
+ database.disable_action(action_id)
485
+
486
+ # Unschedule the action
487
+ if hasattr(app_instance, 'action_scheduler') and app_instance.action_scheduler:
488
+ app_instance.action_scheduler.unschedule_action(action_id)
489
+
490
+ return {
491
+ "status": "success",
492
+ "message": f"Action '{action['name']}' disabled",
493
+ }
494
+
495
+ except HTTPException:
496
+ raise
497
+ except Exception as e:
498
+ logger.error(f"Error disabling action {action_id}: {e}")
499
+ raise HTTPException(status_code=500, detail=str(e))
500
+
501
+
502
+ @router.post("/actions/{action_id}/run-now")
503
+ async def run_action_now(
504
+ action_id: int,
505
+ request: Request,
506
+ session_id: str = Depends(get_current_session),
507
+ ) -> dict:
508
+ """
509
+ Trigger an action to run immediately.
510
+
511
+ Args:
512
+ action_id: ID of the action
513
+
514
+ Returns:
515
+ Status message
516
+ """
517
+ try:
518
+ app_instance = request.app.state.app_instance
519
+ database = app_instance.database
520
+
521
+ action = database.get_action(action_id)
522
+ if not action:
523
+ raise HTTPException(status_code=404, detail="Action not found")
524
+
525
+ # Check if action is currently locked by another process (e.g., daemon)
526
+ from dtSpark.database.autonomous_actions import get_action_lock_info
527
+ lock_info = get_action_lock_info(
528
+ conn=database.conn,
529
+ action_id=action_id,
530
+ user_guid=database.user_guid
531
+ )
532
+ if lock_info and lock_info.get('locked_by'):
533
+ raise HTTPException(
534
+ status_code=409,
535
+ detail=f"Action is currently being executed by another process ({lock_info['locked_by']})"
536
+ )
537
+
538
+ # Trigger immediate execution
539
+ if hasattr(app_instance, 'action_scheduler') and app_instance.action_scheduler:
540
+ success = app_instance.action_scheduler.run_action_now(
541
+ action_id=action_id,
542
+ user_guid=database.user_guid
543
+ )
544
+ if not success:
545
+ raise HTTPException(status_code=500, detail="Failed to trigger action")
546
+ else:
547
+ raise HTTPException(status_code=503, detail="Action scheduler not available")
548
+
549
+ return {
550
+ "status": "success",
551
+ "message": f"Action '{action['name']}' triggered for immediate execution",
552
+ }
553
+
554
+ except HTTPException:
555
+ raise
556
+ except Exception as e:
557
+ logger.error(f"Error triggering action {action_id}: {e}")
558
+ raise HTTPException(status_code=500, detail=str(e))
559
+
560
+
561
+ @router.get("/actions/{action_id}/runs")
562
+ async def list_action_runs(
563
+ action_id: int,
564
+ request: Request,
565
+ limit: int = Query(50, ge=1, le=100),
566
+ offset: int = Query(0, ge=0),
567
+ session_id: str = Depends(get_current_session),
568
+ ) -> List[ActionRunSummary]:
569
+ """
570
+ List runs for a specific action.
571
+
572
+ Args:
573
+ action_id: ID of the action
574
+ limit: Maximum number of runs to return
575
+ offset: Offset for pagination
576
+
577
+ Returns:
578
+ List of ActionRunSummary objects
579
+ """
580
+ try:
581
+ app_instance = request.app.state.app_instance
582
+ database = app_instance.database
583
+
584
+ action = database.get_action(action_id)
585
+ if not action:
586
+ raise HTTPException(status_code=404, detail="Action not found")
587
+
588
+ runs = database.get_action_runs(action_id, limit=limit, offset=offset)
589
+
590
+ return [
591
+ ActionRunSummary(
592
+ id=run['id'],
593
+ action_id=run['action_id'],
594
+ action_name=run.get('action_name', action['name']),
595
+ started_at=parse_datetime(run['started_at']),
596
+ completed_at=parse_datetime(run.get('completed_at')),
597
+ status=run['status'],
598
+ input_tokens=run.get('input_tokens', 0),
599
+ output_tokens=run.get('output_tokens', 0),
600
+ )
601
+ for run in runs
602
+ ]
603
+
604
+ except HTTPException:
605
+ raise
606
+ except Exception as e:
607
+ logger.error(f"Error listing runs for action {action_id}: {e}")
608
+ raise HTTPException(status_code=500, detail=str(e))
609
+
610
+
611
+ @router.get("/actions/{action_id}/runs/{run_id}")
612
+ async def get_action_run(
613
+ action_id: int,
614
+ run_id: int,
615
+ request: Request,
616
+ session_id: str = Depends(get_current_session),
617
+ ) -> ActionRunDetail:
618
+ """
619
+ Get detailed information about a specific run.
620
+
621
+ Args:
622
+ action_id: ID of the action
623
+ run_id: ID of the run
624
+
625
+ Returns:
626
+ ActionRunDetail object
627
+ """
628
+ try:
629
+ app_instance = request.app.state.app_instance
630
+ database = app_instance.database
631
+
632
+ run = database.get_action_run(run_id)
633
+ if not run or run['action_id'] != action_id:
634
+ raise HTTPException(status_code=404, detail="Run not found")
635
+
636
+ return ActionRunDetail(
637
+ id=run['id'],
638
+ action_id=run['action_id'],
639
+ action_name=run.get('action_name', 'Unknown'),
640
+ started_at=parse_datetime(run['started_at']),
641
+ completed_at=parse_datetime(run.get('completed_at')),
642
+ status=run['status'],
643
+ result_text=run.get('result_text'),
644
+ result_html=run.get('result_html'),
645
+ error_message=run.get('error_message'),
646
+ input_tokens=run.get('input_tokens', 0),
647
+ output_tokens=run.get('output_tokens', 0),
648
+ )
649
+
650
+ except HTTPException:
651
+ raise
652
+ except Exception as e:
653
+ logger.error(f"Error getting run {run_id}: {e}")
654
+ raise HTTPException(status_code=500, detail=str(e))
655
+
656
+
657
+ @router.get("/actions/{action_id}/runs/{run_id}/export")
658
+ async def export_run_result(
659
+ action_id: int,
660
+ run_id: int,
661
+ request: Request,
662
+ format: str = Query("text", pattern="^(text|html|markdown)$"),
663
+ session_id: str = Depends(get_current_session),
664
+ ):
665
+ """
666
+ Export run result in specified format.
667
+
668
+ Args:
669
+ action_id: ID of the action
670
+ run_id: ID of the run
671
+ format: Export format (text, html, markdown)
672
+
673
+ Returns:
674
+ Exported content in requested format
675
+ """
676
+ try:
677
+ app_instance = request.app.state.app_instance
678
+ database = app_instance.database
679
+
680
+ run = database.get_action_run(run_id)
681
+ if not run or run['action_id'] != action_id:
682
+ raise HTTPException(status_code=404, detail="Run not found")
683
+
684
+ if format == "html":
685
+ content = run.get('result_html') or f"<pre>{run.get('result_text', 'No result')}</pre>"
686
+ return HTMLResponse(content=content)
687
+
688
+ elif format == "markdown":
689
+ result = run.get('result_text', 'No result')
690
+ header = f"# Action Run {run_id}\n\n"
691
+ header += f"**Action:** {run.get('action_name', 'Unknown')}\n"
692
+ header += f"**Status:** {run['status']}\n"
693
+ header += f"**Started:** {run.get('started_at', 'N/A')}\n"
694
+ header += f"**Completed:** {run.get('completed_at', 'N/A')}\n\n"
695
+ header += "## Result\n\n"
696
+ content = header + result
697
+ return PlainTextResponse(content=content, media_type="text/markdown")
698
+
699
+ else: # text
700
+ content = run.get('result_text', 'No result')
701
+ return PlainTextResponse(content=content)
702
+
703
+ except HTTPException:
704
+ raise
705
+ except Exception as e:
706
+ logger.error(f"Error exporting run {run_id}: {e}")
707
+ raise HTTPException(status_code=500, detail=str(e))
708
+
709
+
710
+ @router.get("/actions/runs/recent")
711
+ async def list_recent_runs(
712
+ request: Request,
713
+ limit: int = Query(20, ge=1, le=100),
714
+ session_id: str = Depends(get_current_session),
715
+ ) -> List[ActionRunSummary]:
716
+ """
717
+ List recent runs across all actions.
718
+
719
+ Args:
720
+ limit: Maximum number of runs to return
721
+
722
+ Returns:
723
+ List of ActionRunSummary objects
724
+ """
725
+ try:
726
+ app_instance = request.app.state.app_instance
727
+ database = app_instance.database
728
+
729
+ runs = database.get_recent_action_runs(limit=limit)
730
+
731
+ return [
732
+ ActionRunSummary(
733
+ id=run['id'],
734
+ action_id=run['action_id'],
735
+ action_name=run.get('action_name', 'Unknown'),
736
+ started_at=parse_datetime(run['started_at']),
737
+ completed_at=parse_datetime(run.get('completed_at')),
738
+ status=run['status'],
739
+ input_tokens=run.get('input_tokens', 0),
740
+ output_tokens=run.get('output_tokens', 0),
741
+ )
742
+ for run in runs
743
+ ]
744
+
745
+ except Exception as e:
746
+ logger.error(f"Error listing recent runs: {e}")
747
+ raise HTTPException(status_code=500, detail=str(e))
748
+
749
+
750
+ @router.get("/actions/status/failed-count")
751
+ async def get_failed_action_count(
752
+ request: Request,
753
+ session_id: str = Depends(get_current_session),
754
+ ) -> dict:
755
+ """
756
+ Get count of failed/disabled actions.
757
+
758
+ Returns:
759
+ Count of failed actions
760
+ """
761
+ try:
762
+ app_instance = request.app.state.app_instance
763
+ database = app_instance.database
764
+
765
+ count = database.get_failed_action_count()
766
+
767
+ return {
768
+ "failed_count": count,
769
+ }
770
+
771
+ except Exception as e:
772
+ logger.error(f"Error getting failed action count: {e}")
773
+ raise HTTPException(status_code=500, detail=str(e))
774
+
775
+
776
+ # =============================================================================
777
+ # AI-ASSISTED ACTION CREATION
778
+ # =============================================================================
779
+
780
+ class AICreationStart(BaseModel):
781
+ """Request model for starting AI-assisted action creation."""
782
+ name: str = Field(..., min_length=1, max_length=100)
783
+ description: str = Field(..., min_length=1, max_length=500)
784
+ model_id: str = Field(..., min_length=1)
785
+
786
+
787
+ class AICreationMessage(BaseModel):
788
+ """Request model for sending a message in AI creation chat."""
789
+ message: str = Field(..., min_length=1)
790
+
791
+
792
+ # Store for active creation sessions (in-memory, per-session)
793
+ _creation_sessions = {}
794
+
795
+
796
+ @router.post("/actions/ai-create/start")
797
+ async def start_ai_creation(
798
+ request: Request,
799
+ data: AICreationStart,
800
+ session_id: str = Depends(get_current_session),
801
+ ) -> dict:
802
+ """
803
+ Start an AI-assisted action creation session.
804
+
805
+ This initialises a chat session with the LLM to help create an action.
806
+
807
+ Args:
808
+ data: Action name, description, and model to use
809
+
810
+ Returns:
811
+ Session information and initial LLM response
812
+ """
813
+ import json
814
+ from dtSpark.scheduler.creation_tools import (
815
+ ACTION_CREATION_SYSTEM_PROMPT,
816
+ get_action_creation_tools,
817
+ execute_creation_tool
818
+ )
819
+
820
+ try:
821
+ app_instance = request.app.state.app_instance
822
+
823
+ # Validate model exists
824
+ models = app_instance.llm_manager.list_all_models()
825
+ model_exists = any(m['id'] == data.model_id for m in models)
826
+ if not model_exists:
827
+ raise HTTPException(status_code=400, detail=f"Model not found: {data.model_id}")
828
+
829
+ # Create unique creation session ID
830
+ import secrets
831
+ creation_id = secrets.token_hex(16)
832
+
833
+ # Set the model for this creation session
834
+ app_instance.llm_manager.set_model(data.model_id)
835
+ app_instance.bedrock_service = app_instance.llm_manager.get_active_service()
836
+
837
+ # Initial message to the LLM with the action name and description
838
+ initial_prompt = (
839
+ f"I want to create an autonomous action with the following details:\n\n"
840
+ f"**Name:** {data.name}\n"
841
+ f"**Description:** {data.description}\n\n"
842
+ f"Please help me configure this action. Ask me any questions needed to "
843
+ f"understand what the action should do, when it should run, and what tools it needs."
844
+ )
845
+
846
+ # Initialise conversation messages
847
+ messages = [
848
+ {'role': 'user', 'content': [{'type': 'text', 'text': initial_prompt}]}
849
+ ]
850
+
851
+ # Get creation tools
852
+ creation_tools = get_action_creation_tools()
853
+ tools_for_api = [{'toolSpec': t} for t in creation_tools]
854
+
855
+ # Invoke the LLM
856
+ response = app_instance.llm_manager.invoke_model(
857
+ messages=messages,
858
+ system=ACTION_CREATION_SYSTEM_PROMPT,
859
+ tools=tools_for_api,
860
+ max_tokens=4096,
861
+ temperature=0.7
862
+ )
863
+
864
+ if response.get('error'):
865
+ raise HTTPException(
866
+ status_code=500,
867
+ detail=f"LLM error: {response.get('error_message', 'Unknown error')}"
868
+ )
869
+
870
+ # Extract response text
871
+ response_text = ""
872
+ content_blocks = response.get('content_blocks', [])
873
+ for block in content_blocks:
874
+ if block.get('type') == 'text':
875
+ response_text += block.get('text', '')
876
+
877
+ # If no content_blocks, try direct content
878
+ if not response_text and response.get('content'):
879
+ response_text = response.get('content', '')
880
+
881
+ # Add assistant response to messages
882
+ messages.append({
883
+ 'role': 'assistant',
884
+ 'content': content_blocks if content_blocks else [{'type': 'text', 'text': response_text}]
885
+ })
886
+
887
+ # Store session state
888
+ _creation_sessions[creation_id] = {
889
+ 'name': data.name,
890
+ 'description': data.description,
891
+ 'model_id': data.model_id,
892
+ 'messages': messages,
893
+ 'created': datetime.now().isoformat(),
894
+ 'completed': False,
895
+ 'action_id': None
896
+ }
897
+
898
+ return {
899
+ 'creation_id': creation_id,
900
+ 'response': response_text,
901
+ 'completed': False
902
+ }
903
+
904
+ except HTTPException:
905
+ raise
906
+ except Exception as e:
907
+ logger.error(f"Error starting AI creation: {e}", exc_info=True)
908
+ raise HTTPException(status_code=500, detail=str(e))
909
+
910
+
911
+ @router.post("/actions/ai-create/{creation_id}/message")
912
+ async def send_ai_creation_message(
913
+ creation_id: str,
914
+ request: Request,
915
+ data: AICreationMessage,
916
+ session_id: str = Depends(get_current_session),
917
+ ) -> dict:
918
+ """
919
+ Send a message in an AI creation chat session.
920
+
921
+ Args:
922
+ creation_id: The creation session ID
923
+ data: The user's message
924
+
925
+ Returns:
926
+ LLM response and completion status
927
+ """
928
+ import json
929
+ from dtSpark.scheduler.creation_tools import (
930
+ ACTION_CREATION_SYSTEM_PROMPT,
931
+ get_action_creation_tools,
932
+ execute_creation_tool
933
+ )
934
+
935
+ try:
936
+ # Get session state
937
+ if creation_id not in _creation_sessions:
938
+ raise HTTPException(status_code=404, detail="Creation session not found")
939
+
940
+ session_state = _creation_sessions[creation_id]
941
+
942
+ if session_state.get('completed'):
943
+ return {
944
+ 'response': 'This action has already been created.',
945
+ 'completed': True,
946
+ 'action_id': session_state.get('action_id')
947
+ }
948
+
949
+ app_instance = request.app.state.app_instance
950
+
951
+ # Pre-fetch available tools (same as /tools endpoint)
952
+ available_tools = []
953
+ # Get MCP tools
954
+ if hasattr(app_instance, 'mcp_manager') and app_instance.mcp_manager:
955
+ try:
956
+ mcp_tools = await app_instance.mcp_manager.list_all_tools()
957
+ for tool in mcp_tools:
958
+ available_tools.append({
959
+ 'name': tool.get('name', 'unknown'),
960
+ 'description': tool.get('description', 'No description available'),
961
+ 'source': tool.get('server', 'mcp')
962
+ })
963
+ except Exception as e:
964
+ logger.warning(f"Error getting MCP tools for AI creation: {e}")
965
+ # Get embedded tools
966
+ if hasattr(app_instance, 'conversation_manager') and app_instance.conversation_manager:
967
+ try:
968
+ embedded = app_instance.conversation_manager.get_embedded_tools()
969
+ for tool in embedded:
970
+ # Embedded tools are wrapped in toolSpec format
971
+ tool_spec = tool.get('toolSpec', tool)
972
+ available_tools.append({
973
+ 'name': tool_spec.get('name', 'unknown'),
974
+ 'description': tool_spec.get('description', 'No description available'),
975
+ 'source': 'embedded'
976
+ })
977
+ except Exception as e:
978
+ logger.warning(f"Error getting embedded tools for AI creation: {e}")
979
+
980
+ # Set the model for this creation session
981
+ app_instance.llm_manager.set_model(session_state['model_id'])
982
+ app_instance.bedrock_service = app_instance.llm_manager.get_active_service()
983
+
984
+ # Add user message
985
+ messages = session_state['messages']
986
+ messages.append({
987
+ 'role': 'user',
988
+ 'content': [{'type': 'text', 'text': data.message}]
989
+ })
990
+
991
+ # Get creation tools
992
+ creation_tools = get_action_creation_tools()
993
+ tools_for_api = [{'toolSpec': t} for t in creation_tools]
994
+
995
+ # Tool execution loop
996
+ max_iterations = 10
997
+ iteration = 0
998
+ final_response = ""
999
+ action_created = False
1000
+ created_action_id = None
1001
+
1002
+ while iteration < max_iterations:
1003
+ iteration += 1
1004
+
1005
+ # Invoke the LLM
1006
+ response = app_instance.llm_manager.invoke_model(
1007
+ messages=messages,
1008
+ system=ACTION_CREATION_SYSTEM_PROMPT,
1009
+ tools=tools_for_api,
1010
+ max_tokens=4096,
1011
+ temperature=0.7
1012
+ )
1013
+
1014
+ if response.get('error'):
1015
+ raise HTTPException(
1016
+ status_code=500,
1017
+ detail=f"LLM error: {response.get('error_message', 'Unknown error')}"
1018
+ )
1019
+
1020
+ # Extract response content
1021
+ content_blocks = response.get('content_blocks', [])
1022
+ stop_reason = response.get('stop_reason', 'end_turn')
1023
+
1024
+ # Check for tool use
1025
+ tool_use_blocks = [b for b in content_blocks if b.get('type') == 'tool_use']
1026
+
1027
+ if tool_use_blocks:
1028
+ # Add assistant response with tool calls
1029
+ messages.append({
1030
+ 'role': 'assistant',
1031
+ 'content': content_blocks
1032
+ })
1033
+
1034
+ # Execute tools
1035
+ tool_results = []
1036
+ for tool_block in tool_use_blocks:
1037
+ tool_name = tool_block.get('name')
1038
+ tool_input = tool_block.get('input', {})
1039
+ tool_id = tool_block.get('id')
1040
+
1041
+ # Execute the creation tool
1042
+ result = execute_creation_tool(
1043
+ tool_name=tool_name,
1044
+ tool_input=tool_input,
1045
+ mcp_manager=app_instance.mcp_manager,
1046
+ database=app_instance.database,
1047
+ scheduler_manager=getattr(app_instance, 'scheduler_manager', None),
1048
+ model_id=session_state['model_id'],
1049
+ user_guid=getattr(app_instance.database, 'user_guid', None),
1050
+ config=getattr(app_instance, 'config', None),
1051
+ available_tools=available_tools
1052
+ )
1053
+
1054
+ tool_results.append({
1055
+ 'type': 'tool_result',
1056
+ 'tool_use_id': tool_id,
1057
+ 'content': json.dumps(result) if isinstance(result, dict) else str(result)
1058
+ })
1059
+
1060
+ # Check if action was created
1061
+ if tool_name == 'create_autonomous_action' and result.get('success'):
1062
+ action_created = True
1063
+ created_action_id = result.get('action_id')
1064
+
1065
+ # Add tool results to messages
1066
+ messages.append({
1067
+ 'role': 'user',
1068
+ 'content': tool_results
1069
+ })
1070
+
1071
+ # Continue loop to get LLM's response to tool results
1072
+ continue
1073
+
1074
+ else:
1075
+ # No tool use - extract text response
1076
+ for block in content_blocks:
1077
+ if block.get('type') == 'text':
1078
+ final_response += block.get('text', '')
1079
+
1080
+ # Add final assistant response to messages
1081
+ messages.append({
1082
+ 'role': 'assistant',
1083
+ 'content': content_blocks if content_blocks else [{'type': 'text', 'text': final_response}]
1084
+ })
1085
+
1086
+ break
1087
+
1088
+ # Update session state
1089
+ session_state['messages'] = messages
1090
+ if action_created:
1091
+ session_state['completed'] = True
1092
+ session_state['action_id'] = created_action_id
1093
+
1094
+ return {
1095
+ 'response': final_response,
1096
+ 'completed': action_created,
1097
+ 'action_id': created_action_id
1098
+ }
1099
+
1100
+ except HTTPException:
1101
+ raise
1102
+ except Exception as e:
1103
+ logger.error(f"Error in AI creation message: {e}", exc_info=True)
1104
+ raise HTTPException(status_code=500, detail=str(e))
1105
+
1106
+
1107
+ @router.delete("/actions/ai-create/{creation_id}")
1108
+ async def cancel_ai_creation(
1109
+ creation_id: str,
1110
+ request: Request,
1111
+ session_id: str = Depends(get_current_session),
1112
+ ) -> dict:
1113
+ """
1114
+ Cancel an AI creation session.
1115
+
1116
+ Args:
1117
+ creation_id: The creation session ID to cancel
1118
+
1119
+ Returns:
1120
+ Confirmation message
1121
+ """
1122
+ if creation_id in _creation_sessions:
1123
+ del _creation_sessions[creation_id]
1124
+
1125
+ return {'status': 'cancelled', 'message': 'Creation session cancelled'}