@stratusagent/channel-slack 0.11.2 → 0.11.3
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 +110 -18
- package/dist/index.d.ts +105 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1042 -155
- package/dist/index.js.map +1 -1
- package/manifest/stratus-agent.manifest.json +1 -0
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { latestTurnReply, SENDER_TRUST_METADATA_KEY, } from '@stratusagent/core';
|
|
5
|
+
import { filePathsOf, latestTurnReply, SENDER_TRUST_METADATA_KEY, sessionWriteTrust, imageDimensions, isImageAttachmentMediaType, IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENT_MAX_DIMENSION, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES, } from '@stratusagent/core';
|
|
6
6
|
import { channelSessionKey, } from '@stratusagent/channels';
|
|
7
7
|
const SLACK_MAX_MESSAGE_CHARS = 4000;
|
|
8
8
|
const DEFAULT_EDIT_INTERVAL_MS = 1000;
|
|
@@ -21,7 +21,7 @@ const THREAD_ADDRESSEE_CAPACITY = 2000;
|
|
|
21
21
|
*/
|
|
22
22
|
const COLD_VERDICT_CAPACITY = 256;
|
|
23
23
|
/**
|
|
24
|
-
* How many
|
|
24
|
+
* How many claims on one thread it remembers.
|
|
25
25
|
*
|
|
26
26
|
* A message older than all of them asks the sessions instead, which answer
|
|
27
27
|
* for the thread as it stands rather than as it stood — so a reply that old
|
|
@@ -29,13 +29,14 @@ const COLD_VERDICT_CAPACITY = 256;
|
|
|
29
29
|
* bounding this, and something has to bound it: the alternative is a list
|
|
30
30
|
* that grows with a conversation for the life of the daemon.
|
|
31
31
|
*
|
|
32
|
-
*
|
|
32
|
+
* A claim is a change of hands or a reply that landed (see
|
|
33
|
+
* `rememberAddressee`), so a busy thread writes one or two per message.
|
|
34
|
+
* Thirty-two sits far past the case the record covers: it only has to
|
|
33
35
|
* span the lag between two sockets, which is messages inside one delivery
|
|
34
|
-
* window
|
|
35
|
-
*
|
|
36
|
-
* behind for all of them.
|
|
36
|
+
* window, and crossing it needs that many replies in one thread while a
|
|
37
|
+
* socket stays behind for all of them.
|
|
37
38
|
*/
|
|
38
|
-
const THREAD_HANDOVER_DEPTH =
|
|
39
|
+
const THREAD_HANDOVER_DEPTH = 32;
|
|
39
40
|
/**
|
|
40
41
|
* How many files an unrendered turn may queue for its outcome. Nothing
|
|
41
42
|
* drains that queue until the outcome arrives, so a session whose outcome
|
|
@@ -45,6 +46,26 @@ const THREAD_HANDOVER_DEPTH = 8;
|
|
|
45
46
|
*/
|
|
46
47
|
const MAX_UNRENDERED_FILES = 20;
|
|
47
48
|
const PLACEHOLDER_TEXT = '…';
|
|
49
|
+
/**
|
|
50
|
+
* How long an agent that judges stays attentive after it last answered a
|
|
51
|
+
* message that addressed it: this many messages, or this many minutes,
|
|
52
|
+
* whichever ends first. Judging costs
|
|
53
|
+
* a model call per message, and two people talking for an hour is a
|
|
54
|
+
* hundred of them for one "no" — so attention decays the way a person's
|
|
55
|
+
* does. Outside the window a message is overheard for free, and a mention
|
|
56
|
+
* re-arms it, which is what a mention has always meant.
|
|
57
|
+
*/
|
|
58
|
+
const ATTENTION_MESSAGES = 8;
|
|
59
|
+
const ATTENTION_MS = 15 * 60 * 1000;
|
|
60
|
+
/**
|
|
61
|
+
* A 5 MB image over a slow link is seconds, not minutes. Past this the
|
|
62
|
+
* downloads are abandoned and the turn runs with the attachments named as
|
|
63
|
+
* unreadable, rather than the thread waiting on them. One deadline for
|
|
64
|
+
* the whole message, not one per file: a message may carry ten, and ten
|
|
65
|
+
* stalls in a row would hold the thread — and `stop()` — ten times as
|
|
66
|
+
* long.
|
|
67
|
+
*/
|
|
68
|
+
const DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
48
69
|
/** What a turn that produced no text puts in its message, wherever it is posted from. */
|
|
49
70
|
const NO_REPLY_TEXT = '(no reply)';
|
|
50
71
|
// Lazy (and CJS-interoperable) so tests with injected fakes never load the
|
|
@@ -58,6 +79,77 @@ const defaultWebClient = (botToken) => {
|
|
|
58
79
|
const { WebClient } = requireModule('@slack/web-api');
|
|
59
80
|
return new WebClient(botToken);
|
|
60
81
|
};
|
|
82
|
+
/**
|
|
83
|
+
* The default fetcher over a `fetch`. Exported as a factory so the bounded
|
|
84
|
+
* read can be tested against a fake `fetch` with a body that never ends;
|
|
85
|
+
* the adapter uses it over the global one.
|
|
86
|
+
*
|
|
87
|
+
* The body is read a chunk at a time and abandoned the moment it passes
|
|
88
|
+
* `maxBytes`: `arrayBuffer()` would buffer the whole response first, and
|
|
89
|
+
* the size the adapter checked before asking came from Slack's metadata,
|
|
90
|
+
* which an oversized response is under no obligation to match. A
|
|
91
|
+
* `content-length` that already says too much is refused before a byte is
|
|
92
|
+
* read.
|
|
93
|
+
*/
|
|
94
|
+
export const createSlackFileFetcher = (fetchImpl) => async (url, botToken, signal, maxBytes) => {
|
|
95
|
+
const response = await fetchImpl(url, { headers: { authorization: `Bearer ${botToken}` }, signal });
|
|
96
|
+
const contentType = response.headers.get('content-type');
|
|
97
|
+
const typed = contentType !== null ? { contentType } : {};
|
|
98
|
+
const declared = Number(response.headers.get('content-length'));
|
|
99
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
100
|
+
await response.body?.cancel();
|
|
101
|
+
return { status: response.status, ...typed, body: Buffer.alloc(0), truncated: true };
|
|
102
|
+
}
|
|
103
|
+
if (response.body === null) {
|
|
104
|
+
return { status: response.status, ...typed, body: Buffer.alloc(0) };
|
|
105
|
+
}
|
|
106
|
+
const reader = response.body.getReader();
|
|
107
|
+
const chunks = [];
|
|
108
|
+
let received = 0;
|
|
109
|
+
try {
|
|
110
|
+
for (;;) {
|
|
111
|
+
const { done, value } = await reader.read();
|
|
112
|
+
if (done) {
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
received += value.byteLength;
|
|
116
|
+
if (received > maxBytes) {
|
|
117
|
+
await reader.cancel();
|
|
118
|
+
return { status: response.status, ...typed, body: Buffer.alloc(0), truncated: true };
|
|
119
|
+
}
|
|
120
|
+
chunks.push(Buffer.from(value));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
reader.releaseLock();
|
|
125
|
+
}
|
|
126
|
+
return { status: response.status, ...typed, body: Buffer.concat(chunks) };
|
|
127
|
+
};
|
|
128
|
+
const defaultFetchFile = (url, botToken, signal, maxBytes) => createSlackFileFetcher(fetch)(url, botToken, signal, maxBytes);
|
|
129
|
+
/**
|
|
130
|
+
* A clock reading as a Slack timestamp, for a file whose upload answered
|
|
131
|
+
* with no share ts. Slack timestamps are epoch seconds with six decimals,
|
|
132
|
+
* so the clock's reading compares with them as long as the two agree to
|
|
133
|
+
* within the gap between one message and the next.
|
|
134
|
+
*/
|
|
135
|
+
const slackTs = (ms) => (ms / 1000).toFixed(6);
|
|
136
|
+
/**
|
|
137
|
+
* Where an upload sits in the thread: the ts of the message it became in
|
|
138
|
+
* `channel`, from the shares the upload answers with. Undefined when the
|
|
139
|
+
* answer carries none.
|
|
140
|
+
*/
|
|
141
|
+
const uploadPlace = (result, channel) => {
|
|
142
|
+
for (const file of result.files ?? []) {
|
|
143
|
+
for (const scope of [file.shares?.public, file.shares?.private]) {
|
|
144
|
+
for (const share of scope?.[channel] ?? []) {
|
|
145
|
+
if (share.ts !== undefined) {
|
|
146
|
+
return share.ts;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
};
|
|
61
153
|
/**
|
|
62
154
|
* Renders one turn's reply into Slack with the placeholder-then-edit
|
|
63
155
|
* pattern: post `…` immediately, fold streaming deltas and tool status
|
|
@@ -103,17 +195,45 @@ class ReplyRenderer {
|
|
|
103
195
|
*/
|
|
104
196
|
generation = 0;
|
|
105
197
|
finalized = false;
|
|
198
|
+
/** Whether any upload of this turn's landed — a file is something said, placeholder or not. */
|
|
199
|
+
uploaded = false;
|
|
200
|
+
/** Whether any edit of this turn's landed — a streamed line Slack took is something said, whatever the final edit's fate. */
|
|
201
|
+
edited = false;
|
|
202
|
+
/**
|
|
203
|
+
* Where in the thread this turn's words stand: the ts of the lowest
|
|
204
|
+
* message Slack took them in — the placeholder an edit landed in, or an
|
|
205
|
+
* overflow chunk posted — and, separately, where a file of its landed
|
|
206
|
+
* (the share ts the upload answers with, or the clock — see `queueUpload`).
|
|
207
|
+
* What the thread rule orders by: an untagged reply is for whoever's
|
|
208
|
+
* reply sits lowest in the thread, the one a reader sees last, which is
|
|
209
|
+
* not the turn that finished last — a placeholder posted at intake and
|
|
210
|
+
* filled in slowly sits above a reply posted while it was being written.
|
|
211
|
+
*/
|
|
212
|
+
textAt;
|
|
213
|
+
uploadedAt;
|
|
214
|
+
/**
|
|
215
|
+
* A turn nobody asked for: the placeholder is not posted at intake but
|
|
216
|
+
* on the first TEXT the turn streams, and a turn that ends having said
|
|
217
|
+
* nothing leaves the thread untouched — no placeholder, no `(no reply)`,
|
|
218
|
+
* no edit. Text and not a tool line, though the spec sketched either: a
|
|
219
|
+
* placeholder posted for a tool the turn ran on the way to deciding it
|
|
220
|
+
* had nothing to add is a message the decision cannot take back.
|
|
221
|
+
*/
|
|
222
|
+
lazy;
|
|
106
223
|
web;
|
|
107
224
|
channel;
|
|
108
225
|
threadTs;
|
|
109
226
|
editIntervalMs;
|
|
110
227
|
warn;
|
|
111
|
-
|
|
228
|
+
now;
|
|
229
|
+
constructor(web, channel, threadTs, editIntervalMs, warn, options = {}) {
|
|
112
230
|
this.web = web;
|
|
113
231
|
this.channel = channel;
|
|
114
232
|
this.threadTs = threadTs;
|
|
115
233
|
this.editIntervalMs = editIntervalMs;
|
|
116
234
|
this.warn = warn;
|
|
235
|
+
this.lazy = options.lazy ?? false;
|
|
236
|
+
this.now = options.now ?? Date.now;
|
|
117
237
|
}
|
|
118
238
|
/**
|
|
119
239
|
* The gateway has begun this renderer's turn. Whatever was streamed here
|
|
@@ -235,12 +355,27 @@ class ReplyRenderer {
|
|
|
235
355
|
// failure instead of an unhandled stream error.
|
|
236
356
|
.then(async () => {
|
|
237
357
|
const data = await readFile(filePath);
|
|
238
|
-
|
|
358
|
+
// Where the file will sit is Slack's to say — the ts of the
|
|
359
|
+
// message it becomes, in the shares the upload answers with. When
|
|
360
|
+
// the answer carries none, the clock stands in, read BEFORE the
|
|
361
|
+
// call: the file is posted somewhere inside it, and a colleague's
|
|
362
|
+
// placeholder posted while the answer was on its way back sits
|
|
363
|
+
// below the file. Read after, the file would be placed below that
|
|
364
|
+
// placeholder and take a thread the colleague holds; read before,
|
|
365
|
+
// the error runs the other way, toward the colleague — the fault
|
|
366
|
+
// an @ fixes.
|
|
367
|
+
const before = slackTs(this.now());
|
|
368
|
+
const result = await this.web.files.uploadV2({
|
|
239
369
|
channel_id: this.channel,
|
|
240
370
|
file: data,
|
|
241
371
|
filename: path.basename(filePath),
|
|
242
372
|
...(this.threadTs ? { thread_ts: this.threadTs } : {}),
|
|
243
373
|
});
|
|
374
|
+
this.uploaded = true;
|
|
375
|
+
const placed = uploadPlace(result, this.channel) ?? before;
|
|
376
|
+
if (this.uploadedAt === undefined || placed > this.uploadedAt) {
|
|
377
|
+
this.uploadedAt = placed;
|
|
378
|
+
}
|
|
244
379
|
})
|
|
245
380
|
.catch((error) => this.warn(`files.uploadV2 failed for ${filePath}: ${error instanceof Error ? error.message : String(error)}`));
|
|
246
381
|
}
|
|
@@ -261,11 +396,24 @@ class ReplyRenderer {
|
|
|
261
396
|
if (this.finalized) {
|
|
262
397
|
return;
|
|
263
398
|
}
|
|
399
|
+
// Nothing said yet: a lazy turn has no placeholder to edit, and a
|
|
400
|
+
// tool line alone does not earn one (see `lazy`). Nor does text
|
|
401
|
+
// streamed here before this turn began — a recovery's, routed to
|
|
402
|
+
// the head of the queue — which `beginTurn` drops.
|
|
403
|
+
if (this.lazy && !this.ref && (!this.turnStarted || this.buffer.trim().length === 0)) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
264
406
|
this.lastEditAt = Date.now();
|
|
265
|
-
this.queueEdit(messageText(this.currentText()));
|
|
407
|
+
void this.queueEdit(messageText(this.currentText()));
|
|
266
408
|
}, delay);
|
|
267
409
|
this.pendingEdit.unref?.();
|
|
268
410
|
}
|
|
411
|
+
/**
|
|
412
|
+
* Queue one edit of the placeholder. Resolves to whether Slack took it —
|
|
413
|
+
* false when there was no placeholder to edit, the text belonged to a
|
|
414
|
+
* turn handed over meanwhile, or the update was refused — which is what
|
|
415
|
+
* lets `finalize` say whether the reply was ever published.
|
|
416
|
+
*/
|
|
269
417
|
queueEdit(text) {
|
|
270
418
|
const generation = this.generation;
|
|
271
419
|
// The handover as it stands when the edit is queued, not when it runs:
|
|
@@ -273,21 +421,48 @@ class ReplyRenderer {
|
|
|
273
421
|
// edit that then waited for that handover would be a cycle nothing
|
|
274
422
|
// could break.
|
|
275
423
|
const handover = this.handover;
|
|
276
|
-
|
|
424
|
+
const edit = this.editChain
|
|
277
425
|
.then(() => handover)
|
|
278
|
-
.then(() => {
|
|
426
|
+
.then(async () => {
|
|
427
|
+
if (this.lazy && !this.ref && this.turnStarted && generation === this.generation) {
|
|
428
|
+
// The first text of a turn nobody asked for: this is where its
|
|
429
|
+
// placeholder is posted, and the edit below fills it. A post
|
|
430
|
+
// that fails leaves the turn without one, and its reply is a
|
|
431
|
+
// message of its own when it comes (see `finalize`).
|
|
432
|
+
try {
|
|
433
|
+
await this.open();
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
this.warn(`could not open a placeholder: ${error instanceof Error ? error.message : String(error)}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
279
439
|
// Read after the handover, not before: the placeholder may have
|
|
280
440
|
// changed hands while this edit waited its turn — and the text
|
|
281
441
|
// may belong to the turn that was handed over.
|
|
282
442
|
const ref = this.ref;
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
443
|
+
if (!ref || generation !== this.generation) {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
await this.web.chat.update({ channel: ref.channel, ts: ref.ts, text });
|
|
447
|
+
this.edited = true;
|
|
448
|
+
this.placeText(ref.ts);
|
|
449
|
+
return true;
|
|
286
450
|
})
|
|
287
|
-
.
|
|
288
|
-
|
|
451
|
+
.catch((error) => {
|
|
452
|
+
this.warn(`chat.update failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
453
|
+
return false;
|
|
454
|
+
});
|
|
455
|
+
this.editChain = edit.then(() => undefined);
|
|
456
|
+
return edit;
|
|
289
457
|
}
|
|
290
|
-
/**
|
|
458
|
+
/**
|
|
459
|
+
* Replace the placeholder with the final reply, splitting if oversized.
|
|
460
|
+
* Resolves to a `ReplyOutcome`. A reply Slack refused any part of was
|
|
461
|
+
* not said in the thread as written, and nothing downstream may treat it
|
|
462
|
+
* as if it had been: a colleague hearing the whole of a reply the thread
|
|
463
|
+
* saw half of is reasoning from text nobody read. But a file beside a
|
|
464
|
+
* refused sentence is still the last thing said, for the thread rule.
|
|
465
|
+
*/
|
|
291
466
|
async finalize(reply) {
|
|
292
467
|
await this.handover;
|
|
293
468
|
this.finalized = true;
|
|
@@ -295,31 +470,121 @@ class ReplyRenderer {
|
|
|
295
470
|
clearTimeout(this.pendingEdit);
|
|
296
471
|
this.pendingEdit = undefined;
|
|
297
472
|
}
|
|
473
|
+
// Let the edits already queued land before deciding anything from
|
|
474
|
+
// `ref`: a lazy turn's first text may be opening its placeholder in
|
|
475
|
+
// that chain right now, and reading `ref` ahead of it would post the
|
|
476
|
+
// reply as a message of its own beside a placeholder that then fills
|
|
477
|
+
// with stale partial text.
|
|
478
|
+
await this.editChain;
|
|
479
|
+
if (this.lazy && reply.trim().length === 0 && (!this.ref || await this.retract())) {
|
|
480
|
+
// Silence, decided: nothing was posted and nothing is — or what was
|
|
481
|
+
// posted has been taken back. The uploads still land: a file the
|
|
482
|
+
// turn produced is not nothing to say, and a turn that posted one
|
|
483
|
+
// has spoken, for the thread rule.
|
|
484
|
+
await this.editChain;
|
|
485
|
+
await this.uploadChain;
|
|
486
|
+
return this.outcome(false, this.uploaded);
|
|
487
|
+
}
|
|
298
488
|
const text = reply.trim().length > 0 ? reply : NO_REPLY_TEXT;
|
|
299
489
|
const chunks = messageChunks(text);
|
|
300
490
|
// No placeholder — a handover could not open a fresh one — and the
|
|
301
491
|
// reply is a message of its own rather than nothing at all.
|
|
302
492
|
const rest = this.ref ? chunks.slice(1) : chunks;
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
493
|
+
const edited = this.ref ? this.queueEdit(chunks[0] ?? NO_REPLY_TEXT) : Promise.resolve(false);
|
|
494
|
+
let landed = await edited;
|
|
495
|
+
let published = this.ref ? landed : true;
|
|
306
496
|
await this.editChain;
|
|
307
497
|
await this.uploadChain;
|
|
308
498
|
for (const chunk of rest) {
|
|
309
499
|
try {
|
|
310
|
-
await this.web.chat.postMessage({
|
|
500
|
+
const posted = await this.web.chat.postMessage({
|
|
311
501
|
channel: this.channel,
|
|
312
502
|
text: chunk,
|
|
313
503
|
...(this.threadTs ? { thread_ts: this.threadTs } : {}),
|
|
314
504
|
});
|
|
505
|
+
landed = true;
|
|
506
|
+
this.placeText(posted.ts);
|
|
315
507
|
}
|
|
316
508
|
catch (error) {
|
|
509
|
+
published = false;
|
|
317
510
|
this.warn(`chat.postMessage failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
318
511
|
}
|
|
319
512
|
}
|
|
513
|
+
// A placeholder that opened for a turn nobody asked for and never
|
|
514
|
+
// took a word of it — every edit refused — is a `…` standing alone
|
|
515
|
+
// under the agent's name, the interruption a judged turn exists to
|
|
516
|
+
// avoid, so it is taken back like an abandoned attempt's. One Slack
|
|
517
|
+
// will not let go stands, and a message the thread can see is the
|
|
518
|
+
// last thing said whatever it says.
|
|
519
|
+
let stranded = false;
|
|
520
|
+
if (this.lazy && this.ref !== undefined && !landed && !this.edited) {
|
|
521
|
+
stranded = !(await this.retract());
|
|
522
|
+
if (stranded) {
|
|
523
|
+
this.placeText(this.ref?.ts);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return this.outcome(published, landed || this.edited || this.uploaded || stranded);
|
|
527
|
+
}
|
|
528
|
+
placeText(ts) {
|
|
529
|
+
if (ts !== undefined && (this.textAt === undefined || ts > this.textAt)) {
|
|
530
|
+
this.textAt = ts;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
outcome(published, spoke) {
|
|
534
|
+
const spokeAt = [this.textAt, this.uploadedAt]
|
|
535
|
+
.filter((ts) => ts !== undefined)
|
|
536
|
+
.sort()
|
|
537
|
+
.at(-1);
|
|
538
|
+
return { published, spoke, ...(spoke && spokeAt !== undefined ? { spokeAt } : {}) };
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Take back the placeholder a turn nobody asked for opened and then had
|
|
542
|
+
* nothing to put in: the line that opened it was an attempt the provider
|
|
543
|
+
* abandoned (`reset` — a fallback after a mid-stream failure), and the
|
|
544
|
+
* attempt that stood answered with nothing. Editing that placeholder to
|
|
545
|
+
* `(no reply)` would leave exactly the interruption a silent turn exists
|
|
546
|
+
* to avoid, and would count as speaking. Resolves to whether Slack let
|
|
547
|
+
* the message go: a delete refused leaves the abandoned line standing,
|
|
548
|
+
* and the turn finishes the ordinary way, since a message the thread
|
|
549
|
+
* can still see is the last thing said whatever it says.
|
|
550
|
+
*/
|
|
551
|
+
async retract() {
|
|
552
|
+
const ref = this.ref;
|
|
553
|
+
if (!ref) {
|
|
554
|
+
return true;
|
|
555
|
+
}
|
|
556
|
+
try {
|
|
557
|
+
await this.web.chat.delete({ channel: ref.channel, ts: ref.ts });
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
this.warn(`chat.delete failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
this.ref = undefined;
|
|
564
|
+
// The line the placeholder took is gone with it: nothing of this
|
|
565
|
+
// turn's stands in the thread unless a file does.
|
|
566
|
+
this.edited = false;
|
|
567
|
+
this.textAt = undefined;
|
|
568
|
+
return true;
|
|
320
569
|
}
|
|
321
570
|
async fail(message) {
|
|
322
|
-
|
|
571
|
+
// The same wait `finalize` takes, for the same reason: a lazy turn's
|
|
572
|
+
// first text may be opening its placeholder right now, and a failure
|
|
573
|
+
// read as "before saying anything" would leave that placeholder saying
|
|
574
|
+
// `(no reply)` where the failure belongs.
|
|
575
|
+
await this.editChain;
|
|
576
|
+
// "Before saying anything" is no placeholder AND nothing buffered: a
|
|
577
|
+
// first text whose zero-delay edit has not fired yet has still been
|
|
578
|
+
// said, and its failure is reported — as a message of its own, since
|
|
579
|
+
// there is no placeholder to edit — rather than swallowed with it.
|
|
580
|
+
if (this.lazy && !this.ref && this.buffer.trim().length === 0) {
|
|
581
|
+
// A turn nobody asked for that failed before saying anything: the
|
|
582
|
+
// failure is in the daemon log, and an error note would be the
|
|
583
|
+
// interruption the turn existed to avoid.
|
|
584
|
+
this.warn(`a turn nobody asked for failed before saying anything: ${message}`);
|
|
585
|
+
return this.finalize('');
|
|
586
|
+
}
|
|
587
|
+
return this.finalize(`Something went wrong: ${message}`);
|
|
323
588
|
}
|
|
324
589
|
/**
|
|
325
590
|
* Claim the placeholder for a turn that finished ahead of this one — the
|
|
@@ -357,7 +622,7 @@ class ReplyRenderer {
|
|
|
357
622
|
return {
|
|
358
623
|
take: async (chunks, between) => {
|
|
359
624
|
if (spent) {
|
|
360
|
-
return
|
|
625
|
+
return 'untaken';
|
|
361
626
|
}
|
|
362
627
|
try {
|
|
363
628
|
await previous;
|
|
@@ -380,7 +645,7 @@ class ReplyRenderer {
|
|
|
380
645
|
const [first, ...rest] = chunks;
|
|
381
646
|
const given = this.ref;
|
|
382
647
|
if (first === undefined || this.finalized || !given) {
|
|
383
|
-
return
|
|
648
|
+
return 'untaken';
|
|
384
649
|
}
|
|
385
650
|
await drained;
|
|
386
651
|
try {
|
|
@@ -390,8 +655,9 @@ class ReplyRenderer {
|
|
|
390
655
|
// Not handed over: the placeholder is still this turn's, and the
|
|
391
656
|
// caller posts the reply as messages of its own.
|
|
392
657
|
this.warn(`chat.update failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
393
|
-
return
|
|
658
|
+
return 'untaken';
|
|
394
659
|
}
|
|
660
|
+
let complete = true;
|
|
395
661
|
// The placeholder is the earlier turn's now, and the reference goes
|
|
396
662
|
// before the reopen, so a reopen that fails cannot leave this turn
|
|
397
663
|
// writing over the reply just handed over. (What was streamed into
|
|
@@ -407,6 +673,7 @@ class ReplyRenderer {
|
|
|
407
673
|
});
|
|
408
674
|
}
|
|
409
675
|
catch (error) {
|
|
676
|
+
complete = false;
|
|
410
677
|
this.warn(`chat.postMessage failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
411
678
|
}
|
|
412
679
|
}
|
|
@@ -425,32 +692,13 @@ class ReplyRenderer {
|
|
|
425
692
|
// message of its own when it comes (see `finalize`).
|
|
426
693
|
this.warn(`could not reopen a placeholder: ${error instanceof Error ? error.message : String(error)}`);
|
|
427
694
|
}
|
|
428
|
-
return
|
|
695
|
+
return complete ? 'complete' : 'partial';
|
|
429
696
|
}
|
|
430
697
|
}
|
|
431
|
-
// The
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
const collectFilePaths = (result) =>
|
|
435
|
-
const output = result.output;
|
|
436
|
-
if (typeof output !== 'object' || output === null || Array.isArray(output)) {
|
|
437
|
-
return [];
|
|
438
|
-
}
|
|
439
|
-
const paths = [];
|
|
440
|
-
const single = output.file;
|
|
441
|
-
if (typeof single === 'string' && single.length > 0) {
|
|
442
|
-
paths.push(single);
|
|
443
|
-
}
|
|
444
|
-
const many = output.files;
|
|
445
|
-
if (Array.isArray(many)) {
|
|
446
|
-
for (const entry of many) {
|
|
447
|
-
if (typeof entry === 'string' && entry.length > 0) {
|
|
448
|
-
paths.push(entry);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
return paths;
|
|
453
|
-
};
|
|
698
|
+
// The file-bearing result convention is the kernel's (`filePathsOf`), so
|
|
699
|
+
// the gateway counts a turn that produced a file as having spoken by the
|
|
700
|
+
// same rule this adapter uploads it by.
|
|
701
|
+
const collectFilePaths = (result) => filePathsOf(result);
|
|
454
702
|
// A hard cut must never land between the halves of a UTF-16 surrogate
|
|
455
703
|
// pair — an emoji on the boundary would reach Slack as two replacement
|
|
456
704
|
// characters. Backs the index off the pair's high half when it would.
|
|
@@ -688,6 +936,57 @@ const convertHeadings = (text, code) => {
|
|
|
688
936
|
return startsInside(closer, within) ? heading : `*${heading}*`;
|
|
689
937
|
});
|
|
690
938
|
};
|
|
939
|
+
/**
|
|
940
|
+
* A stand-in for one code segment while the prose around it is rewritten.
|
|
941
|
+
*
|
|
942
|
+
* Emphasis was converted one prose fragment at a time until this existed,
|
|
943
|
+
* and a fragment is not a run: inline code cuts one in two, so the `**`
|
|
944
|
+
* opening a bold run and the `**` closing it land in different fragments
|
|
945
|
+
* whenever a snippet sits between them, and neither half ever finds its
|
|
946
|
+
* partner. ``**the `fs.read` tool**`` reached a reader as four literal
|
|
947
|
+
* asterisks around a snippet, and so did every link, italic and
|
|
948
|
+
* strikethrough written the same way. It is the defect #142 found in
|
|
949
|
+
* headings, in the rule right next to it, and it has the same answer:
|
|
950
|
+
* rewrite the whole text, with code held out of the rewrite rather than
|
|
951
|
+
* cut out of the text.
|
|
952
|
+
*
|
|
953
|
+
* A mask names its segment rather than merely standing in for one, because
|
|
954
|
+
* a rewrite is allowed to move it: the link rule writes its two captures
|
|
955
|
+
* back in the other order, so `[use `a`](https://host/`b`)` hands the URL's
|
|
956
|
+
* snippet to the label and the label's to the URL, and a snippet that
|
|
957
|
+
* arrives somewhere else is the same defect as one that is rewritten. Named,
|
|
958
|
+
* it comes back to the place holding its own name. No rewrite can damage
|
|
959
|
+
* the name itself: none of the delimiters they look for appear in it, and
|
|
960
|
+
* every rule copies what it matched through to its replacement.
|
|
961
|
+
*
|
|
962
|
+
* A mask also carries whether its segment holds whitespace, because three
|
|
963
|
+
* rules refuse to cross it — a destination is `[^()\s]+`, a label and an
|
|
964
|
+
* emphasis run stop at a newline — and a name alone has none to stop them.
|
|
965
|
+
* `[label](https://host/`a b`)` is not a destination and was never meant to
|
|
966
|
+
* convert; masked shapelessly it did, and came back rearranged into a link
|
|
967
|
+
* Slack cannot render. Only whitespace is carried, and only ever so a rule
|
|
968
|
+
* refuses: a `*` in a snippet must still not act like one. Both ends of a
|
|
969
|
+
* mask stay the mask character, so a run that has to hug its content still
|
|
970
|
+
* hugs it — a segment opens and closes with a backtick, so that is not a
|
|
971
|
+
* lie about its ends.
|
|
972
|
+
*
|
|
973
|
+
* The character is chosen against the text rather than fixed, because a
|
|
974
|
+
* reply that already contained it would have a snippet spliced in where
|
|
975
|
+
* the writer's own character stood — the one thing this conversion must
|
|
976
|
+
* never do. A reply holding all 6400 of the private-use characters has no
|
|
977
|
+
* mask to be given and is left in Markdown instead: prose that reads as it
|
|
978
|
+
* was written is a cosmetic loss, and a snippet spliced somewhere else is
|
|
979
|
+
* not.
|
|
980
|
+
*/
|
|
981
|
+
const maskFor = (text) => {
|
|
982
|
+
for (let point = 0xe000; point <= 0xf8ff; point += 1) {
|
|
983
|
+
const candidate = String.fromCharCode(point);
|
|
984
|
+
if (!text.includes(candidate)) {
|
|
985
|
+
return candidate;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
return undefined;
|
|
989
|
+
};
|
|
691
990
|
/**
|
|
692
991
|
* Markdown as a model writes it, in the spelling Slack actually renders.
|
|
693
992
|
*
|
|
@@ -703,28 +1002,49 @@ const convertHeadings = (text, code) => {
|
|
|
703
1002
|
* add ways to be wrong and fix nothing.
|
|
704
1003
|
*/
|
|
705
1004
|
const toSlackMrkdwn = (text) => {
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
1005
|
+
const mask = maskFor(text);
|
|
1006
|
+
if (mask === undefined) {
|
|
1007
|
+
return text;
|
|
1008
|
+
}
|
|
709
1009
|
const segments = proseAndCode(text);
|
|
710
|
-
|
|
711
|
-
|
|
1010
|
+
const code = [];
|
|
1011
|
+
let masked = '';
|
|
1012
|
+
for (const [index, segment] of segments.entries()) {
|
|
712
1013
|
if (index % 2 === 1) {
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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;
|
|
1014
|
+
const shape = /\n/.test(segment) ? '\n' : /\s/.test(segment) ? ' ' : '';
|
|
1015
|
+
masked += `${mask}${code.length}${shape}${mask}`;
|
|
1016
|
+
code.push(segment);
|
|
1017
|
+
continue;
|
|
724
1018
|
}
|
|
725
|
-
|
|
726
|
-
}
|
|
727
|
-
|
|
1019
|
+
masked += segment;
|
|
1020
|
+
}
|
|
1021
|
+
// A code segment that ends the text is the unterminated fence: every
|
|
1022
|
+
// closed run is followed by a prose slice, even an empty one, so only an
|
|
1023
|
+
// unclosed one leaves the segments ending on a code index. Named, not
|
|
1024
|
+
// last: a rewrite may have moved it, and it is the segment that ran to
|
|
1025
|
+
// the text's end when it was cut, wherever it now sits.
|
|
1026
|
+
const unterminated = segments.length % 2 === 0 ? code.length - 1 : -1;
|
|
1027
|
+
// Where each code segment ends up in the converted text, which is not
|
|
1028
|
+
// where it started: the prose around it changes length as it is
|
|
1029
|
+
// rewritten, and the masks are the only record of where it went. Split
|
|
1030
|
+
// on the names, so the pieces alternate prose and the name of the
|
|
1031
|
+
// segment that follows it.
|
|
1032
|
+
const pieces = convertInline(masked).split(new RegExp(`${mask}(\\d+)[ \\n]?${mask}`));
|
|
1033
|
+
const spans = [];
|
|
1034
|
+
let converted = pieces[0] ?? '';
|
|
1035
|
+
for (let piece = 1; piece < pieces.length; piece += 2) {
|
|
1036
|
+
const named = Number(pieces[piece]);
|
|
1037
|
+
const segment = code[named] ?? '';
|
|
1038
|
+
// The unterminated fence has no end to be past, so nothing may be
|
|
1039
|
+
// appended after it either — recording the end as it looks would say a
|
|
1040
|
+
// marker placed at the text's end was safely outside.
|
|
1041
|
+
const ends = named === unterminated
|
|
1042
|
+
? Number.POSITIVE_INFINITY
|
|
1043
|
+
: converted.length + segment.length;
|
|
1044
|
+
spans.push([converted.length, ends]);
|
|
1045
|
+
converted += `${segment}${pieces[piece + 1] ?? ''}`;
|
|
1046
|
+
}
|
|
1047
|
+
return convertHeadings(converted, spans);
|
|
728
1048
|
};
|
|
729
1049
|
const splitForSlack = (text) => {
|
|
730
1050
|
if (text.length <= SLACK_MAX_MESSAGE_CHARS) {
|
|
@@ -775,23 +1095,17 @@ const APPROVAL_ACTIONS = {
|
|
|
775
1095
|
* agent does unattended, and an approver should see the scope they granted
|
|
776
1096
|
* in the record of granting it.
|
|
777
1097
|
*
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
*
|
|
784
|
-
*
|
|
785
|
-
*
|
|
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.
|
|
1098
|
+
* What it lasts rode in on the request as `always` (see `AlwaysMeans`),
|
|
1099
|
+
* so the line names the grant that was made rather than hedging between
|
|
1100
|
+
* lifetimes — which this message once had to, when nothing on the bus said
|
|
1101
|
+
* whether an "always" was the session's or the file's. One thing the
|
|
1102
|
+
* request still cannot promise is the disk write: the daemon does not
|
|
1103
|
+
* write over a whitelist it could not read, and by then this message has
|
|
1104
|
+
* been sent. The daemon's log has that line; `stratus grants <agent>` has
|
|
1105
|
+
* the list.
|
|
791
1106
|
*/
|
|
792
1107
|
const OUTCOME_TEXT = {
|
|
793
1108
|
'decided:once': 'Allowed once',
|
|
794
|
-
'decided:always': 'Allowed and remembered — for this session at least',
|
|
795
1109
|
'decided:deny': 'Denied',
|
|
796
1110
|
'timeout:deny': 'Expired without an answer — denied',
|
|
797
1111
|
// Covers both endings that reach here: the turn was aborted, and the
|
|
@@ -799,6 +1113,49 @@ const OUTCOME_TEXT = {
|
|
|
799
1113
|
'cancelled:deny': 'Cancelled before anyone answered — denied',
|
|
800
1114
|
'undeliverable:deny': 'Could not be put to an approver — denied',
|
|
801
1115
|
};
|
|
1116
|
+
/**
|
|
1117
|
+
* What **Always allow** did, by the grant the request said it would make.
|
|
1118
|
+
* A tool grant names the agent because that is its scope — not this
|
|
1119
|
+
* thread, not the session — and says how to take it back, since a grant
|
|
1120
|
+
* an approver cannot find later is a ratchet.
|
|
1121
|
+
*/
|
|
1122
|
+
const alwaysOutcome = (always, agentName, agentId) => {
|
|
1123
|
+
switch (always) {
|
|
1124
|
+
case 'scope':
|
|
1125
|
+
return 'Allowed, and this command\'s scope now runs without asking — until revoked';
|
|
1126
|
+
case 'origin':
|
|
1127
|
+
return 'Allowed, and this site is now acted on without asking — until revoked';
|
|
1128
|
+
case 'tool':
|
|
1129
|
+
return `Allowed, and granted to ${escapeSlackText(agentName)} until revoked (stratus grants ${escapeSlackText(agentId)})`;
|
|
1130
|
+
case 'session':
|
|
1131
|
+
return 'Allowed for the rest of this session';
|
|
1132
|
+
default:
|
|
1133
|
+
// An `always` submitted through `POST /approvals` against a request
|
|
1134
|
+
// this daemon did not label — an older daemon's event, say. The
|
|
1135
|
+
// request did not say, so neither does this.
|
|
1136
|
+
return 'Allowed and remembered — for this session at least';
|
|
1137
|
+
}
|
|
1138
|
+
};
|
|
1139
|
+
/**
|
|
1140
|
+
* What **Always allow** would do, said beside the button. The lifetime is
|
|
1141
|
+
* the half of the question the button widens, and it differs by tool: a
|
|
1142
|
+
* standing grant to the agent is a larger thing to hand out than a session,
|
|
1143
|
+
* and an approver should know which they are clicking.
|
|
1144
|
+
*/
|
|
1145
|
+
const alwaysOffer = (always, agentName) => {
|
|
1146
|
+
switch (always) {
|
|
1147
|
+
case 'tool':
|
|
1148
|
+
return `*Always allow* grants this tool to ${escapeSlackText(agentName)} until an operator revokes it.`;
|
|
1149
|
+
case 'session':
|
|
1150
|
+
return '*Always allow* stops this tool asking again for the rest of this session.';
|
|
1151
|
+
case 'scope':
|
|
1152
|
+
return '*Always allow* remembers this command\'s scope for this agent, until revoked.';
|
|
1153
|
+
case 'origin':
|
|
1154
|
+
return '*Always allow* remembers this site for this agent, until revoked.';
|
|
1155
|
+
default:
|
|
1156
|
+
return undefined;
|
|
1157
|
+
}
|
|
1158
|
+
};
|
|
802
1159
|
/** Comfortably inside Slack's 3000-character section limit. */
|
|
803
1160
|
const APPROVAL_INPUT_LIMIT = 1400;
|
|
804
1161
|
/** One line in a notification; the blocks carry the readable version. */
|
|
@@ -824,10 +1181,11 @@ const MAX_LISTED_ATTACHMENTS = 5;
|
|
|
824
1181
|
* What a message's attachments are called, for a turn that cannot open
|
|
825
1182
|
* them. Without this the agent is handed "here's the log" and no log, and
|
|
826
1183
|
* answers as though it had read something — the note is what lets it say
|
|
827
|
-
* the true thing instead.
|
|
1184
|
+
* the true thing instead. Images the model was actually shown are not
|
|
1185
|
+
* listed here: they travel on the dispatch, name included.
|
|
828
1186
|
*/
|
|
829
1187
|
const attachmentNote = (files) => {
|
|
830
|
-
if (
|
|
1188
|
+
if (files.length === 0) {
|
|
831
1189
|
return '';
|
|
832
1190
|
}
|
|
833
1191
|
const names = files.slice(0, MAX_LISTED_ATTACHMENTS).map((file) => file.name ?? file.title ?? 'an unnamed file');
|
|
@@ -835,6 +1193,150 @@ const attachmentNote = (files) => {
|
|
|
835
1193
|
const listed = rest > 0 ? `${names.join(', ')}, and ${rest} more` : names.join(', ');
|
|
836
1194
|
return `\n[Attached: ${listed}. Attachment contents cannot be read here — say so rather than guessing at them.]`;
|
|
837
1195
|
};
|
|
1196
|
+
/** What Slack calls a file, in a warning about it. */
|
|
1197
|
+
const fileLabel = (file) => file.name ?? file.title ?? file.id ?? 'an unnamed file';
|
|
1198
|
+
/**
|
|
1199
|
+
* A message's attachments, sorted into the images the model will be shown
|
|
1200
|
+
* and everything else. An image is fetched only when Slack's own metadata
|
|
1201
|
+
* says it is one the API accepts and small enough to send; that keeps a
|
|
1202
|
+
* 40 MB PNG from being downloaded just to be dropped, and a PDF from being
|
|
1203
|
+
* fetched at all. What comes back is checked as well: without `files:read`
|
|
1204
|
+
* Slack serves a sign-in page with a 200, and sending that to the model as
|
|
1205
|
+
* an image would fail the turn with an error naming nothing the person can
|
|
1206
|
+
* fix — the warning here names the scope instead, and the file falls back
|
|
1207
|
+
* to the by-name note like any other attachment the turn cannot open.
|
|
1208
|
+
*/
|
|
1209
|
+
const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn) => {
|
|
1210
|
+
// Decided from the last file back, the way the replay window is spent:
|
|
1211
|
+
// when a message's images do not all fit, the ones kept are the ones it
|
|
1212
|
+
// listed last, so intake and replay agree about which survive. Delivered
|
|
1213
|
+
// in the message's own order, because that is how the person sees them.
|
|
1214
|
+
const kept = new Map();
|
|
1215
|
+
const dropped = new Set();
|
|
1216
|
+
// The message's one deadline, shared by every download in it.
|
|
1217
|
+
const deadline = AbortSignal.timeout(timeoutMs);
|
|
1218
|
+
// Decoded bytes accepted so far. Checked against Slack's reported size
|
|
1219
|
+
// before a download and the real length after it, because the message
|
|
1220
|
+
// as a whole has a budget the per-image cap alone cannot keep.
|
|
1221
|
+
let total = 0;
|
|
1222
|
+
// Once an image that fits on its own does not fit what is left of the
|
|
1223
|
+
// message's budget, nothing listed before it is taken either: the window
|
|
1224
|
+
// is contiguous, as the replay window is, and a smaller older image must
|
|
1225
|
+
// not slip in past a larger newer one that was left out.
|
|
1226
|
+
let windowClosed = false;
|
|
1227
|
+
// Why an image is being left out, or nothing when it fits. One place for
|
|
1228
|
+
// both limits so the warning and the fallback cannot disagree.
|
|
1229
|
+
const overLimit = (label, bytes) => {
|
|
1230
|
+
if (bytes > IMAGE_ATTACHMENT_MAX_BYTES) {
|
|
1231
|
+
return `slack: ${label} is ${bytes} bytes, over the ${IMAGE_ATTACHMENT_MAX_BYTES}-byte limit an image can be sent to the model at; the turn is told it cannot be read.`;
|
|
1232
|
+
}
|
|
1233
|
+
if (total + bytes > IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES) {
|
|
1234
|
+
windowClosed = true;
|
|
1235
|
+
return `slack: ${label} would take this message's images past the ${IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES} bytes one request can carry; the turn is told it cannot be read. Send it in a message of its own.`;
|
|
1236
|
+
}
|
|
1237
|
+
return undefined;
|
|
1238
|
+
};
|
|
1239
|
+
for (let position = files.length - 1; position >= 0; position -= 1) {
|
|
1240
|
+
const file = files[position];
|
|
1241
|
+
const url = file.url_private_download ?? file.url_private;
|
|
1242
|
+
if (!isImageAttachmentMediaType(file.mimetype) || url === undefined) {
|
|
1243
|
+
dropped.add(position);
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
if (windowClosed) {
|
|
1247
|
+
warn(`slack: ${fileLabel(file)} was not taken: an image listed after it already filled what this message's images can carry; the turn is told it cannot be read.`);
|
|
1248
|
+
dropped.add(position);
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
const tooBig = file.size === undefined ? undefined : overLimit(fileLabel(file), file.size);
|
|
1252
|
+
if (tooBig !== undefined) {
|
|
1253
|
+
warn(tooBig);
|
|
1254
|
+
dropped.add(position);
|
|
1255
|
+
continue;
|
|
1256
|
+
}
|
|
1257
|
+
// What this image may weigh: the per-image cap, or what is left of the
|
|
1258
|
+
// message's budget if that is less. Handed to the fetcher so an
|
|
1259
|
+
// oversized response is abandoned mid-body rather than buffered whole.
|
|
1260
|
+
const maxBytes = Math.min(IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES - total);
|
|
1261
|
+
if (deadline.aborted) {
|
|
1262
|
+
warn(`slack: ${fileLabel(file)} was not downloaded: this message's downloads had already taken longer than ${timeoutMs}ms. The turn is told it cannot be read.`);
|
|
1263
|
+
dropped.add(position);
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
let download;
|
|
1267
|
+
try {
|
|
1268
|
+
download = await fetchFile(url, botToken, deadline, maxBytes);
|
|
1269
|
+
}
|
|
1270
|
+
catch (error) {
|
|
1271
|
+
const reason = error instanceof Error && error.name === 'TimeoutError'
|
|
1272
|
+
? `this message's downloads took longer than ${timeoutMs}ms`
|
|
1273
|
+
: (error instanceof Error ? error.message : String(error));
|
|
1274
|
+
warn(`slack: could not download ${fileLabel(file)}: ${reason}. The turn is told it cannot be read.`);
|
|
1275
|
+
dropped.add(position);
|
|
1276
|
+
continue;
|
|
1277
|
+
}
|
|
1278
|
+
// Refused on what Slack sends when it will not serve the file — a
|
|
1279
|
+
// non-200, or a 200 whose body is a page — rather than on the label of
|
|
1280
|
+
// what it does serve: a download URL may be typed as the image or as
|
|
1281
|
+
// a plain octet stream, and either is the bytes Slack said it was.
|
|
1282
|
+
const contentType = download.contentType?.split(';')[0]?.trim().toLowerCase() ?? '';
|
|
1283
|
+
if (download.status !== 200 || contentType.startsWith('text/')) {
|
|
1284
|
+
warn(`slack: ${fileLabel(file)} came back as ${contentType || 'no content type'} (HTTP ${download.status}) rather than an image. This is what a bot token without the files:read scope gets — add the scope under OAuth & Permissions and reinstall the app.`);
|
|
1285
|
+
dropped.add(position);
|
|
1286
|
+
continue;
|
|
1287
|
+
}
|
|
1288
|
+
if (download.truncated === true) {
|
|
1289
|
+
// Cut off at what was left of the message's budget, rather than at
|
|
1290
|
+
// the per-image cap: the same overflow the size check closes the
|
|
1291
|
+
// window on, learned from the bytes instead of the metadata.
|
|
1292
|
+
if (maxBytes < IMAGE_ATTACHMENT_MAX_BYTES) {
|
|
1293
|
+
windowClosed = true;
|
|
1294
|
+
}
|
|
1295
|
+
warn(`slack: ${fileLabel(file)} is larger than the ${maxBytes} bytes this message could still take for an image; the download was abandoned and the turn is told it cannot be read.`);
|
|
1296
|
+
dropped.add(position);
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
const stillTooBig = overLimit(fileLabel(file), download.body.length);
|
|
1300
|
+
if (stillTooBig !== undefined) {
|
|
1301
|
+
warn(stillTooBig);
|
|
1302
|
+
dropped.add(position);
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
// Read from the bytes, not from Slack's metadata: an image the model
|
|
1306
|
+
// API refuses fails the turn it arrives on and, once stored, every turn
|
|
1307
|
+
// after — so what is stored has to be what the API takes.
|
|
1308
|
+
const dimensions = imageDimensions(download.body, file.mimetype);
|
|
1309
|
+
if (dimensions === undefined) {
|
|
1310
|
+
warn(`slack: ${fileLabel(file)} is not a complete ${file.mimetype} — its header or trailer is missing; the turn is told it cannot be read.`);
|
|
1311
|
+
dropped.add(position);
|
|
1312
|
+
continue;
|
|
1313
|
+
}
|
|
1314
|
+
if (dimensions.width > IMAGE_ATTACHMENT_MAX_DIMENSION || dimensions.height > IMAGE_ATTACHMENT_MAX_DIMENSION) {
|
|
1315
|
+
warn(`slack: ${fileLabel(file)} is ${dimensions.width}×${dimensions.height}, over the ${IMAGE_ATTACHMENT_MAX_DIMENSION}-pixel side the model can take; the turn is told it cannot be read.`);
|
|
1316
|
+
dropped.add(position);
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1319
|
+
total += download.body.length;
|
|
1320
|
+
const name = file.name ?? file.title;
|
|
1321
|
+
kept.set(position, {
|
|
1322
|
+
mediaType: file.mimetype,
|
|
1323
|
+
data: download.body.toString('base64'),
|
|
1324
|
+
...(name !== undefined ? { name } : {}),
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
const images = [];
|
|
1328
|
+
const unread = [];
|
|
1329
|
+
files.forEach((file, position) => {
|
|
1330
|
+
const image = kept.get(position);
|
|
1331
|
+
if (image !== undefined) {
|
|
1332
|
+
images.push(image);
|
|
1333
|
+
}
|
|
1334
|
+
else if (dropped.has(position)) {
|
|
1335
|
+
unread.push(file);
|
|
1336
|
+
}
|
|
1337
|
+
});
|
|
1338
|
+
return { images, unread };
|
|
1339
|
+
};
|
|
838
1340
|
/**
|
|
839
1341
|
* Whether `text` addresses a bot user by name. Slack writes a mention as
|
|
840
1342
|
* `<@U123>` markup whoever typed it, so this is the same question for an
|
|
@@ -877,8 +1379,9 @@ const renderInvocation = (input) => {
|
|
|
877
1379
|
truncated,
|
|
878
1380
|
};
|
|
879
1381
|
};
|
|
880
|
-
const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, oneShot) => {
|
|
1382
|
+
const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, oneShot, always) => {
|
|
881
1383
|
const invocation = renderInvocation(input);
|
|
1384
|
+
const offer = oneShot ? undefined : alwaysOffer(always, agentName);
|
|
882
1385
|
return [
|
|
883
1386
|
{
|
|
884
1387
|
type: 'section',
|
|
@@ -905,7 +1408,8 @@ const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, one
|
|
|
905
1408
|
elements: [{ type: 'mrkdwn', text: ':warning: Arguments shown are truncated — the full call is longer than this.' }],
|
|
906
1409
|
}]
|
|
907
1410
|
: []),
|
|
908
|
-
// Why there is no second button, when there is no second button
|
|
1411
|
+
// Why there is no second button, when there is no second button — and
|
|
1412
|
+
// what the second button does, when there is one.
|
|
909
1413
|
...(oneShot
|
|
910
1414
|
? [{
|
|
911
1415
|
type: 'context',
|
|
@@ -914,7 +1418,9 @@ const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, one
|
|
|
914
1418
|
text: ':lock: An approval covers this call only — nothing about it is remembered.',
|
|
915
1419
|
}],
|
|
916
1420
|
}]
|
|
917
|
-
:
|
|
1421
|
+
: offer !== undefined
|
|
1422
|
+
? [{ type: 'context', elements: [{ type: 'mrkdwn', text: offer }] }]
|
|
1423
|
+
: []),
|
|
918
1424
|
{
|
|
919
1425
|
type: 'actions',
|
|
920
1426
|
// The request id rides on every button rather than in the message
|
|
@@ -962,6 +1468,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
962
1468
|
const editIntervalMs = options.editIntervalMs ?? DEFAULT_EDIT_INTERVAL_MS;
|
|
963
1469
|
const createSocket = options.createSocketClient ?? defaultSocketClient;
|
|
964
1470
|
const createWeb = options.createWebClient ?? defaultWebClient;
|
|
1471
|
+
const fetchFile = options.fetchFile ?? defaultFetchFile;
|
|
1472
|
+
const fileDownloadTimeoutMs = options.fileDownloadTimeoutMs ?? DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS;
|
|
1473
|
+
const now = options.now ?? Date.now;
|
|
965
1474
|
const connections = [];
|
|
966
1475
|
// Every agent this adapter was asked to carry, connected or not — the
|
|
967
1476
|
// difference between "mine and broken" and "not mine".
|
|
@@ -1145,6 +1654,199 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1145
1654
|
return result.trim();
|
|
1146
1655
|
};
|
|
1147
1656
|
const connectionFor = (agentId) => connections.find((candidate) => candidate.config.agentId === agentId);
|
|
1657
|
+
/**
|
|
1658
|
+
* How an agent listens, from its soul — read per message, so a roster
|
|
1659
|
+
* reload takes effect on the next one. `thread` when the soul says
|
|
1660
|
+
* nothing, which is every soul written before there was a choice.
|
|
1661
|
+
*/
|
|
1662
|
+
const listensOf = (agentId) => gatewayRef?.agents().find((agent) => agent.id === agentId)?.listens ?? 'thread';
|
|
1663
|
+
/**
|
|
1664
|
+
* Whether an agent that judges is still paying attention to a thread:
|
|
1665
|
+
* within `ATTENTION_MESSAGES` and `ATTENTION_MS` of when it last
|
|
1666
|
+
* answered a message that addressed it, both read from the session
|
|
1667
|
+
* rather than remembered here, so a restart forgets nothing. Anchored
|
|
1668
|
+
* to an addressed answer and not to any reply: a reply the agent chose
|
|
1669
|
+
* to give on a turn nobody asked for does not re-arm it, or a talkative
|
|
1670
|
+
* judge would keep itself attentive for good and cost a call per message
|
|
1671
|
+
* — the self-extension the spec declined. An agent never addressed in
|
|
1672
|
+
* the thread is not attentive: it was mentioned and its first turn has
|
|
1673
|
+
* not answered yet, or answered with nothing. Measured against the
|
|
1674
|
+
* message's own timestamp, not the clock, so a delivery that ran late is
|
|
1675
|
+
* judged as of when it was said. Bounded below as well as above: a
|
|
1676
|
+
* message said before the answer the window starts from is outside it
|
|
1677
|
+
* — Slack redelivers events a restarted daemon has no memory of, and a
|
|
1678
|
+
* negative age would otherwise pass the minutes bound however old the
|
|
1679
|
+
* message, on a turn the agent's answer already came after.
|
|
1680
|
+
*/
|
|
1681
|
+
const attentive = (routing, messageTs, pending) => {
|
|
1682
|
+
if (routing.lastAnsweredAt === undefined) {
|
|
1683
|
+
return false;
|
|
1684
|
+
}
|
|
1685
|
+
const answeredAt = Date.parse(routing.lastAnsweredAt);
|
|
1686
|
+
const saidAt = Number(messageTs) * 1000;
|
|
1687
|
+
if (Number.isNaN(answeredAt) || Number.isNaN(saidAt) || saidAt < answeredAt || saidAt - answeredAt > ATTENTION_MS) {
|
|
1688
|
+
return false;
|
|
1689
|
+
}
|
|
1690
|
+
return (routing.heardSinceAnswered ?? 0) + pending < ATTENTION_MESSAGES;
|
|
1691
|
+
};
|
|
1692
|
+
/**
|
|
1693
|
+
* Messages this adapter has dispatched to a session that the session
|
|
1694
|
+
* does not hold yet. The store counts what it holds, and a burst of
|
|
1695
|
+
* messages typed inside one second would each read the same count and
|
|
1696
|
+
* each be judged — the bound the window exists to give, blown by exactly
|
|
1697
|
+
* the thread that needs it. The renderer queue already knows: a renderer
|
|
1698
|
+
* is queued at dispatch and its turn starts (`turnStarted`) only once
|
|
1699
|
+
* the runner has saved the message — the same moment the store's count
|
|
1700
|
+
* takes it over — so the two never count one message twice.
|
|
1701
|
+
*/
|
|
1702
|
+
const pendingFor = (sessionId) => (renderers.get(sessionId) ?? []).filter((renderer) => !renderer.turnStarted).length
|
|
1703
|
+
+ (pendingObserves.get(sessionId) ?? 0);
|
|
1704
|
+
/**
|
|
1705
|
+
* Overhears placed on the gateway's chain and not yet written, per
|
|
1706
|
+
* session: the other kind of message the store does not hold yet. An
|
|
1707
|
+
* observe queued behind a running turn is heard only when the turn ends,
|
|
1708
|
+
* and a burst of messages to a colleague would otherwise count toward
|
|
1709
|
+
* nothing until then. Counted from placement to settlement.
|
|
1710
|
+
*/
|
|
1711
|
+
const pendingObserves = new Map();
|
|
1712
|
+
const placeObserve = (sessionId, observed) => {
|
|
1713
|
+
pendingObserves.set(sessionId, (pendingObserves.get(sessionId) ?? 0) + 1);
|
|
1714
|
+
const release = () => {
|
|
1715
|
+
const left = (pendingObserves.get(sessionId) ?? 1) - 1;
|
|
1716
|
+
if (left > 0) {
|
|
1717
|
+
pendingObserves.set(sessionId, left);
|
|
1718
|
+
}
|
|
1719
|
+
else {
|
|
1720
|
+
pendingObserves.delete(sessionId);
|
|
1721
|
+
}
|
|
1722
|
+
};
|
|
1723
|
+
void observed.then(release, release);
|
|
1724
|
+
return observed;
|
|
1725
|
+
};
|
|
1726
|
+
/**
|
|
1727
|
+
* Run `work` after everything already queued for this session's intake,
|
|
1728
|
+
* and hold the next link until it settles. The link is claimed
|
|
1729
|
+
* synchronously, so a message's place is fixed by when the adapter
|
|
1730
|
+
* learned of it and not by how long its lookups take — see
|
|
1731
|
+
* `handleInbound`, whose ordering this is.
|
|
1732
|
+
*/
|
|
1733
|
+
const onIntakeChain = (sessionId, work) => {
|
|
1734
|
+
const previous = intakeChains.get(sessionId) ?? Promise.resolve();
|
|
1735
|
+
const link = previous.then(work);
|
|
1736
|
+
const settled = link.then(() => undefined, () => undefined);
|
|
1737
|
+
intakeChains.set(sessionId, settled);
|
|
1738
|
+
void settled.then(() => {
|
|
1739
|
+
if (intakeChains.get(sessionId) === settled) {
|
|
1740
|
+
intakeChains.delete(sessionId);
|
|
1741
|
+
}
|
|
1742
|
+
});
|
|
1743
|
+
return link;
|
|
1744
|
+
};
|
|
1745
|
+
/**
|
|
1746
|
+
* What one agent REPLIED in a thread, heard by the thread's other agents.
|
|
1747
|
+
*
|
|
1748
|
+
* The other half of overhearing. What people say reaches an agent through
|
|
1749
|
+
* its socket (`admit`), but a colleague's reply is a bot message the
|
|
1750
|
+
* socket ignores on purpose, and its final text reaches Slack as a stream
|
|
1751
|
+
* of edits rather than one event — so it is not an inbound path at all.
|
|
1752
|
+
* The adapter that rendered the reply is the one thing that knows its
|
|
1753
|
+
* final text, and it observes that into the session every other agent
|
|
1754
|
+
* holds for the same thread, with no Slack event involved. Without this
|
|
1755
|
+
* an agent that followed a thread knew the questions its colleague was
|
|
1756
|
+
* asked and not the answers.
|
|
1757
|
+
*
|
|
1758
|
+
* Each hearer takes it on its own intake chain, claimed the moment the
|
|
1759
|
+
* reply is final: a message typed while the reply was still streaming
|
|
1760
|
+
* is already in that chain and is heard first, and one typed after the
|
|
1761
|
+
* final edit cannot overtake it. The observe itself is placed there and
|
|
1762
|
+
* not awaited inside the link — returned in a wrapper, since a promise
|
|
1763
|
+
* returned from the link is one the chain adopts — for the reason
|
|
1764
|
+
* `handleInbound` gives: a hearer with a long turn running must not hold
|
|
1765
|
+
* its next message's place hostage to that turn.
|
|
1766
|
+
*
|
|
1767
|
+
* Only once it is SAID, in full. The reservation is taken when the reply
|
|
1768
|
+
* is final, but the observe waits, inside the link, on `published` —
|
|
1769
|
+
* whether Slack took every edit and post of it — and a reply Slack
|
|
1770
|
+
* refused any part of is heard by nobody, since the thread did not see
|
|
1771
|
+
* it as written and the hearer has no way to know which part. That holds
|
|
1772
|
+
* the hearer's chain for the length of the final edit, which is the
|
|
1773
|
+
* same network round trip a person's message holds it for.
|
|
1774
|
+
*
|
|
1775
|
+
* Two boundaries decide who hears it, and neither is "same workspace".
|
|
1776
|
+
* Whether the hearer is in the THREAD is the gateway's answer on the
|
|
1777
|
+
* session's chain, as for every overhear: an agent never invited here
|
|
1778
|
+
* has no session under the key. Whether it is still in the CHANNEL is
|
|
1779
|
+
* Slack's, asked per reply: a message a person types reaches an agent
|
|
1780
|
+
* only through its own socket, so an app removed from a private channel
|
|
1781
|
+
* simply stops hearing — but a reply is forwarded by this process, and
|
|
1782
|
+
* would keep reaching an agent the operator took out of the room. That
|
|
1783
|
+
* is the one path where membership has to be checked rather than
|
|
1784
|
+
* assumed, and a lookup that fails leaves the agent not hearing, the
|
|
1785
|
+
* direction a boundary should fail in.
|
|
1786
|
+
*
|
|
1787
|
+
* Sent under the speaker's own label rather than `user`: an agent's
|
|
1788
|
+
* reply is at most its own word, and carries whatever its session has
|
|
1789
|
+
* been exposed to — a restatement of a stranger's text is still the
|
|
1790
|
+
* stranger's, which is exactly what `sessionWriteTrust` says.
|
|
1791
|
+
*
|
|
1792
|
+
* Only replies: a turn that said nothing posts `(no reply)`, which is
|
|
1793
|
+
* nothing to hear, and a failed turn's error note is the failure's, not
|
|
1794
|
+
* the agent's.
|
|
1795
|
+
*/
|
|
1796
|
+
const overhearReply = async (speaker, channel, thread, reply, spoken, published) => {
|
|
1797
|
+
const gateway = gatewayRef;
|
|
1798
|
+
if (!gateway?.observe || reply.trim().length === 0) {
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
const team = speaker.teamId;
|
|
1802
|
+
const metadata = {
|
|
1803
|
+
channel: 'slack',
|
|
1804
|
+
team,
|
|
1805
|
+
slackChannel: channel,
|
|
1806
|
+
slackUser: speaker.botUserId,
|
|
1807
|
+
slackThread: thread,
|
|
1808
|
+
[SENDER_TRUST_METADATA_KEY]: sessionWriteTrust(spoken),
|
|
1809
|
+
};
|
|
1810
|
+
// In the reply's own workspace only, because a session key carries a
|
|
1811
|
+
// team — the same reason `resolveFollowUpWinner` stays in-workspace.
|
|
1812
|
+
const hearers = connections.filter((candidate) => candidate.teamId === team && candidate.config.agentId !== speaker.config.agentId);
|
|
1813
|
+
await Promise.all(hearers.map(async (hearer) => {
|
|
1814
|
+
const sessionId = channelSessionKey({
|
|
1815
|
+
channel: 'slack',
|
|
1816
|
+
agentId: hearer.config.agentId,
|
|
1817
|
+
team,
|
|
1818
|
+
conversation: channel,
|
|
1819
|
+
thread,
|
|
1820
|
+
});
|
|
1821
|
+
try {
|
|
1822
|
+
const { observed } = await onIntakeChain(sessionId, async () => {
|
|
1823
|
+
if (!(await published)) {
|
|
1824
|
+
return { observed: undefined };
|
|
1825
|
+
}
|
|
1826
|
+
let member;
|
|
1827
|
+
try {
|
|
1828
|
+
member = (await hearer.web.conversations.info({ channel })).channel?.is_member;
|
|
1829
|
+
}
|
|
1830
|
+
catch (error) {
|
|
1831
|
+
warn(`slack: could not tell whether ${hearer.config.agentId} is still in ${channel}, so it does not hear ${speaker.config.agentId}'s reply there: ${error instanceof Error ? error.message : String(error)}`);
|
|
1832
|
+
}
|
|
1833
|
+
if (member !== true) {
|
|
1834
|
+
return { observed: undefined };
|
|
1835
|
+
}
|
|
1836
|
+
// Named the way a person's message names its speaker: whoever
|
|
1837
|
+
// spoke, by display name. A bot user's is the app's own, set by
|
|
1838
|
+
// whoever installed it.
|
|
1839
|
+
const author = await displayNameFor(hearer, speaker.botUserId);
|
|
1840
|
+
const observed = gateway.observe?.({ sessionId, agentId: hearer.config.agentId, message: `${author}: ${reply}`, metadata });
|
|
1841
|
+
return { observed: observed === undefined ? undefined : placeObserve(sessionId, observed) };
|
|
1842
|
+
});
|
|
1843
|
+
await observed;
|
|
1844
|
+
}
|
|
1845
|
+
catch (error) {
|
|
1846
|
+
warn(`slack: ${hearer.config.agentId} could not overhear ${speaker.config.agentId}'s reply in ${channel}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1847
|
+
}
|
|
1848
|
+
}));
|
|
1849
|
+
};
|
|
1148
1850
|
/**
|
|
1149
1851
|
* The addressable outbound seam — the channel contract's first real
|
|
1150
1852
|
* `OutboundConnection` implementation. Validate-then-hand-over: every
|
|
@@ -1293,7 +1995,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1293
1995
|
+ `${event.origin ? ` on ${event.origin}` : ''}`
|
|
1294
1996
|
+ `${invocationSummary ? `: ${invocationSummary}` : ''}.`),
|
|
1295
1997
|
...(thread ? { thread_ts: thread } : {}),
|
|
1296
|
-
blocks: approvalBlocks(agentName, event.call.toolName, event.risk, event.requestId, event.call.input, event.origin, event.oneShot === true),
|
|
1998
|
+
blocks: approvalBlocks(agentName, event.call.toolName, event.risk, event.requestId, event.call.input, event.origin, event.oneShot === true, event.always),
|
|
1297
1999
|
});
|
|
1298
2000
|
post = posted.ts
|
|
1299
2001
|
? {
|
|
@@ -1305,6 +2007,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1305
2007
|
toolName: event.call.toolName,
|
|
1306
2008
|
risk: event.risk,
|
|
1307
2009
|
oneShot: event.oneShot === true,
|
|
2010
|
+
...(event.always !== undefined ? { always: event.always } : {}),
|
|
2011
|
+
agentId: event.agentId,
|
|
1308
2012
|
approvers,
|
|
1309
2013
|
}
|
|
1310
2014
|
: undefined;
|
|
@@ -1409,7 +2113,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1409
2113
|
? post.risk === 'dangerous'
|
|
1410
2114
|
? 'Allowed once — a dangerous tool is never remembered'
|
|
1411
2115
|
: 'Allowed once — nothing about this call could be remembered'
|
|
1412
|
-
:
|
|
2116
|
+
: event.reason === 'decided' && event.answer === 'always'
|
|
2117
|
+
? alwaysOutcome(post.always, post.agentName, post.agentId)
|
|
2118
|
+
: OUTCOME_TEXT[`${event.reason}:${event.answer}`] ?? 'Resolved';
|
|
1413
2119
|
const by = event.actor ? ` by <@${event.actor}>` : '';
|
|
1414
2120
|
const text = `*${escapeSlackText(post.agentName)}* — \`${escapeSlackText(post.toolName)}\` (${post.risk}): ${outcome}${by}.`;
|
|
1415
2121
|
try {
|
|
@@ -1445,9 +2151,13 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1445
2151
|
* cost the rest of the answer.
|
|
1446
2152
|
*/
|
|
1447
2153
|
const postAheadOf = async (behind, connection, channel, thread, chunks, between) => {
|
|
1448
|
-
|
|
1449
|
-
|
|
2154
|
+
const taken = behind ? await behind.take(chunks, between) : 'untaken';
|
|
2155
|
+
if (taken !== 'untaken') {
|
|
2156
|
+
return taken === 'complete';
|
|
1450
2157
|
}
|
|
2158
|
+
// Whether ALL of it landed, as `finalize` answers: a reply the thread
|
|
2159
|
+
// saw part of is not one anybody else may be told the whole of.
|
|
2160
|
+
let published = true;
|
|
1451
2161
|
for (const chunk of chunks) {
|
|
1452
2162
|
try {
|
|
1453
2163
|
await connection.web.chat.postMessage({
|
|
@@ -1457,12 +2167,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1457
2167
|
});
|
|
1458
2168
|
}
|
|
1459
2169
|
catch (error) {
|
|
2170
|
+
published = false;
|
|
1460
2171
|
warn(`slack: could not post part of a turn's outcome: ${error instanceof Error ? error.message : String(error)}`);
|
|
1461
2172
|
}
|
|
1462
2173
|
}
|
|
1463
2174
|
if (between) {
|
|
1464
2175
|
await between();
|
|
1465
2176
|
}
|
|
2177
|
+
return published;
|
|
1466
2178
|
};
|
|
1467
2179
|
/**
|
|
1468
2180
|
* The files an unrendered turn produced, taken off the session the moment
|
|
@@ -1519,6 +2231,26 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1519
2231
|
}
|
|
1520
2232
|
const channel = metadata.slackChannel;
|
|
1521
2233
|
const thread = typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined;
|
|
2234
|
+
if (routing.unaddressed === true && routing.reply === undefined) {
|
|
2235
|
+
// A turn nobody asked for that said nothing before it broke — the
|
|
2236
|
+
// daemon died inside it, and the next start failed it with no
|
|
2237
|
+
// renderer here to keep quiet for it. The same rule as the lazy
|
|
2238
|
+
// renderer's: the failure is in the log, and the files it produced
|
|
2239
|
+
// still land, since a file is not nothing to say.
|
|
2240
|
+
//
|
|
2241
|
+
// "Said nothing" as the session can tell it: no reply saved for the
|
|
2242
|
+
// turn. A line the old process had streamed into a placeholder and
|
|
2243
|
+
// never saved is invisible here — the session records what was
|
|
2244
|
+
// said, not what Slack took (roadmap 31's open item) — so such a
|
|
2245
|
+
// turn is failed without a note under that line. Taken over the
|
|
2246
|
+
// alternative on purpose: a note for every judged turn a restart
|
|
2247
|
+
// caught turns a silent turn, the common case, into the
|
|
2248
|
+
// interruption it existed to avoid; a line left standing happens
|
|
2249
|
+
// only to a turn that had already chosen to speak.
|
|
2250
|
+
warn(`slack: a turn nobody asked for failed before saying anything: ${event.error}`);
|
|
2251
|
+
await uploadUnrenderedFiles(connection, channel, thread, files);
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
1522
2254
|
await postAheadOf(behind, connection, channel, thread, messageChunks(`Something went wrong: ${event.error}`), () => uploadUnrenderedFiles(connection, channel, thread, files));
|
|
1523
2255
|
};
|
|
1524
2256
|
/**
|
|
@@ -1567,7 +2299,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1567
2299
|
await uploads();
|
|
1568
2300
|
return;
|
|
1569
2301
|
}
|
|
1570
|
-
|
|
2302
|
+
// A reply the thread's other agents hear like any other, taking their
|
|
2303
|
+
// place for it before the post, as `handleInbound` does.
|
|
2304
|
+
const published = postAheadOf(behind, connection, channel, thread, posting, uploads);
|
|
2305
|
+
const heard = routing.reply !== undefined && thread !== undefined
|
|
2306
|
+
? overhearReply(connection, channel, thread, routing.reply, routing, published)
|
|
2307
|
+
: undefined;
|
|
2308
|
+
await published;
|
|
2309
|
+
await heard;
|
|
1571
2310
|
log(`slack: posted the reply of a turn finished after a restart to ${channel}`);
|
|
1572
2311
|
};
|
|
1573
2312
|
const handleInteractive = async (connection, args) => {
|
|
@@ -1647,14 +2386,25 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1647
2386
|
* Records that `agentId` holds the thread from the message at `ts`
|
|
1648
2387
|
* onwards, wherever in the thread's history that message falls.
|
|
1649
2388
|
*
|
|
1650
|
-
*
|
|
1651
|
-
*
|
|
1652
|
-
*
|
|
1653
|
-
*
|
|
2389
|
+
* Every claim is kept, not only changes of hands. A claim by the agent
|
|
2390
|
+
* already holding the thread looks redundant until another agent's reply
|
|
2391
|
+
* is placed between it and the one before — a judge's reply posted below
|
|
2392
|
+
* the holder's older message and above its newer one — at which point it
|
|
2393
|
+
* is what keeps the newer message the holder's; dropped, the judge's
|
|
2394
|
+
* later insertion would take the thread from a reply that sits below it.
|
|
2395
|
+
* Only the same claim twice — the same message reaching a second
|
|
2396
|
+
* connection — is dropped.
|
|
2397
|
+
* The oldest claims are dropped past `THREAD_HANDOVER_DEPTH`, and the
|
|
1654
2398
|
* least recently written thread past `THREAD_ADDRESSEE_CAPACITY`; both
|
|
1655
2399
|
* losses cost a lookup, never an answer, since the sessions still hold
|
|
1656
2400
|
* the durable version.
|
|
1657
2401
|
*
|
|
2402
|
+
* `ts` is a message's place in the thread: the message that handed the
|
|
2403
|
+
* thread over at intake, or — once a reply has landed — the reply's own
|
|
2404
|
+
* (`ReplyOutcome.spokeAt`), which is what puts a judge's reply posted
|
|
2405
|
+
* below a colleague's placeholder after it, and one streamed above it
|
|
2406
|
+
* before, whichever turn finished first.
|
|
2407
|
+
*
|
|
1658
2408
|
* Slack timestamps compare as strings: `seconds.microseconds`, both
|
|
1659
2409
|
* parts fixed width, so lexicographic order is chronological order.
|
|
1660
2410
|
*/
|
|
@@ -1665,9 +2415,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1665
2415
|
at -= 1;
|
|
1666
2416
|
}
|
|
1667
2417
|
const before = handovers[at - 1];
|
|
1668
|
-
if (before && before.agentId === agentId) {
|
|
1669
|
-
// Already this agent's from earlier in the thread; the same message
|
|
1670
|
-
// reaching a second connection lands here too.
|
|
2418
|
+
if (before && before.agentId === agentId && before.ts === ts) {
|
|
1671
2419
|
return;
|
|
1672
2420
|
}
|
|
1673
2421
|
handovers.splice(at, 0, { agentId, ts });
|
|
@@ -1896,10 +2644,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1896
2644
|
* the dedupe and the handover record included, which are the two pieces a
|
|
1897
2645
|
* later message reads.
|
|
1898
2646
|
*
|
|
1899
|
-
* `undefined` means the message is
|
|
1900
|
-
*
|
|
1901
|
-
*
|
|
1902
|
-
*
|
|
2647
|
+
* `undefined` means the message is nothing to this agent — a DM to
|
|
2648
|
+
* someone else, the room rather than a thread, its own words.
|
|
2649
|
+
* `overhear: true` means it is somebody else's to answer and this
|
|
2650
|
+
* agent's to hear, if it is in the thread at all, which only the sessions
|
|
2651
|
+
* can say. `settled: false` means only the sessions can decide even that
|
|
2652
|
+
* much, which is the one case that has to wait — and it is the cold
|
|
2653
|
+
* case, where by definition no message of this thread has been seen in
|
|
2654
|
+
* this process.
|
|
1903
2655
|
*/
|
|
1904
2656
|
const admit = (connection, args) => {
|
|
1905
2657
|
const event = args.event;
|
|
@@ -1942,13 +2694,6 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1942
2694
|
// or stands alone, is a follow-up to nothing.
|
|
1943
2695
|
return undefined;
|
|
1944
2696
|
}
|
|
1945
|
-
if (named) {
|
|
1946
|
-
// Somebody else was asked. Tagging an agent hands it the question,
|
|
1947
|
-
// and the agent that had it is no longer being asked — two answers
|
|
1948
|
-
// to one message is what a thread with a roster in it must never
|
|
1949
|
-
// produce.
|
|
1950
|
-
return undefined;
|
|
1951
|
-
}
|
|
1952
2697
|
}
|
|
1953
2698
|
// One Slack MESSAGE, not one delivery. An app subscribed to both
|
|
1954
2699
|
// `app_mention` and `message.channels` is told about a mention twice —
|
|
@@ -1960,7 +2705,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1960
2705
|
// After the handover record and not before it: a redelivery re-records
|
|
1961
2706
|
// the same agent, which costs nothing, while deduping first would drop
|
|
1962
2707
|
// the record whenever the other copy of a doubly-delivered mention is
|
|
1963
|
-
// the one a connection sees second.
|
|
2708
|
+
// the one a connection sees second. And before every stand-down below,
|
|
2709
|
+
// because standing down is no longer nothing: an agent that hears a
|
|
2710
|
+
// message it is not answering must hear it once.
|
|
1964
2711
|
const eventKey = `${connection.config.agentId}:${event.channel}:${event.ts}`;
|
|
1965
2712
|
if (alreadySeen(eventKey)) {
|
|
1966
2713
|
return undefined;
|
|
@@ -1980,6 +2727,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1980
2727
|
...(thread ? { thread } : {}),
|
|
1981
2728
|
...(threadKey ? { threadKey } : {}),
|
|
1982
2729
|
settled: true,
|
|
2730
|
+
overhear: false,
|
|
2731
|
+
judge: false,
|
|
1983
2732
|
};
|
|
1984
2733
|
if (addressed) {
|
|
1985
2734
|
// A message that named this agent and others is recorded above under
|
|
@@ -1989,9 +2738,33 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1989
2738
|
// reading of a message that asked several.
|
|
1990
2739
|
return admission;
|
|
1991
2740
|
}
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
2741
|
+
if (named) {
|
|
2742
|
+
// Somebody else was asked. Tagging an agent hands it the question,
|
|
2743
|
+
// and the agent that had it is no longer being asked — two answers
|
|
2744
|
+
// to one message is what a thread with a roster in it must never
|
|
2745
|
+
// produce. But the agent that had it is still in the room, and a
|
|
2746
|
+
// person in that position hears the question asked of their
|
|
2747
|
+
// colleague: it goes into this agent's session, unanswered, so the
|
|
2748
|
+
// next thing it is asked is asked of someone who followed along.
|
|
2749
|
+
return { ...admission, overhear: true };
|
|
2750
|
+
}
|
|
2751
|
+
// An untagged reply. How this agent listens decides what it is: an
|
|
2752
|
+
// agent that answers only mentions hears it and nothing more, and one
|
|
2753
|
+
// that judges hears it and, if still attentive, takes a turn nobody
|
|
2754
|
+
// asked for on it — decided in the intake, since only the session can
|
|
2755
|
+
// say. Neither takes part in the holder rule below: that rule answers
|
|
2756
|
+
// "whose is an untagged reply", and for these two agents the soul has
|
|
2757
|
+
// already answered.
|
|
2758
|
+
const listens = listensOf(connection.config.agentId);
|
|
2759
|
+
if (listens === 'mentions') {
|
|
2760
|
+
return { ...admission, overhear: true };
|
|
2761
|
+
}
|
|
2762
|
+
if (listens === 'judge') {
|
|
2763
|
+
return { ...admission, overhear: true, judge: true };
|
|
2764
|
+
}
|
|
2765
|
+
// The record is what this process has actually seen, in the order it
|
|
2766
|
+
// saw it — right even inside the window before a handover reaches the
|
|
2767
|
+
// store.
|
|
1995
2768
|
const holder = threadKey === undefined ? undefined : holderAt(threadKey, event.ts);
|
|
1996
2769
|
if (holder !== undefined && threadKey !== undefined) {
|
|
1997
2770
|
if (holder !== connection.config.agentId) {
|
|
@@ -2004,7 +2777,10 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2004
2777
|
// membership the adapter does not track, and would have to be
|
|
2005
2778
|
// tracked in the durable path too, since a removed app is still a
|
|
2006
2779
|
// live connection with the newest session there.
|
|
2007
|
-
|
|
2780
|
+
//
|
|
2781
|
+
// Standing down is not leaving: the reply is the holder's, and this
|
|
2782
|
+
// agent hears it.
|
|
2783
|
+
return { ...admission, overhear: true };
|
|
2008
2784
|
}
|
|
2009
2785
|
rememberAddressee(threadKey, connection.config.agentId, event.ts);
|
|
2010
2786
|
return admission;
|
|
@@ -2047,42 +2823,127 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2047
2823
|
// reverse order in durable history. The chain is claimed here,
|
|
2048
2824
|
// synchronously, so a message's place in it is fixed by when Slack
|
|
2049
2825
|
// delivered it and not by how long anything it does takes.
|
|
2050
|
-
const
|
|
2051
|
-
const intake = previousIntake.then(async () => {
|
|
2826
|
+
const intake = onIntakeChain(sessionId, async () => {
|
|
2052
2827
|
// The durable half of addressing runs INSIDE the chain, for the same
|
|
2053
2828
|
// reason: it awaits, and a message that had to ask the sessions must
|
|
2054
2829
|
// not lose its place to one that did not. A message that turns out
|
|
2055
2830
|
// not to be this agent's leaves an empty link behind, which is all it
|
|
2056
2831
|
// should cost.
|
|
2832
|
+
let overhear = admitted.overhear;
|
|
2833
|
+
// Judged, not ruled: a turn nobody asked for, while the agent is
|
|
2834
|
+
// still attentive, and a free overhear past that. Read off the
|
|
2835
|
+
// chain — a session the invitation is still creating reads as not
|
|
2836
|
+
// there, and the message is heard rather than judged, which is the
|
|
2837
|
+
// safe side. A host that cannot say leaves the agent hearing.
|
|
2838
|
+
let judged = false;
|
|
2839
|
+
if (admitted.judge && gateway.sessionRouting) {
|
|
2840
|
+
// The pending count is taken BEFORE the store is read: a message
|
|
2841
|
+
// that settles between the two would be counted by neither the
|
|
2842
|
+
// other way round, and a window that runs one message short in a
|
|
2843
|
+
// race is a bound kept; one that runs a message long is not.
|
|
2844
|
+
const pending = pendingFor(sessionId);
|
|
2845
|
+
let routing;
|
|
2846
|
+
try {
|
|
2847
|
+
routing = await gateway.sessionRouting(sessionId);
|
|
2848
|
+
}
|
|
2849
|
+
catch (error) {
|
|
2850
|
+
warn(`slack: could not read whether ${connection.config.agentId} is attentive in ${event.channel}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2851
|
+
}
|
|
2852
|
+
if (routing && attentive(routing, event.ts, pending)) {
|
|
2853
|
+
judged = true;
|
|
2854
|
+
overhear = false;
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2057
2857
|
if (!admitted.settled) {
|
|
2058
2858
|
// One verdict for this message, shared with whichever other agents
|
|
2059
2859
|
// are asking about it — see `followUpWinner`.
|
|
2060
2860
|
const winner = await followUpWinner({ team, conversation: event.channel, thread: event.thread_ts ?? '' }, event.ts);
|
|
2061
2861
|
if (winner !== connection.config.agentId) {
|
|
2062
|
-
|
|
2862
|
+
// Not this agent's to answer — and, if it is in the thread,
|
|
2863
|
+
// this agent's to hear, same as the two warm stand-downs.
|
|
2864
|
+
overhear = true;
|
|
2063
2865
|
}
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2866
|
+
else if (threadKey) {
|
|
2867
|
+
// Answered from the store rather than from memory: hold it, so
|
|
2868
|
+
// the rest of the thread is ordered by receipt like any other.
|
|
2067
2869
|
rememberAddressee(threadKey, connection.config.agentId, event.ts);
|
|
2068
2870
|
}
|
|
2069
2871
|
}
|
|
2070
2872
|
const cleaned = await humanizeMentions(connection, event.text ?? '');
|
|
2071
|
-
|
|
2873
|
+
// Fetched inside the chain, like the lookups below: it is network
|
|
2874
|
+
// I/O, and a message whose screenshot is slow to arrive must not lose
|
|
2875
|
+
// its place to the one typed after it. Only for a message this agent
|
|
2876
|
+
// will answer: an overheard one is appended as text with no turn run
|
|
2877
|
+
// on it, and its attachments reach the transcript by name, as every
|
|
2878
|
+
// attachment did before images.
|
|
2879
|
+
// Nor for one it will judge: the kernel refuses images on a turn
|
|
2880
|
+
// nobody asked for, since they reach the model ahead of the frame
|
|
2881
|
+
// that marks the message as somebody else's, and a judged message
|
|
2882
|
+
// names its attachments the way an overheard one does.
|
|
2883
|
+
const { images, unread } = overhear || judged
|
|
2884
|
+
? { images: [], unread: event.files ?? [] }
|
|
2885
|
+
: await readImageAttachments(event.files ?? [], connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn);
|
|
2886
|
+
// An image with nothing said is still a question ("what's this?"); a
|
|
2887
|
+
// file the turn could not open with nothing said is not one, and gets
|
|
2888
|
+
// no reply.
|
|
2889
|
+
if (cleaned.length === 0 && images.length === 0) {
|
|
2072
2890
|
return undefined;
|
|
2073
2891
|
}
|
|
2074
2892
|
const author = await displayNameFor(connection, userId);
|
|
2075
2893
|
// In shared channels the model should know who is speaking; a DM is
|
|
2076
|
-
// unambiguous.
|
|
2077
|
-
const spoken = isDm ? cleaned : `${author}: ${cleaned}
|
|
2078
|
-
const userMessage = `${spoken}${attachmentNote(
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2894
|
+
// unambiguous. A bare image in a channel still says who sent it.
|
|
2895
|
+
const spoken = isDm ? cleaned : (cleaned.length > 0 ? `${author}: ${cleaned}` : `${author}:`);
|
|
2896
|
+
const userMessage = `${spoken}${attachmentNote(unread)}`;
|
|
2897
|
+
// Who sent this, judged against the operator's list — per message,
|
|
2898
|
+
// never remembered from the first one in the thread. A DM proves
|
|
2899
|
+
// nothing about who is typing, so a DM from an unlisted member is
|
|
2900
|
+
// `unknown` exactly as a channel mention would be. The same label
|
|
2901
|
+
// whether the agent answers or only hears: the text is in its
|
|
2902
|
+
// transcript either way.
|
|
2903
|
+
const senderTrust = (connection.config.principals ?? []).includes(userId) ? 'user' : 'unknown';
|
|
2904
|
+
const metadata = {
|
|
2905
|
+
channel: 'slack',
|
|
2906
|
+
team,
|
|
2907
|
+
slackChannel: event.channel,
|
|
2908
|
+
slackUser: userId,
|
|
2909
|
+
// Carried so a mid-turn approval question is asked in the thread
|
|
2910
|
+
// the turn belongs to rather than at the top of a busy channel.
|
|
2911
|
+
...(thread ? { slackThread: thread } : {}),
|
|
2912
|
+
[SENDER_TRUST_METADATA_KEY]: senderTrust,
|
|
2913
|
+
};
|
|
2914
|
+
if (overhear) {
|
|
2915
|
+
// Heard, not answered: no placeholder, no turn. Only into a
|
|
2916
|
+
// conversation this agent is already in — a session under this
|
|
2917
|
+
// thread's key exists exactly when it was invited here — and the
|
|
2918
|
+
// gateway is the one to say whether it is, on the session's chain:
|
|
2919
|
+
// a first mention whose dispatch is queued ahead of this message
|
|
2920
|
+
// has created the session by the time the observe runs, where a
|
|
2921
|
+
// membership read from here would find nothing and drop a
|
|
2922
|
+
// message said moments after the invitation. A host that cannot
|
|
2923
|
+
// take it leaves the agent hearing what it answers, as every
|
|
2924
|
+
// agent did before.
|
|
2925
|
+
if (!gateway.observe) {
|
|
2926
|
+
return undefined;
|
|
2927
|
+
}
|
|
2928
|
+
// Placed on the gateway's chain here, in receipt order, and not
|
|
2929
|
+
// awaited here: a dispatch is placed the same way, and awaiting
|
|
2930
|
+
// an overhear queued behind a long turn would hold the next
|
|
2931
|
+
// message's place in this chain hostage to that turn.
|
|
2932
|
+
return {
|
|
2933
|
+
observed: placeObserve(sessionId, gateway.observe({ sessionId, agentId: connection.config.agentId, message: userMessage, metadata })),
|
|
2934
|
+
};
|
|
2082
2935
|
}
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2936
|
+
// A turn nobody asked for opens its placeholder on its first text,
|
|
2937
|
+
// if it ever has any — see `ReplyRenderer.lazy`.
|
|
2938
|
+
const renderer = new ReplyRenderer(connection.web, event.channel, thread, editIntervalMs, warn, { lazy: judged, now });
|
|
2939
|
+
if (!judged) {
|
|
2940
|
+
try {
|
|
2941
|
+
await renderer.open();
|
|
2942
|
+
}
|
|
2943
|
+
catch (error) {
|
|
2944
|
+
warn(`could not post to ${event.channel}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2945
|
+
return undefined;
|
|
2946
|
+
}
|
|
2086
2947
|
}
|
|
2087
2948
|
// The push and the dispatch happen in the same microtask, so queue
|
|
2088
2949
|
// order matches the gateway's single-flight turn order for this
|
|
@@ -2094,35 +2955,29 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2094
2955
|
sessionId,
|
|
2095
2956
|
agentId: connection.config.agentId,
|
|
2096
2957
|
userMessage,
|
|
2958
|
+
...(images.length > 0 ? { images } : {}),
|
|
2959
|
+
...(judged ? { addressed: false } : {}),
|
|
2097
2960
|
turnId: renderer.turnId,
|
|
2098
|
-
metadata
|
|
2099
|
-
channel: 'slack',
|
|
2100
|
-
team,
|
|
2101
|
-
slackChannel: event.channel,
|
|
2102
|
-
slackUser: userId,
|
|
2103
|
-
// Carried so a mid-turn approval question is asked in the thread
|
|
2104
|
-
// the turn belongs to rather than at the top of a busy channel.
|
|
2105
|
-
...(thread ? { slackThread: thread } : {}),
|
|
2106
|
-
// This turn's sender, judged against the operator's list — per
|
|
2107
|
-
// message, never remembered from the first one in the thread. A
|
|
2108
|
-
// DM proves nothing about who is typing, so a DM from an unlisted
|
|
2109
|
-
// member is `unknown` exactly as a channel mention would be.
|
|
2110
|
-
[SENDER_TRUST_METADATA_KEY]: (connection.config.principals ?? []).includes(userId) ? 'user' : 'unknown',
|
|
2111
|
-
},
|
|
2961
|
+
metadata,
|
|
2112
2962
|
});
|
|
2113
2963
|
return { renderer, turn };
|
|
2114
2964
|
});
|
|
2115
|
-
const settledIntake = intake.then(() => undefined, () => undefined);
|
|
2116
|
-
intakeChains.set(sessionId, settledIntake);
|
|
2117
|
-
void settledIntake.then(() => {
|
|
2118
|
-
if (intakeChains.get(sessionId) === settledIntake) {
|
|
2119
|
-
intakeChains.delete(sessionId);
|
|
2120
|
-
}
|
|
2121
|
-
});
|
|
2122
2965
|
const started = await intake;
|
|
2123
2966
|
if (!started) {
|
|
2124
2967
|
return;
|
|
2125
2968
|
}
|
|
2969
|
+
if ('observed' in started) {
|
|
2970
|
+
// Nothing to render; the one outcome worth a line is the host
|
|
2971
|
+
// refusing it — a turn parked across a restart, most likely — since
|
|
2972
|
+
// a message dropped on the floor is the thing this exists to end.
|
|
2973
|
+
try {
|
|
2974
|
+
await started.observed;
|
|
2975
|
+
}
|
|
2976
|
+
catch (error) {
|
|
2977
|
+
warn(`slack: ${connection.config.agentId} could not overhear a message in ${event.channel}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2978
|
+
}
|
|
2979
|
+
return;
|
|
2980
|
+
}
|
|
2126
2981
|
const { renderer, turn } = started;
|
|
2127
2982
|
const removeFromQueue = () => {
|
|
2128
2983
|
const current = renderers.get(sessionId);
|
|
@@ -2149,10 +3004,41 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2149
3004
|
// the potentially slow final edit, uploads, and overflow posts.
|
|
2150
3005
|
removeFromQueue();
|
|
2151
3006
|
if (session) {
|
|
2152
|
-
|
|
3007
|
+
// The reply is final here, so this is when the thread's other agents
|
|
3008
|
+
// take their place for it — before the final edit and the overflow
|
|
3009
|
+
// posts, which are network I/O a later message must not overtake.
|
|
3010
|
+
// A DM has no thread, and nobody else in it.
|
|
3011
|
+
const reply = latestTurnReply(session);
|
|
3012
|
+
// A turn nobody asked for that said nothing posts nothing; every
|
|
3013
|
+
// other turn says `(no reply)` where its answer would have gone.
|
|
3014
|
+
const finalized = renderer.finalize(renderer.lazy ? reply ?? '' : lastAssistantReply(session));
|
|
3015
|
+
const heard = thread !== undefined && reply !== undefined
|
|
3016
|
+
? overhearReply(connection, event.channel, thread, reply, session, finalized.then((outcome) => outcome.published))
|
|
3017
|
+
: undefined;
|
|
3018
|
+
const { spoke, spokeAt } = await finalized;
|
|
3019
|
+
if (spoke && threadKey !== undefined) {
|
|
3020
|
+
// The voice that just answered, recorded where its reply sits in
|
|
3021
|
+
// the thread. For a judging agent this is the only record there
|
|
3022
|
+
// is — it took no part in the holder rule at intake — and without
|
|
3023
|
+
// it a thread-rule colleague that held the thread before would
|
|
3024
|
+
// keep answering beside it. For any other turn the intake wrote
|
|
3025
|
+
// one at the message answered, and this one says where the answer
|
|
3026
|
+
// landed, which is what settles two turns running at once: a
|
|
3027
|
+
// placeholder posted at intake sits above a reply a judge posted
|
|
3028
|
+
// while it was being filled, whichever turn finished first, and
|
|
3029
|
+
// the reader's next untagged message is for the lower of the two.
|
|
3030
|
+
// Late, either way, for a message typed while the turn was still
|
|
3031
|
+
// running, which the other may therefore still take.
|
|
3032
|
+
rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
|
|
3033
|
+
}
|
|
3034
|
+
await heard;
|
|
2153
3035
|
}
|
|
2154
3036
|
else {
|
|
2155
|
-
await renderer.fail(failure instanceof Error ? failure.message : String(failure));
|
|
3037
|
+
const { spoke, spokeAt } = await renderer.fail(failure instanceof Error ? failure.message : String(failure));
|
|
3038
|
+
if (spoke && threadKey !== undefined) {
|
|
3039
|
+
// A file it posted before breaking is still the last thing said.
|
|
3040
|
+
rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
|
|
3041
|
+
}
|
|
2156
3042
|
}
|
|
2157
3043
|
};
|
|
2158
3044
|
return {
|
|
@@ -2344,6 +3230,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2344
3230
|
approvalPosts.clear();
|
|
2345
3231
|
threadAddressee.clear();
|
|
2346
3232
|
coldVerdicts.clear();
|
|
3233
|
+
pendingObserves.clear();
|
|
2347
3234
|
botIdentities.clear();
|
|
2348
3235
|
rendering.clear();
|
|
2349
3236
|
resolvedWhileRendering.clear();
|