Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy-slack-agent.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ jobs:
--port=8080 \
--add-volume=name=state,type=cloud-storage,bucket=aieng-bot-slack-agent-state \
--add-volume-mount=volume=state,mount-path=/var/state \
--set-env-vars="GIT_SHA=${{ github.sha }},STATE_DIR=/var/state,BOOKSTACK_STAFF_ROLE_ID=5,BOOKSTACK_PUBLIC_ROLE_ID=4,AGENT_WRITERS=U02NR1PBR88,SLACK_BOT_TOKEN=${{ secrets.SLACK_BOT_TOKEN }},SLACK_APP_TOKEN=${{ secrets.SLACK_APP_TOKEN }},LLM_API_KEY=${{ secrets.BOOKSTACK_API_KEY }},LLM_BASE_URL=${{ secrets.LLM_BASE_URL }},CLAUDE_MODEL=${{ secrets.CLAUDE_MODEL }},BOOKSTACK_URL=https://bookstack.vectorinstitute.ai,BOOKSTACK_TOKEN_ID=${{ secrets.BOOKSTACK_TOKEN_ID }},BOOKSTACK_TOKEN_SECRET=${{ secrets.BOOKSTACK_TOKEN_SECRET }}" \
--set-env-vars="GIT_SHA=${{ github.sha }},STATE_DIR=/var/state,BOOKSTACK_STAFF_ROLE_ID=5,BOOKSTACK_PUBLIC_ROLE_ID=4,AGENT_WRITERS=U02NR1PBR88,SLACK_BOT_TOKEN=${{ secrets.SLACK_BOT_TOKEN }},SLACK_APP_TOKEN=${{ secrets.SLACK_APP_TOKEN }},LLM_API_KEY=${{ secrets.BOOKSTACK_API_KEY }},LLM_BASE_URL=${{ secrets.LLM_BASE_URL }},CLAUDE_MODEL=${{ secrets.CLAUDE_MODEL }},BOOKSTACK_URL=https://bookstack.vectorinstitute.ai,BOOKSTACK_TOKEN_ID=${{ secrets.BOOKSTACK_TOKEN_ID }},BOOKSTACK_TOKEN_SECRET=${{ secrets.BOOKSTACK_TOKEN_SECRET }},GITHUB_ORG=VectorInstitute,GITHUB_TOKEN=${{ secrets.ORG_ACCESS_TOKEN }},GITHUB_APP_ID=${{ secrets.GH_APP_ID }},GITHUB_APP_INSTALLATION_ID=${{ secrets.GH_APP_INSTALLATION_ID }},GITHUB_APP_PRIVATE_KEY_B64=${{ secrets.GH_APP_PRIVATE_KEY_B64 }}" \
--update-labels="deployed-by=github-actions,commit=${{ github.sha }},service=${{ env.SERVICE_NAME }}" \
--quiet

Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ docs = [
slack-agent = [
"aiohttp>=3.11.0",
"slack-bolt>=1.21.0",
"cryptography>=48.0.1", # RS256 signing backend for GitHub App JWTs
"pyjwt>=2.13.0", # GitHub App installation-token flow
]


Expand Down
77 changes: 77 additions & 0 deletions scripts/verify_github_access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Verify the Slack agent's GitHub credentials end to end.

Uses the same auth + client code as the github sub-agent, so a passing
run means the deployed bot will work with the same environment.

Reads the same env vars as the agent (a repo-root ``.env`` works):
``GITHUB_APP_ID`` + ``GITHUB_APP_PRIVATE_KEY_FILE`` (or ``_B64`` /
inline) and optional ``GITHUB_APP_INSTALLATION_ID``, else a read-only
``GITHUB_TOKEN``; ``GITHUB_ORG`` defaults to VectorInstitute.

Usage:
uv run python scripts/verify_github_access.py
"""

import os
import sys
from pathlib import Path

from dotenv import load_dotenv

# slack_agent is a top-level directory, not an installed package.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
load_dotenv()

from slack_agent.agents.github.auth import ( # noqa: E402
AppInstallationAuth,
StaticTokenAuth,
TokenProvider,
)
from slack_agent.agents.github.client import GitHubClient # noqa: E402
from slack_agent.config import _resolve_github_private_key # noqa: E402


def _build_auth(org: str) -> TokenProvider:
app_id = os.environ.get("GITHUB_APP_ID", "")
private_key = _resolve_github_private_key()
if app_id and private_key:
installation = os.environ.get("GITHUB_APP_INSTALLATION_ID", "")
print(f"auth: GitHub App {app_id} (installation: {installation or 'auto'})")
return AppInstallationAuth(
app_id=app_id,
private_key_pem=private_key,
org=org,
installation_id=int(installation) if installation else None,
)
token = os.environ.get("GITHUB_TOKEN", "")
if token:
print("auth: static GITHUB_TOKEN")
return StaticTokenAuth(token)
sys.exit(
"No GitHub credentials: set GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY_FILE "
"(or _B64/inline), or GITHUB_TOKEN"
)


def main() -> None:
"""Mint a token and exercise the read tools against the org."""
org = os.environ.get("GITHUB_ORG", "VectorInstitute")
client = GitHubClient(_build_auth(org), org=org)

repos = client.list_repos(limit=5)
print(f"\n✓ list_repos: {len(repos)} of the most recently pushed {org} repos:")
for repo in repos:
print(f" {repo['name']} ({repo.get('visibility')})")

readme = client.get_file("aieng-bot", "README.md")
print(f"\n✓ get_file: read {readme['path']} from aieng-bot")

checks = client.get_check_runs("aieng-bot", "main")
print(f"✓ get_check_runs: {checks.get('total_count', 0)} checks on aieng-bot@main")

print("\nAll reads succeeded. The github sub-agent will enable with this env.")


if __name__ == "__main__":
main()
57 changes: 40 additions & 17 deletions slack_agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ Slack event (mention, DM, channel message, slash command)
and thread messages (slack_context.py) on the session's first turn
· mentions and DMs: handed to the Orchestrator
→ Orchestrator (agents/orchestrator.py) picks a specialist sub-agent
· sticky per-thread sessions, keyword scoring for fresh ones
→ sub-agent runs its own LLM loop, streaming into a StreamingReply (streaming.py)
· tools: BookStack (search, get_page) + Slack history on demand
· tools: BookStack (search, get_page) or GitHub (repos, code, PRs,
issues, CI) + Slack history on demand
(agents/slack_tools.py; bound to the current channel)
· native plan block while working, final answer + context footer when done
→ markdown converted to Slack mrkdwn (mrkdwn.py)
Expand All @@ -35,23 +37,28 @@ DMed.

The agent layer is orchestrator-shaped: `agents/orchestrator.py` owns the
roster of specialist sub-agents and routes each request to one of them.
With a single sub-agent registered the route is direct; when more land,
`Orchestrator.route()` becomes the dispatch point (heuristics or a small
classifier over the sub-agents' descriptions) with no change to the Slack
plumbing or the sub-agents.
Routing is sticky per thread session (follow-ups stay with the agent that
answered); fresh sessions are scored against each sub-agent's `keywords`
hints, falling back to the first registered agent.

| Sub-agent | Module | Status |
|---|---|---|
| bookstack: answers documentation questions from the Vector wiki | `agents/bookstack/` | live |
| github: answers questions about VectorInstitute repos, code, PRs, issues, CI (read-only) | `agents/github/` | live |

New sub-agents implement the `SubAgent` protocol (`agents/base.py`) and
register in `agents/__init__.py`.

The bookstack sub-agent fully owns the QA stack (`agents/bookstack/`:
Anthropic tool-use loop, BookStack API client, tools, prompts), using the
shared model + gateway plumbing (`CLAUDE_MODEL` via `aieng_bot.config`,
`LLM_BASE_URL` + `LLM_API_KEY` bearer auth). The former web chat UI and its
FastAPI backend were retired in favor of Slack.
register in `agents/__init__.py`. The Anthropic tool-use loop (streaming,
thinking-model handling, tool dispatch) is shared in `agents/toolloop.py`;
each sub-agent binds it to its own API client, tools, and prompts, using
the shared model + gateway plumbing (`CLAUDE_MODEL` via `aieng_bot.config`,
`LLM_BASE_URL` + `LLM_API_KEY` bearer auth).

GitHub credentials are read-only by construction: preferably a GitHub App
(`GITHUB_APP_ID` + private key) whose installation tokens carry only the
app's read permissions, or a fine-grained read-only PAT (`GITHUB_TOKEN`)
for local dev. All lookups are pinned to `GITHUB_ORG` (default
VectorInstitute) inside the client, so the model cannot reach other
owners.

## Local development

Expand All @@ -64,12 +71,17 @@ uv run python -m slack_agent.app

Configuration comes from two optional dotenv files:

- repo root `.env`: LLM + BookStack credentials (`ANTHROPIC_API_KEY` or
`LLM_BASE_URL`/`LLM_API_KEY`, `BOOKSTACK_TOKEN_ID`, `BOOKSTACK_TOKEN_SECRET`)
- repo root `.env`: LLM + BookStack + GitHub credentials
(`ANTHROPIC_API_KEY` or `LLM_BASE_URL`/`LLM_API_KEY`,
`BOOKSTACK_TOKEN_ID`, `BOOKSTACK_TOKEN_SECRET`, and either
`GITHUB_APP_ID` + `GITHUB_APP_PRIVATE_KEY_FILE` (path to the
downloaded `.pem`, optionally `GITHUB_APP_INSTALLATION_ID`) or a
read-only `GITHUB_TOKEN`)
- `slack_agent/.env`: `SLACK_BOT_TOKEN` (xoxb), `SLACK_APP_TOKEN` (xapp)

Without BookStack/LLM credentials the bot still runs with no agents
and says so when asked.
Sub-agents enable independently: missing credentials disable that
sub-agent (logged at startup), and with none configured the bot still
runs and says so when asked.

Note: the production bot runs on Cloud Run with a single Socket Mode
connection. Running locally at the same time means both instances receive
Expand All @@ -88,6 +100,15 @@ Required GitHub secrets:
- `BOOKSTACK_API_KEY` (used as `LLM_API_KEY`), `LLM_BASE_URL`, `CLAUDE_MODEL`,
`BOOKSTACK_TOKEN_ID`, `BOOKSTACK_TOKEN_SECRET`: LLM gateway and BookStack
API credentials
- `ORG_ACCESS_TOKEN` (passed as `GITHUB_TOKEN`): interim credential for
the github sub-agent. The token is write-capable, but the agent's
tool roster is read-only and org-pinned, so writes cannot happen
through the agent; swap to the App credentials below to make the
credential itself read-only.
- `GH_APP_ID`, `GH_APP_INSTALLATION_ID`, `GH_APP_PRIVATE_KEY_B64`
(base64 of the `.pem`, newline-safe for `--set-env-vars`): read-only
GitHub App credentials; when set they take precedence over
`GITHUB_TOKEN`
- `GCP_WORKLOAD_IDENTITY_PROVIDER`, `GCP_SERVICE_ACCOUNT`: GCP auth

The service runs exactly one always-on instance (`--min-instances=1
Expand All @@ -108,5 +129,7 @@ Logo assets live in `assets/`.
- [x] BookStack QA capability with streaming replies
- [ ] Persist thread contexts across deploys
- [x] Ambient channel context + on-demand Slack history tools
- [ ] More sub-agents: GitHub, CI failures, dashboards
- [x] GitHub sub-agent (read-only: repos, code search, PRs, issues, CI)
- [ ] More sub-agents: CI failure fixing, dashboards
- [ ] GitHub write capabilities (behind the `write` access tier + org-owned App)
- [ ] Slack "Agents & AI Apps" assistant surface (needs `assistant:write` scope + reinstall)
5 changes: 5 additions & 0 deletions slack_agent/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from ..slack_context import SlackContextService
from .base import SubAgent
from .bookstack import BookstackSubAgent
from .github import GithubSubAgent
from .orchestrator import Orchestrator

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -42,4 +43,8 @@ def build_orchestrator(
logger.warning(
"bookstack sub-agent disabled: missing LLM or BookStack credentials"
)
if settings.github_configured:
agents.append(GithubSubAgent(settings, slack_context))
else:
logger.warning("github sub-agent disabled: missing LLM or GitHub credentials")
return Orchestrator(agents)
5 changes: 5 additions & 0 deletions slack_agent/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,15 @@ class SubAgent(Protocol):
Each sub-agent owns one domain (BookStack documentation, GitHub, CI, …),
runs its own LLM loop with its own tools, and renders its progress and
final answer into the thread's :class:`StreamingReply`.

``keywords`` are routing hints: lowercase terms whose presence in a
question signals this agent's domain. The orchestrator scores agents
by keyword hits and falls back to the first registered agent.
"""

name: str
description: str
keywords: frozenset[str]

async def handle(
self,
Expand Down
Loading