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,3050 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Conversation manager module for handling chat sessions with intelligent context compaction.
|
|
3
|
+
|
|
4
|
+
This module provides functionality for:
|
|
5
|
+
- Managing conversation state and history
|
|
6
|
+
- Intelligent context compaction using model-specific context windows
|
|
7
|
+
- Message history management with selective preservation
|
|
8
|
+
- MCP tool integration
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import concurrent.futures
|
|
15
|
+
from typing import List, Dict, Optional, Tuple, Any, Union
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from dtSpark.tools import builtin
|
|
18
|
+
from dtSpark.mcp_integration import ToolSelector
|
|
19
|
+
from dtSpark.limits import LimitStatus
|
|
20
|
+
from dtSpark.safety import PromptInspector
|
|
21
|
+
from dtSpark.database.tool_permissions import PERMISSION_ALLOWED, PERMISSION_DENIED
|
|
22
|
+
from dtSpark.llm.context_limits import ContextLimitResolver
|
|
23
|
+
from dtSpark.core.context_compaction import ContextCompactor, get_provider_from_model_id
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConversationManager:
|
|
27
|
+
"""Manages conversation state and automatic rollup for token management."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, database, bedrock_service, max_tokens: int = 4096,
|
|
30
|
+
rollup_threshold: float = 0.8, rollup_summary_ratio: float = 0.3,
|
|
31
|
+
max_tool_result_tokens: int = 10000, max_tool_iterations: int = 25,
|
|
32
|
+
max_tool_selections: int = 30, emergency_rollup_threshold: float = 0.95,
|
|
33
|
+
mcp_manager = None, cli_interface = None, web_interface = None,
|
|
34
|
+
global_instructions: Optional[str] = None,
|
|
35
|
+
token_manager = None, prompt_inspector: Optional[PromptInspector] = None,
|
|
36
|
+
user_guid: Optional[str] = None, config: Optional[Dict[str, Any]] = None):
|
|
37
|
+
"""
|
|
38
|
+
Initialise the conversation manager.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
database: ConversationDatabase instance
|
|
42
|
+
bedrock_service: BedrockService instance
|
|
43
|
+
max_tokens: Maximum token limit for the model
|
|
44
|
+
rollup_threshold: Fraction of max_tokens at which to trigger rollup (0.0-1.0)
|
|
45
|
+
rollup_summary_ratio: Target ratio for summarised content (0.0-1.0)
|
|
46
|
+
max_tool_result_tokens: Maximum tokens per tool result (prevents context overflow)
|
|
47
|
+
max_tool_iterations: Maximum consecutive tool calls before stopping
|
|
48
|
+
max_tool_selections: Maximum number of tools to send with each request
|
|
49
|
+
emergency_rollup_threshold: Force rollup threshold even during tool use (0.0-1.0)
|
|
50
|
+
mcp_manager: Optional MCPManager instance for tool support
|
|
51
|
+
cli_interface: Optional CLI interface for displaying tool calls
|
|
52
|
+
web_interface: Optional Web interface for tool permission prompts
|
|
53
|
+
global_instructions: Optional global instructions that apply to all conversations
|
|
54
|
+
token_manager: Optional TokenManager instance for usage limit enforcement
|
|
55
|
+
prompt_inspector: Optional PromptInspector for security analysis
|
|
56
|
+
user_guid: Optional user GUID for multi-user support
|
|
57
|
+
config: Optional configuration dictionary for embedded tools
|
|
58
|
+
"""
|
|
59
|
+
self.database = database
|
|
60
|
+
self.bedrock_service = bedrock_service
|
|
61
|
+
self.default_max_tokens = max_tokens # Store global default
|
|
62
|
+
self.max_tokens = max_tokens # Current max_tokens (can be overridden per-conversation)
|
|
63
|
+
self.rollup_threshold = rollup_threshold
|
|
64
|
+
self.rollup_summary_ratio = rollup_summary_ratio
|
|
65
|
+
self.max_tool_result_tokens = max_tool_result_tokens
|
|
66
|
+
self.max_tool_iterations = max_tool_iterations
|
|
67
|
+
self.max_tool_selections = max_tool_selections
|
|
68
|
+
self.emergency_rollup_threshold = emergency_rollup_threshold
|
|
69
|
+
self.current_conversation_id = None
|
|
70
|
+
self.current_instructions: Optional[str] = None
|
|
71
|
+
self.global_instructions: Optional[str] = global_instructions
|
|
72
|
+
self.mcp_manager = mcp_manager
|
|
73
|
+
self.cli_interface = cli_interface
|
|
74
|
+
self.web_interface = web_interface
|
|
75
|
+
self.token_manager = token_manager
|
|
76
|
+
self.prompt_inspector = prompt_inspector
|
|
77
|
+
self.user_guid = user_guid
|
|
78
|
+
self.config = config # Store config for embedded tools
|
|
79
|
+
self._tools_cache: Optional[List[Dict[str, Any]]] = None
|
|
80
|
+
self._in_tool_use_loop = False # Flag to defer rollup during tool use sequences
|
|
81
|
+
# Initialise tool selector for intelligent tool selection
|
|
82
|
+
self.tool_selector = ToolSelector(max_tools_per_request=max_tool_selections)
|
|
83
|
+
|
|
84
|
+
# Initialise intelligent context compaction system
|
|
85
|
+
self.context_limit_resolver = ContextLimitResolver(config)
|
|
86
|
+
self.context_compactor = ContextCompactor(
|
|
87
|
+
bedrock_service=bedrock_service,
|
|
88
|
+
database=database,
|
|
89
|
+
context_limit_resolver=self.context_limit_resolver,
|
|
90
|
+
cli_interface=cli_interface,
|
|
91
|
+
web_interface=web_interface,
|
|
92
|
+
compaction_threshold=rollup_threshold,
|
|
93
|
+
emergency_threshold=emergency_rollup_threshold,
|
|
94
|
+
compaction_ratio=rollup_summary_ratio
|
|
95
|
+
)
|
|
96
|
+
logging.info("ConversationManager initialised with intelligent context compaction")
|
|
97
|
+
|
|
98
|
+
def update_service(self, bedrock_service):
|
|
99
|
+
"""
|
|
100
|
+
Update the LLM service used for conversation and compaction.
|
|
101
|
+
|
|
102
|
+
This should be called when the active provider/model changes.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
bedrock_service: The new LLM service to use
|
|
106
|
+
"""
|
|
107
|
+
old_provider = "unknown"
|
|
108
|
+
new_provider = "unknown"
|
|
109
|
+
|
|
110
|
+
if self.bedrock_service and hasattr(self.bedrock_service, 'get_provider_name'):
|
|
111
|
+
old_provider = self.bedrock_service.get_provider_name()
|
|
112
|
+
if bedrock_service and hasattr(bedrock_service, 'get_provider_name'):
|
|
113
|
+
new_provider = bedrock_service.get_provider_name()
|
|
114
|
+
|
|
115
|
+
self.bedrock_service = bedrock_service
|
|
116
|
+
|
|
117
|
+
# Also update the context compactor's service
|
|
118
|
+
if hasattr(self, 'context_compactor') and self.context_compactor:
|
|
119
|
+
self.context_compactor.update_service(bedrock_service)
|
|
120
|
+
|
|
121
|
+
logging.info(f"ConversationManager service updated: {old_provider} -> {new_provider}")
|
|
122
|
+
|
|
123
|
+
def get_embedded_tools(self) -> List[Dict[str, Any]]:
|
|
124
|
+
"""
|
|
125
|
+
Get embedded/built-in tools in toolSpec format for the web UI.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
List of tool definitions wrapped in {'toolSpec': tool} format
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
# Get raw builtin tools
|
|
132
|
+
raw_tools = builtin.get_builtin_tools(config=self.config)
|
|
133
|
+
|
|
134
|
+
# Wrap each tool in toolSpec format for web UI compatibility
|
|
135
|
+
embedded_tools = []
|
|
136
|
+
for tool in raw_tools:
|
|
137
|
+
embedded_tools.append({
|
|
138
|
+
'toolSpec': {
|
|
139
|
+
'name': tool.get('name', 'unknown'),
|
|
140
|
+
'description': tool.get('description', ''),
|
|
141
|
+
'inputSchema': tool.get('input_schema', {})
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
return embedded_tools
|
|
146
|
+
|
|
147
|
+
except Exception as e:
|
|
148
|
+
logging.warning(f"Error getting embedded tools: {e}")
|
|
149
|
+
return []
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _extract_text_from_content(content: Union[str, List[Dict[str, Any]]]) -> str:
|
|
153
|
+
"""
|
|
154
|
+
Extract text from content which can be either a string or list of content blocks.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
content: Either a string or list of content blocks (e.g., [{'text': 'Hello'}])
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Extracted text as a string
|
|
161
|
+
"""
|
|
162
|
+
if isinstance(content, str):
|
|
163
|
+
return content
|
|
164
|
+
elif isinstance(content, list):
|
|
165
|
+
# Extract text from all text blocks and concatenate
|
|
166
|
+
text_parts = []
|
|
167
|
+
for block in content:
|
|
168
|
+
if isinstance(block, dict) and 'text' in block:
|
|
169
|
+
text_parts.append(block['text'])
|
|
170
|
+
return ''.join(text_parts)
|
|
171
|
+
else:
|
|
172
|
+
return ''
|
|
173
|
+
|
|
174
|
+
def create_conversation(self, name: str, model_id: str, instructions: Optional[str] = None,
|
|
175
|
+
compaction_threshold: Optional[float] = None) -> int:
|
|
176
|
+
"""
|
|
177
|
+
Create a new conversation.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
name: Name for the conversation
|
|
181
|
+
model_id: Bedrock model ID to use
|
|
182
|
+
instructions: Optional instructions/system prompt for the conversation
|
|
183
|
+
compaction_threshold: Optional compaction threshold override (0.0-1.0, None uses global default)
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
ID of the newly created conversation
|
|
187
|
+
"""
|
|
188
|
+
conversation_id = self.database.create_conversation(name, model_id, instructions,
|
|
189
|
+
compaction_threshold=compaction_threshold)
|
|
190
|
+
self.current_conversation_id = conversation_id
|
|
191
|
+
self.current_instructions = instructions
|
|
192
|
+
|
|
193
|
+
# Update context compactor with conversation-specific threshold if set
|
|
194
|
+
if compaction_threshold is not None:
|
|
195
|
+
self.context_compactor.compaction_threshold = compaction_threshold
|
|
196
|
+
logging.info(f"Using conversation-specific compaction threshold: {compaction_threshold:.0%}")
|
|
197
|
+
else:
|
|
198
|
+
# Reset to global default
|
|
199
|
+
self.context_compactor.compaction_threshold = self.rollup_threshold
|
|
200
|
+
logging.info(f"Using global default compaction threshold: {self.rollup_threshold:.0%}")
|
|
201
|
+
|
|
202
|
+
logging.info(f"Created new conversation: {name} (ID: {conversation_id})")
|
|
203
|
+
return conversation_id
|
|
204
|
+
|
|
205
|
+
def load_conversation(self, conversation_id: int) -> bool:
|
|
206
|
+
"""
|
|
207
|
+
Load an existing conversation.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
conversation_id: ID of the conversation to load
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
True if loaded successfully, False otherwise
|
|
214
|
+
"""
|
|
215
|
+
conversation = self.database.get_conversation(conversation_id)
|
|
216
|
+
if conversation:
|
|
217
|
+
self.current_conversation_id = conversation_id
|
|
218
|
+
self.current_instructions = conversation.get('instructions')
|
|
219
|
+
|
|
220
|
+
# Load conversation-specific max_tokens if set (otherwise use global default)
|
|
221
|
+
conversation_max_tokens = conversation.get('max_tokens')
|
|
222
|
+
if conversation_max_tokens is not None:
|
|
223
|
+
self.max_tokens = conversation_max_tokens
|
|
224
|
+
logging.info(f"Using conversation-specific max_tokens: {conversation_max_tokens}")
|
|
225
|
+
else:
|
|
226
|
+
# Reset to global default (in case previous conversation had custom value)
|
|
227
|
+
self.max_tokens = self.default_max_tokens
|
|
228
|
+
logging.info(f"Using global default max_tokens: {self.default_max_tokens}")
|
|
229
|
+
|
|
230
|
+
# Load conversation-specific compaction_threshold if set (otherwise use global default)
|
|
231
|
+
conversation_compaction_threshold = conversation.get('compaction_threshold')
|
|
232
|
+
if conversation_compaction_threshold is not None:
|
|
233
|
+
self.context_compactor.compaction_threshold = conversation_compaction_threshold
|
|
234
|
+
logging.info(f"Using conversation-specific compaction threshold: {conversation_compaction_threshold:.0%}")
|
|
235
|
+
else:
|
|
236
|
+
# Reset to global default (in case previous conversation had custom value)
|
|
237
|
+
self.context_compactor.compaction_threshold = self.rollup_threshold
|
|
238
|
+
logging.info(f"Using global default compaction threshold: {self.rollup_threshold:.0%}")
|
|
239
|
+
|
|
240
|
+
logging.info(f"Loaded conversation: {conversation['name']} (ID: {conversation_id})")
|
|
241
|
+
return True
|
|
242
|
+
else:
|
|
243
|
+
logging.error(f"Conversation {conversation_id} not found")
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
def add_user_message(self, content: str) -> int:
|
|
247
|
+
"""
|
|
248
|
+
Add a user message to the current conversation.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
content: Message content
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Message ID
|
|
255
|
+
"""
|
|
256
|
+
if not self.current_conversation_id:
|
|
257
|
+
raise ValueError("No active conversation. Create or load a conversation first.")
|
|
258
|
+
|
|
259
|
+
token_count = self.bedrock_service.count_tokens(content)
|
|
260
|
+
message_id = self.database.add_message(
|
|
261
|
+
self.current_conversation_id,
|
|
262
|
+
'user',
|
|
263
|
+
content,
|
|
264
|
+
token_count
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
logging.debug(f"Added user message ({token_count} tokens)")
|
|
268
|
+
|
|
269
|
+
# Check if rollup is needed after adding the message
|
|
270
|
+
self._check_and_perform_rollup()
|
|
271
|
+
|
|
272
|
+
return message_id
|
|
273
|
+
|
|
274
|
+
def add_assistant_message(self, content: str) -> int:
|
|
275
|
+
"""
|
|
276
|
+
Add an assistant message to the current conversation.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
content: Message content
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Message ID
|
|
283
|
+
"""
|
|
284
|
+
if not self.current_conversation_id:
|
|
285
|
+
raise ValueError("No active conversation. Create or load a conversation first.")
|
|
286
|
+
|
|
287
|
+
token_count = self.bedrock_service.count_tokens(content)
|
|
288
|
+
message_id = self.database.add_message(
|
|
289
|
+
self.current_conversation_id,
|
|
290
|
+
'assistant',
|
|
291
|
+
content,
|
|
292
|
+
token_count
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
logging.debug(f"Added assistant message ({token_count} tokens)")
|
|
296
|
+
|
|
297
|
+
# Check if rollup is needed after adding the message
|
|
298
|
+
self._check_and_perform_rollup()
|
|
299
|
+
|
|
300
|
+
return message_id
|
|
301
|
+
|
|
302
|
+
def get_messages_for_model(self) -> List[Dict[str, Any]]:
|
|
303
|
+
"""
|
|
304
|
+
Get messages formatted for model input (excluding rolled-up messages).
|
|
305
|
+
Properly formats tool use and tool result messages for Claude API.
|
|
306
|
+
Validates that tool_use blocks have corresponding tool_result blocks.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
List of message dictionaries with 'role' and 'content'
|
|
310
|
+
"""
|
|
311
|
+
if not self.current_conversation_id:
|
|
312
|
+
return []
|
|
313
|
+
|
|
314
|
+
messages = self.database.get_conversation_messages(
|
|
315
|
+
self.current_conversation_id,
|
|
316
|
+
include_rolled_up=False
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Format for model
|
|
320
|
+
formatted_messages = []
|
|
321
|
+
for msg in messages:
|
|
322
|
+
content = msg['content']
|
|
323
|
+
|
|
324
|
+
# Check if this is a tool-related message (stored as JSON)
|
|
325
|
+
if msg['role'] == 'assistant' and content.startswith('['):
|
|
326
|
+
# This is likely a tool_use message stored as JSON
|
|
327
|
+
try:
|
|
328
|
+
content_blocks = json.loads(content)
|
|
329
|
+
formatted_messages.append({
|
|
330
|
+
'role': 'assistant',
|
|
331
|
+
'content': content_blocks
|
|
332
|
+
})
|
|
333
|
+
continue
|
|
334
|
+
except json.JSONDecodeError:
|
|
335
|
+
pass # Not JSON, treat as regular message
|
|
336
|
+
|
|
337
|
+
if msg['role'] == 'user' and content.startswith('[TOOL_RESULTS]'):
|
|
338
|
+
# This is a tool results message
|
|
339
|
+
try:
|
|
340
|
+
tool_results_json = content.replace('[TOOL_RESULTS]', '', 1)
|
|
341
|
+
tool_results = json.loads(tool_results_json)
|
|
342
|
+
formatted_messages.append({
|
|
343
|
+
'role': 'user',
|
|
344
|
+
'content': tool_results
|
|
345
|
+
})
|
|
346
|
+
continue
|
|
347
|
+
except json.JSONDecodeError:
|
|
348
|
+
pass # Not JSON, treat as regular message
|
|
349
|
+
|
|
350
|
+
# Regular message
|
|
351
|
+
formatted_messages.append({
|
|
352
|
+
'role': msg['role'],
|
|
353
|
+
'content': content
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
# Validate tool_use/tool_result pairing to prevent API errors
|
|
357
|
+
validated_messages = []
|
|
358
|
+
orphaned_tool_ids = set() # Track tool_use IDs that were filtered out
|
|
359
|
+
i = 0
|
|
360
|
+
|
|
361
|
+
while i < len(formatted_messages):
|
|
362
|
+
msg = formatted_messages[i]
|
|
363
|
+
|
|
364
|
+
# Check if this is an assistant message with tool_use blocks
|
|
365
|
+
if msg['role'] == 'assistant' and isinstance(msg['content'], list):
|
|
366
|
+
has_tool_use = any(block.get('type') == 'tool_use' for block in msg['content'] if isinstance(block, dict))
|
|
367
|
+
|
|
368
|
+
if has_tool_use:
|
|
369
|
+
# Verify the next message is a user message with tool_results
|
|
370
|
+
if i + 1 < len(formatted_messages):
|
|
371
|
+
next_msg = formatted_messages[i + 1]
|
|
372
|
+
if next_msg['role'] == 'user' and isinstance(next_msg['content'], list):
|
|
373
|
+
has_tool_result = any(block.get('type') == 'tool_result' for block in next_msg['content'] if isinstance(block, dict))
|
|
374
|
+
if has_tool_result:
|
|
375
|
+
# Valid pair - add both messages
|
|
376
|
+
validated_messages.append(msg)
|
|
377
|
+
validated_messages.append(next_msg)
|
|
378
|
+
i += 2
|
|
379
|
+
continue
|
|
380
|
+
|
|
381
|
+
# Orphaned tool_use - collect IDs and filter out tool_use blocks
|
|
382
|
+
tool_use_ids = [block.get('id') for block in msg['content']
|
|
383
|
+
if isinstance(block, dict) and block.get('type') == 'tool_use' and block.get('id')]
|
|
384
|
+
orphaned_tool_ids.update(tool_use_ids)
|
|
385
|
+
|
|
386
|
+
logging.warning(f"Found orphaned tool_use blocks at message {i} (IDs: {tool_use_ids}), filtering out")
|
|
387
|
+
filtered_content = [block for block in msg['content']
|
|
388
|
+
if not (isinstance(block, dict) and block.get('type') == 'tool_use')]
|
|
389
|
+
if filtered_content:
|
|
390
|
+
validated_messages.append({
|
|
391
|
+
'role': 'assistant',
|
|
392
|
+
'content': filtered_content
|
|
393
|
+
})
|
|
394
|
+
i += 1
|
|
395
|
+
continue
|
|
396
|
+
|
|
397
|
+
# Check if this is a user message with tool_results for orphaned tool_use blocks
|
|
398
|
+
if msg['role'] == 'user' and isinstance(msg['content'], list) and orphaned_tool_ids:
|
|
399
|
+
# Filter out tool_results that reference orphaned tool_use IDs
|
|
400
|
+
filtered_content = [block for block in msg['content']
|
|
401
|
+
if not (isinstance(block, dict) and
|
|
402
|
+
block.get('type') == 'tool_result' and
|
|
403
|
+
block.get('tool_use_id') in orphaned_tool_ids)]
|
|
404
|
+
|
|
405
|
+
# If we filtered out any tool_results, log it
|
|
406
|
+
if len(filtered_content) < len(msg['content']):
|
|
407
|
+
removed_ids = [block.get('tool_use_id') for block in msg['content']
|
|
408
|
+
if isinstance(block, dict) and
|
|
409
|
+
block.get('type') == 'tool_result' and
|
|
410
|
+
block.get('tool_use_id') in orphaned_tool_ids]
|
|
411
|
+
logging.warning(f"Filtered out orphaned tool_results at message {i} (tool_use_ids: {removed_ids})")
|
|
412
|
+
|
|
413
|
+
# Only add the message if there's content left
|
|
414
|
+
if filtered_content:
|
|
415
|
+
validated_messages.append({
|
|
416
|
+
'role': 'user',
|
|
417
|
+
'content': filtered_content
|
|
418
|
+
})
|
|
419
|
+
i += 1
|
|
420
|
+
continue
|
|
421
|
+
|
|
422
|
+
# Regular message or already validated - add it
|
|
423
|
+
validated_messages.append(msg)
|
|
424
|
+
i += 1
|
|
425
|
+
|
|
426
|
+
return validated_messages
|
|
427
|
+
|
|
428
|
+
def get_conversation_history(self, include_rolled_up: bool = False) -> List[Dict]:
|
|
429
|
+
"""
|
|
430
|
+
Get full conversation history including metadata.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
include_rolled_up: Whether to include messages that have been rolled up (default: False for chat, True for export)
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
List of message dictionaries with all fields
|
|
437
|
+
"""
|
|
438
|
+
if not self.current_conversation_id:
|
|
439
|
+
return []
|
|
440
|
+
|
|
441
|
+
return self.database.get_conversation_messages(
|
|
442
|
+
self.current_conversation_id,
|
|
443
|
+
include_rolled_up=include_rolled_up
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
def get_last_assistant_message(self) -> Optional[str]:
|
|
447
|
+
"""
|
|
448
|
+
Get the last assistant message content for copying to clipboard.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
The text content of the last assistant message, or None if no assistant message exists
|
|
452
|
+
"""
|
|
453
|
+
if not self.current_conversation_id:
|
|
454
|
+
return None
|
|
455
|
+
|
|
456
|
+
messages = self.get_conversation_history(include_rolled_up=False)
|
|
457
|
+
|
|
458
|
+
# Find the last assistant message
|
|
459
|
+
for message in reversed(messages):
|
|
460
|
+
if message['role'] == 'assistant':
|
|
461
|
+
content = message['content']
|
|
462
|
+
|
|
463
|
+
# Check if it's a JSON tool use message
|
|
464
|
+
if content.startswith('[') and content.strip().endswith(']'):
|
|
465
|
+
try:
|
|
466
|
+
blocks = json.loads(content)
|
|
467
|
+
if isinstance(blocks, list):
|
|
468
|
+
# Extract text blocks only (skip tool_use blocks)
|
|
469
|
+
text_parts = []
|
|
470
|
+
for block in blocks:
|
|
471
|
+
if isinstance(block, dict) and block.get('type') == 'text':
|
|
472
|
+
text_parts.append(block.get('text', ''))
|
|
473
|
+
if text_parts:
|
|
474
|
+
return '\n'.join(text_parts)
|
|
475
|
+
except (json.JSONDecodeError, ValueError):
|
|
476
|
+
# Not JSON, return as-is
|
|
477
|
+
pass
|
|
478
|
+
|
|
479
|
+
# Check if it's a rollup summary
|
|
480
|
+
if content.startswith('[Summary of previous conversation]'):
|
|
481
|
+
return content
|
|
482
|
+
|
|
483
|
+
# Regular assistant message
|
|
484
|
+
return content
|
|
485
|
+
|
|
486
|
+
return None
|
|
487
|
+
|
|
488
|
+
def _check_and_perform_rollup(self):
|
|
489
|
+
"""
|
|
490
|
+
Check if context compaction is needed and perform it if threshold is exceeded.
|
|
491
|
+
|
|
492
|
+
Uses intelligent context compaction with model-specific context window limits.
|
|
493
|
+
Defers compaction if currently in a tool use loop to avoid breaking
|
|
494
|
+
tool_use/tool_result sequences, unless we've reached the emergency threshold.
|
|
495
|
+
"""
|
|
496
|
+
if not self.current_conversation_id:
|
|
497
|
+
return
|
|
498
|
+
|
|
499
|
+
# Get current model ID and provider for context limit lookup
|
|
500
|
+
model_id = self._get_current_model_id()
|
|
501
|
+
provider = self._get_current_provider()
|
|
502
|
+
|
|
503
|
+
# Delegate to the intelligent context compactor
|
|
504
|
+
self.context_compactor.check_and_compact(
|
|
505
|
+
conversation_id=self.current_conversation_id,
|
|
506
|
+
model_id=model_id,
|
|
507
|
+
provider=provider,
|
|
508
|
+
in_tool_use_loop=self._in_tool_use_loop
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
def _get_current_model_id(self) -> str:
|
|
512
|
+
"""
|
|
513
|
+
Get the model ID for the current conversation.
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
Model ID string, or 'unknown' if not available
|
|
517
|
+
"""
|
|
518
|
+
if self.current_conversation_id:
|
|
519
|
+
conv = self.database.get_conversation(self.current_conversation_id)
|
|
520
|
+
if conv:
|
|
521
|
+
return conv.get('model_id', 'unknown')
|
|
522
|
+
return 'unknown'
|
|
523
|
+
|
|
524
|
+
def _get_current_provider(self) -> str:
|
|
525
|
+
"""
|
|
526
|
+
Get the provider for the current model.
|
|
527
|
+
|
|
528
|
+
Attempts to determine the provider from:
|
|
529
|
+
1. The bedrock_service type
|
|
530
|
+
2. The model ID pattern
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
Provider name string
|
|
534
|
+
"""
|
|
535
|
+
# Try to get provider from service type
|
|
536
|
+
if hasattr(self.bedrock_service, 'get_provider_name'):
|
|
537
|
+
provider = self.bedrock_service.get_provider_name()
|
|
538
|
+
if provider:
|
|
539
|
+
return provider.lower().replace(' ', '_')
|
|
540
|
+
|
|
541
|
+
# Fall back to inferring from model ID
|
|
542
|
+
model_id = self._get_current_model_id()
|
|
543
|
+
return get_provider_from_model_id(model_id)
|
|
544
|
+
|
|
545
|
+
def _perform_rollup(self):
|
|
546
|
+
"""
|
|
547
|
+
Perform conversation rollup by summarising older messages.
|
|
548
|
+
Ensures tool_use/tool_result pairs are never split.
|
|
549
|
+
"""
|
|
550
|
+
# Display rollup start notification
|
|
551
|
+
if self.cli_interface:
|
|
552
|
+
self.cli_interface.print_separator("─")
|
|
553
|
+
self.cli_interface.print_info("⚙️ Starting conversation rollup to manage token usage...")
|
|
554
|
+
self.cli_interface.print_separator("─")
|
|
555
|
+
|
|
556
|
+
messages = self.database.get_conversation_messages(
|
|
557
|
+
self.current_conversation_id,
|
|
558
|
+
include_rolled_up=False
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
if len(messages) <= 2:
|
|
562
|
+
logging.warning("Not enough messages to perform rollup")
|
|
563
|
+
if self.cli_interface:
|
|
564
|
+
self.cli_interface.print_warning("Not enough messages to perform rollup")
|
|
565
|
+
return
|
|
566
|
+
|
|
567
|
+
# Find a safe cutoff point that doesn't split tool_use/tool_result pairs
|
|
568
|
+
# Start with keeping at least the last 2 complete exchanges (4 messages)
|
|
569
|
+
messages_to_keep_count = 4
|
|
570
|
+
|
|
571
|
+
# Look backwards from the cutoff point to ensure we don't split tool pairs
|
|
572
|
+
# If the message at the cutoff is a tool_result, we need to keep its tool_use too
|
|
573
|
+
cutoff_index = len(messages) - messages_to_keep_count
|
|
574
|
+
|
|
575
|
+
while cutoff_index > 0:
|
|
576
|
+
cutoff_msg = messages[cutoff_index] if cutoff_index < len(messages) else None
|
|
577
|
+
|
|
578
|
+
if cutoff_msg and cutoff_msg['content'].startswith('[TOOL_RESULTS]'):
|
|
579
|
+
# This is a tool_result message, we need to keep the preceding tool_use
|
|
580
|
+
# Move cutoff back one more message
|
|
581
|
+
cutoff_index -= 1
|
|
582
|
+
messages_to_keep_count += 1
|
|
583
|
+
else:
|
|
584
|
+
# Safe to cut here
|
|
585
|
+
break
|
|
586
|
+
|
|
587
|
+
# Also check if the message right before cutoff is a tool_use without result
|
|
588
|
+
if cutoff_index > 0:
|
|
589
|
+
prev_msg = messages[cutoff_index - 1]
|
|
590
|
+
if prev_msg['role'] == 'assistant' and prev_msg['content'].startswith('['):
|
|
591
|
+
try:
|
|
592
|
+
# Check if it's a tool_use message
|
|
593
|
+
content_blocks = json.loads(prev_msg['content'])
|
|
594
|
+
if any(block.get('type') == 'tool_use' for block in content_blocks):
|
|
595
|
+
# Move cutoff back to include this tool_use and its result
|
|
596
|
+
cutoff_index -= 1
|
|
597
|
+
messages_to_keep_count += 1
|
|
598
|
+
except:
|
|
599
|
+
pass
|
|
600
|
+
|
|
601
|
+
messages_to_summarise = messages[:cutoff_index] if cutoff_index > 0 else []
|
|
602
|
+
|
|
603
|
+
if not messages_to_summarise or len(messages_to_summarise) == 0:
|
|
604
|
+
logging.warning("No messages available for rollup after ensuring tool pairs stay together")
|
|
605
|
+
if self.cli_interface:
|
|
606
|
+
self.cli_interface.print_warning("No messages available for rollup")
|
|
607
|
+
return
|
|
608
|
+
|
|
609
|
+
# Calculate original token count
|
|
610
|
+
original_token_count = sum(msg['token_count'] for msg in messages_to_summarise)
|
|
611
|
+
|
|
612
|
+
# Display rollup details
|
|
613
|
+
if self.cli_interface:
|
|
614
|
+
self.cli_interface.print_info(f"Summarising {len(messages_to_summarise)} messages ({original_token_count:,} tokens)...")
|
|
615
|
+
|
|
616
|
+
# Create a summary of the older messages
|
|
617
|
+
summary_content = self._create_summary(messages_to_summarise)
|
|
618
|
+
summary_token_count = self.bedrock_service.count_tokens(summary_content)
|
|
619
|
+
|
|
620
|
+
# Add the summary as a user message (Claude doesn't accept 'system' role in messages)
|
|
621
|
+
self.database.add_message(
|
|
622
|
+
self.current_conversation_id,
|
|
623
|
+
'user',
|
|
624
|
+
f"[Summary of previous conversation]\n{summary_content}",
|
|
625
|
+
summary_token_count
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
# Mark old messages as rolled up
|
|
629
|
+
message_ids = [msg['id'] for msg in messages_to_summarise]
|
|
630
|
+
self.database.mark_messages_as_rolled_up(message_ids)
|
|
631
|
+
|
|
632
|
+
# Record the rollup operation
|
|
633
|
+
self.database.record_rollup(
|
|
634
|
+
self.current_conversation_id,
|
|
635
|
+
len(messages_to_summarise),
|
|
636
|
+
summary_content,
|
|
637
|
+
original_token_count,
|
|
638
|
+
summary_token_count
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
token_reduction = original_token_count - summary_token_count
|
|
642
|
+
logging.info(f"Rollup completed: {len(messages_to_summarise)} messages summarised, "
|
|
643
|
+
f"reduced tokens by {token_reduction}")
|
|
644
|
+
|
|
645
|
+
# Display rollup completion
|
|
646
|
+
if self.cli_interface:
|
|
647
|
+
reduction_pct = (token_reduction / original_token_count * 100) if original_token_count > 0 else 0
|
|
648
|
+
self.cli_interface.print_success(f"✓ Rollup completed: {len(messages_to_summarise)} messages → 1 summary")
|
|
649
|
+
self.cli_interface.print_info(f"Token reduction: {original_token_count:,} → {summary_token_count:,} ({reduction_pct:.1f}% reduction)")
|
|
650
|
+
self.cli_interface.print_separator("─")
|
|
651
|
+
|
|
652
|
+
def _calculate_suggested_max_tokens(self) -> int:
|
|
653
|
+
"""
|
|
654
|
+
Calculate a suggested max_tokens value when current limit is hit.
|
|
655
|
+
Uses common model token limits as suggestions.
|
|
656
|
+
|
|
657
|
+
Returns:
|
|
658
|
+
Suggested max_tokens value
|
|
659
|
+
"""
|
|
660
|
+
current = self.max_tokens
|
|
661
|
+
|
|
662
|
+
# Common model token limits
|
|
663
|
+
common_limits = [4096, 8192, 16384, 32768, 65536, 131072, 200000]
|
|
664
|
+
|
|
665
|
+
# Find next higher limit
|
|
666
|
+
for limit in common_limits:
|
|
667
|
+
if limit > current:
|
|
668
|
+
return limit
|
|
669
|
+
|
|
670
|
+
# If already at highest, suggest 2x current
|
|
671
|
+
return current * 2
|
|
672
|
+
|
|
673
|
+
def update_conversation_max_tokens(self, new_max_tokens: int) -> bool:
|
|
674
|
+
"""
|
|
675
|
+
Update the max_tokens setting for the current conversation.
|
|
676
|
+
|
|
677
|
+
Args:
|
|
678
|
+
new_max_tokens: New max_tokens value
|
|
679
|
+
|
|
680
|
+
Returns:
|
|
681
|
+
True if successful, False otherwise
|
|
682
|
+
"""
|
|
683
|
+
if not self.current_conversation_id:
|
|
684
|
+
logging.error("Cannot update max_tokens: no active conversation")
|
|
685
|
+
return False
|
|
686
|
+
|
|
687
|
+
try:
|
|
688
|
+
# Update in database
|
|
689
|
+
self.database.update_conversation_max_tokens(
|
|
690
|
+
self.current_conversation_id,
|
|
691
|
+
new_max_tokens
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
# Update in memory
|
|
695
|
+
self.max_tokens = new_max_tokens
|
|
696
|
+
|
|
697
|
+
logging.info(f"Updated max_tokens for conversation {self.current_conversation_id} to {new_max_tokens}")
|
|
698
|
+
return True
|
|
699
|
+
except Exception as e:
|
|
700
|
+
logging.error(f"Failed to update max_tokens: {e}")
|
|
701
|
+
return False
|
|
702
|
+
|
|
703
|
+
def _detect_synthesis_response(self, assistant_message: str, tool_call_history: List[str]) -> bool:
|
|
704
|
+
"""
|
|
705
|
+
Detect if the assistant's response appears to be creating a synthesis/summary document
|
|
706
|
+
that aggregates data from multiple sources.
|
|
707
|
+
|
|
708
|
+
Args:
|
|
709
|
+
assistant_message: The assistant's message content
|
|
710
|
+
tool_call_history: List of tool names called in this conversation turn
|
|
711
|
+
|
|
712
|
+
Returns:
|
|
713
|
+
True if synthesis/aggregation is detected
|
|
714
|
+
"""
|
|
715
|
+
if not assistant_message:
|
|
716
|
+
return False
|
|
717
|
+
|
|
718
|
+
message_lower = assistant_message.lower()
|
|
719
|
+
|
|
720
|
+
# Patterns that indicate synthesis/summary documents
|
|
721
|
+
synthesis_patterns = [
|
|
722
|
+
'executive summary',
|
|
723
|
+
'cost summary',
|
|
724
|
+
'overall summary',
|
|
725
|
+
'combined total',
|
|
726
|
+
'total savings',
|
|
727
|
+
'in total',
|
|
728
|
+
'altogether',
|
|
729
|
+
'aggregated',
|
|
730
|
+
'consolidated',
|
|
731
|
+
'across all accounts',
|
|
732
|
+
'across all',
|
|
733
|
+
'total potential',
|
|
734
|
+
'combined savings',
|
|
735
|
+
'grand total',
|
|
736
|
+
'overall cost'
|
|
737
|
+
]
|
|
738
|
+
|
|
739
|
+
# Check for synthesis patterns in message
|
|
740
|
+
for pattern in synthesis_patterns:
|
|
741
|
+
if pattern in message_lower:
|
|
742
|
+
logging.debug(f"Synthesis pattern detected: '{pattern}'")
|
|
743
|
+
return True
|
|
744
|
+
|
|
745
|
+
# Check if creating summary documents via tools
|
|
746
|
+
summary_tool_patterns = ['summary', 'executive', 'total', 'overview']
|
|
747
|
+
for tool_name in tool_call_history[-5:]: # Check last 5 tools
|
|
748
|
+
tool_lower = tool_name.lower()
|
|
749
|
+
for pattern in summary_tool_patterns:
|
|
750
|
+
if pattern in tool_lower or ('append' in tool_lower and pattern in message_lower):
|
|
751
|
+
logging.debug(f"Summary document creation detected via tool: {tool_name}")
|
|
752
|
+
return True
|
|
753
|
+
|
|
754
|
+
# Check for numerical aggregation patterns with currency
|
|
755
|
+
aggregation_with_numbers = [
|
|
756
|
+
r'total.*\$[\d,]+',
|
|
757
|
+
r'combined.*\$[\d,]+',
|
|
758
|
+
r'overall.*\$[\d,]+',
|
|
759
|
+
r'savings.*\$[\d,]+.*-.*\$[\d,]+' # Range of savings
|
|
760
|
+
]
|
|
761
|
+
|
|
762
|
+
import re
|
|
763
|
+
for pattern in aggregation_with_numbers:
|
|
764
|
+
if re.search(pattern, message_lower):
|
|
765
|
+
logging.debug(f"Numerical aggregation pattern detected: '{pattern}'")
|
|
766
|
+
return True
|
|
767
|
+
|
|
768
|
+
return False
|
|
769
|
+
|
|
770
|
+
def _extract_numerical_data(self, content: str) -> Optional[str]:
|
|
771
|
+
"""
|
|
772
|
+
Extract numerical data and key findings from tool results.
|
|
773
|
+
|
|
774
|
+
Args:
|
|
775
|
+
content: Tool result content string
|
|
776
|
+
|
|
777
|
+
Returns:
|
|
778
|
+
Extracted numerical data summary or None
|
|
779
|
+
"""
|
|
780
|
+
try:
|
|
781
|
+
import json
|
|
782
|
+
import re
|
|
783
|
+
|
|
784
|
+
# Remove [TOOL_RESULTS] prefix if present
|
|
785
|
+
if content.startswith('[TOOL_RESULTS]'):
|
|
786
|
+
content = content[len('[TOOL_RESULTS]'):].strip()
|
|
787
|
+
|
|
788
|
+
# Try to parse as JSON
|
|
789
|
+
try:
|
|
790
|
+
data = json.loads(content)
|
|
791
|
+
except:
|
|
792
|
+
# Not JSON, try to extract numbers from text
|
|
793
|
+
data = None
|
|
794
|
+
|
|
795
|
+
findings = []
|
|
796
|
+
|
|
797
|
+
# Extract from JSON structure
|
|
798
|
+
if data:
|
|
799
|
+
# Look for common patterns in tool results
|
|
800
|
+
if isinstance(data, dict):
|
|
801
|
+
# Look for summary, total, savings, cost patterns
|
|
802
|
+
for key in ['summary', 'total', 'savings', 'cost', 'amount', 'count', 'potential_savings']:
|
|
803
|
+
if key in data:
|
|
804
|
+
value = data[key]
|
|
805
|
+
if isinstance(value, (int, float)):
|
|
806
|
+
findings.append(f"{key}: {value}")
|
|
807
|
+
elif isinstance(value, dict):
|
|
808
|
+
# Nested summary data
|
|
809
|
+
for subkey, subval in value.items():
|
|
810
|
+
if isinstance(subval, (int, float)):
|
|
811
|
+
findings.append(f"{key}.{subkey}: {subval}")
|
|
812
|
+
|
|
813
|
+
elif isinstance(data, list) and len(data) > 0:
|
|
814
|
+
# List of items - report count
|
|
815
|
+
findings.append(f"Items count: {len(data)}")
|
|
816
|
+
|
|
817
|
+
# Extract currency amounts from text (e.g., $1,234.56, USD $1,234)
|
|
818
|
+
currency_pattern = r'(?:USD\s*)?\$\s*[\d,]+(?:\.\d{2})?(?:\s*(?:million|thousand|billion|[KMB]))?'
|
|
819
|
+
currencies = re.findall(currency_pattern, content, re.IGNORECASE)
|
|
820
|
+
if currencies:
|
|
821
|
+
# Limit to first 5 to avoid overwhelming the summary
|
|
822
|
+
findings.extend([f"Currency value: {c.strip()}" for c in currencies[:5]])
|
|
823
|
+
|
|
824
|
+
# Extract percentages
|
|
825
|
+
percentage_pattern = r'\d+(?:\.\d+)?%'
|
|
826
|
+
percentages = re.findall(percentage_pattern, content)
|
|
827
|
+
if percentages:
|
|
828
|
+
findings.extend([f"Percentage: {p}" for p in percentages[:5]])
|
|
829
|
+
|
|
830
|
+
# Extract large numbers (likely significant)
|
|
831
|
+
number_pattern = r'\b\d{1,3}(?:,\d{3})+(?:\.\d+)?\b'
|
|
832
|
+
numbers = re.findall(number_pattern, content)
|
|
833
|
+
if numbers:
|
|
834
|
+
findings.extend([f"Value: {n}" for n in numbers[:5]])
|
|
835
|
+
|
|
836
|
+
if findings:
|
|
837
|
+
return "Key data: " + "; ".join(findings[:10]) # Limit to 10 findings
|
|
838
|
+
|
|
839
|
+
return None
|
|
840
|
+
|
|
841
|
+
except Exception as e:
|
|
842
|
+
logging.debug(f"Error extracting numerical data: {e}")
|
|
843
|
+
return None
|
|
844
|
+
|
|
845
|
+
def _create_summary(self, messages: List[Dict]) -> str:
|
|
846
|
+
"""
|
|
847
|
+
Create a summary of messages using the Bedrock model.
|
|
848
|
+
|
|
849
|
+
Args:
|
|
850
|
+
messages: List of message dictionaries to summarise
|
|
851
|
+
|
|
852
|
+
Returns:
|
|
853
|
+
Summary text
|
|
854
|
+
"""
|
|
855
|
+
# Build a prompt for summarisation - clean up tool use content
|
|
856
|
+
conversation_text = []
|
|
857
|
+
numerical_data_found = []
|
|
858
|
+
|
|
859
|
+
for msg in messages:
|
|
860
|
+
role = msg['role'].capitalize()
|
|
861
|
+
content = msg['content']
|
|
862
|
+
|
|
863
|
+
# Parse and clean tool-related content for better summarization
|
|
864
|
+
if content.startswith('[TOOL_RESULTS]'):
|
|
865
|
+
# Extract numerical data from tool results
|
|
866
|
+
numerical_summary = self._extract_numerical_data(content)
|
|
867
|
+
if numerical_summary:
|
|
868
|
+
conversation_text.append(f"{role}: [Tool execution results - {numerical_summary}]")
|
|
869
|
+
numerical_data_found.append(numerical_summary)
|
|
870
|
+
else:
|
|
871
|
+
conversation_text.append(f"{role}: [Received tool execution results]")
|
|
872
|
+
elif content.startswith('['):
|
|
873
|
+
# Try to parse tool_use blocks
|
|
874
|
+
try:
|
|
875
|
+
import json
|
|
876
|
+
content_blocks = json.loads(content)
|
|
877
|
+
text_parts = []
|
|
878
|
+
tool_parts = []
|
|
879
|
+
for block in content_blocks:
|
|
880
|
+
if block.get('type') == 'text':
|
|
881
|
+
text_parts.append(block.get('text', ''))
|
|
882
|
+
elif block.get('type') == 'tool_use':
|
|
883
|
+
tool_parts.append(f"used tool {block.get('name')}")
|
|
884
|
+
|
|
885
|
+
# Combine text and tool use descriptions
|
|
886
|
+
if text_parts:
|
|
887
|
+
conversation_text.append(f"{role}: {' '.join(text_parts)}")
|
|
888
|
+
if tool_parts:
|
|
889
|
+
conversation_text.append(f"{role}: [Called tools: {', '.join(tool_parts)}]")
|
|
890
|
+
except:
|
|
891
|
+
# If parsing fails, include content as-is
|
|
892
|
+
conversation_text.append(f"{role}: {content}")
|
|
893
|
+
else:
|
|
894
|
+
conversation_text.append(f"{role}: {content}")
|
|
895
|
+
|
|
896
|
+
full_conversation = '\n\n'.join(conversation_text)
|
|
897
|
+
|
|
898
|
+
# Calculate sensible token targets
|
|
899
|
+
original_tokens = sum(msg['token_count'] for msg in messages)
|
|
900
|
+
target_tokens = int(original_tokens * self.rollup_summary_ratio)
|
|
901
|
+
# Ensure minimum of 500 tokens and maximum of 3000 for summary
|
|
902
|
+
max_summary_tokens = max(500, min(target_tokens, 3000))
|
|
903
|
+
|
|
904
|
+
# Build the summary prompt with emphasis on numerical data if present
|
|
905
|
+
numerical_data_section = ""
|
|
906
|
+
if numerical_data_found:
|
|
907
|
+
numerical_data_section = f"""
|
|
908
|
+
CRITICAL - NUMERICAL DATA DETECTED:
|
|
909
|
+
The conversation contains important numerical data from tool results. You MUST preserve these exact values in your summary:
|
|
910
|
+
{chr(10).join(['- ' + data for data in numerical_data_found[:20]])}
|
|
911
|
+
|
|
912
|
+
When summarising, explicitly include these numerical values to maintain accuracy.
|
|
913
|
+
"""
|
|
914
|
+
|
|
915
|
+
summary_prompt = [
|
|
916
|
+
{
|
|
917
|
+
'role': 'user',
|
|
918
|
+
'content': f"""Please provide a comprehensive summary of the following conversation.
|
|
919
|
+
|
|
920
|
+
IMPORTANT: Focus on preserving:
|
|
921
|
+
- Key decisions and conclusions
|
|
922
|
+
- Important data, numbers, and findings (especially calculations, totals, costs, savings)
|
|
923
|
+
- Action items and tasks completed
|
|
924
|
+
- Critical context needed to continue the conversation
|
|
925
|
+
- Any errors or corrections that were identified
|
|
926
|
+
{numerical_data_section}
|
|
927
|
+
The original conversation contained {len(messages)} messages with {original_tokens} tokens.
|
|
928
|
+
Your summary should capture the essential information in approximately {target_tokens} tokens.
|
|
929
|
+
|
|
930
|
+
Conversation to summarise:
|
|
931
|
+
{full_conversation}
|
|
932
|
+
|
|
933
|
+
Summary:"""
|
|
934
|
+
}
|
|
935
|
+
]
|
|
936
|
+
|
|
937
|
+
# Use the current model to generate the summary
|
|
938
|
+
response = self.bedrock_service.invoke_model(
|
|
939
|
+
summary_prompt,
|
|
940
|
+
max_tokens=max_summary_tokens,
|
|
941
|
+
temperature=0.3 # Lower temperature for more focused summary
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
if response and response.get('content'):
|
|
945
|
+
summary_text = response['content'].strip()
|
|
946
|
+
# Verify summary is not trivial
|
|
947
|
+
if len(summary_text) < 50:
|
|
948
|
+
logging.warning(f"Summary too brief ({len(summary_text)} chars), using detailed fallback")
|
|
949
|
+
return f"Previous conversation covered {len(messages)} messages discussing:\n" + '\n'.join(conversation_text[:5])
|
|
950
|
+
return summary_text
|
|
951
|
+
else:
|
|
952
|
+
# Fallback to simple concatenation if summarisation fails
|
|
953
|
+
logging.warning("Model summarisation failed, using detailed fallback")
|
|
954
|
+
return f"Previous conversation covered {len(messages)} messages:\n" + '\n'.join(conversation_text[:10])
|
|
955
|
+
|
|
956
|
+
def get_active_conversations(self) -> List[Dict]:
|
|
957
|
+
"""
|
|
958
|
+
Get list of all active conversations.
|
|
959
|
+
|
|
960
|
+
Returns:
|
|
961
|
+
List of conversation dictionaries
|
|
962
|
+
"""
|
|
963
|
+
return self.database.get_active_conversations()
|
|
964
|
+
|
|
965
|
+
def get_current_token_count(self) -> int:
|
|
966
|
+
"""
|
|
967
|
+
Get the current token count for the active conversation.
|
|
968
|
+
|
|
969
|
+
Returns:
|
|
970
|
+
Total token count
|
|
971
|
+
"""
|
|
972
|
+
if not self.current_conversation_id:
|
|
973
|
+
return 0
|
|
974
|
+
|
|
975
|
+
return self.database.get_conversation_token_count(self.current_conversation_id)
|
|
976
|
+
|
|
977
|
+
def get_current_conversation_info(self) -> Optional[Dict]:
|
|
978
|
+
"""
|
|
979
|
+
Get information about the current conversation.
|
|
980
|
+
|
|
981
|
+
Returns:
|
|
982
|
+
Conversation dictionary or None
|
|
983
|
+
"""
|
|
984
|
+
if not self.current_conversation_id:
|
|
985
|
+
return None
|
|
986
|
+
|
|
987
|
+
return self.database.get_conversation(self.current_conversation_id)
|
|
988
|
+
|
|
989
|
+
def get_context_window(self) -> int:
|
|
990
|
+
"""
|
|
991
|
+
Get the context window size for the current conversation's model.
|
|
992
|
+
|
|
993
|
+
Uses the ContextLimitResolver to determine the actual context window
|
|
994
|
+
based on the model ID and provider.
|
|
995
|
+
|
|
996
|
+
Returns:
|
|
997
|
+
Context window size in tokens, or default of 8192 if unavailable
|
|
998
|
+
"""
|
|
999
|
+
conv_info = self.get_current_conversation_info()
|
|
1000
|
+
if not conv_info:
|
|
1001
|
+
return 8192 # Safe default
|
|
1002
|
+
|
|
1003
|
+
model_id = conv_info.get('model_id', '')
|
|
1004
|
+
if not model_id:
|
|
1005
|
+
return 8192
|
|
1006
|
+
|
|
1007
|
+
# Determine provider from model ID
|
|
1008
|
+
provider = get_provider_from_model_id(model_id)
|
|
1009
|
+
|
|
1010
|
+
# Get context window from resolver
|
|
1011
|
+
return self.context_limit_resolver.get_context_window(model_id, provider)
|
|
1012
|
+
|
|
1013
|
+
def change_model(self, new_model_id: str) -> bool:
|
|
1014
|
+
"""
|
|
1015
|
+
Change the model for the current conversation.
|
|
1016
|
+
|
|
1017
|
+
Args:
|
|
1018
|
+
new_model_id: ID of the new model to use
|
|
1019
|
+
|
|
1020
|
+
Returns:
|
|
1021
|
+
True if successful, False otherwise
|
|
1022
|
+
"""
|
|
1023
|
+
if not self.current_conversation_id:
|
|
1024
|
+
logging.warning("No conversation loaded to change model")
|
|
1025
|
+
return False
|
|
1026
|
+
|
|
1027
|
+
try:
|
|
1028
|
+
# Update conversation model in database
|
|
1029
|
+
cursor = self.database.conn.cursor()
|
|
1030
|
+
cursor.execute('''
|
|
1031
|
+
UPDATE conversations
|
|
1032
|
+
SET model_id = ?
|
|
1033
|
+
WHERE id = ?
|
|
1034
|
+
''', (new_model_id, self.current_conversation_id))
|
|
1035
|
+
self.database.conn.commit()
|
|
1036
|
+
|
|
1037
|
+
# Update bedrock service to use new model
|
|
1038
|
+
self.bedrock_service.set_model(new_model_id)
|
|
1039
|
+
|
|
1040
|
+
logging.info(f"Changed model to {new_model_id} for conversation {self.current_conversation_id}")
|
|
1041
|
+
return True
|
|
1042
|
+
|
|
1043
|
+
except Exception as e:
|
|
1044
|
+
logging.error(f"Failed to change model: {e}")
|
|
1045
|
+
self.database.conn.rollback()
|
|
1046
|
+
return False
|
|
1047
|
+
|
|
1048
|
+
def update_instructions(self, instructions: Optional[str]) -> bool:
|
|
1049
|
+
"""
|
|
1050
|
+
Update the instructions/system prompt for the current conversation.
|
|
1051
|
+
|
|
1052
|
+
Args:
|
|
1053
|
+
instructions: New instructions (None to clear)
|
|
1054
|
+
|
|
1055
|
+
Returns:
|
|
1056
|
+
True if successful, False otherwise
|
|
1057
|
+
"""
|
|
1058
|
+
if not self.current_conversation_id:
|
|
1059
|
+
logging.warning("No conversation loaded to update instructions")
|
|
1060
|
+
return False
|
|
1061
|
+
|
|
1062
|
+
try:
|
|
1063
|
+
# Update instructions in database
|
|
1064
|
+
self.database.update_conversation_instructions(self.current_conversation_id, instructions)
|
|
1065
|
+
|
|
1066
|
+
logging.info(f"Updated instructions for conversation {self.current_conversation_id}")
|
|
1067
|
+
return True
|
|
1068
|
+
|
|
1069
|
+
except Exception as e:
|
|
1070
|
+
logging.error(f"Failed to update instructions: {e}")
|
|
1071
|
+
return False
|
|
1072
|
+
|
|
1073
|
+
def get_model_usage_breakdown(self) -> List[Dict]:
|
|
1074
|
+
"""
|
|
1075
|
+
Get per-model token usage breakdown for the current conversation.
|
|
1076
|
+
|
|
1077
|
+
Returns:
|
|
1078
|
+
List of dictionaries with model usage details
|
|
1079
|
+
"""
|
|
1080
|
+
if not self.current_conversation_id:
|
|
1081
|
+
return []
|
|
1082
|
+
|
|
1083
|
+
return self.database.get_model_usage_breakdown(self.current_conversation_id)
|
|
1084
|
+
|
|
1085
|
+
def _get_embedded_system_instructions(self) -> str:
|
|
1086
|
+
"""
|
|
1087
|
+
Generate embedded system instructions that take priority over all other instructions.
|
|
1088
|
+
These include Spark's identity and current date/time with timezone.
|
|
1089
|
+
|
|
1090
|
+
Returns:
|
|
1091
|
+
Embedded system instructions string
|
|
1092
|
+
"""
|
|
1093
|
+
# Get current datetime with timezone
|
|
1094
|
+
now = datetime.now().astimezone()
|
|
1095
|
+
|
|
1096
|
+
# Format: "Monday, 17 November 2025 at 02:30:45 PM AEDT (UTC+1100)"
|
|
1097
|
+
datetime_str = now.strftime("%A, %d %B %Y at %I:%M:%S %p %Z (UTC%z)")
|
|
1098
|
+
|
|
1099
|
+
# Build embedded instructions
|
|
1100
|
+
embedded_instructions = f"""Your name is Spark which is short for "Secure Personal AI Research Kit".
|
|
1101
|
+
|
|
1102
|
+
Current date and time: {datetime_str}"""
|
|
1103
|
+
|
|
1104
|
+
return embedded_instructions
|
|
1105
|
+
|
|
1106
|
+
def _get_combined_instructions(self) -> Optional[str]:
|
|
1107
|
+
"""
|
|
1108
|
+
Combine embedded, global and conversation-specific instructions.
|
|
1109
|
+
Priority order (highest to lowest):
|
|
1110
|
+
1. Embedded system instructions (identity, date/time)
|
|
1111
|
+
2. Global instructions (prepended to prevent override)
|
|
1112
|
+
3. Conversation-specific instructions
|
|
1113
|
+
|
|
1114
|
+
Returns:
|
|
1115
|
+
Combined instructions string, or None if no instructions exist
|
|
1116
|
+
"""
|
|
1117
|
+
instructions_parts = []
|
|
1118
|
+
|
|
1119
|
+
# Add embedded system instructions first (highest priority - always present)
|
|
1120
|
+
instructions_parts.append(self._get_embedded_system_instructions())
|
|
1121
|
+
|
|
1122
|
+
# Add global instructions second (if they exist)
|
|
1123
|
+
if self.global_instructions:
|
|
1124
|
+
instructions_parts.append(self.global_instructions)
|
|
1125
|
+
|
|
1126
|
+
# Add conversation-specific instructions last (if they exist)
|
|
1127
|
+
if self.current_instructions:
|
|
1128
|
+
instructions_parts.append(self.current_instructions)
|
|
1129
|
+
|
|
1130
|
+
# Return combined instructions (always at least embedded instructions)
|
|
1131
|
+
return '\n\n'.join(instructions_parts)
|
|
1132
|
+
|
|
1133
|
+
def get_all_mcp_server_names(self) -> List[str]:
|
|
1134
|
+
"""Get names of all available MCP servers."""
|
|
1135
|
+
if not self.mcp_manager:
|
|
1136
|
+
return []
|
|
1137
|
+
# MCPManager uses 'clients' attribute, not 'servers'
|
|
1138
|
+
if not hasattr(self.mcp_manager, 'clients'):
|
|
1139
|
+
logging.warning("MCPManager does not have 'clients' attribute")
|
|
1140
|
+
return []
|
|
1141
|
+
return list(self.mcp_manager.clients.keys())
|
|
1142
|
+
|
|
1143
|
+
def get_mcp_server_states(self) -> List[Dict]:
|
|
1144
|
+
"""
|
|
1145
|
+
Get enabled/disabled state for all MCP servers in current conversation.
|
|
1146
|
+
|
|
1147
|
+
Returns:
|
|
1148
|
+
List of dicts with 'server_name' and 'enabled' keys
|
|
1149
|
+
"""
|
|
1150
|
+
if not self.current_conversation_id or not self.mcp_manager:
|
|
1151
|
+
return []
|
|
1152
|
+
|
|
1153
|
+
all_servers = self.get_all_mcp_server_names()
|
|
1154
|
+
return self.database.get_all_mcp_server_states(self.current_conversation_id, all_servers)
|
|
1155
|
+
|
|
1156
|
+
def set_mcp_server_enabled(self, server_name: str, enabled: bool) -> bool:
|
|
1157
|
+
"""
|
|
1158
|
+
Enable or disable an MCP server for the current conversation.
|
|
1159
|
+
Invalidates tool cache to force reload with new server states.
|
|
1160
|
+
|
|
1161
|
+
Args:
|
|
1162
|
+
server_name: Name of the MCP server
|
|
1163
|
+
enabled: True to enable, False to disable
|
|
1164
|
+
|
|
1165
|
+
Returns:
|
|
1166
|
+
True if successful, False otherwise
|
|
1167
|
+
"""
|
|
1168
|
+
if not self.current_conversation_id:
|
|
1169
|
+
logging.error("No active conversation")
|
|
1170
|
+
return False
|
|
1171
|
+
|
|
1172
|
+
if not self.mcp_manager:
|
|
1173
|
+
logging.error("MCP manager not available")
|
|
1174
|
+
return False
|
|
1175
|
+
|
|
1176
|
+
# Check if server exists (MCPManager uses 'clients' attribute)
|
|
1177
|
+
if not hasattr(self.mcp_manager, 'clients') or server_name not in self.mcp_manager.clients:
|
|
1178
|
+
logging.error(f"MCP server '{server_name}' not found")
|
|
1179
|
+
return False
|
|
1180
|
+
|
|
1181
|
+
# Update database
|
|
1182
|
+
if self.database.set_mcp_server_enabled(self.current_conversation_id, server_name, enabled):
|
|
1183
|
+
# Invalidate tools cache to force reload with new enabled servers
|
|
1184
|
+
self._tools_cache = None
|
|
1185
|
+
logging.info(f"Invalidated tools cache after {'enabling' if enabled else 'disabling'} server '{server_name}'")
|
|
1186
|
+
return True
|
|
1187
|
+
return False
|
|
1188
|
+
|
|
1189
|
+
def _get_mcp_tools(self) -> List[Dict[str, Any]]:
|
|
1190
|
+
"""
|
|
1191
|
+
Get available tools from MCP servers and built-in tools in Claude-compatible format.
|
|
1192
|
+
|
|
1193
|
+
Returns:
|
|
1194
|
+
List of tool definitions
|
|
1195
|
+
"""
|
|
1196
|
+
# Cache tools to avoid repeated async calls
|
|
1197
|
+
if self._tools_cache is not None:
|
|
1198
|
+
logging.debug(f"Returning cached tools: {len(self._tools_cache)} tools")
|
|
1199
|
+
return self._tools_cache
|
|
1200
|
+
|
|
1201
|
+
# Start with built-in tools (always available)
|
|
1202
|
+
all_tools = []
|
|
1203
|
+
try:
|
|
1204
|
+
builtin_tool_list = builtin.get_builtin_tools(config=self.config)
|
|
1205
|
+
for tool in builtin_tool_list:
|
|
1206
|
+
# Mark as built-in tool
|
|
1207
|
+
tool['server'] = 'builtin'
|
|
1208
|
+
tool['original_name'] = tool['name'] # Built-in tools don't need renaming
|
|
1209
|
+
all_tools.append(tool)
|
|
1210
|
+
logging.info(f"Loaded {len(builtin_tool_list)} built-in tool(s)")
|
|
1211
|
+
except Exception as e:
|
|
1212
|
+
logging.error(f"Failed to load built-in tools: {e}")
|
|
1213
|
+
|
|
1214
|
+
# Return early if no MCP manager (just built-in tools)
|
|
1215
|
+
if not self.mcp_manager:
|
|
1216
|
+
self._tools_cache = all_tools
|
|
1217
|
+
return all_tools
|
|
1218
|
+
|
|
1219
|
+
try:
|
|
1220
|
+
logging.debug("Fetching tools from MCP servers...")
|
|
1221
|
+
|
|
1222
|
+
# Check if we're already in a running event loop (e.g., from FastAPI)
|
|
1223
|
+
try:
|
|
1224
|
+
running_loop = asyncio.get_running_loop()
|
|
1225
|
+
# We're in an async context - use run_coroutine_threadsafe
|
|
1226
|
+
logging.debug("Detected running event loop, using thread-safe approach")
|
|
1227
|
+
|
|
1228
|
+
# Run the coroutine in the existing loop from a thread pool
|
|
1229
|
+
def run_in_loop():
|
|
1230
|
+
# Create a new event loop for this thread
|
|
1231
|
+
new_loop = asyncio.new_event_loop()
|
|
1232
|
+
asyncio.set_event_loop(new_loop)
|
|
1233
|
+
try:
|
|
1234
|
+
result = new_loop.run_until_complete(
|
|
1235
|
+
asyncio.wait_for(
|
|
1236
|
+
self.mcp_manager.list_all_tools(),
|
|
1237
|
+
timeout=10.0
|
|
1238
|
+
)
|
|
1239
|
+
)
|
|
1240
|
+
return result
|
|
1241
|
+
except asyncio.TimeoutError:
|
|
1242
|
+
logging.error("Timeout fetching MCP tools after 10 seconds")
|
|
1243
|
+
return []
|
|
1244
|
+
finally:
|
|
1245
|
+
new_loop.close()
|
|
1246
|
+
|
|
1247
|
+
# Run in thread pool to avoid event loop conflict
|
|
1248
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
|
1249
|
+
future = executor.submit(run_in_loop)
|
|
1250
|
+
mcp_tools = future.result(timeout=15.0) # Give extra time for thread overhead
|
|
1251
|
+
|
|
1252
|
+
except RuntimeError:
|
|
1253
|
+
# No running event loop - we're in sync context
|
|
1254
|
+
logging.debug("No running event loop detected, using standard approach")
|
|
1255
|
+
|
|
1256
|
+
# Use the initialization loop if available, otherwise create new one
|
|
1257
|
+
if hasattr(self.mcp_manager, '_initialization_loop') and self.mcp_manager._initialization_loop:
|
|
1258
|
+
loop = self.mcp_manager._initialization_loop
|
|
1259
|
+
# Add timeout to prevent indefinite hanging
|
|
1260
|
+
mcp_tools = loop.run_until_complete(
|
|
1261
|
+
asyncio.wait_for(
|
|
1262
|
+
self.mcp_manager.list_all_tools(),
|
|
1263
|
+
timeout=10.0 # 10 second timeout
|
|
1264
|
+
)
|
|
1265
|
+
)
|
|
1266
|
+
else:
|
|
1267
|
+
# Fallback: create temporary loop (shouldn't normally happen)
|
|
1268
|
+
loop = asyncio.new_event_loop()
|
|
1269
|
+
asyncio.set_event_loop(loop)
|
|
1270
|
+
try:
|
|
1271
|
+
mcp_tools = loop.run_until_complete(
|
|
1272
|
+
asyncio.wait_for(
|
|
1273
|
+
self.mcp_manager.list_all_tools(),
|
|
1274
|
+
timeout=10.0
|
|
1275
|
+
)
|
|
1276
|
+
)
|
|
1277
|
+
except asyncio.TimeoutError:
|
|
1278
|
+
logging.error("Timeout fetching MCP tools after 10 seconds")
|
|
1279
|
+
mcp_tools = []
|
|
1280
|
+
finally:
|
|
1281
|
+
loop.close()
|
|
1282
|
+
|
|
1283
|
+
logging.debug(f"Fetched {len(mcp_tools)} tools from MCP servers")
|
|
1284
|
+
|
|
1285
|
+
# Detect and handle duplicate tool names by prefixing with server name
|
|
1286
|
+
tool_name_counts = {}
|
|
1287
|
+
for tool in mcp_tools:
|
|
1288
|
+
tool_name = tool['name']
|
|
1289
|
+
tool_name_counts[tool_name] = tool_name_counts.get(tool_name, 0) + 1
|
|
1290
|
+
|
|
1291
|
+
# Identify which tools need prefixing (appear more than once)
|
|
1292
|
+
duplicates = {name for name, count in tool_name_counts.items() if count > 1}
|
|
1293
|
+
|
|
1294
|
+
if duplicates:
|
|
1295
|
+
logging.warning(f"Found {len(duplicates)} duplicate tool names across servers: {', '.join(sorted(duplicates))}")
|
|
1296
|
+
|
|
1297
|
+
# Convert to Claude format, prefixing duplicates with server name
|
|
1298
|
+
mcp_claude_tools = []
|
|
1299
|
+
for tool in mcp_tools:
|
|
1300
|
+
original_name = tool['name']
|
|
1301
|
+
server_name = tool.get('server', 'unknown')
|
|
1302
|
+
|
|
1303
|
+
# If tool name is duplicated, prefix with server name
|
|
1304
|
+
if original_name in duplicates:
|
|
1305
|
+
prefixed_name = f"{server_name}__{original_name}"
|
|
1306
|
+
logging.info(f"Renaming duplicate tool '{original_name}' from server '{server_name}' to '{prefixed_name}'")
|
|
1307
|
+
tool_name = prefixed_name
|
|
1308
|
+
else:
|
|
1309
|
+
tool_name = original_name
|
|
1310
|
+
|
|
1311
|
+
mcp_claude_tools.append({
|
|
1312
|
+
'name': tool_name,
|
|
1313
|
+
'description': tool['description'],
|
|
1314
|
+
'input_schema': tool['input_schema'],
|
|
1315
|
+
'server': server_name, # Keep server info for tool calling
|
|
1316
|
+
'original_name': original_name # Keep original name for MCP calls
|
|
1317
|
+
})
|
|
1318
|
+
|
|
1319
|
+
# Filter MCP tools based on enabled servers for this conversation
|
|
1320
|
+
if self.current_conversation_id:
|
|
1321
|
+
filtered_tools = []
|
|
1322
|
+
for tool in mcp_claude_tools:
|
|
1323
|
+
server_name = tool['server']
|
|
1324
|
+
if self.database.is_mcp_server_enabled(self.current_conversation_id, server_name):
|
|
1325
|
+
filtered_tools.append(tool)
|
|
1326
|
+
else:
|
|
1327
|
+
logging.debug(f"Excluding tool '{tool['name']}' from disabled server '{server_name}'")
|
|
1328
|
+
|
|
1329
|
+
disabled_count = len(mcp_claude_tools) - len(filtered_tools)
|
|
1330
|
+
if disabled_count > 0:
|
|
1331
|
+
logging.info(f"Filtered out {disabled_count} tools from disabled MCP servers")
|
|
1332
|
+
mcp_claude_tools = filtered_tools
|
|
1333
|
+
|
|
1334
|
+
# Merge MCP tools with built-in tools
|
|
1335
|
+
all_tools.extend(mcp_claude_tools)
|
|
1336
|
+
|
|
1337
|
+
self._tools_cache = all_tools
|
|
1338
|
+
logging.info(f"Loaded {len(all_tools)} total tools: {len(all_tools) - len(mcp_claude_tools)} built-in, {len(mcp_claude_tools)} from MCP servers ({len(duplicates)} renamed to resolve conflicts)")
|
|
1339
|
+
return all_tools
|
|
1340
|
+
|
|
1341
|
+
except Exception as e:
|
|
1342
|
+
logging.error(f"Failed to get MCP tools: {e}", exc_info=True)
|
|
1343
|
+
# Still return built-in tools even if MCP tools fail
|
|
1344
|
+
self._tools_cache = all_tools
|
|
1345
|
+
return all_tools
|
|
1346
|
+
|
|
1347
|
+
def _call_mcp_tool(self, tool_name: str, tool_input: Dict[str, Any],
|
|
1348
|
+
user_prompt: str = "") -> Tuple[str, int, bool]:
|
|
1349
|
+
"""
|
|
1350
|
+
Call an MCP tool or built-in tool and return the result with metrics.
|
|
1351
|
+
Handles prefixed tool names (server__toolname) for duplicate resolution.
|
|
1352
|
+
|
|
1353
|
+
Args:
|
|
1354
|
+
tool_name: Name of the tool to call (may be prefixed with server name)
|
|
1355
|
+
tool_input: Tool input parameters
|
|
1356
|
+
user_prompt: The original user prompt that triggered this tool call
|
|
1357
|
+
|
|
1358
|
+
Returns:
|
|
1359
|
+
Tuple of (result_string, execution_time_ms, is_error)
|
|
1360
|
+
"""
|
|
1361
|
+
import time
|
|
1362
|
+
|
|
1363
|
+
start_time = time.time()
|
|
1364
|
+
is_error = False
|
|
1365
|
+
|
|
1366
|
+
try:
|
|
1367
|
+
# Check if this is a built-in tool
|
|
1368
|
+
is_builtin_tool = False
|
|
1369
|
+
original_tool_name = tool_name
|
|
1370
|
+
|
|
1371
|
+
if self._tools_cache:
|
|
1372
|
+
for cached_tool in self._tools_cache:
|
|
1373
|
+
if cached_tool['name'] == tool_name:
|
|
1374
|
+
if cached_tool.get('server') == 'builtin':
|
|
1375
|
+
is_builtin_tool = True
|
|
1376
|
+
original_tool_name = cached_tool['original_name']
|
|
1377
|
+
logging.debug(f"Identified built-in tool: {original_tool_name}")
|
|
1378
|
+
break
|
|
1379
|
+
elif '__' in tool_name:
|
|
1380
|
+
# Prefixed MCP tool name
|
|
1381
|
+
original_tool_name = cached_tool['original_name']
|
|
1382
|
+
logging.debug(f"Resolved prefixed tool name '{tool_name}' to original '{original_tool_name}'")
|
|
1383
|
+
break
|
|
1384
|
+
|
|
1385
|
+
# Execute built-in tool
|
|
1386
|
+
if is_builtin_tool:
|
|
1387
|
+
logging.debug(f"Calling built-in tool: {original_tool_name} with input: {tool_input}")
|
|
1388
|
+
result = builtin.execute_builtin_tool(original_tool_name, tool_input, config=self.config)
|
|
1389
|
+
execution_time = int((time.time() - start_time) * 1000)
|
|
1390
|
+
|
|
1391
|
+
if result.get('success'):
|
|
1392
|
+
# Format result as string
|
|
1393
|
+
result_data = result.get('result', {})
|
|
1394
|
+
if isinstance(result_data, dict):
|
|
1395
|
+
result_str = json.dumps(result_data, indent=2)
|
|
1396
|
+
else:
|
|
1397
|
+
result_str = str(result_data)
|
|
1398
|
+
return result_str, execution_time, False
|
|
1399
|
+
else:
|
|
1400
|
+
error_msg = result.get('error', 'Unknown error')
|
|
1401
|
+
return f"Error: {error_msg}", execution_time, True
|
|
1402
|
+
|
|
1403
|
+
# Execute MCP tool
|
|
1404
|
+
if not self.mcp_manager:
|
|
1405
|
+
return "Error: MCP manager not available", 0, True
|
|
1406
|
+
|
|
1407
|
+
logging.debug(f"Calling MCP tool: {original_tool_name} with input: {tool_input}")
|
|
1408
|
+
|
|
1409
|
+
# Check if we're in a running event loop (e.g., from FastAPI)
|
|
1410
|
+
try:
|
|
1411
|
+
running_loop = asyncio.get_running_loop()
|
|
1412
|
+
# We're in an async context - run in a separate thread
|
|
1413
|
+
logging.debug("Detected running event loop, using thread-safe approach for tool call")
|
|
1414
|
+
|
|
1415
|
+
def run_tool_in_loop():
|
|
1416
|
+
# Create a new event loop for this thread
|
|
1417
|
+
new_loop = asyncio.new_event_loop()
|
|
1418
|
+
asyncio.set_event_loop(new_loop)
|
|
1419
|
+
try:
|
|
1420
|
+
result = new_loop.run_until_complete(
|
|
1421
|
+
asyncio.wait_for(
|
|
1422
|
+
self.mcp_manager.call_tool(original_tool_name, tool_input),
|
|
1423
|
+
timeout=30.0
|
|
1424
|
+
)
|
|
1425
|
+
)
|
|
1426
|
+
return result
|
|
1427
|
+
except asyncio.TimeoutError:
|
|
1428
|
+
logging.error(f"Timeout calling MCP tool {original_tool_name} after 30 seconds")
|
|
1429
|
+
return None
|
|
1430
|
+
finally:
|
|
1431
|
+
new_loop.close()
|
|
1432
|
+
|
|
1433
|
+
# Run in thread pool to avoid event loop conflict
|
|
1434
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
|
1435
|
+
future = executor.submit(run_tool_in_loop)
|
|
1436
|
+
result = future.result(timeout=35.0) # Give extra time for thread overhead
|
|
1437
|
+
|
|
1438
|
+
if result is None:
|
|
1439
|
+
execution_time = int((time.time() - start_time) * 1000)
|
|
1440
|
+
return f"Error: Tool execution timed out after 30 seconds", execution_time, True
|
|
1441
|
+
|
|
1442
|
+
except RuntimeError:
|
|
1443
|
+
# No running event loop - we're in sync context
|
|
1444
|
+
logging.debug("No running event loop detected, using standard approach for tool call")
|
|
1445
|
+
|
|
1446
|
+
# Use the initialisation loop if available, otherwise create a temporary one
|
|
1447
|
+
if hasattr(self.mcp_manager, '_initialization_loop') and self.mcp_manager._initialization_loop:
|
|
1448
|
+
loop = self.mcp_manager._initialization_loop
|
|
1449
|
+
should_close_loop = False
|
|
1450
|
+
logging.debug("Using stored initialisation event loop for tool call")
|
|
1451
|
+
else:
|
|
1452
|
+
loop = asyncio.new_event_loop()
|
|
1453
|
+
asyncio.set_event_loop(loop)
|
|
1454
|
+
should_close_loop = True
|
|
1455
|
+
logging.warning("No stored event loop found, creating temporary loop")
|
|
1456
|
+
|
|
1457
|
+
try:
|
|
1458
|
+
result = loop.run_until_complete(
|
|
1459
|
+
asyncio.wait_for(
|
|
1460
|
+
self.mcp_manager.call_tool(original_tool_name, tool_input),
|
|
1461
|
+
timeout=30.0 # 30 second timeout for tool execution
|
|
1462
|
+
)
|
|
1463
|
+
)
|
|
1464
|
+
except asyncio.TimeoutError:
|
|
1465
|
+
logging.error(f"Timeout calling MCP tool {original_tool_name} after 30 seconds")
|
|
1466
|
+
execution_time = int((time.time() - start_time) * 1000)
|
|
1467
|
+
return f"Error: Tool execution timed out after 30 seconds", execution_time, True
|
|
1468
|
+
finally:
|
|
1469
|
+
# Only close the loop if we created it temporarily
|
|
1470
|
+
if should_close_loop:
|
|
1471
|
+
loop.close()
|
|
1472
|
+
|
|
1473
|
+
execution_time = int((time.time() - start_time) * 1000)
|
|
1474
|
+
|
|
1475
|
+
if result and not result.get('isError'):
|
|
1476
|
+
# Extract text content from result
|
|
1477
|
+
content_parts = []
|
|
1478
|
+
for content in result.get('content', []):
|
|
1479
|
+
if content.get('type') == 'text':
|
|
1480
|
+
content_parts.append(content.get('text', ''))
|
|
1481
|
+
|
|
1482
|
+
result_str = '\n'.join(content_parts) if content_parts else 'Tool executed successfully (no output)'
|
|
1483
|
+
return result_str, execution_time, False
|
|
1484
|
+
else:
|
|
1485
|
+
error_msg = "Tool execution failed"
|
|
1486
|
+
if result:
|
|
1487
|
+
for content in result.get('content', []):
|
|
1488
|
+
if content.get('type') == 'text':
|
|
1489
|
+
error_msg = content.get('text', error_msg)
|
|
1490
|
+
return f"Error: {error_msg}", execution_time, True
|
|
1491
|
+
|
|
1492
|
+
except Exception as e:
|
|
1493
|
+
execution_time = int((time.time() - start_time) * 1000)
|
|
1494
|
+
logging.error(f"Failed to call MCP tool {tool_name}: {e}")
|
|
1495
|
+
return f"Error calling tool: {str(e)}", execution_time, True
|
|
1496
|
+
|
|
1497
|
+
def send_message(self, user_message: str) -> Optional[str]:
|
|
1498
|
+
"""
|
|
1499
|
+
Send a user message and get the assistant's response.
|
|
1500
|
+
Handles MCP tool calls automatically.
|
|
1501
|
+
|
|
1502
|
+
Args:
|
|
1503
|
+
user_message: User's message content
|
|
1504
|
+
|
|
1505
|
+
Returns:
|
|
1506
|
+
Assistant's response content or None on failure
|
|
1507
|
+
"""
|
|
1508
|
+
if not self.current_conversation_id:
|
|
1509
|
+
raise ValueError("No active conversation. Create or load a conversation first.")
|
|
1510
|
+
|
|
1511
|
+
logging.debug(f"send_message called with: {user_message[:50]}...")
|
|
1512
|
+
|
|
1513
|
+
# NEW: Prompt inspection before processing
|
|
1514
|
+
if self.prompt_inspector and self.prompt_inspector.enabled:
|
|
1515
|
+
inspection_result = self.prompt_inspector.inspect_prompt(
|
|
1516
|
+
prompt=user_message,
|
|
1517
|
+
user_guid=self.user_guid or 'unknown',
|
|
1518
|
+
conversation_id=self.current_conversation_id
|
|
1519
|
+
)
|
|
1520
|
+
|
|
1521
|
+
if inspection_result.blocked:
|
|
1522
|
+
# Log violation and notify user
|
|
1523
|
+
if self.cli_interface:
|
|
1524
|
+
self.cli_interface.display_prompt_violation(inspection_result)
|
|
1525
|
+
logging.warning(f"Prompt blocked: {inspection_result.explanation}")
|
|
1526
|
+
return None
|
|
1527
|
+
|
|
1528
|
+
elif inspection_result.needs_confirmation:
|
|
1529
|
+
# Show warning and ask for confirmation
|
|
1530
|
+
if self.cli_interface:
|
|
1531
|
+
confirmed = self.cli_interface.confirm_risky_prompt(inspection_result)
|
|
1532
|
+
if not confirmed:
|
|
1533
|
+
logging.info("User declined to send risky prompt")
|
|
1534
|
+
return None
|
|
1535
|
+
|
|
1536
|
+
# Use sanitised version if available
|
|
1537
|
+
if inspection_result.sanitised_prompt:
|
|
1538
|
+
logging.info("Using sanitised version of prompt")
|
|
1539
|
+
user_message = inspection_result.sanitised_prompt
|
|
1540
|
+
|
|
1541
|
+
# Check if this is the first message - if so, prepend file context
|
|
1542
|
+
messages = self.get_conversation_history()
|
|
1543
|
+
is_first_message = len(messages) == 0
|
|
1544
|
+
|
|
1545
|
+
if is_first_message:
|
|
1546
|
+
file_context = self._get_file_context()
|
|
1547
|
+
if file_context:
|
|
1548
|
+
# Prepend file context to the first user message
|
|
1549
|
+
user_message = f"{file_context}\n\n{user_message}"
|
|
1550
|
+
logging.info("Added file context to first message")
|
|
1551
|
+
|
|
1552
|
+
# Add user message to conversation
|
|
1553
|
+
self.add_user_message(user_message)
|
|
1554
|
+
logging.debug("User message added to database")
|
|
1555
|
+
|
|
1556
|
+
# Get available tools
|
|
1557
|
+
logging.debug("About to fetch MCP tools...")
|
|
1558
|
+
all_tools = self._get_mcp_tools()
|
|
1559
|
+
logging.debug(f"Got {len(all_tools)} tools")
|
|
1560
|
+
|
|
1561
|
+
# Use tool selector to choose relevant tools for this conversation
|
|
1562
|
+
# This significantly reduces token usage by only sending relevant tools
|
|
1563
|
+
tools = self.tool_selector.select_tools(
|
|
1564
|
+
all_tools=all_tools,
|
|
1565
|
+
user_message=user_message,
|
|
1566
|
+
conversation_history=self.get_messages_for_model() if all_tools else None
|
|
1567
|
+
)
|
|
1568
|
+
logging.debug(f"Tool selector reduced {len(all_tools)} tools to {len(tools)} relevant tools")
|
|
1569
|
+
|
|
1570
|
+
# Tool use loop - continue until model gives a final answer
|
|
1571
|
+
# Set flag to defer rollup during tool use sequences (prevents splitting tool_use/tool_result pairs)
|
|
1572
|
+
self._in_tool_use_loop = True
|
|
1573
|
+
iteration = 0
|
|
1574
|
+
tool_call_history = [] # Track tool calls to detect loops
|
|
1575
|
+
|
|
1576
|
+
while iteration < self.max_tool_iterations:
|
|
1577
|
+
iteration += 1
|
|
1578
|
+
logging.debug(f"Tool use iteration {iteration}/{self.max_tool_iterations}")
|
|
1579
|
+
|
|
1580
|
+
# Get conversation history for the model
|
|
1581
|
+
messages = self.get_messages_for_model()
|
|
1582
|
+
logging.debug(f"Sending {len(messages)} messages to model (tools: {len(tools) if tools else 0})")
|
|
1583
|
+
|
|
1584
|
+
# Filter tools to only include Claude API fields (remove internal metadata)
|
|
1585
|
+
filtered_tools = None
|
|
1586
|
+
if tools:
|
|
1587
|
+
filtered_tools = [
|
|
1588
|
+
{
|
|
1589
|
+
'name': tool['name'],
|
|
1590
|
+
'description': tool['description'],
|
|
1591
|
+
'input_schema': tool['input_schema']
|
|
1592
|
+
}
|
|
1593
|
+
for tool in tools
|
|
1594
|
+
]
|
|
1595
|
+
|
|
1596
|
+
# Token Management: Check limits before making API call
|
|
1597
|
+
if self.token_manager:
|
|
1598
|
+
# Estimate input tokens
|
|
1599
|
+
input_token_estimate = self.bedrock_service.count_message_tokens(messages)
|
|
1600
|
+
|
|
1601
|
+
# Check limits
|
|
1602
|
+
model_id = self.bedrock_service.current_model_id
|
|
1603
|
+
region = self.bedrock_service.bedrock_runtime_client.meta.region_name
|
|
1604
|
+
|
|
1605
|
+
allowed, warning_message, limit_status = self.token_manager.check_limits_before_request(
|
|
1606
|
+
model_id, region, input_token_estimate, self.max_tokens
|
|
1607
|
+
)
|
|
1608
|
+
|
|
1609
|
+
# Display warnings based on limit status
|
|
1610
|
+
if warning_message and self.cli_interface:
|
|
1611
|
+
if limit_status == LimitStatus.WARNING_75:
|
|
1612
|
+
self.cli_interface.print_budget_warning(warning_message, "75")
|
|
1613
|
+
elif limit_status == LimitStatus.WARNING_85:
|
|
1614
|
+
self.cli_interface.print_budget_warning(warning_message, "85")
|
|
1615
|
+
elif limit_status == LimitStatus.WARNING_95:
|
|
1616
|
+
self.cli_interface.print_budget_warning(warning_message, "95")
|
|
1617
|
+
|
|
1618
|
+
# Handle limit exceeded
|
|
1619
|
+
if not allowed:
|
|
1620
|
+
if self.cli_interface:
|
|
1621
|
+
self.cli_interface.print_separator("─")
|
|
1622
|
+
self.cli_interface.print_error("Token Limit Reached")
|
|
1623
|
+
self.cli_interface.print_error(warning_message)
|
|
1624
|
+
self.cli_interface.print_separator("─")
|
|
1625
|
+
|
|
1626
|
+
# Offer override if allowed
|
|
1627
|
+
if self.token_manager.allow_override:
|
|
1628
|
+
override_accepted, additional_percentage = self.cli_interface.prompt_budget_override()
|
|
1629
|
+
|
|
1630
|
+
if override_accepted:
|
|
1631
|
+
# Apply override
|
|
1632
|
+
self.token_manager.apply_override(additional_percentage)
|
|
1633
|
+
new_input_limit = self.token_manager.max_input_tokens + self.token_manager.current_input_override
|
|
1634
|
+
new_output_limit = self.token_manager.max_output_tokens + self.token_manager.current_output_override
|
|
1635
|
+
self.cli_interface.print_success(
|
|
1636
|
+
f"Token limit override applied: +{additional_percentage}% "
|
|
1637
|
+
f"(new limits: {new_input_limit:,} input, {new_output_limit:,} output tokens)"
|
|
1638
|
+
)
|
|
1639
|
+
# Continue with request
|
|
1640
|
+
else:
|
|
1641
|
+
# User declined override
|
|
1642
|
+
self.cli_interface.print_info("Request cancelled due to token limit")
|
|
1643
|
+
self._in_tool_use_loop = False
|
|
1644
|
+
self._check_and_perform_rollup()
|
|
1645
|
+
return "I apologise, but the token limit has been reached and I cannot process this request at this time. Please wait for the limit to reset or contact your administrator to increase the limit."
|
|
1646
|
+
else:
|
|
1647
|
+
# No override allowed
|
|
1648
|
+
self.cli_interface.print_info("Request cancelled due to token limit (no override allowed)")
|
|
1649
|
+
self._in_tool_use_loop = False
|
|
1650
|
+
self._check_and_perform_rollup()
|
|
1651
|
+
return "I apologise, but the token limit has been reached and I cannot process this request at this time. Please wait for the limit to reset."
|
|
1652
|
+
else:
|
|
1653
|
+
# No CLI interface, just log and return
|
|
1654
|
+
logging.warning("Budget limit reached, request blocked")
|
|
1655
|
+
self._in_tool_use_loop = False
|
|
1656
|
+
self._check_and_perform_rollup()
|
|
1657
|
+
return "Budget limit reached."
|
|
1658
|
+
|
|
1659
|
+
# Invoke the model with tools and combined instructions (global + conversation-specific)
|
|
1660
|
+
response = self.bedrock_service.invoke_model(
|
|
1661
|
+
messages,
|
|
1662
|
+
max_tokens=self.max_tokens,
|
|
1663
|
+
tools=filtered_tools,
|
|
1664
|
+
system=self._get_combined_instructions()
|
|
1665
|
+
)
|
|
1666
|
+
|
|
1667
|
+
if not response or response.get('error'):
|
|
1668
|
+
# Handle error response
|
|
1669
|
+
if response and response.get('error'):
|
|
1670
|
+
error_code = response.get('error_code', 'Unknown')
|
|
1671
|
+
error_message = response.get('error_message', 'No details available')
|
|
1672
|
+
error_type = response.get('error_type', 'Unknown')
|
|
1673
|
+
retries_attempted = response.get('retries_attempted', 0)
|
|
1674
|
+
|
|
1675
|
+
logging.error(f"Model invocation failed - Type: {error_type}, Code: {error_code}, Message: {error_message}")
|
|
1676
|
+
if retries_attempted > 0:
|
|
1677
|
+
logging.error(f"Failed after {retries_attempted} retry attempt(s)")
|
|
1678
|
+
|
|
1679
|
+
if self.cli_interface:
|
|
1680
|
+
self.cli_interface.print_separator("─")
|
|
1681
|
+
self.cli_interface.print_error(f"✗ Failed to get response from the model")
|
|
1682
|
+
if retries_attempted > 0:
|
|
1683
|
+
self.cli_interface.print_error(f"(Failed after {retries_attempted} retry attempt(s))")
|
|
1684
|
+
self.cli_interface.print_error(f"Error Code: {error_code}")
|
|
1685
|
+
self.cli_interface.print_error(f"Error Message: {error_message}")
|
|
1686
|
+
|
|
1687
|
+
# Provide helpful suggestions based on error type
|
|
1688
|
+
if 'ThrottlingException' in error_code or 'TooManyRequestsException' in error_code:
|
|
1689
|
+
self.cli_interface.print_info("💡 Suggestion: You're hitting rate limits. Wait a moment and try again.")
|
|
1690
|
+
elif 'ModelTimeoutException' in error_code or 'timeout' in error_message.lower():
|
|
1691
|
+
self.cli_interface.print_info("💡 Suggestion: The request timed out. Try simplifying your request or reducing conversation history.")
|
|
1692
|
+
elif 'ValidationException' in error_code:
|
|
1693
|
+
self.cli_interface.print_info("💡 Suggestion: There's an issue with the request format. Check your message content and tool configurations.")
|
|
1694
|
+
elif 'ModelNotReadyException' in error_code:
|
|
1695
|
+
self.cli_interface.print_info("💡 Suggestion: The model is not ready. Wait a moment and try again.")
|
|
1696
|
+
elif 'ServiceUnavailableException' in error_code or 'InternalServerError' in error_code:
|
|
1697
|
+
self.cli_interface.print_info("💡 Suggestion: AWS Bedrock service is experiencing issues. Wait a moment and try again.")
|
|
1698
|
+
elif 'AccessDeniedException' in error_code or 'UnauthorizedException' in error_code:
|
|
1699
|
+
self.cli_interface.print_info("💡 Suggestion: Check your AWS credentials and permissions for Bedrock access.")
|
|
1700
|
+
elif 'ModelStreamErrorException' in error_code:
|
|
1701
|
+
self.cli_interface.print_info("💡 Suggestion: There was an error in the model's response stream. Try again.")
|
|
1702
|
+
else:
|
|
1703
|
+
self.cli_interface.print_info("💡 Suggestion: Check the application logs for more details.")
|
|
1704
|
+
|
|
1705
|
+
self.cli_interface.print_separator("─")
|
|
1706
|
+
else:
|
|
1707
|
+
logging.error("Failed to get response from model - no response received")
|
|
1708
|
+
if self.cli_interface:
|
|
1709
|
+
self.cli_interface.print_error("✗ Failed to get response from the model (no response received)")
|
|
1710
|
+
|
|
1711
|
+
# Clear tool use flag even on failure
|
|
1712
|
+
self._in_tool_use_loop = False
|
|
1713
|
+
self._check_and_perform_rollup()
|
|
1714
|
+
|
|
1715
|
+
return None
|
|
1716
|
+
|
|
1717
|
+
# Track API token usage
|
|
1718
|
+
usage = response.get('usage', {})
|
|
1719
|
+
if usage:
|
|
1720
|
+
input_tokens = usage.get('input_tokens', 0)
|
|
1721
|
+
output_tokens = usage.get('output_tokens', 0)
|
|
1722
|
+
if input_tokens or output_tokens:
|
|
1723
|
+
# Record usage with model_id for per-model tracking
|
|
1724
|
+
model_id = self.bedrock_service.current_model_id
|
|
1725
|
+
self.database.update_token_usage(
|
|
1726
|
+
self.current_conversation_id,
|
|
1727
|
+
input_tokens,
|
|
1728
|
+
output_tokens,
|
|
1729
|
+
model_id
|
|
1730
|
+
)
|
|
1731
|
+
logging.debug(f"API usage: {input_tokens} input tokens, {output_tokens} output tokens (model: {model_id})")
|
|
1732
|
+
|
|
1733
|
+
# Token Management: Record actual usage for token tracking
|
|
1734
|
+
if self.token_manager:
|
|
1735
|
+
region = self.bedrock_service.bedrock_runtime_client.meta.region_name
|
|
1736
|
+
recorded_input, recorded_output = self.token_manager.record_usage(
|
|
1737
|
+
conversation_id=self.current_conversation_id,
|
|
1738
|
+
model_id=model_id,
|
|
1739
|
+
region=region,
|
|
1740
|
+
input_tokens=input_tokens,
|
|
1741
|
+
output_tokens=output_tokens
|
|
1742
|
+
)
|
|
1743
|
+
logging.debug(f"Token tracking: {recorded_input:,} input, {recorded_output:,} output tokens for this request")
|
|
1744
|
+
|
|
1745
|
+
# Check if model wants to use tools
|
|
1746
|
+
content_blocks = response.get('content_blocks', [])
|
|
1747
|
+
stop_reason = response.get('stop_reason')
|
|
1748
|
+
logging.debug(f"Model response: stop_reason={stop_reason}, content_blocks={len(content_blocks)}")
|
|
1749
|
+
|
|
1750
|
+
# Check for tool use
|
|
1751
|
+
tool_uses = [block for block in content_blocks if block.get('type') == 'tool_use']
|
|
1752
|
+
|
|
1753
|
+
if tool_uses and stop_reason == 'tool_use':
|
|
1754
|
+
# Model wants to use tools
|
|
1755
|
+
logging.info(f"Model requested {len(tool_uses)} tool call(s)")
|
|
1756
|
+
|
|
1757
|
+
# Store assistant's tool use message (with all content blocks)
|
|
1758
|
+
assistant_content_json = json.dumps(content_blocks)
|
|
1759
|
+
self.add_assistant_message(assistant_content_json)
|
|
1760
|
+
logging.debug(f"Stored assistant tool use message")
|
|
1761
|
+
|
|
1762
|
+
# Display any text blocks that appear with tool calls (e.g., "Let me check that...")
|
|
1763
|
+
if self.cli_interface:
|
|
1764
|
+
text_blocks = [block for block in content_blocks if block.get('type') == 'text']
|
|
1765
|
+
for text_block in text_blocks:
|
|
1766
|
+
text_content = text_block.get('text', '')
|
|
1767
|
+
if text_content:
|
|
1768
|
+
# Display as assistant message (not markdown since it's usually brief)
|
|
1769
|
+
self.cli_interface.console.print(f"[bold cyan]Assistant:[/bold cyan] {text_content}")
|
|
1770
|
+
|
|
1771
|
+
# Call each tool and collect results
|
|
1772
|
+
tool_results = []
|
|
1773
|
+
for tool_use in tool_uses:
|
|
1774
|
+
tool_name = tool_use.get('name')
|
|
1775
|
+
tool_input = tool_use.get('input', {})
|
|
1776
|
+
tool_id = tool_use.get('id')
|
|
1777
|
+
|
|
1778
|
+
# Check tool permission
|
|
1779
|
+
permission_allowed = self.database.is_tool_allowed(self.current_conversation_id, tool_name)
|
|
1780
|
+
|
|
1781
|
+
if permission_allowed is None:
|
|
1782
|
+
# First-time usage - check if auto-approve is enabled
|
|
1783
|
+
auto_approve = self.config.get('tool_permissions', {}).get('auto_approve', False)
|
|
1784
|
+
|
|
1785
|
+
if auto_approve:
|
|
1786
|
+
# Auto-approve enabled - allow this time without storing permission
|
|
1787
|
+
logging.info(f"Tool {tool_name} auto-approved (tool_permissions.auto_approve=true)")
|
|
1788
|
+
permission_response = 'once' # Allow but don't store in database
|
|
1789
|
+
else:
|
|
1790
|
+
# Prompt user for permission
|
|
1791
|
+
logging.info(f"First-time tool usage detected: {tool_name}, prompting user for permission")
|
|
1792
|
+
|
|
1793
|
+
# Get tool description from cache if available
|
|
1794
|
+
tool_description = None
|
|
1795
|
+
if self._tools_cache:
|
|
1796
|
+
for cached_tool in self._tools_cache:
|
|
1797
|
+
if cached_tool.get('name') == tool_name:
|
|
1798
|
+
input_schema = cached_tool.get('input_schema', {})
|
|
1799
|
+
tool_description = input_schema.get('description', cached_tool.get('description'))
|
|
1800
|
+
break
|
|
1801
|
+
|
|
1802
|
+
# Prompt user via the appropriate interface (Web takes priority over CLI)
|
|
1803
|
+
if hasattr(self, 'web_interface') and self.web_interface:
|
|
1804
|
+
# User is in web mode, prompt via web interface
|
|
1805
|
+
permission_response = self.web_interface.prompt_tool_permission(tool_name, tool_description)
|
|
1806
|
+
elif self.cli_interface:
|
|
1807
|
+
# User is in CLI mode, prompt via CLI interface
|
|
1808
|
+
permission_response = self.cli_interface.prompt_tool_permission(tool_name, tool_description)
|
|
1809
|
+
else:
|
|
1810
|
+
# No interface available, deny by default for security
|
|
1811
|
+
logging.warning(f"No interface available to prompt for tool permission, denying tool: {tool_name}")
|
|
1812
|
+
permission_response = 'denied'
|
|
1813
|
+
|
|
1814
|
+
if permission_response == 'once':
|
|
1815
|
+
# Allow this time only, don't store permission
|
|
1816
|
+
logging.info(f"Tool {tool_name} allowed for this use only")
|
|
1817
|
+
permission_allowed = True
|
|
1818
|
+
elif permission_response == 'allowed':
|
|
1819
|
+
# Store permission and proceed
|
|
1820
|
+
self.database.set_tool_permission(self.current_conversation_id, tool_name, PERMISSION_ALLOWED)
|
|
1821
|
+
logging.info(f"Tool {tool_name} permission granted for all future uses")
|
|
1822
|
+
permission_allowed = True
|
|
1823
|
+
elif permission_response == 'denied':
|
|
1824
|
+
# Store denial
|
|
1825
|
+
self.database.set_tool_permission(self.current_conversation_id, tool_name, PERMISSION_DENIED)
|
|
1826
|
+
logging.info(f"Tool {tool_name} permission denied")
|
|
1827
|
+
permission_allowed = False
|
|
1828
|
+
else:
|
|
1829
|
+
# Cancelled or error - skip this tool
|
|
1830
|
+
logging.info(f"Tool {tool_name} permission cancelled by user")
|
|
1831
|
+
permission_allowed = False
|
|
1832
|
+
|
|
1833
|
+
# If tool is denied, skip it and add error result
|
|
1834
|
+
if not permission_allowed:
|
|
1835
|
+
logging.warning(f"Tool {tool_name} denied by user permission settings, skipping")
|
|
1836
|
+
tool_results.append({
|
|
1837
|
+
'type': 'tool_result',
|
|
1838
|
+
'tool_use_id': tool_id,
|
|
1839
|
+
'content': f"Error: Tool '{tool_name}' is not allowed. Permission was denied by user.",
|
|
1840
|
+
'is_error': True
|
|
1841
|
+
})
|
|
1842
|
+
continue
|
|
1843
|
+
|
|
1844
|
+
# Track tool call for loop detection
|
|
1845
|
+
tool_call_history.append(tool_name)
|
|
1846
|
+
logging.info(f"Calling tool: {tool_name} (iteration {iteration})")
|
|
1847
|
+
|
|
1848
|
+
# Display tool call if CLI interface available
|
|
1849
|
+
if self.cli_interface:
|
|
1850
|
+
self.cli_interface.display_tool_call(tool_name, tool_input)
|
|
1851
|
+
|
|
1852
|
+
# Call the tool and get metrics
|
|
1853
|
+
result, execution_time_ms, is_error = self._call_mcp_tool(tool_name, tool_input, user_message)
|
|
1854
|
+
|
|
1855
|
+
# Find which server this tool belongs to
|
|
1856
|
+
tool_server = "unknown"
|
|
1857
|
+
if self._tools_cache:
|
|
1858
|
+
for cached_tool in self._tools_cache:
|
|
1859
|
+
if cached_tool.get('name') == tool_name:
|
|
1860
|
+
tool_server = cached_tool.get('server', 'unknown')
|
|
1861
|
+
break
|
|
1862
|
+
|
|
1863
|
+
# Record MCP transaction for security monitoring
|
|
1864
|
+
try:
|
|
1865
|
+
self.database.record_mcp_transaction(
|
|
1866
|
+
conversation_id=self.current_conversation_id,
|
|
1867
|
+
user_prompt=user_message,
|
|
1868
|
+
tool_name=tool_name,
|
|
1869
|
+
tool_server=tool_server,
|
|
1870
|
+
tool_input=json.dumps(tool_input),
|
|
1871
|
+
tool_response=result,
|
|
1872
|
+
is_error=is_error,
|
|
1873
|
+
execution_time_ms=execution_time_ms
|
|
1874
|
+
)
|
|
1875
|
+
except Exception as txn_err:
|
|
1876
|
+
logging.error(f"Failed to record MCP transaction: {txn_err}")
|
|
1877
|
+
|
|
1878
|
+
# Display tool result if CLI interface available
|
|
1879
|
+
if self.cli_interface:
|
|
1880
|
+
self.cli_interface.display_tool_result(tool_name, result, is_error)
|
|
1881
|
+
|
|
1882
|
+
# Truncate very large tool results to prevent token explosion
|
|
1883
|
+
# Most models can't handle more than ~200K tokens total
|
|
1884
|
+
result_tokens = self.bedrock_service.count_tokens(result)
|
|
1885
|
+
|
|
1886
|
+
if result_tokens > self.max_tool_result_tokens:
|
|
1887
|
+
# Truncate the result and add a warning
|
|
1888
|
+
truncated_result = result[:int(len(result) * (self.max_tool_result_tokens / result_tokens))]
|
|
1889
|
+
truncated_result += f"\n\n[Result truncated: {result_tokens} tokens reduced to ~{self.max_tool_result_tokens} tokens]"
|
|
1890
|
+
logging.warning(f"Tool {tool_name} result truncated from {result_tokens} to ~{self.max_tool_result_tokens} tokens")
|
|
1891
|
+
result = truncated_result
|
|
1892
|
+
|
|
1893
|
+
tool_results.append({
|
|
1894
|
+
'type': 'tool_result',
|
|
1895
|
+
'tool_use_id': tool_id,
|
|
1896
|
+
'content': result
|
|
1897
|
+
})
|
|
1898
|
+
|
|
1899
|
+
# Add tool results as a user message
|
|
1900
|
+
tool_results_json = json.dumps(tool_results)
|
|
1901
|
+
self.add_user_message(f"[TOOL_RESULTS]{tool_results_json}")
|
|
1902
|
+
|
|
1903
|
+
# Continue loop to get model's next response
|
|
1904
|
+
continue
|
|
1905
|
+
|
|
1906
|
+
else:
|
|
1907
|
+
# Model gave a final answer (or incomplete response)
|
|
1908
|
+
assistant_message = self._extract_text_from_content(response.get('content', ''))
|
|
1909
|
+
|
|
1910
|
+
# Check if this looks like an incomplete response (model said it would do something but didn't)
|
|
1911
|
+
if assistant_message and stop_reason == 'max_tokens':
|
|
1912
|
+
logging.warning(f"Model response may be incomplete (stop_reason: max_tokens). "
|
|
1913
|
+
f"Response: {assistant_message[:100]}...")
|
|
1914
|
+
if self.cli_interface:
|
|
1915
|
+
suggested_max_tokens = self._calculate_suggested_max_tokens()
|
|
1916
|
+
self.cli_interface.print_separator("─")
|
|
1917
|
+
self.cli_interface.print_warning(
|
|
1918
|
+
"⚠️ Model response may be incomplete (hit token limit)."
|
|
1919
|
+
)
|
|
1920
|
+
self.cli_interface.print_info(
|
|
1921
|
+
f"Current max_tokens: {self.max_tokens:,}"
|
|
1922
|
+
)
|
|
1923
|
+
self.cli_interface.print_info(
|
|
1924
|
+
f"💡 Suggested max_tokens: {suggested_max_tokens:,}"
|
|
1925
|
+
)
|
|
1926
|
+
|
|
1927
|
+
# Prompt user if they want to increase max_tokens
|
|
1928
|
+
try:
|
|
1929
|
+
response = input(f"\nWould you like to increase max_tokens to {suggested_max_tokens:,} for this conversation? (y/n): ").strip().lower()
|
|
1930
|
+
if response == 'y' or response == 'yes':
|
|
1931
|
+
if self.update_conversation_max_tokens(suggested_max_tokens):
|
|
1932
|
+
self.cli_interface.print_success(
|
|
1933
|
+
f"✓ max_tokens increased to {suggested_max_tokens:,} for this conversation"
|
|
1934
|
+
)
|
|
1935
|
+
self.cli_interface.print_info(
|
|
1936
|
+
"This setting will be retained when you return to this conversation."
|
|
1937
|
+
)
|
|
1938
|
+
else:
|
|
1939
|
+
self.cli_interface.print_error(
|
|
1940
|
+
"Failed to update max_tokens. Please try again or modify config.yaml."
|
|
1941
|
+
)
|
|
1942
|
+
else:
|
|
1943
|
+
self.cli_interface.print_info(
|
|
1944
|
+
"max_tokens unchanged. Consider simplifying your request or manually adjusting in config.yaml."
|
|
1945
|
+
)
|
|
1946
|
+
except (EOFError, KeyboardInterrupt):
|
|
1947
|
+
# User cancelled or EOF
|
|
1948
|
+
self.cli_interface.print_info("\nmax_tokens unchanged.")
|
|
1949
|
+
|
|
1950
|
+
self.cli_interface.print_separator("─")
|
|
1951
|
+
|
|
1952
|
+
# Detect potential incomplete tool use (model says it will do something but no tool calls)
|
|
1953
|
+
intent_keywords = ['let me', "i'll", "i will", "now i", "now let me"]
|
|
1954
|
+
if assistant_message and not tool_uses and any(keyword in assistant_message.lower() for keyword in intent_keywords):
|
|
1955
|
+
logging.warning(f"Model indicated intent to act but made no tool calls. "
|
|
1956
|
+
f"Response: {assistant_message[:150]}...")
|
|
1957
|
+
if self.cli_interface and stop_reason != 'max_tokens':
|
|
1958
|
+
self.cli_interface.print_warning(
|
|
1959
|
+
"⚠️ Model indicated an action but didn't execute it. You may need to prompt again or rephrase your request."
|
|
1960
|
+
)
|
|
1961
|
+
|
|
1962
|
+
if assistant_message:
|
|
1963
|
+
# Add assistant's final response to conversation
|
|
1964
|
+
self.add_assistant_message(assistant_message)
|
|
1965
|
+
|
|
1966
|
+
# Detect if this is a synthesis/summary response that aggregates data
|
|
1967
|
+
# If so, prompt user to verify calculations to catch potential errors
|
|
1968
|
+
if self._detect_synthesis_response(assistant_message, tool_call_history):
|
|
1969
|
+
logging.info("Synthesis response detected - suggesting verification to user")
|
|
1970
|
+
if self.cli_interface:
|
|
1971
|
+
self.cli_interface.print_separator("─")
|
|
1972
|
+
self.cli_interface.print_warning(
|
|
1973
|
+
"📊 Synthesis/Summary Detected: This response aggregates data from multiple sources. "
|
|
1974
|
+
"To ensure accuracy, consider asking the assistant to verify its calculations "
|
|
1975
|
+
"by comparing with the detailed source data."
|
|
1976
|
+
)
|
|
1977
|
+
self.cli_interface.print_info(
|
|
1978
|
+
"💡 Suggested verification prompt: "
|
|
1979
|
+
"\"Please verify your calculations by reviewing the detailed reports and confirming all totals are accurate.\""
|
|
1980
|
+
)
|
|
1981
|
+
self.cli_interface.print_separator("─")
|
|
1982
|
+
|
|
1983
|
+
# Clear tool use flag and check for deferred rollup
|
|
1984
|
+
self._in_tool_use_loop = False
|
|
1985
|
+
self._check_and_perform_rollup()
|
|
1986
|
+
|
|
1987
|
+
return assistant_message
|
|
1988
|
+
else:
|
|
1989
|
+
logging.warning(f"Model returned empty response (stop_reason: {stop_reason})")
|
|
1990
|
+
|
|
1991
|
+
# Clear tool use flag even on empty response
|
|
1992
|
+
self._in_tool_use_loop = False
|
|
1993
|
+
self._check_and_perform_rollup()
|
|
1994
|
+
|
|
1995
|
+
return None
|
|
1996
|
+
|
|
1997
|
+
# Max iterations reached
|
|
1998
|
+
from collections import Counter
|
|
1999
|
+
tool_counts = Counter(tool_call_history)
|
|
2000
|
+
most_common = tool_counts.most_common(3)
|
|
2001
|
+
tool_summary = ', '.join([f"{name}({count})" for name, count in most_common])
|
|
2002
|
+
|
|
2003
|
+
logging.warning(f"Max tool iterations ({self.max_tool_iterations}) reached. "
|
|
2004
|
+
f"Tools called: {tool_summary}. Total calls: {len(tool_call_history)}")
|
|
2005
|
+
|
|
2006
|
+
# Clear tool use flag and check for deferred rollup
|
|
2007
|
+
self._in_tool_use_loop = False
|
|
2008
|
+
self._check_and_perform_rollup()
|
|
2009
|
+
|
|
2010
|
+
return (f"I apologise, but I've reached the maximum number of tool calls ({self.max_tool_iterations}) "
|
|
2011
|
+
f"for this request. I called {len(tool_call_history)} tools in total. "
|
|
2012
|
+
f"You may need to rephrase your request or break it into smaller tasks.")
|
|
2013
|
+
|
|
2014
|
+
def delete_current_conversation(self) -> bool:
|
|
2015
|
+
"""
|
|
2016
|
+
Delete the current conversation.
|
|
2017
|
+
|
|
2018
|
+
Returns:
|
|
2019
|
+
True if successful, False otherwise
|
|
2020
|
+
"""
|
|
2021
|
+
if not self.current_conversation_id:
|
|
2022
|
+
logging.warning("No conversation loaded to delete")
|
|
2023
|
+
return False
|
|
2024
|
+
|
|
2025
|
+
success = self.database.delete_conversation(self.current_conversation_id)
|
|
2026
|
+
if success:
|
|
2027
|
+
self.current_conversation_id = None
|
|
2028
|
+
logging.info("Current conversation deleted")
|
|
2029
|
+
return success
|
|
2030
|
+
|
|
2031
|
+
def attach_files(self, file_data: Union[List[str], List[Dict]]) -> bool:
|
|
2032
|
+
"""
|
|
2033
|
+
Process and attach files to the current conversation.
|
|
2034
|
+
|
|
2035
|
+
Args:
|
|
2036
|
+
file_data: List of file paths (strings) or list of dicts with 'path' and 'tags' keys
|
|
2037
|
+
|
|
2038
|
+
Returns:
|
|
2039
|
+
True if all files attached successfully
|
|
2040
|
+
"""
|
|
2041
|
+
if not self.current_conversation_id:
|
|
2042
|
+
logging.warning("No conversation loaded to attach files to")
|
|
2043
|
+
return False
|
|
2044
|
+
|
|
2045
|
+
from dtSpark.files.manager import FileManager
|
|
2046
|
+
|
|
2047
|
+
file_manager = FileManager(bedrock_service=self.bedrock_service)
|
|
2048
|
+
success_count = 0
|
|
2049
|
+
|
|
2050
|
+
# Normalize input to list of dicts format
|
|
2051
|
+
normalized_files = []
|
|
2052
|
+
if file_data and isinstance(file_data[0], str):
|
|
2053
|
+
# Old format: list of strings
|
|
2054
|
+
normalized_files = [{'path': fp, 'tags': None} for fp in file_data]
|
|
2055
|
+
else:
|
|
2056
|
+
# New format: list of dicts
|
|
2057
|
+
normalized_files = file_data
|
|
2058
|
+
|
|
2059
|
+
for file_info in normalized_files:
|
|
2060
|
+
file_path = file_info['path']
|
|
2061
|
+
tags = file_info.get('tags')
|
|
2062
|
+
|
|
2063
|
+
try:
|
|
2064
|
+
# Process the file
|
|
2065
|
+
result = file_manager.process_file(file_path)
|
|
2066
|
+
|
|
2067
|
+
if 'error' in result:
|
|
2068
|
+
logging.error(f"Failed to process file {file_path}: {result['error']}")
|
|
2069
|
+
continue
|
|
2070
|
+
|
|
2071
|
+
# Add to database
|
|
2072
|
+
file_id = self.database.add_file(
|
|
2073
|
+
conversation_id=self.current_conversation_id,
|
|
2074
|
+
filename=result['filename'],
|
|
2075
|
+
file_type=result['file_type'],
|
|
2076
|
+
file_size=result['file_size'],
|
|
2077
|
+
content_text=result.get('content_text'),
|
|
2078
|
+
content_base64=result.get('content_base64'),
|
|
2079
|
+
mime_type=result.get('mime_type'),
|
|
2080
|
+
token_count=result['token_count'],
|
|
2081
|
+
tags=tags
|
|
2082
|
+
)
|
|
2083
|
+
|
|
2084
|
+
tags_str = f" with tags '{tags}'" if tags else ""
|
|
2085
|
+
logging.info(f"Attached file {result['filename']} (ID: {file_id}, {result['token_count']} tokens{tags_str})")
|
|
2086
|
+
success_count += 1
|
|
2087
|
+
|
|
2088
|
+
except Exception as e:
|
|
2089
|
+
logging.error(f"Error attaching file {file_path}: {e}")
|
|
2090
|
+
continue
|
|
2091
|
+
|
|
2092
|
+
return success_count == len(normalized_files)
|
|
2093
|
+
|
|
2094
|
+
def attach_files_with_message(self, file_data: Union[List[str], List[Dict]]) -> bool:
|
|
2095
|
+
"""
|
|
2096
|
+
Process and attach files to the current conversation, adding their content
|
|
2097
|
+
as a user message so the model can immediately access them.
|
|
2098
|
+
|
|
2099
|
+
Args:
|
|
2100
|
+
file_data: List of file paths (strings) or list of dicts with 'path' and 'tags' keys
|
|
2101
|
+
|
|
2102
|
+
Returns:
|
|
2103
|
+
True if all files attached successfully
|
|
2104
|
+
"""
|
|
2105
|
+
if not self.current_conversation_id:
|
|
2106
|
+
logging.warning("No conversation loaded to attach files to")
|
|
2107
|
+
return False
|
|
2108
|
+
|
|
2109
|
+
from dtSpark.files.manager import FileManager
|
|
2110
|
+
|
|
2111
|
+
file_manager = FileManager(bedrock_service=self.bedrock_service)
|
|
2112
|
+
success_count = 0
|
|
2113
|
+
attached_file_info = []
|
|
2114
|
+
|
|
2115
|
+
# Normalize input to list of dicts format
|
|
2116
|
+
normalized_files = []
|
|
2117
|
+
if file_data and isinstance(file_data[0], str):
|
|
2118
|
+
# Old format: list of strings
|
|
2119
|
+
normalized_files = [{'path': fp, 'tags': None} for fp in file_data]
|
|
2120
|
+
else:
|
|
2121
|
+
# New format: list of dicts
|
|
2122
|
+
normalized_files = file_data
|
|
2123
|
+
|
|
2124
|
+
for file_info in normalized_files:
|
|
2125
|
+
file_path = file_info['path']
|
|
2126
|
+
tags = file_info.get('tags')
|
|
2127
|
+
|
|
2128
|
+
try:
|
|
2129
|
+
# Process the file
|
|
2130
|
+
result = file_manager.process_file(file_path)
|
|
2131
|
+
|
|
2132
|
+
if 'error' in result:
|
|
2133
|
+
logging.error(f"Failed to process file {file_path}: {result['error']}")
|
|
2134
|
+
continue
|
|
2135
|
+
|
|
2136
|
+
# Add to database
|
|
2137
|
+
file_id = self.database.add_file(
|
|
2138
|
+
conversation_id=self.current_conversation_id,
|
|
2139
|
+
filename=result['filename'],
|
|
2140
|
+
file_type=result['file_type'],
|
|
2141
|
+
file_size=result['file_size'],
|
|
2142
|
+
content_text=result.get('content_text'),
|
|
2143
|
+
content_base64=result.get('content_base64'),
|
|
2144
|
+
mime_type=result.get('mime_type'),
|
|
2145
|
+
token_count=result['token_count'],
|
|
2146
|
+
tags=tags
|
|
2147
|
+
)
|
|
2148
|
+
|
|
2149
|
+
tags_str = f" with tags '{tags}'" if tags else ""
|
|
2150
|
+
logging.info(f"Attached file {result['filename']} (ID: {file_id}, {result['token_count']} tokens{tags_str})")
|
|
2151
|
+
|
|
2152
|
+
# Store result with tags for message generation
|
|
2153
|
+
result['tags'] = tags
|
|
2154
|
+
attached_file_info.append(result)
|
|
2155
|
+
success_count += 1
|
|
2156
|
+
|
|
2157
|
+
except Exception as e:
|
|
2158
|
+
logging.error(f"Error attaching file {file_path}: {e}")
|
|
2159
|
+
continue
|
|
2160
|
+
|
|
2161
|
+
# If files were successfully attached, add their content as a user message
|
|
2162
|
+
if attached_file_info:
|
|
2163
|
+
context_parts = ["=== Newly Attached Files ===\n"]
|
|
2164
|
+
|
|
2165
|
+
for file_info in attached_file_info:
|
|
2166
|
+
# Include tags in file header if present
|
|
2167
|
+
tags_str = f" [Tags: {file_info.get('tags')}]" if file_info.get('tags') else ""
|
|
2168
|
+
context_parts.append(f"File: {file_info['filename']} ({file_info['file_type']}){tags_str}")
|
|
2169
|
+
context_parts.append("")
|
|
2170
|
+
|
|
2171
|
+
# Add text content if available
|
|
2172
|
+
if file_info.get('content_text'):
|
|
2173
|
+
context_parts.append(file_info['content_text'])
|
|
2174
|
+
context_parts.append("")
|
|
2175
|
+
|
|
2176
|
+
# For images, just note that they're attached
|
|
2177
|
+
elif file_info.get('content_base64'):
|
|
2178
|
+
context_parts.append(f"[Image file: {file_info.get('mime_type')}]")
|
|
2179
|
+
context_parts.append("")
|
|
2180
|
+
|
|
2181
|
+
context_parts.append("---")
|
|
2182
|
+
context_parts.append("")
|
|
2183
|
+
|
|
2184
|
+
context_parts.append("The above files have been attached to this conversation for reference.")
|
|
2185
|
+
|
|
2186
|
+
# Add as user message
|
|
2187
|
+
file_context_message = '\n'.join(context_parts)
|
|
2188
|
+
self.add_user_message(file_context_message)
|
|
2189
|
+
logging.info(f"Added file context message for {len(attached_file_info)} newly attached files")
|
|
2190
|
+
|
|
2191
|
+
return success_count == len(normalized_files)
|
|
2192
|
+
|
|
2193
|
+
def get_attached_files(self) -> List[Dict]:
|
|
2194
|
+
"""
|
|
2195
|
+
Get all files attached to the current conversation.
|
|
2196
|
+
|
|
2197
|
+
Returns:
|
|
2198
|
+
List of file dictionaries
|
|
2199
|
+
"""
|
|
2200
|
+
if not self.current_conversation_id:
|
|
2201
|
+
return []
|
|
2202
|
+
|
|
2203
|
+
return self.database.get_conversation_files(self.current_conversation_id)
|
|
2204
|
+
|
|
2205
|
+
def get_files_by_tag(self, tag: str) -> List[Dict]:
|
|
2206
|
+
"""
|
|
2207
|
+
Get files attached to the current conversation filtered by tag.
|
|
2208
|
+
|
|
2209
|
+
Args:
|
|
2210
|
+
tag: Tag to filter by (case-insensitive)
|
|
2211
|
+
|
|
2212
|
+
Returns:
|
|
2213
|
+
List of file dictionaries with matching tag
|
|
2214
|
+
"""
|
|
2215
|
+
if not self.current_conversation_id:
|
|
2216
|
+
return []
|
|
2217
|
+
|
|
2218
|
+
return self.database.get_files_by_tag(self.current_conversation_id, tag)
|
|
2219
|
+
|
|
2220
|
+
def _get_file_context(self) -> str:
|
|
2221
|
+
"""
|
|
2222
|
+
Build context string from attached files.
|
|
2223
|
+
|
|
2224
|
+
Returns:
|
|
2225
|
+
Formatted string with file contents
|
|
2226
|
+
"""
|
|
2227
|
+
files = self.get_attached_files()
|
|
2228
|
+
if not files:
|
|
2229
|
+
return ""
|
|
2230
|
+
|
|
2231
|
+
context_parts = ["=== Attached Files ===\n"]
|
|
2232
|
+
|
|
2233
|
+
for file_info in files:
|
|
2234
|
+
context_parts.append(f"File: {file_info['filename']} ({file_info['file_type']})")
|
|
2235
|
+
context_parts.append("")
|
|
2236
|
+
|
|
2237
|
+
# Add text content if available
|
|
2238
|
+
if file_info.get('content_text'):
|
|
2239
|
+
context_parts.append(file_info['content_text'])
|
|
2240
|
+
context_parts.append("")
|
|
2241
|
+
|
|
2242
|
+
# For images, just note that they're attached
|
|
2243
|
+
elif file_info.get('content_base64'):
|
|
2244
|
+
context_parts.append(f"[Image file: {file_info['mime_type']}]")
|
|
2245
|
+
context_parts.append("")
|
|
2246
|
+
|
|
2247
|
+
context_parts.append("---")
|
|
2248
|
+
context_parts.append("")
|
|
2249
|
+
|
|
2250
|
+
return '\n'.join(context_parts)
|
|
2251
|
+
|
|
2252
|
+
def export_conversation(self, file_path: str, format: str = 'markdown', include_tools: bool = True) -> bool:
|
|
2253
|
+
"""
|
|
2254
|
+
Export the current conversation to a file in specified format.
|
|
2255
|
+
|
|
2256
|
+
Args:
|
|
2257
|
+
file_path: Path to save the file
|
|
2258
|
+
format: Export format ('markdown', 'html', 'csv')
|
|
2259
|
+
include_tools: Whether to include tool use details
|
|
2260
|
+
|
|
2261
|
+
Returns:
|
|
2262
|
+
True if successful, False otherwise
|
|
2263
|
+
"""
|
|
2264
|
+
if format == 'markdown':
|
|
2265
|
+
return self._export_to_markdown(file_path, include_tools)
|
|
2266
|
+
elif format == 'html':
|
|
2267
|
+
return self._export_to_html(file_path, include_tools)
|
|
2268
|
+
elif format == 'csv':
|
|
2269
|
+
return self._export_to_csv(file_path, include_tools)
|
|
2270
|
+
else:
|
|
2271
|
+
logging.error(f"Unsupported export format: {format}")
|
|
2272
|
+
return False
|
|
2273
|
+
|
|
2274
|
+
def export_conversation_to_markdown(self, file_path: str) -> bool:
|
|
2275
|
+
"""
|
|
2276
|
+
Export the current conversation to a markdown file (legacy method).
|
|
2277
|
+
|
|
2278
|
+
Args:
|
|
2279
|
+
file_path: Path to save the markdown file
|
|
2280
|
+
|
|
2281
|
+
Returns:
|
|
2282
|
+
True if successful, False otherwise
|
|
2283
|
+
"""
|
|
2284
|
+
return self._export_to_markdown(file_path, include_tools=True)
|
|
2285
|
+
|
|
2286
|
+
def export_to_markdown(self, include_tool_details: bool = True) -> str:
|
|
2287
|
+
"""
|
|
2288
|
+
Export the current conversation to markdown format and return as string.
|
|
2289
|
+
|
|
2290
|
+
This method is designed for web API use where content is returned rather
|
|
2291
|
+
than written to a file.
|
|
2292
|
+
|
|
2293
|
+
Args:
|
|
2294
|
+
include_tool_details: Whether to include tool use details
|
|
2295
|
+
|
|
2296
|
+
Returns:
|
|
2297
|
+
Markdown-formatted string of the conversation
|
|
2298
|
+
"""
|
|
2299
|
+
return self._generate_markdown_content(include_tool_details)
|
|
2300
|
+
|
|
2301
|
+
def export_to_html(self, include_tool_details: bool = True) -> str:
|
|
2302
|
+
"""
|
|
2303
|
+
Export the current conversation to HTML format and return as string.
|
|
2304
|
+
|
|
2305
|
+
This method is designed for web API use where content is returned rather
|
|
2306
|
+
than written to a file.
|
|
2307
|
+
|
|
2308
|
+
Args:
|
|
2309
|
+
include_tool_details: Whether to include tool use details
|
|
2310
|
+
|
|
2311
|
+
Returns:
|
|
2312
|
+
HTML-formatted string of the conversation
|
|
2313
|
+
"""
|
|
2314
|
+
return self._generate_html_content(include_tool_details)
|
|
2315
|
+
|
|
2316
|
+
def export_to_csv(self, include_tool_details: bool = True) -> str:
|
|
2317
|
+
"""
|
|
2318
|
+
Export the current conversation to CSV format and return as string.
|
|
2319
|
+
|
|
2320
|
+
This method is designed for web API use where content is returned rather
|
|
2321
|
+
than written to a file.
|
|
2322
|
+
|
|
2323
|
+
Args:
|
|
2324
|
+
include_tool_details: Whether to include tool use details
|
|
2325
|
+
|
|
2326
|
+
Returns:
|
|
2327
|
+
CSV-formatted string of the conversation
|
|
2328
|
+
"""
|
|
2329
|
+
return self._generate_csv_content(include_tool_details)
|
|
2330
|
+
|
|
2331
|
+
def _generate_markdown_content(self, include_tools: bool = True) -> str:
|
|
2332
|
+
"""
|
|
2333
|
+
Generate markdown-formatted content for the current conversation.
|
|
2334
|
+
|
|
2335
|
+
Args:
|
|
2336
|
+
include_tools: Whether to include tool use details
|
|
2337
|
+
|
|
2338
|
+
Returns:
|
|
2339
|
+
Markdown-formatted string of the conversation
|
|
2340
|
+
"""
|
|
2341
|
+
if not self.current_conversation_id:
|
|
2342
|
+
logging.warning("No conversation loaded to export")
|
|
2343
|
+
return ""
|
|
2344
|
+
|
|
2345
|
+
# Get conversation info
|
|
2346
|
+
conv_info = self.get_current_conversation_info()
|
|
2347
|
+
if not conv_info:
|
|
2348
|
+
return ""
|
|
2349
|
+
|
|
2350
|
+
# Get messages (including rolled-up messages for complete history)
|
|
2351
|
+
messages = self.get_conversation_history(include_rolled_up=True)
|
|
2352
|
+
|
|
2353
|
+
# Build markdown content
|
|
2354
|
+
md_lines = []
|
|
2355
|
+
|
|
2356
|
+
# Header
|
|
2357
|
+
md_lines.append(f"# {conv_info['name']}")
|
|
2358
|
+
md_lines.append("")
|
|
2359
|
+
md_lines.append(f"**Model:** {conv_info['model_id']}")
|
|
2360
|
+
md_lines.append(f"**Created:** {conv_info['created_at']}")
|
|
2361
|
+
md_lines.append(f"**Last Updated:** {conv_info['last_updated']}")
|
|
2362
|
+
md_lines.append(f"**Total Tokens:** {conv_info['total_tokens']:,}")
|
|
2363
|
+
md_lines.append("")
|
|
2364
|
+
|
|
2365
|
+
# Include instructions if they exist
|
|
2366
|
+
if conv_info.get('instructions'):
|
|
2367
|
+
md_lines.append("## Instructions")
|
|
2368
|
+
md_lines.append("")
|
|
2369
|
+
md_lines.append(conv_info['instructions'])
|
|
2370
|
+
md_lines.append("")
|
|
2371
|
+
|
|
2372
|
+
# Include attached files if they exist
|
|
2373
|
+
attached_files = self.get_attached_files()
|
|
2374
|
+
if attached_files:
|
|
2375
|
+
md_lines.append("## Attached Files")
|
|
2376
|
+
md_lines.append("")
|
|
2377
|
+
for file_info in attached_files:
|
|
2378
|
+
size_kb = file_info['file_size'] / 1024
|
|
2379
|
+
md_lines.append(f"- **{file_info['filename']}** ({file_info['file_type']}, {size_kb:.1f} KB, {file_info['token_count']} tokens)")
|
|
2380
|
+
md_lines.append("")
|
|
2381
|
+
|
|
2382
|
+
md_lines.append("---")
|
|
2383
|
+
md_lines.append("")
|
|
2384
|
+
|
|
2385
|
+
# Messages
|
|
2386
|
+
for msg in messages:
|
|
2387
|
+
timestamp = datetime.fromisoformat(msg['timestamp'])
|
|
2388
|
+
role = msg['role'].capitalize()
|
|
2389
|
+
content = msg['content']
|
|
2390
|
+
|
|
2391
|
+
# Detect special message types
|
|
2392
|
+
is_rollup_summary = content.startswith('[Summary of previous conversation]')
|
|
2393
|
+
is_tool_result = content.startswith('[TOOL_RESULTS]')
|
|
2394
|
+
|
|
2395
|
+
# Check if this is a tool call message (assistant with tool_use blocks)
|
|
2396
|
+
is_tool_call = False
|
|
2397
|
+
if role.lower() == 'assistant':
|
|
2398
|
+
try:
|
|
2399
|
+
content_blocks = json.loads(content)
|
|
2400
|
+
if isinstance(content_blocks, list) and any(block.get('type') == 'tool_use' for block in content_blocks):
|
|
2401
|
+
is_tool_call = True
|
|
2402
|
+
except:
|
|
2403
|
+
pass
|
|
2404
|
+
|
|
2405
|
+
# Format role header based on message type
|
|
2406
|
+
if is_rollup_summary:
|
|
2407
|
+
md_lines.append(f"## 📋 Rollup Summary")
|
|
2408
|
+
elif is_tool_result:
|
|
2409
|
+
md_lines.append(f"## 🔧 Tool Results")
|
|
2410
|
+
elif is_tool_call:
|
|
2411
|
+
md_lines.append(f"## 🤖 Assistant (with Tool Calls)")
|
|
2412
|
+
elif role.lower() == 'user':
|
|
2413
|
+
md_lines.append(f"## 👤 {role}")
|
|
2414
|
+
elif role.lower() == 'assistant':
|
|
2415
|
+
md_lines.append(f"## 🤖 {role}")
|
|
2416
|
+
else:
|
|
2417
|
+
md_lines.append(f"## {role}")
|
|
2418
|
+
|
|
2419
|
+
md_lines.append(f"*{timestamp.strftime('%Y-%m-%d %H:%M:%S')}*")
|
|
2420
|
+
md_lines.append("")
|
|
2421
|
+
|
|
2422
|
+
# Clean up content if it's tool-related
|
|
2423
|
+
if content.startswith('[TOOL_RESULTS]') and include_tools:
|
|
2424
|
+
# Parse and format tool results
|
|
2425
|
+
try:
|
|
2426
|
+
tool_results_json = content.replace('[TOOL_RESULTS]', '', 1)
|
|
2427
|
+
tool_results = json.loads(tool_results_json)
|
|
2428
|
+
md_lines.append("**Tool Results:**")
|
|
2429
|
+
md_lines.append("")
|
|
2430
|
+
for result in tool_results:
|
|
2431
|
+
md_lines.append(f"- Tool: `{result.get('tool_use_id', 'unknown')}`")
|
|
2432
|
+
md_lines.append(f" Result: {result.get('content', '')}")
|
|
2433
|
+
md_lines.append("")
|
|
2434
|
+
except:
|
|
2435
|
+
md_lines.append(content)
|
|
2436
|
+
md_lines.append("")
|
|
2437
|
+
elif content.startswith('[TOOL_RESULTS]') and not include_tools:
|
|
2438
|
+
# Skip tool results if not including tools
|
|
2439
|
+
md_lines.append("*[Tool execution details omitted]*")
|
|
2440
|
+
md_lines.append("")
|
|
2441
|
+
elif content.startswith('['):
|
|
2442
|
+
# Try to parse as JSON (tool use blocks)
|
|
2443
|
+
try:
|
|
2444
|
+
content_blocks = json.loads(content)
|
|
2445
|
+
for block in content_blocks:
|
|
2446
|
+
if block.get('type') == 'text':
|
|
2447
|
+
md_lines.append(block.get('text', ''))
|
|
2448
|
+
md_lines.append("")
|
|
2449
|
+
elif block.get('type') == 'tool_use' and include_tools:
|
|
2450
|
+
md_lines.append(f"**Tool Call:** `{block.get('name')}`")
|
|
2451
|
+
md_lines.append(f"**Input:** {json.dumps(block.get('input', {}), indent=2)}")
|
|
2452
|
+
md_lines.append("")
|
|
2453
|
+
except:
|
|
2454
|
+
md_lines.append(content)
|
|
2455
|
+
md_lines.append("")
|
|
2456
|
+
else:
|
|
2457
|
+
md_lines.append(content)
|
|
2458
|
+
md_lines.append("")
|
|
2459
|
+
|
|
2460
|
+
md_lines.append("---")
|
|
2461
|
+
md_lines.append("")
|
|
2462
|
+
|
|
2463
|
+
# Return the joined content
|
|
2464
|
+
return '\n'.join(md_lines)
|
|
2465
|
+
|
|
2466
|
+
def _export_to_markdown(self, file_path: str, include_tools: bool = True) -> bool:
|
|
2467
|
+
"""
|
|
2468
|
+
Export the current conversation to a markdown file.
|
|
2469
|
+
|
|
2470
|
+
Args:
|
|
2471
|
+
file_path: Path to save the markdown file
|
|
2472
|
+
include_tools: Whether to include tool use details
|
|
2473
|
+
|
|
2474
|
+
Returns:
|
|
2475
|
+
True if successful, False otherwise
|
|
2476
|
+
"""
|
|
2477
|
+
try:
|
|
2478
|
+
content = self._generate_markdown_content(include_tools)
|
|
2479
|
+
if not content:
|
|
2480
|
+
return False
|
|
2481
|
+
|
|
2482
|
+
# Write to file
|
|
2483
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
2484
|
+
f.write(content)
|
|
2485
|
+
|
|
2486
|
+
logging.info(f"Exported conversation to {file_path}")
|
|
2487
|
+
return True
|
|
2488
|
+
|
|
2489
|
+
except Exception as e:
|
|
2490
|
+
logging.error(f"Failed to export conversation: {e}")
|
|
2491
|
+
return False
|
|
2492
|
+
|
|
2493
|
+
def _generate_html_content(self, include_tools: bool = True) -> str:
|
|
2494
|
+
"""
|
|
2495
|
+
Generate HTML-formatted content for the current conversation.
|
|
2496
|
+
|
|
2497
|
+
Args:
|
|
2498
|
+
include_tools: Whether to include tool use details
|
|
2499
|
+
|
|
2500
|
+
Returns:
|
|
2501
|
+
HTML-formatted string of the conversation
|
|
2502
|
+
"""
|
|
2503
|
+
import tempfile
|
|
2504
|
+
import os
|
|
2505
|
+
|
|
2506
|
+
# Use temporary file approach to reuse existing export logic
|
|
2507
|
+
try:
|
|
2508
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.html', delete=False, encoding='utf-8') as tmp:
|
|
2509
|
+
tmp_path = tmp.name
|
|
2510
|
+
|
|
2511
|
+
# Export to temporary file
|
|
2512
|
+
success = self._export_to_html(tmp_path, include_tools)
|
|
2513
|
+
if not success:
|
|
2514
|
+
return ""
|
|
2515
|
+
|
|
2516
|
+
# Read content back
|
|
2517
|
+
with open(tmp_path, 'r', encoding='utf-8') as f:
|
|
2518
|
+
content = f.read()
|
|
2519
|
+
|
|
2520
|
+
# Clean up temporary file
|
|
2521
|
+
os.unlink(tmp_path)
|
|
2522
|
+
|
|
2523
|
+
return content
|
|
2524
|
+
|
|
2525
|
+
except Exception as e:
|
|
2526
|
+
logging.error(f"Failed to generate HTML content: {e}")
|
|
2527
|
+
return ""
|
|
2528
|
+
|
|
2529
|
+
def _generate_csv_content(self, include_tools: bool = True) -> str:
|
|
2530
|
+
"""
|
|
2531
|
+
Generate CSV-formatted content for the current conversation.
|
|
2532
|
+
|
|
2533
|
+
Args:
|
|
2534
|
+
include_tools: Whether to include tool use details
|
|
2535
|
+
|
|
2536
|
+
Returns:
|
|
2537
|
+
CSV-formatted string of the conversation
|
|
2538
|
+
"""
|
|
2539
|
+
import tempfile
|
|
2540
|
+
import os
|
|
2541
|
+
|
|
2542
|
+
# Use temporary file approach to reuse existing export logic
|
|
2543
|
+
try:
|
|
2544
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False, encoding='utf-8') as tmp:
|
|
2545
|
+
tmp_path = tmp.name
|
|
2546
|
+
|
|
2547
|
+
# Export to temporary file
|
|
2548
|
+
success = self._export_to_csv(tmp_path, include_tools)
|
|
2549
|
+
if not success:
|
|
2550
|
+
return ""
|
|
2551
|
+
|
|
2552
|
+
# Read content back
|
|
2553
|
+
with open(tmp_path, 'r', encoding='utf-8') as f:
|
|
2554
|
+
content = f.read()
|
|
2555
|
+
|
|
2556
|
+
# Clean up temporary file
|
|
2557
|
+
os.unlink(tmp_path)
|
|
2558
|
+
|
|
2559
|
+
return content
|
|
2560
|
+
|
|
2561
|
+
except Exception as e:
|
|
2562
|
+
logging.error(f"Failed to generate CSV content: {e}")
|
|
2563
|
+
return ""
|
|
2564
|
+
|
|
2565
|
+
def _export_to_html(self, file_path: str, include_tools: bool = True) -> bool:
|
|
2566
|
+
"""
|
|
2567
|
+
Export the current conversation to an HTML file with chat styling.
|
|
2568
|
+
|
|
2569
|
+
Args:
|
|
2570
|
+
file_path: Path to save the HTML file
|
|
2571
|
+
include_tools: Whether to include tool use details
|
|
2572
|
+
|
|
2573
|
+
Returns:
|
|
2574
|
+
True if successful, False otherwise
|
|
2575
|
+
"""
|
|
2576
|
+
if not self.current_conversation_id:
|
|
2577
|
+
logging.warning("No conversation loaded to export")
|
|
2578
|
+
return False
|
|
2579
|
+
|
|
2580
|
+
try:
|
|
2581
|
+
# Get conversation info
|
|
2582
|
+
conv_info = self.get_current_conversation_info()
|
|
2583
|
+
if not conv_info:
|
|
2584
|
+
return False
|
|
2585
|
+
|
|
2586
|
+
# Get messages (including rolled-up messages for complete history)
|
|
2587
|
+
messages = self.get_conversation_history(include_rolled_up=True)
|
|
2588
|
+
|
|
2589
|
+
# Build HTML content
|
|
2590
|
+
html_parts = []
|
|
2591
|
+
|
|
2592
|
+
# HTML header with styling
|
|
2593
|
+
html_parts.append('''<!DOCTYPE html>
|
|
2594
|
+
<html lang="en">
|
|
2595
|
+
<head>
|
|
2596
|
+
<meta charset="UTF-8">
|
|
2597
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2598
|
+
<title>''' + conv_info['name'] + '''</title>
|
|
2599
|
+
<style>
|
|
2600
|
+
* {
|
|
2601
|
+
margin: 0;
|
|
2602
|
+
padding: 0;
|
|
2603
|
+
box-sizing: border-box;
|
|
2604
|
+
}
|
|
2605
|
+
body {
|
|
2606
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
2607
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
2608
|
+
padding: 20px;
|
|
2609
|
+
line-height: 1.6;
|
|
2610
|
+
}
|
|
2611
|
+
.container {
|
|
2612
|
+
max-width: 900px;
|
|
2613
|
+
margin: 0 auto;
|
|
2614
|
+
background: white;
|
|
2615
|
+
border-radius: 12px;
|
|
2616
|
+
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
|
|
2617
|
+
overflow: hidden;
|
|
2618
|
+
}
|
|
2619
|
+
.header {
|
|
2620
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
2621
|
+
color: white;
|
|
2622
|
+
padding: 30px;
|
|
2623
|
+
text-align: center;
|
|
2624
|
+
}
|
|
2625
|
+
.header h1 {
|
|
2626
|
+
font-size: 28px;
|
|
2627
|
+
margin-bottom: 10px;
|
|
2628
|
+
}
|
|
2629
|
+
.header .metadata {
|
|
2630
|
+
font-size: 14px;
|
|
2631
|
+
opacity: 0.9;
|
|
2632
|
+
}
|
|
2633
|
+
.chat-container {
|
|
2634
|
+
padding: 20px;
|
|
2635
|
+
max-height: 80vh;
|
|
2636
|
+
overflow-y: auto;
|
|
2637
|
+
}
|
|
2638
|
+
.message {
|
|
2639
|
+
margin-bottom: 20px;
|
|
2640
|
+
animation: fadeIn 0.3s ease-in;
|
|
2641
|
+
}
|
|
2642
|
+
@keyframes fadeIn {
|
|
2643
|
+
from { opacity: 0; transform: translateY(10px); }
|
|
2644
|
+
to { opacity: 1; transform: translateY(0); }
|
|
2645
|
+
}
|
|
2646
|
+
.message-user {
|
|
2647
|
+
display: flex;
|
|
2648
|
+
justify-content: flex-end;
|
|
2649
|
+
}
|
|
2650
|
+
.message-assistant {
|
|
2651
|
+
display: flex;
|
|
2652
|
+
justify-content: flex-start;
|
|
2653
|
+
}
|
|
2654
|
+
.message-bubble {
|
|
2655
|
+
max-width: 70%;
|
|
2656
|
+
padding: 15px 20px;
|
|
2657
|
+
border-radius: 18px;
|
|
2658
|
+
position: relative;
|
|
2659
|
+
}
|
|
2660
|
+
.message-user .message-bubble {
|
|
2661
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
2662
|
+
color: white;
|
|
2663
|
+
}
|
|
2664
|
+
.message-assistant .message-bubble {
|
|
2665
|
+
background: #f0f0f0;
|
|
2666
|
+
color: #333;
|
|
2667
|
+
}
|
|
2668
|
+
.message-tool-result {
|
|
2669
|
+
display: flex;
|
|
2670
|
+
justify-content: center;
|
|
2671
|
+
}
|
|
2672
|
+
.message-tool-result .message-bubble {
|
|
2673
|
+
background: #fff9e6;
|
|
2674
|
+
color: #333;
|
|
2675
|
+
border: 1px solid #ffc107;
|
|
2676
|
+
max-width: 85%;
|
|
2677
|
+
}
|
|
2678
|
+
.message-rollup {
|
|
2679
|
+
display: flex;
|
|
2680
|
+
justify-content: center;
|
|
2681
|
+
}
|
|
2682
|
+
.message-rollup .message-bubble {
|
|
2683
|
+
background: #e8f5e9;
|
|
2684
|
+
color: #333;
|
|
2685
|
+
border: 1px solid #4caf50;
|
|
2686
|
+
max-width: 85%;
|
|
2687
|
+
font-style: italic;
|
|
2688
|
+
}
|
|
2689
|
+
.message-tool-call {
|
|
2690
|
+
display: flex;
|
|
2691
|
+
justify-content: flex-start;
|
|
2692
|
+
}
|
|
2693
|
+
.message-tool-call .message-bubble {
|
|
2694
|
+
background: #e3f2fd;
|
|
2695
|
+
color: #333;
|
|
2696
|
+
border: 1px solid #2196f3;
|
|
2697
|
+
}
|
|
2698
|
+
.message-role {
|
|
2699
|
+
font-weight: bold;
|
|
2700
|
+
font-size: 12px;
|
|
2701
|
+
margin-bottom: 5px;
|
|
2702
|
+
opacity: 0.8;
|
|
2703
|
+
}
|
|
2704
|
+
.message-timestamp {
|
|
2705
|
+
font-size: 11px;
|
|
2706
|
+
opacity: 0.6;
|
|
2707
|
+
margin-top: 5px;
|
|
2708
|
+
}
|
|
2709
|
+
.message-content {
|
|
2710
|
+
white-space: pre-wrap;
|
|
2711
|
+
word-wrap: break-word;
|
|
2712
|
+
}
|
|
2713
|
+
.tool-section {
|
|
2714
|
+
background: transparent;
|
|
2715
|
+
border-left: none;
|
|
2716
|
+
padding: 0;
|
|
2717
|
+
margin-top: 0;
|
|
2718
|
+
border-radius: 0;
|
|
2719
|
+
font-size: 13px;
|
|
2720
|
+
}
|
|
2721
|
+
.tool-section .tool-title {
|
|
2722
|
+
font-weight: bold;
|
|
2723
|
+
margin-bottom: 10px;
|
|
2724
|
+
color: #f57c00;
|
|
2725
|
+
}
|
|
2726
|
+
.tool-call {
|
|
2727
|
+
background: #e3f2fd;
|
|
2728
|
+
border-left: 4px solid #2196f3;
|
|
2729
|
+
padding: 10px;
|
|
2730
|
+
margin-top: 10px;
|
|
2731
|
+
border-radius: 4px;
|
|
2732
|
+
font-size: 13px;
|
|
2733
|
+
}
|
|
2734
|
+
.tool-title {
|
|
2735
|
+
font-weight: bold;
|
|
2736
|
+
margin-bottom: 5px;
|
|
2737
|
+
}
|
|
2738
|
+
code {
|
|
2739
|
+
background: rgba(0,0,0,0.05);
|
|
2740
|
+
padding: 2px 6px;
|
|
2741
|
+
border-radius: 3px;
|
|
2742
|
+
font-family: 'Courier New', monospace;
|
|
2743
|
+
font-size: 13px;
|
|
2744
|
+
}
|
|
2745
|
+
pre {
|
|
2746
|
+
background: rgba(0,0,0,0.05);
|
|
2747
|
+
padding: 10px;
|
|
2748
|
+
border-radius: 4px;
|
|
2749
|
+
overflow-x: auto;
|
|
2750
|
+
margin: 10px 0;
|
|
2751
|
+
}
|
|
2752
|
+
.info-section {
|
|
2753
|
+
background: #f8f9fa;
|
|
2754
|
+
padding: 20px;
|
|
2755
|
+
border-top: 1px solid #dee2e6;
|
|
2756
|
+
}
|
|
2757
|
+
.info-section h3 {
|
|
2758
|
+
margin-bottom: 10px;
|
|
2759
|
+
color: #667eea;
|
|
2760
|
+
}
|
|
2761
|
+
.info-section ul {
|
|
2762
|
+
list-style: none;
|
|
2763
|
+
}
|
|
2764
|
+
.info-section li {
|
|
2765
|
+
padding: 5px 0;
|
|
2766
|
+
}
|
|
2767
|
+
</style>
|
|
2768
|
+
</head>
|
|
2769
|
+
<body>
|
|
2770
|
+
<div class="container">
|
|
2771
|
+
<div class="header">
|
|
2772
|
+
<h1>''' + conv_info['name'] + '''</h1>
|
|
2773
|
+
<div class="metadata">
|
|
2774
|
+
<div>Model: ''' + conv_info['model_id'] + '''</div>
|
|
2775
|
+
<div>Created: ''' + conv_info['created_at'] + '''</div>
|
|
2776
|
+
<div>Total Tokens: ''' + f"{conv_info['total_tokens']:,}" + '''</div>
|
|
2777
|
+
</div>
|
|
2778
|
+
</div>
|
|
2779
|
+
''')
|
|
2780
|
+
|
|
2781
|
+
# Instructions section if exists
|
|
2782
|
+
if conv_info.get('instructions'):
|
|
2783
|
+
html_parts.append('''
|
|
2784
|
+
<div class="info-section">
|
|
2785
|
+
<h3>Instructions</h3>
|
|
2786
|
+
<p>''' + conv_info['instructions'].replace('\n', '<br>') + '''</p>
|
|
2787
|
+
</div>
|
|
2788
|
+
''')
|
|
2789
|
+
|
|
2790
|
+
# Attached files if exist
|
|
2791
|
+
attached_files = self.get_attached_files()
|
|
2792
|
+
if attached_files:
|
|
2793
|
+
html_parts.append('''
|
|
2794
|
+
<div class="info-section">
|
|
2795
|
+
<h3>Attached Files</h3>
|
|
2796
|
+
<ul>
|
|
2797
|
+
''')
|
|
2798
|
+
for file_info in attached_files:
|
|
2799
|
+
size_kb = file_info['file_size'] / 1024
|
|
2800
|
+
html_parts.append(f''' <li><strong>{file_info['filename']}</strong> ({file_info['file_type']}, {size_kb:.1f} KB, {file_info['token_count']} tokens)</li>
|
|
2801
|
+
''')
|
|
2802
|
+
html_parts.append(''' </ul>
|
|
2803
|
+
</div>
|
|
2804
|
+
''')
|
|
2805
|
+
|
|
2806
|
+
# Chat messages
|
|
2807
|
+
html_parts.append('''
|
|
2808
|
+
<div class="chat-container">
|
|
2809
|
+
''')
|
|
2810
|
+
|
|
2811
|
+
for msg in messages:
|
|
2812
|
+
timestamp = datetime.fromisoformat(msg['timestamp'])
|
|
2813
|
+
role = msg['role'].capitalize()
|
|
2814
|
+
content = msg['content']
|
|
2815
|
+
|
|
2816
|
+
# Detect special message types
|
|
2817
|
+
is_rollup_summary = content.startswith('[Summary of previous conversation]')
|
|
2818
|
+
is_tool_result = content.startswith('[TOOL_RESULTS]')
|
|
2819
|
+
|
|
2820
|
+
# Check if this is a tool call message (assistant with tool_use blocks)
|
|
2821
|
+
is_tool_call = False
|
|
2822
|
+
if role.lower() == 'assistant' and not is_tool_result:
|
|
2823
|
+
try:
|
|
2824
|
+
content_blocks = json.loads(content)
|
|
2825
|
+
if isinstance(content_blocks, list) and any(block.get('type') == 'tool_use' for block in content_blocks):
|
|
2826
|
+
is_tool_call = True
|
|
2827
|
+
except:
|
|
2828
|
+
pass
|
|
2829
|
+
|
|
2830
|
+
# Assign message class and labels based on type
|
|
2831
|
+
if is_rollup_summary:
|
|
2832
|
+
message_class = "message-rollup"
|
|
2833
|
+
role_icon = "📋"
|
|
2834
|
+
role_label = "Rollup Summary"
|
|
2835
|
+
elif is_tool_result:
|
|
2836
|
+
message_class = "message-tool-result"
|
|
2837
|
+
role_icon = "🔧"
|
|
2838
|
+
role_label = "Tool Results"
|
|
2839
|
+
elif is_tool_call:
|
|
2840
|
+
message_class = "message-tool-call"
|
|
2841
|
+
role_icon = "🛠️"
|
|
2842
|
+
role_label = "Assistant (Tool Calls)"
|
|
2843
|
+
else:
|
|
2844
|
+
message_class = f"message-{msg['role']}"
|
|
2845
|
+
role_icon = '👤 ' if role == 'User' else '🤖 '
|
|
2846
|
+
role_label = role
|
|
2847
|
+
|
|
2848
|
+
html_parts.append(f'''
|
|
2849
|
+
<div class="message {message_class}">
|
|
2850
|
+
<div class="message-bubble">
|
|
2851
|
+
<div class="message-role">{role_icon}{role_label}</div>
|
|
2852
|
+
<div class="message-timestamp">{timestamp.strftime('%Y-%m-%d %H:%M:%S')}</div>
|
|
2853
|
+
<div class="message-content">
|
|
2854
|
+
''')
|
|
2855
|
+
|
|
2856
|
+
# Process content
|
|
2857
|
+
if content.startswith('[TOOL_RESULTS]') and include_tools:
|
|
2858
|
+
try:
|
|
2859
|
+
tool_results_json = content.replace('[TOOL_RESULTS]', '', 1)
|
|
2860
|
+
tool_results = json.loads(tool_results_json)
|
|
2861
|
+
html_parts.append(''' <div class="tool-section">
|
|
2862
|
+
<div class="tool-title">🔧 Tool Results:</div>
|
|
2863
|
+
''')
|
|
2864
|
+
for idx, result in enumerate(tool_results, 1):
|
|
2865
|
+
result_content = result.get('content', '')
|
|
2866
|
+
# Truncate very long results for display
|
|
2867
|
+
if len(result_content) > 500:
|
|
2868
|
+
result_content = result_content[:500] + '... [truncated]'
|
|
2869
|
+
html_parts.append(f''' <div style="margin-bottom: 15px; padding: 10px; background: rgba(255,255,255,0.5); border-radius: 4px;">
|
|
2870
|
+
<div style="font-weight: bold; color: #f57c00; margin-bottom: 5px;">Result {idx}</div>
|
|
2871
|
+
<div style="font-size: 12px; color: #666; margin-bottom: 5px;">Tool ID: <code style="background: rgba(0,0,0,0.05); padding: 2px 4px; border-radius: 2px;">{result.get('tool_use_id', 'unknown')}</code></div>
|
|
2872
|
+
<div style="white-space: pre-wrap; word-wrap: break-word;">{result_content.replace('<', '<').replace('>', '>')}</div>
|
|
2873
|
+
</div>
|
|
2874
|
+
''')
|
|
2875
|
+
html_parts.append(''' </div>
|
|
2876
|
+
''')
|
|
2877
|
+
except:
|
|
2878
|
+
html_parts.append(f''' {content.replace('<', '<').replace('>', '>')}
|
|
2879
|
+
''')
|
|
2880
|
+
elif content.startswith('[TOOL_RESULTS]') and not include_tools:
|
|
2881
|
+
html_parts.append(''' <em>[Tool execution details omitted]</em>
|
|
2882
|
+
''')
|
|
2883
|
+
elif content.startswith('['):
|
|
2884
|
+
try:
|
|
2885
|
+
content_blocks = json.loads(content)
|
|
2886
|
+
for block in content_blocks:
|
|
2887
|
+
if block.get('type') == 'text':
|
|
2888
|
+
html_parts.append(f''' {block.get('text', '').replace('<', '<').replace('>', '>')}
|
|
2889
|
+
''')
|
|
2890
|
+
elif block.get('type') == 'tool_use' and include_tools:
|
|
2891
|
+
html_parts.append(f''' <div class="tool-call">
|
|
2892
|
+
<div class="tool-title">Tool Call: <code>{block.get('name')}</code></div>
|
|
2893
|
+
<pre>{json.dumps(block.get('input', {}), indent=2)}</pre>
|
|
2894
|
+
</div>
|
|
2895
|
+
''')
|
|
2896
|
+
except:
|
|
2897
|
+
html_parts.append(f''' {content.replace('<', '<').replace('>', '>')}
|
|
2898
|
+
''')
|
|
2899
|
+
else:
|
|
2900
|
+
escaped_content = content.replace('<', '<').replace('>', '>').replace('\n', '<br>')
|
|
2901
|
+
html_parts.append(f''' {escaped_content}
|
|
2902
|
+
''')
|
|
2903
|
+
|
|
2904
|
+
html_parts.append(''' </div>
|
|
2905
|
+
</div>
|
|
2906
|
+
</div>
|
|
2907
|
+
''')
|
|
2908
|
+
|
|
2909
|
+
html_parts.append('''
|
|
2910
|
+
</div>
|
|
2911
|
+
</div>
|
|
2912
|
+
</body>
|
|
2913
|
+
</html>
|
|
2914
|
+
''')
|
|
2915
|
+
|
|
2916
|
+
# Write to file
|
|
2917
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
2918
|
+
f.write(''.join(html_parts))
|
|
2919
|
+
|
|
2920
|
+
logging.info(f"Exported conversation to HTML: {file_path}")
|
|
2921
|
+
return True
|
|
2922
|
+
|
|
2923
|
+
except Exception as e:
|
|
2924
|
+
logging.error(f"Failed to export conversation to HTML: {e}")
|
|
2925
|
+
return False
|
|
2926
|
+
|
|
2927
|
+
def _export_to_csv(self, file_path: str, include_tools: bool = True) -> bool:
|
|
2928
|
+
"""
|
|
2929
|
+
Export the current conversation to a CSV file.
|
|
2930
|
+
|
|
2931
|
+
Args:
|
|
2932
|
+
file_path: Path to save the CSV file
|
|
2933
|
+
include_tools: Whether to include tool use details
|
|
2934
|
+
|
|
2935
|
+
Returns:
|
|
2936
|
+
True if successful, False otherwise
|
|
2937
|
+
"""
|
|
2938
|
+
if not self.current_conversation_id:
|
|
2939
|
+
logging.warning("No conversation loaded to export")
|
|
2940
|
+
return False
|
|
2941
|
+
|
|
2942
|
+
try:
|
|
2943
|
+
import csv
|
|
2944
|
+
|
|
2945
|
+
# Get conversation info
|
|
2946
|
+
conv_info = self.get_current_conversation_info()
|
|
2947
|
+
if not conv_info:
|
|
2948
|
+
return False
|
|
2949
|
+
|
|
2950
|
+
# Get messages (including rolled-up messages for complete history)
|
|
2951
|
+
messages = self.get_conversation_history(include_rolled_up=True)
|
|
2952
|
+
|
|
2953
|
+
with open(file_path, 'w', newline='', encoding='utf-8') as csvfile:
|
|
2954
|
+
fieldnames = ['Timestamp', 'Type', 'Role', 'Content', 'Token Count']
|
|
2955
|
+
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
|
2956
|
+
|
|
2957
|
+
# Write header
|
|
2958
|
+
writer.writeheader()
|
|
2959
|
+
|
|
2960
|
+
# Write metadata as comments
|
|
2961
|
+
writer.writerow({
|
|
2962
|
+
'Timestamp': 'METADATA',
|
|
2963
|
+
'Type': '',
|
|
2964
|
+
'Role': 'Conversation',
|
|
2965
|
+
'Content': conv_info['name'],
|
|
2966
|
+
'Token Count': ''
|
|
2967
|
+
})
|
|
2968
|
+
writer.writerow({
|
|
2969
|
+
'Timestamp': 'METADATA',
|
|
2970
|
+
'Type': '',
|
|
2971
|
+
'Role': 'Model',
|
|
2972
|
+
'Content': conv_info['model_id'],
|
|
2973
|
+
'Token Count': ''
|
|
2974
|
+
})
|
|
2975
|
+
writer.writerow({
|
|
2976
|
+
'Timestamp': 'METADATA',
|
|
2977
|
+
'Type': '',
|
|
2978
|
+
'Role': 'Total Tokens',
|
|
2979
|
+
'Content': str(conv_info['total_tokens']),
|
|
2980
|
+
'Token Count': ''
|
|
2981
|
+
})
|
|
2982
|
+
|
|
2983
|
+
# Write messages
|
|
2984
|
+
for msg in messages:
|
|
2985
|
+
timestamp = datetime.fromisoformat(msg['timestamp']).strftime('%Y-%m-%d %H:%M:%S')
|
|
2986
|
+
role = msg['role'].capitalize()
|
|
2987
|
+
content = msg['content']
|
|
2988
|
+
|
|
2989
|
+
# Detect special message types
|
|
2990
|
+
is_rollup_summary = content.startswith('[Summary of previous conversation]')
|
|
2991
|
+
is_tool_result = content.startswith('[TOOL_RESULTS]')
|
|
2992
|
+
|
|
2993
|
+
# Check if this is a tool call message
|
|
2994
|
+
is_tool_call = False
|
|
2995
|
+
if role.lower() == 'assistant' and not is_tool_result:
|
|
2996
|
+
try:
|
|
2997
|
+
content_blocks = json.loads(content)
|
|
2998
|
+
if isinstance(content_blocks, list) and any(block.get('type') == 'tool_use' for block in content_blocks):
|
|
2999
|
+
is_tool_call = True
|
|
3000
|
+
except:
|
|
3001
|
+
pass
|
|
3002
|
+
|
|
3003
|
+
# Assign type label
|
|
3004
|
+
if is_rollup_summary:
|
|
3005
|
+
msg_type = 'Rollup Summary'
|
|
3006
|
+
elif is_tool_result:
|
|
3007
|
+
msg_type = 'Tool Results'
|
|
3008
|
+
elif is_tool_call:
|
|
3009
|
+
msg_type = 'Tool Call'
|
|
3010
|
+
else:
|
|
3011
|
+
msg_type = 'Message'
|
|
3012
|
+
|
|
3013
|
+
# Process tool-related content
|
|
3014
|
+
if content.startswith('[TOOL_RESULTS]'):
|
|
3015
|
+
if include_tools:
|
|
3016
|
+
try:
|
|
3017
|
+
tool_results_json = content.replace('[TOOL_RESULTS]', '', 1)
|
|
3018
|
+
tool_results = json.loads(tool_results_json)
|
|
3019
|
+
content = f"Tool Results: {json.dumps(tool_results, indent=2)}"
|
|
3020
|
+
except:
|
|
3021
|
+
pass
|
|
3022
|
+
else:
|
|
3023
|
+
content = "[Tool execution details omitted]"
|
|
3024
|
+
elif content.startswith('['):
|
|
3025
|
+
try:
|
|
3026
|
+
content_blocks = json.loads(content)
|
|
3027
|
+
text_parts = []
|
|
3028
|
+
for block in content_blocks:
|
|
3029
|
+
if block.get('type') == 'text':
|
|
3030
|
+
text_parts.append(block.get('text', ''))
|
|
3031
|
+
elif block.get('type') == 'tool_use' and include_tools:
|
|
3032
|
+
text_parts.append(f"Tool Call: {block.get('name')} - Input: {json.dumps(block.get('input', {}))}")
|
|
3033
|
+
content = '\n'.join(text_parts) if text_parts else content
|
|
3034
|
+
except:
|
|
3035
|
+
pass
|
|
3036
|
+
|
|
3037
|
+
writer.writerow({
|
|
3038
|
+
'Timestamp': timestamp,
|
|
3039
|
+
'Type': msg_type,
|
|
3040
|
+
'Role': role,
|
|
3041
|
+
'Content': content,
|
|
3042
|
+
'Token Count': msg.get('token_count', '')
|
|
3043
|
+
})
|
|
3044
|
+
|
|
3045
|
+
logging.info(f"Exported conversation to CSV: {file_path}")
|
|
3046
|
+
return True
|
|
3047
|
+
|
|
3048
|
+
except Exception as e:
|
|
3049
|
+
logging.error(f"Failed to export conversation to CSV: {e}")
|
|
3050
|
+
return False
|