@stratusagent/channel-slack 0.12.2 → 0.12.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -1
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +384 -19
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { constants } from 'node:fs';
|
|
|
3
3
|
import { lstat, mkdir, open, readdir, readFile, realpath } from 'node:fs/promises';
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { filePathsOf, isLatestTurn, latestTurnReply, MAX_IDEMPOTENCY_KEY_LENGTH, turnFailureFor, turnFilesFor, turnReplyFor, CONVERSATION_METADATA_KEY, SENDER_TRUST_METADATA_KEY, sessionWriteTrust, imageDimensions, isImageAttachmentMediaType, IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENT_MAX_DIMENSION, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES, escapeControlCharacters, leastTrusted, sessionTrustOf, SESSION_TRUST_METADATA_KEY, } from '@stratusagent/core';
|
|
6
|
+
import { filePathsOf, isLatestTurn, latestTurnReply, MAX_IDEMPOTENCY_KEY_LENGTH, turnFailureFor, turnFilesFor, turnReplyFor, CONVERSATION_METADATA_KEY, SENDER_TRUST_METADATA_KEY, sessionWriteTrust, imageDimensions, isImageAttachmentMediaType, IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENT_MAX_DIMENSION, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES, escapeControlCharacters, RunAbortedError, leastTrusted, sessionTrustOf, SESSION_TRUST_METADATA_KEY, } from '@stratusagent/core';
|
|
7
7
|
import { admitsSender, channelSessionKey, senderTrustFor, } from '@stratusagent/channels';
|
|
8
8
|
import { codeRunsOf, toSlackMrkdwn } from "./mrkdwn.js";
|
|
9
9
|
/**
|
|
@@ -44,6 +44,13 @@ const COLD_VERDICT_CAPACITY = 256;
|
|
|
44
44
|
* socket stays behind for all of them.
|
|
45
45
|
*/
|
|
46
46
|
const THREAD_HANDOVER_DEPTH = 32;
|
|
47
|
+
/**
|
|
48
|
+
* How many agent mentions one thread admits in a row before a person has
|
|
49
|
+
* to speak there again (card 513): enough for a question, an answer and a
|
|
50
|
+
* follow-up each way, and a stop to two agents tagging each other forever.
|
|
51
|
+
* Counted per daemon, in memory; a restart starts it again.
|
|
52
|
+
*/
|
|
53
|
+
export const AGENT_MENTION_HOP_LIMIT = 4;
|
|
47
54
|
/**
|
|
48
55
|
* How many files an unrendered turn may queue for its outcome. Nothing
|
|
49
56
|
* drains that queue until the outcome arrives, so a session whose outcome
|
|
@@ -323,6 +330,14 @@ class ReplyRenderer {
|
|
|
323
330
|
* not be this one.
|
|
324
331
|
*/
|
|
325
332
|
turnId = randomUUID();
|
|
333
|
+
/**
|
|
334
|
+
* What a Slack stop aborts (card 337): the signal this renderer's dispatch
|
|
335
|
+
* runs under, so a stop cancels this turn whether it is running or still
|
|
336
|
+
* queued behind another.
|
|
337
|
+
*/
|
|
338
|
+
controller = new AbortController();
|
|
339
|
+
/** The Slack user who stopped this turn, once someone has. */
|
|
340
|
+
stoppedBy;
|
|
326
341
|
buffer = '';
|
|
327
342
|
/**
|
|
328
343
|
* In `final` mode, what the model has said since its last tool call. A
|
|
@@ -1113,6 +1128,18 @@ class ReplyRenderer {
|
|
|
1113
1128
|
this.settle();
|
|
1114
1129
|
}
|
|
1115
1130
|
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Ends a turn an approver stopped from Slack: says who stopped it where
|
|
1133
|
+
* the reply would have gone. A turn nobody asked for that had said
|
|
1134
|
+
* nothing stays silent, as its failure would.
|
|
1135
|
+
*/
|
|
1136
|
+
async stopped(by) {
|
|
1137
|
+
await this.editChain;
|
|
1138
|
+
if (this.lazy && !this.ref && this.buffer.trim().length === 0) {
|
|
1139
|
+
return this.finalize('');
|
|
1140
|
+
}
|
|
1141
|
+
return this.finalize(`Stopped by <@${by}>.`);
|
|
1142
|
+
}
|
|
1116
1143
|
async fail(message) {
|
|
1117
1144
|
// The same wait `finalize` takes, for the same reason: a lazy turn's
|
|
1118
1145
|
// first text may be opening its placeholder right now, and a failure
|
|
@@ -1411,6 +1438,8 @@ const alwaysOutcome = (always, agentName, agentId) => {
|
|
|
1411
1438
|
return 'Allowed, and this site is now acted on without asking — until revoked';
|
|
1412
1439
|
case 'tool':
|
|
1413
1440
|
return `Allowed, and granted to ${escapeSlackText(agentName)} until revoked (stratus grants ${escapeSlackText(agentId)})`;
|
|
1441
|
+
case 'destination':
|
|
1442
|
+
return `Allowed, and granted to ${escapeSlackText(agentName)} for this conversation until revoked (stratus grants ${escapeSlackText(agentId)})`;
|
|
1414
1443
|
case 'session':
|
|
1415
1444
|
return 'Allowed for the rest of this session';
|
|
1416
1445
|
default:
|
|
@@ -1430,6 +1459,8 @@ const alwaysOffer = (always, agentName) => {
|
|
|
1430
1459
|
switch (always) {
|
|
1431
1460
|
case 'tool':
|
|
1432
1461
|
return `*Always allow* grants this tool to ${escapeSlackText(agentName)} until an operator revokes it.`;
|
|
1462
|
+
case 'destination':
|
|
1463
|
+
return `*Always allow* lets ${escapeSlackText(agentName)} use this tool on this conversation without asking, until revoked.`;
|
|
1433
1464
|
case 'session':
|
|
1434
1465
|
return '*Always allow* stops this tool asking again for the rest of this session.';
|
|
1435
1466
|
case 'scope':
|
|
@@ -1458,6 +1489,12 @@ const APPROVAL_SUMMARY_LIMIT = 160;
|
|
|
1458
1489
|
* words, and marked only by how it is rendered.
|
|
1459
1490
|
*/
|
|
1460
1491
|
const HUMAN_MESSAGE_SUBTYPES = new Set(['file_share', 'me_message', 'thread_broadcast']);
|
|
1492
|
+
/**
|
|
1493
|
+
* Message subtypes that are another agent's bot speaking (card 513): a plain
|
|
1494
|
+
* bot post, an upload with a comment, or a thread reply also sent to the
|
|
1495
|
+
* channel. Edits, deletions and joins are never a mention to answer.
|
|
1496
|
+
*/
|
|
1497
|
+
const AGENT_SPEECH_SUBTYPES = new Set(['bot_message', 'file_share', 'thread_broadcast']);
|
|
1461
1498
|
const isPersonSpeaking = (event) => event.subtype === undefined || HUMAN_MESSAGE_SUBTYPES.has(event.subtype);
|
|
1462
1499
|
/** At most this many attachment names are listed; the rest are counted. */
|
|
1463
1500
|
const MAX_LISTED_ATTACHMENTS = 5;
|
|
@@ -1921,6 +1958,20 @@ const textAttachmentBlocks = (texts) => texts.map(({ name, text }) => `\n[Attach
|
|
|
1921
1958
|
* sees.
|
|
1922
1959
|
*/
|
|
1923
1960
|
const mentions = (text, botUserId) => botUserId.length > 0 && text.includes(`<@${botUserId}>`);
|
|
1961
|
+
/**
|
|
1962
|
+
* Whether a message asks this agent to stop (card 337): the word "stop"
|
|
1963
|
+
* and nothing else but mentions and closing punctuation, in any case. A
|
|
1964
|
+
* message that mentions someone and not this agent is for them. Exported
|
|
1965
|
+
* for tests.
|
|
1966
|
+
*/
|
|
1967
|
+
export const isStopRequest = (text, botUserId) => {
|
|
1968
|
+
const mentioned = [...text.matchAll(/<@([^>|]+)(?:\|[^>]*)?>/g)].map((match) => match[1]);
|
|
1969
|
+
if (mentioned.length > 0 && !mentioned.includes(botUserId)) {
|
|
1970
|
+
return false;
|
|
1971
|
+
}
|
|
1972
|
+
const words = text.replace(/<@[^>|]+(?:\|[^>]*)?>/g, ' ').trim().replace(/[.!]+$/, '').trim();
|
|
1973
|
+
return words.toLowerCase() === 'stop';
|
|
1974
|
+
};
|
|
1924
1975
|
/**
|
|
1925
1976
|
* Slack's three markup characters. Tool input is written by a model, so it
|
|
1926
1977
|
* reaches this message as untrusted text: unescaped, `<@U123>` becomes a
|
|
@@ -2124,6 +2175,19 @@ const credentialModal = (post, requestId) => ({
|
|
|
2124
2175
|
*/
|
|
2125
2176
|
/** Uncached author names one `message.read` looks up; the rest keep their id. */
|
|
2126
2177
|
const READ_NAME_LOOKUPS = 10;
|
|
2178
|
+
/**
|
|
2179
|
+
* Orders two Slack message timestamps ("seconds.micros"). Compared as
|
|
2180
|
+
* integers, not floats: a current ts has more digits than a double holds.
|
|
2181
|
+
*/
|
|
2182
|
+
const compareSlackTs = (left, right) => {
|
|
2183
|
+
const [leftSeconds = '0', leftMicros = ''] = left.split('.');
|
|
2184
|
+
const [rightSeconds = '0', rightMicros = ''] = right.split('.');
|
|
2185
|
+
const seconds = Number(leftSeconds) - Number(rightSeconds);
|
|
2186
|
+
if (seconds !== 0) {
|
|
2187
|
+
return Math.sign(seconds);
|
|
2188
|
+
}
|
|
2189
|
+
return Math.sign(Number(leftMicros.padEnd(6, '0').slice(0, 6)) - Number(rightMicros.padEnd(6, '0').slice(0, 6)));
|
|
2190
|
+
};
|
|
2127
2191
|
export const createSlackChannelAdapter = (options) => {
|
|
2128
2192
|
const log = options.log ?? (() => { });
|
|
2129
2193
|
const warn = options.warn ?? (() => { });
|
|
@@ -2161,6 +2225,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2161
2225
|
// Per-session intake chains: the pre-dispatch pipeline (lookups,
|
|
2162
2226
|
// placeholder, dispatch call) runs in Slack receipt order.
|
|
2163
2227
|
const intakeChains = new Map();
|
|
2228
|
+
/**
|
|
2229
|
+
* Stop (card 337), for messages still in intake: how many each session
|
|
2230
|
+
* has between claiming a place on its chain and reaching `renderers`, and
|
|
2231
|
+
* the latest stop to land there. A message compares the stop it claimed
|
|
2232
|
+
* its place under with the one in force when it is about to dispatch, so a
|
|
2233
|
+
* stop also cancels a message whose lookups were still running.
|
|
2234
|
+
*/
|
|
2235
|
+
const intakeInFlight = new Map();
|
|
2236
|
+
const stopsBySession = new Map();
|
|
2164
2237
|
const displayNames = new Map();
|
|
2165
2238
|
// Socket Mode redelivers on missed acks; a slow turn must not run twice.
|
|
2166
2239
|
const seenEvents = new Set();
|
|
@@ -2382,8 +2455,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2382
2455
|
// as an unlisted person. This fleet's own agents are not: they are
|
|
2383
2456
|
// the conversation the mention joins — an authenticated peer whose
|
|
2384
2457
|
// socket failed to start included, since what it said is still its.
|
|
2458
|
+
// Nor is a remote team agent this one answers (card 513): its
|
|
2459
|
+
// mention is admitted, so what it said before the mention is too.
|
|
2385
2460
|
return admitsSender(connection.config, message.user ?? '')
|
|
2386
|
-
|| (message.user !== undefined && [...botIdentities.values()].some((peer) => peer.teamId === connection.teamId && peer.botUserId === message.user))
|
|
2461
|
+
|| (message.user !== undefined && [...botIdentities.values()].some((peer) => peer.teamId === connection.teamId && peer.botUserId === message.user))
|
|
2462
|
+
|| (message.user !== undefined && connection.teamId !== undefined
|
|
2463
|
+
&& answersAgentMention(connection.config.agentId, connection.teamId, message.user));
|
|
2387
2464
|
}
|
|
2388
2465
|
return message.user !== undefined
|
|
2389
2466
|
&& isPersonSpeaking({ type: 'message', ts: message.ts, channel, ...(message.subtype ? { subtype: message.subtype } : {}) })
|
|
@@ -3972,6 +4049,13 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3972
4049
|
* their sessions answer for them after a restart.
|
|
3973
4050
|
*/
|
|
3974
4051
|
const isFleetBot = (team, user) => user !== undefined && [...botIdentities.values()].some((identity) => identity.teamId === team && identity.botUserId === user);
|
|
4052
|
+
/**
|
|
4053
|
+
* Whether a bot message is a team agent asking one of this daemon's
|
|
4054
|
+
* agents something (card 513): a question, not another voice taking the
|
|
4055
|
+
* thread. The agent asked answers it, and the thread stays with whoever
|
|
4056
|
+
* held it. Used live and when a thread is read back after a restart.
|
|
4057
|
+
*/
|
|
4058
|
+
const asksAgentHere = (team, asker, text) => asker !== undefined && [...botIdentities].some(([agentId, identity]) => identity.teamId === team && mentions(text ?? '', identity.botUserId) && answersAgentMention(agentId, team, asker));
|
|
3975
4059
|
/**
|
|
3976
4060
|
* Records a bot this adapter does not serve as the thread's holder from
|
|
3977
4061
|
* the message it posted. An agent on another gateway answers in the same
|
|
@@ -4000,6 +4084,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4000
4084
|
if (speaker === undefined || isFleetBot(team, event.user)) {
|
|
4001
4085
|
return;
|
|
4002
4086
|
}
|
|
4087
|
+
if (asksAgentHere(team, event.user, event.text)) {
|
|
4088
|
+
return;
|
|
4089
|
+
}
|
|
4003
4090
|
const thread = event.thread_ts ?? event.ts;
|
|
4004
4091
|
rememberAddressee(threadKeyFor({ team, conversation: event.channel, thread }), `${FOREIGN_SPEAKER_PREFIX}${speaker}`, event.ts);
|
|
4005
4092
|
};
|
|
@@ -4069,7 +4156,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4069
4156
|
if (bot) {
|
|
4070
4157
|
// A bot's mention hands nothing over live (admit ignores bots),
|
|
4071
4158
|
// so here it is only another app speaking, or a peer's words.
|
|
4072
|
-
if (!isFleetBot(parts.team, message.user)) {
|
|
4159
|
+
if (!isFleetBot(parts.team, message.user) && !asksAgentHere(parts.team, message.user, message.text)) {
|
|
4073
4160
|
foreign = Math.max(foreign, at);
|
|
4074
4161
|
}
|
|
4075
4162
|
continue;
|
|
@@ -4388,11 +4475,178 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4388
4475
|
* case, where by definition no message of this thread has been seen in
|
|
4389
4476
|
* this process.
|
|
4390
4477
|
*/
|
|
4478
|
+
/**
|
|
4479
|
+
* Stop (card 337): cancels every turn this agent is running or has queued
|
|
4480
|
+
* in this conversation, including messages still in intake, and says
|
|
4481
|
+
* nothing was stopped when there was nothing to stop. Synchronous, and
|
|
4482
|
+
* called off the intake chain before any dispatch, so a stop is never
|
|
4483
|
+
* queued behind the turn it stops nor merged into the next one. The
|
|
4484
|
+
* caller has checked that the sender is an approver: a stop throws away
|
|
4485
|
+
* work someone else asked for.
|
|
4486
|
+
*
|
|
4487
|
+
* `fromIntake` is a stop that could only be placed on the intake chain,
|
|
4488
|
+
* once the sessions named this agent (the cold case). Everything ahead of
|
|
4489
|
+
* it on the chain has already been dispatched, and everything still in
|
|
4490
|
+
* intake arrived after it, so it cancels the dispatched turns only.
|
|
4491
|
+
*/
|
|
4492
|
+
const stopTurns = (connection, sessionId, userId, channel, fromIntake = false) => {
|
|
4493
|
+
const running = renderers.get(sessionId) ?? [];
|
|
4494
|
+
const inIntake = fromIntake ? 0 : intakeInFlight.get(sessionId) ?? 0;
|
|
4495
|
+
if (running.length === 0 && inIntake === 0) {
|
|
4496
|
+
return false;
|
|
4497
|
+
}
|
|
4498
|
+
for (const renderer of running) {
|
|
4499
|
+
if (renderer.stoppedBy === undefined) {
|
|
4500
|
+
renderer.stoppedBy = userId;
|
|
4501
|
+
renderer.controller.abort(new RunAbortedError(`Stopped from Slack by ${userId}`));
|
|
4502
|
+
}
|
|
4503
|
+
}
|
|
4504
|
+
if (inIntake > 0) {
|
|
4505
|
+
stopsBySession.set(sessionId, { count: (stopsBySession.get(sessionId)?.count ?? 0) + 1, by: userId });
|
|
4506
|
+
}
|
|
4507
|
+
log(`slack: ${userId} stopped ${running.length + inIntake} turn(s) of ${connection.config.agentId} in ${channel}`);
|
|
4508
|
+
return true;
|
|
4509
|
+
};
|
|
4510
|
+
/**
|
|
4511
|
+
* Agent mentions admitted per thread since a person last spoke there, by
|
|
4512
|
+
* message ts (card 513). A copy of one message reaching a second socket,
|
|
4513
|
+
* or redelivered, is the same hop. Bounded like the addressee map.
|
|
4514
|
+
*/
|
|
4515
|
+
const agentHops = new Map();
|
|
4516
|
+
/**
|
|
4517
|
+
* Which team agent a bot user in `team` is, for the agent configured by
|
|
4518
|
+
* `config` in workspace `agentTeam`, if any. A configured id counts only
|
|
4519
|
+
* in that agent's own workspace: user ids are unique per workspace, so the
|
|
4520
|
+
* same id elsewhere is some other bot.
|
|
4521
|
+
*/
|
|
4522
|
+
const teamAgentFor = (config, agentTeam, team, botUser) => {
|
|
4523
|
+
for (const [agentId, identity] of botIdentities) {
|
|
4524
|
+
if (identity.teamId === team && identity.botUserId === botUser) {
|
|
4525
|
+
return agentId;
|
|
4526
|
+
}
|
|
4527
|
+
}
|
|
4528
|
+
if (agentTeam === undefined || agentTeam !== team) {
|
|
4529
|
+
return undefined;
|
|
4530
|
+
}
|
|
4531
|
+
for (const [name, id] of Object.entries(config.agentMentions?.team ?? {})) {
|
|
4532
|
+
if (id === botUser) {
|
|
4533
|
+
return name;
|
|
4534
|
+
}
|
|
4535
|
+
}
|
|
4536
|
+
return undefined;
|
|
4537
|
+
};
|
|
4538
|
+
/**
|
|
4539
|
+
* Whether `agentId` answers a mention from `botUser` in `team`: it has
|
|
4540
|
+
* agent mentions on, the bot is a team agent to it, and on its list if it
|
|
4541
|
+
* keeps one. The hop limit is not asked here.
|
|
4542
|
+
*/
|
|
4543
|
+
const answersAgentMention = (agentId, team, botUser) => {
|
|
4544
|
+
const config = agentConfigFor(agentId);
|
|
4545
|
+
const settings = config?.agentMentions;
|
|
4546
|
+
if (!config || !settings) {
|
|
4547
|
+
return false;
|
|
4548
|
+
}
|
|
4549
|
+
const name = teamAgentFor(config, botIdentities.get(agentId)?.teamId, team, botUser);
|
|
4550
|
+
return name !== undefined && (settings.only === undefined || settings.only.includes(name));
|
|
4551
|
+
};
|
|
4552
|
+
/**
|
|
4553
|
+
* A message another agent's bot posted, for an agent that answers team
|
|
4554
|
+
* agents (`agentMentions`, card 513). Admitted only when it names this
|
|
4555
|
+
* agent, comes from a team agent this one listens to, and the thread is
|
|
4556
|
+
* under its hop limit; anything else from a bot is dropped, as every
|
|
4557
|
+
* bot message was before. Never part of the holder rule: a bot naming an
|
|
4558
|
+
* agent asks it one thing, and hands it nothing.
|
|
4559
|
+
*/
|
|
4560
|
+
const admitAgentMention = (connection, args) => {
|
|
4561
|
+
const event = args.event;
|
|
4562
|
+
const settings = connection.config.agentMentions;
|
|
4563
|
+
if (!settings || !event || event.user === undefined || event.user === connection.botUserId) {
|
|
4564
|
+
return undefined;
|
|
4565
|
+
}
|
|
4566
|
+
if ((event.type !== 'app_mention' && event.type !== 'message') || event.channel_type === 'im') {
|
|
4567
|
+
return undefined;
|
|
4568
|
+
}
|
|
4569
|
+
if (event.subtype !== undefined && !AGENT_SPEECH_SUBTYPES.has(event.subtype)) {
|
|
4570
|
+
return undefined;
|
|
4571
|
+
}
|
|
4572
|
+
const text = event.text ?? '';
|
|
4573
|
+
if (!mentions(text, connection.botUserId)) {
|
|
4574
|
+
return undefined;
|
|
4575
|
+
}
|
|
4576
|
+
const team = args.body?.team_id ?? connection.teamId;
|
|
4577
|
+
const name = teamAgentFor(connection.config, connection.teamId, team, event.user);
|
|
4578
|
+
if (name === undefined) {
|
|
4579
|
+
return undefined;
|
|
4580
|
+
}
|
|
4581
|
+
const eventKey = `${connection.config.agentId}:${event.channel}:${event.ts}`;
|
|
4582
|
+
if (settings.only !== undefined && !settings.only.includes(name)) {
|
|
4583
|
+
if (!alreadySeen(eventKey)) {
|
|
4584
|
+
log(`slack: ${connection.config.agentId} ignored a mention from agent ${name}: not on its agent mention list`);
|
|
4585
|
+
}
|
|
4586
|
+
return undefined;
|
|
4587
|
+
}
|
|
4588
|
+
const thread = event.thread_ts ?? event.ts;
|
|
4589
|
+
const threadKey = threadKeyFor({ team, conversation: event.channel, thread });
|
|
4590
|
+
const hops = agentHops.get(threadKey) ?? [];
|
|
4591
|
+
if (!hops.includes(event.ts)) {
|
|
4592
|
+
if (hops.length >= AGENT_MENTION_HOP_LIMIT) {
|
|
4593
|
+
if (!alreadySeen(eventKey)) {
|
|
4594
|
+
log(`slack: ${connection.config.agentId} ignored a mention from agent ${name}: the thread reached its hop limit of ${AGENT_MENTION_HOP_LIMIT} agent mentions; a person has to speak there first`);
|
|
4595
|
+
}
|
|
4596
|
+
return undefined;
|
|
4597
|
+
}
|
|
4598
|
+
agentHops.delete(threadKey);
|
|
4599
|
+
agentHops.set(threadKey, [...hops, event.ts]);
|
|
4600
|
+
if (agentHops.size > THREAD_ADDRESSEE_CAPACITY) {
|
|
4601
|
+
const oldest = agentHops.keys().next();
|
|
4602
|
+
if (!oldest.done) {
|
|
4603
|
+
agentHops.delete(oldest.value);
|
|
4604
|
+
}
|
|
4605
|
+
}
|
|
4606
|
+
}
|
|
4607
|
+
if (alreadySeen(eventKey)) {
|
|
4608
|
+
return undefined;
|
|
4609
|
+
}
|
|
4610
|
+
return {
|
|
4611
|
+
event,
|
|
4612
|
+
isDm: false,
|
|
4613
|
+
team,
|
|
4614
|
+
eventKey,
|
|
4615
|
+
userId: event.user,
|
|
4616
|
+
sessionId: channelSessionKey({ channel: 'slack', agentId: connection.config.agentId, team, conversation: event.channel, thread }),
|
|
4617
|
+
thread,
|
|
4618
|
+
threadKey,
|
|
4619
|
+
settled: true,
|
|
4620
|
+
overhear: false,
|
|
4621
|
+
judge: false,
|
|
4622
|
+
fromAgent: name,
|
|
4623
|
+
};
|
|
4624
|
+
};
|
|
4391
4625
|
const admit = (connection, args) => {
|
|
4392
4626
|
const event = args.event;
|
|
4393
4627
|
if (gatewayRef) {
|
|
4394
4628
|
noteForeignSpeaker(connection, args);
|
|
4395
4629
|
}
|
|
4630
|
+
if (gatewayRef && event && (event.bot_id !== undefined || event.subtype === 'bot_message')) {
|
|
4631
|
+
return admitAgentMention(connection, args);
|
|
4632
|
+
}
|
|
4633
|
+
if (event && event.user !== undefined && event.channel_type !== 'im' && isPersonSpeaking(event) && admitsSender(connection.config, event.user)) {
|
|
4634
|
+
// A person this agent admits, speaking in a thread, ends a run of
|
|
4635
|
+
// agent mentions there. Someone it refuses can't restart one. Only
|
|
4636
|
+
// hops before the person's message reset: another agent's socket can
|
|
4637
|
+
// deliver an older human message late, after newer hops.
|
|
4638
|
+
const hopKey = threadKeyFor({ team: args.body?.team_id ?? connection.teamId, conversation: event.channel, thread: event.thread_ts ?? event.ts });
|
|
4639
|
+
const hops = agentHops.get(hopKey);
|
|
4640
|
+
if (hops) {
|
|
4641
|
+
const newer = hops.filter((hop) => compareSlackTs(hop, event.ts) > 0);
|
|
4642
|
+
if (newer.length > 0) {
|
|
4643
|
+
agentHops.set(hopKey, newer);
|
|
4644
|
+
}
|
|
4645
|
+
else {
|
|
4646
|
+
agentHops.delete(hopKey);
|
|
4647
|
+
}
|
|
4648
|
+
}
|
|
4649
|
+
}
|
|
4396
4650
|
if (!gatewayRef || !event || !event.user || event.bot_id || !isPersonSpeaking(event)) {
|
|
4397
4651
|
return undefined;
|
|
4398
4652
|
}
|
|
@@ -4496,6 +4750,27 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4496
4750
|
// Consuming the key here is safe because a refused message records
|
|
4497
4751
|
// nothing a later copy could be needed for.
|
|
4498
4752
|
if (!admitsSender(connection.config, sender)) {
|
|
4753
|
+
// An approver the operator did not list as a principal can still stop
|
|
4754
|
+
// the agent (card 337): approving is its own grant. Only when the
|
|
4755
|
+
// message names this agent (or is a DM to it), and nothing else they
|
|
4756
|
+
// say gets through.
|
|
4757
|
+
if (addressed &&
|
|
4758
|
+
(connection.config.approvers ?? []).includes(sender) &&
|
|
4759
|
+
isStopRequest(text, connection.botUserId) &&
|
|
4760
|
+
!alreadySeen(eventKey)) {
|
|
4761
|
+
const stopSession = channelSessionKey({
|
|
4762
|
+
channel: 'slack',
|
|
4763
|
+
agentId: connection.config.agentId,
|
|
4764
|
+
team,
|
|
4765
|
+
conversation: event.channel,
|
|
4766
|
+
...(thread ? { thread } : {}),
|
|
4767
|
+
});
|
|
4768
|
+
if (stopTurns(connection, stopSession, sender, event.channel)) {
|
|
4769
|
+
return undefined;
|
|
4770
|
+
}
|
|
4771
|
+
log(`slack: ${connection.config.agentId} refused a message from ${sender}: not a listed principal, and admit is "principals"`);
|
|
4772
|
+
return undefined;
|
|
4773
|
+
}
|
|
4499
4774
|
const refusal = `slack: ${connection.config.agentId} refused a message from ${sender}: not a listed principal, and admit is "principals"`;
|
|
4500
4775
|
const held = threadKey !== undefined ? holderAt(threadKey, event.ts) : undefined;
|
|
4501
4776
|
if (addressed || held === connection.config.agentId) {
|
|
@@ -4592,11 +4867,25 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4592
4867
|
// "whose is an untagged reply", and for these two agents the soul has
|
|
4593
4868
|
// already answered.
|
|
4594
4869
|
const listens = listensOf(connection.config.agentId);
|
|
4595
|
-
if (listens === 'mentions') {
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
|
|
4870
|
+
if (listens === 'mentions' || listens === 'judge') {
|
|
4871
|
+
// Except a stop (card 337): the holder rule decides whose it is, as
|
|
4872
|
+
// for any agent, and it only acts if there is something to stop. A
|
|
4873
|
+
// thread the record has forgotten is left to the sessions, as below,
|
|
4874
|
+
// so a stop for a turn that outlived its record still reaches it.
|
|
4875
|
+
const approverStop = threadKey !== undefined &&
|
|
4876
|
+
(connection.config.approvers ?? []).includes(sender) &&
|
|
4877
|
+
isStopRequest(text, connection.botUserId);
|
|
4878
|
+
const stopHolder = approverStop ? holderAt(threadKey, event.ts) : undefined;
|
|
4879
|
+
// A stop held by another agent is only heard: judging it would let an
|
|
4880
|
+
// attentive agent take a turn on a stop that was never its own.
|
|
4881
|
+
const stopForAnother = stopHolder !== undefined && stopHolder !== connection.config.agentId;
|
|
4882
|
+
return {
|
|
4883
|
+
...admission,
|
|
4884
|
+
overhear: true,
|
|
4885
|
+
...(listens === 'judge' && !stopForAnother ? { judge: true } : {}),
|
|
4886
|
+
...(stopHolder === connection.config.agentId ? { holderStop: true } : {}),
|
|
4887
|
+
...(approverStop && stopHolder === undefined ? { settled: false } : {}),
|
|
4888
|
+
};
|
|
4600
4889
|
}
|
|
4601
4890
|
// The record is what this process has actually seen, in the order it
|
|
4602
4891
|
// saw it — right even inside the window before a handover reaches the
|
|
@@ -4652,6 +4941,19 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4652
4941
|
return;
|
|
4653
4942
|
}
|
|
4654
4943
|
const { event, isDm, team, userId, thread, sessionId, threadKey, eventKey } = admitted;
|
|
4944
|
+
// Stop (card 337), see `stopTurns`. Only a message addressed to this
|
|
4945
|
+
// agent: an untagged reply this agent merely overhears is the holder's
|
|
4946
|
+
// (unless this agent is the holder and only `listens` made it an
|
|
4947
|
+
// overhear, see `holderStop`), and one only the sessions can place is
|
|
4948
|
+
// not this agent's to act on yet.
|
|
4949
|
+
if ((!admitted.overhear || admitted.holderStop === true) &&
|
|
4950
|
+
admitted.settled &&
|
|
4951
|
+
admitted.fromAgent === undefined &&
|
|
4952
|
+
(connection.config.approvers ?? []).includes(userId) &&
|
|
4953
|
+
isStopRequest(event.text ?? '', connection.botUserId) &&
|
|
4954
|
+
stopTurns(connection, sessionId, userId, event.channel)) {
|
|
4955
|
+
return;
|
|
4956
|
+
}
|
|
4655
4957
|
// What the gateway knows this message by (see `dispatchKeyFor`).
|
|
4656
4958
|
const dispatchKey = dispatchKeyFor(eventKey);
|
|
4657
4959
|
// Everything up to (and including) the dispatch call is serialized per
|
|
@@ -4661,6 +4963,30 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4661
4963
|
// reverse order in durable history. The chain is claimed here,
|
|
4662
4964
|
// synchronously, so a message's place in it is fixed by when Slack
|
|
4663
4965
|
// delivered it and not by how long anything it does takes.
|
|
4966
|
+
const stopsAtClaim = stopsBySession.get(sessionId)?.count ?? 0;
|
|
4967
|
+
// Only intake that can still become a turn is something a stop can
|
|
4968
|
+
// cancel. A settled overhear that is not judged only ever reaches
|
|
4969
|
+
// `gateway.observe`, so counting it would let a stop with nothing to
|
|
4970
|
+
// stop be consumed instead of passing on as an ordinary message.
|
|
4971
|
+
const mayRun = !admitted.overhear || admitted.judge || !admitted.settled;
|
|
4972
|
+
if (mayRun) {
|
|
4973
|
+
intakeInFlight.set(sessionId, (intakeInFlight.get(sessionId) ?? 0) + 1);
|
|
4974
|
+
}
|
|
4975
|
+
let leftIntake = !mayRun;
|
|
4976
|
+
const leaveIntake = () => {
|
|
4977
|
+
if (leftIntake) {
|
|
4978
|
+
return;
|
|
4979
|
+
}
|
|
4980
|
+
leftIntake = true;
|
|
4981
|
+
const left = (intakeInFlight.get(sessionId) ?? 1) - 1;
|
|
4982
|
+
if (left > 0) {
|
|
4983
|
+
intakeInFlight.set(sessionId, left);
|
|
4984
|
+
}
|
|
4985
|
+
else {
|
|
4986
|
+
intakeInFlight.delete(sessionId);
|
|
4987
|
+
stopsBySession.delete(sessionId);
|
|
4988
|
+
}
|
|
4989
|
+
};
|
|
4664
4990
|
const intake = onIntakeChain(sessionId, async () => {
|
|
4665
4991
|
// The durable half of addressing runs INSIDE the chain, for the same
|
|
4666
4992
|
// reason: it awaits, and a message that had to ask the sessions must
|
|
@@ -4703,13 +5029,26 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4703
5029
|
}, event.ts);
|
|
4704
5030
|
if (winner !== connection.config.agentId) {
|
|
4705
5031
|
// Not this agent's to answer — and, if it is in the thread,
|
|
4706
|
-
// this agent's to hear, same as the two warm stand-downs.
|
|
5032
|
+
// this agent's to hear, same as the two warm stand-downs. Only a
|
|
5033
|
+
// stop sends a judging agent here, and a stop the sessions place
|
|
5034
|
+
// with another agent is heard, never judged, as it is warm.
|
|
5035
|
+
judged = false;
|
|
4707
5036
|
overhear = true;
|
|
4708
5037
|
}
|
|
4709
|
-
else
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
5038
|
+
else {
|
|
5039
|
+
if (threadKey) {
|
|
5040
|
+
// Answered from the store rather than from memory: hold it, so
|
|
5041
|
+
// the rest of the thread is ordered by receipt like any other.
|
|
5042
|
+
rememberAddressee(threadKey, connection.config.agentId, event.ts);
|
|
5043
|
+
}
|
|
5044
|
+
// Stop (card 337) for a message only the sessions could place:
|
|
5045
|
+
// now that they say it is this agent's, it is a stop like the
|
|
5046
|
+
// warm one above, not a message to queue behind the turn it stops.
|
|
5047
|
+
if ((connection.config.approvers ?? []).includes(userId) &&
|
|
5048
|
+
isStopRequest(event.text ?? '', connection.botUserId) &&
|
|
5049
|
+
stopTurns(connection, sessionId, userId, event.channel, true)) {
|
|
5050
|
+
return undefined;
|
|
5051
|
+
}
|
|
4713
5052
|
}
|
|
4714
5053
|
}
|
|
4715
5054
|
const cleaned = await humanizeMentions(connection, event.text ?? '');
|
|
@@ -4737,7 +5076,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4737
5076
|
const { texts, saved, unread } = overhear || judged
|
|
4738
5077
|
? { texts: [], saved: [], unread: notImages }
|
|
4739
5078
|
: await readTextAttachments(notImages, connection.config.botToken, fetchFile, fileDownloadTimeoutMs, deadline, warn, reasons, options.attachmentStore
|
|
4740
|
-
? createAttachmentSaver(options.attachmentStore, connection.config.agentId, senderTrustFor(connection.config, userId), options.now ?? Date.now)
|
|
5079
|
+
? createAttachmentSaver(options.attachmentStore, connection.config.agentId, admitted.fromAgent !== undefined ? 'unknown' : senderTrustFor(connection.config, userId), options.now ?? Date.now)
|
|
4741
5080
|
: undefined);
|
|
4742
5081
|
// An image or a document with nothing said is still a question
|
|
4743
5082
|
// ("what's this?"); a file the turn could not open with nothing said
|
|
@@ -4745,7 +5084,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4745
5084
|
if (cleaned.length === 0 && images.length === 0 && texts.length === 0 && saved.length === 0) {
|
|
4746
5085
|
return undefined;
|
|
4747
5086
|
}
|
|
4748
|
-
|
|
5087
|
+
// A team agent goes by the name the operator gave it, not a profile
|
|
5088
|
+
// its own app chose.
|
|
5089
|
+
// A team agent's daemon hears only what tags its bot (card 513), so
|
|
5090
|
+
// the model gets that tag and decides whether its answer needs it
|
|
5091
|
+
// (Dylan, 2026-10-15: the model tags back, the adapter never does).
|
|
5092
|
+
const author = admitted.fromAgent !== undefined
|
|
5093
|
+
? `${admitted.fromAgent} (an agent; it sees your answer only if you tag <@${userId}>)`
|
|
5094
|
+
: await authorFor(connection, userId);
|
|
4749
5095
|
// In shared channels the model should know who is speaking; a DM is
|
|
4750
5096
|
// unambiguous. A bare image in a channel still says who sent it.
|
|
4751
5097
|
const spoken = isDm ? cleaned : (cleaned.length > 0 ? `${author}: ${cleaned}` : `${author}:`);
|
|
@@ -4756,7 +5102,10 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4756
5102
|
// `unknown` exactly as a channel mention would be. The same label
|
|
4757
5103
|
// whether the agent answers or only hears: the text is in its
|
|
4758
5104
|
// transcript either way.
|
|
4759
|
-
|
|
5105
|
+
// A team agent is below a principal whatever the list says: its
|
|
5106
|
+
// words carry whatever its own session was exposed to, which nothing
|
|
5107
|
+
// here can see (card 513).
|
|
5108
|
+
const senderTrust = admitted.fromAgent !== undefined ? 'unknown' : senderTrustFor(connection.config, userId);
|
|
4760
5109
|
// Who can read this, so the agent writes for them, and a thread says
|
|
4761
5110
|
// it is one. A DM names the other person only when they are one of
|
|
4762
5111
|
// the operator's principals: this lands in the system prompt, and
|
|
@@ -4889,6 +5238,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4889
5238
|
const queue = renderers.get(sessionId) ?? [];
|
|
4890
5239
|
queue.push(renderer);
|
|
4891
5240
|
renderers.set(sessionId, queue);
|
|
5241
|
+
// Stopped while its lookups ran: dispatched under an aborted signal,
|
|
5242
|
+
// so it ends the way a queued turn a stop reached does.
|
|
5243
|
+
const stopped = stopsBySession.get(sessionId);
|
|
5244
|
+
leaveIntake();
|
|
5245
|
+
if (stopped !== undefined && stopped.count !== stopsAtClaim && renderer.stoppedBy === undefined) {
|
|
5246
|
+
renderer.stoppedBy = stopped.by;
|
|
5247
|
+
renderer.controller.abort(new RunAbortedError(`Stopped from Slack by ${stopped.by}`));
|
|
5248
|
+
}
|
|
4892
5249
|
// In a DM each message is its own status key, and Slack stops showing
|
|
4893
5250
|
// the running turn's once a newer message sits below it — so a
|
|
4894
5251
|
// message queued there that stayed dark read as the agent having
|
|
@@ -4906,6 +5263,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4906
5263
|
...(earlier.length > 0 ? { earlier } : {}),
|
|
4907
5264
|
turnId: renderer.turnId,
|
|
4908
5265
|
metadata,
|
|
5266
|
+
signal: renderer.controller.signal,
|
|
4909
5267
|
// A redelivery this process no longer remembers (it restarted, or
|
|
4910
5268
|
// the dedupe evicted it) is still the message it was: the gateway
|
|
4911
5269
|
// runs no second turn for it, and says so through `onRepeat`.
|
|
@@ -4922,6 +5280,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4922
5280
|
});
|
|
4923
5281
|
return { renderer, turn, repeat: () => repeat, absorbed: () => absorbed };
|
|
4924
5282
|
});
|
|
5283
|
+
// Whatever way the intake ended, it is no longer in flight.
|
|
5284
|
+
void intake.then(leaveIntake, leaveIntake);
|
|
4925
5285
|
const started = await intake;
|
|
4926
5286
|
if (!started) {
|
|
4927
5287
|
return;
|
|
@@ -5071,9 +5431,11 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5071
5431
|
// not a failure of the agent. Matched by name, as this package does
|
|
5072
5432
|
// not import the gateway. A turn nobody asked for stays silent.
|
|
5073
5433
|
const pausedReply = failure instanceof Error && failure.name === 'IntakePausedError';
|
|
5074
|
-
const { spoke, spokeAt } =
|
|
5075
|
-
? await renderer.
|
|
5076
|
-
:
|
|
5434
|
+
const { spoke, spokeAt } = renderer.stoppedBy !== undefined
|
|
5435
|
+
? await renderer.stopped(renderer.stoppedBy)
|
|
5436
|
+
: pausedReply
|
|
5437
|
+
? await renderer.finalize(renderer.lazy ? '' : failure.message)
|
|
5438
|
+
: await renderer.fail(failure instanceof Error ? failure.message : String(failure));
|
|
5077
5439
|
if (spoke && threadKey !== undefined) {
|
|
5078
5440
|
// A file it posted before breaking is still the last thing said.
|
|
5079
5441
|
rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
|
|
@@ -5085,6 +5447,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5085
5447
|
name: 'slack',
|
|
5086
5448
|
resolveOutbound,
|
|
5087
5449
|
readConversation,
|
|
5450
|
+
// A channel id is unique only within its workspace, so a standing
|
|
5451
|
+
// grant on one is bound to the team the agent's app authenticated in.
|
|
5452
|
+
accountFor: (agentId) => botIdentities.get(agentId)?.teamId || undefined,
|
|
5088
5453
|
requestCredential,
|
|
5089
5454
|
async start(gateway) {
|
|
5090
5455
|
gatewayRef = gateway;
|