From cd76e9f92fa4b7cf6357b25b3496012d847825d6 Mon Sep 17 00:00:00 2001 From: apexlnc <43242113+apexlnc@users.noreply.github.com> Date: Mon, 13 Oct 2025 17:16:15 -0400 Subject: [PATCH] feat: add Slack bot integration for kagent - Socket Mode bot for DMs and @mentions - Agent discovery and routing - Streaming responses with human-in-the-loop approval - RBAC with Slack user groups - Prometheus metrics and structured logging - Production-ready Dockerfile with Python 3.13 --- slackbot/.dockerignore | 58 ++++ slackbot/.env.example | 18 ++ slackbot/.gitignore | 6 + slackbot/.python-version | 1 + slackbot/Dockerfile | 34 +++ slackbot/README.md | 276 ++++++++++++++++++ slackbot/SLACK_SETUP.md | 238 +++++++++++++++ .../k8s/configmap-permissions.example.yaml | 29 ++ slackbot/manifests/k8s/configmap.yaml | 32 ++ slackbot/manifests/k8s/deployment.yaml | 101 +++++++ slackbot/manifests/k8s/hpa.yaml | 39 +++ slackbot/manifests/k8s/namespace.yaml | 7 + slackbot/manifests/k8s/pdb.yaml | 11 + slackbot/manifests/k8s/role.yaml | 15 + slackbot/manifests/k8s/rolebinding.yaml | 14 + slackbot/manifests/k8s/secret.yaml | 18 ++ slackbot/manifests/k8s/service.yaml | 19 ++ slackbot/manifests/k8s/serviceaccount.yaml | 9 + slackbot/pyproject.toml | 28 ++ slackbot/slack-app-manifest.yaml | 36 +++ slackbot/src/kagent_slackbot/__init__.py | 3 + slackbot/src/kagent_slackbot/auth/__init__.py | 0 .../src/kagent_slackbot/auth/permissions.py | 121 ++++++++ .../src/kagent_slackbot/auth/slack_groups.py | 95 ++++++ slackbot/src/kagent_slackbot/config.py | 75 +++++ slackbot/src/kagent_slackbot/constants.py | 23 ++ .../src/kagent_slackbot/handlers/__init__.py | 0 .../src/kagent_slackbot/handlers/actions.py | 148 ++++++++++ .../src/kagent_slackbot/handlers/commands.py | 199 +++++++++++++ .../src/kagent_slackbot/handlers/mentions.py | 257 ++++++++++++++++ .../kagent_slackbot/handlers/middleware.py | 46 +++ slackbot/src/kagent_slackbot/main.py | 136 +++++++++ .../src/kagent_slackbot/services/__init__.py | 0 .../kagent_slackbot/services/a2a_client.py | 171 +++++++++++ .../services/agent_discovery.py | 121 ++++++++ .../kagent_slackbot/services/agent_router.py | 91 ++++++ .../src/kagent_slackbot/slack/__init__.py | 0 .../src/kagent_slackbot/slack/formatters.py | 270 +++++++++++++++++ .../src/kagent_slackbot/slack/validators.py | 56 ++++ 39 files changed, 2801 insertions(+) create mode 100644 slackbot/.dockerignore create mode 100644 slackbot/.env.example create mode 100644 slackbot/.gitignore create mode 100644 slackbot/.python-version create mode 100644 slackbot/Dockerfile create mode 100644 slackbot/README.md create mode 100644 slackbot/SLACK_SETUP.md create mode 100644 slackbot/manifests/k8s/configmap-permissions.example.yaml create mode 100644 slackbot/manifests/k8s/configmap.yaml create mode 100644 slackbot/manifests/k8s/deployment.yaml create mode 100644 slackbot/manifests/k8s/hpa.yaml create mode 100644 slackbot/manifests/k8s/namespace.yaml create mode 100644 slackbot/manifests/k8s/pdb.yaml create mode 100644 slackbot/manifests/k8s/role.yaml create mode 100644 slackbot/manifests/k8s/rolebinding.yaml create mode 100644 slackbot/manifests/k8s/secret.yaml create mode 100644 slackbot/manifests/k8s/service.yaml create mode 100644 slackbot/manifests/k8s/serviceaccount.yaml create mode 100644 slackbot/pyproject.toml create mode 100644 slackbot/slack-app-manifest.yaml create mode 100644 slackbot/src/kagent_slackbot/__init__.py create mode 100644 slackbot/src/kagent_slackbot/auth/__init__.py create mode 100644 slackbot/src/kagent_slackbot/auth/permissions.py create mode 100644 slackbot/src/kagent_slackbot/auth/slack_groups.py create mode 100644 slackbot/src/kagent_slackbot/config.py create mode 100644 slackbot/src/kagent_slackbot/constants.py create mode 100644 slackbot/src/kagent_slackbot/handlers/__init__.py create mode 100644 slackbot/src/kagent_slackbot/handlers/actions.py create mode 100644 slackbot/src/kagent_slackbot/handlers/commands.py create mode 100644 slackbot/src/kagent_slackbot/handlers/mentions.py create mode 100644 slackbot/src/kagent_slackbot/handlers/middleware.py create mode 100644 slackbot/src/kagent_slackbot/main.py create mode 100644 slackbot/src/kagent_slackbot/services/__init__.py create mode 100644 slackbot/src/kagent_slackbot/services/a2a_client.py create mode 100644 slackbot/src/kagent_slackbot/services/agent_discovery.py create mode 100644 slackbot/src/kagent_slackbot/services/agent_router.py create mode 100644 slackbot/src/kagent_slackbot/slack/__init__.py create mode 100644 slackbot/src/kagent_slackbot/slack/formatters.py create mode 100644 slackbot/src/kagent_slackbot/slack/validators.py diff --git a/slackbot/.dockerignore b/slackbot/.dockerignore new file mode 100644 index 000000000..b1481f019 --- /dev/null +++ b/slackbot/.dockerignore @@ -0,0 +1,58 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ +.venv/ +venv/ +env/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Git +.git/ +.gitignore + +# CI/CD +.github/ + +# Documentation +*.md +docs/ + +# Environment files (should be injected at runtime) +.env +.env.* + +# Temporary files +*.log +*.tmp +tmp/ + +# OS +.DS_Store +Thumbs.db + +# Development tools +Makefile +mypy.ini +.mypy_cache/ +.ruff_cache/ + +# Manifests (not needed in runtime image) +manifests/ diff --git a/slackbot/.env.example b/slackbot/.env.example new file mode 100644 index 000000000..08023a42c --- /dev/null +++ b/slackbot/.env.example @@ -0,0 +1,18 @@ +# Slack Configuration +SLACK_BOT_TOKEN=xoxb-your-bot-token-here +SLACK_APP_TOKEN=xapp-your-app-token-here +SLACK_SIGNING_SECRET=your-signing-secret-here + +# Kagent Configuration +KAGENT_BASE_URL=http://localhost:8083 +KAGENT_TIMEOUT=30 + +# Server Configuration +SERVER_HOST=0.0.0.0 +SERVER_PORT=8080 + +# Logging +LOG_LEVEL=INFO + +# Permissions (Phase 3) +PERMISSIONS_FILE=config/permissions.yaml diff --git a/slackbot/.gitignore b/slackbot/.gitignore new file mode 100644 index 000000000..9f02d6ad6 --- /dev/null +++ b/slackbot/.gitignore @@ -0,0 +1,6 @@ +.mypy_cache/ +.ruff_cache/ +.venv/ +*.pyc +__pycache__/ +.env diff --git a/slackbot/.python-version b/slackbot/.python-version new file mode 100644 index 000000000..976544ccb --- /dev/null +++ b/slackbot/.python-version @@ -0,0 +1 @@ +3.13.7 diff --git a/slackbot/Dockerfile b/slackbot/Dockerfile new file mode 100644 index 000000000..87b229d17 --- /dev/null +++ b/slackbot/Dockerfile @@ -0,0 +1,34 @@ +FROM python:3.13-slim + +# Set environment variables +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +# Create non-root user early (files copied after will be owned by this user) +RUN useradd -m -u 1000 appuser && \ + chown appuser:appuser /app + +# Switch to non-root user for subsequent operations +USER appuser + +# Install dependencies first (better layer caching) +COPY --chown=appuser:appuser pyproject.toml . +RUN pip install --upgrade pip && \ + pip install . + +# Copy application code +COPY --chown=appuser:appuser src/ src/ + +# Copy default config (can be overridden with volume mount at runtime) +COPY --chown=appuser:appuser config/ config/ + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')" + +# Run application +CMD ["python", "-m", "kagent_slackbot.main"] diff --git a/slackbot/README.md b/slackbot/README.md new file mode 100644 index 000000000..e0e7b73f5 --- /dev/null +++ b/slackbot/README.md @@ -0,0 +1,276 @@ +# Kagent Slackbot + +Production-ready Slack bot for the Kagent multi-agent platform. This bot provides a unified interface to interact with multiple AI agents through Slack, featuring dynamic agent discovery, intelligent routing, and rich Block Kit formatting. + +## Features + +- **Dynamic Agent Discovery**: Automatically discovers agents from Kagent via `/api/agents` +- **Intelligent Routing**: Keyword-based matching to route messages to appropriate agents +- **Rich Formatting**: Professional Slack Block Kit responses with metadata +- **Session Management**: Maintains conversation context across multiple turns +- **Async Architecture**: Built with modern slack-bolt AsyncApp for high performance +- **Production Ready**: Prometheus metrics, health checks, structured logging +- **Kubernetes Native**: Complete K8s manifests with HPA, PDB, and security contexts + +## Architecture + +``` +User in Slack + ↓ +@mention / slash command + ↓ +Kagent Slackbot (AsyncApp) + ├── Agent Discovery (cache agents from /api/agents) + ├── Agent Router (keyword matching) + └── A2A Client (JSON-RPC 2.0) + ↓ +Kagent Controller (/api/a2a/{namespace}/{name}) + ↓ + ┌─────────┬─────────┬──────────┐ + │ k8s │ security│ research │ + │ agent │ agent │ agent │ + └─────────┴─────────┴──────────┘ +``` + +## Quick Start + +### Prerequisites + +- Python 3.11+ +- Slack workspace with bot app configured +- Kagent instance running and accessible + +### Installation + +1. Navigate to the slackbot directory: +```bash +cd /path/to/kagent/slackbot +``` + +2. Create virtual environment and install dependencies: +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" +``` + +3. Configure environment variables: +```bash +cp .env.example .env +# Edit .env with your Slack tokens and Kagent URL +``` + +Required environment variables: +- `SLACK_BOT_TOKEN`: Bot user OAuth token (xoxb-*) +- `SLACK_APP_TOKEN`: App-level token for Socket Mode (xapp-*) +- `SLACK_SIGNING_SECRET`: Signing secret for request verification +- `KAGENT_BASE_URL`: Kagent API base URL (e.g., http://localhost:8083) + +### Running Locally + +```bash +source .venv/bin/activate +python -m kagent_slackbot.main +``` + +The bot will: +1. Connect to Slack via Socket Mode (WebSocket) +2. Start health server on port 8080 +3. Discover available agents from Kagent +4. Begin processing messages + +### Slack App Configuration + +Your Slack app needs these OAuth scopes: +- `app_mentions:read` - Receive @mentions +- `chat:write` - Send messages +- `commands` - Handle slash commands +- `reactions:write` - Add emoji reactions + +Required features: +- **Socket Mode**: Enabled (no public HTTP endpoint needed) +- **Event Subscriptions**: `app_mention` +- **Slash Commands**: `/agents`, `/agent-switch` + +## Usage + +### Interacting with Agents + +**@mention the bot** with your question: +``` +@kagent show me failing pods +``` + +The bot will: +1. Extract keywords from your message ("pods" → k8s-agent) +2. Route to the appropriate agent +3. Respond with formatted blocks showing: + - Which agent responded + - Why that agent was selected + - Response time and session ID + +### Slash Commands + +**List available agents**: +``` +/agents +``` + +Shows all agents with: +- Namespace and name +- Description +- Ready status + +**Switch to specific agent**: +``` +/agent-switch kagent/security-agent +``` + +All subsequent @mentions will use this agent until you reset. + +**Reset to automatic routing**: +``` +/agent-switch reset +``` + +Returns to keyword-based agent selection. + +## Development + +### Project Structure + +``` +src/kagent_slackbot/ +├── main.py # Entry point, AsyncApp initialization +├── config.py # Configuration management +├── constants.py # Application constants +├── handlers/ # Slack event handlers +│ ├── mentions.py # @mention handling +│ ├── commands.py # Slash command handling +│ └── middleware.py # Prometheus metrics +├── services/ # Business logic +│ ├── a2a_client.py # Kagent A2A protocol client +│ ├── agent_discovery.py # Agent discovery from API +│ └── agent_router.py # Agent routing logic +└── slack/ # Slack utilities + ├── formatters.py # Block Kit formatting + └── validators.py # Input validation +``` + +### Type Checking + +```bash +.venv/bin/mypy src/kagent_slackbot/ +``` + +### Linting + +```bash +.venv/bin/ruff check src/ +``` + +Auto-fix issues: +```bash +.venv/bin/ruff check --fix src/ +``` + +## Deployment + +### Kubernetes + +1. Create namespace: +```bash +kubectl apply -f manifests/k8s/namespace.yaml +``` + +2. Create secrets (update with your tokens): +```bash +kubectl create secret generic kagent-slackbot-secrets \ + --namespace=kagent-slackbot \ + --from-literal=slack-bot-token=xoxb-... \ + --from-literal=slack-app-token=xapp-... \ + --from-literal=slack-signing-secret=... +``` + +3. Apply manifests: +```bash +kubectl apply -f manifests/k8s/configmap.yaml +kubectl apply -f manifests/k8s/deployment.yaml +``` + +4. Verify deployment: +```bash +kubectl get pods -n kagent-slackbot +kubectl logs -f -n kagent-slackbot deployment/kagent-slackbot +``` + +### Monitoring + +**Prometheus Metrics** available at `/metrics`: +- `slack_messages_total{event_type, status}` - Total messages processed +- `slack_message_duration_seconds{event_type}` - Message processing time +- `slack_commands_total{command, status}` - Slash command counts +- `agent_invocations_total{agent, status}` - Agent invocation counts + +**Health Endpoints**: +- `/health` - Liveness probe +- `/ready` - Readiness probe + +**Structured Logs**: JSON format with fields: +- `event`: Log message +- `level`: Log level (INFO, ERROR, etc.) +- `timestamp`: ISO 8601 timestamp +- `user`, `agent`, `session`: Contextual fields + +## Troubleshooting + +### Bot doesn't respond to @mentions + +1. Check bot is online: `kubectl logs -n kagent-slackbot deployment/kagent-slackbot` +2. Verify Socket Mode connection is established (look for "Connecting to Slack via Socket Mode") +3. Ensure Slack app has `app_mentions:read` scope +4. Check event subscription for `app_mention` is configured + +### Agent discovery fails + +1. Verify Kagent is accessible: `curl http://kagent.kagent.svc.cluster.local:8083/api/agents` +2. Check logs for "Agent discovery failed" messages +3. Ensure `KAGENT_BASE_URL` is configured correctly + +### Type errors during development + +Run type checking: +```bash +.venv/bin/mypy src/kagent_slackbot/ +``` + +Common issues: +- Missing type annotations - add explicit types +- Untyped external libraries - use `# type: ignore[no-untyped-call]` + +## Roadmap + +### Phase 2: Enhanced UX (Next) +- Streaming responses for real-time updates +- Interactive feedback buttons +- Improved error handling + +### Phase 3: RBAC +- Slack user group integration +- Agent-level permissions +- Configuration-driven access control + +### Phase 4: Polish +- Session management commands +- Usage analytics +- Advanced features + +## References + +- **Slack Bolt Docs**: https://slack.dev/bolt-python/ +- **Kagent A2A Protocol**: `go/internal/a2a/` +- **Agent CRD Spec**: `go/api/v1alpha2/agent_types.go` + +## License + +See LICENSE file for details. diff --git a/slackbot/SLACK_SETUP.md b/slackbot/SLACK_SETUP.md new file mode 100644 index 000000000..b557b46aa --- /dev/null +++ b/slackbot/SLACK_SETUP.md @@ -0,0 +1,238 @@ +# Slack App Setup Guide + +This guide walks you through creating and configuring the Slack app for Kagent Slackbot. + +## Step 1: Create the Slack App + +### Option A: Use the App Manifest (Recommended) + +1. Go to https://api.slack.com/apps +2. Click **"Create New App"** +3. Select **"From an app manifest"** +4. Choose your workspace +5. Select **YAML** tab +6. Copy and paste the contents of `manifests/slack/kagent.yaml` +7. Click **"Next"** → Review → **"Create"** + +The manifest will create an app named **"kagent-bot"** with all necessary scopes and settings. + +### Option B: Manual Configuration + +1. Go to https://api.slack.com/apps +2. Click **"Create New App"** → **"From scratch"** +3. Name: `kagent-bot` +4. Choose your workspace +5. Continue with manual configuration below + +## Step 2: Configure OAuth & Permissions + +Go to **OAuth & Permissions** in the sidebar: + +### Bot Token Scopes + +Add these scopes: +- `app_mentions:read` - Listen for @mentions +- `chat:write` - Send messages +- `commands` - Handle slash commands +- `reactions:write` - Add emoji reactions +- `im:history` - Receive direct messages +- `users:read` - View user profiles (needed for Phase 3 RBAC) +- `users:read.email` - View user emails (needed for Phase 3 RBAC) + +Click **"Save Changes"** + +## Step 3: Enable Socket Mode + +Go to **Socket Mode** in the sidebar: + +1. Toggle **"Enable Socket Mode"** to **ON** +2. Give your token a name: `kagent-socket-token` +3. Click **"Generate"** +4. **SAVE THIS TOKEN** - you'll need it for `SLACK_APP_TOKEN` + - Format: `xapp-1-...` + - This is your **App-Level Token** + +## Step 4: Configure Event Subscriptions + +Go to **Event Subscriptions** in the sidebar: + +1. Toggle **"Enable Events"** to **ON** +2. Under **"Subscribe to bot events"**, add: + - `app_mention` - For @mentions in channels + - `message.im` - For direct messages + +Note: With Socket Mode, you don't need a Request URL! + +## Step 5: Create Slash Commands + +Go to **Slash Commands** in the sidebar: + +### Command 1: /agents +- **Command**: `/agents` +- **Short Description**: `List available Kagent agents` +- **Usage Hint**: `[no parameters]` +- Click **"Save"** + +### Command 2: /agent-switch +- **Command**: `/agent-switch` +- **Short Description**: `Switch to a specific agent or reset to auto-routing` +- **Usage Hint**: `/ or reset` +- Click **"Save"** + +## Step 6: Install App to Workspace + +1. Go to **Install App** in the sidebar +2. Click **"Install to Workspace"** +3. Review permissions +4. Click **"Allow"** + +## Step 7: Collect Your Tokens + +You'll need **3 tokens** for the bot: + +### 1. Bot User OAuth Token +- Go to **OAuth & Permissions** +- Copy **"Bot User OAuth Token"** +- Format: `xoxb-...` +- Save as: `SLACK_BOT_TOKEN` + +### 2. App-Level Token +- Go to **Basic Information** → **App-Level Tokens** +- Copy the token you created in Step 3 +- Format: `xapp-1-...` +- Save as: `SLACK_APP_TOKEN` + +### 3. Signing Secret +- Go to **Basic Information** → **App Credentials** +- Copy **"Signing Secret"** +- Format: alphanumeric string +- Save as: `SLACK_SIGNING_SECRET` + +## Step 8: Configure the Bot + +Create a `.env` file in the slackbot directory: + +```bash +cd /path/to/kagent/slackbot +cp .env.example .env +``` + +Edit `.env` with your tokens: + +```bash +# Slack Configuration +SLACK_BOT_TOKEN=xoxb-your-bot-token-here +SLACK_APP_TOKEN=xapp-1-your-app-token-here +SLACK_SIGNING_SECRET=your-signing-secret-here + +# Kagent Configuration +KAGENT_BASE_URL=http://localhost:8083 +KAGENT_TIMEOUT=30 + +# Server Configuration +SERVER_HOST=0.0.0.0 +SERVER_PORT=8080 + +# Logging +LOG_LEVEL=INFO +``` + +## Step 9: Invite Bot to Channels + +In Slack: + +1. Go to any channel where you want to use the bot +2. Type: `/invite @kagent-bot` +3. Or mention it: `@kagent-bot` (it will prompt you to invite it) + +**Or** just DM the bot directly (no invite needed for DMs) + +## Step 10: Test the Bot + +### Start the bot locally: + +```bash +cd /path/to/kagent/slackbot +source .venv/bin/activate +python -m kagent_slackbot.main +``` + +You should see: +```json +{"event": "Starting Kagent Slackbot", "level": "info", ...} +{"event": "Health server started", "host": "0.0.0.0", "port": 8080, ...} +{"event": "Connecting to Slack via Socket Mode", ...} +``` + +### Test in Slack: + +1. **Test agent list**: + ``` + /agents + ``` + Should show available agents from kagent + +2. **Test @mention**: + ``` + @kagent-bot hello + ``` + Should get a formatted response from an agent + +3. **Test DM**: + - DM the bot directly: "show me kubernetes pods" + - Should respond without needing @mention + +4. **Test agent switching**: + ``` + /agent-switch kagent/k8s-agent + ``` + Should confirm the switch + +## Troubleshooting + +### "Invalid token" error + +- Double-check you copied the tokens correctly +- Ensure no extra spaces or newlines +- Bot token should start with `xoxb-` +- App token should start with `xapp-` + +### Bot doesn't respond to @mentions + +- Check bot is invited to the channel (`/invite @kagent-bot`) +- Verify Socket Mode is enabled +- Check bot logs for connection errors +- Ensure `app_mention` and `message.im` event subscriptions are configured + +### "Missing required scopes" error + +- Reinstall the app to workspace +- Go to OAuth & Permissions → Reinstall App + +### Commands not showing up + +- Slash commands can take a few minutes to propagate +- Try logging out and back into Slack +- Check if commands show up in the `/` menu + +## Quick Checklist + +- [ ] Created Slack app +- [ ] Enabled Socket Mode +- [ ] Added bot scopes: `app_mentions:read`, `chat:write`, `commands`, `reactions:write` +- [ ] Subscribed to `app_mention` event +- [ ] Created `/agents` slash command +- [ ] Created `/agent-switch` slash command +- [ ] Installed app to workspace +- [ ] Copied all 3 tokens +- [ ] Created `.env` file with tokens +- [ ] Invited bot to test channel +- [ ] Bot started successfully +- [ ] Bot responds to @mentions + +## Next Steps + +Once the bot is working: +- Set up Kubernetes deployment (see README.md) +- Configure RBAC with Slack user groups (Phase 3) +- Add streaming responses (Phase 2) diff --git a/slackbot/manifests/k8s/configmap-permissions.example.yaml b/slackbot/manifests/k8s/configmap-permissions.example.yaml new file mode 100644 index 000000000..8d3512e4b --- /dev/null +++ b/slackbot/manifests/k8s/configmap-permissions.example.yaml @@ -0,0 +1,29 @@ +# Agent-level permissions configuration +# Configure which users/groups can access which agents + +# Example: Restrict k8s-agent to SRE team +# agent_permissions: +# kagent/k8s-agent: +# user_groups: +# - S0T8FCWSB # Replace with your Slack user group ID +# users: +# - admin@company.com +# deny_message: "K8s agent requires @sre-team membership" +# +# kagent/observability-agent: +# user_groups: +# - S1A2B3C4D # Replace with your Slack user group ID +# users: [] +# deny_message: "Observability agent requires @monitoring-team membership" +# +# kagent/helm-agent: +# # Public - no restrictions +# user_groups: [] +# users: [] + +# Default: If agent not listed above, it's public (accessible to all) +agent_permissions: {} + +# Global settings +settings: + user_group_cache_ttl: 300 # 5 minutes diff --git a/slackbot/manifests/k8s/configmap.yaml b/slackbot/manifests/k8s/configmap.yaml new file mode 100644 index 000000000..13381faf9 --- /dev/null +++ b/slackbot/manifests/k8s/configmap.yaml @@ -0,0 +1,32 @@ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: kagent-slackbot-config + namespace: kagent-slackbot +data: + KAGENT_BASE_URL: "http://kagent.kagent.svc.cluster.local:8083" + KAGENT_TIMEOUT: "30" + SERVER_HOST: "0.0.0.0" + SERVER_PORT: "8080" + LOG_LEVEL: "INFO" + permissions.yaml: | + # Agent-level permissions configuration + # Configure which users/groups can access which agents + + # Example: Restrict k8s-agent to SRE team + # agent_permissions: + # kagent/k8s-agent: + # user_groups: + # - S0T8FCWSB # Replace with your Slack user group ID + # users: + # - admin@company.com + # deny_message: "K8s agent requires @sre-team membership" + + # Default: If agent not listed above, it's public (accessible to all) + agent_permissions: {} + + # Global settings + settings: + user_group_cache_ttl: 300 # 5 minutes + PERMISSIONS_FILE: "/app/config/permissions.yaml" diff --git a/slackbot/manifests/k8s/deployment.yaml b/slackbot/manifests/k8s/deployment.yaml new file mode 100644 index 000000000..c2b4ad0d3 --- /dev/null +++ b/slackbot/manifests/k8s/deployment.yaml @@ -0,0 +1,101 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kagent-slackbot + namespace: kagent-slackbot + labels: + app: kagent-slackbot +spec: + replicas: 2 + selector: + matchLabels: + app: kagent-slackbot + template: + metadata: + labels: + app: kagent-slackbot + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "8080" + prometheus.io/path: "/metrics" + spec: + serviceAccountName: kagent-slackbot + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + containers: + - name: slackbot + image: kagent-slackbot:latest + imagePullPolicy: Always + ports: + - containerPort: 8080 + name: http + protocol: TCP + env: + - name: SLACK_BOT_TOKEN + valueFrom: + secretKeyRef: + name: kagent-slackbot-secrets + key: slack-bot-token + - name: SLACK_APP_TOKEN + valueFrom: + secretKeyRef: + name: kagent-slackbot-secrets + key: slack-app-token + - name: SLACK_SIGNING_SECRET + valueFrom: + secretKeyRef: + name: kagent-slackbot-secrets + key: slack-signing-secret + envFrom: + - configMapRef: + name: kagent-slackbot-config + resources: + requests: + memory: "256Mi" + cpu: "200m" + limits: + memory: "512Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /ready + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1000 + capabilities: + drop: + - ALL + volumeMounts: + - name: tmp + mountPath: /tmp + - name: permissions-config + mountPath: /app/config + readOnly: true + volumes: + - name: tmp + emptyDir: {} + - name: permissions-config + configMap: + name: kagent-slackbot-config + items: + - key: permissions.yaml + path: permissions.yaml diff --git a/slackbot/manifests/k8s/hpa.yaml b/slackbot/manifests/k8s/hpa.yaml new file mode 100644 index 000000000..75fb18026 --- /dev/null +++ b/slackbot/manifests/k8s/hpa.yaml @@ -0,0 +1,39 @@ +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: slack-bot-hpa + namespace: slack-bot +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: slack-bot + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 + behavior: + scaleDown: + stabilizationWindowSeconds: 300 + policies: + - type: Percent + value: 10 + periodSeconds: 60 + scaleUp: + stabilizationWindowSeconds: 60 + policies: + - type: Percent + value: 100 + periodSeconds: 15 diff --git a/slackbot/manifests/k8s/namespace.yaml b/slackbot/manifests/k8s/namespace.yaml new file mode 100644 index 000000000..8f347c890 --- /dev/null +++ b/slackbot/manifests/k8s/namespace.yaml @@ -0,0 +1,7 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: kagent-slackbot + labels: + name: kagent-slackbot diff --git a/slackbot/manifests/k8s/pdb.yaml b/slackbot/manifests/k8s/pdb.yaml new file mode 100644 index 000000000..695d53936 --- /dev/null +++ b/slackbot/manifests/k8s/pdb.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: slack-bot-pdb + namespace: slack-bot +spec: + minAvailable: 1 + selector: + matchLabels: + app: slack-bot diff --git a/slackbot/manifests/k8s/role.yaml b/slackbot/manifests/k8s/role.yaml new file mode 100644 index 000000000..a9a1e8a6b --- /dev/null +++ b/slackbot/manifests/k8s/role.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: slack-bot-role + namespace: slack-bot +rules: +# Minimal permissions - no cluster-wide access +- apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "list"] +- apiGroups: [""] + resources: ["secrets"] + verbs: ["get"] + resourceNames: ["slack-secrets"] diff --git a/slackbot/manifests/k8s/rolebinding.yaml b/slackbot/manifests/k8s/rolebinding.yaml new file mode 100644 index 000000000..a5c9d3682 --- /dev/null +++ b/slackbot/manifests/k8s/rolebinding.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: slack-bot-binding + namespace: slack-bot +subjects: +- kind: ServiceAccount + name: slack-bot-sa + namespace: slack-bot +roleRef: + kind: Role + name: slack-bot-role + apiGroup: rbac.authorization.k8s.io diff --git a/slackbot/manifests/k8s/secret.yaml b/slackbot/manifests/k8s/secret.yaml new file mode 100644 index 000000000..3f293f3ad --- /dev/null +++ b/slackbot/manifests/k8s/secret.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: slack-secrets + namespace: slack-bot +type: Opaque +stringData: + # Slack Bot Token - replace with actual values + SLACK_BOT_TOKEN: "xoxb-your-bot-token-here" + # Slack Signing Secret for webhook verification + SLACK_SIGNING_SECRET: "your-signing-secret-here" + # Slack App Token for Socket Mode (if needed) + SLACK_APP_TOKEN: "xapp-your-app-token-here" + # Slack Team ID + SLACK_TEAM_ID: "T1234567890" + # Slack Channel IDs (comma-separated) + SLACK_CHANNEL_IDS: "C1234567890,C0987654321" diff --git a/slackbot/manifests/k8s/service.yaml b/slackbot/manifests/k8s/service.yaml new file mode 100644 index 000000000..30140cb80 --- /dev/null +++ b/slackbot/manifests/k8s/service.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: slack-bot-service + namespace: slack-bot + labels: + app: slack-bot + annotations: + cloud.google.com/backend-config: '{"default": "slack-webhook-backend"}' +spec: + type: ClusterIP + selector: + app: slack-bot + ports: + - name: http + port: 80 + targetPort: 8080 + protocol: TCP diff --git a/slackbot/manifests/k8s/serviceaccount.yaml b/slackbot/manifests/k8s/serviceaccount.yaml new file mode 100644 index 000000000..cee74fff7 --- /dev/null +++ b/slackbot/manifests/k8s/serviceaccount.yaml @@ -0,0 +1,9 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: slack-bot-sa + namespace: slack-bot + labels: + app: slack-bot +automountServiceAccountToken: false diff --git a/slackbot/pyproject.toml b/slackbot/pyproject.toml new file mode 100644 index 000000000..c6576d2bf --- /dev/null +++ b/slackbot/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "kagent-slackbot" +version = "0.1.0" +requires-python = ">=3.13" +dependencies = [ + "slack-bolt>=1.26.0", + "httpx>=0.27.0", + "python-dotenv>=1.0.0", + "pyyaml>=6.0.0", + "prometheus-client>=0.20.0", + "structlog>=24.0.0", + "aiohttp>=3.9.0", +] + +[project.optional-dependencies] +dev = [ + "mypy>=1.11.0", + "ruff>=0.6.0", + "types-pyyaml", +] + +[tool.ruff] +line-length = 120 +target-version = "py313" + +[tool.mypy] +python_version = "3.13" +strict = true diff --git a/slackbot/slack-app-manifest.yaml b/slackbot/slack-app-manifest.yaml new file mode 100644 index 000000000..8b189e075 --- /dev/null +++ b/slackbot/slack-app-manifest.yaml @@ -0,0 +1,36 @@ +display_information: + name: kagent-bot + description: Kagent Multi-Agent AI Assistant + background_color: "#483354" + long_description: Kagent Bot connects you to multiple AI agents in the Kagent platform. It automatically routes your questions to the right agent based on keywords, or you can manually select specific agents. Conversations maintain full context across multiple turns, and you can interact via @mentions or direct messages. +features: + bot_user: + display_name: kagent-bot + always_online: true + slash_commands: + - command: /agents + description: List available Kagent agents + usage_hint: "[no parameters]" + should_escape: false + - command: /agent-switch + description: Switch to a specific agent or reset to auto-routing + usage_hint: "/ or reset" + should_escape: false +oauth_config: + scopes: + bot: + - app_mentions:read + - chat:write + - commands + - reactions:write + - im:history + - users:read + - users:read.email +settings: + event_subscriptions: + bot_events: + - app_mention + - message.im + org_deploy_enabled: false + socket_mode_enabled: true + token_rotation_enabled: false diff --git a/slackbot/src/kagent_slackbot/__init__.py b/slackbot/src/kagent_slackbot/__init__.py new file mode 100644 index 000000000..dffd87137 --- /dev/null +++ b/slackbot/src/kagent_slackbot/__init__.py @@ -0,0 +1,3 @@ +"""Kagent Slackbot - Production-ready Slack bot for Kagent multi-agent platform""" + +__version__ = "0.1.0" diff --git a/slackbot/src/kagent_slackbot/auth/__init__.py b/slackbot/src/kagent_slackbot/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/slackbot/src/kagent_slackbot/auth/permissions.py b/slackbot/src/kagent_slackbot/auth/permissions.py new file mode 100644 index 000000000..ff2ada7ae --- /dev/null +++ b/slackbot/src/kagent_slackbot/auth/permissions.py @@ -0,0 +1,121 @@ +"""Agent permission checking""" + +import yaml +from typing import Tuple, Any +from structlog import get_logger +from .slack_groups import SlackGroupChecker + +logger = get_logger(__name__) + + +class PermissionChecker: + """Check agent access permissions using FilterByUser pattern""" + + def __init__(self, config_path: str, group_checker: SlackGroupChecker): + self.group_checker = group_checker + self.config = self._load_config(config_path) + self.permissions = self.config.get("agent_permissions", {}) + + def _load_config(self, path: str) -> dict[str, Any]: + """Load permissions from YAML""" + try: + with open(path) as f: + config = yaml.safe_load(f) + return config if config else {} + except FileNotFoundError: + logger.warning("Permissions config not found", path=path) + return {} + except Exception as e: + logger.error("Failed to load permissions config", path=path, error=str(e)) + return {} + + async def can_access_agent( + self, + user_id: str, + agent_ref: str, + ) -> Tuple[bool, str]: + """ + Check if user can access agent (FilterByUser pattern from breakglass-bot) + + Args: + user_id: Slack user ID + agent_ref: Agent reference (namespace/name) + + Returns: + Tuple of (allowed, reason) + """ + + # If agent not in config, allow by default (public agent) + if agent_ref not in self.permissions: + return True, "public agent" + + perms = self.permissions[agent_ref] + + # Get user email + user_email = await self.group_checker.get_user_email(user_id) + + # Check specific users allowlist + if user_email in perms.get("users", []): + logger.info("User allowed via allowlist", user=user_id, agent=agent_ref) + return True, "user allowlist" + + # Check user groups + for group_id in perms.get("user_groups", []): + if await self.group_checker.is_user_in_group(user_id, group_id): + logger.info( + "User allowed via group", + user=user_id, + agent=agent_ref, + group=group_id, + ) + return True, "group membership" + + # If both lists empty, agent is public + if not perms.get("users") and not perms.get("user_groups"): + return True, "public agent" + + # Denied + deny_msg = perms.get("deny_message", f"Access denied to {agent_ref}") + + logger.warning( + "User denied access to agent", + user=user_id, + agent=agent_ref, + ) + + return False, deny_msg + + async def filter_agents_by_user( + self, + user_id: str, + agents: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + """ + Filter agent list to only show accessible agents (FilterByUser pattern) + + Args: + user_id: Slack user ID + agents: List of agent info dicts + + Returns: + Filtered list of agents user can access + """ + allowed = [] + + for agent in agents: + ref = f"{agent['namespace']}/{agent['name']}" + can_access, _ = await self.can_access_agent(user_id, ref) + + if can_access: + allowed.append(agent) + else: + logger.debug("Agent filtered out for user", user=user_id, agent=ref) + + logger.info( + "Filtered agents for user", + user=user_id, + total=len(agents), + allowed=len(allowed), + ) + + return allowed diff --git a/slackbot/src/kagent_slackbot/auth/slack_groups.py b/slackbot/src/kagent_slackbot/auth/slack_groups.py new file mode 100644 index 000000000..4b67456b6 --- /dev/null +++ b/slackbot/src/kagent_slackbot/auth/slack_groups.py @@ -0,0 +1,95 @@ +"""Slack user group membership checking""" + +import time +from slack_sdk.web.async_client import AsyncWebClient +from structlog import get_logger + +logger = get_logger(__name__) + + +class SlackGroupChecker: + """Check Slack user group membership with caching""" + + def __init__(self, client: AsyncWebClient, cache_ttl: int = 300): + self.client = client + self.cache_ttl = cache_ttl + self.cache: dict[str, tuple[set[str], float]] = {} + self.email_cache: dict[str, tuple[str, float]] = {} + + async def is_user_in_group(self, user_id: str, group_id: str) -> bool: + """ + Check if user is in Slack user group + + Args: + user_id: Slack user ID + group_id: Slack user group ID + + Returns: + True if user is in group + """ + # Check cache + if group_id in self.cache: + members, timestamp = self.cache[group_id] + if time.time() - timestamp < self.cache_ttl: + return user_id in members + + # Fetch from Slack API + try: + response = await self.client.usergroups_users_list(usergroup=group_id) + members = set(response["users"]) + + # Update cache + self.cache[group_id] = (members, time.time()) + + result = user_id in members + + logger.debug( + "Checked group membership", + user=user_id, + group=group_id, + is_member=result, + ) + + return result + + except Exception as e: + logger.error( + "Failed to check group membership", + user=user_id, + group=group_id, + error=str(e), + ) + return False + + async def get_user_email(self, user_id: str) -> str: + """ + Get user email from Slack + + Args: + user_id: Slack user ID + + Returns: + User email address (lowercase) + """ + # Check cache + if user_id in self.email_cache: + email, timestamp = self.email_cache[user_id] + if time.time() - timestamp < self.cache_ttl: + return email + + # Fetch from Slack API + try: + response = await self.client.users_info(user=user_id) + user = response["user"] + email = str(user["profile"]["email"]).lower() + + # Update cache + self.email_cache[user_id] = (email, time.time()) + + logger.debug("Retrieved user email", user=user_id, email=email) + + return email + + except Exception as e: + logger.error("Failed to get user email", user=user_id, error=str(e)) + return "" diff --git a/slackbot/src/kagent_slackbot/config.py b/slackbot/src/kagent_slackbot/config.py new file mode 100644 index 000000000..fda62179c --- /dev/null +++ b/slackbot/src/kagent_slackbot/config.py @@ -0,0 +1,75 @@ +from dataclasses import dataclass +import os +from dotenv import load_dotenv + +load_dotenv() + + +@dataclass +class SlackConfig: + """Slack-specific configuration""" + + bot_token: str + app_token: str + signing_secret: str + + +@dataclass +class KagentConfig: + """Kagent API configuration""" + + base_url: str + timeout: int = 30 + + +@dataclass +class ServerConfig: + """HTTP server configuration""" + + host: str = "0.0.0.0" + port: int = 8080 + + +@dataclass +class Config: + """Main application configuration""" + + slack: SlackConfig + kagent: KagentConfig + server: ServerConfig + permissions_file: str = "config/permissions.yaml" + log_level: str = "INFO" + + +def load_config() -> Config: + """Load configuration from environment variables""" + + # Required variables + required = [ + "SLACK_BOT_TOKEN", + "SLACK_APP_TOKEN", + "SLACK_SIGNING_SECRET", + "KAGENT_BASE_URL", + ] + + missing = [var for var in required if not os.getenv(var)] + if missing: + raise ValueError(f"Missing required environment variables: {', '.join(missing)}") + + return Config( + slack=SlackConfig( + bot_token=os.environ["SLACK_BOT_TOKEN"], + app_token=os.environ["SLACK_APP_TOKEN"], + signing_secret=os.environ["SLACK_SIGNING_SECRET"], + ), + kagent=KagentConfig( + base_url=os.environ["KAGENT_BASE_URL"], + timeout=int(os.getenv("KAGENT_TIMEOUT", "30")), + ), + server=ServerConfig( + host=os.getenv("SERVER_HOST", "0.0.0.0"), + port=int(os.getenv("SERVER_PORT", "8080")), + ), + permissions_file=os.getenv("PERMISSIONS_FILE", "config/permissions.yaml"), + log_level=os.getenv("LOG_LEVEL", "INFO"), + ) diff --git a/slackbot/src/kagent_slackbot/constants.py b/slackbot/src/kagent_slackbot/constants.py new file mode 100644 index 000000000..43aed52fa --- /dev/null +++ b/slackbot/src/kagent_slackbot/constants.py @@ -0,0 +1,23 @@ +"""Application constants""" + +# Slack message limits +SLACK_BLOCK_LIMIT = 2900 # Characters per block + +# User input limits +MAX_MESSAGE_LENGTH = 4000 +MIN_MESSAGE_LENGTH = 1 + +# Agent discovery +AGENT_CACHE_TTL = 300 # 5 minutes + +# Session ID format +SESSION_ID_PREFIX = "slack" + +# Default agent (fallback) +DEFAULT_AGENT_NAMESPACE = "kagent" +DEFAULT_AGENT_NAME = "k8s-agent" + +# Emojis for UX +EMOJI_ROBOT = ":robot_face:" +EMOJI_THINKING = ":thinking_face:" +EMOJI_CLOCK = ":clock1:" diff --git a/slackbot/src/kagent_slackbot/handlers/__init__.py b/slackbot/src/kagent_slackbot/handlers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/slackbot/src/kagent_slackbot/handlers/actions.py b/slackbot/src/kagent_slackbot/handlers/actions.py new file mode 100644 index 000000000..267627c2f --- /dev/null +++ b/slackbot/src/kagent_slackbot/handlers/actions.py @@ -0,0 +1,148 @@ +"""Action (button) handlers""" + +from typing import Any +from slack_bolt.async_app import AsyncApp +from slack_sdk.web.async_client import AsyncWebClient +from structlog import get_logger + +from ..services.a2a_client import A2AClient + +logger = get_logger(__name__) + + +def register_action_handlers(app: AsyncApp, a2a_client: A2AClient) -> None: + """Register action handlers for interactive buttons""" + + @app.action("approval_approve") + async def handle_approval_approve( + ack: Any, + action: dict[str, Any], + body: dict[str, Any], + client: AsyncWebClient, + ) -> None: + """Handle approval button click""" + await ack() + + button_value = action["value"] + parts = button_value.split("|") + session_id = parts[0] + agent_full_name = parts[1] if len(parts) > 1 else "" + + user_id = body["user"]["id"] + channel = body["container"]["channel_id"] + message_ts = body["container"]["message_ts"] + + logger.info( + "User approved action", + user=user_id, + session=session_id, + agent=agent_full_name, + ) + + # Send approval message back to agent in same session + if "/" in agent_full_name: + namespace, agent_name = agent_full_name.split("/", 1) + + try: + await a2a_client.invoke_agent( + namespace=namespace, + agent_name=agent_name, + message="User approved: proceed with the action.", + session_id=session_id, + user_id=user_id, + ) + + await client.chat_update( + channel=channel, + ts=message_ts, + text="✅ Approved - Agent will proceed", + blocks=body["message"]["blocks"] + + [ + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "✅ _User approved - agent proceeding_", + } + ], + } + ], + ) + + logger.info("Approval sent to agent", session=session_id, agent=agent_full_name) + + except Exception as e: + logger.error("Failed to send approval", error=str(e), session=session_id) + await client.chat_postEphemeral( + channel=channel, + user=user_id, + text=f"❌ Failed to send approval to agent: {str(e)}", + ) + + @app.action("approval_deny") + async def handle_approval_deny( + ack: Any, + action: dict[str, Any], + body: dict[str, Any], + client: AsyncWebClient, + ) -> None: + """Handle denial button click""" + await ack() + + button_value = action["value"] + parts = button_value.split("|") + session_id = parts[0] + agent_full_name = parts[1] if len(parts) > 1 else "" + + user_id = body["user"]["id"] + channel = body["container"]["channel_id"] + message_ts = body["container"]["message_ts"] + + logger.info( + "User denied action", + user=user_id, + session=session_id, + agent=agent_full_name, + ) + + # Send denial message back to agent + if "/" in agent_full_name: + namespace, agent_name = agent_full_name.split("/", 1) + + try: + await a2a_client.invoke_agent( + namespace=namespace, + agent_name=agent_name, + message="User denied: cancel the action and do not proceed.", + session_id=session_id, + user_id=user_id, + ) + + await client.chat_update( + channel=channel, + ts=message_ts, + text="❌ Denied - Agent will not proceed", + blocks=body["message"]["blocks"] + + [ + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "❌ _User denied - agent canceled_", + } + ], + } + ], + ) + + logger.info("Denial sent to agent", session=session_id, agent=agent_full_name) + + except Exception as e: + logger.error("Failed to send denial", error=str(e), session=session_id) + await client.chat_postEphemeral( + channel=channel, + user=user_id, + text=f"❌ Failed to send denial to agent: {str(e)}", + ) diff --git a/slackbot/src/kagent_slackbot/handlers/commands.py b/slackbot/src/kagent_slackbot/handlers/commands.py new file mode 100644 index 000000000..453085c8e --- /dev/null +++ b/slackbot/src/kagent_slackbot/handlers/commands.py @@ -0,0 +1,199 @@ +"""Slash command handlers""" + +from typing import Any +from slack_bolt.async_app import AsyncApp +from slack_bolt.context.ack.async_ack import AsyncAck +from slack_bolt.context.respond.async_respond import AsyncRespond +from structlog import get_logger + +from ..services.agent_discovery import AgentDiscovery +from ..services.agent_router import AgentRouter +from ..auth.permissions import PermissionChecker +from ..slack.formatters import format_agent_list, format_error +from ..constants import EMOJI_ROBOT + +logger = get_logger(__name__) + + +def register_command_handlers( + app: AsyncApp, + agent_discovery: AgentDiscovery, + agent_router: AgentRouter, + permission_checker: PermissionChecker, +) -> None: + """Register slash command handlers""" + + @app.command("/agents") + async def handle_agents_command( + ack: AsyncAck, + command: dict[str, Any], + respond: AsyncRespond, + ) -> None: + """List available agents""" + await ack() + + user_id = command["user_id"] + + logger.info("Listing agents", user=user_id) + + try: + # Discover agents + agents_dict = await agent_discovery.discover_agents() + + # Format for display + agents_list = [ + { + "namespace": agent.namespace, + "name": agent.name, + "description": agent.description, + "ready": agent.ready, + } + for agent in agents_dict.values() + ] + + # Sort by namespace/name + agents_list.sort(key=lambda a: (a["namespace"], a["name"])) + + # Filter by user permissions (RBAC) + agents_list = await permission_checker.filter_agents_by_user(user_id, agents_list) + + if not agents_list: + await respond( + blocks=format_error("No agents available or accessible to you at the moment."), + response_type="ephemeral", + ) + return + + blocks = format_agent_list(agents_list) + await respond(blocks=blocks, response_type="ephemeral") + + logger.info("Listed agents", user=user_id, count=len(agents_list)) + + except Exception as e: + logger.error("Failed to list agents", user=user_id, error=str(e)) + await respond( + blocks=format_error(f"Failed to fetch agents: {str(e)}"), + response_type="ephemeral", + ) + + @app.command("/agent-switch") + async def handle_agent_switch_command( + ack: AsyncAck, + command: dict[str, Any], + respond: AsyncRespond, + ) -> None: + """Switch to specific agent""" + await ack() + + user_id = command["user_id"] + text = command.get("text", "").strip() + + logger.info("Agent switch requested", user=user_id, text=text) + + # Handle reset command + if text.lower() == "reset": + agent_router.clear_explicit_agent(user_id) + + await respond( + text=( + ":recycle: *Agent selection reset*\n\n" + "I'll now automatically select the best agent based on your message." + ), + response_type="ephemeral", + ) + + logger.info("Agent selection reset", user=user_id) + return + + if not text: + await respond( + text=( + f"{EMOJI_ROBOT} *Agent Switch*\n\n" + "Usage: `/agent-switch /`\n\n" + "Example: `/agent-switch kagent/k8s-agent`\n\n" + "Use `/agents` to see available agents." + ), + response_type="ephemeral", + ) + return + + # Parse namespace/name + if "/" not in text: + await respond( + blocks=format_error( + "Invalid format. Use: `/agent-switch /`\n" + "Example: `/agent-switch kagent/k8s-agent`" + ), + response_type="ephemeral", + ) + return + + try: + namespace, name = text.split("/", 1) + namespace = namespace.strip() + name = name.strip() + + # Verify agent exists + agent = await agent_discovery.get_agent(namespace, name) + + if not agent: + await respond( + blocks=format_error( + f"Agent `{namespace}/{name}` not found.\n" "Use `/agents` to see available agents." + ), + response_type="ephemeral", + ) + return + + if not agent.ready: + await respond( + blocks=format_error( + f"Agent `{namespace}/{name}` exists but is not ready.\n" + "Please try again later or choose a different agent." + ), + response_type="ephemeral", + ) + return + + # Check permissions (RBAC) + agent_ref = f"{namespace}/{name}" + can_access, access_reason = await permission_checker.can_access_agent(user_id, agent_ref) + + if not can_access: + await respond( + blocks=format_error(f"⛔ {access_reason}"), + response_type="ephemeral", + ) + logger.warning("User denied access to agent", user=user_id, agent=agent_ref) + return + + # Set explicit agent selection + agent_router.set_explicit_agent(user_id, namespace, name) + + await respond( + text=( + f":white_check_mark: *Switched to {namespace}/{name}*\n\n" + f"_{agent.description}_\n\n" + "Your next messages will be routed to this agent.\n" + "To return to automatic routing, use `/agent-switch reset`" + ), + response_type="ephemeral", + ) + + logger.info( + "Agent switched", + user=user_id, + agent=f"{namespace}/{name}", + ) + + except ValueError: + await respond( + blocks=format_error("Invalid format. Use: `/agent-switch /`"), + response_type="ephemeral", + ) + except Exception as e: + logger.error("Failed to switch agent", user=user_id, error=str(e)) + await respond( + blocks=format_error(f"Failed to switch agent: {str(e)}"), + response_type="ephemeral", + ) diff --git a/slackbot/src/kagent_slackbot/handlers/mentions.py b/slackbot/src/kagent_slackbot/handlers/mentions.py new file mode 100644 index 000000000..aa719efbd --- /dev/null +++ b/slackbot/src/kagent_slackbot/handlers/mentions.py @@ -0,0 +1,257 @@ +"""App mention handlers""" + +import time +from typing import Any +from slack_bolt.async_app import AsyncApp +from slack_sdk.web.async_client import AsyncWebClient +from slack_bolt.context.say.async_say import AsyncSay +from structlog import get_logger + +from ..services.a2a_client import A2AClient +from ..services.agent_router import AgentRouter +from ..services.agent_discovery import AgentDiscovery +from ..auth.permissions import PermissionChecker +from ..slack.validators import validate_message, sanitize_message, strip_bot_mention +from ..slack.formatters import format_agent_response, format_error +from ..constants import SESSION_ID_PREFIX, EMOJI_THINKING + +logger = get_logger(__name__) + + +def register_mention_handlers( + app: AsyncApp, + a2a_client: A2AClient, + agent_router: AgentRouter, + agent_discovery: AgentDiscovery, + permission_checker: PermissionChecker, +) -> None: + """Register app mention and DM handlers""" + + async def process_user_message( + event: dict[str, Any], + say: AsyncSay, + client: AsyncWebClient, + is_dm: bool = False, + ) -> None: + """Shared logic for processing messages from @mentions or DMs""" + + user_id = event["user"] + channel = event["channel"] + text = event["text"] + thread_ts = event.get("thread_ts", event["ts"]) + + logger.info( + "Received message", + user=user_id, + channel=channel, + thread_ts=thread_ts, + is_dm=is_dm, + ) + + # Acknowledge with reaction + try: + await client.reactions_add( + channel=channel, + timestamp=event["ts"], + name="eyes", + ) + except Exception as e: + logger.warning("Failed to add reaction", error=str(e)) + + # Strip bot mention (for @mentions) and validate + if not is_dm: + message = strip_bot_mention(text) + else: + message = text + message = sanitize_message(message) + + if not validate_message(message): + await say( + blocks=format_error("Please provide a message after mentioning me!"), + thread_ts=thread_ts, + ) + return + + # Build session ID (includes thread_ts to isolate thread contexts) + session_id = f"{SESSION_ID_PREFIX}-{user_id}-{channel}-{thread_ts}" + + try: + # Route to agent + start_time = time.time() + namespace, agent_name, reason = await agent_router.route(message, user_id) + + # Check permissions (RBAC) + agent_ref = f"{namespace}/{agent_name}" + can_access, access_reason = await permission_checker.can_access_agent(user_id, agent_ref) + + if not can_access: + await say( + blocks=format_error(f"⛔ {access_reason}"), + thread_ts=thread_ts, + ) + logger.warning( + "User denied access to agent", + user=user_id, + agent=agent_ref, + reason=access_reason, + ) + return + + # Check if agent supports streaming + agent = await agent_discovery.get_agent(namespace, agent_name) + use_streaming = agent and agent.type == "Declarative" + + if use_streaming: + # Streaming response with real-time updates + working_msg = await say( + text=f"{EMOJI_THINKING} Processing your request...", + thread_ts=thread_ts, + ) + working_ts = working_msg["ts"] + + response_text = "" + last_update = time.time() + + try: + async for event in a2a_client.stream_agent( + namespace, agent_name, message, session_id, user_id + ): + # Extract message from event + result = event.get("result", {}) + status = result.get("status", {}) + + if status.get("message"): + msg = status["message"] + # Only accumulate agent messages, not user messages + if msg.get("role") == "agent" or not msg.get("role"): + parts = msg.get("parts", []) + for part in parts: + if part.get("text"): + response_text += part["text"] + + # Update every 2 seconds + if time.time() - last_update > 2: + preview = response_text[:1000] + ("..." if len(response_text) > 1000 else "") + await client.chat_update( + channel=channel, + ts=working_ts, + text=preview, + ) + last_update = time.time() + + # Final update with full formatted response + response_time = time.time() - start_time + blocks = format_agent_response( + agent_name=f"{namespace}/{agent_name}", + response_text=response_text or "Agent completed but returned no message.", + routing_reason=reason, + response_time=response_time, + session_id=session_id, + ) + + await client.chat_update( + channel=channel, + ts=working_ts, + text=response_text[:100], + blocks=blocks, + ) + + logger.info( + "Successfully processed streaming message", + user=user_id, + agent=f"{namespace}/{agent_name}", + response_time=response_time, + ) + + except Exception as e: + # If streaming fails, update message with error and exit cleanly + logger.error("Streaming failed", error=str(e), exc_info=True) + await client.chat_update( + channel=channel, + ts=working_ts, + blocks=format_error(f"Sorry, streaming failed: {str(e)}"), + ) + return # Exit cleanly - error already shown to user + + else: + # Fallback to synchronous invocation + result = await a2a_client.invoke_agent( + namespace=namespace, + agent_name=agent_name, + message=message, + session_id=session_id, + user_id=user_id, + ) + + response_time = time.time() - start_time + + # Extract response text from A2A result + task = result.get("result", {}) + history = task.get("history", []) + + if history: + # Get only agent messages, not user messages + agent_messages = [msg for msg in history if msg.get("role") == "agent"] + if agent_messages: + last_message = agent_messages[-1] + parts = last_message.get("parts", []) + response_text = "\n".join(part.get("text", "") for part in parts if part.get("text")) + else: + response_text = "Agent responded but no message was returned." + else: + response_text = "Agent responded but no message was returned." + + # Format and send response + blocks = format_agent_response( + agent_name=f"{namespace}/{agent_name}", + response_text=response_text, + routing_reason=reason, + response_time=response_time, + session_id=session_id, + ) + + await say(blocks=blocks, thread_ts=thread_ts) + + logger.info( + "Successfully processed mention", + user=user_id, + agent=f"{namespace}/{agent_name}", + response_time=response_time, + ) + + except Exception as e: + logger.error( + "Failed to process mention", + user=user_id, + error=str(e), + exc_info=True, + ) + + await say( + blocks=format_error( + f"Sorry, I encountered an error: {str(e)}\n\n" + "Please try again or contact support if the issue persists." + ), + thread_ts=thread_ts, + ) + + # Register event handlers + @app.event("app_mention") + async def handle_mention( + event: dict[str, Any], + say: AsyncSay, + client: AsyncWebClient, + ) -> None: + """Handle @bot mentions in channels""" + await process_user_message(event, say, client, is_dm=False) + + @app.event("message") + async def handle_dm( + event: dict[str, Any], + say: AsyncSay, + client: AsyncWebClient, + ) -> None: + """Handle direct messages to bot""" + # Only handle DMs (channels starting with 'D'), ignore bot messages + if event.get("channel_type") == "im" and not event.get("bot_id"): + await process_user_message(event, say, client, is_dm=True) diff --git a/slackbot/src/kagent_slackbot/handlers/middleware.py b/slackbot/src/kagent_slackbot/handlers/middleware.py new file mode 100644 index 000000000..7ef920904 --- /dev/null +++ b/slackbot/src/kagent_slackbot/handlers/middleware.py @@ -0,0 +1,46 @@ +"""Middleware for metrics and logging""" + +import time +from typing import Any, Callable, Awaitable +from slack_bolt.async_app import AsyncApp +from prometheus_client import Counter, Histogram +from structlog import get_logger + +logger = get_logger(__name__) + +# Prometheus metrics +slack_messages_total = Counter( + "slack_messages_total", "Total Slack messages processed", ["event_type", "status"] +) + +slack_message_duration_seconds = Histogram( + "slack_message_duration_seconds", "Message processing duration", ["event_type"] +) + +slack_commands_total = Counter("slack_commands_total", "Total slash commands", ["command", "status"]) + +agent_invocations_total = Counter( + "agent_invocations_total", "Total agent invocations", ["agent", "status"] +) + + +def register_middleware(app: AsyncApp) -> None: + """Register middleware for metrics and logging""" + + @app.middleware + async def metrics_middleware(body: dict[str, Any], next_: Callable[[], Awaitable[None]]) -> None: + """Collect metrics for all events""" + + event_type = body.get("event", {}).get("type") or body.get("type", "unknown") + start_time = time.time() + + try: + await next_() + slack_messages_total.labels(event_type=event_type, status="success").inc() + except Exception as e: + slack_messages_total.labels(event_type=event_type, status="error").inc() + logger.error("Middleware error", event_type=event_type, error=str(e)) + raise + finally: + duration = time.time() - start_time + slack_message_duration_seconds.labels(event_type=event_type).observe(duration) diff --git a/slackbot/src/kagent_slackbot/main.py b/slackbot/src/kagent_slackbot/main.py new file mode 100644 index 000000000..6790f5455 --- /dev/null +++ b/slackbot/src/kagent_slackbot/main.py @@ -0,0 +1,136 @@ +"""Main application entry point""" + +import asyncio +import structlog +from slack_bolt.async_app import AsyncApp +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler +from aiohttp import web +from prometheus_client import generate_latest, CONTENT_TYPE_LATEST + +from .config import load_config +from .services.a2a_client import A2AClient +from .services.agent_discovery import AgentDiscovery +from .services.agent_router import AgentRouter +from .auth.slack_groups import SlackGroupChecker +from .auth.permissions import PermissionChecker +from .handlers.mentions import register_mention_handlers +from .handlers.commands import register_command_handlers +from .handlers.middleware import register_middleware +from .handlers.actions import register_action_handlers + +# Configure structured logging +structlog.configure( + processors=[ + structlog.stdlib.filter_by_level, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.UnicodeDecoder(), + structlog.processors.JSONRenderer(), + ], + context_class=dict, + logger_factory=structlog.stdlib.LoggerFactory(), + cache_logger_on_first_use=True, +) + +logger = structlog.get_logger(__name__) + + +async def health_check(request: web.Request) -> web.Response: + """Health check endpoint""" + return web.Response(text="OK") + + +async def metrics_endpoint(request: web.Request) -> web.Response: + """Prometheus metrics endpoint""" + return web.Response( + body=generate_latest(), + content_type=CONTENT_TYPE_LATEST, + ) + + +async def start_health_server(host: str, port: int) -> None: + """Start health check HTTP server""" + app = web.Application() + app.router.add_get("/health", health_check) + app.router.add_get("/ready", health_check) + app.router.add_get("/metrics", metrics_endpoint) + + runner = web.AppRunner(app) + await runner.setup() + + site = web.TCPSite(runner, host, port) + await site.start() + + logger.info("Health server started", host=host, port=port) + + +async def main() -> None: + """Main application""" + + # Load configuration + config = load_config() + + logger.info( + "Starting Kagent Slackbot", + log_level=config.log_level, + kagent_url=config.kagent.base_url, + ) + + # Initialize services + a2a_client = A2AClient( + base_url=config.kagent.base_url, + timeout=config.kagent.timeout, + ) + + agent_discovery = AgentDiscovery( + base_url=config.kagent.base_url, + timeout=config.kagent.timeout, + ) + + agent_router = AgentRouter(agent_discovery) + + # Initialize Slack app + app = AsyncApp(token=config.slack.bot_token) + + # Initialize RBAC components + slack_group_checker = SlackGroupChecker( + client=app.client, + cache_ttl=300, + ) + + permission_checker = PermissionChecker( + config_path=config.permissions_file, + group_checker=slack_group_checker, + ) + + # Register middleware + register_middleware(app) + + # Register handlers + register_mention_handlers(app, a2a_client, agent_router, agent_discovery, permission_checker) + register_command_handlers(app, agent_discovery, agent_router, permission_checker) + register_action_handlers(app, a2a_client) + + # Start health server + await start_health_server(config.server.host, config.server.port) + + # Start Socket Mode handler + handler = AsyncSocketModeHandler(app, config.slack.app_token) + + logger.info("Connecting to Slack via Socket Mode") + + try: + await handler.start_async() # type: ignore[no-untyped-call] + except KeyboardInterrupt: + logger.info("Shutting down gracefully") + finally: + await a2a_client.close() + await agent_discovery.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/slackbot/src/kagent_slackbot/services/__init__.py b/slackbot/src/kagent_slackbot/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/slackbot/src/kagent_slackbot/services/a2a_client.py b/slackbot/src/kagent_slackbot/services/a2a_client.py new file mode 100644 index 000000000..49f186055 --- /dev/null +++ b/slackbot/src/kagent_slackbot/services/a2a_client.py @@ -0,0 +1,171 @@ +"""Kagent A2A protocol client""" + +import httpx +import uuid +import json +from typing import Any, AsyncIterator +from structlog import get_logger + +logger = get_logger(__name__) + + +class A2AClient: + """Client for Kagent A2A protocol (JSON-RPC 2.0)""" + + def __init__(self, base_url: str, timeout: int = 30): + self.base_url = base_url.rstrip("/") + self.client = httpx.AsyncClient(timeout=timeout) + # Longer timeout for streaming (agents can take time to respond) + self.streaming_client = httpx.AsyncClient(timeout=120) + + async def invoke_agent( + self, + namespace: str, + agent_name: str, + message: str, + session_id: str, + user_id: str, + ) -> dict[str, Any]: + """ + Invoke agent via A2A protocol (synchronous) + + Args: + namespace: Agent namespace (e.g., "kagent") + agent_name: Agent name (e.g., "k8s-agent") + message: User message text + session_id: Session ID for context + user_id: User ID for authentication + + Returns: + A2A protocol Task response + """ + url = f"{self.base_url}/api/a2a/{namespace}/{agent_name}/" + + # JSON-RPC 2.0 request + request = { + "jsonrpc": "2.0", + "method": "message/send", + "params": { + "message": { + "kind": "message", + "role": "user", + "parts": [{"kind": "text", "text": message}], + "context_id": session_id, + } + }, + "id": str(uuid.uuid4()), + } + + headers = { + "Content-Type": "application/json", + "X-User-Id": user_id, + } + + logger.info( + "Invoking agent", + namespace=namespace, + agent=agent_name, + user_id=user_id, + session_id=session_id, + ) + + try: + response = await self.client.post(url, json=request, headers=headers) + response.raise_for_status() + + result: dict[str, Any] = response.json() + + logger.info( + "Agent invocation successful", + namespace=namespace, + agent=agent_name, + status_code=response.status_code, + ) + + return result + + except httpx.HTTPStatusError as e: + logger.error( + "Agent invocation failed", + namespace=namespace, + agent=agent_name, + status_code=e.response.status_code, + error=str(e), + ) + raise + except Exception as e: + logger.error( + "Agent invocation error", + namespace=namespace, + agent=agent_name, + error=str(e), + ) + raise + + async def stream_agent( + self, + namespace: str, + agent_name: str, + message: str, + session_id: str, + user_id: str, + ) -> AsyncIterator[dict[str, Any]]: + """ + Invoke agent via A2A protocol (streaming) + + Args: + namespace: Agent namespace + agent_name: Agent name + message: User message text + session_id: Session ID for context + user_id: User ID for authentication + + Yields: + SSE events from agent + """ + url = f"{self.base_url}/api/a2a/{namespace}/{agent_name}/" + + request = { + "jsonrpc": "2.0", + "method": "message/stream", + "params": { + "message": { + "kind": "message", + "role": "user", + "parts": [{"kind": "text", "text": message}], + "context_id": session_id, + } + }, + "id": str(uuid.uuid4()), + } + + headers = { + "Content-Type": "application/json", + "Accept": "text/event-stream", + "X-User-Id": user_id, + } + + logger.info( + "Streaming from agent", + namespace=namespace, + agent=agent_name, + user_id=user_id, + session_id=session_id, + ) + + async with self.streaming_client.stream("POST", url, json=request, headers=headers) as response: + response.raise_for_status() + + async for line in response.aiter_lines(): + if line.startswith("data: "): + data = line[6:] + if data.strip() and data.strip() != "[DONE]": + try: + yield json.loads(data) + except json.JSONDecodeError as e: + logger.warning("Failed to parse SSE data", error=str(e), data=data) + + async def close(self) -> None: + """Close HTTP clients""" + await self.client.aclose() + await self.streaming_client.aclose() diff --git a/slackbot/src/kagent_slackbot/services/agent_discovery.py b/slackbot/src/kagent_slackbot/services/agent_discovery.py new file mode 100644 index 000000000..b4fb2d0f1 --- /dev/null +++ b/slackbot/src/kagent_slackbot/services/agent_discovery.py @@ -0,0 +1,121 @@ +"""Agent discovery from Kagent API""" + +import time +import httpx +from typing import Optional, Any +from structlog import get_logger +from ..constants import AGENT_CACHE_TTL + +logger = get_logger(__name__) + + +class AgentInfo: + """Information about an agent""" + + def __init__(self, data: dict[str, Any]): + self.namespace = data["agent"]["metadata"]["namespace"] + self.name = data["agent"]["metadata"]["name"] + self.description = data["agent"]["spec"].get("description", "") + self.type = data["agent"]["spec"]["type"] + self.ready = self._check_ready(data["agent"]["status"]) + + # Extract skills from a2aConfig if available + self.skills = [] + a2a_config = data["agent"]["spec"].get("declarative", {}).get("a2aConfig", {}) + if a2a_config: + self.skills = a2a_config.get("skills", []) + + def _check_ready(self, status: dict[str, Any]) -> bool: + """Check if agent is ready""" + conditions = status.get("conditions", []) + for condition in conditions: + if condition.get("type") == "Ready" and condition.get("status") == "True": + return True + return False + + @property + def ref(self) -> str: + """Agent reference string""" + return f"{self.namespace}/{self.name}" + + def extract_keywords(self) -> list[str]: + """Extract routing keywords from agent metadata""" + keywords = [] + + # From description + if self.description: + # Simple word extraction (can be made more sophisticated) + words = self.description.lower().split() + keywords.extend(words) + + # From skills + for skill in self.skills: + skill_desc = skill.get("description", "").lower() + keywords.extend(skill_desc.split()) + keywords.extend(skill.get("tags", [])) + + return list(set(keywords)) # Deduplicate + + +class AgentDiscovery: + """Discover agents from Kagent API""" + + def __init__(self, base_url: str, timeout: int = 30): + self.base_url = base_url.rstrip("/") + self.client = httpx.AsyncClient(timeout=timeout) + self.cache: dict[str, AgentInfo] = {} + self.last_refresh = 0.0 + + async def discover_agents(self, force_refresh: bool = False) -> dict[str, AgentInfo]: + """ + Discover available agents + + Args: + force_refresh: Force cache refresh + + Returns: + Dict mapping agent ref to AgentInfo + """ + now = time.time() + + if not force_refresh and (now - self.last_refresh) < AGENT_CACHE_TTL: + logger.debug("Using cached agent list", count=len(self.cache)) + return self.cache + + logger.info("Discovering agents from Kagent API") + + try: + url = f"{self.base_url}/api/agents" + response = await self.client.get(url) + response.raise_for_status() + + data = response.json() + agents_data = data.get("data", []) + + # Build cache + self.cache = {} + for agent_data in agents_data: + agent_info = AgentInfo(agent_data) + self.cache[agent_info.ref] = agent_info + + self.last_refresh = now + + logger.info("Agent discovery complete", count=len(self.cache)) + return self.cache + + except Exception as e: + logger.error("Agent discovery failed", error=str(e)) + # Return cached agents if available + if self.cache: + logger.warning("Using stale agent cache") + return self.cache + raise + + async def get_agent(self, namespace: str, name: str) -> Optional[AgentInfo]: + """Get specific agent info""" + agents = await self.discover_agents() + return agents.get(f"{namespace}/{name}") + + async def close(self) -> None: + """Close HTTP client""" + await self.client.aclose() diff --git a/slackbot/src/kagent_slackbot/services/agent_router.py b/slackbot/src/kagent_slackbot/services/agent_router.py new file mode 100644 index 000000000..1ae402a76 --- /dev/null +++ b/slackbot/src/kagent_slackbot/services/agent_router.py @@ -0,0 +1,91 @@ +"""Agent routing logic""" + +import re +from typing import Tuple +from structlog import get_logger +from .agent_discovery import AgentDiscovery +from ..constants import DEFAULT_AGENT_NAMESPACE, DEFAULT_AGENT_NAME + +logger = get_logger(__name__) + + +class AgentRouter: + """Route user messages to appropriate agents""" + + def __init__(self, agent_discovery: AgentDiscovery): + self.discovery = agent_discovery + self.explicit_agent: dict[str, str] = {} # user_id -> agent_ref + + async def route(self, message: str, user_id: str) -> Tuple[str, str, str]: + """ + Route message to agent + + Args: + message: User message text + user_id: User ID (for explicit agent selection) + + Returns: + Tuple of (namespace, agent_name, reason) + """ + + # Check for explicit agent selection + if user_id in self.explicit_agent: + ref = self.explicit_agent[user_id] + namespace, name = ref.split("/") + logger.info("Using explicitly selected agent", agent=ref, user=user_id) + return namespace, name, "explicitly selected by user" + + # Discover available agents + agents = await self.discovery.discover_agents() + + if not agents: + logger.warning("No agents available, using default") + return DEFAULT_AGENT_NAMESPACE, DEFAULT_AGENT_NAME, "default (no agents found)" + + # Score agents based on keyword matching + scores: dict[str, float] = {} + message_lower = message.lower() + message_words = set(re.findall(r"\w+", message_lower)) + + for ref, agent in agents.items(): + if not agent.ready: + continue + + keywords = agent.extract_keywords() + keyword_set = set(keywords) + + # Calculate match score + matches = message_words & keyword_set + if matches: + # Score based on number of matches and keyword frequency + score = len(matches) + scores[ref] = score + + # Select highest scoring agent + if scores: + best_agent_ref = max(scores, key=lambda x: scores[x]) + namespace, name = best_agent_ref.split("/") + score = int(scores[best_agent_ref]) + logger.info( + "Agent selected via keyword matching", + agent=best_agent_ref, + score=score, + user=user_id, + ) + return namespace, name, f"matched keywords (score: {score})" + + # No matches, use default + logger.info("No keyword matches, using default agent", user=user_id) + return DEFAULT_AGENT_NAMESPACE, DEFAULT_AGENT_NAME, "default (no keyword matches)" + + def set_explicit_agent(self, user_id: str, namespace: str, name: str) -> None: + """Set explicit agent selection for user""" + ref = f"{namespace}/{name}" + self.explicit_agent[user_id] = ref + logger.info("User selected agent explicitly", user=user_id, agent=ref) + + def clear_explicit_agent(self, user_id: str) -> None: + """Clear explicit agent selection""" + if user_id in self.explicit_agent: + del self.explicit_agent[user_id] + logger.info("Cleared explicit agent selection", user=user_id) diff --git a/slackbot/src/kagent_slackbot/slack/__init__.py b/slackbot/src/kagent_slackbot/slack/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/slackbot/src/kagent_slackbot/slack/formatters.py b/slackbot/src/kagent_slackbot/slack/formatters.py new file mode 100644 index 000000000..f3d0f840e --- /dev/null +++ b/slackbot/src/kagent_slackbot/slack/formatters.py @@ -0,0 +1,270 @@ +"""Slack Block Kit formatting""" + +import re +from typing import Optional, Any +from datetime import datetime +from ..constants import SLACK_BLOCK_LIMIT, EMOJI_ROBOT, EMOJI_CLOCK + + +def needs_approval(response_text: str) -> bool: + """ + Detect if agent response needs human approval + + Args: + response_text: Agent's response text + + Returns: + True if approval is needed + """ + approval_patterns = [ + r"should I", + r"do you want me to", + r"shall I", + r"would you like me to", + r"may I", + r"confirm", + r"approve", + r"permission to", + r"proceed\?", + r"continue\?", + ] + + text_lower = response_text.lower() + for pattern in approval_patterns: + if re.search(pattern, text_lower): + return True + + return False + + +def chunk_text(text: str, max_length: int = SLACK_BLOCK_LIMIT) -> list[str]: + """ + Chunk text into pieces that fit Slack block limits + + Args: + text: Text to chunk + max_length: Maximum length per chunk + + Returns: + List of text chunks + """ + if len(text) <= max_length: + return [text] + + chunks = [] + current_chunk = "" + + for line in text.split("\n"): + if len(current_chunk) + len(line) + 1 > max_length: + if current_chunk: + chunks.append(current_chunk) + current_chunk = line + else: + if current_chunk: + current_chunk += "\n" + line + else: + current_chunk = line + + if current_chunk: + chunks.append(current_chunk) + + return chunks + + +def format_agent_response( + agent_name: str, + response_text: str, + routing_reason: str, + response_time: Optional[float] = None, + session_id: Optional[str] = None, + show_actions: bool = True, +) -> list[dict[str, Any]]: + """ + Format agent response as Slack blocks + + Args: + agent_name: Name of the agent that responded + response_text: Agent's response text + routing_reason: Why this agent was selected + response_time: Response time in seconds + session_id: Session ID (for display) + show_actions: Whether to show action buttons + + Returns: + List of Slack block dictionaries + """ + blocks = [] + + # Header + blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"{EMOJI_ROBOT} *Response from {agent_name}*", + }, + } + ) + + # Routing context + context_block: dict[str, Any] = { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f"_Agent selected: {routing_reason}_", + } + ], + } + blocks.append(context_block) + + # Divider + blocks.append({"type": "divider"}) + + # Response content (chunked if needed) + chunks = chunk_text(response_text) + for chunk in chunks: + blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": chunk, + }, + } + ) + + # Footer with metadata + current_time = datetime.now().strftime("%H:%M") + footer_parts = [f"{EMOJI_CLOCK} _Response at {current_time}"] + + if response_time: + footer_parts.append(f" • {response_time:.1f}s") + + if session_id: + # Show last 8 chars of session ID + footer_parts.append(f" • Session: `{session_id[-8:]}`") + + footer_parts.append("_") + + footer_block: dict[str, Any] = { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "".join(footer_parts), + } + ], + } + blocks.append(footer_block) + + # Action buttons for human-in-the-loop approval + # Only show if response needs approval + if show_actions and session_id and needs_approval(response_text): + # Encode context in button value (session_id|namespace|agent_name) + button_value = f"{session_id}|{agent_name}" + + # Add approval prompt + blocks.append({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "⚠️ *This action requires your approval*", + }, + }) + + actions_block: dict[str, Any] = { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "✅ Approve"}, + "style": "primary", + "action_id": "approval_approve", + "value": button_value, + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "❌ Deny"}, + "style": "danger", + "action_id": "approval_deny", + "value": button_value, + }, + ], + } + blocks.append(actions_block) + + return blocks + + +def format_agent_list(agents: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + Format list of agents as Slack blocks + + Args: + agents: List of agent info dicts + + Returns: + List of Slack block dictionaries + """ + blocks = [] + + # Header + blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"{EMOJI_ROBOT} *Available Agents*", + }, + } + ) + + blocks.append({"type": "divider"}) + + # Agent list + for agent in agents: + status_emoji = ":white_check_mark:" if agent["ready"] else ":x:" + + text = f"*{agent['name']}* (`{agent['namespace']}/{agent['name']}`)\n" + text += f"{status_emoji} Status: {'Ready' if agent['ready'] else 'Not Ready'}\n" + + if agent.get("description"): + text += f"_{agent['description']}_" + + blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": text, + }, + } + ) + + # Footer + footer_block: dict[str, Any] = { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f"_Total: {len(agents)} agents • Use `/agent-switch /` to select one_", + } + ], + } + blocks.append(footer_block) + + return blocks + + +def format_error(error_message: str) -> list[dict[str, Any]]: + """Format error message as Slack blocks""" + return [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f":x: *Error*\n{error_message}", + }, + }, + ] diff --git a/slackbot/src/kagent_slackbot/slack/validators.py b/slackbot/src/kagent_slackbot/slack/validators.py new file mode 100644 index 000000000..c1860a232 --- /dev/null +++ b/slackbot/src/kagent_slackbot/slack/validators.py @@ -0,0 +1,56 @@ +"""Input validation and sanitization""" + +import re +from ..constants import MAX_MESSAGE_LENGTH, MIN_MESSAGE_LENGTH + + +def validate_message(text: str) -> bool: + """ + Validate user message + + Args: + text: Message text + + Returns: + True if valid, False otherwise + """ + if not text or len(text.strip()) < MIN_MESSAGE_LENGTH: + return False + + if len(text) > MAX_MESSAGE_LENGTH: + return False + + return True + + +def sanitize_message(text: str) -> str: + """ + Sanitize user message + + Args: + text: Raw message text + + Returns: + Sanitized text + """ + text = text.strip() + text = re.sub(r"\s+", " ", text) + + if len(text) > MAX_MESSAGE_LENGTH: + text = text[:MAX_MESSAGE_LENGTH] + + return text + + +def strip_bot_mention(text: str) -> str: + """ + Remove bot mention from text + + Args: + text: Text with potential @bot mention + + Returns: + Text without mention + """ + text = re.sub(r"<@[A-Z0-9]+>", "", text) + return text.strip()