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
18 changes: 17 additions & 1 deletion apps/slack-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ agent/
tools/render_chart.ts # renders a PNG chart in-process and posts it into the thread
tools/{bash,glob,grep,read_file,write_file,web_fetch,web_search}.ts
# `disableTool()` sentinels — see Framework tools below
lib/thread-context.ts # full-thread turn context (renders Block Kit / alert cards too)
lib/bot-identity.ts # per-team bot user id learned from the webhook envelope
lib/chart.ts # pure SVG chart renderer + unicode-sparkline fallback
lib/slack-upload.ts # Slack external-upload flow (files.getUploadURLExternal → complete)
lib/env.ts # shared is-this-deployed predicate (route auth + token fallback)
Expand Down Expand Up @@ -337,7 +339,8 @@ eve's native idiom:
- **Modes → skills:** the web chat's dashboard-builder and investigate modes are progressive-
disclosure skills (`agent/skills/dashboard-builder/`, `agent/skills/incident-investigation/`)
the model loads via `load_skill`. Alert context comes from the Slack thread (Maple delivers
alert notifications into Slack), not from a request payload.
alert notifications into Slack), not from a request payload — including the alert card itself,
which `agent/lib/thread-context.ts` renders out of its Block Kit attachment (see Notes).
- **Approvals — both sides now interrupt, by different mechanisms:** the web chat stops the turn on
a gated tool and emits a `tool-call` with `proposed: true` and no result
(`apps/api/src/chat/agent.ts`); the user approves and `POST /api/chat/apply` performs the
Expand Down Expand Up @@ -490,6 +493,19 @@ and **activate public distribution** so the app can be installed into any worksp
The SSE must carry `delta.tool_calls`, not a JSON blob inside the text content. Also set
`OPENROUTER_CONTEXT_WINDOW` to the new model's window.

- **Thread context is ours, not `slackChannel({ threadContext })`.** Every mention and DM ships the
whole thread transcript with the turn, rendered by `agent/lib/thread-context.ts` and returned as
the mention result's `context` from `onAppMention` / `onDirectMessage`. eve's built-in option
couldn't carry an alert thread — the case that matters most, since the user is replying to
something Maple posted rather than opening a topic. It reads a message's content from `text`
alone, and Maple's alert notifications have none: the blocks ride inside a colored attachment
(`apps/api/src/services/alerts/AlertDeliveryDispatch.ts`), so the model saw an empty
`<content></content>`. Worse, `since: "last-agent-reply"` counted the alert as the agent's own
reply (eve's `isMe` is `bot_id !== undefined` — any bot) and cut context off _after_ it, dropping
the alert entirely. The user had to hand the bot a recap of the alert it had just sent. Ours
falls back to blocks/attachments for content, keeps the full thread (which also survives a
session lost to a redeploy), and attributes speakers with the workspace's real bot user id from
`agent/lib/bot-identity.ts` so a third-party app in the channel isn't quoted back as the agent.
- **Auth:** `agent/channels/eve.ts` fails closed in deployed environments (`RAILWAY_ENVIRONMENT_NAME`
set, or `NODE_ENV=production`): the browser/API routes always require HTTP Basic there. With
`ROUTE_AUTH_BASIC_PASSWORD` set that's your stable credential; without it, a random per-boot
Expand Down
46 changes: 41 additions & 5 deletions apps/slack-agent/agent/channels/slack.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { slackChannel } from "eve/channels/slack"
import type { SlackWebhookVerifier } from "eve/channels/slack"
import { defaultSlackAuth, slackChannel } from "eve/channels/slack"
import type { SlackContext, SlackMentionResult, SlackMessage, SlackWebhookVerifier } from "eve/channels/slack"
import { acknowledgeIncomingMessage } from "#lib/ack-reaction.js"
import { describeActions, truncateTypingStatus } from "#lib/action-status.js"
import { botUserIdForTeam, rememberBotUserId } from "#lib/bot-identity.js"
import { resolveBotToken, verifySlackV0Signature, type SlackTokenContext } from "#lib/maple.js"
import { loadThreadContext } from "#lib/thread-context.js"
import { promoteThreadFollowUp } from "#lib/thread-follow-up.js"
import { forwardUninstallEvent } from "#lib/uninstall-detection.js"

Expand Down Expand Up @@ -38,6 +40,11 @@ const webhookVerifier: SlackWebhookVerifier = async (request, body) => {
return false
}

// Every event_callback names this workspace's bot user under
// `authorizations`, so thread-context attribution gets it for free — no
// auth.test round-trip (#lib/bot-identity.js).
rememberBotUserId(body)

// app_uninstalled / tokens_revoked: eve only dispatches app_mention + DM
// events downstream, so it would otherwise drop these as "unsupported".
// Fired without awaiting — it must never delay this webhook's ack, and it
Expand Down Expand Up @@ -75,6 +82,34 @@ const webhookVerifier: SlackWebhookVerifier = async (request, body) => {
return body
}

/**
* Stands in for eve's default mention/DM pipeline: same two steps — a
* "Thinking..." typing indicator and workspace-scoped auth derivation — plus
* the thread transcript as turn context.
*
* The context is ours rather than the channel's `threadContext` option because
* eve reads a message's content from `text` alone and treats every bot post as
* the agent's own last reply. Maple's alert notifications are Block Kit inside
* an attachment with no top-level text, so both rules misfired at once and an
* @mention in an alert thread reached the model with the alert blank and
* everything before it cut away — see #lib/thread-context.js.
*
* Runs after eve has already returned 200 to Slack (`waitUntil`), so the thread
* fetch is off the webhook's delivery budget. It must not throw: eve drops the
* whole mention when this handler does, so `loadThreadContext` degrades to no
* context instead.
*/
async function dispatchWithThreadContext(
ctx: SlackContext,
message: SlackMessage,
): Promise<SlackMentionResult> {
await ctx.thread.startTyping("Thinking...")
const context = await loadThreadContext(ctx.thread, message, {
botUserId: botUserIdForTeam(message.teamId),
})
return { auth: defaultSlackAuth(message, ctx), context }
}

export default slackChannel({
credentials: {
webhookVerifier,
Expand All @@ -83,9 +118,10 @@ export default slackChannel({
// SLACK_BOT_TOKEN.
botToken: async (context?: SlackTokenContext) => resolveBotToken(context),
},
// Repeated mentions in a thread only inject what's new since the agent's
// last reply, instead of re-reading the whole thread each time.
threadContext: { since: "last-agent-reply" },
// Thread context is loaded by these two handlers, not by the channel's
// `threadContext` option — see dispatchWithThreadContext above.
onAppMention: dispatchWithThreadContext,
onDirectMessage: dispatchWithThreadContext,
events: {
// Eve's default flashes the raw tool-call label (`maple__list_services
// startTime=...`) into the typing status. Replace it with a plain
Expand Down
6 changes: 6 additions & 0 deletions apps/slack-agent/agent/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ reader's next move.
never mention or describe the reaction in your reply text.
- You are replying inside a Slack thread; stay on topic for that thread, and
when several people are involved, pay attention to who is asking.
- The turn carries the thread so far in `<slack_thread_context>`. Read it
before answering and never ask the user to restate what is already there.
When it opens with a Maple alert notification (`sender_type: bot`, a rule
name with a severity, an observed value, and an incident link), that alert is
the subject: take the rule, window, and incident id straight from it and load
the incident-investigation skill.
- When you don't know something, say so plainly rather than guessing.

### Length calibration
Expand Down
88 changes: 88 additions & 0 deletions apps/slack-agent/agent/lib/bot-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* Per-workspace bot user id, learned from inbound webhook envelopes.
*
* Every `event_callback` Slack delivers names the app's own bot user under
* `authorizations[].user_id`, so the id is free — no `auth.test` round-trip and
* no extra secret. The webhook verifier records it (`rememberBotUserId`) and
* later stages read it back per team (`botUserIdForTeam`).
*
* Why it is needed at all: eve marks a thread message as the agent's own with
* `isMe = bot_id !== undefined`, i.e. *any* bot. In a channel that also hosts a
* CI or GitHub app, that mislabels a stranger's post as something this agent
* said. Thread context (`#lib/thread-context.js`) attributes speakers with this
* id instead.
*
* The cache is process-global and keyed by team id, which is the only
* multi-tenant-safe key here: this app serves every workspace that installed
* the Slack app.
*/

import { createTtlCache } from "./ttl-cache.js"

// A workspace's bot user id is stable for the life of the installation, so the
// TTL only bounds how long an uninstalled workspace lingers in memory.
const BOT_USER_ID_TTL_MS = 24 * 60 * 60_000
const CACHE_MAX_ENTRIES = 500
const CACHE_SWEEP_INTERVAL_MS = 60 * 60_000

interface BotUserIdEntry {
readonly botUserId: string
readonly expiresAt: number
}

const botUserIds = createTtlCache<BotUserIdEntry>({
maxEntries: CACHE_MAX_ENTRIES,
sweepIntervalMs: CACHE_SWEEP_INTERVAL_MS,
})

/** Test-only: clears the cache so each test starts cold. */
export function resetBotUserIdCacheForTests(): void {
botUserIds.clear()
}

/**
* The event envelope's `authorizations` names the app's bot user — no
* `auth.test` round-trip needed. Returns null when the envelope carries none.
*/
export function botUserIdFromEnvelope(envelope: Record<string, unknown>): string | null {
const authorizations = envelope.authorizations
if (!Array.isArray(authorizations)) return null
for (const auth of authorizations) {
if (isRecord(auth) && typeof auth.user_id === "string" && auth.user_id.length > 0) {
return auth.user_id
}
}
return null
}

/**
* Records the bot user id carried by a verified inbound webhook body. Never
* throws — a body we cannot read simply teaches us nothing.
*/
export function rememberBotUserId(rawBody: string): void {
let parsed: unknown
try {
parsed = JSON.parse(rawBody)
} catch {
return
}
if (!isRecord(parsed)) return
const teamId = typeof parsed.team_id === "string" ? parsed.team_id : ""
const botUserId = botUserIdFromEnvelope(parsed)
if (!teamId || !botUserId) return
botUserIds.set(teamId, { botUserId, expiresAt: Date.now() + BOT_USER_ID_TTL_MS })
}

/**
* The bot user id for a workspace, or undefined when no webhook has taught us
* one yet. Callers must degrade gracefully rather than block on it: the id is
* an attribution refinement, never a gate.
*/
export function botUserIdForTeam(teamId: string | undefined): string | undefined {
if (!teamId) return undefined
return botUserIds.get(teamId)?.botUserId
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
Loading
Loading