@stratusagent/channel-slack 0.12.4 → 0.12.6
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 -42
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +656 -194
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -27,6 +27,17 @@ const THREAD_ADDRESSEE_CAPACITY = 2000;
|
|
|
27
27
|
* same message, and the addressee record answers for every message after.
|
|
28
28
|
*/
|
|
29
29
|
const COLD_VERDICT_CAPACITY = 256;
|
|
30
|
+
/**
|
|
31
|
+
* How many of one person's mentions one thread remembers (card 541). Only
|
|
32
|
+
* has to span the lag between two sockets, like `THREAD_HANDOVER_DEPTH`.
|
|
33
|
+
*/
|
|
34
|
+
const SENDER_ADDRESSEE_DEPTH = 8;
|
|
35
|
+
/**
|
|
36
|
+
* How many people one thread remembers an addressee for. Past this the
|
|
37
|
+
* least recently heard is forgotten, and their next untagged reply goes to
|
|
38
|
+
* the thread's owner, as after a restart.
|
|
39
|
+
*/
|
|
40
|
+
const THREAD_SENDER_CAPACITY = 64;
|
|
30
41
|
/**
|
|
31
42
|
* How many claims on one thread it remembers.
|
|
32
43
|
*
|
|
@@ -69,17 +80,6 @@ const toolStatus = (toolName) => `is running ${toolName}…`;
|
|
|
69
80
|
* approval wait — is exactly the one whose status must not vanish.
|
|
70
81
|
*/
|
|
71
82
|
const STATUS_REFRESH_MS = 60_000;
|
|
72
|
-
/**
|
|
73
|
-
* How long an agent that judges stays attentive after it last answered a
|
|
74
|
-
* message that addressed it: this many messages, or this many minutes,
|
|
75
|
-
* whichever ends first. Judging costs
|
|
76
|
-
* a model call per message, and two people talking for an hour is a
|
|
77
|
-
* hundred of them for one "no" — so attention decays the way a person's
|
|
78
|
-
* does. Outside the window a message is overheard for free, and a mention
|
|
79
|
-
* re-arms it, which is what a mention has always meant.
|
|
80
|
-
*/
|
|
81
|
-
const ATTENTION_MESSAGES = 8;
|
|
82
|
-
const ATTENTION_MS = 15 * 60 * 1000;
|
|
83
83
|
/**
|
|
84
84
|
* A 5 MB image over a slow link is seconds, not minutes. Past this the
|
|
85
85
|
* downloads are abandoned and the turn runs with the attachments named as
|
|
@@ -2283,6 +2283,77 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2283
2283
|
* corrects it.
|
|
2284
2284
|
*/
|
|
2285
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();
|
|
2296
|
+
/**
|
|
2297
|
+
* Per thread, the agent each person last addressed by name, and when
|
|
2298
|
+
* (card 541). An untagged reply from that person is that agent's —
|
|
2299
|
+
* `@Bea your turn`, then `and?`, is Bea's — ahead of the thread's owner,
|
|
2300
|
+
* who keeps every reply from someone who has not addressed anyone else.
|
|
2301
|
+
* Recorded by every socket from the message itself, before any refusal,
|
|
2302
|
+
* as a handover is, so the sockets agree. Bounded like the addressee map;
|
|
2303
|
+
* a lost entry, or a restart, falls back to the owner.
|
|
2304
|
+
*/
|
|
2305
|
+
const senderAddressees = new Map();
|
|
2306
|
+
const rememberSenderAddressee = (key, sender, agentId, ts) => {
|
|
2307
|
+
const senders = senderAddressees.get(key) ?? new Map();
|
|
2308
|
+
// Kept in timestamp order, so a socket that is behind can still find the
|
|
2309
|
+
// mention that preceded the reply it is only now reaching. A late
|
|
2310
|
+
// delivery of an older mention slots in where it belongs.
|
|
2311
|
+
const history = (senders.get(sender) ?? []).filter((entry) => entry.ts !== ts);
|
|
2312
|
+
let at = history.length;
|
|
2313
|
+
while (at > 0 && history[at - 1].ts > ts) {
|
|
2314
|
+
at -= 1;
|
|
2315
|
+
}
|
|
2316
|
+
history.splice(at, 0, { agentId, ts });
|
|
2317
|
+
if (history.length > SENDER_ADDRESSEE_DEPTH) {
|
|
2318
|
+
history.splice(0, history.length - SENDER_ADDRESSEE_DEPTH);
|
|
2319
|
+
}
|
|
2320
|
+
// Most recently heard sender last, so the first key is the one to drop.
|
|
2321
|
+
senders.delete(sender);
|
|
2322
|
+
senders.set(sender, history);
|
|
2323
|
+
if (senders.size > THREAD_SENDER_CAPACITY) {
|
|
2324
|
+
const oldest = senders.keys().next();
|
|
2325
|
+
if (!oldest.done) {
|
|
2326
|
+
senders.delete(oldest.value);
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
senderAddressees.delete(key);
|
|
2330
|
+
senderAddressees.set(key, senders);
|
|
2331
|
+
if (senderAddressees.size > THREAD_ADDRESSEE_CAPACITY) {
|
|
2332
|
+
const oldest = senderAddressees.keys().next();
|
|
2333
|
+
if (!oldest.done) {
|
|
2334
|
+
senderAddressees.delete(oldest.value);
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
};
|
|
2338
|
+
/**
|
|
2339
|
+
* The agent `sender` last addressed in thread `key` before `ts`, if this
|
|
2340
|
+
* process saw it. A reply older than every mention still remembered for
|
|
2341
|
+
* that sender has nothing to go on, and falls back to the owner.
|
|
2342
|
+
*/
|
|
2343
|
+
const senderAddresseeAt = (key, sender, ts) => {
|
|
2344
|
+
const history = senderAddressees.get(key)?.get(sender);
|
|
2345
|
+
if (!history) {
|
|
2346
|
+
return undefined;
|
|
2347
|
+
}
|
|
2348
|
+
for (let i = history.length - 1; i >= 0; i -= 1) {
|
|
2349
|
+
if (history[i].ts < ts) {
|
|
2350
|
+
return history[i].agentId;
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
return undefined;
|
|
2354
|
+
};
|
|
2355
|
+
/** Whose an untagged reply from `sender` at `ts` is: who they last addressed, else the owner. */
|
|
2356
|
+
const replyTargetAt = (key, sender, ts) => senderAddresseeAt(key, sender, ts) ?? ownerAt(key, ts);
|
|
2286
2357
|
/**
|
|
2287
2358
|
* One cold verdict per message — see `followUpWinner`. Bounded, because
|
|
2288
2359
|
* an entry outlives its own resolution on purpose; an entry that has not
|
|
@@ -2803,69 +2874,6 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2803
2874
|
* nothing, which is every soul written before there was a choice.
|
|
2804
2875
|
*/
|
|
2805
2876
|
const listensOf = (agentId) => gatewayRef?.agents().find((agent) => agent.id === agentId)?.listens ?? 'thread';
|
|
2806
|
-
/**
|
|
2807
|
-
* Whether an agent that judges is still paying attention to a thread:
|
|
2808
|
-
* within `ATTENTION_MESSAGES` and `ATTENTION_MS` of when it last
|
|
2809
|
-
* answered a message that addressed it, both read from the session
|
|
2810
|
-
* rather than remembered here, so a restart forgets nothing. Anchored
|
|
2811
|
-
* to an addressed answer and not to any reply: a reply the agent chose
|
|
2812
|
-
* to give on a turn nobody asked for does not re-arm it, or a talkative
|
|
2813
|
-
* judge would keep itself attentive for good and cost a call per message
|
|
2814
|
-
* — the self-extension the spec declined. An agent never addressed in
|
|
2815
|
-
* the thread is not attentive: it was mentioned and its first turn has
|
|
2816
|
-
* not answered yet, or answered with nothing. Measured against the
|
|
2817
|
-
* message's own timestamp, not the clock, so a delivery that ran late is
|
|
2818
|
-
* judged as of when it was said. Bounded below as well as above: a
|
|
2819
|
-
* message said before the answer the window starts from is outside it
|
|
2820
|
-
* — Slack redelivers events a restarted daemon has no memory of, and a
|
|
2821
|
-
* negative age would otherwise pass the minutes bound however old the
|
|
2822
|
-
* message, on a turn the agent's answer already came after.
|
|
2823
|
-
*/
|
|
2824
|
-
const attentive = (routing, messageTs, pending) => {
|
|
2825
|
-
if (routing.lastAnsweredAt === undefined) {
|
|
2826
|
-
return false;
|
|
2827
|
-
}
|
|
2828
|
-
const answeredAt = Date.parse(routing.lastAnsweredAt);
|
|
2829
|
-
const saidAt = Number(messageTs) * 1000;
|
|
2830
|
-
if (Number.isNaN(answeredAt) || Number.isNaN(saidAt) || saidAt < answeredAt || saidAt - answeredAt > ATTENTION_MS) {
|
|
2831
|
-
return false;
|
|
2832
|
-
}
|
|
2833
|
-
return (routing.heardSinceAnswered ?? 0) + pending < ATTENTION_MESSAGES;
|
|
2834
|
-
};
|
|
2835
|
-
/**
|
|
2836
|
-
* Messages this adapter has dispatched to a session that the session
|
|
2837
|
-
* does not hold yet. The store counts what it holds, and a burst of
|
|
2838
|
-
* messages typed inside one second would each read the same count and
|
|
2839
|
-
* each be judged — the bound the window exists to give, blown by exactly
|
|
2840
|
-
* the thread that needs it. The renderer queue already knows: a renderer
|
|
2841
|
-
* is queued at dispatch and its turn starts (`turnStarted`) only once
|
|
2842
|
-
* the runner has saved the message — the same moment the store's count
|
|
2843
|
-
* takes it over — so the two never count one message twice.
|
|
2844
|
-
*/
|
|
2845
|
-
const pendingFor = (sessionId) => (renderers.get(sessionId) ?? []).filter((renderer) => !renderer.turnStarted).length
|
|
2846
|
-
+ (pendingObserves.get(sessionId) ?? 0);
|
|
2847
|
-
/**
|
|
2848
|
-
* Overhears placed on the gateway's chain and not yet written, per
|
|
2849
|
-
* session: the other kind of message the store does not hold yet. An
|
|
2850
|
-
* observe queued behind a running turn is heard only when the turn ends,
|
|
2851
|
-
* and a burst of messages to a colleague would otherwise count toward
|
|
2852
|
-
* nothing until then. Counted from placement to settlement.
|
|
2853
|
-
*/
|
|
2854
|
-
const pendingObserves = new Map();
|
|
2855
|
-
const placeObserve = (sessionId, observed) => {
|
|
2856
|
-
pendingObserves.set(sessionId, (pendingObserves.get(sessionId) ?? 0) + 1);
|
|
2857
|
-
const release = () => {
|
|
2858
|
-
const left = (pendingObserves.get(sessionId) ?? 1) - 1;
|
|
2859
|
-
if (left > 0) {
|
|
2860
|
-
pendingObserves.set(sessionId, left);
|
|
2861
|
-
}
|
|
2862
|
-
else {
|
|
2863
|
-
pendingObserves.delete(sessionId);
|
|
2864
|
-
}
|
|
2865
|
-
};
|
|
2866
|
-
void observed.then(release, release);
|
|
2867
|
-
return observed;
|
|
2868
|
-
};
|
|
2869
2877
|
/**
|
|
2870
2878
|
* Run `work` after everything already queued for this session's intake,
|
|
2871
2879
|
* and hold the next link until it settles. The link is claimed
|
|
@@ -2987,7 +2995,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2987
2995
|
// whoever installed it.
|
|
2988
2996
|
const author = await displayNameFor(hearer, speaker.botUserId);
|
|
2989
2997
|
const observed = gateway.observe?.({ sessionId, agentId: hearer.config.agentId, message: `${author}: ${text}`, metadata: { ...metadata, [SENDER_TRUST_METADATA_KEY]: trust } });
|
|
2990
|
-
return { observed
|
|
2998
|
+
return { observed };
|
|
2991
2999
|
});
|
|
2992
3000
|
await observed;
|
|
2993
3001
|
}
|
|
@@ -2996,6 +3004,73 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2996
3004
|
}
|
|
2997
3005
|
}));
|
|
2998
3006
|
};
|
|
3007
|
+
/**
|
|
3008
|
+
* An agent's own post into a thread, heard in its own session for that
|
|
3009
|
+
* thread (card 364). A scheduled report or a `message.send` reply is a
|
|
3010
|
+
* bot message, which `admit` drops on purpose, and backfill reads a
|
|
3011
|
+
* thread only when a session for it is first made, so without this an
|
|
3012
|
+
* agent already in the thread never knew it had posted there: a reply
|
|
3013
|
+
* under its report reached a session with the question and not the
|
|
3014
|
+
* report. Through `observe`, so it starts no turn and lands only in a
|
|
3015
|
+
* session that already exists; a thread the agent was never in is read
|
|
3016
|
+
* by backfill when somebody first replies there.
|
|
3017
|
+
*
|
|
3018
|
+
* Labelled as the agent's own post, at the label the SENDING session's
|
|
3019
|
+
* words carry (`speaker.trust`, `sessionWriteTrust` of that session):
|
|
3020
|
+
* a report written by a firing that read a web page carries what that
|
|
3021
|
+
* page said, so it is heard at `external`, and one written from `user`
|
|
3022
|
+
* and `agent` context is heard at `agent`. Never higher, so hearing its
|
|
3023
|
+
* own report cannot launder anything into the thread. A post nobody
|
|
3024
|
+
* vouches for (no speaker) is not heard at all, rather than guessed at.
|
|
3025
|
+
*
|
|
3026
|
+
* Not when the sender IS that thread's session: that turn already holds
|
|
3027
|
+
* the call and what it said. Claimed on the intake chain BEFORE the
|
|
3028
|
+
* first chunk goes to Slack, so a reply typed under the report, even
|
|
3029
|
+
* while a long one is still being sent, is heard after it. `posted`
|
|
3030
|
+
* settles with what Slack accepted: all of it, the chunks before one it
|
|
3031
|
+
* refused (they are in the thread, so they are heard), or nothing. Not
|
|
3032
|
+
* awaited by `post`, only by the shutdown drain, so a long turn running
|
|
3033
|
+
* in the thread never holds up the sender, and two threads posting into
|
|
3034
|
+
* each other cannot wait on each other.
|
|
3035
|
+
*/
|
|
3036
|
+
const hearOwnPost = (connection, channel, thread, posted, speaker) => {
|
|
3037
|
+
const gateway = gatewayRef;
|
|
3038
|
+
if (!gateway?.observe) {
|
|
3039
|
+
return;
|
|
3040
|
+
}
|
|
3041
|
+
const agentId = connection.config.agentId;
|
|
3042
|
+
const team = connection.teamId;
|
|
3043
|
+
const sessionId = channelSessionKey({ channel: 'slack', agentId, team, conversation: channel, thread });
|
|
3044
|
+
if (sessionId === speaker.sessionId) {
|
|
3045
|
+
return;
|
|
3046
|
+
}
|
|
3047
|
+
const metadata = {
|
|
3048
|
+
channel: 'slack',
|
|
3049
|
+
team,
|
|
3050
|
+
slackChannel: channel,
|
|
3051
|
+
slackUser: connection.botUserId,
|
|
3052
|
+
slackThread: thread,
|
|
3053
|
+
[SENDER_TRUST_METADATA_KEY]: leastTrusted('agent', speaker.trust),
|
|
3054
|
+
};
|
|
3055
|
+
const heard = onIntakeChain(sessionId, async () => {
|
|
3056
|
+
const text = await posted;
|
|
3057
|
+
if (text === undefined) {
|
|
3058
|
+
return { observed: undefined };
|
|
3059
|
+
}
|
|
3060
|
+
const author = await displayNameFor(connection, connection.botUserId);
|
|
3061
|
+
const observed = gateway.observe?.({ sessionId, agentId, message: `${author} (your own post, sent from another session): ${text}`, metadata });
|
|
3062
|
+
return { observed };
|
|
3063
|
+
});
|
|
3064
|
+
// Tracked, so stop() drains it: a restart right after a report must not
|
|
3065
|
+
// close the stores while the thread's session is still to hear it.
|
|
3066
|
+
track(heard
|
|
3067
|
+
.then(async ({ observed }) => {
|
|
3068
|
+
await observed;
|
|
3069
|
+
})
|
|
3070
|
+
.catch((error) => {
|
|
3071
|
+
warn(`slack: ${agentId} could not hear its own post in ${channel} thread ${thread}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3072
|
+
}));
|
|
3073
|
+
};
|
|
2999
3074
|
/**
|
|
3000
3075
|
* The addressable outbound seam — the channel contract's first real
|
|
3001
3076
|
* `OutboundConnection` implementation. Validate-then-hand-over: every
|
|
@@ -3041,16 +3116,31 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3041
3116
|
// A reply under a message rather than a post beside it. Every chunk of
|
|
3042
3117
|
// a long one goes to the same thread, so a split reply stays together.
|
|
3043
3118
|
const thread = address.thread?.trim() || undefined;
|
|
3119
|
+
const speaker = address.speaker;
|
|
3044
3120
|
return {
|
|
3045
3121
|
async post(text) {
|
|
3046
3122
|
const chunks = messageChunks(text.trim().length > 0 ? text : '(empty message)');
|
|
3123
|
+
// Heard in the thread's session (card 364): its place is claimed
|
|
3124
|
+
// now, before Slack shows anything, and filled once we know what
|
|
3125
|
+
// Slack accepted.
|
|
3126
|
+
let accepted = () => { };
|
|
3127
|
+
if (thread && speaker && text.trim().length > 0) {
|
|
3128
|
+
hearOwnPost(connection, channelId, thread, new Promise((resolve) => { accepted = resolve; }), speaker);
|
|
3129
|
+
}
|
|
3047
3130
|
let first;
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3131
|
+
let sent = 0;
|
|
3132
|
+
try {
|
|
3133
|
+
for (const chunk of chunks) {
|
|
3134
|
+
const posted = await web.chat.postMessage({ channel: channelId, text: chunk, ...(thread ? { thread_ts: thread } : {}) });
|
|
3135
|
+
sent += 1;
|
|
3136
|
+
if (!first) {
|
|
3137
|
+
first = { channel: posted.channel ?? channelId, ts: posted.ts ?? '' };
|
|
3138
|
+
}
|
|
3052
3139
|
}
|
|
3053
3140
|
}
|
|
3141
|
+
finally {
|
|
3142
|
+
accepted(sent === chunks.length ? text : sent > 0 ? chunks.slice(0, sent).join('\n') : undefined);
|
|
3143
|
+
}
|
|
3054
3144
|
return first ?? { channel: channelId, ts: '' };
|
|
3055
3145
|
},
|
|
3056
3146
|
async edit(ref, text) {
|
|
@@ -3069,6 +3159,55 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3069
3159
|
},
|
|
3070
3160
|
};
|
|
3071
3161
|
};
|
|
3162
|
+
/**
|
|
3163
|
+
* Channel or DM, as this agent's app sees a conversation, for the
|
|
3164
|
+
* operator's standing channel authorization (`approvals.channels`), which
|
|
3165
|
+
* covers channels only. Asked of Slack (`conversations.info`), never read
|
|
3166
|
+
* off the id: a `G…` id is a private channel or a group DM depending on
|
|
3167
|
+
* its age. A definite answer is kept, since a conversation never turns
|
|
3168
|
+
* from one into the other; a failed lookup is not, and reads as unknown,
|
|
3169
|
+
* so the call asks.
|
|
3170
|
+
*/
|
|
3171
|
+
const conversationKinds = new Map();
|
|
3172
|
+
const CONVERSATION_KIND_CACHE_LIMIT = 1000;
|
|
3173
|
+
const conversationKindFor = async (agentId, conversation) => {
|
|
3174
|
+
const id = conversation.trim();
|
|
3175
|
+
const connection = connectionFor(agentId);
|
|
3176
|
+
if (!id || !connection) {
|
|
3177
|
+
return undefined;
|
|
3178
|
+
}
|
|
3179
|
+
const key = `${agentId}:${botIdentities.get(agentId)?.teamId ?? ''}:${id}`;
|
|
3180
|
+
const known = conversationKinds.get(key);
|
|
3181
|
+
if (known !== undefined) {
|
|
3182
|
+
return known;
|
|
3183
|
+
}
|
|
3184
|
+
let info;
|
|
3185
|
+
try {
|
|
3186
|
+
info = (await connection.web.conversations.info({ channel: id })).channel;
|
|
3187
|
+
}
|
|
3188
|
+
catch {
|
|
3189
|
+
return undefined;
|
|
3190
|
+
}
|
|
3191
|
+
if (!info?.id) {
|
|
3192
|
+
return undefined;
|
|
3193
|
+
}
|
|
3194
|
+
// A DM flag wins. Otherwise a channel only when Slack says so, by a
|
|
3195
|
+
// channel flag or by stating it is not a DM: a reply with none of
|
|
3196
|
+
// them is no proof of a channel.
|
|
3197
|
+
const kind = info.is_im === true || info.is_mpim === true
|
|
3198
|
+
? 'direct'
|
|
3199
|
+
: info.is_channel === true || info.is_group === true || (info.is_im === false && info.is_mpim === false) ? 'channel' : undefined;
|
|
3200
|
+
if (kind !== undefined) {
|
|
3201
|
+
if (conversationKinds.size >= CONVERSATION_KIND_CACHE_LIMIT) {
|
|
3202
|
+
const oldest = conversationKinds.keys().next().value;
|
|
3203
|
+
if (oldest !== undefined) {
|
|
3204
|
+
conversationKinds.delete(oldest);
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
conversationKinds.set(key, kind);
|
|
3208
|
+
}
|
|
3209
|
+
return kind;
|
|
3210
|
+
};
|
|
3072
3211
|
/**
|
|
3073
3212
|
* The read side of the same boundary `resolveOutbound` draws: a channel
|
|
3074
3213
|
* the agent's own app is a member of, public or private. DMs and group
|
|
@@ -3242,8 +3381,21 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3242
3381
|
const sameChannel = metadata.channel === 'slack' && typeof metadata.slackChannel === 'string'
|
|
3243
3382
|
? metadata.slackChannel
|
|
3244
3383
|
: undefined;
|
|
3245
|
-
|
|
3246
|
-
|
|
3384
|
+
// A scheduled firing has no conversation of its own. With no approval
|
|
3385
|
+
// channel configured it used to be denied at its first gated call, seen
|
|
3386
|
+
// only in the daemon log (card 218). Its schedule's destination is a
|
|
3387
|
+
// conversation a person approved as where it speaks, and only approvers
|
|
3388
|
+
// can answer there, so that is where it is asked. A configured approval
|
|
3389
|
+
// channel still wins: the operator chose it for exactly this. Another
|
|
3390
|
+
// channel's destination is not a Slack conversation id.
|
|
3391
|
+
const scheduled = event.scheduleDestination?.channel.trim().toLowerCase() === 'slack'
|
|
3392
|
+
? event.scheduleDestination
|
|
3393
|
+
: undefined;
|
|
3394
|
+
const viaSchedule = !sameChannel && !connection.config.approvalChannel ? scheduled : undefined;
|
|
3395
|
+
const channel = sameChannel ?? connection.config.approvalChannel ?? viaSchedule?.to;
|
|
3396
|
+
const thread = sameChannel
|
|
3397
|
+
? (typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined)
|
|
3398
|
+
: viaSchedule?.thread;
|
|
3247
3399
|
// Undeliverable, not decided: nobody was asked, so filing this next to
|
|
3248
3400
|
// the denials somebody actually made would make the audit record lie
|
|
3249
3401
|
// about which is which.
|
|
@@ -3977,6 +4129,34 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3977
4129
|
};
|
|
3978
4130
|
/** One thread, whichever agent is talking in it. */
|
|
3979
4131
|
const threadKeyFor = (parts) => `${parts.team}:${parts.conversation}:${parts.thread}`;
|
|
4132
|
+
/**
|
|
4133
|
+
* A thread's claims cut to `THREAD_HANDOVER_DEPTH`, oldest first out, but
|
|
4134
|
+
* never the two `ownerAt` reads past the rest, so at most one over: the
|
|
4135
|
+
* first claim, which is who the thread belongs to, and the latest claim
|
|
4136
|
+
* that took the thread.
|
|
4137
|
+
* Dropping that one would hand the thread back to its owner after enough
|
|
4138
|
+
* mentions of a third agent, while the judge that took it still answers.
|
|
4139
|
+
*/
|
|
4140
|
+
const trimHandovers = (handovers) => {
|
|
4141
|
+
if (handovers.length <= THREAD_HANDOVER_DEPTH) {
|
|
4142
|
+
return handovers;
|
|
4143
|
+
}
|
|
4144
|
+
const first = handovers[0];
|
|
4145
|
+
let latestTake = -1;
|
|
4146
|
+
for (let index = handovers.length - 1; index > 0; index -= 1) {
|
|
4147
|
+
if (handovers[index]?.takes === true) {
|
|
4148
|
+
latestTake = index;
|
|
4149
|
+
break;
|
|
4150
|
+
}
|
|
4151
|
+
}
|
|
4152
|
+
const tailFrom = handovers.length - (THREAD_HANDOVER_DEPTH - 1);
|
|
4153
|
+
// The kept take sits on top of the full recent tail, one past the depth,
|
|
4154
|
+
// so `holderAt` still sees every recent claim it saw before.
|
|
4155
|
+
const tail = handovers.slice(tailFrom);
|
|
4156
|
+
return latestTake > 0 && latestTake < tailFrom
|
|
4157
|
+
? [first, handovers[latestTake], ...tail]
|
|
4158
|
+
: [first, ...tail];
|
|
4159
|
+
};
|
|
3980
4160
|
/**
|
|
3981
4161
|
* Records that `agentId` holds the thread from the message at `ts`
|
|
3982
4162
|
* onwards, wherever in the thread's history that message falls.
|
|
@@ -3989,7 +4169,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
3989
4169
|
* later insertion would take the thread from a reply that sits below it.
|
|
3990
4170
|
* Only the same claim twice — the same message reaching a second
|
|
3991
4171
|
* connection — is dropped.
|
|
3992
|
-
* The oldest claims are dropped past `THREAD_HANDOVER_DEPTH
|
|
4172
|
+
* The oldest claims are dropped past `THREAD_HANDOVER_DEPTH` (but not
|
|
4173
|
+
* the first or the latest that took the thread: `trimHandovers`), and the
|
|
3993
4174
|
* least recently written thread past `THREAD_ADDRESSEE_CAPACITY`; both
|
|
3994
4175
|
* losses cost a lookup, never an answer, since the sessions still hold
|
|
3995
4176
|
* the durable version.
|
|
@@ -4003,7 +4184,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4003
4184
|
* Slack timestamps compare as strings: `seconds.microseconds`, both
|
|
4004
4185
|
* parts fixed width, so lexicographic order is chronological order.
|
|
4005
4186
|
*/
|
|
4006
|
-
const rememberAddressee = (key, agentId, ts) => {
|
|
4187
|
+
const rememberAddressee = (key, agentId, ts, takes = false, cold = false) => {
|
|
4007
4188
|
const handovers = threadAddressee.get(key) ?? [];
|
|
4008
4189
|
let at = handovers.length;
|
|
4009
4190
|
while (at > 0 && handovers[at - 1].ts > ts) {
|
|
@@ -4011,17 +4192,28 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4011
4192
|
}
|
|
4012
4193
|
const before = handovers[at - 1];
|
|
4013
4194
|
if (before && before.agentId === agentId && before.ts === ts) {
|
|
4195
|
+
if (takes) {
|
|
4196
|
+
before.takes = true;
|
|
4197
|
+
}
|
|
4198
|
+
// Seen live as well: no longer only a reconstruction.
|
|
4199
|
+
if (!cold) {
|
|
4200
|
+
delete before.cold;
|
|
4201
|
+
}
|
|
4014
4202
|
return;
|
|
4015
4203
|
}
|
|
4016
|
-
handovers.splice(at, 0, { agentId, ts });
|
|
4204
|
+
handovers.splice(at, 0, { agentId, ts, ...(takes ? { takes } : {}), ...(cold ? { cold } : {}) });
|
|
4017
4205
|
// Re-inserted rather than mutated in place: a Map iterates in insertion
|
|
4018
4206
|
// order, which is what makes the first key the one to evict.
|
|
4019
4207
|
threadAddressee.delete(key);
|
|
4020
|
-
threadAddressee.set(key, handovers
|
|
4208
|
+
threadAddressee.set(key, trimHandovers(handovers));
|
|
4021
4209
|
if (threadAddressee.size > THREAD_ADDRESSEE_CAPACITY) {
|
|
4022
4210
|
const oldest = threadAddressee.keys().next();
|
|
4023
4211
|
if (!oldest.done) {
|
|
4024
4212
|
threadAddressee.delete(oldest.value);
|
|
4213
|
+
// The owner goes with the record of who took the thread since: an
|
|
4214
|
+
// owner kept alone would stand in for a judge's takeover it no
|
|
4215
|
+
// longer knows of, where the thread read would see it.
|
|
4216
|
+
threadOrigins.delete(oldest.value);
|
|
4025
4217
|
}
|
|
4026
4218
|
}
|
|
4027
4219
|
};
|
|
@@ -4030,19 +4222,122 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4030
4222
|
* handover at or before it. Undefined when this process has no handover
|
|
4031
4223
|
* that old, which sends the question to the sessions.
|
|
4032
4224
|
*/
|
|
4225
|
+
/**
|
|
4226
|
+
* Whose a stop at `ts` is, from the live record: who was asked last as
|
|
4227
|
+
* of `ts` (`holderAt`). Undefined when the record cannot say, either
|
|
4228
|
+
* because this process has seen nobody asked here by `ts`, or because the
|
|
4229
|
+
* newest entry by then was rebuilt from a thread read (card 508): a
|
|
4230
|
+
* mention since would not be among them. The stop is then left to a
|
|
4231
|
+
* fresh read of the thread, never sent to the owner on a guess.
|
|
4232
|
+
*/
|
|
4233
|
+
const stopHolderAt = (key, ts) => holderAt(key, ts);
|
|
4033
4234
|
const holderAt = (key, ts) => {
|
|
4034
4235
|
const handovers = threadAddressee.get(key);
|
|
4035
4236
|
if (!handovers) {
|
|
4036
4237
|
return undefined;
|
|
4037
4238
|
}
|
|
4239
|
+
// The newest entry at or before `ts` answers, and only if this process
|
|
4240
|
+
// saw it live. A claim rebuilt from a thread read is a post; who was
|
|
4241
|
+
// asked since may be a mention it cannot see, so it leaves the question
|
|
4242
|
+
// to a fresh read. And it is newer than every live entry before it, so
|
|
4243
|
+
// skipping it for one of those would answer with a claim it superseded.
|
|
4038
4244
|
for (let index = handovers.length - 1; index >= 0; index -= 1) {
|
|
4039
4245
|
const handover = handovers[index];
|
|
4040
4246
|
if (handover && handover.ts <= ts) {
|
|
4041
|
-
return handover.agentId;
|
|
4247
|
+
return handover.cold === true ? undefined : handover.agentId;
|
|
4042
4248
|
}
|
|
4043
4249
|
}
|
|
4044
4250
|
return undefined;
|
|
4045
4251
|
};
|
|
4252
|
+
/**
|
|
4253
|
+
* Who an untagged reply at `ts` belongs to (card 508): the agent the
|
|
4254
|
+
* thread belongs to, not whoever spoke last. That is the first to hold
|
|
4255
|
+
* it — the agent its first message named, a home channel's owner, or the
|
|
4256
|
+
* bot (this daemon's or another's) that posted it — unless a judging
|
|
4257
|
+
* agent has since chosen to speak, which takes it (`takes`). A mention
|
|
4258
|
+
* of another agent gets that agent's answer to that message and nothing
|
|
4259
|
+
* more: the thread stays where it was, and so does every reply after.
|
|
4260
|
+
* In a thread that began before this process did, and that the thread
|
|
4261
|
+
* read has not placed yet, the first claim this process SAW stands in
|
|
4262
|
+
* for the owner: the best it has, and no worse than the last-speaker
|
|
4263
|
+
* rule it replaces. Undefined when this process has seen no claim at
|
|
4264
|
+
* all, which sends the question to the sessions and the thread.
|
|
4265
|
+
*/
|
|
4266
|
+
const ownerAt = (key, ts) => {
|
|
4267
|
+
const origin = threadOrigins.get(key);
|
|
4268
|
+
const handovers = threadAddressee.get(key) ?? [];
|
|
4269
|
+
for (let index = handovers.length - 1; index >= 0; index -= 1) {
|
|
4270
|
+
const handover = handovers[index];
|
|
4271
|
+
if (handover && handover.ts <= ts && (handover.takes === true || (index === 0 && typeof origin !== 'string'))) {
|
|
4272
|
+
return handover.agentId;
|
|
4273
|
+
}
|
|
4274
|
+
}
|
|
4275
|
+
return typeof origin === 'string' ? origin : undefined;
|
|
4276
|
+
};
|
|
4277
|
+
/**
|
|
4278
|
+
* The agent the thread `key` belongs to, whoever holds it now: the owner
|
|
4279
|
+
* the thread read settled, or else the first claim this process saw.
|
|
4280
|
+
* Answering in its own thread takes the thread back from a judge that
|
|
4281
|
+
* spoke since (card 508), so the owner is asked for here, not the holder.
|
|
4282
|
+
*/
|
|
4283
|
+
const threadOwner = (key) => {
|
|
4284
|
+
const origin = threadOrigins.get(key);
|
|
4285
|
+
return typeof origin === 'string' ? origin : threadAddressee.get(key)?.[0]?.agentId;
|
|
4286
|
+
};
|
|
4287
|
+
/** Records that this process saw the thread `key` begin, or who owns it. */
|
|
4288
|
+
const noteOrigin = (key, owner = true) => {
|
|
4289
|
+
if (owner === true && threadOrigins.has(key)) {
|
|
4290
|
+
return;
|
|
4291
|
+
}
|
|
4292
|
+
threadOrigins.delete(key);
|
|
4293
|
+
threadOrigins.set(key, owner);
|
|
4294
|
+
if (threadOrigins.size > THREAD_ADDRESSEE_CAPACITY) {
|
|
4295
|
+
const oldest = threadOrigins.keys().next();
|
|
4296
|
+
if (!oldest.done) {
|
|
4297
|
+
threadOrigins.delete(oldest.value);
|
|
4298
|
+
}
|
|
4299
|
+
}
|
|
4300
|
+
};
|
|
4301
|
+
/**
|
|
4302
|
+
* A message that starts a thread, seen by any connection: from here on
|
|
4303
|
+
* this process sees the thread's first claim, so it knows the owner. One
|
|
4304
|
+
* this daemon's own agent posted (a schedule's delivery, a message.send)
|
|
4305
|
+
* is that agent's thread.
|
|
4306
|
+
*/
|
|
4307
|
+
const noteThreadStart = (connection, args) => {
|
|
4308
|
+
const event = args.event;
|
|
4309
|
+
if (!event || (event.type !== 'message' && event.type !== 'app_mention') || event.channel_type === 'im') {
|
|
4310
|
+
return;
|
|
4311
|
+
}
|
|
4312
|
+
if (event.thread_ts !== undefined && event.thread_ts !== event.ts) {
|
|
4313
|
+
return;
|
|
4314
|
+
}
|
|
4315
|
+
if (event.subtype !== undefined && event.subtype !== 'bot_message' && !HUMAN_MESSAGE_SUBTYPES.has(event.subtype)) {
|
|
4316
|
+
return;
|
|
4317
|
+
}
|
|
4318
|
+
const team = args.body?.team_id ?? connection.teamId;
|
|
4319
|
+
const key = threadKeyFor({ team, conversation: event.channel, thread: event.ts });
|
|
4320
|
+
if (threadOrigins.has(key)) {
|
|
4321
|
+
return;
|
|
4322
|
+
}
|
|
4323
|
+
noteOrigin(key);
|
|
4324
|
+
if (event.user !== undefined) {
|
|
4325
|
+
for (const [agentId, identity] of botIdentities) {
|
|
4326
|
+
if (identity.teamId === team && identity.botUserId === event.user) {
|
|
4327
|
+
rememberAddressee(key, agentId, event.ts);
|
|
4328
|
+
}
|
|
4329
|
+
}
|
|
4330
|
+
}
|
|
4331
|
+
// A team agent on another gateway that starts a thread owns it, even
|
|
4332
|
+
// when its first message asks an agent here something: that agent
|
|
4333
|
+
// answers the question, and the thread stays the asker's. Recorded
|
|
4334
|
+
// here, ahead of the answer, since `noteForeignSpeaker` leaves out a
|
|
4335
|
+
// bot's question to an agent here.
|
|
4336
|
+
const bot = event.bot_id !== undefined || event.subtype === 'bot_message';
|
|
4337
|
+
if (bot && event.user !== undefined && !isFleetBot(team, event.user) && !startsThreadAsIntegration(team, event.user, event.thread_ts, event.ts)) {
|
|
4338
|
+
rememberAddressee(key, `${FOREIGN_SPEAKER_PREFIX}${event.user}`, event.ts);
|
|
4339
|
+
}
|
|
4340
|
+
};
|
|
4046
4341
|
/**
|
|
4047
4342
|
* Whether `user` is one of this adapter's own agents in `team`. Their
|
|
4048
4343
|
* replies reach the holder record through `ReplyOutcome.spokeAt`, and
|
|
@@ -4056,6 +4351,24 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4056
4351
|
* held it. Used live and when a thread is read back after a restart.
|
|
4057
4352
|
*/
|
|
4058
4353
|
const asksAgentHere = (team, asker, text) => asker !== undefined && [...botIdentities].some(([agentId, identity]) => identity.teamId === team && mentions(text ?? '', identity.botUserId) && answersAgentMention(agentId, team, asker));
|
|
4354
|
+
/**
|
|
4355
|
+
* Whether a bot message is another app starting a thread — an alert, a
|
|
4356
|
+
* deploy note — rather than an agent (card 508). That starts the thread
|
|
4357
|
+
* without owning it, so an agent named under it keeps the conversation it
|
|
4358
|
+
* was given. A team agent (`agentMentions.team`) starting one owns it the
|
|
4359
|
+
* way an agent here does, and any app replying inside a thread counts as
|
|
4360
|
+
* a speaker there, since an agent on a gateway nobody listed looks like
|
|
4361
|
+
* any other app.
|
|
4362
|
+
*/
|
|
4363
|
+
const startsThreadAsIntegration = (team, user, threadTs, ts) => {
|
|
4364
|
+
if (threadTs !== undefined && threadTs !== ts) {
|
|
4365
|
+
return false;
|
|
4366
|
+
}
|
|
4367
|
+
return user === undefined || ![...botIdentities].some(([agentId, identity]) => {
|
|
4368
|
+
const config = agentConfigFor(agentId);
|
|
4369
|
+
return config !== undefined && teamAgentFor(config, identity.teamId, team, user) !== undefined;
|
|
4370
|
+
});
|
|
4371
|
+
};
|
|
4059
4372
|
/**
|
|
4060
4373
|
* Records a bot this adapter does not serve as the thread's holder from
|
|
4061
4374
|
* the message it posted. An agent on another gateway answers in the same
|
|
@@ -4087,6 +4400,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4087
4400
|
if (asksAgentHere(team, event.user, event.text)) {
|
|
4088
4401
|
return;
|
|
4089
4402
|
}
|
|
4403
|
+
if (startsThreadAsIntegration(team, event.user, event.thread_ts, event.ts)) {
|
|
4404
|
+
return;
|
|
4405
|
+
}
|
|
4090
4406
|
const thread = event.thread_ts ?? event.ts;
|
|
4091
4407
|
rememberAddressee(threadKeyFor({ team, conversation: event.channel, thread }), `${FOREIGN_SPEAKER_PREFIX}${speaker}`, event.ts);
|
|
4092
4408
|
};
|
|
@@ -4119,16 +4435,30 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4119
4435
|
const botUserId = botIdentities.get(candidate.agentId)?.botUserId;
|
|
4120
4436
|
if (botUserId !== undefined) {
|
|
4121
4437
|
byBotUser.set(botUserId, candidate.agentId);
|
|
4122
|
-
held.set(candidate.agentId,
|
|
4438
|
+
held.set(candidate.agentId, Number.POSITIVE_INFINITY);
|
|
4123
4439
|
continue;
|
|
4124
4440
|
}
|
|
4125
4441
|
const spoke = candidate.lastSpokeAt === undefined ? Number.NaN : Date.parse(candidate.lastSpokeAt) / 1000;
|
|
4126
|
-
held.set(candidate.agentId, Number.isFinite(spoke) ? spoke :
|
|
4442
|
+
held.set(candidate.agentId, Number.isFinite(spoke) ? spoke : Number.POSITIVE_INFINITY);
|
|
4443
|
+
}
|
|
4444
|
+
// Where each last held it, for a stop, which is for whoever was asked
|
|
4445
|
+
// last (card 337), not for the thread's owner.
|
|
4446
|
+
const last = new Map();
|
|
4447
|
+
for (const [agentId, at] of held) {
|
|
4448
|
+
last.set(agentId, Number.isFinite(at) ? at : 0);
|
|
4127
4449
|
}
|
|
4128
4450
|
const place = (agentId, at) => {
|
|
4129
|
-
held.set(agentId, Math.
|
|
4451
|
+
held.set(agentId, Math.min(held.get(agentId) ?? Number.POSITIVE_INFINITY, at));
|
|
4452
|
+
last.set(agentId, Math.max(last.get(agentId) ?? 0, at));
|
|
4130
4453
|
};
|
|
4131
|
-
let foreign =
|
|
4454
|
+
let foreign = Number.POSITIVE_INFINITY;
|
|
4455
|
+
let lastForeign = 0;
|
|
4456
|
+
// Each of these agents' own messages, and whether the message before it
|
|
4457
|
+
// had asked it anything: one nobody asked for is a judge taking the
|
|
4458
|
+
// thread, or its owner answering in it (card 508).
|
|
4459
|
+
const posts = [];
|
|
4460
|
+
let asked = new Set();
|
|
4461
|
+
const askedIn = (text) => new Set([...byBotUser].filter(([botUserId]) => mentions(text ?? '', botUserId)).map(([, agentId]) => agentId));
|
|
4132
4462
|
try {
|
|
4133
4463
|
let cursor;
|
|
4134
4464
|
let pages = 0;
|
|
@@ -4150,14 +4480,35 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4150
4480
|
const own = message.user === undefined ? undefined : byBotUser.get(message.user);
|
|
4151
4481
|
if (own !== undefined) {
|
|
4152
4482
|
place(own, at);
|
|
4483
|
+
posts.push({ agentId: own, at, ts: message.ts, asked: asked.has(own) });
|
|
4484
|
+
// An agent here naming a colleague that answers its mentions
|
|
4485
|
+
// asks it, as `admitAgentMention` has it live, so the answer is
|
|
4486
|
+
// not a judge speaking unasked.
|
|
4487
|
+
const sender = message.user;
|
|
4488
|
+
const named = sender === undefined
|
|
4489
|
+
? []
|
|
4490
|
+
: [...askedIn(message.text)].filter((agentId) => agentId !== own && answersAgentMention(agentId, parts.team, sender));
|
|
4491
|
+
if (named.length > 0) {
|
|
4492
|
+
asked = new Set(named);
|
|
4493
|
+
}
|
|
4153
4494
|
continue;
|
|
4154
4495
|
}
|
|
4155
4496
|
const bot = message.bot_id !== undefined || message.subtype === 'bot_message';
|
|
4156
4497
|
if (bot) {
|
|
4157
4498
|
// A bot's mention hands nothing over live (admit ignores bots),
|
|
4158
4499
|
// so here it is only another app speaking, or a peer's words.
|
|
4159
|
-
|
|
4160
|
-
|
|
4500
|
+
// A team agent's question to an agent here is not it taking the
|
|
4501
|
+
// thread, unless it started the thread with it: then the thread
|
|
4502
|
+
// is its own, as it is live.
|
|
4503
|
+
const asks = asksAgentHere(parts.team, message.user, message.text);
|
|
4504
|
+
if (asks) {
|
|
4505
|
+
asked = askedIn(message.text);
|
|
4506
|
+
}
|
|
4507
|
+
if (!isFleetBot(parts.team, message.user) &&
|
|
4508
|
+
(!asks || message.ts === parts.thread) &&
|
|
4509
|
+
!startsThreadAsIntegration(parts.team, message.user, parts.thread, message.ts ?? '')) {
|
|
4510
|
+
foreign = Math.min(foreign, at);
|
|
4511
|
+
lastForeign = Math.max(lastForeign, at);
|
|
4161
4512
|
}
|
|
4162
4513
|
continue;
|
|
4163
4514
|
}
|
|
@@ -4167,10 +4518,19 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4167
4518
|
if (sender === undefined) {
|
|
4168
4519
|
continue;
|
|
4169
4520
|
}
|
|
4521
|
+
asked = askedIn(message.text);
|
|
4170
4522
|
const named = agentNamedIn(message.text ?? '', parts.team, (agentId) => admitsSender(agentConfigFor(agentId), sender));
|
|
4171
4523
|
if (named !== undefined && held.has(named) && botIdentities.get(named)?.botUserId !== undefined) {
|
|
4172
4524
|
place(named, at);
|
|
4173
4525
|
}
|
|
4526
|
+
// A root that names nobody in a home channel is its owner's, as
|
|
4527
|
+
// the live home-channel rule has it.
|
|
4528
|
+
const home = message.ts === parts.thread && named === undefined
|
|
4529
|
+
? homeOwners.get(`${parts.team}:${parts.conversation}`)
|
|
4530
|
+
: undefined;
|
|
4531
|
+
if (home !== undefined && held.has(home) && admitsSender(agentConfigFor(home), sender)) {
|
|
4532
|
+
place(home, at);
|
|
4533
|
+
}
|
|
4174
4534
|
}
|
|
4175
4535
|
cursor = page.response_metadata?.next_cursor || undefined;
|
|
4176
4536
|
} while (cursor && pages < EARLIER_THREAD_PAGES);
|
|
@@ -4182,12 +4542,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4182
4542
|
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)}`);
|
|
4183
4543
|
return undefined;
|
|
4184
4544
|
}
|
|
4185
|
-
return { held, foreign };
|
|
4186
|
-
};
|
|
4187
|
-
/** Whether another app spoke in the thread after `agentId` last held it. */
|
|
4188
|
-
const foreignSpokeSince = async (agentId, parts, ts, lastSpokeAt) => {
|
|
4189
|
-
const read = await threadPositions([{ agentId, ...(lastSpokeAt !== undefined ? { lastSpokeAt } : {}) }], parts, ts);
|
|
4190
|
-
return read !== undefined && read.foreign > (read.held.get(agentId) ?? 0);
|
|
4545
|
+
return { held, last, foreign, lastForeign, posts };
|
|
4191
4546
|
};
|
|
4192
4547
|
/**
|
|
4193
4548
|
* Which of this process's agents a message names, or undefined if it
|
|
@@ -4253,25 +4608,19 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4253
4608
|
* restart forgets nothing.
|
|
4254
4609
|
*
|
|
4255
4610
|
* Whose turn it is, is the second. In a thread with several agents an
|
|
4256
|
-
* unaddressed reply belongs to
|
|
4257
|
-
*
|
|
4258
|
-
*
|
|
4259
|
-
* agent last *replied*, not when its session last changed,
|
|
4260
|
-
* saves on tool results and approval checkpoints without
|
|
4261
|
-
*
|
|
4262
|
-
* window.
|
|
4611
|
+
* unaddressed reply belongs to the agent the thread belongs to (card
|
|
4612
|
+
* 508): the first to hold it, read off the thread itself. Only when the
|
|
4613
|
+
* thread can't be read do the sessions decide, and then by `lastSpokeAt`
|
|
4614
|
+
* — when each agent last *replied*, not when its session last changed,
|
|
4615
|
+
* because a turn saves on tool results and approval checkpoints without
|
|
4616
|
+
* having said anything — since that is the only order they keep.
|
|
4263
4617
|
*
|
|
4264
4618
|
* Nobody is the answer only when the agents cannot be *ordered* — nothing
|
|
4265
|
-
* in the thread has spoken
|
|
4266
|
-
*
|
|
4267
|
-
*
|
|
4268
|
-
* would put two answers under one message. An agent that simply has not
|
|
4269
|
-
* spoken is a different thing and is skipped rather than fatal: it cannot
|
|
4270
|
-
* have spoken last, and letting it silence the agent that did would leave
|
|
4271
|
-
* a reply nobody answers. The agent alone in its thread, which is nearly
|
|
4272
|
-
* every thread, is answered without the question arising.
|
|
4619
|
+
* in the thread has spoken, or two tie — because guessing there would put
|
|
4620
|
+
* two answers under one message. The agent alone in its thread, which is
|
|
4621
|
+
* nearly every thread, is answered without the question arising.
|
|
4273
4622
|
*/
|
|
4274
|
-
const resolveFollowUpWinner = async (parts, ts) => {
|
|
4623
|
+
const resolveFollowUpWinner = async (parts, ts, mode = 'reply') => {
|
|
4275
4624
|
const gateway = gatewayRef;
|
|
4276
4625
|
if (!gateway?.sessionRouting || !parts.thread) {
|
|
4277
4626
|
return undefined;
|
|
@@ -4349,22 +4698,131 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4349
4698
|
}
|
|
4350
4699
|
for (const [agentId, identity] of botIdentities) {
|
|
4351
4700
|
if (identity.teamId === parts.team && identity.botUserId === parts.parentUser) {
|
|
4352
|
-
|
|
4701
|
+
noteOrigin(threadKeyFor(parts), agentId);
|
|
4702
|
+
return agentId;
|
|
4353
4703
|
}
|
|
4354
4704
|
}
|
|
4355
4705
|
return undefined;
|
|
4356
4706
|
}
|
|
4357
|
-
|
|
4358
|
-
|
|
4707
|
+
// The thread says who it belongs to: whichever of these agents held it
|
|
4708
|
+
// first — its first message named it, or it posted there first — or
|
|
4709
|
+
// another app that spoke before any of them did (card 508). When it
|
|
4710
|
+
// can't be read, the sessions decide: the only agent in it, or, as a
|
|
4711
|
+
// last resort, the one that spoke last, which is all they record.
|
|
4712
|
+
// The agent that posted the root takes part even with no session here:
|
|
4713
|
+
// a post made through message.send or a schedule leaves none, and a
|
|
4714
|
+
// colleague named under it since must not look like the owner.
|
|
4715
|
+
const candidates = [...engaged];
|
|
4716
|
+
if (parts.parentUser !== undefined) {
|
|
4717
|
+
for (const [agentId, identity] of botIdentities) {
|
|
4718
|
+
if (identity.teamId === parts.team && identity.botUserId === parts.parentUser && !candidates.some((entry) => entry.agentId === agentId)) {
|
|
4719
|
+
candidates.push({ agentId });
|
|
4720
|
+
}
|
|
4721
|
+
}
|
|
4722
|
+
}
|
|
4723
|
+
const read = await threadPositions(candidates, parts, ts);
|
|
4724
|
+
if (!read) {
|
|
4725
|
+
return engaged.length === 1 ? first.agentId : lastSpeaker(engaged);
|
|
4726
|
+
}
|
|
4727
|
+
if (mode === 'stop') {
|
|
4728
|
+
// A stop is for whoever was asked last (card 337), as `holderAt` has
|
|
4729
|
+
// it live: the turn it means is that agent's, not the owner's.
|
|
4730
|
+
let holder;
|
|
4731
|
+
let tiedLast = false;
|
|
4732
|
+
for (const entry of candidates) {
|
|
4733
|
+
const at = read.last.get(entry.agentId) ?? 0;
|
|
4734
|
+
if (at <= 0) {
|
|
4735
|
+
continue;
|
|
4736
|
+
}
|
|
4737
|
+
if (!holder || at > holder.at) {
|
|
4738
|
+
holder = { agentId: entry.agentId, at };
|
|
4739
|
+
tiedLast = false;
|
|
4740
|
+
}
|
|
4741
|
+
else if (at === holder.at) {
|
|
4742
|
+
tiedLast = true;
|
|
4743
|
+
}
|
|
4744
|
+
}
|
|
4745
|
+
if (!holder) {
|
|
4746
|
+
return read.lastForeign > 0 ? undefined : (engaged.length === 1 ? first.agentId : lastSpeaker(engaged));
|
|
4747
|
+
}
|
|
4748
|
+
return tiedLast || read.lastForeign > holder.at ? undefined : holder.agentId;
|
|
4749
|
+
}
|
|
4750
|
+
let owner;
|
|
4751
|
+
let tiedAt = false;
|
|
4752
|
+
for (const entry of candidates) {
|
|
4753
|
+
const at = read.held.get(entry.agentId) ?? Number.POSITIVE_INFINITY;
|
|
4754
|
+
if (!Number.isFinite(at) || at <= 0) {
|
|
4755
|
+
continue;
|
|
4756
|
+
}
|
|
4757
|
+
if (!owner || at < owner.at) {
|
|
4758
|
+
owner = { agentId: entry.agentId, at };
|
|
4759
|
+
tiedAt = false;
|
|
4760
|
+
}
|
|
4761
|
+
else if (at === owner.at) {
|
|
4762
|
+
tiedAt = true;
|
|
4763
|
+
}
|
|
4764
|
+
}
|
|
4765
|
+
// A judging agent that spoke unasked took the thread, and the owner
|
|
4766
|
+
// answering in it took it back, as live (card 508): the latest of those
|
|
4767
|
+
// holds it. The live record of who took it is lost on a restart; the
|
|
4768
|
+
// thread keeps it. Only a judge takes by speaking unasked, as only its
|
|
4769
|
+
// turns are judged live; another agent's post there (a message.send)
|
|
4770
|
+
// takes nothing.
|
|
4771
|
+
const ownerId = owner && !tiedAt && !(read.foreign < owner.at) ? owner.agentId : undefined;
|
|
4772
|
+
let taken;
|
|
4773
|
+
const takeovers = [];
|
|
4774
|
+
for (const post of read.posts) {
|
|
4775
|
+
const takes = post.agentId === ownerId || (!post.asked && listensOf(post.agentId) === 'judge');
|
|
4776
|
+
if (takes) {
|
|
4777
|
+
takeovers.push(post);
|
|
4778
|
+
if (!taken || post.at > taken.at) {
|
|
4779
|
+
taken = post;
|
|
4780
|
+
}
|
|
4781
|
+
}
|
|
4359
4782
|
}
|
|
4783
|
+
// Once the owner is known, the live record learns what the thread read
|
|
4784
|
+
// found, as it would have live: who owns the thread, and every post
|
|
4785
|
+
// that moved it, where it sits. With only the verdict, a judge's reply
|
|
4786
|
+
// to this message became the first claim this process saw and read as
|
|
4787
|
+
// the owner, so the real owner answering a mention could not take the
|
|
4788
|
+
// thread back (Codex 4290051968). Every take rather than the latest, so
|
|
4789
|
+
// a message delivered late is placed where it was typed, and the judge's
|
|
4790
|
+
// take at its own post, so a reply typed before its answer lands still
|
|
4791
|
+
// goes to the judge, as this verdict says.
|
|
4792
|
+
const warm = () => {
|
|
4793
|
+
if (ownerId === undefined) {
|
|
4794
|
+
return;
|
|
4795
|
+
}
|
|
4796
|
+
const key = threadKeyFor(parts);
|
|
4797
|
+
noteOrigin(key, ownerId);
|
|
4798
|
+
for (const post of takeovers) {
|
|
4799
|
+
rememberAddressee(key, post.agentId, post.ts, true, true);
|
|
4800
|
+
}
|
|
4801
|
+
};
|
|
4802
|
+
if (taken && taken.agentId !== ownerId && (ownerId !== undefined || taken.at > read.foreign)) {
|
|
4803
|
+
warm();
|
|
4804
|
+
return taken.agentId;
|
|
4805
|
+
}
|
|
4806
|
+
if (!owner) {
|
|
4807
|
+
return Number.isFinite(read.foreign) ? undefined : (engaged.length === 1 ? first.agentId : lastSpeaker(engaged));
|
|
4808
|
+
}
|
|
4809
|
+
if (ownerId === undefined) {
|
|
4810
|
+
return undefined;
|
|
4811
|
+
}
|
|
4812
|
+
warm();
|
|
4813
|
+
return ownerId;
|
|
4814
|
+
};
|
|
4815
|
+
/**
|
|
4816
|
+
* Of several agents in a thread, the one whose session says it spoke
|
|
4817
|
+
* last — the sessions' only ordering, used when the thread itself can't
|
|
4818
|
+
* be read. Undefined when nobody has spoken or the newest two tie, since
|
|
4819
|
+
* guessing there would put two answers under one message.
|
|
4820
|
+
*/
|
|
4821
|
+
const lastSpeaker = (engaged) => {
|
|
4360
4822
|
let latest;
|
|
4361
4823
|
let tied = false;
|
|
4362
4824
|
for (const entry of engaged) {
|
|
4363
4825
|
if (entry.lastSpokeAt === undefined) {
|
|
4364
|
-
// In the thread, but has never said anything in it — mentioned and
|
|
4365
|
-
// still on its first turn, or a turn that produced no text. It
|
|
4366
|
-
// cannot be the one who spoke last, and it must not stop the agent
|
|
4367
|
-
// that did from answering.
|
|
4368
4826
|
continue;
|
|
4369
4827
|
}
|
|
4370
4828
|
if (!latest || entry.lastSpokeAt > latest.lastSpokeAt) {
|
|
@@ -4375,34 +4833,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4375
4833
|
tied = true;
|
|
4376
4834
|
}
|
|
4377
4835
|
}
|
|
4378
|
-
|
|
4379
|
-
// Sessions date a reply by when it finished, the thread by where its
|
|
4380
|
-
// placeholder went up, and an agent on another gateway may have spoken
|
|
4381
|
-
// since: only the thread can say who holds it. When it can't be read,
|
|
4382
|
-
// the sessions decide, as before.
|
|
4383
|
-
const read = await threadPositions(engaged, parts, ts);
|
|
4384
|
-
if (!read) {
|
|
4385
|
-
return bySession;
|
|
4386
|
-
}
|
|
4387
|
-
let holder;
|
|
4388
|
-
let tiedAt = false;
|
|
4389
|
-
for (const entry of engaged) {
|
|
4390
|
-
const at = read.held.get(entry.agentId) ?? 0;
|
|
4391
|
-
if (at <= 0) {
|
|
4392
|
-
continue;
|
|
4393
|
-
}
|
|
4394
|
-
if (!holder || at > holder.at) {
|
|
4395
|
-
holder = { agentId: entry.agentId, at };
|
|
4396
|
-
tiedAt = false;
|
|
4397
|
-
}
|
|
4398
|
-
else if (at === holder.at) {
|
|
4399
|
-
tiedAt = true;
|
|
4400
|
-
}
|
|
4401
|
-
}
|
|
4402
|
-
if (!holder) {
|
|
4403
|
-
return read.foreign > 0 ? undefined : bySession;
|
|
4404
|
-
}
|
|
4405
|
-
return tiedAt || read.foreign > holder.at ? undefined : holder.agentId;
|
|
4836
|
+
return tied ? undefined : latest?.agentId;
|
|
4406
4837
|
};
|
|
4407
4838
|
/**
|
|
4408
4839
|
* `resolveFollowUpWinner`, resolved ONCE PER MESSAGE and shared by every
|
|
@@ -4422,13 +4853,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4422
4853
|
* this after the winner has finished, and recomputing for that agent is
|
|
4423
4854
|
* precisely the disagreement the memo exists to prevent.
|
|
4424
4855
|
*/
|
|
4425
|
-
const followUpWinner = (parts, ts) => {
|
|
4426
|
-
|
|
4856
|
+
const followUpWinner = (parts, ts, mode = 'reply') => {
|
|
4857
|
+
// A stop asks a different question (whoever was asked last), so it
|
|
4858
|
+
// gets its own verdict, the same for every agent that asks it.
|
|
4859
|
+
const key = `${threadKeyFor(parts)}:${ts}${mode === 'stop' ? ':stop' : ''}`;
|
|
4427
4860
|
const existing = coldVerdicts.get(key);
|
|
4428
4861
|
if (existing) {
|
|
4429
4862
|
return existing.verdict;
|
|
4430
4863
|
}
|
|
4431
|
-
const entry = { verdict: resolveFollowUpWinner(parts, ts), settled: false };
|
|
4864
|
+
const entry = { verdict: resolveFollowUpWinner(parts, ts, mode), settled: false };
|
|
4432
4865
|
coldVerdicts.set(key, entry);
|
|
4433
4866
|
void entry.verdict.then(() => {
|
|
4434
4867
|
entry.settled = true;
|
|
@@ -4625,6 +5058,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4625
5058
|
const admit = (connection, args) => {
|
|
4626
5059
|
const event = args.event;
|
|
4627
5060
|
if (gatewayRef) {
|
|
5061
|
+
noteThreadStart(connection, args);
|
|
4628
5062
|
noteForeignSpeaker(connection, args);
|
|
4629
5063
|
}
|
|
4630
5064
|
if (gatewayRef && event && (event.bot_id !== undefined || event.subtype === 'bot_message')) {
|
|
@@ -4716,6 +5150,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4716
5150
|
// Ava's transcript; only the map learns who holds the thread.
|
|
4717
5151
|
if (threadKey && handoverTo) {
|
|
4718
5152
|
rememberAddressee(threadKey, handoverTo, event.ts);
|
|
5153
|
+
rememberSenderAddressee(threadKey, sender, handoverTo, event.ts);
|
|
4719
5154
|
}
|
|
4720
5155
|
// A home-channel message hands its new thread to the channel's owner
|
|
4721
5156
|
// the way a mention would, and is recorded by every socket for the same
|
|
@@ -4772,7 +5207,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4772
5207
|
return undefined;
|
|
4773
5208
|
}
|
|
4774
5209
|
const refusal = `slack: ${connection.config.agentId} refused a message from ${sender}: not a listed principal, and admit is "principals"`;
|
|
4775
|
-
|
|
5210
|
+
// Whose message this is, as for an admitted one: the thread's owner's
|
|
5211
|
+
// (card 508), or for a stop, whoever was asked last (card 337).
|
|
5212
|
+
const refusedStop = (connection.config.approvers ?? []).includes(sender) && isStopRequest(text, connection.botUserId);
|
|
5213
|
+
const held = threadKey === undefined
|
|
5214
|
+
? undefined
|
|
5215
|
+
: refusedStop ? stopHolderAt(threadKey, event.ts) : replyTargetAt(threadKey, sender, event.ts);
|
|
4776
5216
|
if (addressed || held === connection.config.agentId) {
|
|
4777
5217
|
if (!alreadySeen(eventKey)) {
|
|
4778
5218
|
log(refusal);
|
|
@@ -4791,7 +5231,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4791
5231
|
conversation: event.channel,
|
|
4792
5232
|
thread,
|
|
4793
5233
|
...(event.parent_user_id !== undefined ? { parentUser: event.parent_user_id } : {}),
|
|
4794
|
-
}, event.ts).then((winner) => {
|
|
5234
|
+
}, event.ts, refusedStop ? 'stop' : 'reply').then((winner) => {
|
|
4795
5235
|
if (winner === connection.config.agentId) {
|
|
4796
5236
|
log(refusal);
|
|
4797
5237
|
}
|
|
@@ -4860,12 +5300,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4860
5300
|
return { ...admission, overhear: true };
|
|
4861
5301
|
}
|
|
4862
5302
|
// An untagged reply. How this agent listens decides what it is: an
|
|
4863
|
-
// agent that answers only mentions hears it and nothing more
|
|
4864
|
-
//
|
|
4865
|
-
//
|
|
4866
|
-
//
|
|
4867
|
-
//
|
|
4868
|
-
//
|
|
5303
|
+
// agent that answers only mentions hears it and nothing more. One that
|
|
5304
|
+
// judges takes a turn nobody asked for on it — one it may stay quiet
|
|
5305
|
+
// on — when the reply is its own by the rule every agent shares (card
|
|
5306
|
+
// 541): the agent this person last addressed, else the thread's owner.
|
|
5307
|
+
// No window: an owner an hour past its last answer still hears an
|
|
5308
|
+
// instruction as its own, and exactly one agent takes each reply.
|
|
4869
5309
|
const listens = listensOf(connection.config.agentId);
|
|
4870
5310
|
if (listens === 'mentions' || listens === 'judge') {
|
|
4871
5311
|
// Except a stop (card 337): the holder rule decides whose it is, as
|
|
@@ -4876,21 +5316,39 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4876
5316
|
(connection.config.approvers ?? []).includes(sender) &&
|
|
4877
5317
|
isStopRequest(text, connection.botUserId);
|
|
4878
5318
|
const stopHolder = approverStop ? holderAt(threadKey, event.ts) : undefined;
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
|
-
|
|
5319
|
+
if (listens === 'judge' && !approverStop && threadKey !== undefined) {
|
|
5320
|
+
const target = replyTargetAt(threadKey, sender, event.ts);
|
|
5321
|
+
if (target === undefined) {
|
|
5322
|
+
// Nothing seen here says whose thread this is: the sessions and
|
|
5323
|
+
// the thread read decide, as for any agent.
|
|
5324
|
+
return { ...admission, overhear: true, judge: true, settled: false, judgeReason: 'owner, from the thread read' };
|
|
5325
|
+
}
|
|
5326
|
+
if (target !== connection.config.agentId) {
|
|
5327
|
+
return { ...admission, overhear: true, judgeReason: `${target}'s reply` };
|
|
5328
|
+
}
|
|
5329
|
+
const reason = senderAddresseeAt(threadKey, sender, event.ts) === target ? 'addressed it last' : 'owner';
|
|
5330
|
+
return { ...admission, overhear: true, judge: true, judgeReason: reason };
|
|
5331
|
+
}
|
|
5332
|
+
// An approver's stop is never judged (card 541): it stops the turn of
|
|
5333
|
+
// whoever holds the thread, and with nothing to stop it is only heard.
|
|
4882
5334
|
return {
|
|
4883
5335
|
...admission,
|
|
4884
5336
|
overhear: true,
|
|
4885
|
-
...(listens === 'judge' && !stopForAnother ? { judge: true } : {}),
|
|
4886
5337
|
...(stopHolder === connection.config.agentId ? { holderStop: true } : {}),
|
|
4887
5338
|
...(approverStop && stopHolder === undefined ? { settled: false } : {}),
|
|
4888
5339
|
};
|
|
4889
5340
|
}
|
|
4890
5341
|
// The record is what this process has actually seen, in the order it
|
|
4891
5342
|
// saw it — right even inside the window before a handover reaches the
|
|
4892
|
-
// store.
|
|
4893
|
-
|
|
5343
|
+
// store. Whose an untagged reply is, is the thread's owner's (card
|
|
5344
|
+
// 508), not the last speaker's — except a stop, which is for whoever
|
|
5345
|
+
// was asked last, since that is the turn it means (card 337).
|
|
5346
|
+
const stopRequest = threadKey !== undefined &&
|
|
5347
|
+
(connection.config.approvers ?? []).includes(sender) &&
|
|
5348
|
+
isStopRequest(text, connection.botUserId);
|
|
5349
|
+
const holder = threadKey === undefined
|
|
5350
|
+
? undefined
|
|
5351
|
+
: stopRequest ? stopHolderAt(threadKey, event.ts) : replyTargetAt(threadKey, sender, event.ts);
|
|
4894
5352
|
if (holder !== undefined && threadKey !== undefined) {
|
|
4895
5353
|
if (holder !== connection.config.agentId) {
|
|
4896
5354
|
// Standing down for the holder, whether or not the holder can still
|
|
@@ -4994,39 +5452,25 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4994
5452
|
// not to be this agent's leaves an empty link behind, which is all it
|
|
4995
5453
|
// should cost.
|
|
4996
5454
|
let overhear = admitted.overhear;
|
|
4997
|
-
// Judged, not ruled
|
|
4998
|
-
//
|
|
4999
|
-
//
|
|
5000
|
-
// there, and the message is heard rather than judged, which is the
|
|
5001
|
-
// safe side. A host that cannot say leaves the agent hearing.
|
|
5455
|
+
// Judged, not ruled (card 541): the intake already placed this reply
|
|
5456
|
+
// with this agent, so it runs a turn nobody asked for, which may stay
|
|
5457
|
+
// quiet. A reply only the sessions can place waits for them below.
|
|
5002
5458
|
let judged = false;
|
|
5003
|
-
if (admitted.judge &&
|
|
5004
|
-
|
|
5005
|
-
|
|
5006
|
-
// other way round, and a window that runs one message short in a
|
|
5007
|
-
// race is a bound kept; one that runs a message long is not.
|
|
5008
|
-
const pending = pendingFor(sessionId);
|
|
5009
|
-
let routing;
|
|
5010
|
-
try {
|
|
5011
|
-
routing = await gateway.sessionRouting(sessionId);
|
|
5012
|
-
}
|
|
5013
|
-
catch (error) {
|
|
5014
|
-
warn(`slack: could not read whether ${connection.config.agentId} is attentive in ${event.channel}: ${error instanceof Error ? error.message : String(error)}`);
|
|
5015
|
-
}
|
|
5016
|
-
if (routing && attentive(routing, event.ts, pending)) {
|
|
5017
|
-
judged = true;
|
|
5018
|
-
overhear = false;
|
|
5019
|
-
}
|
|
5459
|
+
if (admitted.judge && admitted.settled) {
|
|
5460
|
+
judged = true;
|
|
5461
|
+
overhear = false;
|
|
5020
5462
|
}
|
|
5021
5463
|
if (!admitted.settled) {
|
|
5022
5464
|
// One verdict for this message, shared with whichever other agents
|
|
5023
5465
|
// are asking about it — see `followUpWinner`.
|
|
5466
|
+
const stop = (connection.config.approvers ?? []).includes(userId) &&
|
|
5467
|
+
isStopRequest(event.text ?? '', connection.botUserId);
|
|
5024
5468
|
const winner = await followUpWinner({
|
|
5025
5469
|
team,
|
|
5026
5470
|
conversation: event.channel,
|
|
5027
5471
|
thread: event.thread_ts ?? '',
|
|
5028
5472
|
...(event.parent_user_id !== undefined ? { parentUser: event.parent_user_id } : {}),
|
|
5029
|
-
}, event.ts);
|
|
5473
|
+
}, event.ts, stop ? 'stop' : 'reply');
|
|
5030
5474
|
if (winner !== connection.config.agentId) {
|
|
5031
5475
|
// Not this agent's to answer — and, if it is in the thread,
|
|
5032
5476
|
// this agent's to hear, same as the two warm stand-downs. Only a
|
|
@@ -5036,6 +5480,10 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5036
5480
|
overhear = true;
|
|
5037
5481
|
}
|
|
5038
5482
|
else {
|
|
5483
|
+
if (admitted.judge) {
|
|
5484
|
+
judged = true;
|
|
5485
|
+
overhear = false;
|
|
5486
|
+
}
|
|
5039
5487
|
if (threadKey) {
|
|
5040
5488
|
// Answered from the store rather than from memory: hold it, so
|
|
5041
5489
|
// the rest of the thread is ordered by receipt like any other.
|
|
@@ -5051,6 +5499,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5051
5499
|
}
|
|
5052
5500
|
}
|
|
5053
5501
|
}
|
|
5502
|
+
// One line per decision a judging agent makes on an untagged reply
|
|
5503
|
+
// (card 541): whether it took the turn, and why. Never the text.
|
|
5504
|
+
if (admitted.judgeReason !== undefined) {
|
|
5505
|
+
const reason = admitted.settled || judged ? admitted.judgeReason : 'another agent\'s, from the thread read';
|
|
5506
|
+
log(`slack: judge: ${connection.config.agentId} ${judged ? 'took' : 'heard'} ${event.ts} in ${event.channel} (${reason})`);
|
|
5507
|
+
}
|
|
5054
5508
|
const cleaned = await humanizeMentions(connection, event.text ?? '');
|
|
5055
5509
|
// Fetched inside the chain, like the lookups below: it is network
|
|
5056
5510
|
// I/O, and a message whose screenshot is slow to arrive must not lose
|
|
@@ -5154,7 +5608,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5154
5608
|
// an overhear queued behind a long turn would hold the next
|
|
5155
5609
|
// message's place in this chain hostage to that turn.
|
|
5156
5610
|
return {
|
|
5157
|
-
observed:
|
|
5611
|
+
observed: gateway.observe({ sessionId, agentId: connection.config.agentId, message: userMessage, metadata }),
|
|
5158
5612
|
};
|
|
5159
5613
|
}
|
|
5160
5614
|
// A turn nobody asked for opens its placeholder on its first text,
|
|
@@ -5418,7 +5872,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5418
5872
|
// the reader's next untagged message is for the lower of the two.
|
|
5419
5873
|
// Late, either way, for a message typed while the turn was still
|
|
5420
5874
|
// running, which the other may therefore still take.
|
|
5421
|
-
|
|
5875
|
+
// Under the owner rule (card 508) only two replies move the thread:
|
|
5876
|
+
// a judging agent that chose to speak, and the owner answering in
|
|
5877
|
+
// its own thread, which takes it back from a judge that spoke above.
|
|
5878
|
+
// An answer to a mention is that message's answer and no more.
|
|
5879
|
+
const takes = renderer.lazy || threadOwner(threadKey) === connection.config.agentId;
|
|
5880
|
+
rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts, takes);
|
|
5422
5881
|
}
|
|
5423
5882
|
// The reply just posted took the thread's status down with it, and a
|
|
5424
5883
|
// turn queued behind this one is still working.
|
|
@@ -5437,8 +5896,10 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5437
5896
|
? await renderer.finalize(renderer.lazy ? '' : failure.message)
|
|
5438
5897
|
: await renderer.fail(failure instanceof Error ? failure.message : String(failure));
|
|
5439
5898
|
if (spoke && threadKey !== undefined) {
|
|
5440
|
-
// A file it posted before breaking
|
|
5441
|
-
|
|
5899
|
+
// A file it posted before breaking has still spoken, by the same
|
|
5900
|
+
// rule as a reply that landed.
|
|
5901
|
+
const takes = renderer.lazy || threadOwner(threadKey) === connection.config.agentId;
|
|
5902
|
+
rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts, takes);
|
|
5442
5903
|
}
|
|
5443
5904
|
renderers.get(sessionId)?.[0]?.refreshLoading();
|
|
5444
5905
|
}
|
|
@@ -5450,6 +5911,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5450
5911
|
// A channel id is unique only within its workspace, so a standing
|
|
5451
5912
|
// grant on one is bound to the team the agent's app authenticated in.
|
|
5452
5913
|
accountFor: (agentId) => botIdentities.get(agentId)?.teamId || undefined,
|
|
5914
|
+
conversationKindFor,
|
|
5453
5915
|
requestCredential,
|
|
5454
5916
|
async start(gateway) {
|
|
5455
5917
|
gatewayRef = gateway;
|
|
@@ -5665,8 +6127,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
5665
6127
|
connections.length = 0;
|
|
5666
6128
|
approvalPosts.clear();
|
|
5667
6129
|
threadAddressee.clear();
|
|
6130
|
+
senderAddressees.clear();
|
|
5668
6131
|
coldVerdicts.clear();
|
|
5669
|
-
pendingObserves.clear();
|
|
5670
6132
|
botIdentities.clear();
|
|
5671
6133
|
homeOwners.clear();
|
|
5672
6134
|
rendering.clear();
|