@stratusagent/channel-slack 0.10.0 → 0.11.1
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 +85 -3
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1353 -109
- package/dist/index.js.map +1 -1
- package/manifest/stratus-agent.manifest.json +7 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,11 +1,52 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { readFile } from 'node:fs/promises';
|
|
2
3
|
import { createRequire } from 'node:module';
|
|
3
4
|
import path from 'node:path';
|
|
5
|
+
import { latestTurnReply } from '@stratusagent/core';
|
|
4
6
|
import { channelSessionKey, } from '@stratusagent/channels';
|
|
5
7
|
const SLACK_MAX_MESSAGE_CHARS = 4000;
|
|
6
8
|
const DEFAULT_EDIT_INTERVAL_MS = 1000;
|
|
7
9
|
const DEDUPE_CAPACITY = 2000;
|
|
10
|
+
/**
|
|
11
|
+
* How many threads' addressees are remembered in receipt order. Past this
|
|
12
|
+
* the least recently addressed is dropped, which costs its next follow-up
|
|
13
|
+
* one session lookup — the durable answer, which is what a restart falls
|
|
14
|
+
* back to anyway.
|
|
15
|
+
*/
|
|
16
|
+
const THREAD_ADDRESSEE_CAPACITY = 2000;
|
|
17
|
+
/**
|
|
18
|
+
* How many cold verdicts are remembered. Far smaller than the addressee
|
|
19
|
+
* map: one is wanted only while the agents sharing a thread resolve the
|
|
20
|
+
* same message, and the addressee record answers for every message after.
|
|
21
|
+
*/
|
|
22
|
+
const COLD_VERDICT_CAPACITY = 256;
|
|
23
|
+
/**
|
|
24
|
+
* How many changes of hands one thread remembers.
|
|
25
|
+
*
|
|
26
|
+
* A message older than all of them asks the sessions instead, which answer
|
|
27
|
+
* for the thread as it stands rather than as it stood — so a reply that old
|
|
28
|
+
* can be resolved to the agent holding the thread now. That is the cost of
|
|
29
|
+
* bounding this, and something has to bound it: the alternative is a list
|
|
30
|
+
* that grows with a conversation for the life of the daemon.
|
|
31
|
+
*
|
|
32
|
+
* Eight is chosen to sit far past the case it covers. This only has to
|
|
33
|
+
* span the lag between two sockets, which is messages inside one delivery
|
|
34
|
+
* window; crossing it needs a single thread to change hands eight times —
|
|
35
|
+
* eight mentions, alternating between agents — while one socket stays
|
|
36
|
+
* behind for all of them.
|
|
37
|
+
*/
|
|
38
|
+
const THREAD_HANDOVER_DEPTH = 8;
|
|
39
|
+
/**
|
|
40
|
+
* How many files an unrendered turn may queue for its outcome. Nothing
|
|
41
|
+
* drains that queue until the outcome arrives, so a session whose outcome
|
|
42
|
+
* this process never sees would otherwise grow it for the life of the
|
|
43
|
+
* daemon. Twenty is well past what one turn produces in practice, and
|
|
44
|
+
* passing it is reported rather than swallowed.
|
|
45
|
+
*/
|
|
46
|
+
const MAX_UNRENDERED_FILES = 20;
|
|
8
47
|
const PLACEHOLDER_TEXT = '…';
|
|
48
|
+
/** What a turn that produced no text puts in its message, wherever it is posted from. */
|
|
49
|
+
const NO_REPLY_TEXT = '(no reply)';
|
|
9
50
|
// Lazy (and CJS-interoperable) so tests with injected fakes never load the
|
|
10
51
|
// real SDK stack.
|
|
11
52
|
const requireModule = createRequire(import.meta.url);
|
|
@@ -24,6 +65,22 @@ const defaultWebClient = (botToken) => {
|
|
|
24
65
|
* (split across messages when it outgrows one).
|
|
25
66
|
*/
|
|
26
67
|
class ReplyRenderer {
|
|
68
|
+
/**
|
|
69
|
+
* Whether the gateway has begun the turn this renderer was opened for.
|
|
70
|
+
* A renderer is queued at intake, before the gateway starts its turn,
|
|
71
|
+
* so until the session reports `running` again the turn at the head of
|
|
72
|
+
* the session's chain may be somebody else's — a recovery still parked
|
|
73
|
+
* on a human — and its completion is not this renderer's to swallow.
|
|
74
|
+
*/
|
|
75
|
+
turnStarted = false;
|
|
76
|
+
/**
|
|
77
|
+
* The id this renderer's turn is dispatched under. A gateway that reports
|
|
78
|
+
* its active turn (`activeTurnId`) lets the adapter match an outcome to
|
|
79
|
+
* this renderer exactly, rather than to whichever turn reported `running`
|
|
80
|
+
* next — which, for a session another surface can also dispatch to, may
|
|
81
|
+
* not be this one.
|
|
82
|
+
*/
|
|
83
|
+
turnId = randomUUID();
|
|
27
84
|
buffer = '';
|
|
28
85
|
turnBreakPending = false;
|
|
29
86
|
toolLine;
|
|
@@ -32,6 +89,19 @@ class ReplyRenderer {
|
|
|
32
89
|
pendingEdit;
|
|
33
90
|
editChain = Promise.resolve();
|
|
34
91
|
uploadChain = Promise.resolve();
|
|
92
|
+
/**
|
|
93
|
+
* A handover in progress (`yieldTo`): edits and the finalize wait for it,
|
|
94
|
+
* so a turn that finishes while its placeholder is being given away
|
|
95
|
+
* writes into the fresh one rather than over the reply it was given to.
|
|
96
|
+
*/
|
|
97
|
+
handover = Promise.resolve();
|
|
98
|
+
/**
|
|
99
|
+
* Bumped by a handover. An edit scheduled before it carries the text of
|
|
100
|
+
* the turn that was handed over — the recovery's stream, routed to this
|
|
101
|
+
* renderer while it was at the head of the queue — and must not land in
|
|
102
|
+
* the placeholder opened for this turn.
|
|
103
|
+
*/
|
|
104
|
+
generation = 0;
|
|
35
105
|
finalized = false;
|
|
36
106
|
web;
|
|
37
107
|
channel;
|
|
@@ -45,6 +115,24 @@ class ReplyRenderer {
|
|
|
45
115
|
this.editIntervalMs = editIntervalMs;
|
|
46
116
|
this.warn = warn;
|
|
47
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* The gateway has begun this renderer's turn. Whatever was streamed here
|
|
120
|
+
* before now belonged to the turn ahead of it on the session chain — a
|
|
121
|
+
* recovery's stream, routed to this renderer because it was at the head
|
|
122
|
+
* of the queue — and is dropped, edits already scheduled for it
|
|
123
|
+
* included, so this turn's placeholder starts empty.
|
|
124
|
+
*/
|
|
125
|
+
beginTurn() {
|
|
126
|
+
this.turnStarted = true;
|
|
127
|
+
if (this.pendingEdit) {
|
|
128
|
+
clearTimeout(this.pendingEdit);
|
|
129
|
+
this.pendingEdit = undefined;
|
|
130
|
+
}
|
|
131
|
+
this.buffer = '';
|
|
132
|
+
this.toolLine = undefined;
|
|
133
|
+
this.turnBreakPending = false;
|
|
134
|
+
this.generation += 1;
|
|
135
|
+
}
|
|
48
136
|
async open() {
|
|
49
137
|
const posted = await this.web.chat.postMessage({
|
|
50
138
|
channel: this.channel,
|
|
@@ -55,7 +143,7 @@ class ReplyRenderer {
|
|
|
55
143
|
this.ref = { channel: posted.channel ?? this.channel, ts: posted.ts };
|
|
56
144
|
}
|
|
57
145
|
}
|
|
58
|
-
onEvent(event) {
|
|
146
|
+
onEvent(event, ofThisTurn = true) {
|
|
59
147
|
if (this.finalized) {
|
|
60
148
|
return;
|
|
61
149
|
}
|
|
@@ -115,7 +203,11 @@ class ReplyRenderer {
|
|
|
115
203
|
// non-streaming or slow to its first delta, and the message must not
|
|
116
204
|
// claim a finished tool is still running that whole time.
|
|
117
205
|
this.scheduleEdit();
|
|
118
|
-
|
|
206
|
+
// Only this turn's files: a result the caller knows belongs to the
|
|
207
|
+
// turn ahead of this one on the session chain — a recovery's
|
|
208
|
+
// screenshot — would otherwise be uploaded in this turn's place in
|
|
209
|
+
// the thread. The adapter holds it for the turn that produced it.
|
|
210
|
+
if (event.result.ok && ofThisTurn) {
|
|
119
211
|
for (const filePath of collectFilePaths(event.result)) {
|
|
120
212
|
this.queueUpload(filePath);
|
|
121
213
|
}
|
|
@@ -127,7 +219,17 @@ class ReplyRenderer {
|
|
|
127
219
|
// channel contract's upload operation. Uploads chain so they land in
|
|
128
220
|
// order and finalize() waits for them.
|
|
129
221
|
queueUpload(filePath) {
|
|
222
|
+
// Behind a handover in progress, for the same reason edits and the
|
|
223
|
+
// finalize are: the turn being handed the placeholder has its own
|
|
224
|
+
// attachments to put in the thread first, and an upload is a message
|
|
225
|
+
// of its own — one sent now would sit above them and read as this
|
|
226
|
+
// turn's answer arriving first. Captured at queue time, not read when
|
|
227
|
+
// the chain runs: a handover that begins after this upload waits for
|
|
228
|
+
// it, and an upload that then waited for that handover would be a
|
|
229
|
+
// cycle nothing breaks (see `queueEdit`).
|
|
230
|
+
const handover = this.handover;
|
|
130
231
|
this.uploadChain = this.uploadChain
|
|
232
|
+
.then(() => handover)
|
|
131
233
|
// Read as file DATA inside the chain: the Web API takes contents,
|
|
132
234
|
// not a path, and a missing file surfaces as this upload's own
|
|
133
235
|
// failure instead of an unhandled stream error.
|
|
@@ -147,7 +249,9 @@ class ReplyRenderer {
|
|
|
147
249
|
return parts.length > 0 ? parts.join('\n\n') : PLACEHOLDER_TEXT;
|
|
148
250
|
}
|
|
149
251
|
scheduleEdit() {
|
|
150
|
-
|
|
252
|
+
// Not gated on having a placeholder: during a handover there is none
|
|
253
|
+
// for a moment, and the edit reads the fresh one when its turn comes.
|
|
254
|
+
if (this.pendingEdit) {
|
|
151
255
|
return;
|
|
152
256
|
}
|
|
153
257
|
const elapsed = Date.now() - this.lastEditAt;
|
|
@@ -158,33 +262,50 @@ class ReplyRenderer {
|
|
|
158
262
|
return;
|
|
159
263
|
}
|
|
160
264
|
this.lastEditAt = Date.now();
|
|
161
|
-
this.queueEdit(
|
|
265
|
+
this.queueEdit(messageText(this.currentText()));
|
|
162
266
|
}, delay);
|
|
163
267
|
this.pendingEdit.unref?.();
|
|
164
268
|
}
|
|
165
269
|
queueEdit(text) {
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
270
|
+
const generation = this.generation;
|
|
271
|
+
// The handover as it stands when the edit is queued, not when it runs:
|
|
272
|
+
// a handover that begins after this edit waits for this edit, and an
|
|
273
|
+
// edit that then waited for that handover would be a cycle nothing
|
|
274
|
+
// could break.
|
|
275
|
+
const handover = this.handover;
|
|
170
276
|
this.editChain = this.editChain
|
|
171
|
-
.then(() =>
|
|
277
|
+
.then(() => handover)
|
|
278
|
+
.then(() => {
|
|
279
|
+
// Read after the handover, not before: the placeholder may have
|
|
280
|
+
// changed hands while this edit waited its turn — and the text
|
|
281
|
+
// may belong to the turn that was handed over.
|
|
282
|
+
const ref = this.ref;
|
|
283
|
+
return ref && generation === this.generation
|
|
284
|
+
? this.web.chat.update({ channel: ref.channel, ts: ref.ts, text })
|
|
285
|
+
: undefined;
|
|
286
|
+
})
|
|
172
287
|
.then(() => undefined)
|
|
173
288
|
.catch((error) => this.warn(`chat.update failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
174
289
|
}
|
|
175
290
|
/** Replace the placeholder with the final reply, splitting if oversized. */
|
|
176
291
|
async finalize(reply) {
|
|
292
|
+
await this.handover;
|
|
177
293
|
this.finalized = true;
|
|
178
294
|
if (this.pendingEdit) {
|
|
179
295
|
clearTimeout(this.pendingEdit);
|
|
180
296
|
this.pendingEdit = undefined;
|
|
181
297
|
}
|
|
182
|
-
const text = reply.trim().length > 0 ? reply :
|
|
183
|
-
const chunks =
|
|
184
|
-
|
|
298
|
+
const text = reply.trim().length > 0 ? reply : NO_REPLY_TEXT;
|
|
299
|
+
const chunks = messageChunks(text);
|
|
300
|
+
// No placeholder — a handover could not open a fresh one — and the
|
|
301
|
+
// reply is a message of its own rather than nothing at all.
|
|
302
|
+
const rest = this.ref ? chunks.slice(1) : chunks;
|
|
303
|
+
if (this.ref) {
|
|
304
|
+
this.queueEdit(chunks[0] ?? NO_REPLY_TEXT);
|
|
305
|
+
}
|
|
185
306
|
await this.editChain;
|
|
186
307
|
await this.uploadChain;
|
|
187
|
-
for (const chunk of
|
|
308
|
+
for (const chunk of rest) {
|
|
188
309
|
try {
|
|
189
310
|
await this.web.chat.postMessage({
|
|
190
311
|
channel: this.channel,
|
|
@@ -200,6 +321,112 @@ class ReplyRenderer {
|
|
|
200
321
|
async fail(message) {
|
|
201
322
|
await this.finalize(`Something went wrong: ${message}`);
|
|
202
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* Claim the placeholder for a turn that finished ahead of this one — the
|
|
326
|
+
* recovery this renderer was queued behind — without yet knowing what
|
|
327
|
+
* that turn has to say. Slack keeps a message's place when it is edited,
|
|
328
|
+
* so the earlier turn's reply lands above this turn's, in the order the
|
|
329
|
+
* conversation happened; posted as a new message instead, it would sit
|
|
330
|
+
* below a placeholder that was posted first.
|
|
331
|
+
*
|
|
332
|
+
* The claim is made synchronously, so it is in place before the caller
|
|
333
|
+
* awaits anything (see `HandoverClaim`).
|
|
334
|
+
*/
|
|
335
|
+
reserveHandover() {
|
|
336
|
+
// One handover at a time: two turns ahead of this one finishing close
|
|
337
|
+
// together would otherwise both take the placeholder as they found it,
|
|
338
|
+
// and the second would write over the first's reply. Each waits for
|
|
339
|
+
// the one before it and takes the placeholder that reopen left.
|
|
340
|
+
// The edits to let through first are the ones queued before this
|
|
341
|
+
// handover existed; those queued after it wait for it (see `queueEdit`),
|
|
342
|
+
// so waiting on the live chain here would wait on them in return.
|
|
343
|
+
const drained = this.editChain;
|
|
344
|
+
const previous = this.handover;
|
|
345
|
+
let release;
|
|
346
|
+
const held = new Promise((resolve) => {
|
|
347
|
+
release = resolve;
|
|
348
|
+
});
|
|
349
|
+
this.handover = previous.then(() => held);
|
|
350
|
+
let spent = false;
|
|
351
|
+
const settle = () => {
|
|
352
|
+
if (!spent) {
|
|
353
|
+
spent = true;
|
|
354
|
+
release();
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
return {
|
|
358
|
+
take: async (chunks, between) => {
|
|
359
|
+
if (spent) {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
await previous;
|
|
364
|
+
return await this.handoverTo(chunks, drained, between);
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
settle();
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
release: settle,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* The body of a handover, running with the placeholder already claimed:
|
|
375
|
+
* edit it to the earlier turn's first chunk, post the rest, run whatever
|
|
376
|
+
* else that turn has to put in the thread, and reopen a placeholder for
|
|
377
|
+
* this turn below it all.
|
|
378
|
+
*/
|
|
379
|
+
async handoverTo(chunks, drained, between) {
|
|
380
|
+
const [first, ...rest] = chunks;
|
|
381
|
+
const given = this.ref;
|
|
382
|
+
if (first === undefined || this.finalized || !given) {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
await drained;
|
|
386
|
+
try {
|
|
387
|
+
await this.web.chat.update({ channel: given.channel, ts: given.ts, text: first });
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
// Not handed over: the placeholder is still this turn's, and the
|
|
391
|
+
// caller posts the reply as messages of its own.
|
|
392
|
+
this.warn(`chat.update failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
// The placeholder is the earlier turn's now, and the reference goes
|
|
396
|
+
// before the reopen, so a reopen that fails cannot leave this turn
|
|
397
|
+
// writing over the reply just handed over. (What was streamed into
|
|
398
|
+
// it is dealt with by `beginTurn`, at the boundary that actually
|
|
399
|
+
// separates the two turns' output.)
|
|
400
|
+
this.ref = undefined;
|
|
401
|
+
for (const chunk of rest) {
|
|
402
|
+
try {
|
|
403
|
+
await this.web.chat.postMessage({
|
|
404
|
+
channel: this.channel,
|
|
405
|
+
text: chunk,
|
|
406
|
+
...(this.threadTs ? { thread_ts: this.threadTs } : {}),
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
this.warn(`chat.postMessage failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// Anything else the earlier turn has to put in the thread goes here,
|
|
414
|
+
// while this renderer still has no placeholder of its own: a message
|
|
415
|
+
// posted after the reopen would sit below this turn's answer, which
|
|
416
|
+
// is the order the handover exists to keep.
|
|
417
|
+
if (between) {
|
|
418
|
+
await between();
|
|
419
|
+
}
|
|
420
|
+
try {
|
|
421
|
+
await this.open();
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
// No placeholder for this turn, then: its reply is posted as a
|
|
425
|
+
// message of its own when it comes (see `finalize`).
|
|
426
|
+
this.warn(`could not reopen a placeholder: ${error instanceof Error ? error.message : String(error)}`);
|
|
427
|
+
}
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
203
430
|
}
|
|
204
431
|
// The adapter-level convention for file-bearing tool results: an ok result
|
|
205
432
|
// whose object output carries `file: string` or `files: string[]` refers to
|
|
@@ -234,6 +461,271 @@ const safeCutIndex = (text, index) => {
|
|
|
234
461
|
const truncateForSlack = (text) => text.length <= SLACK_MAX_MESSAGE_CHARS
|
|
235
462
|
? text
|
|
236
463
|
: `${text.slice(0, safeCutIndex(text, SLACK_MAX_MESSAGE_CHARS - 1))}…`;
|
|
464
|
+
/**
|
|
465
|
+
* Where the run of exactly `length` backticks that closes an open one
|
|
466
|
+
* begins, or -1. Exactly: a longer run is not a closer, which is the whole
|
|
467
|
+
* point of writing a span as ``code with a ` in it`` and a fence as ````
|
|
468
|
+
* when the block itself contains ```.
|
|
469
|
+
*/
|
|
470
|
+
const closingBacktickRun = (text, from, length, limit) => {
|
|
471
|
+
const run = '`'.repeat(length);
|
|
472
|
+
for (let at = text.indexOf(run, from); at !== -1 && at + length <= limit; at = text.indexOf(run, at + 1)) {
|
|
473
|
+
if (text[at - 1] !== '`' && text[at + length] !== '`') {
|
|
474
|
+
return at;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return -1;
|
|
478
|
+
};
|
|
479
|
+
/**
|
|
480
|
+
* The text as alternating prose and code — even indices prose, odd code.
|
|
481
|
+
*
|
|
482
|
+
* Code is the one place a reader means the characters themselves: `**` in a
|
|
483
|
+
* shell snippet is the snippet, so nothing below rewrites it. A run of N
|
|
484
|
+
* backticks opens code and the next run of exactly N closes it, which is
|
|
485
|
+
* the same rule for a span and for a fence and is why a longer delimiter
|
|
486
|
+
* can carry a shorter one inside it.
|
|
487
|
+
*
|
|
488
|
+
* A span may close on a later line, and is protected the whole way. That
|
|
489
|
+
* looks wasteful — two stray backticks paragraphs apart become one long
|
|
490
|
+
* "span" whose contents are left unconverted — and it was briefly changed
|
|
491
|
+
* to end a span at its line for exactly that reason. That was wrong, and
|
|
492
|
+
* the reason is the rule this whole file answers to: what Slack does with
|
|
493
|
+
* the message decides, not what Markdown says. Slack pairs those backticks
|
|
494
|
+
* too, so the text between them is what it renders as code, and rewriting
|
|
495
|
+
* a `**bold**` in there would alter the contents of somebody's snippet.
|
|
496
|
+
* Leaving prose unconverted is a cosmetic loss; changing what a reader is
|
|
497
|
+
* told is code is not, so the doubt resolves toward protecting more.
|
|
498
|
+
*
|
|
499
|
+
* A fence with no closer runs to the end on purpose: a model that forgets
|
|
500
|
+
* to close one, and every partially streamed block on its way to being
|
|
501
|
+
* closed, would otherwise have its contents rewritten as prose. One or two
|
|
502
|
+
* unmatched backticks are the opposite case — literal text, since "use the
|
|
503
|
+
* ` character" must not swallow what follows.
|
|
504
|
+
*/
|
|
505
|
+
const proseAndCode = (text) => {
|
|
506
|
+
const segments = [];
|
|
507
|
+
let prose = 0;
|
|
508
|
+
let cursor = 0;
|
|
509
|
+
while (cursor < text.length) {
|
|
510
|
+
const open = text.indexOf('`', cursor);
|
|
511
|
+
if (open === -1) {
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
let length = 1;
|
|
515
|
+
while (text[open + length] === '`') {
|
|
516
|
+
length += 1;
|
|
517
|
+
}
|
|
518
|
+
const close = closingBacktickRun(text, open + length, length, text.length);
|
|
519
|
+
if (close === -1) {
|
|
520
|
+
if (length < 3) {
|
|
521
|
+
cursor = open + length;
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
segments.push(text.slice(prose, open), text.slice(open));
|
|
525
|
+
return segments;
|
|
526
|
+
}
|
|
527
|
+
segments.push(text.slice(prose, open), text.slice(open, close + length));
|
|
528
|
+
prose = close + length;
|
|
529
|
+
cursor = prose;
|
|
530
|
+
}
|
|
531
|
+
segments.push(text.slice(prose));
|
|
532
|
+
return segments;
|
|
533
|
+
};
|
|
534
|
+
/**
|
|
535
|
+
* The spellings that differ, applied to prose only, in an order that matters.
|
|
536
|
+
*
|
|
537
|
+
* Single-asterisk emphasis goes first and is barred from crossing a `*`, so
|
|
538
|
+
* `**bold**` cannot be read as an italic run that happens to begin with one.
|
|
539
|
+
* Bold then rewrites what is left, and its output is never rescanned.
|
|
540
|
+
*
|
|
541
|
+
* Each delimiter has to hug its content — `3 * 4 * 5` is arithmetic, not
|
|
542
|
+
* emphasis — and none may span a line, so one stray marker cannot italicize
|
|
543
|
+
* the rest of a message.
|
|
544
|
+
*/
|
|
545
|
+
const MRKDWN_REWRITES = [
|
|
546
|
+
// Both at once, before either rule can take half of it and leave the
|
|
547
|
+
// other half as literal asterisks. Its output is why the italic rule
|
|
548
|
+
// below refuses content that already opens with an underscore.
|
|
549
|
+
[/\*\*\*([^\s*][^*\n]*[^\s*]|[^\s*])\*\*\*/g, '*_$1_*'],
|
|
550
|
+
[/(^|[^*])\*([^\s*_][^*\n]*[^\s*]|[^\s*_])\*(?!\*)/g, '$1_$2_'],
|
|
551
|
+
[/\*\*([^\s*][^*\n]*[^\s*]|[^\s*])\*\*/g, '*$1*'],
|
|
552
|
+
[/(^|[^_])__([^\s_][^_\n]*[^\s_]|[^\s_])__(?!_)/g, '$1*$2*'],
|
|
553
|
+
[/~~([^\s~][^~\n]*[^\s~]|[^\s~])~~/g, '~$1~'],
|
|
554
|
+
// The one construct Slack spells backwards. A scheme is required: a
|
|
555
|
+
// bare `[1](2)` in prose is not a link anybody meant to follow.
|
|
556
|
+
[/\[([^\]\n]+)\]\(((?:https?:\/\/|mailto:)[^()\s]+)\)/g, '<$2|$1>'],
|
|
557
|
+
];
|
|
558
|
+
/**
|
|
559
|
+
* `## Heading`, which mrkdwn has no spelling for at all.
|
|
560
|
+
*
|
|
561
|
+
* The optional closing hashes have to be spaced off the text, as Markdown
|
|
562
|
+
* requires: without that, `# C#` is a heading whose name loses its last
|
|
563
|
+
* character.
|
|
564
|
+
*
|
|
565
|
+
* What they and any trailing spaces cover is captured rather than dropped,
|
|
566
|
+
* because dropping is only right when they are the heading's own syntax. A
|
|
567
|
+
* heading that opens a code span reaches its end inside one, and there the
|
|
568
|
+
* same characters are the snippet: `` # show `value # `` has a hash that
|
|
569
|
+
* belongs to whoever wrote the code.
|
|
570
|
+
*/
|
|
571
|
+
const MARKDOWN_HEADING = /^ {0,3}#{1,6}[ \t]+(.+?)((?:[ \t]+#+)?[ \t]*)$/gm;
|
|
572
|
+
const convertInline = (segment) => {
|
|
573
|
+
let converted = segment;
|
|
574
|
+
for (const [pattern, replacement] of MRKDWN_REWRITES) {
|
|
575
|
+
converted = converted.replace(pattern, replacement);
|
|
576
|
+
}
|
|
577
|
+
return converted;
|
|
578
|
+
};
|
|
579
|
+
/**
|
|
580
|
+
* Headings, found on the text's own lines rather than inside each prose
|
|
581
|
+
* fragment.
|
|
582
|
+
*
|
|
583
|
+
* A fragment is not a line: inline code cuts one in two, and the halves
|
|
584
|
+
* begin wherever the code ended. Matched per fragment, `^` then lied in
|
|
585
|
+
* both directions — the tail of `` `status` # not a heading `` looked like a
|
|
586
|
+
* line of its own and lost its hash, and ``## Run `npm test` now`` was only
|
|
587
|
+
* ever half a line, so only the half before the code was wrapped.
|
|
588
|
+
*
|
|
589
|
+
* So the whole text is matched instead, with the code spans it now contains
|
|
590
|
+
* passed in: a `#` whose line begins inside one is a comment in somebody's
|
|
591
|
+
* snippet, not a heading. A heading that merely *contains* code is a
|
|
592
|
+
* heading, and its code goes along untouched.
|
|
593
|
+
*
|
|
594
|
+
* Emphasis inside is already converted by here, so the line is only wrapped
|
|
595
|
+
* when nothing in it can pair with the wrapper. Slack has one bold
|
|
596
|
+
* delimiter and no way to nest it: wrapping `*Run* the thing` again yields
|
|
597
|
+
* `**Run* the thing*`, which it renders as literal asterisks rather than as
|
|
598
|
+
* anything bold. A heading that already carries emphasis therefore keeps
|
|
599
|
+
* exactly the emphasis it has — `## **Title**` is bold by now and needs
|
|
600
|
+
* nothing more, and `# glob *.ts` keeps an asterisk that was never a marker
|
|
601
|
+
* at all. An unbolded heading reads fine; a heading full of stray asterisks
|
|
602
|
+
* does not, and one with characters removed to make room is worse than both.
|
|
603
|
+
*
|
|
604
|
+
* An asterisk inside a code span is not one of those: Slack does not read
|
|
605
|
+
* markup there, so it cannot pair with anything, and ``## Match `*.ts` files``
|
|
606
|
+
* is bolded like any other heading.
|
|
607
|
+
*
|
|
608
|
+
* Both walks are cursors rather than searches: `replace` reports matches in
|
|
609
|
+
* increasing offset, and the spans were collected in that order too, so
|
|
610
|
+
* neither has to be looked at twice. The same work as a scan per heading,
|
|
611
|
+
* without its quadratic on a reply that is mostly headings and snippets.
|
|
612
|
+
*/
|
|
613
|
+
/** What could pair with a heading's wrapper, or with something out of sight. */
|
|
614
|
+
const PAIRABLE_MARKER = /[*`]/g;
|
|
615
|
+
const convertHeadings = (text, code) => {
|
|
616
|
+
let span = 0;
|
|
617
|
+
const startsInside = (position, from) => position >= (code[from]?.[0] ?? Number.POSITIVE_INFINITY);
|
|
618
|
+
return text.replace(MARKDOWN_HEADING, (line, body, tail, offset) => {
|
|
619
|
+
while (span < code.length && (code[span]?.[1] ?? 0) <= offset) {
|
|
620
|
+
span += 1;
|
|
621
|
+
}
|
|
622
|
+
// A `#` whose line begins inside a span is a comment in somebody's code.
|
|
623
|
+
if (startsInside(offset, span)) {
|
|
624
|
+
return line;
|
|
625
|
+
}
|
|
626
|
+
const heading = body.trim();
|
|
627
|
+
if (heading.length === 0) {
|
|
628
|
+
return line;
|
|
629
|
+
}
|
|
630
|
+
// Where everything this would drop from the line's end begins: the
|
|
631
|
+
// closing hashes and spaces the pattern captured, and whatever else
|
|
632
|
+
// `trimEnd` would take with them. The two are not the same set — the
|
|
633
|
+
// pattern knows only spaces and tabs, while trimming also takes a
|
|
634
|
+
// no-break space, an ideographic space, and the rest of what
|
|
635
|
+
// ECMAScript counts as whitespace — and the difference is exactly
|
|
636
|
+
// where a character goes missing.
|
|
637
|
+
//
|
|
638
|
+
// Dropping any of it is the heading's own syntax being removed, but
|
|
639
|
+
// only while it is the heading's: a line that opens a span reaches its
|
|
640
|
+
// end inside one, and there the same characters are somebody's
|
|
641
|
+
// snippet. Nothing on this line is safe to strip then, so it is left
|
|
642
|
+
// exactly as written. (The front needs no such care: the body starts
|
|
643
|
+
// where the hashes stop, which this line has already been shown to
|
|
644
|
+
// reach in prose.)
|
|
645
|
+
const dropped = tail.length + (body.length - body.trimEnd().length);
|
|
646
|
+
if (dropped > 0) {
|
|
647
|
+
const droppedAt = offset + line.length - dropped;
|
|
648
|
+
let over = span;
|
|
649
|
+
while (over < code.length && (code[over]?.[1] ?? 0) <= droppedAt) {
|
|
650
|
+
over += 1;
|
|
651
|
+
}
|
|
652
|
+
if (startsInside(droppedAt, over)) {
|
|
653
|
+
return line;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
// Anything left on the line that a delimiter could pair with, walked
|
|
657
|
+
// from where the heading cursor already stands rather than from the
|
|
658
|
+
// beginning. An asterisk is the obvious one — it would pair with the
|
|
659
|
+
// wrapper. A backtick counts too: one this side found no partner for is
|
|
660
|
+
// a literal character here, but Slack parses the message itself and may
|
|
661
|
+
// pair it with a backtick further down, and the wrapper's closing `*`
|
|
662
|
+
// would then be written inside what Slack reads as code, where it is
|
|
663
|
+
// ignored — leaving the opening one with nothing to close it. Both are
|
|
664
|
+
// spent characters inside a span, which is why the ranges are consulted
|
|
665
|
+
// rather than the text alone.
|
|
666
|
+
let within = span;
|
|
667
|
+
for (const marker of line.matchAll(PAIRABLE_MARKER)) {
|
|
668
|
+
const position = offset + (marker.index ?? 0);
|
|
669
|
+
while (within < code.length && (code[within]?.[1] ?? 0) <= position) {
|
|
670
|
+
within += 1;
|
|
671
|
+
}
|
|
672
|
+
if (!startsInside(position, within)) {
|
|
673
|
+
return heading;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
// Both markers have to land in prose, not just the opening one. A fence
|
|
677
|
+
// opened on this line — by a model mid-answer, or by a block still being
|
|
678
|
+
// streamed — runs past its end, so the closing `*` would be written
|
|
679
|
+
// inside the code and ignored there, leaving the opening one unpaired.
|
|
680
|
+
// The line began in prose or this never ran, so it is the end that is
|
|
681
|
+
// in question: where the marker would go, one past the line's last
|
|
682
|
+
// character, and not that character itself — a span that closes exactly
|
|
683
|
+
// at the line's end leaves the marker just outside it, which is fine.
|
|
684
|
+
const closer = offset + line.length;
|
|
685
|
+
while (within < code.length && (code[within]?.[1] ?? 0) <= closer) {
|
|
686
|
+
within += 1;
|
|
687
|
+
}
|
|
688
|
+
return startsInside(closer, within) ? heading : `*${heading}*`;
|
|
689
|
+
});
|
|
690
|
+
};
|
|
691
|
+
/**
|
|
692
|
+
* Markdown as a model writes it, in the spelling Slack actually renders.
|
|
693
|
+
*
|
|
694
|
+
* Slack's mrkdwn is not Markdown, and the gap is not cosmetic: `**bold**`
|
|
695
|
+
* reaches a reader as four literal asterisks, and `## Heading` as a line
|
|
696
|
+
* that starts with two hashes. Models write Markdown because that is what
|
|
697
|
+
* every other surface here renders, and a soul should not have to know
|
|
698
|
+
* which channel is carrying it, so the translation happens at the edge that
|
|
699
|
+
* does know.
|
|
700
|
+
*
|
|
701
|
+
* Only what differs is touched. Lists, block quotes, and inline code
|
|
702
|
+
* already mean in mrkdwn what they mean in Markdown; rewriting them would
|
|
703
|
+
* add ways to be wrong and fix nothing.
|
|
704
|
+
*/
|
|
705
|
+
const toSlackMrkdwn = (text) => {
|
|
706
|
+
// Where each code span ends up in the converted text, which is not where
|
|
707
|
+
// it started: the prose before it changes length as it is rewritten.
|
|
708
|
+
const code = [];
|
|
709
|
+
const segments = proseAndCode(text);
|
|
710
|
+
let converted = '';
|
|
711
|
+
segments.forEach((segment, index) => {
|
|
712
|
+
if (index % 2 === 1) {
|
|
713
|
+
// A code segment that ends the text is the unterminated fence: every
|
|
714
|
+
// closed run is followed by a prose slice, even an empty one. It has
|
|
715
|
+
// no end to be past, so nothing may be appended after it either —
|
|
716
|
+
// recording the end as it looks would say a marker placed at the
|
|
717
|
+
// text's end was safely outside.
|
|
718
|
+
const ends = index === segments.length - 1
|
|
719
|
+
? Number.POSITIVE_INFINITY
|
|
720
|
+
: converted.length + segment.length;
|
|
721
|
+
code.push([converted.length, ends]);
|
|
722
|
+
converted += segment;
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
converted += convertInline(segment);
|
|
726
|
+
});
|
|
727
|
+
return convertHeadings(converted, code);
|
|
728
|
+
};
|
|
237
729
|
const splitForSlack = (text) => {
|
|
238
730
|
if (text.length <= SLACK_MAX_MESSAGE_CHARS) {
|
|
239
731
|
return [text];
|
|
@@ -254,37 +746,52 @@ const splitForSlack = (text) => {
|
|
|
254
746
|
}
|
|
255
747
|
return chunks;
|
|
256
748
|
};
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
749
|
+
/**
|
|
750
|
+
* Agent text as one Slack message: converted, then sized.
|
|
751
|
+
*
|
|
752
|
+
* These two are the only ways anything an agent wrote reaches Slack, so
|
|
753
|
+
* mrkdwn is a property of the send path rather than something each call
|
|
754
|
+
* site has to remember — the reason the streamed placeholder and the final
|
|
755
|
+
* reply cannot end up in different markup.
|
|
756
|
+
*/
|
|
757
|
+
const messageText = (text) => truncateForSlack(toSlackMrkdwn(text));
|
|
758
|
+
/**
|
|
759
|
+
* Agent text as however many Slack messages it takes. Converted before it
|
|
760
|
+
* is split, so a chunk is measured at the length it will be sent at, and so
|
|
761
|
+
* no rewrite is ever asked to span a boundary.
|
|
762
|
+
*/
|
|
763
|
+
const messageChunks = (text) => splitForSlack(toSlackMrkdwn(text));
|
|
764
|
+
// One rule with the gateway's `sessionRouting`, which posts the same
|
|
765
|
+
// message for a turn this adapter did not start.
|
|
766
|
+
const lastAssistantReply = (session) => latestTurnReply(session) ?? NO_REPLY_TEXT;
|
|
274
767
|
const APPROVAL_ACTIONS = {
|
|
275
768
|
stratus_approve_once: 'once',
|
|
276
769
|
stratus_approve_always: 'always',
|
|
277
770
|
stratus_deny: 'deny',
|
|
278
771
|
};
|
|
279
772
|
/**
|
|
280
|
-
* How an approval request reads once it is settled. `always`
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
773
|
+
* How an approval request reads once it is settled. `always` says out loud
|
|
774
|
+
* how long it lasts: the button is the shortest path to widening what an
|
|
775
|
+
* agent does unattended, and an approver should see the scope they granted
|
|
776
|
+
* in the record of granting it.
|
|
777
|
+
*
|
|
778
|
+
* It states the **floor**, because that is the only thing true in every
|
|
779
|
+
* case. A call judged by a scope — a shell command, a browser site —
|
|
780
|
+
* persists that scope in the agent's whitelist and normally keeps it
|
|
781
|
+
* across restarts, but not when the daemon cannot write the file (a
|
|
782
|
+
* hand-edit that will not parse) or was built without one, and by then
|
|
783
|
+
* this message has already been sent. Nothing on the bus distinguishes
|
|
784
|
+
* any of it: the policy returns a boolean, and `tool.approval-resolved`
|
|
785
|
+
* carries the answer that was submitted, not how long the grant lasts.
|
|
786
|
+
*
|
|
787
|
+
* So it says what is always so and no more. Claiming only the session
|
|
788
|
+
* lifetime, as this line first did, tells an approver a durable grant
|
|
789
|
+
* revokes itself; claiming the restart, as it then did, promises one the
|
|
790
|
+
* daemon may have failed to save. The daemon's log has the exact line.
|
|
284
791
|
*/
|
|
285
792
|
const OUTCOME_TEXT = {
|
|
286
793
|
'decided:once': 'Allowed once',
|
|
287
|
-
'decided:always': 'Allowed
|
|
794
|
+
'decided:always': 'Allowed and remembered — for this session at least',
|
|
288
795
|
'decided:deny': 'Denied',
|
|
289
796
|
'timeout:deny': 'Expired without an answer — denied',
|
|
290
797
|
// Covers both endings that reach here: the turn was aborted, and the
|
|
@@ -296,6 +803,45 @@ const OUTCOME_TEXT = {
|
|
|
296
803
|
const APPROVAL_INPUT_LIMIT = 1400;
|
|
297
804
|
/** One line in a notification; the blocks carry the readable version. */
|
|
298
805
|
const APPROVAL_SUMMARY_LIMIT = 160;
|
|
806
|
+
/**
|
|
807
|
+
* Subtypes that are still a person saying something, rather than Slack
|
|
808
|
+
* narrating the channel. Everything else a `subtype` marks — an edit, a
|
|
809
|
+
* deletion, a join, a pinned file — is bookkeeping, and an agent following
|
|
810
|
+
* a thread must not answer bookkeeping.
|
|
811
|
+
*
|
|
812
|
+
* These three are here because each is an ordinary thing a person does:
|
|
813
|
+
* `file_share` is a message that happens to carry an attachment (asking a
|
|
814
|
+
* question with the log attached is the normal way to ask it),
|
|
815
|
+
* `thread_broadcast` is a thread reply the author also sent to the channel,
|
|
816
|
+
* and `me_message` is what `/me` produces — typed by a person, in their own
|
|
817
|
+
* words, and marked only by how it is rendered.
|
|
818
|
+
*/
|
|
819
|
+
const HUMAN_MESSAGE_SUBTYPES = new Set(['file_share', 'me_message', 'thread_broadcast']);
|
|
820
|
+
const isPersonSpeaking = (event) => event.subtype === undefined || HUMAN_MESSAGE_SUBTYPES.has(event.subtype);
|
|
821
|
+
/** At most this many attachment names are listed; the rest are counted. */
|
|
822
|
+
const MAX_LISTED_ATTACHMENTS = 5;
|
|
823
|
+
/**
|
|
824
|
+
* What a message's attachments are called, for a turn that cannot open
|
|
825
|
+
* them. Without this the agent is handed "here's the log" and no log, and
|
|
826
|
+
* answers as though it had read something — the note is what lets it say
|
|
827
|
+
* the true thing instead.
|
|
828
|
+
*/
|
|
829
|
+
const attachmentNote = (files) => {
|
|
830
|
+
if (!files || files.length === 0) {
|
|
831
|
+
return '';
|
|
832
|
+
}
|
|
833
|
+
const names = files.slice(0, MAX_LISTED_ATTACHMENTS).map((file) => file.name ?? file.title ?? 'an unnamed file');
|
|
834
|
+
const rest = files.length - names.length;
|
|
835
|
+
const listed = rest > 0 ? `${names.join(', ')}, and ${rest} more` : names.join(', ');
|
|
836
|
+
return `\n[Attached: ${listed}. Attachment contents cannot be read here — say so rather than guessing at them.]`;
|
|
837
|
+
};
|
|
838
|
+
/**
|
|
839
|
+
* Whether `text` addresses a bot user by name. Slack writes a mention as
|
|
840
|
+
* `<@U123>` markup whoever typed it, so this is the same question for an
|
|
841
|
+
* `app_mention` event and for a plain channel message the app now also
|
|
842
|
+
* sees.
|
|
843
|
+
*/
|
|
844
|
+
const mentions = (text, botUserId) => botUserId.length > 0 && text.includes(`<@${botUserId}>`);
|
|
299
845
|
/**
|
|
300
846
|
* Slack's three markup characters. Tool input is written by a model, so it
|
|
301
847
|
* reaches this message as untrusted text: unescaped, `<@U123>` becomes a
|
|
@@ -331,14 +877,20 @@ const renderInvocation = (input) => {
|
|
|
331
877
|
truncated,
|
|
332
878
|
};
|
|
333
879
|
};
|
|
334
|
-
const approvalBlocks = (agentName, toolName, risk, requestId, input) => {
|
|
880
|
+
const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, oneShot) => {
|
|
335
881
|
const invocation = renderInvocation(input);
|
|
336
882
|
return [
|
|
337
883
|
{
|
|
338
884
|
type: 'section',
|
|
339
885
|
text: {
|
|
340
886
|
type: 'mrkdwn',
|
|
341
|
-
|
|
887
|
+
// The site, when the call is judged by one. It is not in the
|
|
888
|
+
// arguments and cannot be: a `browser.act` request shows a CSS
|
|
889
|
+
// selector, which says nothing about where the click lands — and
|
|
890
|
+
// **Always allow** widens exactly that site, so an approver who was
|
|
891
|
+
// not shown it is being asked to grant something they cannot see.
|
|
892
|
+
text: `*${escapeSlackText(agentName)}* wants to run \`${escapeSlackText(toolName)}\` (${risk})`
|
|
893
|
+
+ `${origin ? ` on \`${escapeSlackText(origin)}\`` : ''}.`,
|
|
342
894
|
},
|
|
343
895
|
},
|
|
344
896
|
...(invocation.detail
|
|
@@ -353,15 +905,33 @@ const approvalBlocks = (agentName, toolName, risk, requestId, input) => {
|
|
|
353
905
|
elements: [{ type: 'mrkdwn', text: ':warning: Arguments shown are truncated — the full call is longer than this.' }],
|
|
354
906
|
}]
|
|
355
907
|
: []),
|
|
908
|
+
// Why there is no second button, when there is no second button.
|
|
909
|
+
...(oneShot
|
|
910
|
+
? [{
|
|
911
|
+
type: 'context',
|
|
912
|
+
elements: [{
|
|
913
|
+
type: 'mrkdwn',
|
|
914
|
+
text: ':lock: An approval covers this call only — nothing about it is remembered.',
|
|
915
|
+
}],
|
|
916
|
+
}]
|
|
917
|
+
: []),
|
|
356
918
|
{
|
|
357
919
|
type: 'actions',
|
|
358
920
|
// The request id rides on every button rather than in the message
|
|
359
921
|
// reference: Slack gives the value back verbatim, so the gateway is
|
|
360
922
|
// asked about the request that was actually rendered, never one
|
|
361
923
|
// re-derived from a channel and timestamp.
|
|
924
|
+
//
|
|
925
|
+
// **Always allow** is absent when the engine would not remember the
|
|
926
|
+
// answer — a `dangerous` tool, or a browser action with no page to
|
|
927
|
+
// grant. Offering it there is worse than useless: it does exactly
|
|
928
|
+
// what **Allow once** does, under a label promising a standing grant
|
|
929
|
+
// nobody gets.
|
|
362
930
|
elements: [
|
|
363
|
-
{ type: 'button', action_id: 'stratus_approve_once', text: { type: 'plain_text', text: 'Allow once' }, value: requestId },
|
|
364
|
-
|
|
931
|
+
{ type: 'button', action_id: 'stratus_approve_once', text: { type: 'plain_text', text: 'Allow once' }, value: requestId, ...(oneShot ? { style: 'primary' } : {}) },
|
|
932
|
+
...(oneShot
|
|
933
|
+
? []
|
|
934
|
+
: [{ type: 'button', action_id: 'stratus_approve_always', text: { type: 'plain_text', text: 'Always allow' }, value: requestId, style: 'primary' }]),
|
|
365
935
|
{ type: 'button', action_id: 'stratus_deny', text: { type: 'plain_text', text: 'Deny' }, value: requestId, style: 'danger' },
|
|
366
936
|
],
|
|
367
937
|
},
|
|
@@ -377,6 +947,14 @@ const approvalBlocks = (agentName, toolName, risk, requestId, input) => {
|
|
|
377
947
|
* (DMs: the DM channel id alone), so threads are resumable conversations
|
|
378
948
|
* and two agents sharing a thread keep fully separate sessions. Replies
|
|
379
949
|
* stream via placeholder-then-edit, throttled for chat.update limits.
|
|
950
|
+
*
|
|
951
|
+
* A mention starts a thread conversation; inside one, the agent keeps
|
|
952
|
+
* listening without being tagged again — see `couldBeFollowUp` and
|
|
953
|
+
* `answersFollowUp` for exactly whose reply an untagged message is. That
|
|
954
|
+
* needs the app to be subscribed to `message.channels` / `message.groups`
|
|
955
|
+
* / `message.mpim` (the shipped manifest is); an app installed before
|
|
956
|
+
* those were in it receives only `app_mention` and behaves as it always
|
|
957
|
+
* did, which makes the workspace's own grant the switch.
|
|
380
958
|
*/
|
|
381
959
|
export const createSlackChannelAdapter = (options) => {
|
|
382
960
|
const log = options.log ?? (() => { });
|
|
@@ -393,6 +971,13 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
393
971
|
// to the FIRST registered renderer (the running turn), never a later
|
|
394
972
|
// message's placeholder. Each handler removes exactly its own renderer.
|
|
395
973
|
const renderers = new Map();
|
|
974
|
+
/**
|
|
975
|
+
* Files a turn nobody here rendered produced — a recovery's screenshot,
|
|
976
|
+
* say — keyed by session, waiting for the turn's outcome to be posted so
|
|
977
|
+
* they can follow it into the thread. A rendered turn's files go through
|
|
978
|
+
* its renderer as they are produced; these have no renderer to go through.
|
|
979
|
+
*/
|
|
980
|
+
const unrenderedFiles = new Map();
|
|
396
981
|
// In-flight turns, so stop() can drain: a reply mid-render should reach
|
|
397
982
|
// Slack before the sockets go away.
|
|
398
983
|
const inflight = new Set();
|
|
@@ -403,6 +988,75 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
403
988
|
// Socket Mode redelivers on missed acks; a slow turn must not run twice.
|
|
404
989
|
const seenEvents = new Set();
|
|
405
990
|
const seenOrder = [];
|
|
991
|
+
/**
|
|
992
|
+
* Who each thread was last handed to, in SLACK RECEIPT ORDER — written
|
|
993
|
+
* the moment a message is accepted for an agent, before any of the work
|
|
994
|
+
* that message causes.
|
|
995
|
+
*
|
|
996
|
+
* The durable answer (`answersFollowUp`) is the agents' own sessions,
|
|
997
|
+
* and it lags: a session row is written when its turn starts, which is a
|
|
998
|
+
* display-name lookup and a placeholder post after the message that
|
|
999
|
+
* asked for it. Inside that window the persisted order still names the
|
|
1000
|
+
* agent that spoke *before* the handover, so a mention of Bea followed
|
|
1001
|
+
* straight away by an untagged addendum would send the addendum to Ava —
|
|
1002
|
+
* and a follow-up typed on the heels of the mention that opened the
|
|
1003
|
+
* thread would find no session at all and be dropped for good.
|
|
1004
|
+
*
|
|
1005
|
+
* Keyed by thread rather than by (agent, thread): one agent has it at a
|
|
1006
|
+
* time, so there is nothing to reconcile. The value is that thread's
|
|
1007
|
+
* HANDOVERS in order, not just its latest one, because the question an
|
|
1008
|
+
* untagged message asks is not "who holds this thread now" but "who held
|
|
1009
|
+
* it when I was sent". Those differ whenever a socket runs behind
|
|
1010
|
+
* another: Bea's connection can reject a pre-handover reply, then take
|
|
1011
|
+
* the mention that moves the thread to her, and Ava's connection reach
|
|
1012
|
+
* that same earlier reply afterwards — which the latest holder alone
|
|
1013
|
+
* would reject for a second time, dropping a message that was plainly
|
|
1014
|
+
* Ava's when it was written.
|
|
1015
|
+
*
|
|
1016
|
+
* Keeping the handovers also makes out-of-order writes ordinary rather
|
|
1017
|
+
* than dangerous. A connection several messages behind, or a redelivered
|
|
1018
|
+
* envelope carrying an old mention, inserts into the past where it
|
|
1019
|
+
* belongs instead of overwriting the present.
|
|
1020
|
+
*
|
|
1021
|
+
* Bounded and lossy on purpose — evicting an entry costs one lookup,
|
|
1022
|
+
* since the sessions still hold the answer for every thread this map has
|
|
1023
|
+
* forgotten, and for every thread a restart forgot.
|
|
1024
|
+
*
|
|
1025
|
+
* One thing they cannot reconstruct: a handover made in the seconds
|
|
1026
|
+
* before the daemon went down. The mention that moved the thread is
|
|
1027
|
+
* recorded only here, and the newly named agent has not replied yet, so
|
|
1028
|
+
* after a restart the sessions still name the previous speaker and the
|
|
1029
|
+
* next untagged reply goes to them. Surviving that needs durable
|
|
1030
|
+
* adapter-owned state, which the channel contract deliberately does not
|
|
1031
|
+
* have — an adapter reads routing and translates events, and inventing a
|
|
1032
|
+
* private store here is the wrong place to answer it. The next mention
|
|
1033
|
+
* corrects it.
|
|
1034
|
+
*/
|
|
1035
|
+
const threadAddressee = new Map();
|
|
1036
|
+
/**
|
|
1037
|
+
* One cold verdict per message — see `followUpWinner`. Bounded, because
|
|
1038
|
+
* an entry outlives its own resolution on purpose; an entry that has not
|
|
1039
|
+
* yet resolved is never the one evicted, since the agent still waiting on
|
|
1040
|
+
* it would otherwise start a second one.
|
|
1041
|
+
*/
|
|
1042
|
+
const coldVerdicts = new Map();
|
|
1043
|
+
/**
|
|
1044
|
+
* `agentId` → bot user id, for every app whose token this adapter has
|
|
1045
|
+
* authenticated — whether or not its socket then came up, and kept for as
|
|
1046
|
+
* long as the adapter runs.
|
|
1047
|
+
*
|
|
1048
|
+
* Separate from `connections` because recognizing that somebody was named
|
|
1049
|
+
* and being able to answer are different questions. An agent whose app is
|
|
1050
|
+
* down cannot answer anything; a message that names it is still not the
|
|
1051
|
+
* other agent's to take, and treating it as untagged would have the wrong
|
|
1052
|
+
* agent answer a question a person deliberately handed elsewhere. Silence
|
|
1053
|
+
* is the correct outcome there, and it is also the visible one — the same
|
|
1054
|
+
* thing a mention has always done when an app is down.
|
|
1055
|
+
*
|
|
1056
|
+
* Insertion order is `options.agents` order, which is what makes
|
|
1057
|
+
* `agentNamedIn` deterministic across every connection.
|
|
1058
|
+
*/
|
|
1059
|
+
const botIdentities = new Map();
|
|
406
1060
|
// Rendered approval requests, keyed by the gateway's request id — the
|
|
407
1061
|
// same id the buttons carry back.
|
|
408
1062
|
const approvalPosts = new Map();
|
|
@@ -523,7 +1177,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
523
1177
|
const web = connection.web;
|
|
524
1178
|
return {
|
|
525
1179
|
async post(text) {
|
|
526
|
-
const chunks =
|
|
1180
|
+
const chunks = messageChunks(text.trim().length > 0 ? text : '(empty message)');
|
|
527
1181
|
let first;
|
|
528
1182
|
for (const chunk of chunks) {
|
|
529
1183
|
const posted = await web.chat.postMessage({ channel: channelId, text: chunk });
|
|
@@ -534,7 +1188,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
534
1188
|
return first ?? { channel: channelId, ts: '' };
|
|
535
1189
|
},
|
|
536
1190
|
async edit(ref, text) {
|
|
537
|
-
await web.chat.update({ channel: ref.channel, ts: ref.ts, text:
|
|
1191
|
+
await web.chat.update({ channel: ref.channel, ts: ref.ts, text: messageText(text) });
|
|
538
1192
|
},
|
|
539
1193
|
async upload(filePath, title) {
|
|
540
1194
|
// File DATA, read here, for the same reason the renderer does it:
|
|
@@ -624,9 +1278,10 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
624
1278
|
// Fallback text for notifications and clients that do not render
|
|
625
1279
|
// blocks — an approval nobody can read is an approval nobody gives.
|
|
626
1280
|
text: escapeSlackText(`${agentName} wants to run ${event.call.toolName} (${event.risk})`
|
|
1281
|
+
+ `${event.origin ? ` on ${event.origin}` : ''}`
|
|
627
1282
|
+ `${invocationSummary ? `: ${invocationSummary}` : ''}.`),
|
|
628
1283
|
...(thread ? { thread_ts: thread } : {}),
|
|
629
|
-
blocks: approvalBlocks(agentName, event.call.toolName, event.risk, event.requestId, event.call.input),
|
|
1284
|
+
blocks: approvalBlocks(agentName, event.call.toolName, event.risk, event.requestId, event.call.input, event.origin, event.oneShot === true),
|
|
630
1285
|
});
|
|
631
1286
|
post = posted.ts
|
|
632
1287
|
? {
|
|
@@ -637,6 +1292,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
637
1292
|
agentName,
|
|
638
1293
|
toolName: event.call.toolName,
|
|
639
1294
|
risk: event.risk,
|
|
1295
|
+
oneShot: event.oneShot === true,
|
|
640
1296
|
approvers,
|
|
641
1297
|
}
|
|
642
1298
|
: undefined;
|
|
@@ -722,7 +1378,26 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
722
1378
|
return;
|
|
723
1379
|
}
|
|
724
1380
|
approvalPosts.delete(event.requestId);
|
|
725
|
-
|
|
1381
|
+
// The one lifetime this adapter *can* tell, because it rode in on the
|
|
1382
|
+
// request: a one-shot call remembers nothing whatever is answered. Both
|
|
1383
|
+
// shapes reach here — a `dangerous` tool, and a call judged by an
|
|
1384
|
+
// origin with no page to grant — and this channel offers no **Always
|
|
1385
|
+
// allow** for either, but `POST /approvals` still takes all three
|
|
1386
|
+
// answers, so another client can submit one. The general line below has
|
|
1387
|
+
// to hedge between two lifetimes it cannot distinguish; this one must
|
|
1388
|
+
// not, or the record of the decision claims a grant that does not exist.
|
|
1389
|
+
// Three shapes are one-shot and only one of them is knowable from here:
|
|
1390
|
+
// `risk` rode in on the request, so a `dangerous` tool can say why. The
|
|
1391
|
+
// other two — a browser action with no page to grant, and a command the
|
|
1392
|
+
// parser cannot reduce to a scope — are not distinguishable without
|
|
1393
|
+
// carrying a reason this adapter would only ever print, so they share a
|
|
1394
|
+
// line that is true of both. Naming one of them by guess is how this
|
|
1395
|
+
// read "there was no page to remember" for a shell pipeline.
|
|
1396
|
+
const outcome = post.oneShot && event.reason === 'decided' && event.answer === 'always'
|
|
1397
|
+
? post.risk === 'dangerous'
|
|
1398
|
+
? 'Allowed once — a dangerous tool is never remembered'
|
|
1399
|
+
: 'Allowed once — nothing about this call could be remembered'
|
|
1400
|
+
: OUTCOME_TEXT[`${event.reason}:${event.answer}`] ?? 'Resolved';
|
|
726
1401
|
const by = event.actor ? ` by <@${event.actor}>` : '';
|
|
727
1402
|
const text = `*${escapeSlackText(post.agentName)}* — \`${escapeSlackText(post.toolName)}\` (${post.risk}): ${outcome}${by}.`;
|
|
728
1403
|
try {
|
|
@@ -750,7 +1425,61 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
750
1425
|
* The routing has to come from the durable session, because the only
|
|
751
1426
|
* in-memory copy died with the process that had it.
|
|
752
1427
|
*/
|
|
753
|
-
|
|
1428
|
+
/**
|
|
1429
|
+
* Post a turn's outcome into its thread ahead of a renderer queued behind
|
|
1430
|
+
* it, when there is one — through that renderer's placeholder, so the
|
|
1431
|
+
* thread reads in order — and as fresh messages otherwise. Each chunk on
|
|
1432
|
+
* its own, the way `finalize` posts overflow: one refused chunk must not
|
|
1433
|
+
* cost the rest of the answer.
|
|
1434
|
+
*/
|
|
1435
|
+
const postAheadOf = async (behind, connection, channel, thread, chunks, between) => {
|
|
1436
|
+
if (behind && await behind.take(chunks, between)) {
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
for (const chunk of chunks) {
|
|
1440
|
+
try {
|
|
1441
|
+
await connection.web.chat.postMessage({
|
|
1442
|
+
channel,
|
|
1443
|
+
text: chunk,
|
|
1444
|
+
...(thread ? { thread_ts: thread } : {}),
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
catch (error) {
|
|
1448
|
+
warn(`slack: could not post part of a turn's outcome: ${error instanceof Error ? error.message : String(error)}`);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
if (between) {
|
|
1452
|
+
await between();
|
|
1453
|
+
}
|
|
1454
|
+
};
|
|
1455
|
+
/**
|
|
1456
|
+
* The files an unrendered turn produced, taken off the session the moment
|
|
1457
|
+
* its outcome arrives — before anything is awaited, so a next turn's files
|
|
1458
|
+
* arriving while this outcome waits on Slack are never mistaken for its.
|
|
1459
|
+
*/
|
|
1460
|
+
const takeUnrenderedFiles = (sessionId) => {
|
|
1461
|
+
const paths = unrenderedFiles.get(sessionId) ?? [];
|
|
1462
|
+
unrenderedFiles.delete(sessionId);
|
|
1463
|
+
return paths;
|
|
1464
|
+
};
|
|
1465
|
+
/** Post the files an unrendered turn produced into its thread, in order. */
|
|
1466
|
+
const uploadUnrenderedFiles = async (connection, channel, thread, paths) => {
|
|
1467
|
+
for (const filePath of paths) {
|
|
1468
|
+
try {
|
|
1469
|
+
const data = await readFile(filePath);
|
|
1470
|
+
await connection.web.files.uploadV2({
|
|
1471
|
+
channel_id: channel,
|
|
1472
|
+
file: data,
|
|
1473
|
+
filename: path.basename(filePath),
|
|
1474
|
+
...(thread ? { thread_ts: thread } : {}),
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
catch (error) {
|
|
1478
|
+
warn(`files.uploadV2 failed for ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
};
|
|
1482
|
+
const reportUnrenderedFailure = async (event, behind, files) => {
|
|
754
1483
|
const gateway = gatewayRef;
|
|
755
1484
|
if (!gateway?.sessionRouting) {
|
|
756
1485
|
return;
|
|
@@ -776,17 +1505,58 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
776
1505
|
if (!connection) {
|
|
777
1506
|
return;
|
|
778
1507
|
}
|
|
1508
|
+
const channel = metadata.slackChannel;
|
|
779
1509
|
const thread = typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined;
|
|
1510
|
+
await postAheadOf(behind, connection, channel, thread, messageChunks(`Something went wrong: ${event.error}`), () => uploadUnrenderedFiles(connection, channel, thread, files));
|
|
1511
|
+
};
|
|
1512
|
+
/**
|
|
1513
|
+
* The other half of `reportUnrenderedFailure`: a turn nobody here was
|
|
1514
|
+
* rendering that *finished*. A turn parked on a human when the daemon
|
|
1515
|
+
* died is re-asked after the restart, approved, and completed by a
|
|
1516
|
+
* process that never opened a placeholder for it — so the approval
|
|
1517
|
+
* survived the restart and the reply went nowhere. Posted as a fresh
|
|
1518
|
+
* message in the thread, since the placeholder it would have edited
|
|
1519
|
+
* belonged to the old process.
|
|
1520
|
+
*/
|
|
1521
|
+
const reportUnrenderedReply = async (event, behind, files) => {
|
|
1522
|
+
const gateway = gatewayRef;
|
|
1523
|
+
if (!gateway?.sessionRouting) {
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
let routing;
|
|
780
1527
|
try {
|
|
781
|
-
await
|
|
782
|
-
channel: metadata.slackChannel,
|
|
783
|
-
text: `Something went wrong: ${event.error}`,
|
|
784
|
-
...(thread ? { thread_ts: thread } : {}),
|
|
785
|
-
});
|
|
1528
|
+
routing = await gateway.sessionRouting(event.sessionId);
|
|
786
1529
|
}
|
|
787
1530
|
catch (error) {
|
|
788
|
-
warn(`slack: could not
|
|
1531
|
+
warn(`slack: could not read the routing for a finished turn: ${error instanceof Error ? error.message : String(error)}`);
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
const metadata = routing?.metadata;
|
|
1535
|
+
if (!routing || metadata?.channel !== 'slack' || typeof metadata.slackChannel !== 'string') {
|
|
1536
|
+
return;
|
|
789
1537
|
}
|
|
1538
|
+
const connection = connectionFor(routing.agentId);
|
|
1539
|
+
if (!connection) {
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
const channel = metadata.slackChannel;
|
|
1543
|
+
const thread = typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined;
|
|
1544
|
+
// A turn that produced files and no text still has the files to post —
|
|
1545
|
+
// and still has to take the placeholder when one is queued behind it.
|
|
1546
|
+
// An upload is a new message, so an attachment posted while the newer
|
|
1547
|
+
// turn's placeholder sits above it reads as the older turn answering
|
|
1548
|
+
// last. `NO_REPLY_TEXT` is what a rendered turn with nothing to say
|
|
1549
|
+
// puts in its own message too. With nothing queued behind, there is no
|
|
1550
|
+
// order to keep and nothing to say, so the files go on their own.
|
|
1551
|
+
const chunks = routing.reply ? messageChunks(routing.reply) : [];
|
|
1552
|
+
const posting = chunks.length > 0 ? chunks : (behind && files.length > 0 ? [NO_REPLY_TEXT] : []);
|
|
1553
|
+
const uploads = () => uploadUnrenderedFiles(connection, channel, thread, files);
|
|
1554
|
+
if (posting.length === 0) {
|
|
1555
|
+
await uploads();
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
await postAheadOf(behind, connection, channel, thread, posting, uploads);
|
|
1559
|
+
log(`slack: posted the reply of a turn finished after a restart to ${channel}`);
|
|
790
1560
|
};
|
|
791
1561
|
const handleInteractive = async (connection, args) => {
|
|
792
1562
|
// Ack first and unconditionally: Slack retries an unacked interaction,
|
|
@@ -859,47 +1629,432 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
859
1629
|
warn(`slack: could not send an ephemeral notice: ${error instanceof Error ? error.message : String(error)}`);
|
|
860
1630
|
}
|
|
861
1631
|
};
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
1632
|
+
/** One thread, whichever agent is talking in it. */
|
|
1633
|
+
const threadKeyFor = (parts) => `${parts.team}:${parts.conversation}:${parts.thread}`;
|
|
1634
|
+
/**
|
|
1635
|
+
* Records that `agentId` holds the thread from the message at `ts`
|
|
1636
|
+
* onwards, wherever in the thread's history that message falls.
|
|
1637
|
+
*
|
|
1638
|
+
* Only actual handovers are kept: a write naming the agent that already
|
|
1639
|
+
* held the thread at that point adds nothing, so the list stays as short
|
|
1640
|
+
* as the conversation's changes of hands rather than growing per message.
|
|
1641
|
+
* The oldest handovers are dropped past `THREAD_HANDOVER_DEPTH`, and the
|
|
1642
|
+
* least recently written thread past `THREAD_ADDRESSEE_CAPACITY`; both
|
|
1643
|
+
* losses cost a lookup, never an answer, since the sessions still hold
|
|
1644
|
+
* the durable version.
|
|
1645
|
+
*
|
|
1646
|
+
* Slack timestamps compare as strings: `seconds.microseconds`, both
|
|
1647
|
+
* parts fixed width, so lexicographic order is chronological order.
|
|
1648
|
+
*/
|
|
1649
|
+
const rememberAddressee = (key, agentId, ts) => {
|
|
1650
|
+
const handovers = threadAddressee.get(key) ?? [];
|
|
1651
|
+
let at = handovers.length;
|
|
1652
|
+
while (at > 0 && handovers[at - 1].ts > ts) {
|
|
1653
|
+
at -= 1;
|
|
1654
|
+
}
|
|
1655
|
+
const before = handovers[at - 1];
|
|
1656
|
+
if (before && before.agentId === agentId) {
|
|
1657
|
+
// Already this agent's from earlier in the thread; the same message
|
|
1658
|
+
// reaching a second connection lands here too.
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
handovers.splice(at, 0, { agentId, ts });
|
|
1662
|
+
// Re-inserted rather than mutated in place: a Map iterates in insertion
|
|
1663
|
+
// order, which is what makes the first key the one to evict.
|
|
1664
|
+
threadAddressee.delete(key);
|
|
1665
|
+
threadAddressee.set(key, handovers.slice(-THREAD_HANDOVER_DEPTH));
|
|
1666
|
+
if (threadAddressee.size > THREAD_ADDRESSEE_CAPACITY) {
|
|
1667
|
+
const oldest = threadAddressee.keys().next();
|
|
1668
|
+
if (!oldest.done) {
|
|
1669
|
+
threadAddressee.delete(oldest.value);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
};
|
|
1673
|
+
/**
|
|
1674
|
+
* Who held the thread when the message at `ts` was sent — the last
|
|
1675
|
+
* handover at or before it. Undefined when this process has no handover
|
|
1676
|
+
* that old, which sends the question to the sessions.
|
|
1677
|
+
*/
|
|
1678
|
+
const holderAt = (key, ts) => {
|
|
1679
|
+
const handovers = threadAddressee.get(key);
|
|
1680
|
+
if (!handovers) {
|
|
1681
|
+
return undefined;
|
|
1682
|
+
}
|
|
1683
|
+
for (let index = handovers.length - 1; index >= 0; index -= 1) {
|
|
1684
|
+
const handover = handovers[index];
|
|
1685
|
+
if (handover && handover.ts <= ts) {
|
|
1686
|
+
return handover.agentId;
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
return undefined;
|
|
1690
|
+
};
|
|
1691
|
+
/**
|
|
1692
|
+
* Which of this process's agents a message names, or undefined if it
|
|
1693
|
+
* names none of them.
|
|
1694
|
+
*
|
|
1695
|
+
* When it names several, the first that is actually serving — and any
|
|
1696
|
+
* of them otherwise. That determinism is the point: every connection sees every message and runs
|
|
1697
|
+
* this over the same text and the same array, so all of them agree on who
|
|
1698
|
+
* a message handed the thread to — including the connections that will go
|
|
1699
|
+
* on to ignore it. Which is what makes the handover record converge
|
|
1700
|
+
* without the sockets having to agree on anything, since each agent is
|
|
1701
|
+
* its own Socket Mode connection and one can run minutes behind another.
|
|
1702
|
+
*
|
|
1703
|
+
* Scoped to the message's own workspace, because a bot user id is only
|
|
1704
|
+
* unique within one — the same reason the display-name cache is keyed by
|
|
1705
|
+
* team. Two agents in two workspaces can hold the same id, and resolving
|
|
1706
|
+
* a mention to the wrong one would hand the thread to an agent that
|
|
1707
|
+
* cannot see it, leaving the agent that was actually named refusing every
|
|
1708
|
+
* reply after.
|
|
1709
|
+
*
|
|
1710
|
+
* Only agents this adapter has authenticated can be recognized at all.
|
|
1711
|
+
* One served by another daemon is invisible here, and both would answer;
|
|
1712
|
+
* so is one whose `auth.test` itself failed, since its bot id was never
|
|
1713
|
+
* learned.
|
|
1714
|
+
*/
|
|
1715
|
+
const agentNamedIn = (text, team) => {
|
|
1716
|
+
let offline;
|
|
1717
|
+
for (const [agentId, identity] of botIdentities) {
|
|
1718
|
+
if (identity.teamId !== team || !mentions(text, identity.botUserId)) {
|
|
1719
|
+
continue;
|
|
1720
|
+
}
|
|
1721
|
+
if (connections.some((live) => live.config.agentId === agentId)) {
|
|
1722
|
+
return agentId;
|
|
1723
|
+
}
|
|
1724
|
+
// Named, recognized, and not serving. Remembered only as the answer
|
|
1725
|
+
// of last resort: a message that names an agent whose app is down and
|
|
1726
|
+
// nobody else still gets silence, which is the deliberate behavior —
|
|
1727
|
+
// but a message that also names one that IS serving must leave the
|
|
1728
|
+
// thread with the agent that can actually answer it, or the reply it
|
|
1729
|
+
// just posted would be the last anyone gets.
|
|
1730
|
+
offline ??= agentId;
|
|
1731
|
+
}
|
|
1732
|
+
return offline;
|
|
1733
|
+
};
|
|
1734
|
+
/**
|
|
1735
|
+
* Who an untagged reply belongs to, asked of the SESSIONS — the durable
|
|
1736
|
+
* answer, for a thread this process has no record of: one it has not seen
|
|
1737
|
+
* a message in since it started, or one evicted from `threadAddressee`.
|
|
1738
|
+
* Everything else is settled synchronously in `admit`, before this is
|
|
1739
|
+
* ever reached.
|
|
1740
|
+
*
|
|
1741
|
+
* Being in the thread is the first half: a session under a thread's key
|
|
1742
|
+
* exists only because someone mentioned that agent here, so an agent
|
|
1743
|
+
* follows up only in conversations it was invited into, and stops when
|
|
1744
|
+
* the thread is gone. `sessionRouting` is the durable record of that — a
|
|
1745
|
+
* restart forgets nothing.
|
|
1746
|
+
*
|
|
1747
|
+
* Whose turn it is, is the second. In a thread with several agents an
|
|
1748
|
+
* unaddressed reply belongs to **whoever spoke last**, the way it does
|
|
1749
|
+
* between people: answer the voice that just answered you, and tag
|
|
1750
|
+
* someone by name to change that. `lastSpokeAt` orders them — when each
|
|
1751
|
+
* agent last *replied*, not when its session last changed, because a turn
|
|
1752
|
+
* saves on tool results and approval checkpoints without having said
|
|
1753
|
+
* anything, and a recovery resuming after a restart lands in exactly this
|
|
1754
|
+
* window.
|
|
1755
|
+
*
|
|
1756
|
+
* Nobody is the answer only when the agents cannot be *ordered* — nothing
|
|
1757
|
+
* in the thread has spoken (a host whose routing carries no `lastSpokeAt`
|
|
1758
|
+
* at all reads this way, and so does a thread where every agent is still
|
|
1759
|
+
* on its first turn), or the newest two tie — because guessing there
|
|
1760
|
+
* would put two answers under one message. An agent that simply has not
|
|
1761
|
+
* spoken is a different thing and is skipped rather than fatal: it cannot
|
|
1762
|
+
* have spoken last, and letting it silence the agent that did would leave
|
|
1763
|
+
* a reply nobody answers. The agent alone in its thread, which is nearly
|
|
1764
|
+
* every thread, is answered without the question arising.
|
|
1765
|
+
*/
|
|
1766
|
+
const resolveFollowUpWinner = async (parts) => {
|
|
866
1767
|
const gateway = gatewayRef;
|
|
1768
|
+
if (!gateway?.sessionRouting || !parts.thread) {
|
|
1769
|
+
return undefined;
|
|
1770
|
+
}
|
|
1771
|
+
const engaged = [];
|
|
1772
|
+
// Live connections in this message's OWN workspace. Live, because an
|
|
1773
|
+
// agent whose app failed to connect cannot answer anything, and leaving
|
|
1774
|
+
// a thread silent because its last speaker is offline is the bug this
|
|
1775
|
+
// whole change exists to remove. In-workspace, because a session key
|
|
1776
|
+
// carries a team: an agent whose app has moved workspaces still has its
|
|
1777
|
+
// old sessions there, and letting those contend for a thread it can no
|
|
1778
|
+
// longer receive a message in would leave nobody to answer.
|
|
1779
|
+
for (const candidate of connections) {
|
|
1780
|
+
if (candidate.teamId !== parts.team) {
|
|
1781
|
+
continue;
|
|
1782
|
+
}
|
|
1783
|
+
const routing = await gateway.sessionRouting(channelSessionKey({
|
|
1784
|
+
channel: 'slack',
|
|
1785
|
+
agentId: candidate.config.agentId,
|
|
1786
|
+
team: parts.team,
|
|
1787
|
+
conversation: parts.conversation,
|
|
1788
|
+
thread: parts.thread,
|
|
1789
|
+
}));
|
|
1790
|
+
if (!routing) {
|
|
1791
|
+
continue;
|
|
1792
|
+
}
|
|
1793
|
+
engaged.push({
|
|
1794
|
+
agentId: candidate.config.agentId,
|
|
1795
|
+
...(routing.lastSpokeAt !== undefined ? { lastSpokeAt: routing.lastSpokeAt } : {}),
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
const first = engaged[0];
|
|
1799
|
+
if (!first) {
|
|
1800
|
+
return undefined;
|
|
1801
|
+
}
|
|
1802
|
+
if (engaged.length === 1) {
|
|
1803
|
+
return first.agentId;
|
|
1804
|
+
}
|
|
1805
|
+
let latest;
|
|
1806
|
+
let tied = false;
|
|
1807
|
+
for (const entry of engaged) {
|
|
1808
|
+
if (entry.lastSpokeAt === undefined) {
|
|
1809
|
+
// In the thread, but has never said anything in it — mentioned and
|
|
1810
|
+
// still on its first turn, or a turn that produced no text. It
|
|
1811
|
+
// cannot be the one who spoke last, and it must not stop the agent
|
|
1812
|
+
// that did from answering.
|
|
1813
|
+
continue;
|
|
1814
|
+
}
|
|
1815
|
+
if (!latest || entry.lastSpokeAt > latest.lastSpokeAt) {
|
|
1816
|
+
latest = { agentId: entry.agentId, lastSpokeAt: entry.lastSpokeAt };
|
|
1817
|
+
tied = false;
|
|
1818
|
+
}
|
|
1819
|
+
else if (entry.lastSpokeAt === latest.lastSpokeAt) {
|
|
1820
|
+
tied = true;
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
return tied ? undefined : latest?.agentId;
|
|
1824
|
+
};
|
|
1825
|
+
/**
|
|
1826
|
+
* `resolveFollowUpWinner`, resolved ONCE PER MESSAGE and shared by every
|
|
1827
|
+
* agent asking about that message.
|
|
1828
|
+
*
|
|
1829
|
+
* The sharing is the point. Each agent's own copy of the question is
|
|
1830
|
+
* several independent session reads, and turns finishing in between can
|
|
1831
|
+
* make two agents' reads disagree: both concluding they are the most
|
|
1832
|
+
* recent speaker, so one message is answered twice, or both concluding
|
|
1833
|
+
* they are not, so it is answered by nobody. One resolution cannot
|
|
1834
|
+
* disagree with itself, which is a stronger guarantee than arbitrating
|
|
1835
|
+
* between two that already have.
|
|
1836
|
+
*
|
|
1837
|
+
* Keyed on the message, so a later reply in the same thread asks again.
|
|
1838
|
+
* Bounded, and deliberately not deleted when it settles: an entry has to
|
|
1839
|
+
* outlive its own resolution, because an agent it beats may only reach
|
|
1840
|
+
* this after the winner has finished, and recomputing for that agent is
|
|
1841
|
+
* precisely the disagreement the memo exists to prevent.
|
|
1842
|
+
*/
|
|
1843
|
+
const followUpWinner = (parts, ts) => {
|
|
1844
|
+
const key = `${threadKeyFor(parts)}:${ts}`;
|
|
1845
|
+
const existing = coldVerdicts.get(key);
|
|
1846
|
+
if (existing) {
|
|
1847
|
+
return existing.verdict;
|
|
1848
|
+
}
|
|
1849
|
+
const entry = { verdict: resolveFollowUpWinner(parts), settled: false };
|
|
1850
|
+
coldVerdicts.set(key, entry);
|
|
1851
|
+
void entry.verdict.then(() => {
|
|
1852
|
+
entry.settled = true;
|
|
1853
|
+
}, () => {
|
|
1854
|
+
entry.settled = true;
|
|
1855
|
+
});
|
|
1856
|
+
if (coldVerdicts.size > COLD_VERDICT_CAPACITY) {
|
|
1857
|
+
// The oldest SETTLED entry, never simply the oldest. Evicting one
|
|
1858
|
+
// still resolving is the one eviction that costs an answer rather
|
|
1859
|
+
// than a lookup: the agent it has not reached yet would start its own
|
|
1860
|
+
// resolution, which is the disagreement this memo exists to prevent.
|
|
1861
|
+
// With none settled there is nothing safe to drop, and the map runs
|
|
1862
|
+
// over its cap by however many resolutions are genuinely in flight.
|
|
1863
|
+
for (const [candidate, held] of coldVerdicts) {
|
|
1864
|
+
if (held.settled) {
|
|
1865
|
+
coldVerdicts.delete(candidate);
|
|
1866
|
+
break;
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
return entry.verdict;
|
|
1871
|
+
};
|
|
1872
|
+
/**
|
|
1873
|
+
* Everything about who a message is for that can be decided WITHOUT
|
|
1874
|
+
* AWAITING, run straight off the socket's own event emission — so for the
|
|
1875
|
+
* whole of it, this process is exactly as far through the conversation as
|
|
1876
|
+
* Slack is.
|
|
1877
|
+
*
|
|
1878
|
+
* That is the point of it being one synchronous block. The Socket Mode
|
|
1879
|
+
* client emits envelopes in order, but `ack()` resolves from a WebSocket
|
|
1880
|
+
* send callback, so two handlers that ack before deciding anything can
|
|
1881
|
+
* come back in either order: a follow-up could then overtake the mention
|
|
1882
|
+
* that opened its thread, find neither a record nor a session, and be
|
|
1883
|
+
* dropped for good. Nothing here awaits, so nothing can be overtaken —
|
|
1884
|
+
* the dedupe and the handover record included, which are the two pieces a
|
|
1885
|
+
* later message reads.
|
|
1886
|
+
*
|
|
1887
|
+
* `undefined` means the message is not this agent's. `settled: false`
|
|
1888
|
+
* means only the sessions can still decide it, which is the one case that
|
|
1889
|
+
* has to wait — and it is the cold case, where by definition no message
|
|
1890
|
+
* of this thread has been seen in this process.
|
|
1891
|
+
*/
|
|
1892
|
+
const admit = (connection, args) => {
|
|
867
1893
|
const event = args.event;
|
|
868
|
-
if (!
|
|
869
|
-
return;
|
|
1894
|
+
if (!gatewayRef || !event || !event.user || event.bot_id || !isPersonSpeaking(event)) {
|
|
1895
|
+
return undefined;
|
|
870
1896
|
}
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
// Mention-only outside DMs.
|
|
874
|
-
return;
|
|
1897
|
+
if (event.type !== 'app_mention' && event.type !== 'message') {
|
|
1898
|
+
return undefined;
|
|
875
1899
|
}
|
|
876
1900
|
if (event.user === connection.botUserId) {
|
|
877
|
-
return;
|
|
878
|
-
}
|
|
879
|
-
const eventKey = `${connection.config.agentId}:${args.body?.event_id ?? args.envelope_id ?? `${event.channel}:${event.ts}`}`;
|
|
880
|
-
if (alreadySeen(eventKey)) {
|
|
881
|
-
return;
|
|
1901
|
+
return undefined;
|
|
882
1902
|
}
|
|
1903
|
+
const isDm = event.channel_type === 'im';
|
|
883
1904
|
const team = args.body?.team_id ?? connection.teamId;
|
|
884
|
-
const userId = event.user;
|
|
885
1905
|
// A top-level mention has no thread_ts; its own ts roots the reply
|
|
886
1906
|
// thread that becomes the conversation. DMs are one conversation per
|
|
887
1907
|
// peer, unkeyed by thread.
|
|
888
1908
|
const thread = isDm ? undefined : event.thread_ts ?? event.ts;
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1909
|
+
// Threads only: a DM has one agent by construction.
|
|
1910
|
+
const threadKey = thread !== undefined && !isDm
|
|
1911
|
+
? threadKeyFor({ team, conversation: event.channel, thread })
|
|
1912
|
+
: undefined;
|
|
1913
|
+
const text = event.text ?? '';
|
|
1914
|
+
const addressed = isDm || mentions(text, connection.botUserId);
|
|
1915
|
+
// Who this message hands the thread to — worked out by EVERY connection
|
|
1916
|
+
// that sees it, not only by the one being named, and recorded before any
|
|
1917
|
+
// of the rejections below. Each agent is its own Socket Mode connection,
|
|
1918
|
+
// so the message naming Bea reaches Ava's socket and Bea's socket
|
|
1919
|
+
// independently and in no fixed order; if only Bea recorded the
|
|
1920
|
+
// handover, Ava could take the next untagged reply while Bea's socket
|
|
1921
|
+
// was still behind, and answer a question that had just been handed
|
|
1922
|
+
// away. The message Ava must record is exactly the one Ava ignores.
|
|
1923
|
+
const named = agentNamedIn(text, team);
|
|
1924
|
+
if (threadKey && named) {
|
|
1925
|
+
rememberAddressee(threadKey, named, event.ts);
|
|
1926
|
+
}
|
|
1927
|
+
if (!addressed) {
|
|
1928
|
+
if (event.thread_ts === undefined) {
|
|
1929
|
+
// The room is not a conversation: a message that starts a thread,
|
|
1930
|
+
// or stands alone, is a follow-up to nothing.
|
|
1931
|
+
return undefined;
|
|
1932
|
+
}
|
|
1933
|
+
if (named) {
|
|
1934
|
+
// Somebody else was asked. Tagging an agent hands it the question,
|
|
1935
|
+
// and the agent that had it is no longer being asked — two answers
|
|
1936
|
+
// to one message is what a thread with a roster in it must never
|
|
1937
|
+
// produce.
|
|
1938
|
+
return undefined;
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
// One Slack MESSAGE, not one delivery. An app subscribed to both
|
|
1942
|
+
// `app_mention` and `message.channels` is told about a mention twice —
|
|
1943
|
+
// two envelopes carrying two event ids — so keying this on the event
|
|
1944
|
+
// id would run the turn twice the moment thread follow-through was
|
|
1945
|
+
// switched on. `(channel, ts)` names the message itself, and a
|
|
1946
|
+
// redelivery (what the event id was here for) repeats both.
|
|
1947
|
+
//
|
|
1948
|
+
// After the handover record and not before it: a redelivery re-records
|
|
1949
|
+
// the same agent, which costs nothing, while deduping first would drop
|
|
1950
|
+
// the record whenever the other copy of a doubly-delivered mention is
|
|
1951
|
+
// the one a connection sees second.
|
|
1952
|
+
const eventKey = `${connection.config.agentId}:${event.channel}:${event.ts}`;
|
|
1953
|
+
if (alreadySeen(eventKey)) {
|
|
1954
|
+
return undefined;
|
|
1955
|
+
}
|
|
1956
|
+
const admission = {
|
|
1957
|
+
event,
|
|
1958
|
+
isDm,
|
|
892
1959
|
team,
|
|
893
|
-
|
|
1960
|
+
userId: event.user,
|
|
1961
|
+
sessionId: channelSessionKey({
|
|
1962
|
+
channel: 'slack',
|
|
1963
|
+
agentId: connection.config.agentId,
|
|
1964
|
+
team,
|
|
1965
|
+
conversation: event.channel,
|
|
1966
|
+
...(thread ? { thread } : {}),
|
|
1967
|
+
}),
|
|
894
1968
|
...(thread ? { thread } : {}),
|
|
1969
|
+
...(threadKey ? { threadKey } : {}),
|
|
1970
|
+
settled: true,
|
|
1971
|
+
};
|
|
1972
|
+
if (addressed) {
|
|
1973
|
+
// A message that named this agent and others is recorded above under
|
|
1974
|
+
// whichever of them comes first in connection order — every agent
|
|
1975
|
+
// named answers it, and the thread it leaves behind has one holder
|
|
1976
|
+
// that all the sockets agree on, which is the only self-consistent
|
|
1977
|
+
// reading of a message that asked several.
|
|
1978
|
+
return admission;
|
|
1979
|
+
}
|
|
1980
|
+
// An untagged reply. The record is what this process has actually seen,
|
|
1981
|
+
// in the order it saw it — right even inside the window before a
|
|
1982
|
+
// handover reaches the store.
|
|
1983
|
+
const holder = threadKey === undefined ? undefined : holderAt(threadKey, event.ts);
|
|
1984
|
+
if (holder !== undefined && threadKey !== undefined) {
|
|
1985
|
+
if (holder !== connection.config.agentId) {
|
|
1986
|
+
// Standing down for the holder, whether or not the holder can still
|
|
1987
|
+
// hear it. An app removed from this channel stops receiving the
|
|
1988
|
+
// thread, and its untagged replies then reach nobody until somebody
|
|
1989
|
+
// names an agent — which is the same answer this adapter gives
|
|
1990
|
+
// whenever the agent being addressed cannot answer, and one mention
|
|
1991
|
+
// reopens the conversation. Doing better means per-channel
|
|
1992
|
+
// membership the adapter does not track, and would have to be
|
|
1993
|
+
// tracked in the durable path too, since a removed app is still a
|
|
1994
|
+
// live connection with the newest session there.
|
|
1995
|
+
return undefined;
|
|
1996
|
+
}
|
|
1997
|
+
rememberAddressee(threadKey, connection.config.agentId, event.ts);
|
|
1998
|
+
return admission;
|
|
1999
|
+
}
|
|
2000
|
+
// Nothing seen for this thread since startup: only the sessions can
|
|
2001
|
+
// answer, and that answer has to be awaited.
|
|
2002
|
+
return { ...admission, settled: false };
|
|
2003
|
+
};
|
|
2004
|
+
const handleInbound = async (connection, args) => {
|
|
2005
|
+
// Decided before the ack, never after it — see `admit`. That block is
|
|
2006
|
+
// map and string work measured in microseconds, against an ack window
|
|
2007
|
+
// Slack measures in seconds, and the envelope is acked below whatever
|
|
2008
|
+
// the decision was.
|
|
2009
|
+
const admitted = admit(connection, args);
|
|
2010
|
+
// Acked without awaiting it. The ack still leaves as promptly as it ever
|
|
2011
|
+
// did — Slack redelivers unacked envelopes, and a turn outlives the ack
|
|
2012
|
+
// window many times over — but nothing here needs its send callback, and
|
|
2013
|
+
// awaiting one was the single await between receiving a message and
|
|
2014
|
+
// taking its place in the queue below. Two handlers waiting on their
|
|
2015
|
+
// acks can come back in either order; two handlers that never wait
|
|
2016
|
+
// cannot. A failed ack costs a redelivery, which the dedupe absorbs.
|
|
2017
|
+
void Promise.resolve(args.ack()).catch((error) => {
|
|
2018
|
+
warn(`slack: could not ack an envelope: ${error instanceof Error ? error.message : String(error)}`);
|
|
895
2019
|
});
|
|
2020
|
+
if (!admitted) {
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
2023
|
+
// Re-read rather than carried through the admission: `stop()` can clear
|
|
2024
|
+
// it while the ack above is in flight, and a dispatch into a gateway
|
|
2025
|
+
// this adapter has let go of is not one to start.
|
|
2026
|
+
const gateway = gatewayRef;
|
|
2027
|
+
if (!gateway) {
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
const { event, isDm, team, userId, thread, sessionId, threadKey } = admitted;
|
|
896
2031
|
// Everything up to (and including) the dispatch call is serialized per
|
|
897
2032
|
// session in Slack receipt order: the user lookups and placeholder
|
|
898
2033
|
// post below await network I/O, and without this chain a later message
|
|
899
2034
|
// could reach gateway.dispatch first — processing the conversation in
|
|
900
|
-
// reverse order in durable history.
|
|
2035
|
+
// reverse order in durable history. The chain is claimed here,
|
|
2036
|
+
// synchronously, so a message's place in it is fixed by when Slack
|
|
2037
|
+
// delivered it and not by how long anything it does takes.
|
|
901
2038
|
const previousIntake = intakeChains.get(sessionId) ?? Promise.resolve();
|
|
902
2039
|
const intake = previousIntake.then(async () => {
|
|
2040
|
+
// The durable half of addressing runs INSIDE the chain, for the same
|
|
2041
|
+
// reason: it awaits, and a message that had to ask the sessions must
|
|
2042
|
+
// not lose its place to one that did not. A message that turns out
|
|
2043
|
+
// not to be this agent's leaves an empty link behind, which is all it
|
|
2044
|
+
// should cost.
|
|
2045
|
+
if (!admitted.settled) {
|
|
2046
|
+
// One verdict for this message, shared with whichever other agents
|
|
2047
|
+
// are asking about it — see `followUpWinner`.
|
|
2048
|
+
const winner = await followUpWinner({ team, conversation: event.channel, thread: event.thread_ts ?? '' }, event.ts);
|
|
2049
|
+
if (winner !== connection.config.agentId) {
|
|
2050
|
+
return undefined;
|
|
2051
|
+
}
|
|
2052
|
+
// Answered from the store rather than from memory: hold it, so the
|
|
2053
|
+
// rest of the thread is ordered by receipt like any other.
|
|
2054
|
+
if (threadKey) {
|
|
2055
|
+
rememberAddressee(threadKey, connection.config.agentId, event.ts);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
903
2058
|
const cleaned = await humanizeMentions(connection, event.text ?? '');
|
|
904
2059
|
if (cleaned.length === 0) {
|
|
905
2060
|
return undefined;
|
|
@@ -907,7 +2062,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
907
2062
|
const author = await displayNameFor(connection, userId);
|
|
908
2063
|
// In shared channels the model should know who is speaking; a DM is
|
|
909
2064
|
// unambiguous.
|
|
910
|
-
const
|
|
2065
|
+
const spoken = isDm ? cleaned : `${author}: ${cleaned}`;
|
|
2066
|
+
const userMessage = `${spoken}${attachmentNote(event.files)}`;
|
|
911
2067
|
const renderer = new ReplyRenderer(connection.web, event.channel, thread, editIntervalMs, warn);
|
|
912
2068
|
try {
|
|
913
2069
|
await renderer.open();
|
|
@@ -926,6 +2082,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
926
2082
|
sessionId,
|
|
927
2083
|
agentId: connection.config.agentId,
|
|
928
2084
|
userMessage,
|
|
2085
|
+
turnId: renderer.turnId,
|
|
929
2086
|
metadata: {
|
|
930
2087
|
channel: 'slack',
|
|
931
2088
|
team,
|
|
@@ -998,33 +2155,95 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
998
2155
|
track(retractApprovalRequest(event));
|
|
999
2156
|
return;
|
|
1000
2157
|
}
|
|
1001
|
-
// A
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
2158
|
+
// A turn ends here in one of two hands. One this adapter started
|
|
2159
|
+
// is finished by the renderer it opened at intake, from the
|
|
2160
|
+
// session `dispatch` returns. One it did not start — a recovery
|
|
2161
|
+
// of a turn parked on a human when the daemon died, or a turn the
|
|
2162
|
+
// startup sweep closed out — has no renderer, and is reported
|
|
2163
|
+
// from the session's stored routing instead.
|
|
1004
2164
|
//
|
|
1005
|
-
// A
|
|
1006
|
-
//
|
|
1007
|
-
//
|
|
1008
|
-
//
|
|
1009
|
-
//
|
|
1010
|
-
//
|
|
1011
|
-
// conservative half of that trade: the alternative reports every
|
|
1012
|
-
// ordinary failure twice.
|
|
2165
|
+
// A renderer in the queue is not proof the outcome is that turn's.
|
|
2166
|
+
// The renderer is queued at intake, before the gateway starts the
|
|
2167
|
+
// turn, so a message arriving while a recovery is still ahead of
|
|
2168
|
+
// it on the session chain has a renderer at the head while the
|
|
2169
|
+
// recovery finishes — and the recovery's reply would be swallowed
|
|
2170
|
+
// as rendered when nothing rendered it.
|
|
1013
2171
|
//
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1016
|
-
// `
|
|
1017
|
-
//
|
|
1018
|
-
//
|
|
1019
|
-
|
|
1020
|
-
|
|
2172
|
+
// Which turn is running is exact when the gateway says so: every
|
|
2173
|
+
// turn this adapter dispatches carries its renderer's `turnId`, and
|
|
2174
|
+
// `activeTurnId` names the one on the session now (`StratusEvent`
|
|
2175
|
+
// itself carries no turn identifier), so a turn another surface
|
|
2176
|
+
// dispatched to the same session is never taken for this adapter's,
|
|
2177
|
+
// whatever order the two started in. A gateway without that answer
|
|
2178
|
+
// leaves the order of events: the session reports `running` as
|
|
2179
|
+
// each turn begins, and the next `running` is taken to be the
|
|
2180
|
+
// head renderer's — right for a recovery, which is always ahead of
|
|
2181
|
+
// the message queued behind it, and wrong only for a foreign turn
|
|
2182
|
+
// that starts after the message was queued.
|
|
2183
|
+
const head = renderers.get(event.sessionId)?.[0];
|
|
2184
|
+
const exact = gateway.activeTurnId !== undefined;
|
|
2185
|
+
const active = gateway.activeTurnId?.(event.sessionId);
|
|
2186
|
+
if (event.type === 'session.updated' && event.status === 'running' && head && (!exact || active === head.turnId)) {
|
|
2187
|
+
head.beginTurn();
|
|
2188
|
+
}
|
|
2189
|
+
// A renderer at the head that has not seen its turn start is
|
|
2190
|
+
// queued behind the turn ending now, and its placeholder is
|
|
2191
|
+
// already in the thread: the outcome goes through it, so the
|
|
2192
|
+
// thread reads in the order the conversation happened.
|
|
2193
|
+
const rendered = exact
|
|
2194
|
+
? active !== undefined && (renderers.get(event.sessionId) ?? []).some((renderer) => renderer.turnId === active)
|
|
2195
|
+
: head?.turnStarted === true;
|
|
2196
|
+
// A file belongs to the turn that produced it. A renderer queued
|
|
2197
|
+
// behind a recovery is not its renderer, and uploading through it
|
|
2198
|
+
// would put the recovery's attachment below the newer turn's
|
|
2199
|
+
// answer — so where the gateway names the running turn, a result
|
|
2200
|
+
// from another one is held for that turn's own outcome instead.
|
|
2201
|
+
// Without that answer the order of events cannot tell a foreign
|
|
2202
|
+
// turn from a renderer still waiting to start, and the renderer
|
|
2203
|
+
// keeps what it is handed, as it always has.
|
|
2204
|
+
const foreign = exact && !rendered;
|
|
2205
|
+
if (event.type === 'tool.completed' && event.result.ok && (foreign || !head)) {
|
|
2206
|
+
const produced = collectFilePaths(event.result);
|
|
2207
|
+
if (produced.length > 0) {
|
|
2208
|
+
const held = [...(unrenderedFiles.get(event.sessionId) ?? []), ...produced];
|
|
2209
|
+
// Said out loud when it bites: these are files somebody asked
|
|
2210
|
+
// for, and attachments that never arrive with nothing in the
|
|
2211
|
+
// log is the hardest kind of loss to work out afterwards.
|
|
2212
|
+
if (held.length > MAX_UNRENDERED_FILES) {
|
|
2213
|
+
warn(`slack: a turn with no renderer has produced ${held.length} files; only the last ${MAX_UNRENDERED_FILES} will be posted to its thread`);
|
|
2214
|
+
}
|
|
2215
|
+
unrenderedFiles.set(event.sessionId, held.slice(-MAX_UNRENDERED_FILES));
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
// Claimed here, synchronously, rather than inside the report: the
|
|
2219
|
+
// report reads the turn's routing from the store first, and the
|
|
2220
|
+
// queued turn can finish inside that round trip (see
|
|
2221
|
+
// `HandoverClaim`). The claim is released either way, so a report
|
|
2222
|
+
// that finds nothing to post — another surface's session, no
|
|
2223
|
+
// connection — never leaves the queued reply stalled behind it.
|
|
2224
|
+
if (event.type === 'session.failed' && !rendered) {
|
|
2225
|
+
const claim = head?.reserveHandover();
|
|
2226
|
+
track(reportUnrenderedFailure(event, claim, takeUnrenderedFiles(event.sessionId)).finally(() => claim?.release()));
|
|
2227
|
+
return;
|
|
2228
|
+
}
|
|
2229
|
+
if (event.type === 'session.completed' && !rendered) {
|
|
2230
|
+
const claim = head?.reserveHandover();
|
|
2231
|
+
track(reportUnrenderedReply(event, claim, takeUnrenderedFiles(event.sessionId)).finally(() => claim?.release()));
|
|
1021
2232
|
return;
|
|
1022
2233
|
}
|
|
1023
2234
|
if ('sessionId' in event) {
|
|
1024
|
-
renderers.get(event.sessionId)?.[0]?.onEvent(event);
|
|
2235
|
+
renderers.get(event.sessionId)?.[0]?.onEvent(event, !foreign);
|
|
1025
2236
|
}
|
|
1026
2237
|
});
|
|
1027
2238
|
const known = new Set(gateway.agents().map((agent) => agent.id));
|
|
2239
|
+
// Two passes, because who a message names has to be answerable from
|
|
2240
|
+
// the first event onwards. Identities are learned for every app
|
|
2241
|
+
// FIRST; only then does any socket start delivering. One pass would
|
|
2242
|
+
// leave the agents at the front of the roster taking messages while
|
|
2243
|
+
// the ones behind them were still authenticating, and a mention of an
|
|
2244
|
+
// agent not yet known reads as an untagged reply — which the agent
|
|
2245
|
+
// holding that thread would then answer.
|
|
2246
|
+
const authenticated = [];
|
|
1028
2247
|
for (const config of options.agents) {
|
|
1029
2248
|
if (!known.has(config.agentId)) {
|
|
1030
2249
|
warn(`slack: no roster agent with id ${config.agentId}; skipping its Slack app`);
|
|
@@ -1033,14 +2252,27 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1033
2252
|
try {
|
|
1034
2253
|
const web = createWeb(config.botToken);
|
|
1035
2254
|
const auth = await web.auth.test();
|
|
1036
|
-
const
|
|
1037
|
-
const
|
|
2255
|
+
const botUserId = auth.user_id ?? '';
|
|
2256
|
+
const teamId = auth.team_id ?? '';
|
|
2257
|
+
// Recorded before the socket is even built: an app that never
|
|
2258
|
+
// comes up must still be recognizable when somebody names it.
|
|
2259
|
+
botIdentities.set(config.agentId, { botUserId, teamId });
|
|
2260
|
+
authenticated.push({
|
|
1038
2261
|
config,
|
|
1039
2262
|
web,
|
|
1040
|
-
socket,
|
|
1041
|
-
botUserId
|
|
1042
|
-
teamId
|
|
1043
|
-
};
|
|
2263
|
+
socket: createSocket(config.appToken),
|
|
2264
|
+
botUserId,
|
|
2265
|
+
teamId,
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
catch (error) {
|
|
2269
|
+
// One broken app must not take the rest of the fleet down.
|
|
2270
|
+
warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
for (const connection of authenticated) {
|
|
2274
|
+
const { config, socket } = connection;
|
|
2275
|
+
try {
|
|
1044
2276
|
const onEvent = (args) => {
|
|
1045
2277
|
const handled = handleInbound(connection, args).catch((error) => {
|
|
1046
2278
|
warn(`slack event handling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -1060,30 +2292,42 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1060
2292
|
log(`slack: ${config.agentId} connected (bot ${connection.botUserId} in team ${connection.teamId})`);
|
|
1061
2293
|
}
|
|
1062
2294
|
catch (error) {
|
|
1063
|
-
//
|
|
2295
|
+
// Authenticated but not serving: it answers nothing, and stays
|
|
2296
|
+
// recognizable so nobody answers in its place.
|
|
1064
2297
|
warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1065
2298
|
}
|
|
1066
2299
|
}
|
|
1067
2300
|
},
|
|
1068
2301
|
async stop() {
|
|
1069
2302
|
// Stop intake first, so no new INBOUND event can slip into
|
|
1070
|
-
// `inflight`
|
|
1071
|
-
//
|
|
1072
|
-
// That is narrower than it used to claim. The snapshot is taken
|
|
1073
|
-
// once, and this unsubscribes from the bus only after it, so a turn
|
|
1074
|
-
// still finishing can emit an event whose subscriber tracks work
|
|
1075
|
-
// this drain never waits for — a retraction posted after stop()
|
|
1076
|
-
// returns, or dropped with the process. Draining until `inflight`
|
|
1077
|
-
// stays empty would close it, and would make stop() wait on turns
|
|
1078
|
-
// the gateway drains after this call rather than before; worth doing
|
|
1079
|
-
// deliberately, not as a side effect.
|
|
2303
|
+
// `inflight` while the drain below runs.
|
|
1080
2304
|
await Promise.allSettled(connections.map((connection) => connection.socket.disconnect()));
|
|
1081
|
-
|
|
2305
|
+
// Until it stays empty, not once. The bus subscription is still live
|
|
2306
|
+
// here — it comes down after the drain, because a turn finishing
|
|
2307
|
+
// inside the drain still has an outcome to render — so a turn the
|
|
2308
|
+
// gateway is finishing can hand `track` work after any one snapshot
|
|
2309
|
+
// was taken. A single snapshot dropped exactly what this drain
|
|
2310
|
+
// exists to deliver: a shutdown denies every parked call, and the
|
|
2311
|
+
// retraction that takes the live buttons off the message is tracked
|
|
2312
|
+
// by a subscriber reacting to that denial, which is to say during
|
|
2313
|
+
// the drain rather than before it.
|
|
2314
|
+
//
|
|
2315
|
+
// This waits for reactions, never for turns: work enters `inflight`
|
|
2316
|
+
// only when an event arrives, so a turn that has not finished
|
|
2317
|
+
// emitting anything is not something the loop can be waiting on. It
|
|
2318
|
+
// ends when the set does, which is when a snapshot drain would have
|
|
2319
|
+
// ended, plus the tail it used to leave behind.
|
|
2320
|
+
while (inflight.size > 0) {
|
|
2321
|
+
await Promise.allSettled([...inflight]);
|
|
2322
|
+
}
|
|
1082
2323
|
unsubscribe?.();
|
|
1083
2324
|
unsubscribe = undefined;
|
|
1084
2325
|
gatewayRef = undefined;
|
|
1085
2326
|
connections.length = 0;
|
|
1086
2327
|
approvalPosts.clear();
|
|
2328
|
+
threadAddressee.clear();
|
|
2329
|
+
coldVerdicts.clear();
|
|
2330
|
+
botIdentities.clear();
|
|
1087
2331
|
rendering.clear();
|
|
1088
2332
|
resolvedWhileRendering.clear();
|
|
1089
2333
|
},
|