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,1152 @@
1
+ """
2
+ Action Executor module.
3
+
4
+ Handles LLM invocation and result handling for autonomous actions.
5
+
6
+
7
+ """
8
+
9
+ import logging
10
+ import json
11
+ import time
12
+ import asyncio
13
+ import concurrent.futures
14
+ from datetime import datetime
15
+ from typing import Dict, Any, Optional, List, Callable, Tuple
16
+ import markdown
17
+
18
+
19
+ # Compaction prompt template for action context compaction
20
+ ACTION_COMPACTION_PROMPT = '''You are compacting the context for an ongoing autonomous action. Distill the conversation history into a compressed format that preserves critical information.
21
+
22
+ ## RULES
23
+ - PRESERVE: Tool results, findings, errors, partial work, next steps
24
+ - COMPRESS: Completed tasks to brief outcomes, verbose tool outputs to key data
25
+ - DISCARD: Redundant results, superseded plans, verbose confirmations
26
+
27
+ ## OUTPUT FORMAT
28
+ # COMPACTED ACTION CONTEXT
29
+
30
+ ## Completed Work
31
+ [Brief list of what has been accomplished]
32
+
33
+ ## Key Findings/Data
34
+ [Important results, numbers, file paths discovered]
35
+
36
+ ## Current State
37
+ [What was being worked on when compaction occurred]
38
+
39
+ ## Pending Tasks
40
+ [What still needs to be done based on original prompt]
41
+
42
+ ## Original Task
43
+ {original_prompt}
44
+
45
+ ## CONVERSATION TO COMPACT ({message_count} messages, ~{token_count:,} tokens)
46
+
47
+ {conversation_history}
48
+
49
+ Begin compacted context now:'''
50
+
51
+
52
+ class ActionContextCompactor:
53
+ """
54
+ Lightweight context compactor for autonomous actions.
55
+
56
+ Works with in-memory message lists rather than database storage.
57
+ Compacts when token usage exceeds threshold to allow long-running actions.
58
+ """
59
+
60
+ def __init__(self, llm_manager, compaction_threshold: float = 0.6,
61
+ emergency_threshold: float = 0.85):
62
+ """
63
+ Initialise the action context compactor.
64
+
65
+ Args:
66
+ llm_manager: LLMManager instance for LLM invocation and token counting
67
+ compaction_threshold: Fraction of context to trigger compaction (default 0.6)
68
+ emergency_threshold: Fraction for emergency compaction (default 0.85)
69
+ """
70
+ self.llm_manager = llm_manager
71
+ self.compaction_threshold = compaction_threshold
72
+ self.emergency_threshold = emergency_threshold
73
+ logging.info(f"ActionContextCompactor initialised with threshold={compaction_threshold}")
74
+
75
+ def check_and_compact(self, messages: List[Dict], original_prompt: str,
76
+ context_window: int, in_tool_loop: bool = True) -> Tuple[List[Dict], bool]:
77
+ """
78
+ Check if compaction is needed and perform it.
79
+
80
+ Args:
81
+ messages: Current message list
82
+ original_prompt: The original action prompt (preserved)
83
+ context_window: Model's context window size
84
+ in_tool_loop: Whether currently in tool use loop
85
+
86
+ Returns:
87
+ Tuple of (possibly compacted messages, whether compaction occurred)
88
+ """
89
+ # Estimate current token usage
90
+ current_tokens = self._estimate_tokens(messages)
91
+
92
+ # Calculate thresholds
93
+ normal_threshold = int(context_window * self.compaction_threshold)
94
+ emergency_threshold = int(context_window * self.emergency_threshold)
95
+
96
+ logging.debug(f"Action compaction check: {current_tokens:,}/{context_window:,} tokens "
97
+ f"(threshold: {normal_threshold:,}, emergency: {emergency_threshold:,})")
98
+
99
+ # Emergency compaction - always perform
100
+ if current_tokens >= emergency_threshold:
101
+ logging.warning(f"EMERGENCY action compaction: {current_tokens:,}/{context_window:,} tokens")
102
+ return self._perform_compaction(messages, original_prompt, current_tokens), True
103
+
104
+ # Normal threshold - defer during tool loop unless close to emergency
105
+ if current_tokens >= normal_threshold:
106
+ if in_tool_loop and current_tokens < emergency_threshold * 0.9:
107
+ logging.debug("Deferring action compaction during tool loop")
108
+ return messages, False
109
+ logging.info(f"Action compaction triggered: {current_tokens:,} tokens")
110
+ return self._perform_compaction(messages, original_prompt, current_tokens), True
111
+
112
+ return messages, False
113
+
114
+ def _estimate_tokens(self, messages: List[Dict]) -> int:
115
+ """
116
+ Estimate token count for messages.
117
+
118
+ Args:
119
+ messages: Message list
120
+
121
+ Returns:
122
+ Estimated token count
123
+ """
124
+ total = 0
125
+ for msg in messages:
126
+ content = msg.get('content', '')
127
+ if isinstance(content, str):
128
+ # Rough estimate: ~4 chars per token
129
+ total += len(content) // 4
130
+ elif isinstance(content, list):
131
+ # Content blocks
132
+ for block in content:
133
+ if isinstance(block, dict):
134
+ if block.get('type') == 'text':
135
+ total += len(block.get('text', '')) // 4
136
+ elif block.get('type') == 'tool_use':
137
+ total += len(json.dumps(block.get('input', {}))) // 4
138
+ elif block.get('type') == 'tool_result':
139
+ total += len(str(block.get('content', ''))) // 4
140
+ return total
141
+
142
+ def _check_rate_limits(self, prompt: str) -> Dict[str, Any]:
143
+ """
144
+ Check if the compaction request would exceed provider rate limits.
145
+
146
+ Args:
147
+ prompt: The compaction prompt to be sent
148
+
149
+ Returns:
150
+ Dictionary with:
151
+ - can_proceed: bool - Whether compaction can proceed
152
+ - message: str - Explanation message
153
+ """
154
+ # Get rate limits from the LLM manager
155
+ if not hasattr(self.llm_manager, 'get_rate_limits'):
156
+ return {'can_proceed': True, 'message': 'No rate limit info available'}
157
+
158
+ rate_limits = self.llm_manager.get_rate_limits()
159
+
160
+ # If provider doesn't have rate limits, proceed
161
+ if not rate_limits or not rate_limits.get('has_limits', False):
162
+ return {'can_proceed': True, 'message': 'No rate limits'}
163
+
164
+ # Estimate tokens for the prompt
165
+ if hasattr(self.llm_manager, 'count_tokens'):
166
+ try:
167
+ estimated_tokens = self.llm_manager.count_tokens(prompt)
168
+ except Exception:
169
+ estimated_tokens = len(prompt) // 4
170
+ else:
171
+ estimated_tokens = len(prompt) // 4
172
+
173
+ # Check against input token limit
174
+ input_limit = rate_limits.get('input_tokens_per_minute')
175
+ if input_limit and estimated_tokens > input_limit:
176
+ provider_name = "the current provider"
177
+ if hasattr(self.llm_manager, 'get_active_provider'):
178
+ provider_name = self.llm_manager.get_active_provider() or provider_name
179
+
180
+ return {
181
+ 'can_proceed': False,
182
+ 'message': (
183
+ f"Request ({estimated_tokens:,} tokens) exceeds {provider_name} "
184
+ f"rate limit ({input_limit:,} tokens/minute)"
185
+ ),
186
+ 'estimated_tokens': estimated_tokens,
187
+ 'rate_limit': input_limit
188
+ }
189
+
190
+ return {'can_proceed': True, 'message': 'Within rate limits'}
191
+
192
+ def _perform_compaction(self, messages: List[Dict], original_prompt: str,
193
+ current_tokens: int) -> List[Dict]:
194
+ """
195
+ Perform the actual compaction.
196
+
197
+ Args:
198
+ messages: Messages to compact
199
+ original_prompt: Original action prompt
200
+ current_tokens: Current estimated token count
201
+
202
+ Returns:
203
+ Compacted message list
204
+ """
205
+ if len(messages) <= 2:
206
+ logging.warning("Not enough messages to compact")
207
+ return messages
208
+
209
+ try:
210
+ # Format messages for compaction
211
+ formatted = self._format_messages(messages)
212
+
213
+ # Build compaction prompt
214
+ prompt = ACTION_COMPACTION_PROMPT.format(
215
+ original_prompt=original_prompt,
216
+ message_count=len(messages),
217
+ token_count=current_tokens,
218
+ conversation_history=formatted
219
+ )
220
+
221
+ # Check rate limits before attempting compaction
222
+ rate_limit_check = self._check_rate_limits(prompt)
223
+ if not rate_limit_check['can_proceed']:
224
+ logging.warning(f"Action compaction blocked by rate limits: {rate_limit_check['message']}")
225
+ return messages
226
+
227
+ # Invoke LLM for compaction (low temperature for consistency)
228
+ response = self.llm_manager.invoke_model(
229
+ messages=[{'role': 'user', 'content': prompt}],
230
+ max_tokens=4096,
231
+ temperature=0.2
232
+ )
233
+
234
+ if not response or response.get('error'):
235
+ logging.error("Compaction failed - keeping original messages")
236
+ return messages
237
+
238
+ # Extract compacted content
239
+ compacted_content = response.get('content', '')
240
+ if isinstance(compacted_content, list):
241
+ text_parts = [b.get('text', '') for b in compacted_content if b.get('type') == 'text']
242
+ compacted_content = '\n'.join(text_parts)
243
+
244
+ if len(compacted_content) < 100:
245
+ logging.warning("Compacted content too brief, keeping original")
246
+ return messages
247
+
248
+ # Create new message list with compacted context
249
+ compacted_tokens = len(compacted_content) // 4
250
+ reduction = ((current_tokens - compacted_tokens) / current_tokens * 100) if current_tokens > 0 else 0
251
+
252
+ logging.info(f"Action compaction: {len(messages)} messages → 1 summary, "
253
+ f"{current_tokens:,} → {compacted_tokens:,} tokens ({reduction:.1f}% reduction)")
254
+
255
+ # Return compacted context as single user message
256
+ return [{
257
+ 'role': 'user',
258
+ 'content': f"[COMPACTED CONTEXT - {len(messages)} messages compacted]\n\n{compacted_content}"
259
+ }]
260
+
261
+ except Exception as e:
262
+ logging.error(f"Action compaction failed: {e}", exc_info=True)
263
+ return messages
264
+
265
+ def _format_messages(self, messages: List[Dict]) -> str:
266
+ """
267
+ Format messages for compaction prompt.
268
+
269
+ Args:
270
+ messages: Message list
271
+
272
+ Returns:
273
+ Formatted string
274
+ """
275
+ lines = []
276
+ for i, msg in enumerate(messages):
277
+ role = msg.get('role', 'unknown').upper()
278
+ content = msg.get('content', '')
279
+
280
+ if isinstance(content, str):
281
+ # Truncate long messages
282
+ if len(content) > 1500:
283
+ content = content[:1500] + f"... [truncated, {len(content) - 1500} more chars]"
284
+ lines.append(f"[{role}]: {content}")
285
+ elif isinstance(content, list):
286
+ # Handle content blocks
287
+ parts = []
288
+ for block in content:
289
+ if isinstance(block, dict):
290
+ if block.get('type') == 'text':
291
+ text = block.get('text', '')
292
+ if len(text) > 500:
293
+ text = text[:500] + "..."
294
+ parts.append(text)
295
+ elif block.get('type') == 'tool_use':
296
+ parts.append(f"[Tool: {block.get('name', 'unknown')}]")
297
+ elif block.get('type') == 'tool_result':
298
+ result = str(block.get('content', ''))
299
+ if len(result) > 300:
300
+ result = result[:300] + "..."
301
+ parts.append(f"[Result: {result}]")
302
+ lines.append(f"[{role}]: {' | '.join(parts)}")
303
+
304
+ return '\n\n'.join(lines)
305
+
306
+
307
+ class ActionExecutor:
308
+ """
309
+ Executes autonomous actions by invoking the LLM.
310
+
311
+ Handles:
312
+ - Context preparation (fresh vs cumulative)
313
+ - Tool filtering by action permissions
314
+ - LLM invocation with action prompt
315
+ - Result storage and error handling
316
+ - Auto-disable after N failures
317
+ """
318
+
319
+ def __init__(self, database, llm_manager, mcp_manager=None,
320
+ get_tools_func: Callable[[], List[Dict]] = None,
321
+ config: Optional[Dict[str, Any]] = None,
322
+ context_limit_resolver=None):
323
+ """
324
+ Initialise the action executor.
325
+
326
+ Args:
327
+ database: ConversationDatabase instance
328
+ llm_manager: LLMManager instance
329
+ mcp_manager: Optional MCPManager for tool access
330
+ get_tools_func: Optional function to get available tools
331
+ config: Optional application configuration for builtin tools
332
+ context_limit_resolver: Optional ContextLimitResolver for model limits
333
+ """
334
+ self.database = database
335
+ self.llm_manager = llm_manager
336
+ self.mcp_manager = mcp_manager
337
+ self.get_tools_func = get_tools_func
338
+ self.config = config or {}
339
+ self.context_limit_resolver = context_limit_resolver
340
+
341
+ # Context storage for cumulative mode
342
+ self._cumulative_contexts: Dict[int, List[Dict]] = {}
343
+
344
+ # Initialise context compactor for long-running actions
345
+ compaction_config = self.config.get('conversation', {}).get('compaction', {})
346
+ self.context_compactor = ActionContextCompactor(
347
+ llm_manager=llm_manager,
348
+ compaction_threshold=compaction_config.get('action_threshold', 0.6),
349
+ emergency_threshold=compaction_config.get('action_emergency_threshold', 0.85)
350
+ )
351
+
352
+ logging.info("ActionExecutor initialised with context compaction support")
353
+
354
+ def execute(self, action_id: int, user_guid: str, is_manual: bool = False) -> Dict[str, Any]:
355
+ """
356
+ Execute an autonomous action.
357
+
358
+ Args:
359
+ action_id: ID of the action to execute
360
+ user_guid: User GUID for database operations
361
+ is_manual: Whether this is a manual "Run Now" execution
362
+
363
+ Returns:
364
+ Dict with 'success', 'run_id', 'result', 'error' keys
365
+ """
366
+ logging.info(f"Executing action {action_id} (manual: {is_manual})")
367
+
368
+ # Get action details
369
+ action = self.database.get_action(action_id)
370
+ if not action:
371
+ logging.error(f"Action {action_id} not found")
372
+ return {
373
+ 'success': False,
374
+ 'run_id': None,
375
+ 'result': None,
376
+ 'error': 'Action not found'
377
+ }
378
+
379
+ if not action['is_enabled'] and not is_manual:
380
+ logging.warning(f"Action {action_id} is disabled, skipping")
381
+ return {
382
+ 'success': False,
383
+ 'run_id': None,
384
+ 'result': None,
385
+ 'error': 'Action is disabled'
386
+ }
387
+
388
+ # Record run start
389
+ run_id = self.database.record_action_run(
390
+ action_id=action_id,
391
+ status='running'
392
+ )
393
+
394
+ try:
395
+ # Execute the action
396
+ result = self._execute_action(action, is_manual)
397
+
398
+ # Update run with success
399
+ self.database.update_action_run(
400
+ run_id=run_id,
401
+ status='completed',
402
+ result_text=result.get('text'),
403
+ result_html=result.get('html'),
404
+ input_tokens=result.get('input_tokens', 0),
405
+ output_tokens=result.get('output_tokens', 0),
406
+ context_snapshot=result.get('context_snapshot')
407
+ )
408
+
409
+ # Update last run time
410
+ next_run = self._calculate_next_run(action)
411
+ self.database.update_action_last_run(action_id, next_run)
412
+
413
+ logging.info(f"Action {action_id} completed successfully (run {run_id})")
414
+ return {
415
+ 'success': True,
416
+ 'run_id': run_id,
417
+ 'result': result.get('text'),
418
+ 'error': None
419
+ }
420
+
421
+ except Exception as e:
422
+ error_message = str(e)
423
+ logging.error(f"Action {action_id} failed: {error_message}")
424
+
425
+ # Update run with failure
426
+ self.database.update_action_run(
427
+ run_id=run_id,
428
+ status='failed',
429
+ error_message=error_message
430
+ )
431
+
432
+ # Increment failure count
433
+ failure_info = self.database.increment_action_failure_count(action_id)
434
+
435
+ if failure_info.get('auto_disabled'):
436
+ logging.error(
437
+ f"Action {action_id} ({action['name']}) auto-disabled "
438
+ f"after {failure_info['failure_count']} failures"
439
+ )
440
+
441
+ return {
442
+ 'success': False,
443
+ 'run_id': run_id,
444
+ 'result': None,
445
+ 'error': error_message
446
+ }
447
+
448
+ def _execute_action(self, action: Dict, is_manual: bool) -> Dict[str, Any]:
449
+ """
450
+ Execute the actual LLM invocation for an action.
451
+
452
+ Args:
453
+ action: Action dictionary
454
+ is_manual: Whether this is a manual execution
455
+
456
+ Returns:
457
+ Dict with 'text', 'html', 'input_tokens', 'output_tokens', 'context_snapshot'
458
+ """
459
+ # Set the model
460
+ model_id = action['model_id']
461
+ try:
462
+ self.llm_manager.set_model(model_id)
463
+ except Exception as e:
464
+ # Log available models to help diagnose the issue
465
+ try:
466
+ available = self.llm_manager.list_all_models()
467
+ available_ids = [m.get('id', 'unknown') for m in available]
468
+ logging.error(f"Available models: {available_ids}")
469
+ except Exception as list_err:
470
+ logging.error(f"Failed to list available models: {list_err}")
471
+ raise RuntimeError(f"Failed to set model {model_id}: {e}")
472
+
473
+ # Prepare context based on context_mode
474
+ messages = self._prepare_context(action)
475
+
476
+ # Get filtered tools based on action permissions
477
+ tools = self._get_filtered_tools(action['id'])
478
+
479
+ # Prepare system prompt
480
+ system_prompt = self._build_system_prompt(action)
481
+
482
+ # Get configured max_tokens for this action (default 8192)
483
+ action_max_tokens = action.get('max_tokens', 8192)
484
+
485
+ # Invoke the model
486
+ start_time = time.time()
487
+ response = self.llm_manager.invoke_model(
488
+ messages=messages,
489
+ max_tokens=action_max_tokens,
490
+ temperature=0.7,
491
+ tools=tools if tools else None,
492
+ system=system_prompt
493
+ )
494
+ elapsed_time = time.time() - start_time
495
+
496
+ if not response:
497
+ raise RuntimeError("No response from LLM")
498
+
499
+ if response.get('error'):
500
+ raise RuntimeError(
501
+ f"LLM error: {response.get('error_message', 'Unknown error')}"
502
+ )
503
+
504
+ # Log response details for debugging token limit issues
505
+ stop_reason = response.get('stop_reason', 'unknown')
506
+ usage = response.get('usage', {})
507
+ output_tokens = usage.get('output_tokens', 0)
508
+ logging.debug(
509
+ f"Action {action['id']} initial response: stop_reason={stop_reason}, "
510
+ f"output_tokens={output_tokens}, max_tokens={action_max_tokens}"
511
+ )
512
+
513
+ # Handle tool calls in a loop until LLM stops requesting tools
514
+ # Note: Bedrock returns 'content' as text string, 'content_blocks' as list
515
+ # Get max iterations from action settings, config, or default to 25
516
+ max_tool_iterations = action.get('max_tool_iterations', None)
517
+ if max_tool_iterations is None:
518
+ max_tool_iterations = self.config.get('conversation', {}).get('max_tool_iterations', 25)
519
+ logging.debug(f"Action {action['id']} max tool iterations: {max_tool_iterations}")
520
+ iteration = 0
521
+
522
+ # Accumulate all text responses and track tool calls
523
+ all_text_responses = []
524
+ tool_calls_summary = []
525
+ compaction_count = 0
526
+
527
+ # Get context window for compaction checks
528
+ context_window = self._get_context_window(model_id)
529
+
530
+ # Extract any text from initial response
531
+ initial_text = self._extract_text_response(response)
532
+ if initial_text:
533
+ all_text_responses.append(initial_text)
534
+
535
+ while iteration < max_tool_iterations:
536
+ content_blocks = response.get('content_blocks', [])
537
+ tool_use_blocks = [b for b in content_blocks if b.get('type') == 'tool_use']
538
+
539
+ if not tool_use_blocks:
540
+ # No more tool calls - we're done
541
+ break
542
+
543
+ iteration += 1
544
+ logging.debug(f"Action {action['id']} tool iteration {iteration}/{max_tool_iterations}")
545
+
546
+ # Track tool calls for summary
547
+ for block in tool_use_blocks:
548
+ tool_calls_summary.append({
549
+ 'iteration': iteration,
550
+ 'tool': block.get('name', 'unknown'),
551
+ 'input': block.get('input', {})
552
+ })
553
+
554
+ # Execute tool calls (uses self._tool_sources for routing)
555
+ tool_results = self._execute_tool_calls(action['id'], tool_use_blocks)
556
+
557
+ # Add tool results to messages and get next response
558
+ messages = self._add_tool_results(messages, response, tool_results)
559
+
560
+ # Check for context compaction every few iterations
561
+ if iteration % 3 == 0 and context_window > 0:
562
+ messages, compacted = self.context_compactor.check_and_compact(
563
+ messages=messages,
564
+ original_prompt=action['action_prompt'],
565
+ context_window=context_window,
566
+ in_tool_loop=True
567
+ )
568
+ if compacted:
569
+ compaction_count += 1
570
+ logging.info(f"Action {action['id']} context compacted (compaction #{compaction_count})")
571
+
572
+ response = self.llm_manager.invoke_model(
573
+ messages=messages,
574
+ max_tokens=action_max_tokens,
575
+ temperature=0.7,
576
+ tools=tools if tools else None,
577
+ system=system_prompt
578
+ )
579
+
580
+ if response.get('error'):
581
+ raise RuntimeError(
582
+ f"LLM error during tool iteration: {response.get('error_message', 'Unknown error')}"
583
+ )
584
+
585
+ # Extract and accumulate text from this iteration
586
+ iter_text = self._extract_text_response(response)
587
+ if iter_text:
588
+ all_text_responses.append(iter_text)
589
+
590
+ # Log iteration response details
591
+ iter_stop_reason = response.get('stop_reason', 'unknown')
592
+ iter_usage = response.get('usage', {})
593
+ iter_output_tokens = iter_usage.get('output_tokens', 0)
594
+ logging.debug(
595
+ f"Action {action['id']} iteration {iteration} response: "
596
+ f"stop_reason={iter_stop_reason}, output_tokens={iter_output_tokens}"
597
+ )
598
+
599
+ if iteration >= max_tool_iterations:
600
+ logging.warning(f"Action {action['id']} reached max tool iterations ({max_tool_iterations})")
601
+ # Add warning to output
602
+ all_text_responses.append(
603
+ f"\n\n---\n**Note:** Action reached maximum tool iterations ({max_tool_iterations}). "
604
+ f"The task may be incomplete. Consider increasing max_tool_iterations in config."
605
+ )
606
+
607
+ # Combine all text responses
608
+ text_response = '\n\n'.join(all_text_responses) if all_text_responses else ''
609
+
610
+ # Add execution summary
611
+ summary_parts = []
612
+ if tool_calls_summary:
613
+ tools_used = set(tc['tool'] for tc in tool_calls_summary)
614
+ summary_parts.append(f"**Tools used ({len(tool_calls_summary)} calls):** {', '.join(sorted(tools_used))}")
615
+ if compaction_count > 0:
616
+ summary_parts.append(f"**Context compactions:** {compaction_count}")
617
+ if summary_parts:
618
+ text_response += "\n\n---\n" + " | ".join(summary_parts)
619
+
620
+ # Convert to HTML
621
+ html_response = self._convert_to_html(text_response)
622
+
623
+ # Update cumulative context if needed
624
+ context_snapshot = None
625
+ if action['context_mode'] == 'cumulative':
626
+ self._update_cumulative_context(action['id'], messages, response)
627
+ context_snapshot = json.dumps(self._cumulative_contexts.get(action['id'], []))
628
+
629
+ # Get token usage
630
+ usage = response.get('usage', {})
631
+
632
+ return {
633
+ 'text': text_response,
634
+ 'html': html_response,
635
+ 'input_tokens': usage.get('input_tokens', 0),
636
+ 'output_tokens': usage.get('output_tokens', 0),
637
+ 'context_snapshot': context_snapshot
638
+ }
639
+
640
+ def _prepare_context(self, action: Dict) -> List[Dict]:
641
+ """
642
+ Prepare the message context for the action.
643
+
644
+ Args:
645
+ action: Action dictionary
646
+
647
+ Returns:
648
+ List of message dictionaries
649
+ """
650
+ if action['context_mode'] == 'cumulative':
651
+ # Use stored context plus new prompt
652
+ context = self._cumulative_contexts.get(action['id'], []).copy()
653
+ context.append({
654
+ 'role': 'user',
655
+ 'content': action['action_prompt']
656
+ })
657
+ return context
658
+ else:
659
+ # Fresh context - just the action prompt
660
+ return [{
661
+ 'role': 'user',
662
+ 'content': action['action_prompt']
663
+ }]
664
+
665
+ def _update_cumulative_context(self, action_id: int,
666
+ messages: List[Dict],
667
+ response: Dict):
668
+ """
669
+ Update the cumulative context for an action.
670
+
671
+ Args:
672
+ action_id: Action ID
673
+ messages: Messages sent to the model
674
+ response: Model response
675
+ """
676
+ # Add assistant response to context
677
+ content = self._extract_text_response(response)
678
+ messages.append({
679
+ 'role': 'assistant',
680
+ 'content': content
681
+ })
682
+
683
+ # Store for next run
684
+ self._cumulative_contexts[action_id] = messages
685
+
686
+ # Trim if too long (keep last 10 exchanges)
687
+ if len(messages) > 20:
688
+ self._cumulative_contexts[action_id] = messages[-20:]
689
+
690
+ def _get_filtered_tools(self, action_id: int) -> Optional[List[Dict]]:
691
+ """
692
+ Get tools filtered by action permissions.
693
+
694
+ Includes both MCP tools and builtin tools (datetime, filesystem).
695
+ Also populates self._tool_sources for routing during execution.
696
+
697
+ Args:
698
+ action_id: Action ID
699
+
700
+ Returns:
701
+ List of allowed tool definitions or None
702
+ """
703
+ from dtSpark.tools import builtin
704
+
705
+ # Get action's tool permissions
706
+ permissions = self.database.get_action_tool_permissions(action_id)
707
+ if not permissions:
708
+ return None # No tools allowed
709
+
710
+ allowed_tools = {p['tool_name'] for p in permissions
711
+ if p['permission_state'] == 'allowed'}
712
+
713
+ filtered = []
714
+ # Store tool sources for execution routing (not sent to API)
715
+ self._tool_sources = {}
716
+
717
+ # Get builtin tools (datetime, filesystem if enabled)
718
+ builtin_tools = builtin.get_builtin_tools(self.config)
719
+ for tool in builtin_tools:
720
+ if tool['name'] in allowed_tools:
721
+ filtered.append({
722
+ 'name': tool['name'],
723
+ 'description': tool['description'],
724
+ 'input_schema': tool['input_schema']
725
+ })
726
+ self._tool_sources[tool['name']] = 'builtin'
727
+
728
+ # Get MCP tools
729
+ if self.get_tools_func:
730
+ mcp_tools = self.get_tools_func()
731
+ if mcp_tools:
732
+ for tool in mcp_tools:
733
+ if tool['name'] in allowed_tools:
734
+ filtered.append({
735
+ 'name': tool['name'],
736
+ 'description': tool['description'],
737
+ 'input_schema': tool['input_schema']
738
+ })
739
+ self._tool_sources[tool['name']] = 'mcp'
740
+
741
+ return filtered if filtered else None
742
+
743
+ def _execute_tool_calls(self, action_id: int,
744
+ tool_use_blocks: List[Dict]) -> List[Dict]:
745
+ """
746
+ Execute tool calls and return results.
747
+
748
+ Handles both builtin tools and MCP tools.
749
+ Uses self._tool_sources (populated by _get_filtered_tools) for routing.
750
+
751
+ Args:
752
+ action_id: Action ID for permission checking
753
+ tool_use_blocks: Tool use blocks from model response
754
+
755
+ Returns:
756
+ List of tool result dictionaries
757
+ """
758
+ from dtSpark.tools import builtin
759
+
760
+ results = []
761
+
762
+ # Get tool sources from instance (populated by _get_filtered_tools)
763
+ tool_sources = getattr(self, '_tool_sources', {})
764
+
765
+ for tool_block in tool_use_blocks:
766
+ tool_name = tool_block.get('name')
767
+ tool_id = tool_block.get('id')
768
+ tool_input = tool_block.get('input', {})
769
+
770
+ # Debug: log the full tool block for write_file issues
771
+ if tool_name == 'write_file':
772
+ logging.debug(f"write_file tool_block keys: {list(tool_block.keys())}")
773
+ logging.debug(f"write_file tool_input keys: {list(tool_input.keys()) if tool_input else 'None'}")
774
+ content_preview = str(tool_input.get('content', ''))[:100] if tool_input else ''
775
+ logging.debug(f"write_file content preview: '{content_preview}...' (len={len(tool_input.get('content', '') or '')})")
776
+
777
+ try:
778
+ # Check permission
779
+ permissions = self.database.get_action_tool_permissions(action_id)
780
+ allowed = any(
781
+ p['tool_name'] == tool_name and p['permission_state'] == 'allowed'
782
+ for p in permissions
783
+ )
784
+
785
+ if not allowed:
786
+ results.append({
787
+ 'type': 'tool_result',
788
+ 'tool_use_id': tool_id,
789
+ 'content': f"Tool '{tool_name}' is not permitted for this action"
790
+ })
791
+ continue
792
+
793
+ # Determine tool source and execute accordingly
794
+ tool_source = tool_sources.get(tool_name, 'mcp')
795
+
796
+ if tool_source == 'builtin':
797
+ # Execute builtin tool
798
+ logging.debug(f"Executing builtin tool: {tool_name}")
799
+ result = builtin.execute_builtin_tool(tool_name, tool_input, self.config)
800
+
801
+ if result.get('success'):
802
+ result_data = result.get('result', {})
803
+ if isinstance(result_data, dict):
804
+ result_str = json.dumps(result_data, indent=2)
805
+ else:
806
+ result_str = str(result_data)
807
+ results.append({
808
+ 'type': 'tool_result',
809
+ 'tool_use_id': tool_id,
810
+ 'content': result_str
811
+ })
812
+ else:
813
+ results.append({
814
+ 'type': 'tool_result',
815
+ 'tool_use_id': tool_id,
816
+ 'content': result.get('error', 'Builtin tool execution failed'),
817
+ 'is_error': True
818
+ })
819
+
820
+ elif self.mcp_manager:
821
+ # Execute MCP tool (async call)
822
+ logging.debug(f"Executing MCP tool: {tool_name}")
823
+ result = self._call_mcp_tool_sync(tool_name, tool_input)
824
+
825
+ if result and not result.get('isError'):
826
+ # Extract text content from result
827
+ content_parts = []
828
+ for content in result.get('content', []):
829
+ if content.get('type') == 'text':
830
+ content_parts.append(content.get('text', ''))
831
+
832
+ result_str = '\n'.join(content_parts) if content_parts else 'Tool executed successfully (no output)'
833
+ results.append({
834
+ 'type': 'tool_result',
835
+ 'tool_use_id': tool_id,
836
+ 'content': result_str
837
+ })
838
+ else:
839
+ error_msg = "Tool execution failed"
840
+ if result:
841
+ for content in result.get('content', []):
842
+ if content.get('type') == 'text':
843
+ error_msg = content.get('text', error_msg)
844
+ break
845
+ results.append({
846
+ 'type': 'tool_result',
847
+ 'tool_use_id': tool_id,
848
+ 'content': error_msg,
849
+ 'is_error': True
850
+ })
851
+ else:
852
+ results.append({
853
+ 'type': 'tool_result',
854
+ 'tool_use_id': tool_id,
855
+ 'content': "No tool execution handler available"
856
+ })
857
+
858
+ except Exception as e:
859
+ logging.error(f"Tool {tool_name} execution failed: {e}")
860
+ results.append({
861
+ 'type': 'tool_result',
862
+ 'tool_use_id': tool_id,
863
+ 'content': f"Error executing tool: {str(e)}",
864
+ 'is_error': True
865
+ })
866
+
867
+ return results
868
+
869
+ def _call_mcp_tool_sync(self, tool_name: str, tool_input: Dict) -> Optional[Dict]:
870
+ """
871
+ Call an async MCP tool from synchronous context.
872
+
873
+ Uses the MCP manager's initialisation event loop or creates a new one
874
+ to execute the async call_tool method.
875
+
876
+ Args:
877
+ tool_name: Name of the tool to call
878
+ tool_input: Tool input parameters
879
+
880
+ Returns:
881
+ Tool result dictionary or None on failure
882
+ """
883
+ try:
884
+ # Check if there's a running event loop
885
+ try:
886
+ loop = asyncio.get_running_loop()
887
+ # Running in async context - use thread pool to avoid blocking
888
+ logging.debug(f"Running event loop detected, using thread pool for tool {tool_name}")
889
+
890
+ def run_tool_in_loop():
891
+ new_loop = asyncio.new_event_loop()
892
+ asyncio.set_event_loop(new_loop)
893
+ try:
894
+ return new_loop.run_until_complete(
895
+ asyncio.wait_for(
896
+ self.mcp_manager.call_tool(tool_name, tool_input),
897
+ timeout=30.0
898
+ )
899
+ )
900
+ except asyncio.TimeoutError:
901
+ logging.error(f"Timeout calling MCP tool {tool_name} after 30 seconds")
902
+ return None
903
+ finally:
904
+ new_loop.close()
905
+
906
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
907
+ future = executor.submit(run_tool_in_loop)
908
+ return future.result(timeout=35.0)
909
+
910
+ except RuntimeError:
911
+ # No running event loop - we're in sync context
912
+ logging.debug(f"No running event loop, using standard approach for tool {tool_name}")
913
+
914
+ # Use the MCP manager's initialisation loop if available
915
+ if hasattr(self.mcp_manager, '_initialization_loop') and self.mcp_manager._initialization_loop:
916
+ loop = self.mcp_manager._initialization_loop
917
+ should_close_loop = False
918
+ logging.debug(f"Using stored initialisation event loop for tool {tool_name}")
919
+ else:
920
+ loop = asyncio.new_event_loop()
921
+ asyncio.set_event_loop(loop)
922
+ should_close_loop = True
923
+ logging.debug(f"Creating temporary event loop for tool {tool_name}")
924
+
925
+ try:
926
+ return loop.run_until_complete(
927
+ asyncio.wait_for(
928
+ self.mcp_manager.call_tool(tool_name, tool_input),
929
+ timeout=30.0
930
+ )
931
+ )
932
+ except asyncio.TimeoutError:
933
+ logging.error(f"Timeout calling MCP tool {tool_name} after 30 seconds")
934
+ return None
935
+ finally:
936
+ if should_close_loop:
937
+ loop.close()
938
+
939
+ except Exception as e:
940
+ logging.error(f"Error calling MCP tool {tool_name}: {e}")
941
+ return None
942
+
943
+ def _add_tool_results(self, messages: List[Dict],
944
+ response: Dict,
945
+ tool_results: List[Dict]) -> List[Dict]:
946
+ """
947
+ Add tool results to the message history.
948
+
949
+ Args:
950
+ messages: Current messages
951
+ response: Model response with tool_use
952
+ tool_results: Tool execution results
953
+
954
+ Returns:
955
+ Updated message list
956
+ """
957
+ # Add assistant's tool use response
958
+ # Note: Must use content_blocks which contains the tool_use blocks
959
+ messages.append({
960
+ 'role': 'assistant',
961
+ 'content': response.get('content_blocks', [])
962
+ })
963
+
964
+ # Add tool results as user message
965
+ messages.append({
966
+ 'role': 'user',
967
+ 'content': tool_results
968
+ })
969
+
970
+ return messages
971
+
972
+ def _extract_text_response(self, response: Dict) -> str:
973
+ """
974
+ Extract text content from model response.
975
+
976
+ Args:
977
+ response: Model response dictionary
978
+
979
+ Returns:
980
+ Text content string
981
+ """
982
+ content = response.get('content', [])
983
+
984
+ if isinstance(content, str):
985
+ return content
986
+
987
+ text_parts = []
988
+ for block in content:
989
+ if block.get('type') == 'text':
990
+ text_parts.append(block.get('text', ''))
991
+
992
+ return '\n'.join(text_parts)
993
+
994
+ def _convert_to_html(self, text: str) -> str:
995
+ """
996
+ Convert markdown text to HTML.
997
+
998
+ Args:
999
+ text: Markdown text
1000
+
1001
+ Returns:
1002
+ HTML string
1003
+ """
1004
+ try:
1005
+ return markdown.markdown(
1006
+ text,
1007
+ extensions=['tables', 'fenced_code', 'codehilite']
1008
+ )
1009
+ except Exception as e:
1010
+ logging.warning(f"Failed to convert to HTML: {e}")
1011
+ return f"<pre>{text}</pre>"
1012
+
1013
+ def _build_system_prompt(self, action: Dict) -> str:
1014
+ """
1015
+ Build the system prompt for the action.
1016
+
1017
+ Includes current date/time for time awareness.
1018
+
1019
+ Args:
1020
+ action: Action dictionary
1021
+
1022
+ Returns:
1023
+ System prompt string
1024
+ """
1025
+ # Get current datetime with timezone
1026
+ now = datetime.now().astimezone()
1027
+ current_datetime = now.strftime("%A, %d %B %Y at %H:%M:%S %Z")
1028
+ iso_datetime = now.isoformat()
1029
+
1030
+ return f"""You are an autonomous AI assistant executing a scheduled action.
1031
+
1032
+ Current Date and Time: {current_datetime}
1033
+ ISO Format: {iso_datetime}
1034
+
1035
+ Action Name: {action['name']}
1036
+ Action Description: {action['description']}
1037
+
1038
+ Execute the requested task to the best of your ability. Be concise and focused.
1039
+ If you need to use tools, use only those that have been explicitly permitted for this action.
1040
+ You have access to the get_current_datetime tool if you need precise time information with timezone support.
1041
+ """
1042
+
1043
+ def _calculate_next_run(self, action: Dict) -> Optional[datetime]:
1044
+ """
1045
+ Calculate the next run time for a recurring action.
1046
+
1047
+ Args:
1048
+ action: Action dictionary
1049
+
1050
+ Returns:
1051
+ Next run datetime or None for one-off actions
1052
+ """
1053
+ if action['schedule_type'] == 'one_off':
1054
+ return None
1055
+
1056
+ # For recurring actions, APScheduler handles the next run calculation
1057
+ # This is just a placeholder - the scheduler will update this
1058
+ return None
1059
+
1060
+ def clear_cumulative_context(self, action_id: int):
1061
+ """
1062
+ Clear the cumulative context for an action.
1063
+
1064
+ Args:
1065
+ action_id: Action ID
1066
+ """
1067
+ if action_id in self._cumulative_contexts:
1068
+ del self._cumulative_contexts[action_id]
1069
+ logging.info(f"Cleared cumulative context for action {action_id}")
1070
+
1071
+ def load_cumulative_context(self, action_id: int, context_json: str):
1072
+ """
1073
+ Load cumulative context from a stored snapshot.
1074
+
1075
+ Args:
1076
+ action_id: Action ID
1077
+ context_json: JSON string of context messages
1078
+ """
1079
+ try:
1080
+ context = json.loads(context_json)
1081
+ self._cumulative_contexts[action_id] = context
1082
+ logging.debug(f"Loaded cumulative context for action {action_id}")
1083
+ except json.JSONDecodeError as e:
1084
+ logging.error(f"Failed to load cumulative context: {e}")
1085
+
1086
+ def _get_context_window(self, model_id: str) -> int:
1087
+ """
1088
+ Get the context window size for a model.
1089
+
1090
+ Uses ContextLimitResolver if available, otherwise returns defaults
1091
+ based on model ID patterns.
1092
+
1093
+ Args:
1094
+ model_id: Model identifier
1095
+
1096
+ Returns:
1097
+ Context window size in tokens
1098
+ """
1099
+ # Try using the context limit resolver if available
1100
+ if self.context_limit_resolver:
1101
+ try:
1102
+ # Determine provider from model ID
1103
+ provider = self._get_provider_from_model_id(model_id)
1104
+ limits = self.context_limit_resolver.get_context_limits(model_id, provider)
1105
+ return limits.get('context_window', 200000)
1106
+ except Exception as e:
1107
+ logging.warning(f"Failed to get context limits for {model_id}: {e}")
1108
+
1109
+ # Fallback defaults based on model patterns
1110
+ model_lower = model_id.lower()
1111
+
1112
+ # Claude models
1113
+ if 'claude-3-5' in model_lower or 'claude-3.5' in model_lower:
1114
+ return 200000
1115
+ elif 'claude-3' in model_lower:
1116
+ return 200000
1117
+ elif 'claude-2' in model_lower:
1118
+ return 100000
1119
+
1120
+ # Llama models
1121
+ if 'llama' in model_lower:
1122
+ return 128000
1123
+
1124
+ # Mistral models
1125
+ if 'mistral' in model_lower:
1126
+ return 32000
1127
+
1128
+ # Default fallback
1129
+ return 128000
1130
+
1131
+ def _get_provider_from_model_id(self, model_id: str) -> str:
1132
+ """
1133
+ Determine provider from model ID.
1134
+
1135
+ Args:
1136
+ model_id: Model identifier
1137
+
1138
+ Returns:
1139
+ Provider name string
1140
+ """
1141
+ model_lower = model_id.lower()
1142
+
1143
+ if 'claude' in model_lower or 'anthropic' in model_lower:
1144
+ return 'anthropic'
1145
+ if 'amazon.' in model_lower or 'titan' in model_lower:
1146
+ return 'aws_bedrock'
1147
+ if 'meta.' in model_lower or 'llama' in model_lower:
1148
+ return 'aws_bedrock'
1149
+ if 'mistral.' in model_lower:
1150
+ return 'aws_bedrock'
1151
+
1152
+ return 'ollama'