diff --git a/helm/kagent/templates/_helpers.tpl b/helm/kagent/templates/_helpers.tpl index 8fd55ade6..4b55a9aeb 100644 --- a/helm/kagent/templates/_helpers.tpl +++ b/helm/kagent/templates/_helpers.tpl @@ -106,3 +106,19 @@ Engine labels {{ include "kagent.labels" . }} app.kubernetes.io/component: engine {{- end }} + +{{/* +Slackbot selector labels +*/}} +{{- define "kagent.slackbot.selectorLabels" -}} +{{ include "kagent.selectorLabels" . }} +app.kubernetes.io/component: slackbot +{{- end }} + +{{/* +Slackbot labels +*/}} +{{- define "kagent.slackbot.labels" -}} +{{ include "kagent.labels" . }} +app.kubernetes.io/component: slackbot +{{- end }} diff --git a/helm/kagent/templates/slackbot-configmap.yaml b/helm/kagent/templates/slackbot-configmap.yaml new file mode 100644 index 000000000..19f579dfd --- /dev/null +++ b/helm/kagent/templates/slackbot-configmap.yaml @@ -0,0 +1,29 @@ +{{- if .Values.slackbot.enabled -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "kagent.fullname" . }}-slackbot-config + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.slackbot.labels" . | nindent 4 }} +data: + permissions.yaml: | + # Agent-level permissions configuration + # Configure which users/groups can access which agents via Slackbot + + # 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) + {{- toYaml .Values.slackbot.permissions | nindent 4 }} + + # Global settings + settings: + user_group_cache_ttl: {{ .Values.slackbot.permissions.settings.user_group_cache_ttl | default 300 }} +{{- end }} diff --git a/helm/kagent/templates/slackbot-deployment.yaml b/helm/kagent/templates/slackbot-deployment.yaml new file mode 100644 index 000000000..c3dcadf19 --- /dev/null +++ b/helm/kagent/templates/slackbot-deployment.yaml @@ -0,0 +1,119 @@ +{{- if .Values.slackbot.enabled -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "kagent.fullname" . }}-slackbot + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.slackbot.labels" . | nindent 4 }} +spec: + {{- if not .Values.slackbot.autoscaling.enabled }} + replicas: {{ .Values.slackbot.replicas }} + {{- end }} + selector: + matchLabels: + {{- include "kagent.slackbot.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + {{- with .Values.slackbot.podAnnotations | default .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "kagent.slackbot.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + serviceAccountName: {{ include "kagent.fullname" . }}-slackbot + {{- with .Values.slackbot.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.slackbot.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: slackbot + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.slackbot.image.registry | default .Values.registry }}/{{ .Values.slackbot.image.repository }}:{{ coalesce .Values.tag .Values.slackbot.image.tag .Chart.Version }}" + imagePullPolicy: {{ .Values.slackbot.image.pullPolicy | default .Values.imagePullPolicy }} + env: + # Slack credentials from secret + - name: SLACK_BOT_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "kagent.fullname" . }}-slackbot-secrets + key: slack-bot-token + - name: SLACK_APP_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "kagent.fullname" . }}-slackbot-secrets + key: slack-app-token + - name: SLACK_SIGNING_SECRET + valueFrom: + secretKeyRef: + name: {{ include "kagent.fullname" . }}-slackbot-secrets + key: slack-signing-secret + # Kagent configuration + - name: KAGENT_BASE_URL + value: "http://{{ include "kagent.fullname" . }}-controller.{{ include "kagent.namespace" . }}.svc.cluster.local:{{ .Values.controller.service.ports.port }}" + - name: KAGENT_TIMEOUT + value: {{ .Values.slackbot.config.kagentTimeout | quote }} + # Server configuration + - name: SERVER_HOST + value: {{ .Values.slackbot.config.serverHost | quote }} + - name: SERVER_PORT + value: {{ .Values.slackbot.config.serverPort | quote }} + # Logging + - name: LOG_LEVEL + value: {{ .Values.slackbot.config.logLevel | quote }} + # Permissions file + - name: PERMISSIONS_FILE + value: "/app/config/permissions.yaml" + {{- with .Values.slackbot.env }} + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.slackbot.config.serverPort }} + protocol: TCP + resources: + {{- toYaml .Values.slackbot.resources | nindent 12 }} + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /ready + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + volumeMounts: + - name: tmp + mountPath: /tmp + - name: permissions-config + mountPath: /app/config + readOnly: true + volumes: + - name: tmp + emptyDir: {} + - name: permissions-config + configMap: + name: {{ include "kagent.fullname" . }}-slackbot-config + items: + - key: permissions.yaml + path: permissions.yaml +{{- end }} diff --git a/helm/kagent/templates/slackbot-hpa.yaml b/helm/kagent/templates/slackbot-hpa.yaml new file mode 100644 index 000000000..9a25c91c3 --- /dev/null +++ b/helm/kagent/templates/slackbot-hpa.yaml @@ -0,0 +1,37 @@ +{{- if and .Values.slackbot.enabled .Values.slackbot.autoscaling.enabled -}} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "kagent.fullname" . }}-slackbot + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.slackbot.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "kagent.fullname" . }}-slackbot + minReplicas: {{ .Values.slackbot.autoscaling.minReplicas }} + maxReplicas: {{ .Values.slackbot.autoscaling.maxReplicas }} + metrics: + {{- if .Values.slackbot.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.slackbot.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.slackbot.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.slackbot.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} + {{- with .Values.slackbot.autoscaling.behavior }} + behavior: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/helm/kagent/templates/slackbot-pdb.yaml b/helm/kagent/templates/slackbot-pdb.yaml new file mode 100644 index 000000000..09d013b31 --- /dev/null +++ b/helm/kagent/templates/slackbot-pdb.yaml @@ -0,0 +1,19 @@ +{{- if and .Values.slackbot.enabled .Values.slackbot.podDisruptionBudget.enabled -}} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "kagent.fullname" . }}-slackbot + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.slackbot.labels" . | nindent 4 }} +spec: + {{- if .Values.slackbot.podDisruptionBudget.minAvailable }} + minAvailable: {{ .Values.slackbot.podDisruptionBudget.minAvailable }} + {{- end }} + {{- if .Values.slackbot.podDisruptionBudget.maxUnavailable }} + maxUnavailable: {{ .Values.slackbot.podDisruptionBudget.maxUnavailable }} + {{- end }} + selector: + matchLabels: + {{- include "kagent.slackbot.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/helm/kagent/templates/slackbot-role.yaml b/helm/kagent/templates/slackbot-role.yaml new file mode 100644 index 000000000..20dead3cd --- /dev/null +++ b/helm/kagent/templates/slackbot-role.yaml @@ -0,0 +1,15 @@ +{{- if and .Values.slackbot.enabled .Values.slackbot.rbac.create -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "kagent.fullname" . }}-slackbot + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.slackbot.labels" . | nindent 4 }} +rules: + # Slackbot doesn't need K8s API access - it only talks to Slack and kagent HTTP API + # These minimal permissions are for potential future features (e.g., checking pod status) + {{- with .Values.slackbot.rbac.rules }} + {{- toYaml . | nindent 2 }} + {{- end }} +{{- end }} diff --git a/helm/kagent/templates/slackbot-rolebinding.yaml b/helm/kagent/templates/slackbot-rolebinding.yaml new file mode 100644 index 000000000..b72b99838 --- /dev/null +++ b/helm/kagent/templates/slackbot-rolebinding.yaml @@ -0,0 +1,17 @@ +{{- if and .Values.slackbot.enabled .Values.slackbot.rbac.create -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "kagent.fullname" . }}-slackbot + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.slackbot.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "kagent.fullname" . }}-slackbot +subjects: + - kind: ServiceAccount + name: {{ include "kagent.fullname" . }}-slackbot + namespace: {{ include "kagent.namespace" . }} +{{- end }} diff --git a/helm/kagent/templates/slackbot-secret.yaml b/helm/kagent/templates/slackbot-secret.yaml new file mode 100644 index 000000000..43509bc22 --- /dev/null +++ b/helm/kagent/templates/slackbot-secret.yaml @@ -0,0 +1,32 @@ +{{- if .Values.slackbot.enabled -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "kagent.fullname" . }}-slackbot-secrets + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.slackbot.labels" . | nindent 4 }} +type: Opaque +stringData: + {{- if .Values.slackbot.secrets.slackBotToken }} + slack-bot-token: {{ .Values.slackbot.secrets.slackBotToken | quote }} + {{- else }} + # IMPORTANT: Replace with your actual Slack Bot Token (xoxb-...) + # Or set via: --set slackbot.secrets.slackBotToken=xoxb-... + slack-bot-token: "" + {{- end }} + {{- if .Values.slackbot.secrets.slackAppToken }} + slack-app-token: {{ .Values.slackbot.secrets.slackAppToken | quote }} + {{- else }} + # IMPORTANT: Replace with your actual Slack App Token (xapp-...) + # Or set via: --set slackbot.secrets.slackAppToken=xapp-... + slack-app-token: "" + {{- end }} + {{- if .Values.slackbot.secrets.slackSigningSecret }} + slack-signing-secret: {{ .Values.slackbot.secrets.slackSigningSecret | quote }} + {{- else }} + # IMPORTANT: Replace with your actual Slack Signing Secret + # Or set via: --set slackbot.secrets.slackSigningSecret=... + slack-signing-secret: "" + {{- end }} +{{- end }} diff --git a/helm/kagent/templates/slackbot-service.yaml b/helm/kagent/templates/slackbot-service.yaml new file mode 100644 index 000000000..ca14f9da5 --- /dev/null +++ b/helm/kagent/templates/slackbot-service.yaml @@ -0,0 +1,22 @@ +{{- if .Values.slackbot.enabled -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "kagent.fullname" . }}-slackbot + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.slackbot.labels" . | nindent 4 }} + {{- with .Values.slackbot.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.slackbot.service.type }} + ports: + - port: {{ .Values.slackbot.service.ports.port }} + targetPort: {{ .Values.slackbot.service.ports.targetPort }} + protocol: TCP + name: http + selector: + {{- include "kagent.slackbot.selectorLabels" . | nindent 4 }} +{{- end }} diff --git a/helm/kagent/templates/slackbot-serviceaccount.yaml b/helm/kagent/templates/slackbot-serviceaccount.yaml new file mode 100644 index 000000000..4ffdb3d2a --- /dev/null +++ b/helm/kagent/templates/slackbot-serviceaccount.yaml @@ -0,0 +1,9 @@ +{{- if .Values.slackbot.enabled -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "kagent.fullname" . }}-slackbot + namespace: {{ include "kagent.namespace" . }} + labels: + {{- include "kagent.slackbot.labels" . | nindent 4 }} +{{- end }} diff --git a/helm/kagent/tests/slackbot-deployment_test.yaml b/helm/kagent/tests/slackbot-deployment_test.yaml new file mode 100644 index 000000000..5936ddb17 --- /dev/null +++ b/helm/kagent/tests/slackbot-deployment_test.yaml @@ -0,0 +1,122 @@ +suite: test slackbot deployment +templates: + - slackbot-deployment.yaml +tests: + - it: should not create deployment when slackbot is disabled + set: + slackbot.enabled: false + asserts: + - hasDocuments: + count: 0 + + - it: should create deployment when slackbot is enabled + set: + slackbot.enabled: true + slackbot.secrets.slackBotToken: "xoxb-test" + slackbot.secrets.slackAppToken: "xapp-test" + slackbot.secrets.slackSigningSecret: "test-secret" + asserts: + - isKind: + of: Deployment + - equal: + path: metadata.name + value: RELEASE-NAME-slackbot + - equal: + path: spec.replicas + value: 2 + + - it: should have correct image configuration + set: + slackbot.enabled: true + registry: "gcr.io/my-registry" + tag: "v1.2.3" + asserts: + - matchRegex: + path: spec.template.spec.containers[0].image + pattern: ^gcr.io/my-registry/kagent-dev/kagent/slackbot:v1.2.3$ + + - it: should configure environment variables correctly + set: + slackbot.enabled: true + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: KAGENT_BASE_URL + value: "http://RELEASE-NAME-controller.NAMESPACE.svc.cluster.local:8083" + - contains: + path: spec.template.spec.containers[0].env + content: + name: LOG_LEVEL + value: "INFO" + + - it: should mount permissions config + set: + slackbot.enabled: true + asserts: + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: permissions-config + mountPath: /app/config + readOnly: true + - contains: + path: spec.template.spec.volumes + content: + name: permissions-config + configMap: + name: RELEASE-NAME-slackbot-config + items: + - key: permissions.yaml + path: permissions.yaml + + - it: should have health probes configured + set: + slackbot.enabled: true + asserts: + - equal: + path: spec.template.spec.containers[0].livenessProbe.httpGet.path + value: /health + - equal: + path: spec.template.spec.containers[0].readinessProbe.httpGet.path + value: /ready + + - it: should apply custom resource limits + set: + slackbot.enabled: true + slackbot.resources.requests.cpu: "500m" + slackbot.resources.limits.memory: "1Gi" + asserts: + - equal: + path: spec.template.spec.containers[0].resources.requests.cpu + value: "500m" + - equal: + path: spec.template.spec.containers[0].resources.limits.memory + value: "1Gi" + + - it: should apply node selector + set: + slackbot.enabled: true + slackbot.nodeSelector: + disktype: ssd + asserts: + - equal: + path: spec.template.spec.nodeSelector.disktype + value: ssd + + - it: should apply tolerations + set: + slackbot.enabled: true + slackbot.tolerations: + - key: "key1" + operator: "Equal" + value: "value1" + effect: "NoSchedule" + asserts: + - contains: + path: spec.template.spec.tolerations + content: + key: "key1" + operator: "Equal" + value: "value1" + effect: "NoSchedule" diff --git a/helm/kagent/tests/slackbot-secret_test.yaml b/helm/kagent/tests/slackbot-secret_test.yaml new file mode 100644 index 000000000..8a6495011 --- /dev/null +++ b/helm/kagent/tests/slackbot-secret_test.yaml @@ -0,0 +1,65 @@ +suite: test slackbot secret +templates: + - slackbot-secret.yaml +tests: + - it: should not create secret when slackbot is disabled + set: + slackbot.enabled: false + asserts: + - hasDocuments: + count: 0 + + - it: should create secret when slackbot is enabled + set: + slackbot.enabled: true + asserts: + - isKind: + of: Secret + - equal: + path: metadata.name + value: RELEASE-NAME-slackbot-secrets + - equal: + path: type + value: Opaque + + - it: should include all required secret keys + set: + slackbot.enabled: true + asserts: + - exists: + path: stringData["slack-bot-token"] + - exists: + path: stringData["slack-app-token"] + - exists: + path: stringData["slack-signing-secret"] + + - it: should use provided token values + set: + slackbot.enabled: true + slackbot.secrets.slackBotToken: "xoxb-123456" + slackbot.secrets.slackAppToken: "xapp-789012" + slackbot.secrets.slackSigningSecret: "secret123" + asserts: + - equal: + path: stringData["slack-bot-token"] + value: "xoxb-123456" + - equal: + path: stringData["slack-app-token"] + value: "xapp-789012" + - equal: + path: stringData["slack-signing-secret"] + value: "secret123" + + - it: should have empty strings when tokens not provided + set: + slackbot.enabled: true + asserts: + - equal: + path: stringData["slack-bot-token"] + value: "" + - equal: + path: stringData["slack-app-token"] + value: "" + - equal: + path: stringData["slack-signing-secret"] + value: "" diff --git a/helm/kagent/tests/slackbot-service_test.yaml b/helm/kagent/tests/slackbot-service_test.yaml new file mode 100644 index 000000000..942f83571 --- /dev/null +++ b/helm/kagent/tests/slackbot-service_test.yaml @@ -0,0 +1,61 @@ +suite: test slackbot service +templates: + - slackbot-service.yaml +tests: + - it: should not create service when slackbot is disabled + set: + slackbot.enabled: false + asserts: + - hasDocuments: + count: 0 + + - it: should create service when slackbot is enabled + set: + slackbot.enabled: true + asserts: + - isKind: + of: Service + - equal: + path: metadata.name + value: RELEASE-NAME-slackbot + - equal: + path: spec.type + value: ClusterIP + + - it: should configure service ports correctly + set: + slackbot.enabled: true + asserts: + - equal: + path: spec.ports[0].port + value: 8080 + - equal: + path: spec.ports[0].targetPort + value: 8080 + - equal: + path: spec.ports[0].name + value: http + + - it: should apply custom annotations + set: + slackbot.enabled: true + slackbot.service.annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "8080" + asserts: + - equal: + path: metadata.annotations + value: + prometheus.io/scrape: "true" + prometheus.io/port: "8080" + + - it: should have correct selector labels + set: + slackbot.enabled: true + asserts: + - equal: + path: spec.selector + value: + app.kubernetes.io/name: kagent + app.kubernetes.io/instance: RELEASE-NAME + app.kubernetes.io/component: slackbot diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 8f2bc4865..da1497c46 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -136,6 +136,140 @@ ui: # -- Node labels to match for `Pod` [scheduling](https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/). nodeSelector: {} +# ============================================================================== +# SLACKBOT CONFIGURATION +# ============================================================================== +# Slack bot integration for interacting with kagent agents via Slack +# Requires Slack app with Socket Mode enabled +# See slackbot/SLACK_SETUP.md for detailed setup instructions + +slackbot: + # -- Enable or disable the Slackbot deployment + enabled: false + + # -- Number of Slackbot replicas + replicas: 2 + + image: + registry: "" + repository: kagent-dev/kagent/slackbot + tag: "" + pullPolicy: "" + + # -- Slack API credentials (required for Slackbot to function) + # IMPORTANT: Set these via Helm values or external secrets management + # DO NOT commit actual tokens to version control + secrets: + # Slack Bot User OAuth Token (starts with xoxb-) + # Obtain from: Slack App > OAuth & Permissions > Bot User OAuth Token + slackBotToken: "" + + # Slack App-Level Token for Socket Mode (starts with xapp-) + # Obtain from: Slack App > Basic Information > App-Level Tokens + slackAppToken: "" + + # Slack Signing Secret for request verification + # Obtain from: Slack App > Basic Information > App Credentials + slackSigningSecret: "" + + # -- Slackbot configuration + config: + # Kagent timeout for agent requests (seconds) + kagentTimeout: "30" + + # Health server configuration + serverHost: "0.0.0.0" + serverPort: 8080 + + # Logging level (DEBUG, INFO, WARNING, ERROR) + logLevel: "INFO" + + # -- Agent access permissions for RBAC + # Configure which Slack users/groups can access which agents + permissions: + agent_permissions: {} + # Example configuration: + # agent_permissions: + # kagent/k8s-agent: + # user_groups: + # - S0T8FCWSB # Slack user group ID + # users: + # - admin@company.com + # deny_message: "K8s agent requires @sre-team membership" + settings: + user_group_cache_ttl: 300 # 5 minutes + + # -- Resource limits and requests + resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + + # -- Service configuration + service: + type: ClusterIP + ports: + port: 8080 + targetPort: 8080 + annotations: {} + # Example: Expose metrics for Prometheus scraping + # annotations: + # prometheus.io/scrape: "true" + # prometheus.io/port: "8080" + # prometheus.io/path: "/metrics" + + # -- Additional environment variables + env: [] + # Example: + # env: + # - name: CUSTOM_VAR + # value: "custom-value" + + # -- Pod annotations + podAnnotations: {} + + # -- Node taints which will be tolerated for Pod scheduling + tolerations: [] + + # -- Node labels to match for Pod scheduling + nodeSelector: {} + + # -- Horizontal Pod Autoscaler configuration + autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + # behavior: + # scaleDown: + # stabilizationWindowSeconds: 300 + # policies: + # - type: Percent + # value: 50 + # periodSeconds: 60 + + # -- Pod Disruption Budget configuration + podDisruptionBudget: + enabled: true + minAvailable: 1 + # maxUnavailable: 1 # Use either minAvailable or maxUnavailable, not both + + # -- RBAC configuration + rbac: + create: false + # Slackbot doesn't need K8s API access by default + # Enable this only if you add features that need to query K8s resources + rules: [] + # Example rules if needed: + # rules: + # - apiGroups: [""] + # resources: ["pods"] + # verbs: ["get", "list"] + # ============================================================================== # LLM PROVIDERS CONFIGURATION # ============================================================================== 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..8aa5a57b2 --- /dev/null +++ b/slackbot/.env.example @@ -0,0 +1,22 @@ +# 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 + +# Default agent (optional - used when no keyword matches) +DEFAULT_AGENT_NAMESPACE=kagent +DEFAULT_AGENT_NAME=k8s-agent + +# Permissions +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..d02df01eb --- /dev/null +++ b/slackbot/Dockerfile @@ -0,0 +1,38 @@ +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 + +# Install CA certificates and update +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates && \ + update-ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +# 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 + +# Copy pyproject and install package structure first +COPY --chown=appuser:appuser pyproject.toml . +COPY --chown=appuser:appuser src/ src/ +COPY --chown=appuser:appuser config/ config/ + +# Install dependencies and package +RUN pip install --upgrade pip && \ + pip install -e . + +# 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/Makefile b/slackbot/Makefile new file mode 100644 index 000000000..2fbbd0dc9 --- /dev/null +++ b/slackbot/Makefile @@ -0,0 +1,68 @@ +.PHONY: help build push dev test lint type-check clean install + +# Default target +help: + @echo "Kagent Slackbot - Available targets:" + @echo " make install - Install Python dependencies" + @echo " make dev - Run bot locally" + @echo " make test - Run tests (if available)" + @echo " make lint - Run linting with ruff" + @echo " make type-check - Run type checking with mypy" + @echo " make build - Build Docker image" + @echo " make push - Push Docker image to registry" + @echo " make clean - Remove build artifacts" + +# Configuration +IMAGE_REGISTRY ?= kagent-dev/kagent +IMAGE_NAME ?= slackbot +IMAGE_TAG ?= latest +IMAGE_FULL = $(IMAGE_REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG) + +# Install dependencies +install: + python3 -m venv .venv + .venv/bin/pip install --upgrade pip + .venv/bin/pip install -e ".[dev]" + +# Run locally +dev: + @if [ ! -f .env ]; then \ + echo "Error: .env file not found. Copy .env.example and fill in your Slack tokens."; \ + exit 1; \ + fi + .venv/bin/python -m kagent_slackbot.main + +# Run tests (placeholder - add tests later) +test: + @echo "No tests implemented yet" + # .venv/bin/pytest tests/ + +# Lint with ruff +lint: + .venv/bin/ruff check src/ + +# Lint and auto-fix +lint-fix: + .venv/bin/ruff check --fix src/ + +# Type check with mypy +type-check: + .venv/bin/mypy src/kagent_slackbot/ + +# Build Docker image +build: + @echo "Building Docker image: $(IMAGE_FULL)" + docker build -t $(IMAGE_FULL) . + +# Push to registry +push: build + @echo "Pushing Docker image: $(IMAGE_FULL)" + docker push $(IMAGE_FULL) + +# Clean build artifacts +clean: + rm -rf .venv + rm -rf __pycache__ + find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete + find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true diff --git a/slackbot/README.md b/slackbot/README.md new file mode 100644 index 000000000..100f1edef --- /dev/null +++ b/slackbot/README.md @@ -0,0 +1,317 @@ +# 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 +- **Streaming Responses**: Real-time updates for declarative agents with human-in-the-loop approval +- **RBAC**: Slack user group integration with agent-level permissions +- **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. + +### Human-in-the-Loop (HITL) Approvals + +When agents request approval for sensitive operations (like deleting pods), the bot displays an interactive approval UI: + +``` +@kagent delete pod my-app-xyz in namespace prod +``` + +The bot shows: +- Tool name and arguments requiring approval +- **Approve** and **Deny** buttons +- Session and task context + +**Workflow**: +1. Agent detects sensitive operation and requests approval +2. Slackbot displays approval buttons in Slack +3. User clicks Approve or Deny +4. Slackbot sends structured decision (DataPart + TextPart) to agent +5. Agent resumes execution with user's decision +6. Completion message shown to user + +**Technical Details**: +- Uses A2A protocol `input_required` state for interrupts +- Sends DataPart with `decision_type: tool_approval` for reliable parsing +- Tracks contextId and taskId for proper task resumption +- Streams completion responses in real-time + +## 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 +│ ├── actions.py # Button action handling (HITL approvals) +│ └── middleware.py # Prometheus metrics +├── services/ # Business logic +│ ├── a2a_client.py # Kagent A2A protocol client (JSON-RPC 2.0) +│ ├── agent_discovery.py # Agent discovery from API +│ └── agent_router.py # Agent routing logic +├── auth/ # Authorization +│ ├── permissions.py # RBAC permissions checker +│ └── slack_groups.py # Slack user group integration +└── 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 + +The Slackbot is deployed via the kagent Helm chart. + +### Prerequisites + +1. **Kubernetes cluster** with Kagent installed (or installing for the first time) +2. **Helm 3.x** installed +3. **Slack App configured** with Socket Mode (see [SLACK_SETUP.md](./SLACK_SETUP.md)) +4. **Slack API tokens** obtained (Bot Token, App Token, Signing Secret) + +### Install with Helm + +```bash +helm upgrade --install kagent ./helm/kagent \ + --namespace kagent \ + --create-namespace \ + --set slackbot.enabled=true \ + --set slackbot.secrets.slackBotToken="xoxb-your-token" \ + --set slackbot.secrets.slackAppToken="xapp-your-token" \ + --set slackbot.secrets.slackSigningSecret="your-secret" +``` + +Or use a values file: + +```yaml +# slackbot-values.yaml +slackbot: + enabled: true + secrets: + slackBotToken: "xoxb-..." + slackAppToken: "xapp-..." + slackSigningSecret: "..." + permissions: + agent_permissions: + kagent/k8s-agent: + user_groups: ["S0T8FCWSB"] +``` + +```bash +helm upgrade --install kagent ./helm/kagent -f slackbot-values.yaml +``` + +### Verify Deployment + +```bash +kubectl get pods -n kagent -l app.kubernetes.io/component=slackbot +kubectl logs -f -n kagent -l app.kubernetes.io/component=slackbot +``` + +### Configuration Options + +See `helm/kagent/values.yaml` for all configuration options including: +- Autoscaling (HPA) +- Pod Disruption Budget +- RBAC permissions for agents +- Resource limits +- Node selectors and tolerations + +### 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 deployment/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-controller.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]` + +## References + +- **Slack Bolt Docs**: https://slack.dev/bolt-python/ +- **Kagent A2A Protocol**: `go/internal/a2a/` +- **Agent CRD Spec**: `go/api/v1alpha2/agent_types.go` diff --git a/slackbot/SLACK_SETUP.md b/slackbot/SLACK_SETUP.md new file mode 100644 index 000000000..4806102b9 --- /dev/null +++ b/slackbot/SLACK_SETUP.md @@ -0,0 +1,270 @@ +# Slack App Setup Guide + +This guide walks you through creating and configuring the Slack app for Kagent Slackbot. + +## Option A: Quick Setup with App Manifest (Recommended) + +Using the app manifest automatically configures everything for you. + +### Steps: + +1. Go to https://api.slack.com/apps +2. Click **"Create New App"** → **"From an app manifest"** +3. Choose your workspace +4. Select **YAML** tab +5. Copy and paste the contents of `slack-app-manifest.yaml` +6. Click **"Next"** → Review → **"Create"** + +The manifest creates an app named **"kagent-bot"** with all scopes, Socket Mode, event subscriptions, and slash commands pre-configured. + +### Generate Socket Mode Token: + +Socket Mode is already enabled by the manifest. You just need to generate a token: + +1. Go to **Socket Mode** in the sidebar +2. Verify the toggle is **ON** (it should already be enabled) +3. Under **App-Level Tokens**, click **"Generate"** to create a new token +4. Name it: `kagent-socket-token` +5. **SAVE THIS TOKEN** - you'll need it for `SLACK_APP_TOKEN` (format: `xapp-1-...`) + +### Install the App: + +1. Go to **Install App** in the sidebar +2. Click **"Install to Workspace"** +3. Review permissions and click **"Allow"** + +### Skip to Step 3 below to collect your tokens. + +--- + +## Option B: Manual Configuration + +If you prefer to configure everything manually instead of using the manifest. + +### Step 1: Create the App + +1. Go to https://api.slack.com/apps +2. Click **"Create New App"** → **"From scratch"** +3. Name: `kagent-bot` +4. Choose your workspace +5. Click **"Create App"** + +### Step 2: Configure OAuth & Permissions (Manual Setup Only) + +Go to **OAuth & Permissions** in the sidebar and add these bot token 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 (for RBAC) +- `users:read.email` - View user emails (for RBAC) +- `usergroups:read` - Read user groups (for RBAC) + +Click **"Save Changes"** + +### Step 3: Enable Socket Mode (Manual Setup Only) + +Go to **Socket Mode** in the sidebar: + +1. Toggle **"Enable Socket Mode"** to **ON** +2. Under **App-Level Tokens**, 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-...`) + +### Step 4: Configure Event Subscriptions (Manual Setup Only) + +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: Enable Interactivity (Manual Setup Only) + +Go to **Interactivity & Shortcuts** in the sidebar: + +1. Toggle **"Interactivity"** to **ON** +2. **No Request URL needed** (Socket Mode handles this automatically) +3. Click **"Save Changes"** + +This enables button actions for HITL approval workflows. + +### Step 6: Create Slash Commands (Manual Setup Only) + +Go to **Slash Commands** in the sidebar and create two commands: + +**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 7: Install App to Workspace (Manual Setup Only) + +1. Go to **Install App** in the sidebar +2. Click **"Install to Workspace"** +3. Review permissions and click **"Allow"** + +--- + +## Collect Your Tokens (Required for Both Options) + +After creating and installing the app (via either option above), you'll need **3 tokens**: + +### 1. Bot User OAuth Token +- Go to **OAuth & Permissions** in the sidebar +- Copy **"Bot User OAuth Token"** +- Format: `xoxb-...` +- Save as: `SLACK_BOT_TOKEN` + +### 2. App-Level Token (Socket Mode) +- Go to **Basic Information** → **App-Level Tokens** +- Copy the token you generated +- 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` + +## Configure the Bot (Required for Both Options) + +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 +``` + +## Invite Bot to Channels (Required for Both Options) + +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) + +## Test the Bot (Required for Both Options) + +### 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` and `message.im` events +- [ ] Enabled Interactivity (for HITL approval buttons) +- [ ] 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 (see README.md for permissions configuration) diff --git a/slackbot/config/permissions.yaml b/slackbot/config/permissions.yaml new file mode 100644 index 000000000..3974f083a --- /dev/null +++ b/slackbot/config/permissions.yaml @@ -0,0 +1,29 @@ +# Kagent Slackbot Permissions Configuration +# +# This file defines which users and user groups can access which agents. +# Format: +# /: +# users: +# - user@example.com +# user_groups: +# - slack_group_id +# deny_message: "Custom denial message (optional)" +# +# Example: +# +# kagent/k8s-agent: +# users: +# - admin@company.com +# user_groups: +# - S01ABC123XYZ # Slack user group ID +# deny_message: "Access to k8s-agent requires SRE group membership" +# +# kagent/data-agent: +# users: +# - data-engineer@company.com +# - analyst@company.com +# user_groups: +# - S01DEF456UVW +# +# Default: If no permissions are specified for an agent, all users have access. +# To restrict an agent, add it to this file with specific users or groups. diff --git a/slackbot/pyproject.toml b/slackbot/pyproject.toml new file mode 100644 index 000000000..8e77c06b2 --- /dev/null +++ b/slackbot/pyproject.toml @@ -0,0 +1,30 @@ +[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", + "a2a-sdk>=0.2.16,<0.4.0", + "pydantic>=2.0.0", +] + +[project.optional-dependencies] +dev = [ + "mypy>=1.11.0", + "ruff>=0.6.0", + "types-pyyaml", +] + +[tool.ruff] +extend = "../python/pyproject.toml" +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..d2fb5c647 --- /dev/null +++ b/slackbot/src/kagent_slackbot/auth/permissions.py @@ -0,0 +1,123 @@ +"""Agent permission checking""" + +from typing import Any + +import yaml +from structlog import get_logger + +from .slack_groups import SlackGroupChecker + +logger = get_logger(__name__) + + +class PermissionChecker: + """Check agent access permissions""" + + 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 + + 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 + + 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..4401fe4db --- /dev/null +++ b/slackbot/src/kagent_slackbot/auth/slack_groups.py @@ -0,0 +1,96 @@ +"""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..cdf506423 --- /dev/null +++ b/slackbot/src/kagent_slackbot/config.py @@ -0,0 +1,76 @@ +import os +from dataclasses import dataclass + +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..e7b8e1eb8 --- /dev/null +++ b/slackbot/src/kagent_slackbot/constants.py @@ -0,0 +1,30 @@ +"""Application constants""" + +import os + +# Slack message limits +SLACK_BLOCK_LIMIT = 2900 # Characters per block +SLACK_TEXT_SUMMARY_LENGTH = 200 # Characters for message text field +PREVIEW_MAX_LENGTH = 1000 # Characters for streaming preview + +# User input limits +MAX_MESSAGE_LENGTH = 4000 +MIN_MESSAGE_LENGTH = 1 + +# UI update timing +UPDATE_THROTTLE_SECONDS = 2 # Seconds between streaming UI updates + +# Agent discovery +AGENT_CACHE_TTL = 300 # 5 minutes + +# Session ID format +SESSION_ID_PREFIX = "slack" + +# Default agent (fallback) - can be overridden via env vars +DEFAULT_AGENT_NAMESPACE = os.getenv("DEFAULT_AGENT_NAMESPACE", "kagent") +DEFAULT_AGENT_NAME = os.getenv("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..244e10e84 --- /dev/null +++ b/slackbot/src/kagent_slackbot/handlers/actions.py @@ -0,0 +1,286 @@ +"""Action (button) handlers""" + +from typing import Any + +from a2a.types import ( + DataPart, + Part, + TaskArtifactUpdateEvent, + TaskStatusUpdateEvent, + TextPart, +) +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 _parse_button_value(action: dict[str, Any]) -> tuple[str, str | None, str]: + """ + Parse approval button value into components. + + Args: + action: Slack action dict containing button value + + Returns: + Tuple of (session_id, task_id, agent_full_name) + """ + button_value = action["value"] + parts = button_value.split("|") + session_id = parts[0] + task_id = parts[1] if len(parts) > 1 else None + agent_full_name = parts[2] if len(parts) > 2 else "" + return session_id, task_id, agent_full_name + + +def _extract_original_message_ts(session_id: str) -> str | None: + """ + Extract original message timestamp from session ID. + + Session ID format: slack-{user_id}-{channel}-{thread_ts} + For top-level messages, thread_ts == original message ts + + Args: + session_id: Session ID string + + Returns: + Original message timestamp or None if parsing fails + """ + parts = session_id.split("-") + if len(parts) >= 4: + return parts[3] # thread_ts + return None + + +async def _remove_reaction( + client: AsyncWebClient, + channel: str, + timestamp: str, +) -> None: + """ + Remove eyes reaction from a message. + + Args: + client: Slack client + channel: Channel ID + timestamp: Message timestamp + """ + try: + await client.reactions_remove( + channel=channel, + timestamp=timestamp, + name="eyes", + ) + except Exception as e: + logger.warning("Failed to remove reaction", error=str(e)) + + +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() + + session_id, task_id, agent_full_name = _parse_button_value(action) + + 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, + task_id=task_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: + # Update UI to show approval was received + await client.chat_update( + channel=channel, + ts=message_ts, + text="✅ Approved - Agent is processing...", + blocks=body["message"]["blocks"] + + [ + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f"✅ _Approved by <@{user_id}> - agent working..._", + } + ], + } + ], + ) + + # Build structured approval response using SDK types + approval_parts: list[Part] = [ + TextPart(text="APPROVED: User approved. Proceed with the action."), + DataPart(data={"decision_type": "tool_approval", "decision": "approve"}), + ] + + response_text = "" + + async for event in a2a_client.stream_agent_with_parts( + namespace=namespace, + agent_name=agent_name, + parts=approval_parts, + session_id=session_id, + user_id=user_id, + task_id=task_id, + ): + # Handle different event types + if isinstance(event, TaskStatusUpdateEvent): + # Collect agent response messages + if event.status.message: + msg = event.status.message + if msg.role == "agent": + for part in msg.parts: + if isinstance(part.root, TextPart): + response_text += part.root.text + + elif isinstance(event, TaskArtifactUpdateEvent): + # Artifact updates REPLACE content (not append) + artifact_text = "" + for part in event.artifact.parts: + if isinstance(part.root, TextPart): + artifact_text += part.root.text + response_text = artifact_text + + # Update with final result + await client.chat_update( + channel=channel, + ts=message_ts, + text=f"✅ Completed: {response_text[:200] if response_text else 'Action completed successfully'}", + blocks=body["message"]["blocks"] + + [ + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f"✅ _Approved by <@{user_id}> - completed_", + } + ], + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": response_text if response_text else "_Action completed_", + }, + }, + ], + ) + + # Remove acknowledgment reaction from original message + original_msg_ts = _extract_original_message_ts(session_id) + if original_msg_ts: + await _remove_reaction(client, channel, original_msg_ts) + + logger.info("Approval completed", 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)}", + ) + + # Remove acknowledgment reaction even on error + original_msg_ts = _extract_original_message_ts(session_id) + if original_msg_ts: + await _remove_reaction(client, channel, original_msg_ts) + + @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() + + session_id, task_id, agent_full_name = _parse_button_value(action) + + 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, + task_id=task_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="DENIED: User denied. Cancel the action and do not proceed.", + session_id=session_id, + task_id=task_id, # Include task_id to resume existing task! + 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": f"❌ _Denied by <@{user_id}> - agent canceled_", + } + ], + } + ], + ) + + # Remove acknowledgment reaction from original message + original_msg_ts = _extract_original_message_ts(session_id) + if original_msg_ts: + await _remove_reaction(client, channel, original_msg_ts) + + 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)}", + ) + + # Remove acknowledgment reaction even on error + original_msg_ts = _extract_original_message_ts(session_id) + if original_msg_ts: + await _remove_reaction(client, channel, original_msg_ts) diff --git a/slackbot/src/kagent_slackbot/handlers/commands.py b/slackbot/src/kagent_slackbot/handlers/commands.py new file mode 100644 index 000000000..b1885d16a --- /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 ..auth.permissions import PermissionChecker +from ..constants import EMOJI_ROBOT +from ..services.agent_discovery import AgentDiscovery +from ..services.agent_router import AgentRouter +from ..slack.formatters import format_agent_list, format_error + +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 /`\nExample: `/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.\nUse `/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..b9f4119a8 --- /dev/null +++ b/slackbot/src/kagent_slackbot/handlers/mentions.py @@ -0,0 +1,436 @@ +"""App mention handlers""" + +import time +from typing import Any + +from a2a.types import ( + DataPart, + Message, + Part, + Task, + TaskArtifactUpdateEvent, + TaskState, + TaskStatus, + TaskStatusUpdateEvent, + TextPart, +) +from slack_bolt.async_app import AsyncApp +from slack_bolt.context.say.async_say import AsyncSay +from slack_sdk.web.async_client import AsyncWebClient +from structlog import get_logger + +from ..auth.permissions import PermissionChecker +from ..constants import ( + EMOJI_THINKING, + PREVIEW_MAX_LENGTH, + SESSION_ID_PREFIX, + SLACK_TEXT_SUMMARY_LENGTH, + UPDATE_THROTTLE_SECONDS, +) +from ..models.interrupt import ActionRequest, InterruptData, ReviewConfig +from ..services.a2a_client import A2AClient +from ..services.agent_discovery import AgentDiscovery +from ..services.agent_router import AgentRouter +from ..slack.formatters import format_agent_response, format_error +from ..slack.validators import sanitize_message, strip_bot_mention, validate_message + +logger = get_logger(__name__) + + +async def _remove_reaction( + client: AsyncWebClient, + channel: str, + timestamp: str, + name: str = "eyes", +) -> None: + """ + Remove a reaction from a message. + + Args: + client: Slack client + channel: Channel ID + timestamp: Message timestamp + name: Reaction name (default: "eyes") + """ + try: + await client.reactions_remove( + channel=channel, + timestamp=timestamp, + name=name, + ) + except Exception as e: + logger.warning("Failed to remove reaction", error=str(e)) + + +async def handle_interrupt_approval( + client: AsyncWebClient, + channel: str, + message_ts: str, + interrupt_status: TaskStatus, # Now typed! + session_id: str, + task_id: str, + agent_full_name: str, + response_text: str, +) -> None: + """Handle HITL interrupt approval request. + + Args: + client: Slack client + channel: Channel ID + message_ts: Message timestamp to update + interrupt_status: TaskStatus with message containing interrupt data + session_id: Session ID for resume + task_id: Task ID of the interrupted task + agent_full_name: Full agent name (namespace/name) + response_text: Accumulated response text so far + """ + # Extract interrupt data from status message + if not interrupt_status.message: + logger.warning("Interrupt status has no message") + return + + action_requests: list[ActionRequest] = [] + review_configs: list[ReviewConfig] = [] + + # Find the DataPart with interrupt information + for part in interrupt_status.message.parts: + if isinstance(part.root, DataPart): + try: + # Validate and parse interrupt data + interrupt_data = InterruptData.model_validate(part.root.data) + action_requests = interrupt_data.action_requests + review_configs = interrupt_data.review_configs + break + except Exception as e: + logger.warning("Failed to parse interrupt data", error=str(e)) + continue + + if not action_requests: + logger.warning("No action requests found in interrupt") + return + + # Generate approval UI + from ..slack.formatters import format_approval_request + + blocks = format_approval_request( + agent_name=agent_full_name, + response_text=response_text, + action_requests=action_requests, + review_configs=review_configs, + session_id=session_id, + task_id=task_id, + ) + + # Update message with approval UI + # Extract text summary for the text field (Slack requires non-empty text) + if response_text and response_text.strip(): + text_summary = response_text[:SLACK_TEXT_SUMMARY_LENGTH] + else: + text_summary = "⚠️ Approval Required - Agent needs your decision" + + await client.chat_update( + channel=channel, + ts=message_ts, + text=text_summary, + blocks=blocks, + ) + + logger.info( + "Showing approval UI", + session=session_id, + agent=agent_full_name, + num_actions=len(action_requests), + ) + + +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 + original_msg_ts = event["ts"] + try: + await client.reactions_add( + channel=channel, + timestamp=original_msg_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, + ) + await _remove_reaction(client, channel, original_msg_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, + ) + await _remove_reaction(client, channel, original_msg_ts) + return + + # Check if agent supports streaming + agent = await agent_discovery.get_agent(namespace, agent_name) + # Enable streaming for both Declarative and BYO agents + use_streaming = agent and agent.type in ["Declarative", "BYO"] + + 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() + pending_interrupt = None + pending_interrupt_task_id = None + pending_interrupt_context_id = None + + try: + async for event in a2a_client.stream_agent(namespace, agent_name, message, session_id, user_id): + # Handle different event types + if isinstance(event, TaskStatusUpdateEvent): + task_state = event.status.state + + # Detect input_required state (interrupt) + if task_state == TaskState.input_required: + pending_interrupt = event.status + pending_interrupt_task_id = event.task_id + pending_interrupt_context_id = event.context_id + break # Stop streaming, show approval UI + + # Extract message text from agent + if event.status.message: + msg = event.status.message + # Only accumulate agent messages + if msg.role == "agent": + for part in msg.parts: + if isinstance(part.root, TextPart): + response_text += part.root.text + + elif isinstance(event, TaskArtifactUpdateEvent): + # Artifact updates REPLACE content (not append) + # Each artifact is a complete message unit + artifact_text = "" + for part in event.artifact.parts: + if isinstance(part.root, TextPart): + artifact_text += part.root.text + + # Replace response_text with the latest artifact + response_text = artifact_text + + # Update every 2 seconds (only if we have content) + if time.time() - last_update > UPDATE_THROTTLE_SECONDS and response_text.strip(): + preview = response_text[:PREVIEW_MAX_LENGTH] + ("..." if len(response_text) > PREVIEW_MAX_LENGTH else "") + await client.chat_update( + channel=channel, + ts=working_ts, + text=preview, + ) + last_update = time.time() + + # Handle interrupt if detected + if pending_interrupt: + await handle_interrupt_approval( + client=client, + channel=channel, + message_ts=working_ts, + interrupt_status=pending_interrupt, + session_id=pending_interrupt_context_id + or session_id, # Use agent's context ID if available! + task_id=pending_interrupt_task_id, + agent_full_name=f"{namespace}/{agent_name}", + response_text=response_text, + ) + return # Don't send final response yet + + # 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, + ) + + # Ensure text is non-empty (Slack API requirement) + text_field = response_text[:SLACK_TEXT_SUMMARY_LENGTH] if response_text and response_text.strip() else "Agent response" + + await client.chat_update( + channel=channel, + ts=working_ts, + text=text_field, + blocks=blocks, + ) + + # Remove acknowledgment reaction + await _remove_reaction(client, channel, original_msg_ts) + + 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)}"), + ) + + # Remove acknowledgment reaction + await _remove_reaction(client, channel, original_msg_ts) + + return # Exit cleanly - error already shown to user + + else: + # Non-streaming mode + result = await a2a_client.invoke_agent( + namespace=namespace, + agent_name=agent_name, + message=message, + session_id=session_id, + task_id=None, + user_id=user_id, + ) + # result is now Task! + + response_time = time.time() - start_time + + response_text = "" + if result.history: + # Filter to agent messages + agent_messages = [msg for msg in result.history if msg.role == "agent"] + if agent_messages: + last_message = agent_messages[-1] + # Extract text from parts + text_parts = [part.root.text for part in last_message.parts if isinstance(part.root, TextPart)] + response_text = "\n".join(text_parts) + 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) + + # Remove acknowledgment reaction + await _remove_reaction(client, channel, original_msg_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, + ) + + # Remove acknowledgment reaction + await _remove_reaction(client, channel, original_msg_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 - Bolt's ignoring_self_events middleware filters bot messages automatically + if event.get("channel_type") == "im": + 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..105f31ec4 --- /dev/null +++ b/slackbot/src/kagent_slackbot/handlers/middleware.py @@ -0,0 +1,55 @@ +"""Middleware for metrics and logging""" + +import time +from typing import Any, Awaitable, Callable + +from prometheus_client import Counter, Histogram +from slack_bolt.async_app import AsyncApp +from structlog import get_logger + +logger = get_logger(__name__) + +# Prometheus metrics with kagent_slackbot namespace prefix +slack_messages_total = Counter( + "kagent_slackbot_messages_total", + "Total Slack messages processed", + ["event_type", "status"], +) + +slack_message_duration_seconds = Histogram( + "kagent_slackbot_message_duration_seconds", + "Message processing duration", + ["event_type"], +) + +slack_commands_total = Counter( + "kagent_slackbot_commands_total", "Total slash commands", ["command", "status"] +) + +agent_invocations_total = Counter( + "kagent_slackbot_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..36b9e6505 --- /dev/null +++ b/slackbot/src/kagent_slackbot/main.py @@ -0,0 +1,138 @@ +"""Main application entry point""" + +import asyncio + +import structlog +from aiohttp import web +from prometheus_client import CONTENT_TYPE_LATEST, generate_latest +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler +from slack_bolt.async_app import AsyncApp + +from .auth.permissions import PermissionChecker +from .auth.slack_groups import SlackGroupChecker +from .config import load_config +from .constants import AGENT_CACHE_TTL +from .handlers.actions import register_action_handlers +from .handlers.commands import register_command_handlers +from .handlers.mentions import register_mention_handlers +from .handlers.middleware import register_middleware +from .services.a2a_client import A2AClient +from .services.agent_discovery import AgentDiscovery +from .services.agent_router import AgentRouter + +# 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=AGENT_CACHE_TTL, + ) + + 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/models/__init__.py b/slackbot/src/kagent_slackbot/models/__init__.py new file mode 100644 index 000000000..0891d2399 --- /dev/null +++ b/slackbot/src/kagent_slackbot/models/__init__.py @@ -0,0 +1,5 @@ +"""Data models for slackbot""" + +from .interrupt import ActionRequest, InterruptData, ReviewConfig + +__all__ = ["ActionRequest", "InterruptData", "ReviewConfig"] diff --git a/slackbot/src/kagent_slackbot/models/interrupt.py b/slackbot/src/kagent_slackbot/models/interrupt.py new file mode 100644 index 000000000..a2abb98b8 --- /dev/null +++ b/slackbot/src/kagent_slackbot/models/interrupt.py @@ -0,0 +1,58 @@ +"""HITL interrupt/approval Pydantic models + +These models define our custom protocol for tool approval interrupts. +They extend the standard A2A protocol with structured approval data. +""" + +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class ActionRequest(BaseModel): + """Tool execution request requiring human approval. + + Sent by agents when they need permission to execute a tool. + """ + + name: str = Field(..., description="Tool name (e.g., 'kubectl_apply')") + args: dict[str, Any] = Field(default_factory=dict, description="Tool arguments") + + class Config: + json_schema_extra = { + "example": {"name": "kubectl_apply", "args": {"namespace": "default", "manifest": "deployment.yaml"}} + } + + +class ReviewConfig(BaseModel): + """Per-tool approval configuration. + + Defines which decisions are allowed for each tool. + Currently passed through but not used in UI. + """ + + tool_name: str + allowed_decisions: list[str] = Field(default_factory=lambda: ["approve", "deny"]) + + +class InterruptData(BaseModel): + """Interrupt data for tool approval requests. + + Embedded in A2A DataPart when agent needs human approval. + The agent sends this in a message part with kind="data". + """ + + interrupt_type: Literal["tool_approval"] = Field(..., description="Must be 'tool_approval' for our protocol") + action_requests: list[ActionRequest] = Field(..., description="List of tools requiring approval") + review_configs: list[ReviewConfig] = Field(default_factory=list, description="Optional per-tool configurations") + + class Config: + json_schema_extra = { + "example": { + "interrupt_type": "tool_approval", + "action_requests": [ + {"name": "kubectl_delete", "args": {"namespace": "prod", "resource": "deployment/api"}} + ], + "review_configs": [], + } + } 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..70e92680d --- /dev/null +++ b/slackbot/src/kagent_slackbot/services/a2a_client.py @@ -0,0 +1,321 @@ +"""Kagent A2A protocol client""" + +import json +import uuid +from typing import Any, AsyncIterator + +import httpx + +# Import A2A SDK types +from a2a.types import ( + Message, + MessageSendParams, + Part, + SendMessageRequest, + SendStreamingMessageRequest, + Task, + TaskArtifactUpdateEvent, + TaskStatus, + TaskStatusUpdateEvent, + TextPart, +) +from structlog import get_logger + +logger = get_logger(__name__) + + +class A2AResponse: + """Wrapper for JSON-RPC response from A2A API. + + The API returns: {"jsonrpc": "2.0", "id": "...", "result": {...}} + This class extracts the result field which contains the actual Task or event data. + """ + + def __init__(self, response_dict: dict[str, Any]): + self.jsonrpc = response_dict.get("jsonrpc", "2.0") + self.id = response_dict.get("id") + self.result = response_dict.get("result", {}) + + # Check for JSON-RPC errors + if "error" in response_dict: + error = response_dict["error"] + raise ValueError(f"A2A JSON-RPC error: {error}") + + +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, + task_id: str | None = None, + ) -> Task: + """ + Invoke an agent synchronously and return the Task. + + 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 + task_id: Optional task ID to resume existing task + + Returns: + Task: Typed Task object with history and status + """ + url = f"{self.base_url}/api/a2a/{namespace}/{agent_name}/" + + # Build message using SDK types + parts: list[Part] = [TextPart(text=message)] + + msg = Message( + message_id=str(uuid.uuid4()), + role="user", + parts=parts, + context_id=session_id, + task_id=task_id, + ) + + # Build request using SDK types + params = MessageSendParams(message=msg) + request = SendMessageRequest( + id=str(uuid.uuid4()), + params=params, + ) + + # Serialize with camelCase aliases + request_dict = request.model_dump(by_alias=True) + + headers = { + "Content-Type": "application/json", + "X-User-Id": user_id, + } + + logger.info( + "Invoking agent", + namespace=namespace, + agent=agent_name, + session=session_id, + task_id=task_id, + ) + + try: + response = await self.client.post(url, json=request_dict, headers=headers) + response.raise_for_status() + + # Parse JSON-RPC response + response_data = response.json() + a2a_response = A2AResponse(response_data) + + # Validate and return typed Task + task = Task.model_validate(a2a_response.result) + + logger.info( + "Agent invocation complete", + namespace=namespace, + agent=agent_name, + task_id=task.id, + state=task.status.state, + ) + + return task + + 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 failed", + namespace=namespace, + agent=agent_name, + error=str(e), + ) + raise + + async def _parse_sse_stream( + self, response + ) -> AsyncIterator[TaskStatusUpdateEvent | TaskArtifactUpdateEvent]: + """ + Parse SSE stream and yield typed events. + + Args: + response: HTTPX streaming response + + Yields: + TaskStatusUpdateEvent | TaskArtifactUpdateEvent: Typed events from stream + """ + async for line in response.aiter_lines(): + if line.startswith("data: "): + data = line[6:] + if data.strip() and data.strip() != "[DONE]": + try: + # Parse SSE event + event_dict = json.loads(data) + + # Extract result from JSON-RPC wrapper + a2a_response = A2AResponse(event_dict) + + # Try to validate as status-update or artifact-update + kind = a2a_response.result.get("kind") + if kind == "status-update": + event = TaskStatusUpdateEvent.model_validate(a2a_response.result) + yield event + elif kind == "artifact-update": + event = TaskArtifactUpdateEvent.model_validate(a2a_response.result) + yield event + else: + logger.warning("Unknown event kind", kind=kind) + + except json.JSONDecodeError as e: + logger.warning("Failed to parse SSE data", error=str(e), data=data) + except Exception as e: + logger.warning("Failed to validate event", error=str(e), data=data) + + async def stream_agent( + self, + namespace: str, + agent_name: str, + message: str, + session_id: str, + user_id: str, + task_id: str | None = None, + ) -> AsyncIterator[TaskStatusUpdateEvent | TaskArtifactUpdateEvent]: + """ + Stream agent responses as typed events. + + Args: + namespace: Agent namespace + agent_name: Agent name + message: User message text + session_id: Session ID for context + user_id: User ID for authentication + task_id: Optional task ID to resume existing task + + Yields: + TaskStatusUpdateEvent | TaskArtifactUpdateEvent: Typed events from agent + """ + url = f"{self.base_url}/api/a2a/{namespace}/{agent_name}/" + + # Build message using SDK types + parts: list[Part] = [TextPart(text=message)] + + msg = Message( + message_id=str(uuid.uuid4()), + role="user", + parts=parts, + context_id=session_id, + task_id=task_id, + ) + + # Build streaming request + params = MessageSendParams(message=msg) + request = SendStreamingMessageRequest( + id=str(uuid.uuid4()), + params=params, + ) + + request_dict = request.model_dump(by_alias=True) + + headers = { + "Content-Type": "application/json", + "Accept": "text/event-stream", + "X-User-Id": user_id, + } + + logger.info( + "Starting agent stream", + namespace=namespace, + agent=agent_name, + session=session_id, + task_id=task_id, + ) + + async with self.streaming_client.stream("POST", url, json=request_dict, headers=headers) as response: + response.raise_for_status() + async for event in self._parse_sse_stream(response): + yield event + + async def stream_agent_with_parts( + self, + namespace: str, + agent_name: str, + parts: list[Part], # Now typed! + session_id: str, + user_id: str, + task_id: str | None = None, + ) -> AsyncIterator[TaskStatusUpdateEvent | TaskArtifactUpdateEvent]: + """ + Stream agent responses with structured message parts. + + Args: + namespace: Agent namespace + agent_name: Agent name + parts: List of typed Part objects (TextPart, DataPart, etc.) + session_id: Session ID for context + user_id: User ID for authentication + task_id: Optional task ID to resume existing task + + Yields: + TaskStatusUpdateEvent | TaskArtifactUpdateEvent: Typed events from agent + """ + url = f"{self.base_url}/api/a2a/{namespace}/{agent_name}/" + + # Build message with provided parts + msg = Message( + message_id=str(uuid.uuid4()), + role="user", + parts=parts, + context_id=session_id, + task_id=task_id, + ) + + # Build streaming request + params = MessageSendParams(message=msg) + request = SendStreamingMessageRequest( + id=str(uuid.uuid4()), + params=params, + ) + + request_dict = request.model_dump(by_alias=True) + + headers = { + "Content-Type": "application/json", + "Accept": "text/event-stream", + "X-User-Id": user_id, + } + + logger.info( + "Starting agent stream with parts", + namespace=namespace, + agent=agent_name, + session=session_id, + task_id=task_id, + num_parts=len(parts), + ) + + async with self.streaming_client.stream("POST", url, json=request_dict, headers=headers) as response: + response.raise_for_status() + async for event in self._parse_sse_stream(response): + yield event + + 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..3d079298a --- /dev/null +++ b/slackbot/src/kagent_slackbot/services/agent_discovery.py @@ -0,0 +1,253 @@ +"""Agent discovery from Kagent API""" + +import time +from typing import Any, Optional + +import httpx +from pydantic import BaseModel, Field, computed_field, field_validator +from structlog import get_logger + +from ..constants import AGENT_CACHE_TTL + +logger = get_logger(__name__) + + +class AgentCondition(BaseModel): + """Kubernetes-style status condition""" + + type: str + status: str + reason: Optional[str] = None + message: Optional[str] = None + + +class AgentSkill(BaseModel): + """Agent skill definition (from A2A protocol)""" + + id: str + name: str + description: str = "" + tags: list[str] = Field(default_factory=list) + examples: list[str] = Field(default_factory=list) + inputModes: list[str] = Field(default_factory=list, alias="inputModes") + outputModes: list[str] = Field(default_factory=list, alias="outputModes") + + +class AgentMetadata(BaseModel): + """Agent metadata""" + + namespace: str + name: str + + +class AgentA2AConfig(BaseModel): + """Agent A2A configuration""" + + skills: list[AgentSkill] = Field(default_factory=list) + + +class AgentDeclarative(BaseModel): + """Agent declarative configuration""" + + a2aConfig: Optional[AgentA2AConfig] = Field(default=None, alias="a2aConfig") + + +class AgentSpec(BaseModel): + """Agent specification""" + + type: str + description: str = "" + declarative: Optional[AgentDeclarative] = None + + +class AgentStatus(BaseModel): + """Agent status""" + + conditions: list[AgentCondition] = Field(default_factory=list) + + +class Agent(BaseModel): + """Agent resource""" + + metadata: AgentMetadata + spec: AgentSpec + status: AgentStatus + + +class AgentResponse(BaseModel): + """ + Agent response from Kagent API /api/agents endpoint. + This matches the AgentResponse struct from the Go backend. + """ + + id: str + agent: Agent + model: str = "" + modelProvider: str = "" + modelConfigRef: str = "" + tools: Optional[list[dict[str, Any]]] = None + deploymentReady: bool = False + accepted: bool = False + + @field_validator("tools", mode="before") + @classmethod + def convert_none_to_empty_list(cls, v): + """Convert None to empty list for tools field""" + return v if v is not None else [] + + +class AgentInfo(BaseModel): + """ + Wrapper around AgentResponse with convenient computed properties. + Used for agent routing and display in Slack. + """ + + # Store the full API response + id: str + agent: Agent + model: str = "" + modelProvider: str = "" + modelConfigRef: str = "" + tools: Optional[list[dict[str, Any]]] = None + deploymentReady: bool = False + accepted: bool = False + + @field_validator("tools", mode="before") + @classmethod + def convert_none_to_empty_list(cls, v): + """Convert None to empty list for tools field""" + return v if v is not None else [] + + @computed_field # type: ignore[misc] + @property + def namespace(self) -> str: + """Agent namespace""" + return self.agent.metadata.namespace + + @computed_field # type: ignore[misc] + @property + def name(self) -> str: + """Agent name""" + return self.agent.metadata.name + + @computed_field # type: ignore[misc] + @property + def description(self) -> str: + """Agent description""" + return self.agent.spec.description + + @computed_field # type: ignore[misc] + @property + def type(self) -> str: + """Agent type""" + return self.agent.spec.type + + @computed_field # type: ignore[misc] + @property + def ready(self) -> bool: + """ + Check if agent is ready. + Uses the pre-computed deploymentReady field from the backend. + """ + return self.deploymentReady + + @computed_field # type: ignore[misc] + @property + def skills(self) -> list[AgentSkill]: + """Extract skills from agent configuration""" + if self.agent.spec.declarative and self.agent.spec.declarative.a2aConfig: + return self.agent.spec.declarative.a2aConfig.skills + return [] + + @computed_field # type: ignore[misc] + @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 - now using all available fields + for skill in self.skills: + # Skill name and description + keywords.extend(skill.name.lower().split()) + keywords.extend(skill.description.lower().split()) + # Tags (already lowercase typically) + keywords.extend([tag.lower() for tag in skill.tags]) + # Examples (extract key phrases from example prompts) + for example in skill.examples: + keywords.extend(example.lower().split()) + + 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 using Pydantic validation + self.cache = {} + for agent_data in agents_data: + agent_info = AgentInfo.model_validate(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..c74ff4d51 --- /dev/null +++ b/slackbot/src/kagent_slackbot/services/agent_router.py @@ -0,0 +1,92 @@ +"""Agent routing logic""" + +import re + +from structlog import get_logger + +from ..constants import DEFAULT_AGENT_NAME, DEFAULT_AGENT_NAMESPACE +from .agent_discovery import AgentDiscovery + +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..1998a2779 --- /dev/null +++ b/slackbot/src/kagent_slackbot/slack/formatters.py @@ -0,0 +1,328 @@ +"""Slack Block Kit formatting""" + +from datetime import datetime +from typing import Any, Optional + +from ..constants import EMOJI_CLOCK, EMOJI_ROBOT, SLACK_BLOCK_LIMIT +from ..models.interrupt import ActionRequest, ReviewConfig + + +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) + + 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_approval_request( + agent_name: str, + response_text: str, + action_requests: list[ActionRequest], # Now typed! + review_configs: list[ReviewConfig], # Now typed! + session_id: str, + task_id: str, +) -> list[dict[str, Any]]: + """Format tool approval request as Slack blocks. + + Args: + agent_name: Name of the agent requesting approval + response_text: Agent's explanation text + action_requests: List of typed ActionRequest objects + review_configs: List of typed ReviewConfig objects + session_id: Session ID for button callbacks + task_id: Task ID of the interrupted task + + Returns: + List of Slack block dictionaries + """ + blocks = [] + + # Header + blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"{EMOJI_ROBOT} *Approval Required from {agent_name}*", + }, + } + ) + + # Agent's explanation (if any) + if response_text: + chunks = chunk_text(response_text) + for chunk in chunks: + blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": chunk, + }, + } + ) + + blocks.append({"type": "divider"}) + + # List each action requiring approval + blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "⚠️ *The following actions require your approval:*", + }, + } + ) + + for action in action_requests: + # Now use properties instead of .get()! + tool_name = action.name + tool_args = action.args + + # Format args nicely + if tool_args: + args_text = "\n".join([f" • `{k}`: `{v}`" for k, v in tool_args.items()]) + tool_text = f"**Tool**: `{tool_name}`\n**Arguments**:\n{args_text}" + else: + tool_text = f"**Tool**: `{tool_name}`\n_(no arguments)_" + + blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": tool_text, + }, + } + ) + + # Approval buttons - include task_id! + button_value = f"{session_id}|{task_id}|{agent_name}" + + blocks.append( + { + "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, + }, + ], + } + ) + + # Footer + blocks.append( + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f"_Session: `{session_id[-8:]}` • Waiting for your decision_", + } + ], + } + ) + + 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..066160194 --- /dev/null +++ b/slackbot/src/kagent_slackbot/slack/validators.py @@ -0,0 +1,57 @@ +"""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()