webex-bot-ai 0.1.0__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.
- src/__init__.py +1 -0
- src/commands/__init__.py +5 -0
- src/commands/ai.py +653 -0
- src/config.py +293 -0
- src/conversation/__init__.py +7 -0
- src/conversation/manager.py +387 -0
- src/conversation/persistence.py +260 -0
- src/conversation/types.py +19 -0
- src/main.py +231 -0
- src/mcp/__init__.py +6 -0
- src/mcp/client.py +278 -0
- src/mcp/types.py +54 -0
- src/sentry.py +170 -0
- webex_bot_ai-0.1.0.dist-info/METADATA +252 -0
- webex_bot_ai-0.1.0.dist-info/RECORD +18 -0
- webex_bot_ai-0.1.0.dist-info/WHEEL +4 -0
- webex_bot_ai-0.1.0.dist-info/entry_points.txt +2 -0
- webex_bot_ai-0.1.0.dist-info/licenses/LICENSE +21 -0
src/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Webex AI Bot - A conversational AI bot with MCP support."""
|
src/commands/__init__.py
ADDED
src/commands/ai.py
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
"""AI command for handling user questions with LiteLLM and thread context."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import concurrent.futures
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import litellm
|
|
11
|
+
from webex_bot.formatting import quote_info
|
|
12
|
+
from webex_bot.models.command import Command
|
|
13
|
+
|
|
14
|
+
from src.config import settings
|
|
15
|
+
from src.conversation.manager import ConversationManager
|
|
16
|
+
from src.mcp.client import MCPMultiClient
|
|
17
|
+
from src.mcp.types import MCPToolResult
|
|
18
|
+
from src.sentry import capture_exception, set_tag
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Thread pool executor for running async MCP operations
|
|
23
|
+
_executor = concurrent.futures.ThreadPoolExecutor(
|
|
24
|
+
max_workers=4, thread_name_prefix="mcp"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Default configuration for all Ollama models
|
|
28
|
+
# All Ollama models support function calling and use chat mode by default
|
|
29
|
+
_OLLAMA_MODEL_DEFAULTS = {
|
|
30
|
+
"supports_function_calling": True,
|
|
31
|
+
"mode": "chat",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Register Ollama models with LiteLLM
|
|
35
|
+
# This applies to all common Ollama model patterns
|
|
36
|
+
_model_registry = {
|
|
37
|
+
"ollama": _OLLAMA_MODEL_DEFAULTS,
|
|
38
|
+
"ollama_chat": _OLLAMA_MODEL_DEFAULTS,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
litellm.register_model(model_cost=_model_registry)
|
|
43
|
+
log.info(
|
|
44
|
+
"Registered Ollama model defaults: supports_function_calling=True, mode=chat"
|
|
45
|
+
)
|
|
46
|
+
except Exception as e:
|
|
47
|
+
log.warning("Failed to register Ollama model defaults: %s", e)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _run_async_in_thread(coro):
|
|
51
|
+
"""Run an async coroutine in a separate thread with its own event loop.
|
|
52
|
+
|
|
53
|
+
This allows async operations to run without conflicting with
|
|
54
|
+
the main event loop used by webex_bot.
|
|
55
|
+
"""
|
|
56
|
+
loop = asyncio.new_event_loop()
|
|
57
|
+
try:
|
|
58
|
+
return loop.run_until_complete(coro)
|
|
59
|
+
finally:
|
|
60
|
+
loop.close()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AICommand(Command):
|
|
64
|
+
"""Command that handles AI-powered responses with thread context.
|
|
65
|
+
|
|
66
|
+
Features:
|
|
67
|
+
- Maintains conversation history per thread
|
|
68
|
+
- Properly handles bot name mentions without confusing the AI
|
|
69
|
+
- Supports follow-up questions in the same thread
|
|
70
|
+
- Integrates MCP tools for extended capabilities
|
|
71
|
+
- Supports multiple LLM providers via LiteLLM
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
conversation_manager: ConversationManager | None = None,
|
|
77
|
+
bot_name: str | None = None,
|
|
78
|
+
mcp_client: MCPMultiClient | None = None,
|
|
79
|
+
):
|
|
80
|
+
"""Initialize the AI command.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
conversation_manager: Shared conversation manager instance.
|
|
84
|
+
bot_name: Bot name for mention handling (defaults to config value).
|
|
85
|
+
mcp_client: MCP client for tool integration (optional).
|
|
86
|
+
"""
|
|
87
|
+
super().__init__(
|
|
88
|
+
command_keyword="",
|
|
89
|
+
help_message=(
|
|
90
|
+
"Ask me anything! I can answer questions and "
|
|
91
|
+
"remember our conversation in this thread."
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
self.bot_name = bot_name or settings.bot.name
|
|
96
|
+
self.model = settings.llm.model
|
|
97
|
+
self.temperature = settings.llm.temperature
|
|
98
|
+
self.max_tokens = settings.llm.max_tokens
|
|
99
|
+
|
|
100
|
+
self.conversation_manager = conversation_manager or ConversationManager(
|
|
101
|
+
max_messages=settings.conversation.max_history_messages,
|
|
102
|
+
timeout_seconds=settings.conversation.timeout_seconds,
|
|
103
|
+
db_path=settings.conversation.db_path,
|
|
104
|
+
enable_persistence=settings.conversation.enable_persistence,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
self.mcp_client = mcp_client
|
|
108
|
+
|
|
109
|
+
self._build_mention_patterns()
|
|
110
|
+
|
|
111
|
+
log.info(
|
|
112
|
+
"AICommand initialized: bot_name='%s', model='%s', mcp_enabled=%s",
|
|
113
|
+
self.bot_name,
|
|
114
|
+
self.model,
|
|
115
|
+
self.mcp_client is not None,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def _build_mention_patterns(self) -> None:
|
|
119
|
+
"""Build regex patterns to detect and clean bot mentions."""
|
|
120
|
+
escaped_name = re.escape(self.bot_name)
|
|
121
|
+
|
|
122
|
+
self._mention_pattern = re.compile(
|
|
123
|
+
rf"^\s*@?\s*{escaped_name}\s*[:,.\s]*",
|
|
124
|
+
re.IGNORECASE,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
self._alt_mention_patterns = []
|
|
128
|
+
display_name = settings.bot.display_name
|
|
129
|
+
if display_name and display_name.lower() != self.bot_name.lower():
|
|
130
|
+
escaped_display = re.escape(display_name)
|
|
131
|
+
alt_pattern = re.compile(
|
|
132
|
+
rf"^\s*@?\s*{escaped_display}\s*[:,.\s]*",
|
|
133
|
+
re.IGNORECASE,
|
|
134
|
+
)
|
|
135
|
+
self._alt_mention_patterns.append(alt_pattern)
|
|
136
|
+
|
|
137
|
+
def _get_thread_id(self, activity: Any) -> str | None:
|
|
138
|
+
"""Extract thread ID from the activity object.
|
|
139
|
+
|
|
140
|
+
For thread replies, returns the parent message ID.
|
|
141
|
+
For new messages, returns the message ID itself.
|
|
142
|
+
"""
|
|
143
|
+
if not activity:
|
|
144
|
+
log.warning("Activity object is None")
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
if isinstance(activity, dict):
|
|
148
|
+
if (
|
|
149
|
+
"parent" in activity
|
|
150
|
+
and isinstance(activity["parent"], dict)
|
|
151
|
+
and "id" in activity["parent"]
|
|
152
|
+
):
|
|
153
|
+
return activity["parent"]["id"]
|
|
154
|
+
|
|
155
|
+
if activity.get("parentId"):
|
|
156
|
+
return activity["parentId"]
|
|
157
|
+
|
|
158
|
+
if activity.get("id"):
|
|
159
|
+
return activity["id"]
|
|
160
|
+
else:
|
|
161
|
+
if hasattr(activity, "parent") and activity.parent:
|
|
162
|
+
parent = activity.parent
|
|
163
|
+
if isinstance(parent, dict) and "id" in parent:
|
|
164
|
+
return parent["id"]
|
|
165
|
+
if hasattr(parent, "id") and parent.id:
|
|
166
|
+
return parent.id
|
|
167
|
+
|
|
168
|
+
for attr in ("parentId", "parent_id"):
|
|
169
|
+
if hasattr(activity, attr):
|
|
170
|
+
value = getattr(activity, attr)
|
|
171
|
+
if value:
|
|
172
|
+
return value
|
|
173
|
+
|
|
174
|
+
if hasattr(activity, "id") and activity.id:
|
|
175
|
+
return activity.id
|
|
176
|
+
|
|
177
|
+
log.warning("Could not extract thread ID from activity")
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
def _clean_prompt(self, prompt: str) -> str:
|
|
181
|
+
"""Remove bot mentions from the prompt.
|
|
182
|
+
|
|
183
|
+
This ensures the AI doesn't get confused by its own name
|
|
184
|
+
appearing at the start of every message.
|
|
185
|
+
"""
|
|
186
|
+
if not prompt:
|
|
187
|
+
return prompt
|
|
188
|
+
|
|
189
|
+
prompt = self._mention_pattern.sub("", prompt).strip()
|
|
190
|
+
|
|
191
|
+
for pattern in self._alt_mention_patterns:
|
|
192
|
+
prompt = pattern.sub("", prompt).strip()
|
|
193
|
+
|
|
194
|
+
return prompt
|
|
195
|
+
|
|
196
|
+
def _build_messages(
|
|
197
|
+
self,
|
|
198
|
+
prompt: str,
|
|
199
|
+
thread_id: str | None,
|
|
200
|
+
) -> list[dict[str, str]]:
|
|
201
|
+
"""Build the messages list for the LLM API call.
|
|
202
|
+
|
|
203
|
+
Includes:
|
|
204
|
+
- System prompt with bot identity
|
|
205
|
+
- Conversation history from this thread
|
|
206
|
+
- Current user message
|
|
207
|
+
"""
|
|
208
|
+
system_prompt = settings.llm.get_system_prompt(self.bot_name)
|
|
209
|
+
|
|
210
|
+
messages = self.conversation_manager.get_messages_for_api(
|
|
211
|
+
thread_id=thread_id or "",
|
|
212
|
+
system_prompt=system_prompt,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
messages.append({"role": "user", "content": prompt})
|
|
216
|
+
|
|
217
|
+
return messages
|
|
218
|
+
|
|
219
|
+
def _get_mcp_tools(self) -> list[dict]:
|
|
220
|
+
"""Get available MCP tools formatted for LiteLLM function calling.
|
|
221
|
+
|
|
222
|
+
Works for all models:
|
|
223
|
+
- OpenAI: Returns native tool_calls
|
|
224
|
+
- Ollama: Returns JSON in content that we parse
|
|
225
|
+
"""
|
|
226
|
+
if not self.mcp_client or not self.mcp_client.available_tools:
|
|
227
|
+
return []
|
|
228
|
+
|
|
229
|
+
tools = self.mcp_client.get_tools_for_litellm()
|
|
230
|
+
log.info("Including %d MCP tools in API request", len(tools))
|
|
231
|
+
return tools
|
|
232
|
+
|
|
233
|
+
def _call_mcp_tool(self, tool_name: str, arguments: dict) -> MCPToolResult:
|
|
234
|
+
"""Call an MCP tool synchronously using a thread pool.
|
|
235
|
+
|
|
236
|
+
This runs the async MCP call in a separate thread to avoid
|
|
237
|
+
event loop conflicts with webex_bot.
|
|
238
|
+
"""
|
|
239
|
+
if not self.mcp_client:
|
|
240
|
+
return MCPToolResult(
|
|
241
|
+
tool_name=tool_name,
|
|
242
|
+
success=False,
|
|
243
|
+
result=None,
|
|
244
|
+
error="MCP client not configured",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
async def _async_call():
|
|
248
|
+
return await self.mcp_client.call_tool(tool_name, arguments)
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
future = _executor.submit(_run_async_in_thread, _async_call())
|
|
252
|
+
result = future.result(timeout=settings.mcp.request_timeout + 5)
|
|
253
|
+
return result
|
|
254
|
+
except concurrent.futures.TimeoutError:
|
|
255
|
+
log.error("MCP tool '%s' timed out", tool_name)
|
|
256
|
+
return MCPToolResult(
|
|
257
|
+
tool_name=tool_name,
|
|
258
|
+
success=False,
|
|
259
|
+
result=None,
|
|
260
|
+
error=f"Tool call timed out after {settings.mcp.request_timeout}s",
|
|
261
|
+
)
|
|
262
|
+
except Exception as e:
|
|
263
|
+
log.exception("MCP tool '%s' error: %s", tool_name, e)
|
|
264
|
+
set_tag("mcp_tool", tool_name)
|
|
265
|
+
capture_exception(e)
|
|
266
|
+
return MCPToolResult(
|
|
267
|
+
tool_name=tool_name,
|
|
268
|
+
success=False,
|
|
269
|
+
result=None,
|
|
270
|
+
error=str(e),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def _handle_tool_calls(self, tool_calls: list) -> dict[str, str]:
|
|
274
|
+
"""Handle LLM function calls to MCP tools.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
tool_calls: List of tool calls from the LLM response.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Dictionary mapping tool call IDs to their results.
|
|
281
|
+
"""
|
|
282
|
+
results = {}
|
|
283
|
+
|
|
284
|
+
for tool_call in tool_calls:
|
|
285
|
+
tool_name = tool_call.function.name
|
|
286
|
+
|
|
287
|
+
# Strip common prefixes that models might add (e.g., "tool.", "tool_")
|
|
288
|
+
if tool_name.startswith(("tool.", "tool_")):
|
|
289
|
+
tool_name = tool_name[5:]
|
|
290
|
+
|
|
291
|
+
# Validate that the tool exists
|
|
292
|
+
if self.mcp_client:
|
|
293
|
+
tool_exists = any(
|
|
294
|
+
tool.name == tool_name for tool in self.mcp_client.available_tools
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if not tool_exists:
|
|
298
|
+
log.warning(
|
|
299
|
+
"Tool '%s' does not exist. Available tools: %s",
|
|
300
|
+
tool_name,
|
|
301
|
+
[t.name for t in self.mcp_client.available_tools],
|
|
302
|
+
)
|
|
303
|
+
results[tool_call.id] = f"Error: Tool '{tool_name}' does not exist"
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
# Parse arguments from JSON string
|
|
308
|
+
arguments = json.loads(tool_call.function.arguments or "{}")
|
|
309
|
+
|
|
310
|
+
# Validate arguments against the tool schema
|
|
311
|
+
if self.mcp_client:
|
|
312
|
+
# Get the tool definition to check its schema
|
|
313
|
+
tool_def = None
|
|
314
|
+
for tool in self.mcp_client.available_tools:
|
|
315
|
+
if tool.name == tool_name:
|
|
316
|
+
tool_def = tool
|
|
317
|
+
break
|
|
318
|
+
|
|
319
|
+
if tool_def:
|
|
320
|
+
# Get allowed parameters from schema
|
|
321
|
+
schema = tool_def.input_schema or {}
|
|
322
|
+
properties = schema.get("properties", {})
|
|
323
|
+
|
|
324
|
+
# Filter arguments to only include those defined in the schema
|
|
325
|
+
valid_arguments = {
|
|
326
|
+
k: v for k, v in arguments.items() if k in properties
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if valid_arguments != arguments:
|
|
330
|
+
invalid_args = set(arguments.keys()) - set(
|
|
331
|
+
properties.keys()
|
|
332
|
+
)
|
|
333
|
+
log.warning(
|
|
334
|
+
"Tool '%s' received invalid arguments: %s. "
|
|
335
|
+
"Only passing valid arguments: %s",
|
|
336
|
+
tool_name,
|
|
337
|
+
invalid_args,
|
|
338
|
+
valid_arguments,
|
|
339
|
+
)
|
|
340
|
+
arguments = valid_arguments
|
|
341
|
+
|
|
342
|
+
log.info("Executing MCP tool '%s' with args: %s", tool_name, arguments)
|
|
343
|
+
|
|
344
|
+
result = self._call_mcp_tool(tool_name, arguments)
|
|
345
|
+
|
|
346
|
+
if result.success:
|
|
347
|
+
results[tool_call.id] = str(result.result)
|
|
348
|
+
log.info("Tool '%s' executed successfully", tool_name)
|
|
349
|
+
else:
|
|
350
|
+
results[tool_call.id] = f"Error: {result.error}"
|
|
351
|
+
log.warning("Tool '%s' failed: %s", tool_name, result.error)
|
|
352
|
+
|
|
353
|
+
except json.JSONDecodeError as e:
|
|
354
|
+
log.error("Error parsing tool arguments for '%s': %s", tool_name, e)
|
|
355
|
+
results[tool_call.id] = f"Error: Invalid JSON arguments - {e}"
|
|
356
|
+
except Exception as e:
|
|
357
|
+
log.exception("Error executing tool '%s': %s", tool_name, e)
|
|
358
|
+
results[tool_call.id] = f"Error: {type(e).__name__}: {e}"
|
|
359
|
+
|
|
360
|
+
return results
|
|
361
|
+
|
|
362
|
+
def _call_llm(
|
|
363
|
+
self,
|
|
364
|
+
messages: list[dict],
|
|
365
|
+
tools: list[dict] | None = None,
|
|
366
|
+
) -> Any:
|
|
367
|
+
"""Make a synchronous LLM API call using LiteLLM.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
messages: List of message dicts for the API.
|
|
371
|
+
tools: Optional list of tool definitions.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
LiteLLM completion response.
|
|
375
|
+
"""
|
|
376
|
+
kwargs: dict[str, Any] = {
|
|
377
|
+
"model": self.model,
|
|
378
|
+
"messages": messages,
|
|
379
|
+
"max_tokens": self.max_tokens,
|
|
380
|
+
"temperature": self.temperature,
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if settings.llm.api_base:
|
|
384
|
+
kwargs["api_base"] = settings.llm.api_base
|
|
385
|
+
|
|
386
|
+
if tools:
|
|
387
|
+
kwargs["tools"] = tools
|
|
388
|
+
|
|
389
|
+
return litellm.completion(**kwargs)
|
|
390
|
+
|
|
391
|
+
def execute(
|
|
392
|
+
self,
|
|
393
|
+
message: str,
|
|
394
|
+
attachment_actions: Any,
|
|
395
|
+
activity: Any,
|
|
396
|
+
) -> list[str]:
|
|
397
|
+
"""Execute the AI command to respond to user message.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
message: The user's message with command keyword stripped.
|
|
401
|
+
attachment_actions: Attachment actions object (unused).
|
|
402
|
+
activity: The Webex activity object.
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
List of response strings.
|
|
406
|
+
"""
|
|
407
|
+
if not message or not message.strip():
|
|
408
|
+
log.warning("Received empty message")
|
|
409
|
+
return ["Please ask me a question! Mention me with your query."]
|
|
410
|
+
|
|
411
|
+
log.info("Raw message: '%s'", message[:100])
|
|
412
|
+
|
|
413
|
+
prompt = self._clean_prompt(message.strip())
|
|
414
|
+
|
|
415
|
+
if not prompt:
|
|
416
|
+
log.warning("Message was empty after cleaning")
|
|
417
|
+
return ["Please ask me a question! I'm here to help."]
|
|
418
|
+
|
|
419
|
+
thread_id = self._get_thread_id(activity)
|
|
420
|
+
|
|
421
|
+
log.info("=" * 60)
|
|
422
|
+
log.info("PROCESSING MESSAGE")
|
|
423
|
+
log.info("Thread ID: %s", thread_id)
|
|
424
|
+
log.info(
|
|
425
|
+
"Has history: %s",
|
|
426
|
+
self.conversation_manager.has_history(thread_id) if thread_id else False,
|
|
427
|
+
)
|
|
428
|
+
log.info("Cleaned prompt: '%s'", prompt[:100])
|
|
429
|
+
log.info("=" * 60)
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
messages = self._build_messages(prompt, thread_id)
|
|
433
|
+
tools = self._get_mcp_tools()
|
|
434
|
+
|
|
435
|
+
log.info(
|
|
436
|
+
"Sending to LLM: model=%s, temperature=%.1f, max_tokens=%d, tools=%d",
|
|
437
|
+
self.model,
|
|
438
|
+
self.temperature,
|
|
439
|
+
self.max_tokens,
|
|
440
|
+
len(tools),
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
completion = self._call_llm(messages, tools if tools else None)
|
|
444
|
+
|
|
445
|
+
if not completion.choices:
|
|
446
|
+
log.error("LLM returned empty choices")
|
|
447
|
+
return ["I'm sorry, I couldn't generate a response. Please try again."]
|
|
448
|
+
|
|
449
|
+
choice = completion.choices[0]
|
|
450
|
+
response_content = choice.message.content
|
|
451
|
+
|
|
452
|
+
# Handle function call-based tool calls
|
|
453
|
+
if (
|
|
454
|
+
hasattr(choice.message, "tool_calls")
|
|
455
|
+
and choice.message.tool_calls
|
|
456
|
+
and self.mcp_client
|
|
457
|
+
):
|
|
458
|
+
log.info("LLM made %d function call(s)", len(choice.message.tool_calls))
|
|
459
|
+
|
|
460
|
+
tool_results = self._handle_tool_calls(choice.message.tool_calls)
|
|
461
|
+
|
|
462
|
+
if tool_results:
|
|
463
|
+
# Add assistant message with tool calls
|
|
464
|
+
messages.append(
|
|
465
|
+
{
|
|
466
|
+
"role": "assistant",
|
|
467
|
+
"content": choice.message.content or "",
|
|
468
|
+
"tool_calls": [
|
|
469
|
+
{
|
|
470
|
+
"id": tc.id,
|
|
471
|
+
"type": "function",
|
|
472
|
+
"function": {
|
|
473
|
+
"name": tc.function.name,
|
|
474
|
+
"arguments": tc.function.arguments,
|
|
475
|
+
},
|
|
476
|
+
}
|
|
477
|
+
for tc in choice.message.tool_calls
|
|
478
|
+
],
|
|
479
|
+
}
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
# Add tool results
|
|
483
|
+
for tool_call in choice.message.tool_calls:
|
|
484
|
+
result = tool_results.get(tool_call.id, "Tool execution failed")
|
|
485
|
+
messages.append(
|
|
486
|
+
{
|
|
487
|
+
"role": "tool",
|
|
488
|
+
"tool_call_id": tool_call.id,
|
|
489
|
+
"content": str(result),
|
|
490
|
+
}
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
# Add explicit instruction to respond with text, not call tools
|
|
494
|
+
messages.append(
|
|
495
|
+
{
|
|
496
|
+
"role": "user",
|
|
497
|
+
"content": "Based on the tool results above, please provide a clear and concise response to the user's original question. Do NOT call any tools. Just respond with text.",
|
|
498
|
+
}
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
# Get final response from LLM with tool results
|
|
502
|
+
log.info("Sending tool results back to LLM")
|
|
503
|
+
completion = self._call_llm(messages, tools=None)
|
|
504
|
+
if completion.choices:
|
|
505
|
+
response_content = completion.choices[0].message.content
|
|
506
|
+
|
|
507
|
+
# Handle JSON-mode tool calls from Ollama
|
|
508
|
+
elif response_content and self.mcp_client:
|
|
509
|
+
# Try to parse JSON tool calls from the response content
|
|
510
|
+
try:
|
|
511
|
+
json_str = response_content.strip()
|
|
512
|
+
|
|
513
|
+
# Try to extract JSON from markdown code blocks
|
|
514
|
+
if "```json" in json_str:
|
|
515
|
+
start = json_str.find("```json") + 7
|
|
516
|
+
end = json_str.find("```", start)
|
|
517
|
+
if end > start:
|
|
518
|
+
json_str = json_str[start:end].strip()
|
|
519
|
+
elif "```" in json_str:
|
|
520
|
+
start = json_str.find("```") + 3
|
|
521
|
+
end = json_str.find("```", start)
|
|
522
|
+
if end > start:
|
|
523
|
+
json_str = json_str[start:end].strip()
|
|
524
|
+
|
|
525
|
+
json_data = json.loads(json_str)
|
|
526
|
+
|
|
527
|
+
# Check if this is a tool call
|
|
528
|
+
if (
|
|
529
|
+
isinstance(json_data, dict)
|
|
530
|
+
and "name" in json_data
|
|
531
|
+
and "arguments" in json_data
|
|
532
|
+
):
|
|
533
|
+
log.info(
|
|
534
|
+
"✅ Detected JSON-mode tool call: %s", json_data["name"]
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
# Debug: Log the raw JSON to diagnose parameter issues
|
|
538
|
+
log.debug(
|
|
539
|
+
"Raw JSON-mode tool call data: %s",
|
|
540
|
+
json.dumps(json_data, indent=2),
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
# Extract arguments - handle case where arguments might be nested incorrectly
|
|
544
|
+
tool_arguments = json_data["arguments"]
|
|
545
|
+
|
|
546
|
+
# If arguments is not a dict, try to parse it as JSON string
|
|
547
|
+
if not isinstance(tool_arguments, dict):
|
|
548
|
+
try:
|
|
549
|
+
if isinstance(tool_arguments, str):
|
|
550
|
+
tool_arguments = json.loads(tool_arguments)
|
|
551
|
+
else:
|
|
552
|
+
log.warning(
|
|
553
|
+
"Tool arguments for '%s' is not a dict or JSON string: %s",
|
|
554
|
+
json_data["name"],
|
|
555
|
+
type(tool_arguments),
|
|
556
|
+
)
|
|
557
|
+
tool_arguments = {}
|
|
558
|
+
except json.JSONDecodeError as e:
|
|
559
|
+
log.warning(
|
|
560
|
+
"Failed to parse tool arguments as JSON for '%s': %s",
|
|
561
|
+
json_data["name"],
|
|
562
|
+
e,
|
|
563
|
+
)
|
|
564
|
+
tool_arguments = {}
|
|
565
|
+
|
|
566
|
+
# Create a tool call object and handle it
|
|
567
|
+
tool_call = type(
|
|
568
|
+
"ToolCall",
|
|
569
|
+
(),
|
|
570
|
+
{
|
|
571
|
+
"id": "json_call_0",
|
|
572
|
+
"function": type(
|
|
573
|
+
"Function",
|
|
574
|
+
(),
|
|
575
|
+
{
|
|
576
|
+
"name": json_data["name"],
|
|
577
|
+
"arguments": json.dumps(tool_arguments),
|
|
578
|
+
},
|
|
579
|
+
)(),
|
|
580
|
+
},
|
|
581
|
+
)()
|
|
582
|
+
|
|
583
|
+
tool_results = self._handle_tool_calls([tool_call])
|
|
584
|
+
|
|
585
|
+
if tool_results:
|
|
586
|
+
# Add assistant message with tool call
|
|
587
|
+
messages.append(
|
|
588
|
+
{
|
|
589
|
+
"role": "assistant",
|
|
590
|
+
"content": "",
|
|
591
|
+
}
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
# Add tool result
|
|
595
|
+
result = tool_results.get(
|
|
596
|
+
"json_call_0", "Tool execution failed"
|
|
597
|
+
)
|
|
598
|
+
messages.append(
|
|
599
|
+
{
|
|
600
|
+
"role": "user",
|
|
601
|
+
"content": f"Tool '{json_data['name']}' returned: {result}\n\nBased on this tool result, please provide a clear and concise response to the user's original question. Do NOT call any tools. Just respond with text.",
|
|
602
|
+
}
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
# Get final response from LLM with tool results
|
|
606
|
+
log.info("Sending tool results back to LLM")
|
|
607
|
+
completion = self._call_llm(messages, tools=None)
|
|
608
|
+
if completion.choices:
|
|
609
|
+
response_content = completion.choices[0].message.content
|
|
610
|
+
except (json.JSONDecodeError, ValueError, AttributeError):
|
|
611
|
+
# Not a valid tool call, treat as regular response
|
|
612
|
+
pass
|
|
613
|
+
|
|
614
|
+
if not response_content or not response_content.strip():
|
|
615
|
+
log.error("LLM returned empty content")
|
|
616
|
+
return ["I'm sorry, I received an empty response. Please try again."]
|
|
617
|
+
|
|
618
|
+
if thread_id:
|
|
619
|
+
self.conversation_manager.add_user_message(thread_id, prompt)
|
|
620
|
+
self.conversation_manager.add_assistant_message(
|
|
621
|
+
thread_id, response_content
|
|
622
|
+
)
|
|
623
|
+
log.info(
|
|
624
|
+
"Saved conversation to thread %s. Total: %d messages",
|
|
625
|
+
thread_id,
|
|
626
|
+
self.conversation_manager.get_message_count(thread_id),
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
log.info("Response generated (%d chars)", len(response_content))
|
|
630
|
+
|
|
631
|
+
return [quote_info(response_content)]
|
|
632
|
+
|
|
633
|
+
except Exception as e:
|
|
634
|
+
log.exception("Error calling LLM: %s", e)
|
|
635
|
+
set_tag("llm_model", self.model)
|
|
636
|
+
set_tag("thread_id", thread_id or "none")
|
|
637
|
+
capture_exception(e)
|
|
638
|
+
return [
|
|
639
|
+
f"I'm sorry, I encountered an error: {type(e).__name__}. "
|
|
640
|
+
"Please try again later."
|
|
641
|
+
]
|
|
642
|
+
|
|
643
|
+
def pre_execute(
|
|
644
|
+
self,
|
|
645
|
+
message: str,
|
|
646
|
+
attachment_actions: Any,
|
|
647
|
+
activity: Any,
|
|
648
|
+
) -> str | None:
|
|
649
|
+
"""Optional pre-execution hook.
|
|
650
|
+
|
|
651
|
+
Can be used to send a "thinking" indicator for long-running requests.
|
|
652
|
+
"""
|
|
653
|
+
return None
|