@stratusagent/channel-slack 0.12.3 → 0.12.5
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 +48 -29
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +818 -100
- 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();
|
|
@@ -2210,6 +2283,16 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2210
2283
|
* corrects it.
|
|
2211
2284
|
*/
|
|
2212
2285
|
const threadAddressee = new Map();
|
|
2286
|
+
/**
|
|
2287
|
+
* Threads whose owner this process can name (card 508): `true` for one
|
|
2288
|
+
* whose first message it saw, so the first claim in `threadAddressee` is
|
|
2289
|
+
* the owner, or the owner itself when the thread read named it after a
|
|
2290
|
+
* restart. Absent for a thread that started before this process did:
|
|
2291
|
+
* its first claim here is only the first one SEEN, and an untagged reply
|
|
2292
|
+
* in it asks the sessions and the thread (see `ownerAt`). Bounded like
|
|
2293
|
+
* the addressee map; a lost entry costs a lookup.
|
|
2294
|
+
*/
|
|
2295
|
+
const threadOrigins = new Map();
|
|
2213
2296
|
/**
|
|
2214
2297
|
* One cold verdict per message — see `followUpWinner`. Bounded, because
|
|
2215
2298
|
* an entry outlives its own resolution on purpose; an entry that has not
|
|
@@ -2382,8 +2465,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2382
2465
|
// as an unlisted person. This fleet's own agents are not: they are
|
|
2383
2466
|
// the conversation the mention joins — an authenticated peer whose
|
|
2384
2467
|
// socket failed to start included, since what it said is still its.
|
|
2468
|
+
// Nor is a remote team agent this one answers (card 513): its
|
|
2469
|
+
// mention is admitted, so what it said before the mention is too.
|
|
2385
2470
|
return admitsSender(connection.config, message.user ?? '')
|
|
2386
|
-
|| (message.user !== undefined && [...botIdentities.values()].some((peer) => peer.teamId === connection.teamId && peer.botUserId === message.user))
|
|
2471
|
+
|| (message.user !== undefined && [...botIdentities.values()].some((peer) => peer.teamId === connection.teamId && peer.botUserId === message.user))
|
|
2472
|
+
|| (message.user !== undefined && connection.teamId !== undefined
|
|
2473
|
+
&& answersAgentMention(connection.config.agentId, connection.teamId, message.user));
|
|
2387
2474
|
}
|
|
2388
2475
|
return message.user !== undefined
|
|
2389
2476
|
&& isPersonSpeaking({ type: 'message', ts: message.ts, channel, ...(message.subtype ? { subtype: message.subtype } : {}) })
|
|
@@ -2919,6 +3006,73 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2919
3006
|
}
|
|
2920
3007
|
}));
|
|
2921
3008
|
};
|
|
3009
|
+
/**
|
|
3010
|
+
* An agent's own post into a thread, heard in its own session for that
|
|
3011
|
+
* thread (card 364). A scheduled report or a `message.send` reply is a
|
|
3012
|
+
* bot message, which `admit` drops on purpose, and backfill reads a
|
|
3013
|
+
* thread only when a session for it is first made, so without this an
|
|
3014
|
+
* agent already in the thread never knew it had posted there: a reply
|
|
3015
|
+
* under its report reached a session with the question and not the
|
|
3016
|
+
* report. Through `observe`, so it starts no turn and lands only in a
|
|
3017
|
+
* session that already exists; a thread the agent was never in is read
|
|
3018
|
+
* by backfill when somebody first replies there.
|
|
3019
|
+
*
|
|
3020
|
+
* Labelled as the agent's own post, at the label the SENDING session's
|
|
3021
|
+
* words carry (`speaker.trust`, `sessionWriteTrust` of that session):
|
|
3022
|
+
* a report written by a firing that read a web page carries what that
|
|
3023
|
+
* page said, so it is heard at `external`, and one written from `user`
|
|
3024
|
+
* and `agent` context is heard at `agent`. Never higher, so hearing its
|
|
3025
|
+
* own report cannot launder anything into the thread. A post nobody
|
|
3026
|
+
* vouches for (no speaker) is not heard at all, rather than guessed at.
|
|
3027
|
+
*
|
|
3028
|
+
* Not when the sender IS that thread's session: that turn already holds
|
|
3029
|
+
* the call and what it said. Claimed on the intake chain BEFORE the
|
|
3030
|
+
* first chunk goes to Slack, so a reply typed under the report, even
|
|
3031
|
+
* while a long one is still being sent, is heard after it. `posted`
|
|
3032
|
+
* settles with what Slack accepted: all of it, the chunks before one it
|
|
3033
|
+
* refused (they are in the thread, so they are heard), or nothing. Not
|
|
3034
|
+
* awaited by `post`, only by the shutdown drain, so a long turn running
|
|
3035
|
+
* in the thread never holds up the sender, and two threads posting into
|
|
3036
|
+
* each other cannot wait on each other.
|
|
3037
|
+
*/
|
|
3038
|
+
const hearOwnPost = (connection, channel, thread, posted, speaker) => {
|
|
3039
|
+
const gateway = gatewayRef;
|
|
3040
|
+
if (!gateway?.observe) {
|
|
3041
|
+
return;
|
|
3042
|
+
}
|
|
3043
|
+
const agentId = connection.config.agentId;
|
|
3044
|
+
const team = connection.teamId;
|
|
3045
|
+
const sessionId = channelSessionKey({ channel: 'slack', agentId, team, conversation: channel, thread });
|
|
3046
|
+
if (sessionId === speaker.sessionId) {
|
|
3047
|
+
return;
|
|
3048
|
+
}
|
|
3049
|
+
const metadata = {
|
|
3050
|
+
channel: 'slack',
|
|
3051
|
+
team,
|
|
3052
|
+
slackChannel: channel,
|
|
3053
|
+
slackUser: connection.botUserId,
|
|
3054
|
+
slackThread: thread,
|
|
3055
|
+
[SENDER_TRUST_METADATA_KEY]: leastTrusted('agent', speaker.trust),
|
|
3056
|
+
};
|
|
3057
|
+
const heard = onIntakeChain(sessionId, async () => {
|
|
3058
|
+
const text = await posted;
|
|
3059
|
+
if (text === undefined) {
|
|
3060
|
+
return { observed: undefined };
|
|
3061
|
+
}
|
|
3062
|
+
const author = await displayNameFor(connection, connection.botUserId);
|
|
3063
|
+
const observed = gateway.observe?.({ sessionId, agentId, message: `${author} (your own post, sent from another session): ${text}`, metadata });
|
|
3064
|
+
return { observed: observed === undefined ? undefined : placeObserve(sessionId, observed) };
|
|
3065
|
+
});
|
|
3066
|
+
// Tracked, so stop() drains it: a restart right after a report must not
|
|
3067
|
+
// close the stores while the thread's session is still to hear it.
|
|
3068
|
+
track(heard
|
|
3069
|
+
.then(async ({ observed }) => {
|
|
3070
|
+
await observed;
|
|
3071
|
+
})
|
|
3072
|
+
.catch((error) => {
|
|
3073
|
+
warn(`slack: ${agentId} could not hear its own post in ${channel} thread ${thread}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3074
|
+
}));
|
|
3075
|
+
};
|
|
2922
3076
|
/**
|
|
2923
3077
|
* The addressable outbound seam — the channel contract's first real
|
|
2924
3078
|
* `OutboundConnection` implementation. Validate-then-hand-over: every
|
|
@@ -2964,16 +3118,31 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2964
3118
|
// A reply under a message rather than a post beside it. Every chunk of
|
|
2965
3119
|
// a long one goes to the same thread, so a split reply stays together.
|
|
2966
3120
|
const thread = address.thread?.trim() || undefined;
|
|
3121
|
+
const speaker = address.speaker;
|
|
2967
3122
|
return {
|
|
2968
3123
|
async post(text) {
|
|
2969
3124
|
const chunks = messageChunks(text.trim().length > 0 ? text : '(empty message)');
|
|
3125
|
+
// Heard in the thread's session (card 364): its place is claimed
|
|
3126
|
+
// now, before Slack shows anything, and filled once we know what
|
|
3127
|
+
// Slack accepted.
|
|
3128
|
+
let accepted = () => { };
|
|
3129
|
+
if (thread && speaker && text.trim().length > 0) {
|
|
3130
|
+
hearOwnPost(connection, channelId, thread, new Promise((resolve) => { accepted = resolve; }), speaker);
|
|
3131
|
+
}
|
|
2970
3132
|
let first;
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
3133
|
+
let sent = 0;
|
|
3134
|
+
try {
|
|
3135
|
+
for (const chunk of chunks) {
|
|
3136
|
+
const posted = await web.chat.postMessage({ channel: channelId, text: chunk, ...(thread ? { thread_ts: thread } : {}) });
|
|
3137
|
+
sent += 1;
|
|
3138
|
+
if (!first) {
|
|
3139
|
+
first = { channel: posted.channel ?? channelId, ts: posted.ts ?? '' };
|
|
3140
|
+
}
|
|
2975
3141
|
}
|
|
2976
3142
|
}
|
|
3143
|
+
finally {
|
|
3144
|
+
accepted(sent === chunks.length ? text : sent > 0 ? chunks.slice(0, sent).join('\n') : undefined);
|
|
3145
|
+
}
|
|
2977
3146
|
return first ?? { channel: channelId, ts: '' };
|
|
2978
3147
|
},
|
|
2979
3148
|
async edit(ref, text) {
|
|
@@ -3900,6 +4069,34 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3900
4069
|
};
|
|
3901
4070
|
/** One thread, whichever agent is talking in it. */
|
|
3902
4071
|
const threadKeyFor = (parts) => `${parts.team}:${parts.conversation}:${parts.thread}`;
|
|
4072
|
+
/**
|
|
4073
|
+
* A thread's claims cut to `THREAD_HANDOVER_DEPTH`, oldest first out, but
|
|
4074
|
+
* never the two `ownerAt` reads past the rest, so at most one over: the
|
|
4075
|
+
* first claim, which is who the thread belongs to, and the latest claim
|
|
4076
|
+
* that took the thread.
|
|
4077
|
+
* Dropping that one would hand the thread back to its owner after enough
|
|
4078
|
+
* mentions of a third agent, while the judge that took it still answers.
|
|
4079
|
+
*/
|
|
4080
|
+
const trimHandovers = (handovers) => {
|
|
4081
|
+
if (handovers.length <= THREAD_HANDOVER_DEPTH) {
|
|
4082
|
+
return handovers;
|
|
4083
|
+
}
|
|
4084
|
+
const first = handovers[0];
|
|
4085
|
+
let latestTake = -1;
|
|
4086
|
+
for (let index = handovers.length - 1; index > 0; index -= 1) {
|
|
4087
|
+
if (handovers[index]?.takes === true) {
|
|
4088
|
+
latestTake = index;
|
|
4089
|
+
break;
|
|
4090
|
+
}
|
|
4091
|
+
}
|
|
4092
|
+
const tailFrom = handovers.length - (THREAD_HANDOVER_DEPTH - 1);
|
|
4093
|
+
// The kept take sits on top of the full recent tail, one past the depth,
|
|
4094
|
+
// so `holderAt` still sees every recent claim it saw before.
|
|
4095
|
+
const tail = handovers.slice(tailFrom);
|
|
4096
|
+
return latestTake > 0 && latestTake < tailFrom
|
|
4097
|
+
? [first, handovers[latestTake], ...tail]
|
|
4098
|
+
: [first, ...tail];
|
|
4099
|
+
};
|
|
3903
4100
|
/**
|
|
3904
4101
|
* Records that `agentId` holds the thread from the message at `ts`
|
|
3905
4102
|
* onwards, wherever in the thread's history that message falls.
|
|
@@ -3912,7 +4109,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3912
4109
|
* later insertion would take the thread from a reply that sits below it.
|
|
3913
4110
|
* Only the same claim twice — the same message reaching a second
|
|
3914
4111
|
* connection — is dropped.
|
|
3915
|
-
* The oldest claims are dropped past `THREAD_HANDOVER_DEPTH
|
|
4112
|
+
* The oldest claims are dropped past `THREAD_HANDOVER_DEPTH` (but not
|
|
4113
|
+
* the first or the latest that took the thread: `trimHandovers`), and the
|
|
3916
4114
|
* least recently written thread past `THREAD_ADDRESSEE_CAPACITY`; both
|
|
3917
4115
|
* losses cost a lookup, never an answer, since the sessions still hold
|
|
3918
4116
|
* the durable version.
|
|
@@ -3926,7 +4124,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3926
4124
|
* Slack timestamps compare as strings: `seconds.microseconds`, both
|
|
3927
4125
|
* parts fixed width, so lexicographic order is chronological order.
|
|
3928
4126
|
*/
|
|
3929
|
-
const rememberAddressee = (key, agentId, ts) => {
|
|
4127
|
+
const rememberAddressee = (key, agentId, ts, takes = false) => {
|
|
3930
4128
|
const handovers = threadAddressee.get(key) ?? [];
|
|
3931
4129
|
let at = handovers.length;
|
|
3932
4130
|
while (at > 0 && handovers[at - 1].ts > ts) {
|
|
@@ -3934,17 +4132,24 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3934
4132
|
}
|
|
3935
4133
|
const before = handovers[at - 1];
|
|
3936
4134
|
if (before && before.agentId === agentId && before.ts === ts) {
|
|
4135
|
+
if (takes) {
|
|
4136
|
+
before.takes = true;
|
|
4137
|
+
}
|
|
3937
4138
|
return;
|
|
3938
4139
|
}
|
|
3939
|
-
handovers.splice(at, 0, { agentId, ts });
|
|
4140
|
+
handovers.splice(at, 0, takes ? { agentId, ts, takes } : { agentId, ts });
|
|
3940
4141
|
// Re-inserted rather than mutated in place: a Map iterates in insertion
|
|
3941
4142
|
// order, which is what makes the first key the one to evict.
|
|
3942
4143
|
threadAddressee.delete(key);
|
|
3943
|
-
threadAddressee.set(key, handovers
|
|
4144
|
+
threadAddressee.set(key, trimHandovers(handovers));
|
|
3944
4145
|
if (threadAddressee.size > THREAD_ADDRESSEE_CAPACITY) {
|
|
3945
4146
|
const oldest = threadAddressee.keys().next();
|
|
3946
4147
|
if (!oldest.done) {
|
|
3947
4148
|
threadAddressee.delete(oldest.value);
|
|
4149
|
+
// The owner goes with the record of who took the thread since: an
|
|
4150
|
+
// owner kept alone would stand in for a judge's takeover it no
|
|
4151
|
+
// longer knows of, where the thread read would see it.
|
|
4152
|
+
threadOrigins.delete(oldest.value);
|
|
3948
4153
|
}
|
|
3949
4154
|
}
|
|
3950
4155
|
};
|
|
@@ -3966,12 +4171,126 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3966
4171
|
}
|
|
3967
4172
|
return undefined;
|
|
3968
4173
|
};
|
|
4174
|
+
/**
|
|
4175
|
+
* Who an untagged reply at `ts` belongs to (card 508): the agent the
|
|
4176
|
+
* thread belongs to, not whoever spoke last. That is the first to hold
|
|
4177
|
+
* it — the agent its first message named, a home channel's owner, or the
|
|
4178
|
+
* bot (this daemon's or another's) that posted it — unless a judging
|
|
4179
|
+
* agent has since chosen to speak, which takes it (`takes`). A mention
|
|
4180
|
+
* of another agent gets that agent's answer to that message and nothing
|
|
4181
|
+
* more: the thread stays where it was, and so does every reply after.
|
|
4182
|
+
* In a thread that began before this process did, and that the thread
|
|
4183
|
+
* read has not placed yet, the first claim this process SAW stands in
|
|
4184
|
+
* for the owner: the best it has, and no worse than the last-speaker
|
|
4185
|
+
* rule it replaces. Undefined when this process has seen no claim at
|
|
4186
|
+
* all, which sends the question to the sessions and the thread.
|
|
4187
|
+
*/
|
|
4188
|
+
const ownerAt = (key, ts) => {
|
|
4189
|
+
const origin = threadOrigins.get(key);
|
|
4190
|
+
const handovers = threadAddressee.get(key) ?? [];
|
|
4191
|
+
for (let index = handovers.length - 1; index >= 0; index -= 1) {
|
|
4192
|
+
const handover = handovers[index];
|
|
4193
|
+
if (handover && handover.ts <= ts && (handover.takes === true || (index === 0 && typeof origin !== 'string'))) {
|
|
4194
|
+
return handover.agentId;
|
|
4195
|
+
}
|
|
4196
|
+
}
|
|
4197
|
+
return typeof origin === 'string' ? origin : undefined;
|
|
4198
|
+
};
|
|
4199
|
+
/**
|
|
4200
|
+
* The agent the thread `key` belongs to, whoever holds it now: the owner
|
|
4201
|
+
* the thread read settled, or else the first claim this process saw.
|
|
4202
|
+
* Answering in its own thread takes the thread back from a judge that
|
|
4203
|
+
* spoke since (card 508), so the owner is asked for here, not the holder.
|
|
4204
|
+
*/
|
|
4205
|
+
const threadOwner = (key) => {
|
|
4206
|
+
const origin = threadOrigins.get(key);
|
|
4207
|
+
return typeof origin === 'string' ? origin : threadAddressee.get(key)?.[0]?.agentId;
|
|
4208
|
+
};
|
|
4209
|
+
/** Records that this process saw the thread `key` begin, or who owns it. */
|
|
4210
|
+
const noteOrigin = (key, owner = true) => {
|
|
4211
|
+
if (owner === true && threadOrigins.has(key)) {
|
|
4212
|
+
return;
|
|
4213
|
+
}
|
|
4214
|
+
threadOrigins.delete(key);
|
|
4215
|
+
threadOrigins.set(key, owner);
|
|
4216
|
+
if (threadOrigins.size > THREAD_ADDRESSEE_CAPACITY) {
|
|
4217
|
+
const oldest = threadOrigins.keys().next();
|
|
4218
|
+
if (!oldest.done) {
|
|
4219
|
+
threadOrigins.delete(oldest.value);
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
4222
|
+
};
|
|
4223
|
+
/**
|
|
4224
|
+
* A message that starts a thread, seen by any connection: from here on
|
|
4225
|
+
* this process sees the thread's first claim, so it knows the owner. One
|
|
4226
|
+
* this daemon's own agent posted (a schedule's delivery, a message.send)
|
|
4227
|
+
* is that agent's thread.
|
|
4228
|
+
*/
|
|
4229
|
+
const noteThreadStart = (connection, args) => {
|
|
4230
|
+
const event = args.event;
|
|
4231
|
+
if (!event || (event.type !== 'message' && event.type !== 'app_mention') || event.channel_type === 'im') {
|
|
4232
|
+
return;
|
|
4233
|
+
}
|
|
4234
|
+
if (event.thread_ts !== undefined && event.thread_ts !== event.ts) {
|
|
4235
|
+
return;
|
|
4236
|
+
}
|
|
4237
|
+
if (event.subtype !== undefined && event.subtype !== 'bot_message' && !HUMAN_MESSAGE_SUBTYPES.has(event.subtype)) {
|
|
4238
|
+
return;
|
|
4239
|
+
}
|
|
4240
|
+
const team = args.body?.team_id ?? connection.teamId;
|
|
4241
|
+
const key = threadKeyFor({ team, conversation: event.channel, thread: event.ts });
|
|
4242
|
+
if (threadOrigins.has(key)) {
|
|
4243
|
+
return;
|
|
4244
|
+
}
|
|
4245
|
+
noteOrigin(key);
|
|
4246
|
+
if (event.user !== undefined) {
|
|
4247
|
+
for (const [agentId, identity] of botIdentities) {
|
|
4248
|
+
if (identity.teamId === team && identity.botUserId === event.user) {
|
|
4249
|
+
rememberAddressee(key, agentId, event.ts);
|
|
4250
|
+
}
|
|
4251
|
+
}
|
|
4252
|
+
}
|
|
4253
|
+
// A team agent on another gateway that starts a thread owns it, even
|
|
4254
|
+
// when its first message asks an agent here something: that agent
|
|
4255
|
+
// answers the question, and the thread stays the asker's. Recorded
|
|
4256
|
+
// here, ahead of the answer, since `noteForeignSpeaker` leaves out a
|
|
4257
|
+
// bot's question to an agent here.
|
|
4258
|
+
const bot = event.bot_id !== undefined || event.subtype === 'bot_message';
|
|
4259
|
+
if (bot && event.user !== undefined && !isFleetBot(team, event.user) && !startsThreadAsIntegration(team, event.user, event.thread_ts, event.ts)) {
|
|
4260
|
+
rememberAddressee(key, `${FOREIGN_SPEAKER_PREFIX}${event.user}`, event.ts);
|
|
4261
|
+
}
|
|
4262
|
+
};
|
|
3969
4263
|
/**
|
|
3970
4264
|
* Whether `user` is one of this adapter's own agents in `team`. Their
|
|
3971
4265
|
* replies reach the holder record through `ReplyOutcome.spokeAt`, and
|
|
3972
4266
|
* their sessions answer for them after a restart.
|
|
3973
4267
|
*/
|
|
3974
4268
|
const isFleetBot = (team, user) => user !== undefined && [...botIdentities.values()].some((identity) => identity.teamId === team && identity.botUserId === user);
|
|
4269
|
+
/**
|
|
4270
|
+
* Whether a bot message is a team agent asking one of this daemon's
|
|
4271
|
+
* agents something (card 513): a question, not another voice taking the
|
|
4272
|
+
* thread. The agent asked answers it, and the thread stays with whoever
|
|
4273
|
+
* held it. Used live and when a thread is read back after a restart.
|
|
4274
|
+
*/
|
|
4275
|
+
const asksAgentHere = (team, asker, text) => asker !== undefined && [...botIdentities].some(([agentId, identity]) => identity.teamId === team && mentions(text ?? '', identity.botUserId) && answersAgentMention(agentId, team, asker));
|
|
4276
|
+
/**
|
|
4277
|
+
* Whether a bot message is another app starting a thread — an alert, a
|
|
4278
|
+
* deploy note — rather than an agent (card 508). That starts the thread
|
|
4279
|
+
* without owning it, so an agent named under it keeps the conversation it
|
|
4280
|
+
* was given. A team agent (`agentMentions.team`) starting one owns it the
|
|
4281
|
+
* way an agent here does, and any app replying inside a thread counts as
|
|
4282
|
+
* a speaker there, since an agent on a gateway nobody listed looks like
|
|
4283
|
+
* any other app.
|
|
4284
|
+
*/
|
|
4285
|
+
const startsThreadAsIntegration = (team, user, threadTs, ts) => {
|
|
4286
|
+
if (threadTs !== undefined && threadTs !== ts) {
|
|
4287
|
+
return false;
|
|
4288
|
+
}
|
|
4289
|
+
return user === undefined || ![...botIdentities].some(([agentId, identity]) => {
|
|
4290
|
+
const config = agentConfigFor(agentId);
|
|
4291
|
+
return config !== undefined && teamAgentFor(config, identity.teamId, team, user) !== undefined;
|
|
4292
|
+
});
|
|
4293
|
+
};
|
|
3975
4294
|
/**
|
|
3976
4295
|
* Records a bot this adapter does not serve as the thread's holder from
|
|
3977
4296
|
* the message it posted. An agent on another gateway answers in the same
|
|
@@ -4000,6 +4319,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4000
4319
|
if (speaker === undefined || isFleetBot(team, event.user)) {
|
|
4001
4320
|
return;
|
|
4002
4321
|
}
|
|
4322
|
+
if (asksAgentHere(team, event.user, event.text)) {
|
|
4323
|
+
return;
|
|
4324
|
+
}
|
|
4325
|
+
if (startsThreadAsIntegration(team, event.user, event.thread_ts, event.ts)) {
|
|
4326
|
+
return;
|
|
4327
|
+
}
|
|
4003
4328
|
const thread = event.thread_ts ?? event.ts;
|
|
4004
4329
|
rememberAddressee(threadKeyFor({ team, conversation: event.channel, thread }), `${FOREIGN_SPEAKER_PREFIX}${speaker}`, event.ts);
|
|
4005
4330
|
};
|
|
@@ -4032,16 +4357,30 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4032
4357
|
const botUserId = botIdentities.get(candidate.agentId)?.botUserId;
|
|
4033
4358
|
if (botUserId !== undefined) {
|
|
4034
4359
|
byBotUser.set(botUserId, candidate.agentId);
|
|
4035
|
-
held.set(candidate.agentId,
|
|
4360
|
+
held.set(candidate.agentId, Number.POSITIVE_INFINITY);
|
|
4036
4361
|
continue;
|
|
4037
4362
|
}
|
|
4038
4363
|
const spoke = candidate.lastSpokeAt === undefined ? Number.NaN : Date.parse(candidate.lastSpokeAt) / 1000;
|
|
4039
|
-
held.set(candidate.agentId, Number.isFinite(spoke) ? spoke :
|
|
4364
|
+
held.set(candidate.agentId, Number.isFinite(spoke) ? spoke : Number.POSITIVE_INFINITY);
|
|
4365
|
+
}
|
|
4366
|
+
// Where each last held it, for a stop, which is for whoever was asked
|
|
4367
|
+
// last (card 337), not for the thread's owner.
|
|
4368
|
+
const last = new Map();
|
|
4369
|
+
for (const [agentId, at] of held) {
|
|
4370
|
+
last.set(agentId, Number.isFinite(at) ? at : 0);
|
|
4040
4371
|
}
|
|
4041
4372
|
const place = (agentId, at) => {
|
|
4042
|
-
held.set(agentId, Math.
|
|
4373
|
+
held.set(agentId, Math.min(held.get(agentId) ?? Number.POSITIVE_INFINITY, at));
|
|
4374
|
+
last.set(agentId, Math.max(last.get(agentId) ?? 0, at));
|
|
4043
4375
|
};
|
|
4044
|
-
let foreign =
|
|
4376
|
+
let foreign = Number.POSITIVE_INFINITY;
|
|
4377
|
+
let lastForeign = 0;
|
|
4378
|
+
// Each of these agents' own messages, and whether the message before it
|
|
4379
|
+
// had asked it anything: one nobody asked for is a judge taking the
|
|
4380
|
+
// thread, or its owner answering in it (card 508).
|
|
4381
|
+
const posts = [];
|
|
4382
|
+
let asked = new Set();
|
|
4383
|
+
const askedIn = (text) => new Set([...byBotUser].filter(([botUserId]) => mentions(text ?? '', botUserId)).map(([, agentId]) => agentId));
|
|
4045
4384
|
try {
|
|
4046
4385
|
let cursor;
|
|
4047
4386
|
let pages = 0;
|
|
@@ -4063,14 +4402,35 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4063
4402
|
const own = message.user === undefined ? undefined : byBotUser.get(message.user);
|
|
4064
4403
|
if (own !== undefined) {
|
|
4065
4404
|
place(own, at);
|
|
4405
|
+
posts.push({ agentId: own, at, asked: asked.has(own) });
|
|
4406
|
+
// An agent here naming a colleague that answers its mentions
|
|
4407
|
+
// asks it, as `admitAgentMention` has it live, so the answer is
|
|
4408
|
+
// not a judge speaking unasked.
|
|
4409
|
+
const sender = message.user;
|
|
4410
|
+
const named = sender === undefined
|
|
4411
|
+
? []
|
|
4412
|
+
: [...askedIn(message.text)].filter((agentId) => agentId !== own && answersAgentMention(agentId, parts.team, sender));
|
|
4413
|
+
if (named.length > 0) {
|
|
4414
|
+
asked = new Set(named);
|
|
4415
|
+
}
|
|
4066
4416
|
continue;
|
|
4067
4417
|
}
|
|
4068
4418
|
const bot = message.bot_id !== undefined || message.subtype === 'bot_message';
|
|
4069
4419
|
if (bot) {
|
|
4070
4420
|
// A bot's mention hands nothing over live (admit ignores bots),
|
|
4071
4421
|
// so here it is only another app speaking, or a peer's words.
|
|
4072
|
-
|
|
4073
|
-
|
|
4422
|
+
// A team agent's question to an agent here is not it taking the
|
|
4423
|
+
// thread, unless it started the thread with it: then the thread
|
|
4424
|
+
// is its own, as it is live.
|
|
4425
|
+
const asks = asksAgentHere(parts.team, message.user, message.text);
|
|
4426
|
+
if (asks) {
|
|
4427
|
+
asked = askedIn(message.text);
|
|
4428
|
+
}
|
|
4429
|
+
if (!isFleetBot(parts.team, message.user) &&
|
|
4430
|
+
(!asks || message.ts === parts.thread) &&
|
|
4431
|
+
!startsThreadAsIntegration(parts.team, message.user, parts.thread, message.ts ?? '')) {
|
|
4432
|
+
foreign = Math.min(foreign, at);
|
|
4433
|
+
lastForeign = Math.max(lastForeign, at);
|
|
4074
4434
|
}
|
|
4075
4435
|
continue;
|
|
4076
4436
|
}
|
|
@@ -4080,10 +4440,19 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4080
4440
|
if (sender === undefined) {
|
|
4081
4441
|
continue;
|
|
4082
4442
|
}
|
|
4443
|
+
asked = askedIn(message.text);
|
|
4083
4444
|
const named = agentNamedIn(message.text ?? '', parts.team, (agentId) => admitsSender(agentConfigFor(agentId), sender));
|
|
4084
4445
|
if (named !== undefined && held.has(named) && botIdentities.get(named)?.botUserId !== undefined) {
|
|
4085
4446
|
place(named, at);
|
|
4086
4447
|
}
|
|
4448
|
+
// A root that names nobody in a home channel is its owner's, as
|
|
4449
|
+
// the live home-channel rule has it.
|
|
4450
|
+
const home = message.ts === parts.thread && named === undefined
|
|
4451
|
+
? homeOwners.get(`${parts.team}:${parts.conversation}`)
|
|
4452
|
+
: undefined;
|
|
4453
|
+
if (home !== undefined && held.has(home) && admitsSender(agentConfigFor(home), sender)) {
|
|
4454
|
+
place(home, at);
|
|
4455
|
+
}
|
|
4087
4456
|
}
|
|
4088
4457
|
cursor = page.response_metadata?.next_cursor || undefined;
|
|
4089
4458
|
} while (cursor && pages < EARLIER_THREAD_PAGES);
|
|
@@ -4095,12 +4464,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4095
4464
|
warn(`slack: could not read thread ${parts.thread} in ${parts.conversation} to see who spoke last, so it is routed as before: ${error instanceof Error ? error.message : String(error)}`);
|
|
4096
4465
|
return undefined;
|
|
4097
4466
|
}
|
|
4098
|
-
return { held, foreign };
|
|
4099
|
-
};
|
|
4100
|
-
/** Whether another app spoke in the thread after `agentId` last held it. */
|
|
4101
|
-
const foreignSpokeSince = async (agentId, parts, ts, lastSpokeAt) => {
|
|
4102
|
-
const read = await threadPositions([{ agentId, ...(lastSpokeAt !== undefined ? { lastSpokeAt } : {}) }], parts, ts);
|
|
4103
|
-
return read !== undefined && read.foreign > (read.held.get(agentId) ?? 0);
|
|
4467
|
+
return { held, last, foreign, lastForeign, posts };
|
|
4104
4468
|
};
|
|
4105
4469
|
/**
|
|
4106
4470
|
* Which of this process's agents a message names, or undefined if it
|
|
@@ -4166,25 +4530,19 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4166
4530
|
* restart forgets nothing.
|
|
4167
4531
|
*
|
|
4168
4532
|
* Whose turn it is, is the second. In a thread with several agents an
|
|
4169
|
-
* unaddressed reply belongs to
|
|
4170
|
-
*
|
|
4171
|
-
*
|
|
4172
|
-
* agent last *replied*, not when its session last changed,
|
|
4173
|
-
* saves on tool results and approval checkpoints without
|
|
4174
|
-
*
|
|
4175
|
-
* window.
|
|
4533
|
+
* unaddressed reply belongs to the agent the thread belongs to (card
|
|
4534
|
+
* 508): the first to hold it, read off the thread itself. Only when the
|
|
4535
|
+
* thread can't be read do the sessions decide, and then by `lastSpokeAt`
|
|
4536
|
+
* — when each agent last *replied*, not when its session last changed,
|
|
4537
|
+
* because a turn saves on tool results and approval checkpoints without
|
|
4538
|
+
* having said anything — since that is the only order they keep.
|
|
4176
4539
|
*
|
|
4177
4540
|
* Nobody is the answer only when the agents cannot be *ordered* — nothing
|
|
4178
|
-
* in the thread has spoken
|
|
4179
|
-
*
|
|
4180
|
-
*
|
|
4181
|
-
* would put two answers under one message. An agent that simply has not
|
|
4182
|
-
* spoken is a different thing and is skipped rather than fatal: it cannot
|
|
4183
|
-
* have spoken last, and letting it silence the agent that did would leave
|
|
4184
|
-
* a reply nobody answers. The agent alone in its thread, which is nearly
|
|
4185
|
-
* every thread, is answered without the question arising.
|
|
4541
|
+
* in the thread has spoken, or two tie — because guessing there would put
|
|
4542
|
+
* two answers under one message. The agent alone in its thread, which is
|
|
4543
|
+
* nearly every thread, is answered without the question arising.
|
|
4186
4544
|
*/
|
|
4187
|
-
const resolveFollowUpWinner = async (parts, ts) => {
|
|
4545
|
+
const resolveFollowUpWinner = async (parts, ts, mode = 'reply') => {
|
|
4188
4546
|
const gateway = gatewayRef;
|
|
4189
4547
|
if (!gateway?.sessionRouting || !parts.thread) {
|
|
4190
4548
|
return undefined;
|
|
@@ -4262,22 +4620,107 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4262
4620
|
}
|
|
4263
4621
|
for (const [agentId, identity] of botIdentities) {
|
|
4264
4622
|
if (identity.teamId === parts.team && identity.botUserId === parts.parentUser) {
|
|
4265
|
-
|
|
4623
|
+
noteOrigin(threadKeyFor(parts), agentId);
|
|
4624
|
+
return agentId;
|
|
4266
4625
|
}
|
|
4267
4626
|
}
|
|
4268
4627
|
return undefined;
|
|
4269
4628
|
}
|
|
4270
|
-
|
|
4271
|
-
|
|
4629
|
+
// The thread says who it belongs to: whichever of these agents held it
|
|
4630
|
+
// first — its first message named it, or it posted there first — or
|
|
4631
|
+
// another app that spoke before any of them did (card 508). When it
|
|
4632
|
+
// can't be read, the sessions decide: the only agent in it, or, as a
|
|
4633
|
+
// last resort, the one that spoke last, which is all they record.
|
|
4634
|
+
// The agent that posted the root takes part even with no session here:
|
|
4635
|
+
// a post made through message.send or a schedule leaves none, and a
|
|
4636
|
+
// colleague named under it since must not look like the owner.
|
|
4637
|
+
const candidates = [...engaged];
|
|
4638
|
+
if (parts.parentUser !== undefined) {
|
|
4639
|
+
for (const [agentId, identity] of botIdentities) {
|
|
4640
|
+
if (identity.teamId === parts.team && identity.botUserId === parts.parentUser && !candidates.some((entry) => entry.agentId === agentId)) {
|
|
4641
|
+
candidates.push({ agentId });
|
|
4642
|
+
}
|
|
4643
|
+
}
|
|
4644
|
+
}
|
|
4645
|
+
const read = await threadPositions(candidates, parts, ts);
|
|
4646
|
+
if (!read) {
|
|
4647
|
+
return engaged.length === 1 ? first.agentId : lastSpeaker(engaged);
|
|
4648
|
+
}
|
|
4649
|
+
if (mode === 'stop') {
|
|
4650
|
+
// A stop is for whoever was asked last (card 337), as `holderAt` has
|
|
4651
|
+
// it live: the turn it means is that agent's, not the owner's.
|
|
4652
|
+
let holder;
|
|
4653
|
+
let tiedLast = false;
|
|
4654
|
+
for (const entry of candidates) {
|
|
4655
|
+
const at = read.last.get(entry.agentId) ?? 0;
|
|
4656
|
+
if (at <= 0) {
|
|
4657
|
+
continue;
|
|
4658
|
+
}
|
|
4659
|
+
if (!holder || at > holder.at) {
|
|
4660
|
+
holder = { agentId: entry.agentId, at };
|
|
4661
|
+
tiedLast = false;
|
|
4662
|
+
}
|
|
4663
|
+
else if (at === holder.at) {
|
|
4664
|
+
tiedLast = true;
|
|
4665
|
+
}
|
|
4666
|
+
}
|
|
4667
|
+
if (!holder) {
|
|
4668
|
+
return read.lastForeign > 0 ? undefined : (engaged.length === 1 ? first.agentId : lastSpeaker(engaged));
|
|
4669
|
+
}
|
|
4670
|
+
return tiedLast || read.lastForeign > holder.at ? undefined : holder.agentId;
|
|
4671
|
+
}
|
|
4672
|
+
let owner;
|
|
4673
|
+
let tiedAt = false;
|
|
4674
|
+
for (const entry of candidates) {
|
|
4675
|
+
const at = read.held.get(entry.agentId) ?? Number.POSITIVE_INFINITY;
|
|
4676
|
+
if (!Number.isFinite(at) || at <= 0) {
|
|
4677
|
+
continue;
|
|
4678
|
+
}
|
|
4679
|
+
if (!owner || at < owner.at) {
|
|
4680
|
+
owner = { agentId: entry.agentId, at };
|
|
4681
|
+
tiedAt = false;
|
|
4682
|
+
}
|
|
4683
|
+
else if (at === owner.at) {
|
|
4684
|
+
tiedAt = true;
|
|
4685
|
+
}
|
|
4686
|
+
}
|
|
4687
|
+
// A judging agent that spoke unasked took the thread, and the owner
|
|
4688
|
+
// answering in it took it back, as live (card 508): the latest of those
|
|
4689
|
+
// holds it. The live record of who took it is lost on a restart; the
|
|
4690
|
+
// thread keeps it. Only a judge takes by speaking unasked, as only its
|
|
4691
|
+
// turns are judged live; another agent's post there (a message.send)
|
|
4692
|
+
// takes nothing.
|
|
4693
|
+
const ownerId = owner && !tiedAt && !(read.foreign < owner.at) ? owner.agentId : undefined;
|
|
4694
|
+
let taken;
|
|
4695
|
+
for (const post of read.posts) {
|
|
4696
|
+
const takes = post.agentId === ownerId || (!post.asked && listensOf(post.agentId) === 'judge');
|
|
4697
|
+
if (takes && (!taken || post.at > taken.at)) {
|
|
4698
|
+
taken = post;
|
|
4699
|
+
}
|
|
4700
|
+
}
|
|
4701
|
+
if (taken && taken.agentId !== ownerId && (ownerId !== undefined || taken.at > read.foreign)) {
|
|
4702
|
+
return taken.agentId;
|
|
4703
|
+
}
|
|
4704
|
+
if (!owner) {
|
|
4705
|
+
return Number.isFinite(read.foreign) ? undefined : (engaged.length === 1 ? first.agentId : lastSpeaker(engaged));
|
|
4706
|
+
}
|
|
4707
|
+
if (ownerId === undefined) {
|
|
4708
|
+
return undefined;
|
|
4272
4709
|
}
|
|
4710
|
+
noteOrigin(threadKeyFor(parts), ownerId);
|
|
4711
|
+
return ownerId;
|
|
4712
|
+
};
|
|
4713
|
+
/**
|
|
4714
|
+
* Of several agents in a thread, the one whose session says it spoke
|
|
4715
|
+
* last — the sessions' only ordering, used when the thread itself can't
|
|
4716
|
+
* be read. Undefined when nobody has spoken or the newest two tie, since
|
|
4717
|
+
* guessing there would put two answers under one message.
|
|
4718
|
+
*/
|
|
4719
|
+
const lastSpeaker = (engaged) => {
|
|
4273
4720
|
let latest;
|
|
4274
4721
|
let tied = false;
|
|
4275
4722
|
for (const entry of engaged) {
|
|
4276
4723
|
if (entry.lastSpokeAt === undefined) {
|
|
4277
|
-
// In the thread, but has never said anything in it — mentioned and
|
|
4278
|
-
// still on its first turn, or a turn that produced no text. It
|
|
4279
|
-
// cannot be the one who spoke last, and it must not stop the agent
|
|
4280
|
-
// that did from answering.
|
|
4281
4724
|
continue;
|
|
4282
4725
|
}
|
|
4283
4726
|
if (!latest || entry.lastSpokeAt > latest.lastSpokeAt) {
|
|
@@ -4288,34 +4731,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4288
4731
|
tied = true;
|
|
4289
4732
|
}
|
|
4290
4733
|
}
|
|
4291
|
-
|
|
4292
|
-
// Sessions date a reply by when it finished, the thread by where its
|
|
4293
|
-
// placeholder went up, and an agent on another gateway may have spoken
|
|
4294
|
-
// since: only the thread can say who holds it. When it can't be read,
|
|
4295
|
-
// the sessions decide, as before.
|
|
4296
|
-
const read = await threadPositions(engaged, parts, ts);
|
|
4297
|
-
if (!read) {
|
|
4298
|
-
return bySession;
|
|
4299
|
-
}
|
|
4300
|
-
let holder;
|
|
4301
|
-
let tiedAt = false;
|
|
4302
|
-
for (const entry of engaged) {
|
|
4303
|
-
const at = read.held.get(entry.agentId) ?? 0;
|
|
4304
|
-
if (at <= 0) {
|
|
4305
|
-
continue;
|
|
4306
|
-
}
|
|
4307
|
-
if (!holder || at > holder.at) {
|
|
4308
|
-
holder = { agentId: entry.agentId, at };
|
|
4309
|
-
tiedAt = false;
|
|
4310
|
-
}
|
|
4311
|
-
else if (at === holder.at) {
|
|
4312
|
-
tiedAt = true;
|
|
4313
|
-
}
|
|
4314
|
-
}
|
|
4315
|
-
if (!holder) {
|
|
4316
|
-
return read.foreign > 0 ? undefined : bySession;
|
|
4317
|
-
}
|
|
4318
|
-
return tiedAt || read.foreign > holder.at ? undefined : holder.agentId;
|
|
4734
|
+
return tied ? undefined : latest?.agentId;
|
|
4319
4735
|
};
|
|
4320
4736
|
/**
|
|
4321
4737
|
* `resolveFollowUpWinner`, resolved ONCE PER MESSAGE and shared by every
|
|
@@ -4335,13 +4751,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4335
4751
|
* this after the winner has finished, and recomputing for that agent is
|
|
4336
4752
|
* precisely the disagreement the memo exists to prevent.
|
|
4337
4753
|
*/
|
|
4338
|
-
const followUpWinner = (parts, ts) => {
|
|
4339
|
-
|
|
4754
|
+
const followUpWinner = (parts, ts, mode = 'reply') => {
|
|
4755
|
+
// A stop asks a different question (whoever was asked last), so it
|
|
4756
|
+
// gets its own verdict, the same for every agent that asks it.
|
|
4757
|
+
const key = `${threadKeyFor(parts)}:${ts}${mode === 'stop' ? ':stop' : ''}`;
|
|
4340
4758
|
const existing = coldVerdicts.get(key);
|
|
4341
4759
|
if (existing) {
|
|
4342
4760
|
return existing.verdict;
|
|
4343
4761
|
}
|
|
4344
|
-
const entry = { verdict: resolveFollowUpWinner(parts, ts), settled: false };
|
|
4762
|
+
const entry = { verdict: resolveFollowUpWinner(parts, ts, mode), settled: false };
|
|
4345
4763
|
coldVerdicts.set(key, entry);
|
|
4346
4764
|
void entry.verdict.then(() => {
|
|
4347
4765
|
entry.settled = true;
|
|
@@ -4388,11 +4806,179 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4388
4806
|
* case, where by definition no message of this thread has been seen in
|
|
4389
4807
|
* this process.
|
|
4390
4808
|
*/
|
|
4809
|
+
/**
|
|
4810
|
+
* Stop (card 337): cancels every turn this agent is running or has queued
|
|
4811
|
+
* in this conversation, including messages still in intake, and says
|
|
4812
|
+
* nothing was stopped when there was nothing to stop. Synchronous, and
|
|
4813
|
+
* called off the intake chain before any dispatch, so a stop is never
|
|
4814
|
+
* queued behind the turn it stops nor merged into the next one. The
|
|
4815
|
+
* caller has checked that the sender is an approver: a stop throws away
|
|
4816
|
+
* work someone else asked for.
|
|
4817
|
+
*
|
|
4818
|
+
* `fromIntake` is a stop that could only be placed on the intake chain,
|
|
4819
|
+
* once the sessions named this agent (the cold case). Everything ahead of
|
|
4820
|
+
* it on the chain has already been dispatched, and everything still in
|
|
4821
|
+
* intake arrived after it, so it cancels the dispatched turns only.
|
|
4822
|
+
*/
|
|
4823
|
+
const stopTurns = (connection, sessionId, userId, channel, fromIntake = false) => {
|
|
4824
|
+
const running = renderers.get(sessionId) ?? [];
|
|
4825
|
+
const inIntake = fromIntake ? 0 : intakeInFlight.get(sessionId) ?? 0;
|
|
4826
|
+
if (running.length === 0 && inIntake === 0) {
|
|
4827
|
+
return false;
|
|
4828
|
+
}
|
|
4829
|
+
for (const renderer of running) {
|
|
4830
|
+
if (renderer.stoppedBy === undefined) {
|
|
4831
|
+
renderer.stoppedBy = userId;
|
|
4832
|
+
renderer.controller.abort(new RunAbortedError(`Stopped from Slack by ${userId}`));
|
|
4833
|
+
}
|
|
4834
|
+
}
|
|
4835
|
+
if (inIntake > 0) {
|
|
4836
|
+
stopsBySession.set(sessionId, { count: (stopsBySession.get(sessionId)?.count ?? 0) + 1, by: userId });
|
|
4837
|
+
}
|
|
4838
|
+
log(`slack: ${userId} stopped ${running.length + inIntake} turn(s) of ${connection.config.agentId} in ${channel}`);
|
|
4839
|
+
return true;
|
|
4840
|
+
};
|
|
4841
|
+
/**
|
|
4842
|
+
* Agent mentions admitted per thread since a person last spoke there, by
|
|
4843
|
+
* message ts (card 513). A copy of one message reaching a second socket,
|
|
4844
|
+
* or redelivered, is the same hop. Bounded like the addressee map.
|
|
4845
|
+
*/
|
|
4846
|
+
const agentHops = new Map();
|
|
4847
|
+
/**
|
|
4848
|
+
* Which team agent a bot user in `team` is, for the agent configured by
|
|
4849
|
+
* `config` in workspace `agentTeam`, if any. A configured id counts only
|
|
4850
|
+
* in that agent's own workspace: user ids are unique per workspace, so the
|
|
4851
|
+
* same id elsewhere is some other bot.
|
|
4852
|
+
*/
|
|
4853
|
+
const teamAgentFor = (config, agentTeam, team, botUser) => {
|
|
4854
|
+
for (const [agentId, identity] of botIdentities) {
|
|
4855
|
+
if (identity.teamId === team && identity.botUserId === botUser) {
|
|
4856
|
+
return agentId;
|
|
4857
|
+
}
|
|
4858
|
+
}
|
|
4859
|
+
if (agentTeam === undefined || agentTeam !== team) {
|
|
4860
|
+
return undefined;
|
|
4861
|
+
}
|
|
4862
|
+
for (const [name, id] of Object.entries(config.agentMentions?.team ?? {})) {
|
|
4863
|
+
if (id === botUser) {
|
|
4864
|
+
return name;
|
|
4865
|
+
}
|
|
4866
|
+
}
|
|
4867
|
+
return undefined;
|
|
4868
|
+
};
|
|
4869
|
+
/**
|
|
4870
|
+
* Whether `agentId` answers a mention from `botUser` in `team`: it has
|
|
4871
|
+
* agent mentions on, the bot is a team agent to it, and on its list if it
|
|
4872
|
+
* keeps one. The hop limit is not asked here.
|
|
4873
|
+
*/
|
|
4874
|
+
const answersAgentMention = (agentId, team, botUser) => {
|
|
4875
|
+
const config = agentConfigFor(agentId);
|
|
4876
|
+
const settings = config?.agentMentions;
|
|
4877
|
+
if (!config || !settings) {
|
|
4878
|
+
return false;
|
|
4879
|
+
}
|
|
4880
|
+
const name = teamAgentFor(config, botIdentities.get(agentId)?.teamId, team, botUser);
|
|
4881
|
+
return name !== undefined && (settings.only === undefined || settings.only.includes(name));
|
|
4882
|
+
};
|
|
4883
|
+
/**
|
|
4884
|
+
* A message another agent's bot posted, for an agent that answers team
|
|
4885
|
+
* agents (`agentMentions`, card 513). Admitted only when it names this
|
|
4886
|
+
* agent, comes from a team agent this one listens to, and the thread is
|
|
4887
|
+
* under its hop limit; anything else from a bot is dropped, as every
|
|
4888
|
+
* bot message was before. Never part of the holder rule: a bot naming an
|
|
4889
|
+
* agent asks it one thing, and hands it nothing.
|
|
4890
|
+
*/
|
|
4891
|
+
const admitAgentMention = (connection, args) => {
|
|
4892
|
+
const event = args.event;
|
|
4893
|
+
const settings = connection.config.agentMentions;
|
|
4894
|
+
if (!settings || !event || event.user === undefined || event.user === connection.botUserId) {
|
|
4895
|
+
return undefined;
|
|
4896
|
+
}
|
|
4897
|
+
if ((event.type !== 'app_mention' && event.type !== 'message') || event.channel_type === 'im') {
|
|
4898
|
+
return undefined;
|
|
4899
|
+
}
|
|
4900
|
+
if (event.subtype !== undefined && !AGENT_SPEECH_SUBTYPES.has(event.subtype)) {
|
|
4901
|
+
return undefined;
|
|
4902
|
+
}
|
|
4903
|
+
const text = event.text ?? '';
|
|
4904
|
+
if (!mentions(text, connection.botUserId)) {
|
|
4905
|
+
return undefined;
|
|
4906
|
+
}
|
|
4907
|
+
const team = args.body?.team_id ?? connection.teamId;
|
|
4908
|
+
const name = teamAgentFor(connection.config, connection.teamId, team, event.user);
|
|
4909
|
+
if (name === undefined) {
|
|
4910
|
+
return undefined;
|
|
4911
|
+
}
|
|
4912
|
+
const eventKey = `${connection.config.agentId}:${event.channel}:${event.ts}`;
|
|
4913
|
+
if (settings.only !== undefined && !settings.only.includes(name)) {
|
|
4914
|
+
if (!alreadySeen(eventKey)) {
|
|
4915
|
+
log(`slack: ${connection.config.agentId} ignored a mention from agent ${name}: not on its agent mention list`);
|
|
4916
|
+
}
|
|
4917
|
+
return undefined;
|
|
4918
|
+
}
|
|
4919
|
+
const thread = event.thread_ts ?? event.ts;
|
|
4920
|
+
const threadKey = threadKeyFor({ team, conversation: event.channel, thread });
|
|
4921
|
+
const hops = agentHops.get(threadKey) ?? [];
|
|
4922
|
+
if (!hops.includes(event.ts)) {
|
|
4923
|
+
if (hops.length >= AGENT_MENTION_HOP_LIMIT) {
|
|
4924
|
+
if (!alreadySeen(eventKey)) {
|
|
4925
|
+
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`);
|
|
4926
|
+
}
|
|
4927
|
+
return undefined;
|
|
4928
|
+
}
|
|
4929
|
+
agentHops.delete(threadKey);
|
|
4930
|
+
agentHops.set(threadKey, [...hops, event.ts]);
|
|
4931
|
+
if (agentHops.size > THREAD_ADDRESSEE_CAPACITY) {
|
|
4932
|
+
const oldest = agentHops.keys().next();
|
|
4933
|
+
if (!oldest.done) {
|
|
4934
|
+
agentHops.delete(oldest.value);
|
|
4935
|
+
}
|
|
4936
|
+
}
|
|
4937
|
+
}
|
|
4938
|
+
if (alreadySeen(eventKey)) {
|
|
4939
|
+
return undefined;
|
|
4940
|
+
}
|
|
4941
|
+
return {
|
|
4942
|
+
event,
|
|
4943
|
+
isDm: false,
|
|
4944
|
+
team,
|
|
4945
|
+
eventKey,
|
|
4946
|
+
userId: event.user,
|
|
4947
|
+
sessionId: channelSessionKey({ channel: 'slack', agentId: connection.config.agentId, team, conversation: event.channel, thread }),
|
|
4948
|
+
thread,
|
|
4949
|
+
threadKey,
|
|
4950
|
+
settled: true,
|
|
4951
|
+
overhear: false,
|
|
4952
|
+
judge: false,
|
|
4953
|
+
fromAgent: name,
|
|
4954
|
+
};
|
|
4955
|
+
};
|
|
4391
4956
|
const admit = (connection, args) => {
|
|
4392
4957
|
const event = args.event;
|
|
4393
4958
|
if (gatewayRef) {
|
|
4959
|
+
noteThreadStart(connection, args);
|
|
4394
4960
|
noteForeignSpeaker(connection, args);
|
|
4395
4961
|
}
|
|
4962
|
+
if (gatewayRef && event && (event.bot_id !== undefined || event.subtype === 'bot_message')) {
|
|
4963
|
+
return admitAgentMention(connection, args);
|
|
4964
|
+
}
|
|
4965
|
+
if (event && event.user !== undefined && event.channel_type !== 'im' && isPersonSpeaking(event) && admitsSender(connection.config, event.user)) {
|
|
4966
|
+
// A person this agent admits, speaking in a thread, ends a run of
|
|
4967
|
+
// agent mentions there. Someone it refuses can't restart one. Only
|
|
4968
|
+
// hops before the person's message reset: another agent's socket can
|
|
4969
|
+
// deliver an older human message late, after newer hops.
|
|
4970
|
+
const hopKey = threadKeyFor({ team: args.body?.team_id ?? connection.teamId, conversation: event.channel, thread: event.thread_ts ?? event.ts });
|
|
4971
|
+
const hops = agentHops.get(hopKey);
|
|
4972
|
+
if (hops) {
|
|
4973
|
+
const newer = hops.filter((hop) => compareSlackTs(hop, event.ts) > 0);
|
|
4974
|
+
if (newer.length > 0) {
|
|
4975
|
+
agentHops.set(hopKey, newer);
|
|
4976
|
+
}
|
|
4977
|
+
else {
|
|
4978
|
+
agentHops.delete(hopKey);
|
|
4979
|
+
}
|
|
4980
|
+
}
|
|
4981
|
+
}
|
|
4396
4982
|
if (!gatewayRef || !event || !event.user || event.bot_id || !isPersonSpeaking(event)) {
|
|
4397
4983
|
return undefined;
|
|
4398
4984
|
}
|
|
@@ -4496,8 +5082,34 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4496
5082
|
// Consuming the key here is safe because a refused message records
|
|
4497
5083
|
// nothing a later copy could be needed for.
|
|
4498
5084
|
if (!admitsSender(connection.config, sender)) {
|
|
5085
|
+
// An approver the operator did not list as a principal can still stop
|
|
5086
|
+
// the agent (card 337): approving is its own grant. Only when the
|
|
5087
|
+
// message names this agent (or is a DM to it), and nothing else they
|
|
5088
|
+
// say gets through.
|
|
5089
|
+
if (addressed &&
|
|
5090
|
+
(connection.config.approvers ?? []).includes(sender) &&
|
|
5091
|
+
isStopRequest(text, connection.botUserId) &&
|
|
5092
|
+
!alreadySeen(eventKey)) {
|
|
5093
|
+
const stopSession = channelSessionKey({
|
|
5094
|
+
channel: 'slack',
|
|
5095
|
+
agentId: connection.config.agentId,
|
|
5096
|
+
team,
|
|
5097
|
+
conversation: event.channel,
|
|
5098
|
+
...(thread ? { thread } : {}),
|
|
5099
|
+
});
|
|
5100
|
+
if (stopTurns(connection, stopSession, sender, event.channel)) {
|
|
5101
|
+
return undefined;
|
|
5102
|
+
}
|
|
5103
|
+
log(`slack: ${connection.config.agentId} refused a message from ${sender}: not a listed principal, and admit is "principals"`);
|
|
5104
|
+
return undefined;
|
|
5105
|
+
}
|
|
4499
5106
|
const refusal = `slack: ${connection.config.agentId} refused a message from ${sender}: not a listed principal, and admit is "principals"`;
|
|
4500
|
-
|
|
5107
|
+
// Whose message this is, as for an admitted one: the thread's owner's
|
|
5108
|
+
// (card 508), or for a stop, whoever was asked last (card 337).
|
|
5109
|
+
const refusedStop = (connection.config.approvers ?? []).includes(sender) && isStopRequest(text, connection.botUserId);
|
|
5110
|
+
const held = threadKey === undefined
|
|
5111
|
+
? undefined
|
|
5112
|
+
: (refusedStop ? holderAt(threadKey, event.ts) : undefined) ?? ownerAt(threadKey, event.ts);
|
|
4501
5113
|
if (addressed || held === connection.config.agentId) {
|
|
4502
5114
|
if (!alreadySeen(eventKey)) {
|
|
4503
5115
|
log(refusal);
|
|
@@ -4516,7 +5128,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4516
5128
|
conversation: event.channel,
|
|
4517
5129
|
thread,
|
|
4518
5130
|
...(event.parent_user_id !== undefined ? { parentUser: event.parent_user_id } : {}),
|
|
4519
|
-
}, event.ts).then((winner) => {
|
|
5131
|
+
}, event.ts, refusedStop ? 'stop' : 'reply').then((winner) => {
|
|
4520
5132
|
if (winner === connection.config.agentId) {
|
|
4521
5133
|
log(refusal);
|
|
4522
5134
|
}
|
|
@@ -4592,16 +5204,37 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4592
5204
|
// "whose is an untagged reply", and for these two agents the soul has
|
|
4593
5205
|
// already answered.
|
|
4594
5206
|
const listens = listensOf(connection.config.agentId);
|
|
4595
|
-
if (listens === 'mentions') {
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
|
|
5207
|
+
if (listens === 'mentions' || listens === 'judge') {
|
|
5208
|
+
// Except a stop (card 337): the holder rule decides whose it is, as
|
|
5209
|
+
// for any agent, and it only acts if there is something to stop. A
|
|
5210
|
+
// thread the record has forgotten is left to the sessions, as below,
|
|
5211
|
+
// so a stop for a turn that outlived its record still reaches it.
|
|
5212
|
+
const approverStop = threadKey !== undefined &&
|
|
5213
|
+
(connection.config.approvers ?? []).includes(sender) &&
|
|
5214
|
+
isStopRequest(text, connection.botUserId);
|
|
5215
|
+
const stopHolder = approverStop ? holderAt(threadKey, event.ts) : undefined;
|
|
5216
|
+
// A stop held by another agent is only heard: judging it would let an
|
|
5217
|
+
// attentive agent take a turn on a stop that was never its own.
|
|
5218
|
+
const stopForAnother = stopHolder !== undefined && stopHolder !== connection.config.agentId;
|
|
5219
|
+
return {
|
|
5220
|
+
...admission,
|
|
5221
|
+
overhear: true,
|
|
5222
|
+
...(listens === 'judge' && !stopForAnother ? { judge: true } : {}),
|
|
5223
|
+
...(stopHolder === connection.config.agentId ? { holderStop: true } : {}),
|
|
5224
|
+
...(approverStop && stopHolder === undefined ? { settled: false } : {}),
|
|
5225
|
+
};
|
|
4600
5226
|
}
|
|
4601
5227
|
// The record is what this process has actually seen, in the order it
|
|
4602
5228
|
// saw it — right even inside the window before a handover reaches the
|
|
4603
|
-
// store.
|
|
4604
|
-
|
|
5229
|
+
// store. Whose an untagged reply is, is the thread's owner's (card
|
|
5230
|
+
// 508), not the last speaker's — except a stop, which is for whoever
|
|
5231
|
+
// was asked last, since that is the turn it means (card 337).
|
|
5232
|
+
const stopRequest = threadKey !== undefined &&
|
|
5233
|
+
(connection.config.approvers ?? []).includes(sender) &&
|
|
5234
|
+
isStopRequest(text, connection.botUserId);
|
|
5235
|
+
const holder = threadKey === undefined
|
|
5236
|
+
? undefined
|
|
5237
|
+
: (stopRequest ? holderAt(threadKey, event.ts) : undefined) ?? ownerAt(threadKey, event.ts);
|
|
4605
5238
|
if (holder !== undefined && threadKey !== undefined) {
|
|
4606
5239
|
if (holder !== connection.config.agentId) {
|
|
4607
5240
|
// Standing down for the holder, whether or not the holder can still
|
|
@@ -4652,6 +5285,19 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4652
5285
|
return;
|
|
4653
5286
|
}
|
|
4654
5287
|
const { event, isDm, team, userId, thread, sessionId, threadKey, eventKey } = admitted;
|
|
5288
|
+
// Stop (card 337), see `stopTurns`. Only a message addressed to this
|
|
5289
|
+
// agent: an untagged reply this agent merely overhears is the holder's
|
|
5290
|
+
// (unless this agent is the holder and only `listens` made it an
|
|
5291
|
+
// overhear, see `holderStop`), and one only the sessions can place is
|
|
5292
|
+
// not this agent's to act on yet.
|
|
5293
|
+
if ((!admitted.overhear || admitted.holderStop === true) &&
|
|
5294
|
+
admitted.settled &&
|
|
5295
|
+
admitted.fromAgent === undefined &&
|
|
5296
|
+
(connection.config.approvers ?? []).includes(userId) &&
|
|
5297
|
+
isStopRequest(event.text ?? '', connection.botUserId) &&
|
|
5298
|
+
stopTurns(connection, sessionId, userId, event.channel)) {
|
|
5299
|
+
return;
|
|
5300
|
+
}
|
|
4655
5301
|
// What the gateway knows this message by (see `dispatchKeyFor`).
|
|
4656
5302
|
const dispatchKey = dispatchKeyFor(eventKey);
|
|
4657
5303
|
// Everything up to (and including) the dispatch call is serialized per
|
|
@@ -4661,6 +5307,30 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4661
5307
|
// reverse order in durable history. The chain is claimed here,
|
|
4662
5308
|
// synchronously, so a message's place in it is fixed by when Slack
|
|
4663
5309
|
// delivered it and not by how long anything it does takes.
|
|
5310
|
+
const stopsAtClaim = stopsBySession.get(sessionId)?.count ?? 0;
|
|
5311
|
+
// Only intake that can still become a turn is something a stop can
|
|
5312
|
+
// cancel. A settled overhear that is not judged only ever reaches
|
|
5313
|
+
// `gateway.observe`, so counting it would let a stop with nothing to
|
|
5314
|
+
// stop be consumed instead of passing on as an ordinary message.
|
|
5315
|
+
const mayRun = !admitted.overhear || admitted.judge || !admitted.settled;
|
|
5316
|
+
if (mayRun) {
|
|
5317
|
+
intakeInFlight.set(sessionId, (intakeInFlight.get(sessionId) ?? 0) + 1);
|
|
5318
|
+
}
|
|
5319
|
+
let leftIntake = !mayRun;
|
|
5320
|
+
const leaveIntake = () => {
|
|
5321
|
+
if (leftIntake) {
|
|
5322
|
+
return;
|
|
5323
|
+
}
|
|
5324
|
+
leftIntake = true;
|
|
5325
|
+
const left = (intakeInFlight.get(sessionId) ?? 1) - 1;
|
|
5326
|
+
if (left > 0) {
|
|
5327
|
+
intakeInFlight.set(sessionId, left);
|
|
5328
|
+
}
|
|
5329
|
+
else {
|
|
5330
|
+
intakeInFlight.delete(sessionId);
|
|
5331
|
+
stopsBySession.delete(sessionId);
|
|
5332
|
+
}
|
|
5333
|
+
};
|
|
4664
5334
|
const intake = onIntakeChain(sessionId, async () => {
|
|
4665
5335
|
// The durable half of addressing runs INSIDE the chain, for the same
|
|
4666
5336
|
// reason: it awaits, and a message that had to ask the sessions must
|
|
@@ -4695,21 +5365,36 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4695
5365
|
if (!admitted.settled) {
|
|
4696
5366
|
// One verdict for this message, shared with whichever other agents
|
|
4697
5367
|
// are asking about it — see `followUpWinner`.
|
|
5368
|
+
const stop = (connection.config.approvers ?? []).includes(userId) &&
|
|
5369
|
+
isStopRequest(event.text ?? '', connection.botUserId);
|
|
4698
5370
|
const winner = await followUpWinner({
|
|
4699
5371
|
team,
|
|
4700
5372
|
conversation: event.channel,
|
|
4701
5373
|
thread: event.thread_ts ?? '',
|
|
4702
5374
|
...(event.parent_user_id !== undefined ? { parentUser: event.parent_user_id } : {}),
|
|
4703
|
-
}, event.ts);
|
|
5375
|
+
}, event.ts, stop ? 'stop' : 'reply');
|
|
4704
5376
|
if (winner !== connection.config.agentId) {
|
|
4705
5377
|
// 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.
|
|
5378
|
+
// this agent's to hear, same as the two warm stand-downs. Only a
|
|
5379
|
+
// stop sends a judging agent here, and a stop the sessions place
|
|
5380
|
+
// with another agent is heard, never judged, as it is warm.
|
|
5381
|
+
judged = false;
|
|
4707
5382
|
overhear = true;
|
|
4708
5383
|
}
|
|
4709
|
-
else
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
5384
|
+
else {
|
|
5385
|
+
if (threadKey) {
|
|
5386
|
+
// Answered from the store rather than from memory: hold it, so
|
|
5387
|
+
// the rest of the thread is ordered by receipt like any other.
|
|
5388
|
+
rememberAddressee(threadKey, connection.config.agentId, event.ts);
|
|
5389
|
+
}
|
|
5390
|
+
// Stop (card 337) for a message only the sessions could place:
|
|
5391
|
+
// now that they say it is this agent's, it is a stop like the
|
|
5392
|
+
// warm one above, not a message to queue behind the turn it stops.
|
|
5393
|
+
if ((connection.config.approvers ?? []).includes(userId) &&
|
|
5394
|
+
isStopRequest(event.text ?? '', connection.botUserId) &&
|
|
5395
|
+
stopTurns(connection, sessionId, userId, event.channel, true)) {
|
|
5396
|
+
return undefined;
|
|
5397
|
+
}
|
|
4713
5398
|
}
|
|
4714
5399
|
}
|
|
4715
5400
|
const cleaned = await humanizeMentions(connection, event.text ?? '');
|
|
@@ -4737,7 +5422,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4737
5422
|
const { texts, saved, unread } = overhear || judged
|
|
4738
5423
|
? { texts: [], saved: [], unread: notImages }
|
|
4739
5424
|
: 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)
|
|
5425
|
+
? createAttachmentSaver(options.attachmentStore, connection.config.agentId, admitted.fromAgent !== undefined ? 'unknown' : senderTrustFor(connection.config, userId), options.now ?? Date.now)
|
|
4741
5426
|
: undefined);
|
|
4742
5427
|
// An image or a document with nothing said is still a question
|
|
4743
5428
|
// ("what's this?"); a file the turn could not open with nothing said
|
|
@@ -4745,7 +5430,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4745
5430
|
if (cleaned.length === 0 && images.length === 0 && texts.length === 0 && saved.length === 0) {
|
|
4746
5431
|
return undefined;
|
|
4747
5432
|
}
|
|
4748
|
-
|
|
5433
|
+
// A team agent goes by the name the operator gave it, not a profile
|
|
5434
|
+
// its own app chose.
|
|
5435
|
+
// A team agent's daemon hears only what tags its bot (card 513), so
|
|
5436
|
+
// the model gets that tag and decides whether its answer needs it
|
|
5437
|
+
// (Dylan, 2026-10-15: the model tags back, the adapter never does).
|
|
5438
|
+
const author = admitted.fromAgent !== undefined
|
|
5439
|
+
? `${admitted.fromAgent} (an agent; it sees your answer only if you tag <@${userId}>)`
|
|
5440
|
+
: await authorFor(connection, userId);
|
|
4749
5441
|
// In shared channels the model should know who is speaking; a DM is
|
|
4750
5442
|
// unambiguous. A bare image in a channel still says who sent it.
|
|
4751
5443
|
const spoken = isDm ? cleaned : (cleaned.length > 0 ? `${author}: ${cleaned}` : `${author}:`);
|
|
@@ -4756,7 +5448,10 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4756
5448
|
// `unknown` exactly as a channel mention would be. The same label
|
|
4757
5449
|
// whether the agent answers or only hears: the text is in its
|
|
4758
5450
|
// transcript either way.
|
|
4759
|
-
|
|
5451
|
+
// A team agent is below a principal whatever the list says: its
|
|
5452
|
+
// words carry whatever its own session was exposed to, which nothing
|
|
5453
|
+
// here can see (card 513).
|
|
5454
|
+
const senderTrust = admitted.fromAgent !== undefined ? 'unknown' : senderTrustFor(connection.config, userId);
|
|
4760
5455
|
// Who can read this, so the agent writes for them, and a thread says
|
|
4761
5456
|
// it is one. A DM names the other person only when they are one of
|
|
4762
5457
|
// the operator's principals: this lands in the system prompt, and
|
|
@@ -4889,6 +5584,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4889
5584
|
const queue = renderers.get(sessionId) ?? [];
|
|
4890
5585
|
queue.push(renderer);
|
|
4891
5586
|
renderers.set(sessionId, queue);
|
|
5587
|
+
// Stopped while its lookups ran: dispatched under an aborted signal,
|
|
5588
|
+
// so it ends the way a queued turn a stop reached does.
|
|
5589
|
+
const stopped = stopsBySession.get(sessionId);
|
|
5590
|
+
leaveIntake();
|
|
5591
|
+
if (stopped !== undefined && stopped.count !== stopsAtClaim && renderer.stoppedBy === undefined) {
|
|
5592
|
+
renderer.stoppedBy = stopped.by;
|
|
5593
|
+
renderer.controller.abort(new RunAbortedError(`Stopped from Slack by ${stopped.by}`));
|
|
5594
|
+
}
|
|
4892
5595
|
// In a DM each message is its own status key, and Slack stops showing
|
|
4893
5596
|
// the running turn's once a newer message sits below it — so a
|
|
4894
5597
|
// message queued there that stayed dark read as the agent having
|
|
@@ -4906,6 +5609,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4906
5609
|
...(earlier.length > 0 ? { earlier } : {}),
|
|
4907
5610
|
turnId: renderer.turnId,
|
|
4908
5611
|
metadata,
|
|
5612
|
+
signal: renderer.controller.signal,
|
|
4909
5613
|
// A redelivery this process no longer remembers (it restarted, or
|
|
4910
5614
|
// the dedupe evicted it) is still the message it was: the gateway
|
|
4911
5615
|
// runs no second turn for it, and says so through `onRepeat`.
|
|
@@ -4922,6 +5626,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4922
5626
|
});
|
|
4923
5627
|
return { renderer, turn, repeat: () => repeat, absorbed: () => absorbed };
|
|
4924
5628
|
});
|
|
5629
|
+
// Whatever way the intake ended, it is no longer in flight.
|
|
5630
|
+
void intake.then(leaveIntake, leaveIntake);
|
|
4925
5631
|
const started = await intake;
|
|
4926
5632
|
if (!started) {
|
|
4927
5633
|
return;
|
|
@@ -5058,7 +5764,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5058
5764
|
// the reader's next untagged message is for the lower of the two.
|
|
5059
5765
|
// Late, either way, for a message typed while the turn was still
|
|
5060
5766
|
// running, which the other may therefore still take.
|
|
5061
|
-
|
|
5767
|
+
// Under the owner rule (card 508) only two replies move the thread:
|
|
5768
|
+
// a judging agent that chose to speak, and the owner answering in
|
|
5769
|
+
// its own thread, which takes it back from a judge that spoke above.
|
|
5770
|
+
// An answer to a mention is that message's answer and no more.
|
|
5771
|
+
const takes = renderer.lazy || threadOwner(threadKey) === connection.config.agentId;
|
|
5772
|
+
rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts, takes);
|
|
5062
5773
|
}
|
|
5063
5774
|
// The reply just posted took the thread's status down with it, and a
|
|
5064
5775
|
// turn queued behind this one is still working.
|
|
@@ -5071,12 +5782,16 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5071
5782
|
// not a failure of the agent. Matched by name, as this package does
|
|
5072
5783
|
// not import the gateway. A turn nobody asked for stays silent.
|
|
5073
5784
|
const pausedReply = failure instanceof Error && failure.name === 'IntakePausedError';
|
|
5074
|
-
const { spoke, spokeAt } =
|
|
5075
|
-
? await renderer.
|
|
5076
|
-
:
|
|
5785
|
+
const { spoke, spokeAt } = renderer.stoppedBy !== undefined
|
|
5786
|
+
? await renderer.stopped(renderer.stoppedBy)
|
|
5787
|
+
: pausedReply
|
|
5788
|
+
? await renderer.finalize(renderer.lazy ? '' : failure.message)
|
|
5789
|
+
: await renderer.fail(failure instanceof Error ? failure.message : String(failure));
|
|
5077
5790
|
if (spoke && threadKey !== undefined) {
|
|
5078
|
-
// A file it posted before breaking
|
|
5079
|
-
|
|
5791
|
+
// A file it posted before breaking has still spoken, by the same
|
|
5792
|
+
// rule as a reply that landed.
|
|
5793
|
+
const takes = renderer.lazy || threadOwner(threadKey) === connection.config.agentId;
|
|
5794
|
+
rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts, takes);
|
|
5080
5795
|
}
|
|
5081
5796
|
renderers.get(sessionId)?.[0]?.refreshLoading();
|
|
5082
5797
|
}
|
|
@@ -5085,6 +5800,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5085
5800
|
name: 'slack',
|
|
5086
5801
|
resolveOutbound,
|
|
5087
5802
|
readConversation,
|
|
5803
|
+
// A channel id is unique only within its workspace, so a standing
|
|
5804
|
+
// grant on one is bound to the team the agent's app authenticated in.
|
|
5805
|
+
accountFor: (agentId) => botIdentities.get(agentId)?.teamId || undefined,
|
|
5088
5806
|
requestCredential,
|
|
5089
5807
|
async start(gateway) {
|
|
5090
5808
|
gatewayRef = gateway;
|