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 ADDED
@@ -0,0 +1 @@
1
+ """Webex AI Bot - A conversational AI bot with MCP support."""
@@ -0,0 +1,5 @@
1
+ """Commands for Webex bot."""
2
+
3
+ from src.commands.ai import AICommand
4
+
5
+ __all__ = ["AICommand"]
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