Repository navigation
Conversation
Signed-off-by: Ben Pankow <ben.pankow@vercel.com>
| from: m.isMe ? "agent" : (m.user ?? "other-bot"), | ||
| text: m.markdown, |
| await ctx.thread.refresh(); | ||
| const thread = ctx.thread.recentMessages.slice(-10).map((m) => ({ | ||
| from: m.isMe ? "agent" : (m.user ?? "other-bot"), | ||
| text: m.markdown, | ||
| })); |
There was a problem hiding this comment.
This window isn't the last ten messages before the new one, which is what the prose on L284 promises.
refresh()loads the thread's first page of 50 replies, oldest first (THREAD_REFRESH_LIMIT,refreshMessages). Once a thread passes 50 messages,slice(-10)returns messages 41 to 50 forever, so the decider judges the new message against stale context. Long, busy threads are exactly where ambient listening runs.- In shorter threads the page already includes the triggering message, so
threadends with the same message thatlatestrepeats.loadThreadContextMessagesstrips it for this reason (thread.ts#L52-L58).
The minimal fix is to filter out the current message and state the 50-message cap in the prose:
const thread = ctx.thread.recentMessages
.filter((m) => m.ts !== message.ts)
.slice(-10)
.map((m) => ({ from: m.isMe ? "agent" : (m.user ?? "other-bot"), text: m.markdown }));If long threads need to work, the real fix is a public recent-window helper. fetchSlackThreadWindow already does this internally, but it isn't exported.
| try { | ||
| const { answers } = await decide({ | ||
| state: { thread, latest: { from: message.author.userId, text: message.markdown } }, | ||
| questions: { | ||
| reply: { | ||
| type: "boolean", | ||
| instructions: "Should the agent reply to the latest message in this Slack thread?", | ||
| criteria: { | ||
| true: "It asks the agent something, follows up on the agent's last reply, or asks it to do work.", | ||
| false: | ||
| "It is addressed to other people, is banter or thanks, or needs nothing from the agent.", | ||
| }, | ||
| }, | ||
| }, | ||
| }); | ||
| return answers.reply.probability >= 0.7 ? { auth } : null; | ||
| } catch { | ||
| // Stay quiet when the decider is unavailable; a mention still works. | ||
| return null; | ||
| } |
There was a problem hiding this comment.
You can drop this try/catch. eve already catches a thrown onMessage error, logs channel_message handler failed, and drops the message (slackChannel.ts#L1709-L1720), so a decider failure already stays quiet. The bare catch {} does the same thing but loses that log, so an outage of the decision model would look like an agent that has stopped replying. Without it, the snippet loses a level of nesting and five lines. If you keep the sentence about falling back to a mention, move it to the prose.
| // Mentions and DMs always get a reply. | ||
| if (ctx.isBotMentioned() || (await ctx.isDMOrPrivateChannel())) return { auth }; |
There was a problem hiding this comment.
The comment says "Mentions and DMs", but isDMOrPrivateChannel() is also true for private channels and group DMs (privacy.ts#L9-L12). So in those conversations, every message skips decide and gets a reply, including thread replies between humans. That is the talk-over case this section exists to prevent. If only 1:1 DMs should bypass the decider, check that conversation type specifically, for example message.raw.channel_type === "im" (mentions are already covered by isBotMentioned()). If the bypass is intentional, update the comment and mention it in the prose.
| }); | ||
| ``` | ||
|
|
||
| The decider sees only the state you pass, here the last ten thread messages and the new one, so trim what you send to what the decision needs. Raise the threshold to make the agent quieter. A message the hook drops never reaches the agent, and returning `{ auth }` starts or steers a turn like any other accepted message. This needs the same `message.channels` event and `channels:history` scope as [continuing conversations without repeated mentions](#continue-conversations-without-repeated-mentions). |
There was a problem hiding this comment.
"A message the hook drops never reaches the agent" isn't true for this snippet. L242 sets threadContext: { since: "last-agent-reply" }, so the next accepted message injects every message since the agent's last reply as context, including the ones the hook dropped. That is probably why threadContext is in the example, but the prose never explains it, so it reads like an unrelated setting.
Suggest something like: "A dropped message starts no turn. Because threadContext loads replies since the agent's last message, the agent still sees the skipped conversation when it next replies." Also update "the last ten thread messages and the new one" to match the window fix on L254 to L258.
Documents how a Slack agent can listen in a thread without a mention on every message, using
decidefromeve/aiinsideonMessage.Why
The Slack docs show two policies for un-mentioned thread replies: answer all of them (
isSubscribed()), or route by participants. Neither covers the common case: keep listening in a thread the agent has joined, but reply only when a message is actually meant for it. That works today withdecidein a message hook, but nothing in the docs shows it, so people assume it isn't supported.Approach
Adds a "Decide when to reply in a thread" section to
docs/channels/slack.mdx, after the existing mention/subscription examples. The example:decide(defaults totypesafe-ai/jev) as one boolean question, and accepts above a 0.7 probabilityAlso adds a one-line cross-link from
docs/guides/decide.md, where it already saysdecideworks outside a tool.Validation
packages/eve/srcpubliceve/aiandeve/channels/slacktypes (no errors; a deliberate bad property access in the same file errored as expected, confirming the types resolved).oxfmtapplied to both files.node scripts/check-docs.mjs: ok. The snippet and MDX checks could not run locally because the full workspace install was incomplete; leaving those to CI.Signed-off-by: Ben Pankow ben.pankow@vercel.com