Add MCP delegation bridge and diagram tools
**Features Added**: 1. **Agent Registry (agent_registry.py)** - Thread-safe global singleton for MCP tool access to Agent instance - Enables MCP tools to call Agent.delegate() without circular imports - Registered at bot startup in bot_runner.py 2. **Sub-Agent Manager (sub_agent_manager.py)** - Watchdog system monitoring sub-agent lifecycle - Detects hung agents (5min timeout, 30s check interval) - Auto-cleanup and status tracking 3. **delegate_task MCP Tool (mcp_tools.py)** - Exposes Agent.delegate() to Claude via MCP protocol - Enables parallel sub-agent execution via tool calls - Supports specialist prompts and agent ID caching 4. **Memory Write Locks (memory_system.py)** - Thread-safe writes to prevent file corruption - Protects write_memory(), update_soul(), update_user() 5. **Diagram Tools** - Mermaid MCP server (flowcharts, sequence diagrams, etc.) - Excalidraw MCP server (hand-drawn style diagrams) - Config files in config/ directory 6. **Adapter Improvements** - Enhanced error handling across all adapters - Unified logging patterns **Testing**: Ready for parallel sub-agent testing Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -145,6 +145,26 @@ class BaseAdapter(ABC):
|
||||
async def send_typing_indicator(self, channel_id: str) -> None:
|
||||
"""Show typing indicator. Optional."""
|
||||
|
||||
async def send_file(
|
||||
self,
|
||||
channel_id: str,
|
||||
file_path: str,
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Send a file attachment to the platform. Optional - override if supported.
|
||||
|
||||
Args:
|
||||
channel_id: Channel/chat ID to send to
|
||||
file_path: Absolute path to file
|
||||
caption: Optional caption/text with the file
|
||||
thread_id: Optional thread/reply ID
|
||||
|
||||
Returns:
|
||||
Dict with success status and message_id or error
|
||||
"""
|
||||
return {"success": False, "error": "send_file not implemented"}
|
||||
|
||||
async def health_check(self) -> Dict[str, Any]:
|
||||
"""Perform health check on the adapter."""
|
||||
return {
|
||||
|
||||
@@ -98,6 +98,61 @@ class AdapterRuntime:
|
||||
print("[Runtime] Warning: No event loop for message dispatch")
|
||||
self._message_queue.put_nowait(message)
|
||||
|
||||
async def _detect_and_send_diagrams(
|
||||
self,
|
||||
response: str,
|
||||
adapter: BaseAdapter,
|
||||
channel_id: str,
|
||||
thread_id: Optional[str]
|
||||
) -> None:
|
||||
"""Detect diagram file paths in response and auto-send them.
|
||||
|
||||
Args:
|
||||
response: Agent's text response
|
||||
adapter: Platform adapter to send files with
|
||||
channel_id: Channel/chat ID
|
||||
thread_id: Thread/message ID for replies
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Match diagram file paths: "Saved to: path/to/diagram.png"
|
||||
# Pattern matches common phrases followed by file path with image/diagram extensions
|
||||
pattern = r"(?:Saved|Created|Generated|Exported|File saved|Output file)\s*(?:to|at)?[:\s]+([^\s]+\.(?:png|svg|pdf|jpg|jpeg))"
|
||||
matches = re.findall(pattern, response, re.IGNORECASE)
|
||||
|
||||
if not matches:
|
||||
return
|
||||
|
||||
sent_files = []
|
||||
for file_path_str in matches:
|
||||
try:
|
||||
file_path = Path(file_path_str)
|
||||
|
||||
# Check if file exists
|
||||
if not file_path.exists():
|
||||
print(f"[Runtime] Diagram file not found: {file_path}")
|
||||
continue
|
||||
|
||||
# Send file via adapter
|
||||
result = await adapter.send_file(
|
||||
channel_id=channel_id,
|
||||
file_path=str(file_path.absolute()),
|
||||
caption=f"Diagram: {file_path.name}",
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
sent_files.append(file_path.name)
|
||||
print(f"[Runtime] Sent diagram file: {file_path.name}")
|
||||
else:
|
||||
print(f"[Runtime] Failed to send diagram: {result.get('error')}")
|
||||
except Exception as e:
|
||||
print(f"[Runtime] Error sending diagram file: {e}")
|
||||
|
||||
if sent_files:
|
||||
print(f"[Runtime] Successfully sent {len(sent_files)} diagram file(s)")
|
||||
|
||||
async def _process_message_queue(self) -> None:
|
||||
"""Background task to process incoming messages."""
|
||||
print("[Runtime] Message processing loop started")
|
||||
@@ -169,12 +224,22 @@ class AdapterRuntime:
|
||||
user_message=processed_message.text,
|
||||
username=username,
|
||||
progress_callback=progress_callback,
|
||||
inbound_message=processed_message,
|
||||
)
|
||||
|
||||
# Apply postprocessors
|
||||
for postprocessor in self._postprocessors:
|
||||
response = postprocessor(response, processed_message)
|
||||
|
||||
# NEW: Detect and send diagram files mentioned in response
|
||||
if adapter:
|
||||
await self._detect_and_send_diagrams(
|
||||
response,
|
||||
adapter,
|
||||
message.channel_id,
|
||||
message.thread_id,
|
||||
)
|
||||
|
||||
# Send response back
|
||||
if adapter:
|
||||
reply_to = (
|
||||
|
||||
@@ -58,19 +58,29 @@ class SlackAdapter(BaseAdapter):
|
||||
)
|
||||
|
||||
def validate_config(self) -> bool:
|
||||
"""Validate Slack configuration."""
|
||||
"""Validate Slack configuration.
|
||||
|
||||
Required scopes for bot token:
|
||||
- files:read (for downloading file attachments)
|
||||
- files:write (for uploading files - future feature)
|
||||
"""
|
||||
if not self.config.credentials:
|
||||
return False
|
||||
|
||||
bot_token = self.config.credentials.get("bot_token", "")
|
||||
app_token = self.config.credentials.get("app_token", "")
|
||||
|
||||
return (
|
||||
valid = (
|
||||
bool(bot_token and app_token)
|
||||
and bot_token.startswith("xoxb-")
|
||||
and app_token.startswith("xapp-")
|
||||
)
|
||||
|
||||
if valid:
|
||||
print("[Slack] ✓ Config valid. Ensure bot has 'files:read' and 'files:write' scopes at api.slack.com")
|
||||
|
||||
return valid
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the Slack Socket Mode connection."""
|
||||
if not self.validate_config():
|
||||
@@ -116,9 +126,40 @@ class SlackAdapter(BaseAdapter):
|
||||
channel = event.get("channel")
|
||||
thread_ts = event.get("thread_ts")
|
||||
ts = event.get("ts")
|
||||
files = event.get("files", [])
|
||||
|
||||
# DEBUG: Log full event structure
|
||||
print(f"[Slack DEBUG] Event subtype: {event.get('subtype')}")
|
||||
print(f"[Slack DEBUG] Event has text: {bool(text)}, text length: {len(text)}")
|
||||
print(f"[Slack DEBUG] Event has files: {bool(files)}, file count: {len(files)}")
|
||||
|
||||
# DEBUG: Log file detection
|
||||
if files:
|
||||
print(f"[Slack DEBUG] Detected {len(files)} file(s) in message")
|
||||
for f in files:
|
||||
print(f"[Slack DEBUG] File: {f.get('name')} ({f.get('mimetype')}, ID: {f.get('id')})")
|
||||
|
||||
username = await self._get_username(user_id)
|
||||
|
||||
# Determine message type
|
||||
message_type = MessageType.FILE if files else MessageType.TEXT
|
||||
|
||||
# Download files
|
||||
downloaded_files = []
|
||||
for file_info in files:
|
||||
print(f"[Slack DEBUG] Downloading: {file_info.get('name')} (ID: {file_info.get('id')})")
|
||||
result = await self._download_slack_file(file_info)
|
||||
if result["success"]:
|
||||
print(f"[Slack DEBUG] Downloaded to: {result['file_path']}")
|
||||
downloaded_files.append(result)
|
||||
else:
|
||||
print(f"[Slack] Failed to download file {file_info.get('name')}: {result['error']}")
|
||||
|
||||
# If files but no text, add placeholder
|
||||
if files and not text:
|
||||
file_names = ", ".join(f["filename"] for f in downloaded_files)
|
||||
text = f"[Uploaded {len(downloaded_files)} file(s): {file_names}]"
|
||||
|
||||
inbound_msg = InboundMessage(
|
||||
platform="slack",
|
||||
user_id=user_id,
|
||||
@@ -127,11 +168,12 @@ class SlackAdapter(BaseAdapter):
|
||||
channel_id=channel,
|
||||
thread_id=thread_ts,
|
||||
reply_to_id=None,
|
||||
message_type=MessageType.TEXT,
|
||||
message_type=message_type,
|
||||
metadata={
|
||||
"ts": ts,
|
||||
"team": event.get("team"),
|
||||
"channel_type": event.get("channel_type"),
|
||||
"files": downloaded_files,
|
||||
},
|
||||
raw=event,
|
||||
)
|
||||
@@ -146,9 +188,35 @@ class SlackAdapter(BaseAdapter):
|
||||
channel = event.get("channel")
|
||||
thread_ts = event.get("thread_ts")
|
||||
ts = event.get("ts")
|
||||
files = event.get("files", [])
|
||||
|
||||
# DEBUG: Log file detection
|
||||
if files:
|
||||
print(f"[Slack DEBUG @mention] Detected {len(files)} file(s)")
|
||||
for f in files:
|
||||
print(f"[Slack DEBUG @mention] File: {f.get('name')} ({f.get('mimetype')})")
|
||||
|
||||
username = await self._get_username(user_id)
|
||||
|
||||
# Determine message type
|
||||
message_type = MessageType.FILE if files else MessageType.TEXT
|
||||
|
||||
# Download files
|
||||
downloaded_files = []
|
||||
for file_info in files:
|
||||
print(f"[Slack DEBUG @mention] Downloading: {file_info.get('name')} (ID: {file_info.get('id')})")
|
||||
result = await self._download_slack_file(file_info)
|
||||
if result["success"]:
|
||||
print(f"[Slack DEBUG @mention] Downloaded to: {result['file_path']}")
|
||||
downloaded_files.append(result)
|
||||
else:
|
||||
print(f"[Slack @mention] Failed to download file {file_info.get('name')}: {result['error']}")
|
||||
|
||||
# If files but no text (after stripping mention), add placeholder
|
||||
if files and not text:
|
||||
file_names = ", ".join(f["filename"] for f in downloaded_files)
|
||||
text = f"[Uploaded {len(downloaded_files)} file(s): {file_names}]"
|
||||
|
||||
inbound_msg = InboundMessage(
|
||||
platform="slack",
|
||||
user_id=user_id,
|
||||
@@ -157,17 +225,88 @@ class SlackAdapter(BaseAdapter):
|
||||
channel_id=channel,
|
||||
thread_id=thread_ts,
|
||||
reply_to_id=None,
|
||||
message_type=MessageType.TEXT,
|
||||
message_type=message_type,
|
||||
metadata={
|
||||
"ts": ts,
|
||||
"mentioned": True,
|
||||
"team": event.get("team"),
|
||||
"files": downloaded_files,
|
||||
},
|
||||
raw=event,
|
||||
)
|
||||
|
||||
self._dispatch_message(inbound_msg)
|
||||
|
||||
async def _download_slack_file(
|
||||
self,
|
||||
file_info: Dict[str, Any],
|
||||
output_dir: str = "downloads/slack"
|
||||
) -> Dict[str, Any]:
|
||||
"""Download a file from Slack using url_private_download.
|
||||
|
||||
Args:
|
||||
file_info: File object from Slack event (contains url_private_download, name, etc.)
|
||||
output_dir: Directory to save files (default: "downloads/slack")
|
||||
|
||||
Returns:
|
||||
Dict with success, file_path, filename, mimetype, size, or error
|
||||
"""
|
||||
import aiohttp
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
url = file_info.get("url_private_download")
|
||||
token = self.config.credentials["bot_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, headers=headers) as response:
|
||||
if response.status == 403:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Permission denied. Add 'files:read' scope to bot at api.slack.com → OAuth & Permissions → Bot Token Scopes"
|
||||
}
|
||||
elif response.status == 404:
|
||||
return {"success": False, "error": "File not found or expired"}
|
||||
elif response.status != 200:
|
||||
return {"success": False, "error": f"HTTP {response.status}"}
|
||||
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
file_data = await response.read()
|
||||
|
||||
# Detect HTML login page (auth failure)
|
||||
if content_type.startswith("text/html") or file_data.startswith(b"<!DOCTYPE") or file_data.startswith(b"<html"):
|
||||
print(f"[Slack] Auth failure: Got HTML instead of file (likely missing 'files:read' scope)")
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Authentication failed. Bot needs 'files:read' scope. Add it at api.slack.com → OAuth & Permissions → Scopes → Add files:read → Reinstall to Workspace"
|
||||
}
|
||||
|
||||
# Save to disk
|
||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||
safe_name = Path(file_info["name"]).name # Prevent path traversal
|
||||
file_path = Path(output_dir) / safe_name
|
||||
|
||||
# Handle duplicates with timestamp
|
||||
if file_path.exists():
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
stem, suffix = safe_name.rsplit(".", 1) if "." in safe_name else (safe_name, "")
|
||||
safe_name = f"{stem}_{timestamp}.{suffix}" if suffix else f"{stem}_{timestamp}"
|
||||
file_path = Path(output_dir) / safe_name
|
||||
|
||||
file_path.write_bytes(file_data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"file_path": str(file_path.absolute()),
|
||||
"filename": safe_name,
|
||||
"mimetype": file_info.get("mimetype", ""),
|
||||
"size": len(file_data)
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def send_message(
|
||||
self, message: OutboundMessage
|
||||
) -> Dict[str, Any]:
|
||||
@@ -256,6 +395,45 @@ class SlackAdapter(BaseAdapter):
|
||||
"error": str(e.response.get("error")),
|
||||
}
|
||||
|
||||
async def send_file(
|
||||
self,
|
||||
channel_id: str,
|
||||
file_path: str,
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Upload a file to Slack channel."""
|
||||
if not self.app:
|
||||
return {"success": False, "error": "Adapter not started"}
|
||||
|
||||
try:
|
||||
from pathlib import Path
|
||||
path = Path(file_path)
|
||||
|
||||
if not path.exists():
|
||||
return {"success": False, "error": f"File not found: {file_path}"}
|
||||
|
||||
result = await self.app.client.files_upload_v2(
|
||||
channel=channel_id,
|
||||
file=str(path.absolute()),
|
||||
title=path.name,
|
||||
initial_comment=caption or "",
|
||||
thread_ts=thread_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message_id": result["file"]["id"],
|
||||
"file_path": file_path,
|
||||
}
|
||||
except SlackApiError as e:
|
||||
error_msg = e.response["error"]
|
||||
print(f"[Slack] Error uploading file: {error_msg}")
|
||||
return {"success": False, "error": error_msg}
|
||||
except Exception as e:
|
||||
print(f"[Slack] Unexpected error uploading file: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def _get_username(self, user_id: str) -> str:
|
||||
"""Get username from user ID, with caching to avoid excessive API calls.
|
||||
|
||||
|
||||
@@ -306,6 +306,58 @@ class TelegramAdapter(BaseAdapter):
|
||||
except TelegramError as e:
|
||||
print(f"[Telegram] Error sending typing indicator: {e}")
|
||||
|
||||
async def send_file(
|
||||
self,
|
||||
channel_id: str,
|
||||
file_path: str,
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Send a file (image or document) to Telegram."""
|
||||
if not self.bot:
|
||||
return {"success": False, "error": "Bot not started"}
|
||||
|
||||
try:
|
||||
from pathlib import Path
|
||||
path = Path(file_path)
|
||||
|
||||
if not path.exists():
|
||||
return {"success": False, "error": f"File not found: {file_path}"}
|
||||
|
||||
chat_id = int(channel_id)
|
||||
reply_to = int(thread_id) if thread_id else None
|
||||
ext = path.suffix.lower()
|
||||
|
||||
# Send as photo for images, document for others
|
||||
if ext in [".png", ".jpg", ".jpeg", ".gif", ".webp"]:
|
||||
with open(path, "rb") as photo:
|
||||
sent = await self.bot.send_photo(
|
||||
chat_id=chat_id,
|
||||
photo=photo,
|
||||
caption=caption,
|
||||
reply_to_message_id=reply_to,
|
||||
)
|
||||
else:
|
||||
with open(path, "rb") as document:
|
||||
sent = await self.bot.send_document(
|
||||
chat_id=chat_id,
|
||||
document=document,
|
||||
caption=caption,
|
||||
reply_to_message_id=reply_to,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message_id": sent.message_id,
|
||||
"file_path": file_path,
|
||||
}
|
||||
except TelegramError as e:
|
||||
print(f"[Telegram] Error sending file: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
except Exception as e:
|
||||
print(f"[Telegram] Unexpected error sending file: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
async def health_check(self) -> Dict[str, Any]:
|
||||
"""Perform health check."""
|
||||
base_health = await super().health_check()
|
||||
|
||||
Reference in New Issue
Block a user