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.
- dtSpark/__init__.py +0 -0
- dtSpark/_description.txt +1 -0
- dtSpark/_full_name.txt +1 -0
- dtSpark/_licence.txt +21 -0
- dtSpark/_metadata.yaml +6 -0
- dtSpark/_name.txt +1 -0
- dtSpark/_version.txt +1 -0
- dtSpark/aws/__init__.py +7 -0
- dtSpark/aws/authentication.py +296 -0
- dtSpark/aws/bedrock.py +578 -0
- dtSpark/aws/costs.py +318 -0
- dtSpark/aws/pricing.py +580 -0
- dtSpark/cli_interface.py +2645 -0
- dtSpark/conversation_manager.py +3050 -0
- dtSpark/core/__init__.py +12 -0
- dtSpark/core/application.py +3355 -0
- dtSpark/core/context_compaction.py +735 -0
- dtSpark/daemon/__init__.py +104 -0
- dtSpark/daemon/__main__.py +10 -0
- dtSpark/daemon/action_monitor.py +213 -0
- dtSpark/daemon/daemon_app.py +730 -0
- dtSpark/daemon/daemon_manager.py +289 -0
- dtSpark/daemon/execution_coordinator.py +194 -0
- dtSpark/daemon/pid_file.py +169 -0
- dtSpark/database/__init__.py +482 -0
- dtSpark/database/autonomous_actions.py +1191 -0
- dtSpark/database/backends.py +329 -0
- dtSpark/database/connection.py +122 -0
- dtSpark/database/conversations.py +520 -0
- dtSpark/database/credential_prompt.py +218 -0
- dtSpark/database/files.py +205 -0
- dtSpark/database/mcp_ops.py +355 -0
- dtSpark/database/messages.py +161 -0
- dtSpark/database/schema.py +673 -0
- dtSpark/database/tool_permissions.py +186 -0
- dtSpark/database/usage.py +167 -0
- dtSpark/files/__init__.py +4 -0
- dtSpark/files/manager.py +322 -0
- dtSpark/launch.py +39 -0
- dtSpark/limits/__init__.py +10 -0
- dtSpark/limits/costs.py +296 -0
- dtSpark/limits/tokens.py +342 -0
- dtSpark/llm/__init__.py +17 -0
- dtSpark/llm/anthropic_direct.py +446 -0
- dtSpark/llm/base.py +146 -0
- dtSpark/llm/context_limits.py +438 -0
- dtSpark/llm/manager.py +177 -0
- dtSpark/llm/ollama.py +578 -0
- dtSpark/mcp_integration/__init__.py +5 -0
- dtSpark/mcp_integration/manager.py +653 -0
- dtSpark/mcp_integration/tool_selector.py +225 -0
- dtSpark/resources/config.yaml.template +631 -0
- dtSpark/safety/__init__.py +22 -0
- dtSpark/safety/llm_service.py +111 -0
- dtSpark/safety/patterns.py +229 -0
- dtSpark/safety/prompt_inspector.py +442 -0
- dtSpark/safety/violation_logger.py +346 -0
- dtSpark/scheduler/__init__.py +20 -0
- dtSpark/scheduler/creation_tools.py +599 -0
- dtSpark/scheduler/execution_queue.py +159 -0
- dtSpark/scheduler/executor.py +1152 -0
- dtSpark/scheduler/manager.py +395 -0
- dtSpark/tools/__init__.py +4 -0
- dtSpark/tools/builtin.py +833 -0
- dtSpark/web/__init__.py +20 -0
- dtSpark/web/auth.py +152 -0
- dtSpark/web/dependencies.py +37 -0
- dtSpark/web/endpoints/__init__.py +17 -0
- dtSpark/web/endpoints/autonomous_actions.py +1125 -0
- dtSpark/web/endpoints/chat.py +621 -0
- dtSpark/web/endpoints/conversations.py +353 -0
- dtSpark/web/endpoints/main_menu.py +547 -0
- dtSpark/web/endpoints/streaming.py +421 -0
- dtSpark/web/server.py +578 -0
- dtSpark/web/session.py +167 -0
- dtSpark/web/ssl_utils.py +195 -0
- dtSpark/web/static/css/dark-theme.css +427 -0
- dtSpark/web/static/js/actions.js +1101 -0
- dtSpark/web/static/js/chat.js +614 -0
- dtSpark/web/static/js/main.js +496 -0
- dtSpark/web/static/js/sse-client.js +242 -0
- dtSpark/web/templates/actions.html +408 -0
- dtSpark/web/templates/base.html +93 -0
- dtSpark/web/templates/chat.html +814 -0
- dtSpark/web/templates/conversations.html +350 -0
- dtSpark/web/templates/goodbye.html +81 -0
- dtSpark/web/templates/login.html +90 -0
- dtSpark/web/templates/main_menu.html +983 -0
- dtSpark/web/templates/new_conversation.html +191 -0
- dtSpark/web/web_interface.py +137 -0
- dtspark-1.0.4.dist-info/METADATA +187 -0
- dtspark-1.0.4.dist-info/RECORD +96 -0
- dtspark-1.0.4.dist-info/WHEEL +5 -0
- dtspark-1.0.4.dist-info/entry_points.txt +3 -0
- dtspark-1.0.4.dist-info/licenses/LICENSE +21 -0
- 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'
|