@stratusagent/channel-slack 0.11.2 → 0.11.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +160 -18
- package/dist/index.d.ts +113 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1185 -164
- 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,8 +2,13 @@ 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, escapeControlCharacters, } from '@stratusagent/core';
|
|
6
6
|
import { channelSessionKey, } from '@stratusagent/channels';
|
|
7
|
+
/**
|
|
8
|
+
* The longest display name the model is shown. Slack caps profile names
|
|
9
|
+
* well under this; the bound is for a profile that is not a name.
|
|
10
|
+
*/
|
|
11
|
+
const MAX_DISPLAY_NAME_LENGTH = 80;
|
|
7
12
|
const SLACK_MAX_MESSAGE_CHARS = 4000;
|
|
8
13
|
const DEFAULT_EDIT_INTERVAL_MS = 1000;
|
|
9
14
|
const DEDUPE_CAPACITY = 2000;
|
|
@@ -21,7 +26,7 @@ const THREAD_ADDRESSEE_CAPACITY = 2000;
|
|
|
21
26
|
*/
|
|
22
27
|
const COLD_VERDICT_CAPACITY = 256;
|
|
23
28
|
/**
|
|
24
|
-
* How many
|
|
29
|
+
* How many claims on one thread it remembers.
|
|
25
30
|
*
|
|
26
31
|
* A message older than all of them asks the sessions instead, which answer
|
|
27
32
|
* for the thread as it stands rather than as it stood — so a reply that old
|
|
@@ -29,13 +34,14 @@ const COLD_VERDICT_CAPACITY = 256;
|
|
|
29
34
|
* bounding this, and something has to bound it: the alternative is a list
|
|
30
35
|
* that grows with a conversation for the life of the daemon.
|
|
31
36
|
*
|
|
32
|
-
*
|
|
37
|
+
* A claim is a change of hands or a reply that landed (see
|
|
38
|
+
* `rememberAddressee`), so a busy thread writes one or two per message.
|
|
39
|
+
* Thirty-two sits far past the case the record covers: it only has to
|
|
33
40
|
* span the lag between two sockets, which is messages inside one delivery
|
|
34
|
-
* window
|
|
35
|
-
*
|
|
36
|
-
* behind for all of them.
|
|
41
|
+
* window, and crossing it needs that many replies in one thread while a
|
|
42
|
+
* socket stays behind for all of them.
|
|
37
43
|
*/
|
|
38
|
-
const THREAD_HANDOVER_DEPTH =
|
|
44
|
+
const THREAD_HANDOVER_DEPTH = 32;
|
|
39
45
|
/**
|
|
40
46
|
* How many files an unrendered turn may queue for its outcome. Nothing
|
|
41
47
|
* drains that queue until the outcome arrives, so a session whose outcome
|
|
@@ -45,6 +51,26 @@ const THREAD_HANDOVER_DEPTH = 8;
|
|
|
45
51
|
*/
|
|
46
52
|
const MAX_UNRENDERED_FILES = 20;
|
|
47
53
|
const PLACEHOLDER_TEXT = '…';
|
|
54
|
+
/**
|
|
55
|
+
* How long an agent that judges stays attentive after it last answered a
|
|
56
|
+
* message that addressed it: this many messages, or this many minutes,
|
|
57
|
+
* whichever ends first. Judging costs
|
|
58
|
+
* a model call per message, and two people talking for an hour is a
|
|
59
|
+
* hundred of them for one "no" — so attention decays the way a person's
|
|
60
|
+
* does. Outside the window a message is overheard for free, and a mention
|
|
61
|
+
* re-arms it, which is what a mention has always meant.
|
|
62
|
+
*/
|
|
63
|
+
const ATTENTION_MESSAGES = 8;
|
|
64
|
+
const ATTENTION_MS = 15 * 60 * 1000;
|
|
65
|
+
/**
|
|
66
|
+
* A 5 MB image over a slow link is seconds, not minutes. Past this the
|
|
67
|
+
* downloads are abandoned and the turn runs with the attachments named as
|
|
68
|
+
* unreadable, rather than the thread waiting on them. One deadline for
|
|
69
|
+
* the whole message, not one per file: a message may carry ten, and ten
|
|
70
|
+
* stalls in a row would hold the thread — and `stop()` — ten times as
|
|
71
|
+
* long.
|
|
72
|
+
*/
|
|
73
|
+
const DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
48
74
|
/** What a turn that produced no text puts in its message, wherever it is posted from. */
|
|
49
75
|
const NO_REPLY_TEXT = '(no reply)';
|
|
50
76
|
// Lazy (and CJS-interoperable) so tests with injected fakes never load the
|
|
@@ -58,6 +84,77 @@ const defaultWebClient = (botToken) => {
|
|
|
58
84
|
const { WebClient } = requireModule('@slack/web-api');
|
|
59
85
|
return new WebClient(botToken);
|
|
60
86
|
};
|
|
87
|
+
/**
|
|
88
|
+
* The default fetcher over a `fetch`. Exported as a factory so the bounded
|
|
89
|
+
* read can be tested against a fake `fetch` with a body that never ends;
|
|
90
|
+
* the adapter uses it over the global one.
|
|
91
|
+
*
|
|
92
|
+
* The body is read a chunk at a time and abandoned the moment it passes
|
|
93
|
+
* `maxBytes`: `arrayBuffer()` would buffer the whole response first, and
|
|
94
|
+
* the size the adapter checked before asking came from Slack's metadata,
|
|
95
|
+
* which an oversized response is under no obligation to match. A
|
|
96
|
+
* `content-length` that already says too much is refused before a byte is
|
|
97
|
+
* read.
|
|
98
|
+
*/
|
|
99
|
+
export const createSlackFileFetcher = (fetchImpl) => async (url, botToken, signal, maxBytes) => {
|
|
100
|
+
const response = await fetchImpl(url, { headers: { authorization: `Bearer ${botToken}` }, signal });
|
|
101
|
+
const contentType = response.headers.get('content-type');
|
|
102
|
+
const typed = contentType !== null ? { contentType } : {};
|
|
103
|
+
const declared = Number(response.headers.get('content-length'));
|
|
104
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
105
|
+
await response.body?.cancel();
|
|
106
|
+
return { status: response.status, ...typed, body: Buffer.alloc(0), truncated: true };
|
|
107
|
+
}
|
|
108
|
+
if (response.body === null) {
|
|
109
|
+
return { status: response.status, ...typed, body: Buffer.alloc(0) };
|
|
110
|
+
}
|
|
111
|
+
const reader = response.body.getReader();
|
|
112
|
+
const chunks = [];
|
|
113
|
+
let received = 0;
|
|
114
|
+
try {
|
|
115
|
+
for (;;) {
|
|
116
|
+
const { done, value } = await reader.read();
|
|
117
|
+
if (done) {
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
received += value.byteLength;
|
|
121
|
+
if (received > maxBytes) {
|
|
122
|
+
await reader.cancel();
|
|
123
|
+
return { status: response.status, ...typed, body: Buffer.alloc(0), truncated: true };
|
|
124
|
+
}
|
|
125
|
+
chunks.push(Buffer.from(value));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
reader.releaseLock();
|
|
130
|
+
}
|
|
131
|
+
return { status: response.status, ...typed, body: Buffer.concat(chunks) };
|
|
132
|
+
};
|
|
133
|
+
const defaultFetchFile = (url, botToken, signal, maxBytes) => createSlackFileFetcher(fetch)(url, botToken, signal, maxBytes);
|
|
134
|
+
/**
|
|
135
|
+
* A clock reading as a Slack timestamp, for a file whose upload answered
|
|
136
|
+
* with no share ts. Slack timestamps are epoch seconds with six decimals,
|
|
137
|
+
* so the clock's reading compares with them as long as the two agree to
|
|
138
|
+
* within the gap between one message and the next.
|
|
139
|
+
*/
|
|
140
|
+
const slackTs = (ms) => (ms / 1000).toFixed(6);
|
|
141
|
+
/**
|
|
142
|
+
* Where an upload sits in the thread: the ts of the message it became in
|
|
143
|
+
* `channel`, from the shares the upload answers with. Undefined when the
|
|
144
|
+
* answer carries none.
|
|
145
|
+
*/
|
|
146
|
+
const uploadPlace = (result, channel) => {
|
|
147
|
+
for (const file of result.files ?? []) {
|
|
148
|
+
for (const scope of [file.shares?.public, file.shares?.private]) {
|
|
149
|
+
for (const share of scope?.[channel] ?? []) {
|
|
150
|
+
if (share.ts !== undefined) {
|
|
151
|
+
return share.ts;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
};
|
|
61
158
|
/**
|
|
62
159
|
* Renders one turn's reply into Slack with the placeholder-then-edit
|
|
63
160
|
* pattern: post `…` immediately, fold streaming deltas and tool status
|
|
@@ -103,17 +200,45 @@ class ReplyRenderer {
|
|
|
103
200
|
*/
|
|
104
201
|
generation = 0;
|
|
105
202
|
finalized = false;
|
|
203
|
+
/** Whether any upload of this turn's landed — a file is something said, placeholder or not. */
|
|
204
|
+
uploaded = false;
|
|
205
|
+
/** Whether any edit of this turn's landed — a streamed line Slack took is something said, whatever the final edit's fate. */
|
|
206
|
+
edited = false;
|
|
207
|
+
/**
|
|
208
|
+
* Where in the thread this turn's words stand: the ts of the lowest
|
|
209
|
+
* message Slack took them in — the placeholder an edit landed in, or an
|
|
210
|
+
* overflow chunk posted — and, separately, where a file of its landed
|
|
211
|
+
* (the share ts the upload answers with, or the clock — see `queueUpload`).
|
|
212
|
+
* What the thread rule orders by: an untagged reply is for whoever's
|
|
213
|
+
* reply sits lowest in the thread, the one a reader sees last, which is
|
|
214
|
+
* not the turn that finished last — a placeholder posted at intake and
|
|
215
|
+
* filled in slowly sits above a reply posted while it was being written.
|
|
216
|
+
*/
|
|
217
|
+
textAt;
|
|
218
|
+
uploadedAt;
|
|
219
|
+
/**
|
|
220
|
+
* A turn nobody asked for: the placeholder is not posted at intake but
|
|
221
|
+
* on the first TEXT the turn streams, and a turn that ends having said
|
|
222
|
+
* nothing leaves the thread untouched — no placeholder, no `(no reply)`,
|
|
223
|
+
* no edit. Text and not a tool line, though the spec sketched either: a
|
|
224
|
+
* placeholder posted for a tool the turn ran on the way to deciding it
|
|
225
|
+
* had nothing to add is a message the decision cannot take back.
|
|
226
|
+
*/
|
|
227
|
+
lazy;
|
|
106
228
|
web;
|
|
107
229
|
channel;
|
|
108
230
|
threadTs;
|
|
109
231
|
editIntervalMs;
|
|
110
232
|
warn;
|
|
111
|
-
|
|
233
|
+
now;
|
|
234
|
+
constructor(web, channel, threadTs, editIntervalMs, warn, options = {}) {
|
|
112
235
|
this.web = web;
|
|
113
236
|
this.channel = channel;
|
|
114
237
|
this.threadTs = threadTs;
|
|
115
238
|
this.editIntervalMs = editIntervalMs;
|
|
116
239
|
this.warn = warn;
|
|
240
|
+
this.lazy = options.lazy ?? false;
|
|
241
|
+
this.now = options.now ?? Date.now;
|
|
117
242
|
}
|
|
118
243
|
/**
|
|
119
244
|
* The gateway has begun this renderer's turn. Whatever was streamed here
|
|
@@ -235,12 +360,27 @@ class ReplyRenderer {
|
|
|
235
360
|
// failure instead of an unhandled stream error.
|
|
236
361
|
.then(async () => {
|
|
237
362
|
const data = await readFile(filePath);
|
|
238
|
-
|
|
363
|
+
// Where the file will sit is Slack's to say — the ts of the
|
|
364
|
+
// message it becomes, in the shares the upload answers with. When
|
|
365
|
+
// the answer carries none, the clock stands in, read BEFORE the
|
|
366
|
+
// call: the file is posted somewhere inside it, and a colleague's
|
|
367
|
+
// placeholder posted while the answer was on its way back sits
|
|
368
|
+
// below the file. Read after, the file would be placed below that
|
|
369
|
+
// placeholder and take a thread the colleague holds; read before,
|
|
370
|
+
// the error runs the other way, toward the colleague — the fault
|
|
371
|
+
// an @ fixes.
|
|
372
|
+
const before = slackTs(this.now());
|
|
373
|
+
const result = await this.web.files.uploadV2({
|
|
239
374
|
channel_id: this.channel,
|
|
240
375
|
file: data,
|
|
241
376
|
filename: path.basename(filePath),
|
|
242
377
|
...(this.threadTs ? { thread_ts: this.threadTs } : {}),
|
|
243
378
|
});
|
|
379
|
+
this.uploaded = true;
|
|
380
|
+
const placed = uploadPlace(result, this.channel) ?? before;
|
|
381
|
+
if (this.uploadedAt === undefined || placed > this.uploadedAt) {
|
|
382
|
+
this.uploadedAt = placed;
|
|
383
|
+
}
|
|
244
384
|
})
|
|
245
385
|
.catch((error) => this.warn(`files.uploadV2 failed for ${filePath}: ${error instanceof Error ? error.message : String(error)}`));
|
|
246
386
|
}
|
|
@@ -261,11 +401,24 @@ class ReplyRenderer {
|
|
|
261
401
|
if (this.finalized) {
|
|
262
402
|
return;
|
|
263
403
|
}
|
|
404
|
+
// Nothing said yet: a lazy turn has no placeholder to edit, and a
|
|
405
|
+
// tool line alone does not earn one (see `lazy`). Nor does text
|
|
406
|
+
// streamed here before this turn began — a recovery's, routed to
|
|
407
|
+
// the head of the queue — which `beginTurn` drops.
|
|
408
|
+
if (this.lazy && !this.ref && (!this.turnStarted || this.buffer.trim().length === 0)) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
264
411
|
this.lastEditAt = Date.now();
|
|
265
|
-
this.queueEdit(messageText(this.currentText()));
|
|
412
|
+
void this.queueEdit(messageText(this.currentText()));
|
|
266
413
|
}, delay);
|
|
267
414
|
this.pendingEdit.unref?.();
|
|
268
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* Queue one edit of the placeholder. Resolves to whether Slack took it —
|
|
418
|
+
* false when there was no placeholder to edit, the text belonged to a
|
|
419
|
+
* turn handed over meanwhile, or the update was refused — which is what
|
|
420
|
+
* lets `finalize` say whether the reply was ever published.
|
|
421
|
+
*/
|
|
269
422
|
queueEdit(text) {
|
|
270
423
|
const generation = this.generation;
|
|
271
424
|
// The handover as it stands when the edit is queued, not when it runs:
|
|
@@ -273,21 +426,48 @@ class ReplyRenderer {
|
|
|
273
426
|
// edit that then waited for that handover would be a cycle nothing
|
|
274
427
|
// could break.
|
|
275
428
|
const handover = this.handover;
|
|
276
|
-
|
|
429
|
+
const edit = this.editChain
|
|
277
430
|
.then(() => handover)
|
|
278
|
-
.then(() => {
|
|
431
|
+
.then(async () => {
|
|
432
|
+
if (this.lazy && !this.ref && this.turnStarted && generation === this.generation) {
|
|
433
|
+
// The first text of a turn nobody asked for: this is where its
|
|
434
|
+
// placeholder is posted, and the edit below fills it. A post
|
|
435
|
+
// that fails leaves the turn without one, and its reply is a
|
|
436
|
+
// message of its own when it comes (see `finalize`).
|
|
437
|
+
try {
|
|
438
|
+
await this.open();
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
this.warn(`could not open a placeholder: ${error instanceof Error ? error.message : String(error)}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
279
444
|
// Read after the handover, not before: the placeholder may have
|
|
280
445
|
// changed hands while this edit waited its turn — and the text
|
|
281
446
|
// may belong to the turn that was handed over.
|
|
282
447
|
const ref = this.ref;
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
448
|
+
if (!ref || generation !== this.generation) {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
await this.web.chat.update({ channel: ref.channel, ts: ref.ts, text });
|
|
452
|
+
this.edited = true;
|
|
453
|
+
this.placeText(ref.ts);
|
|
454
|
+
return true;
|
|
286
455
|
})
|
|
287
|
-
.
|
|
288
|
-
|
|
456
|
+
.catch((error) => {
|
|
457
|
+
this.warn(`chat.update failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
458
|
+
return false;
|
|
459
|
+
});
|
|
460
|
+
this.editChain = edit.then(() => undefined);
|
|
461
|
+
return edit;
|
|
289
462
|
}
|
|
290
|
-
/**
|
|
463
|
+
/**
|
|
464
|
+
* Replace the placeholder with the final reply, splitting if oversized.
|
|
465
|
+
* Resolves to a `ReplyOutcome`. A reply Slack refused any part of was
|
|
466
|
+
* not said in the thread as written, and nothing downstream may treat it
|
|
467
|
+
* as if it had been: a colleague hearing the whole of a reply the thread
|
|
468
|
+
* saw half of is reasoning from text nobody read. But a file beside a
|
|
469
|
+
* refused sentence is still the last thing said, for the thread rule.
|
|
470
|
+
*/
|
|
291
471
|
async finalize(reply) {
|
|
292
472
|
await this.handover;
|
|
293
473
|
this.finalized = true;
|
|
@@ -295,31 +475,121 @@ class ReplyRenderer {
|
|
|
295
475
|
clearTimeout(this.pendingEdit);
|
|
296
476
|
this.pendingEdit = undefined;
|
|
297
477
|
}
|
|
478
|
+
// Let the edits already queued land before deciding anything from
|
|
479
|
+
// `ref`: a lazy turn's first text may be opening its placeholder in
|
|
480
|
+
// that chain right now, and reading `ref` ahead of it would post the
|
|
481
|
+
// reply as a message of its own beside a placeholder that then fills
|
|
482
|
+
// with stale partial text.
|
|
483
|
+
await this.editChain;
|
|
484
|
+
if (this.lazy && reply.trim().length === 0 && (!this.ref || await this.retract())) {
|
|
485
|
+
// Silence, decided: nothing was posted and nothing is — or what was
|
|
486
|
+
// posted has been taken back. The uploads still land: a file the
|
|
487
|
+
// turn produced is not nothing to say, and a turn that posted one
|
|
488
|
+
// has spoken, for the thread rule.
|
|
489
|
+
await this.editChain;
|
|
490
|
+
await this.uploadChain;
|
|
491
|
+
return this.outcome(false, this.uploaded);
|
|
492
|
+
}
|
|
298
493
|
const text = reply.trim().length > 0 ? reply : NO_REPLY_TEXT;
|
|
299
494
|
const chunks = messageChunks(text);
|
|
300
495
|
// No placeholder — a handover could not open a fresh one — and the
|
|
301
496
|
// reply is a message of its own rather than nothing at all.
|
|
302
497
|
const rest = this.ref ? chunks.slice(1) : chunks;
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
498
|
+
const edited = this.ref ? this.queueEdit(chunks[0] ?? NO_REPLY_TEXT) : Promise.resolve(false);
|
|
499
|
+
let landed = await edited;
|
|
500
|
+
let published = this.ref ? landed : true;
|
|
306
501
|
await this.editChain;
|
|
307
502
|
await this.uploadChain;
|
|
308
503
|
for (const chunk of rest) {
|
|
309
504
|
try {
|
|
310
|
-
await this.web.chat.postMessage({
|
|
505
|
+
const posted = await this.web.chat.postMessage({
|
|
311
506
|
channel: this.channel,
|
|
312
507
|
text: chunk,
|
|
313
508
|
...(this.threadTs ? { thread_ts: this.threadTs } : {}),
|
|
314
509
|
});
|
|
510
|
+
landed = true;
|
|
511
|
+
this.placeText(posted.ts);
|
|
315
512
|
}
|
|
316
513
|
catch (error) {
|
|
514
|
+
published = false;
|
|
317
515
|
this.warn(`chat.postMessage failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
318
516
|
}
|
|
319
517
|
}
|
|
518
|
+
// A placeholder that opened for a turn nobody asked for and never
|
|
519
|
+
// took a word of it — every edit refused — is a `…` standing alone
|
|
520
|
+
// under the agent's name, the interruption a judged turn exists to
|
|
521
|
+
// avoid, so it is taken back like an abandoned attempt's. One Slack
|
|
522
|
+
// will not let go stands, and a message the thread can see is the
|
|
523
|
+
// last thing said whatever it says.
|
|
524
|
+
let stranded = false;
|
|
525
|
+
if (this.lazy && this.ref !== undefined && !landed && !this.edited) {
|
|
526
|
+
stranded = !(await this.retract());
|
|
527
|
+
if (stranded) {
|
|
528
|
+
this.placeText(this.ref?.ts);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return this.outcome(published, landed || this.edited || this.uploaded || stranded);
|
|
532
|
+
}
|
|
533
|
+
placeText(ts) {
|
|
534
|
+
if (ts !== undefined && (this.textAt === undefined || ts > this.textAt)) {
|
|
535
|
+
this.textAt = ts;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
outcome(published, spoke) {
|
|
539
|
+
const spokeAt = [this.textAt, this.uploadedAt]
|
|
540
|
+
.filter((ts) => ts !== undefined)
|
|
541
|
+
.sort()
|
|
542
|
+
.at(-1);
|
|
543
|
+
return { published, spoke, ...(spoke && spokeAt !== undefined ? { spokeAt } : {}) };
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Take back the placeholder a turn nobody asked for opened and then had
|
|
547
|
+
* nothing to put in: the line that opened it was an attempt the provider
|
|
548
|
+
* abandoned (`reset` — a fallback after a mid-stream failure), and the
|
|
549
|
+
* attempt that stood answered with nothing. Editing that placeholder to
|
|
550
|
+
* `(no reply)` would leave exactly the interruption a silent turn exists
|
|
551
|
+
* to avoid, and would count as speaking. Resolves to whether Slack let
|
|
552
|
+
* the message go: a delete refused leaves the abandoned line standing,
|
|
553
|
+
* and the turn finishes the ordinary way, since a message the thread
|
|
554
|
+
* can still see is the last thing said whatever it says.
|
|
555
|
+
*/
|
|
556
|
+
async retract() {
|
|
557
|
+
const ref = this.ref;
|
|
558
|
+
if (!ref) {
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
await this.web.chat.delete({ channel: ref.channel, ts: ref.ts });
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
this.warn(`chat.delete failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
this.ref = undefined;
|
|
569
|
+
// The line the placeholder took is gone with it: nothing of this
|
|
570
|
+
// turn's stands in the thread unless a file does.
|
|
571
|
+
this.edited = false;
|
|
572
|
+
this.textAt = undefined;
|
|
573
|
+
return true;
|
|
320
574
|
}
|
|
321
575
|
async fail(message) {
|
|
322
|
-
|
|
576
|
+
// The same wait `finalize` takes, for the same reason: a lazy turn's
|
|
577
|
+
// first text may be opening its placeholder right now, and a failure
|
|
578
|
+
// read as "before saying anything" would leave that placeholder saying
|
|
579
|
+
// `(no reply)` where the failure belongs.
|
|
580
|
+
await this.editChain;
|
|
581
|
+
// "Before saying anything" is no placeholder AND nothing buffered: a
|
|
582
|
+
// first text whose zero-delay edit has not fired yet has still been
|
|
583
|
+
// said, and its failure is reported — as a message of its own, since
|
|
584
|
+
// there is no placeholder to edit — rather than swallowed with it.
|
|
585
|
+
if (this.lazy && !this.ref && this.buffer.trim().length === 0) {
|
|
586
|
+
// A turn nobody asked for that failed before saying anything: the
|
|
587
|
+
// failure is in the daemon log, and an error note would be the
|
|
588
|
+
// interruption the turn existed to avoid.
|
|
589
|
+
this.warn(`a turn nobody asked for failed before saying anything: ${message}`);
|
|
590
|
+
return this.finalize('');
|
|
591
|
+
}
|
|
592
|
+
return this.finalize(`Something went wrong: ${message}`);
|
|
323
593
|
}
|
|
324
594
|
/**
|
|
325
595
|
* Claim the placeholder for a turn that finished ahead of this one — the
|
|
@@ -357,7 +627,7 @@ class ReplyRenderer {
|
|
|
357
627
|
return {
|
|
358
628
|
take: async (chunks, between) => {
|
|
359
629
|
if (spent) {
|
|
360
|
-
return
|
|
630
|
+
return 'untaken';
|
|
361
631
|
}
|
|
362
632
|
try {
|
|
363
633
|
await previous;
|
|
@@ -380,7 +650,7 @@ class ReplyRenderer {
|
|
|
380
650
|
const [first, ...rest] = chunks;
|
|
381
651
|
const given = this.ref;
|
|
382
652
|
if (first === undefined || this.finalized || !given) {
|
|
383
|
-
return
|
|
653
|
+
return 'untaken';
|
|
384
654
|
}
|
|
385
655
|
await drained;
|
|
386
656
|
try {
|
|
@@ -390,8 +660,9 @@ class ReplyRenderer {
|
|
|
390
660
|
// Not handed over: the placeholder is still this turn's, and the
|
|
391
661
|
// caller posts the reply as messages of its own.
|
|
392
662
|
this.warn(`chat.update failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
393
|
-
return
|
|
663
|
+
return 'untaken';
|
|
394
664
|
}
|
|
665
|
+
let complete = true;
|
|
395
666
|
// The placeholder is the earlier turn's now, and the reference goes
|
|
396
667
|
// before the reopen, so a reopen that fails cannot leave this turn
|
|
397
668
|
// writing over the reply just handed over. (What was streamed into
|
|
@@ -407,6 +678,7 @@ class ReplyRenderer {
|
|
|
407
678
|
});
|
|
408
679
|
}
|
|
409
680
|
catch (error) {
|
|
681
|
+
complete = false;
|
|
410
682
|
this.warn(`chat.postMessage failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
411
683
|
}
|
|
412
684
|
}
|
|
@@ -425,32 +697,13 @@ class ReplyRenderer {
|
|
|
425
697
|
// message of its own when it comes (see `finalize`).
|
|
426
698
|
this.warn(`could not reopen a placeholder: ${error instanceof Error ? error.message : String(error)}`);
|
|
427
699
|
}
|
|
428
|
-
return
|
|
700
|
+
return complete ? 'complete' : 'partial';
|
|
429
701
|
}
|
|
430
702
|
}
|
|
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
|
-
};
|
|
703
|
+
// The file-bearing result convention is the kernel's (`filePathsOf`), so
|
|
704
|
+
// the gateway counts a turn that produced a file as having spoken by the
|
|
705
|
+
// same rule this adapter uploads it by.
|
|
706
|
+
const collectFilePaths = (result) => filePathsOf(result);
|
|
454
707
|
// A hard cut must never land between the halves of a UTF-16 surrogate
|
|
455
708
|
// pair — an emoji on the boundary would reach Slack as two replacement
|
|
456
709
|
// characters. Backs the index off the pair's high half when it would.
|
|
@@ -688,6 +941,57 @@ const convertHeadings = (text, code) => {
|
|
|
688
941
|
return startsInside(closer, within) ? heading : `*${heading}*`;
|
|
689
942
|
});
|
|
690
943
|
};
|
|
944
|
+
/**
|
|
945
|
+
* A stand-in for one code segment while the prose around it is rewritten.
|
|
946
|
+
*
|
|
947
|
+
* Emphasis was converted one prose fragment at a time until this existed,
|
|
948
|
+
* and a fragment is not a run: inline code cuts one in two, so the `**`
|
|
949
|
+
* opening a bold run and the `**` closing it land in different fragments
|
|
950
|
+
* whenever a snippet sits between them, and neither half ever finds its
|
|
951
|
+
* partner. ``**the `fs.read` tool**`` reached a reader as four literal
|
|
952
|
+
* asterisks around a snippet, and so did every link, italic and
|
|
953
|
+
* strikethrough written the same way. It is the defect #142 found in
|
|
954
|
+
* headings, in the rule right next to it, and it has the same answer:
|
|
955
|
+
* rewrite the whole text, with code held out of the rewrite rather than
|
|
956
|
+
* cut out of the text.
|
|
957
|
+
*
|
|
958
|
+
* A mask names its segment rather than merely standing in for one, because
|
|
959
|
+
* a rewrite is allowed to move it: the link rule writes its two captures
|
|
960
|
+
* back in the other order, so `[use `a`](https://host/`b`)` hands the URL's
|
|
961
|
+
* snippet to the label and the label's to the URL, and a snippet that
|
|
962
|
+
* arrives somewhere else is the same defect as one that is rewritten. Named,
|
|
963
|
+
* it comes back to the place holding its own name. No rewrite can damage
|
|
964
|
+
* the name itself: none of the delimiters they look for appear in it, and
|
|
965
|
+
* every rule copies what it matched through to its replacement.
|
|
966
|
+
*
|
|
967
|
+
* A mask also carries whether its segment holds whitespace, because three
|
|
968
|
+
* rules refuse to cross it — a destination is `[^()\s]+`, a label and an
|
|
969
|
+
* emphasis run stop at a newline — and a name alone has none to stop them.
|
|
970
|
+
* `[label](https://host/`a b`)` is not a destination and was never meant to
|
|
971
|
+
* convert; masked shapelessly it did, and came back rearranged into a link
|
|
972
|
+
* Slack cannot render. Only whitespace is carried, and only ever so a rule
|
|
973
|
+
* refuses: a `*` in a snippet must still not act like one. Both ends of a
|
|
974
|
+
* mask stay the mask character, so a run that has to hug its content still
|
|
975
|
+
* hugs it — a segment opens and closes with a backtick, so that is not a
|
|
976
|
+
* lie about its ends.
|
|
977
|
+
*
|
|
978
|
+
* The character is chosen against the text rather than fixed, because a
|
|
979
|
+
* reply that already contained it would have a snippet spliced in where
|
|
980
|
+
* the writer's own character stood — the one thing this conversion must
|
|
981
|
+
* never do. A reply holding all 6400 of the private-use characters has no
|
|
982
|
+
* mask to be given and is left in Markdown instead: prose that reads as it
|
|
983
|
+
* was written is a cosmetic loss, and a snippet spliced somewhere else is
|
|
984
|
+
* not.
|
|
985
|
+
*/
|
|
986
|
+
const maskFor = (text) => {
|
|
987
|
+
for (let point = 0xe000; point <= 0xf8ff; point += 1) {
|
|
988
|
+
const candidate = String.fromCharCode(point);
|
|
989
|
+
if (!text.includes(candidate)) {
|
|
990
|
+
return candidate;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return undefined;
|
|
994
|
+
};
|
|
691
995
|
/**
|
|
692
996
|
* Markdown as a model writes it, in the spelling Slack actually renders.
|
|
693
997
|
*
|
|
@@ -703,28 +1007,49 @@ const convertHeadings = (text, code) => {
|
|
|
703
1007
|
* add ways to be wrong and fix nothing.
|
|
704
1008
|
*/
|
|
705
1009
|
const toSlackMrkdwn = (text) => {
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
1010
|
+
const mask = maskFor(text);
|
|
1011
|
+
if (mask === undefined) {
|
|
1012
|
+
return text;
|
|
1013
|
+
}
|
|
709
1014
|
const segments = proseAndCode(text);
|
|
710
|
-
|
|
711
|
-
|
|
1015
|
+
const code = [];
|
|
1016
|
+
let masked = '';
|
|
1017
|
+
for (const [index, segment] of segments.entries()) {
|
|
712
1018
|
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;
|
|
1019
|
+
const shape = /\n/.test(segment) ? '\n' : /\s/.test(segment) ? ' ' : '';
|
|
1020
|
+
masked += `${mask}${code.length}${shape}${mask}`;
|
|
1021
|
+
code.push(segment);
|
|
1022
|
+
continue;
|
|
724
1023
|
}
|
|
725
|
-
|
|
726
|
-
}
|
|
727
|
-
|
|
1024
|
+
masked += segment;
|
|
1025
|
+
}
|
|
1026
|
+
// A code segment that ends the text is the unterminated fence: every
|
|
1027
|
+
// closed run is followed by a prose slice, even an empty one, so only an
|
|
1028
|
+
// unclosed one leaves the segments ending on a code index. Named, not
|
|
1029
|
+
// last: a rewrite may have moved it, and it is the segment that ran to
|
|
1030
|
+
// the text's end when it was cut, wherever it now sits.
|
|
1031
|
+
const unterminated = segments.length % 2 === 0 ? code.length - 1 : -1;
|
|
1032
|
+
// Where each code segment ends up in the converted text, which is not
|
|
1033
|
+
// where it started: the prose around it changes length as it is
|
|
1034
|
+
// rewritten, and the masks are the only record of where it went. Split
|
|
1035
|
+
// on the names, so the pieces alternate prose and the name of the
|
|
1036
|
+
// segment that follows it.
|
|
1037
|
+
const pieces = convertInline(masked).split(new RegExp(`${mask}(\\d+)[ \\n]?${mask}`));
|
|
1038
|
+
const spans = [];
|
|
1039
|
+
let converted = pieces[0] ?? '';
|
|
1040
|
+
for (let piece = 1; piece < pieces.length; piece += 2) {
|
|
1041
|
+
const named = Number(pieces[piece]);
|
|
1042
|
+
const segment = code[named] ?? '';
|
|
1043
|
+
// The unterminated fence has no end to be past, so nothing may be
|
|
1044
|
+
// appended after it either — recording the end as it looks would say a
|
|
1045
|
+
// marker placed at the text's end was safely outside.
|
|
1046
|
+
const ends = named === unterminated
|
|
1047
|
+
? Number.POSITIVE_INFINITY
|
|
1048
|
+
: converted.length + segment.length;
|
|
1049
|
+
spans.push([converted.length, ends]);
|
|
1050
|
+
converted += `${segment}${pieces[piece + 1] ?? ''}`;
|
|
1051
|
+
}
|
|
1052
|
+
return convertHeadings(converted, spans);
|
|
728
1053
|
};
|
|
729
1054
|
const splitForSlack = (text) => {
|
|
730
1055
|
if (text.length <= SLACK_MAX_MESSAGE_CHARS) {
|
|
@@ -775,23 +1100,17 @@ const APPROVAL_ACTIONS = {
|
|
|
775
1100
|
* agent does unattended, and an approver should see the scope they granted
|
|
776
1101
|
* in the record of granting it.
|
|
777
1102
|
*
|
|
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.
|
|
1103
|
+
* What it lasts rode in on the request as `always` (see `AlwaysMeans`),
|
|
1104
|
+
* so the line names the grant that was made rather than hedging between
|
|
1105
|
+
* lifetimes — which this message once had to, when nothing on the bus said
|
|
1106
|
+
* whether an "always" was the session's or the file's. One thing the
|
|
1107
|
+
* request still cannot promise is the disk write: the daemon does not
|
|
1108
|
+
* write over a whitelist it could not read, and by then this message has
|
|
1109
|
+
* been sent. The daemon's log has that line; `stratus grants <agent>` has
|
|
1110
|
+
* the list.
|
|
791
1111
|
*/
|
|
792
1112
|
const OUTCOME_TEXT = {
|
|
793
1113
|
'decided:once': 'Allowed once',
|
|
794
|
-
'decided:always': 'Allowed and remembered — for this session at least',
|
|
795
1114
|
'decided:deny': 'Denied',
|
|
796
1115
|
'timeout:deny': 'Expired without an answer — denied',
|
|
797
1116
|
// Covers both endings that reach here: the turn was aborted, and the
|
|
@@ -799,6 +1118,49 @@ const OUTCOME_TEXT = {
|
|
|
799
1118
|
'cancelled:deny': 'Cancelled before anyone answered — denied',
|
|
800
1119
|
'undeliverable:deny': 'Could not be put to an approver — denied',
|
|
801
1120
|
};
|
|
1121
|
+
/**
|
|
1122
|
+
* What **Always allow** did, by the grant the request said it would make.
|
|
1123
|
+
* A tool grant names the agent because that is its scope — not this
|
|
1124
|
+
* thread, not the session — and says how to take it back, since a grant
|
|
1125
|
+
* an approver cannot find later is a ratchet.
|
|
1126
|
+
*/
|
|
1127
|
+
const alwaysOutcome = (always, agentName, agentId) => {
|
|
1128
|
+
switch (always) {
|
|
1129
|
+
case 'scope':
|
|
1130
|
+
return 'Allowed, and this command\'s scope now runs without asking — until revoked';
|
|
1131
|
+
case 'origin':
|
|
1132
|
+
return 'Allowed, and this site is now acted on without asking — until revoked';
|
|
1133
|
+
case 'tool':
|
|
1134
|
+
return `Allowed, and granted to ${escapeSlackText(agentName)} until revoked (stratus grants ${escapeSlackText(agentId)})`;
|
|
1135
|
+
case 'session':
|
|
1136
|
+
return 'Allowed for the rest of this session';
|
|
1137
|
+
default:
|
|
1138
|
+
// An `always` submitted through `POST /approvals` against a request
|
|
1139
|
+
// this daemon did not label — an older daemon's event, say. The
|
|
1140
|
+
// request did not say, so neither does this.
|
|
1141
|
+
return 'Allowed and remembered — for this session at least';
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
/**
|
|
1145
|
+
* What **Always allow** would do, said beside the button. The lifetime is
|
|
1146
|
+
* the half of the question the button widens, and it differs by tool: a
|
|
1147
|
+
* standing grant to the agent is a larger thing to hand out than a session,
|
|
1148
|
+
* and an approver should know which they are clicking.
|
|
1149
|
+
*/
|
|
1150
|
+
const alwaysOffer = (always, agentName) => {
|
|
1151
|
+
switch (always) {
|
|
1152
|
+
case 'tool':
|
|
1153
|
+
return `*Always allow* grants this tool to ${escapeSlackText(agentName)} until an operator revokes it.`;
|
|
1154
|
+
case 'session':
|
|
1155
|
+
return '*Always allow* stops this tool asking again for the rest of this session.';
|
|
1156
|
+
case 'scope':
|
|
1157
|
+
return '*Always allow* remembers this command\'s scope for this agent, until revoked.';
|
|
1158
|
+
case 'origin':
|
|
1159
|
+
return '*Always allow* remembers this site for this agent, until revoked.';
|
|
1160
|
+
default:
|
|
1161
|
+
return undefined;
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
802
1164
|
/** Comfortably inside Slack's 3000-character section limit. */
|
|
803
1165
|
const APPROVAL_INPUT_LIMIT = 1400;
|
|
804
1166
|
/** One line in a notification; the blocks carry the readable version. */
|
|
@@ -824,10 +1186,11 @@ const MAX_LISTED_ATTACHMENTS = 5;
|
|
|
824
1186
|
* What a message's attachments are called, for a turn that cannot open
|
|
825
1187
|
* them. Without this the agent is handed "here's the log" and no log, and
|
|
826
1188
|
* answers as though it had read something — the note is what lets it say
|
|
827
|
-
* the true thing instead.
|
|
1189
|
+
* the true thing instead. Images the model was actually shown are not
|
|
1190
|
+
* listed here: they travel on the dispatch, name included.
|
|
828
1191
|
*/
|
|
829
1192
|
const attachmentNote = (files) => {
|
|
830
|
-
if (
|
|
1193
|
+
if (files.length === 0) {
|
|
831
1194
|
return '';
|
|
832
1195
|
}
|
|
833
1196
|
const names = files.slice(0, MAX_LISTED_ATTACHMENTS).map((file) => file.name ?? file.title ?? 'an unnamed file');
|
|
@@ -835,6 +1198,150 @@ const attachmentNote = (files) => {
|
|
|
835
1198
|
const listed = rest > 0 ? `${names.join(', ')}, and ${rest} more` : names.join(', ');
|
|
836
1199
|
return `\n[Attached: ${listed}. Attachment contents cannot be read here — say so rather than guessing at them.]`;
|
|
837
1200
|
};
|
|
1201
|
+
/** What Slack calls a file, in a warning about it. */
|
|
1202
|
+
const fileLabel = (file) => file.name ?? file.title ?? file.id ?? 'an unnamed file';
|
|
1203
|
+
/**
|
|
1204
|
+
* A message's attachments, sorted into the images the model will be shown
|
|
1205
|
+
* and everything else. An image is fetched only when Slack's own metadata
|
|
1206
|
+
* says it is one the API accepts and small enough to send; that keeps a
|
|
1207
|
+
* 40 MB PNG from being downloaded just to be dropped, and a PDF from being
|
|
1208
|
+
* fetched at all. What comes back is checked as well: without `files:read`
|
|
1209
|
+
* Slack serves a sign-in page with a 200, and sending that to the model as
|
|
1210
|
+
* an image would fail the turn with an error naming nothing the person can
|
|
1211
|
+
* fix — the warning here names the scope instead, and the file falls back
|
|
1212
|
+
* to the by-name note like any other attachment the turn cannot open.
|
|
1213
|
+
*/
|
|
1214
|
+
const readImageAttachments = async (files, botToken, fetchFile, timeoutMs, warn) => {
|
|
1215
|
+
// Decided from the last file back, the way the replay window is spent:
|
|
1216
|
+
// when a message's images do not all fit, the ones kept are the ones it
|
|
1217
|
+
// listed last, so intake and replay agree about which survive. Delivered
|
|
1218
|
+
// in the message's own order, because that is how the person sees them.
|
|
1219
|
+
const kept = new Map();
|
|
1220
|
+
const dropped = new Set();
|
|
1221
|
+
// The message's one deadline, shared by every download in it.
|
|
1222
|
+
const deadline = AbortSignal.timeout(timeoutMs);
|
|
1223
|
+
// Decoded bytes accepted so far. Checked against Slack's reported size
|
|
1224
|
+
// before a download and the real length after it, because the message
|
|
1225
|
+
// as a whole has a budget the per-image cap alone cannot keep.
|
|
1226
|
+
let total = 0;
|
|
1227
|
+
// Once an image that fits on its own does not fit what is left of the
|
|
1228
|
+
// message's budget, nothing listed before it is taken either: the window
|
|
1229
|
+
// is contiguous, as the replay window is, and a smaller older image must
|
|
1230
|
+
// not slip in past a larger newer one that was left out.
|
|
1231
|
+
let windowClosed = false;
|
|
1232
|
+
// Why an image is being left out, or nothing when it fits. One place for
|
|
1233
|
+
// both limits so the warning and the fallback cannot disagree.
|
|
1234
|
+
const overLimit = (label, bytes) => {
|
|
1235
|
+
if (bytes > IMAGE_ATTACHMENT_MAX_BYTES) {
|
|
1236
|
+
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.`;
|
|
1237
|
+
}
|
|
1238
|
+
if (total + bytes > IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES) {
|
|
1239
|
+
windowClosed = true;
|
|
1240
|
+
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.`;
|
|
1241
|
+
}
|
|
1242
|
+
return undefined;
|
|
1243
|
+
};
|
|
1244
|
+
for (let position = files.length - 1; position >= 0; position -= 1) {
|
|
1245
|
+
const file = files[position];
|
|
1246
|
+
const url = file.url_private_download ?? file.url_private;
|
|
1247
|
+
if (!isImageAttachmentMediaType(file.mimetype) || url === undefined) {
|
|
1248
|
+
dropped.add(position);
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
if (windowClosed) {
|
|
1252
|
+
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.`);
|
|
1253
|
+
dropped.add(position);
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
const tooBig = file.size === undefined ? undefined : overLimit(fileLabel(file), file.size);
|
|
1257
|
+
if (tooBig !== undefined) {
|
|
1258
|
+
warn(tooBig);
|
|
1259
|
+
dropped.add(position);
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
// What this image may weigh: the per-image cap, or what is left of the
|
|
1263
|
+
// message's budget if that is less. Handed to the fetcher so an
|
|
1264
|
+
// oversized response is abandoned mid-body rather than buffered whole.
|
|
1265
|
+
const maxBytes = Math.min(IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES - total);
|
|
1266
|
+
if (deadline.aborted) {
|
|
1267
|
+
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.`);
|
|
1268
|
+
dropped.add(position);
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
let download;
|
|
1272
|
+
try {
|
|
1273
|
+
download = await fetchFile(url, botToken, deadline, maxBytes);
|
|
1274
|
+
}
|
|
1275
|
+
catch (error) {
|
|
1276
|
+
const reason = error instanceof Error && error.name === 'TimeoutError'
|
|
1277
|
+
? `this message's downloads took longer than ${timeoutMs}ms`
|
|
1278
|
+
: (error instanceof Error ? error.message : String(error));
|
|
1279
|
+
warn(`slack: could not download ${fileLabel(file)}: ${reason}. The turn is told it cannot be read.`);
|
|
1280
|
+
dropped.add(position);
|
|
1281
|
+
continue;
|
|
1282
|
+
}
|
|
1283
|
+
// Refused on what Slack sends when it will not serve the file — a
|
|
1284
|
+
// non-200, or a 200 whose body is a page — rather than on the label of
|
|
1285
|
+
// what it does serve: a download URL may be typed as the image or as
|
|
1286
|
+
// a plain octet stream, and either is the bytes Slack said it was.
|
|
1287
|
+
const contentType = download.contentType?.split(';')[0]?.trim().toLowerCase() ?? '';
|
|
1288
|
+
if (download.status !== 200 || contentType.startsWith('text/')) {
|
|
1289
|
+
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.`);
|
|
1290
|
+
dropped.add(position);
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
if (download.truncated === true) {
|
|
1294
|
+
// Cut off at what was left of the message's budget, rather than at
|
|
1295
|
+
// the per-image cap: the same overflow the size check closes the
|
|
1296
|
+
// window on, learned from the bytes instead of the metadata.
|
|
1297
|
+
if (maxBytes < IMAGE_ATTACHMENT_MAX_BYTES) {
|
|
1298
|
+
windowClosed = true;
|
|
1299
|
+
}
|
|
1300
|
+
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.`);
|
|
1301
|
+
dropped.add(position);
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
const stillTooBig = overLimit(fileLabel(file), download.body.length);
|
|
1305
|
+
if (stillTooBig !== undefined) {
|
|
1306
|
+
warn(stillTooBig);
|
|
1307
|
+
dropped.add(position);
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
// Read from the bytes, not from Slack's metadata: an image the model
|
|
1311
|
+
// API refuses fails the turn it arrives on and, once stored, every turn
|
|
1312
|
+
// after — so what is stored has to be what the API takes.
|
|
1313
|
+
const dimensions = imageDimensions(download.body, file.mimetype);
|
|
1314
|
+
if (dimensions === undefined) {
|
|
1315
|
+
warn(`slack: ${fileLabel(file)} is not a complete ${file.mimetype} — its header or trailer is missing; the turn is told it cannot be read.`);
|
|
1316
|
+
dropped.add(position);
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1319
|
+
if (dimensions.width > IMAGE_ATTACHMENT_MAX_DIMENSION || dimensions.height > IMAGE_ATTACHMENT_MAX_DIMENSION) {
|
|
1320
|
+
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.`);
|
|
1321
|
+
dropped.add(position);
|
|
1322
|
+
continue;
|
|
1323
|
+
}
|
|
1324
|
+
total += download.body.length;
|
|
1325
|
+
const name = file.name ?? file.title;
|
|
1326
|
+
kept.set(position, {
|
|
1327
|
+
mediaType: file.mimetype,
|
|
1328
|
+
data: download.body.toString('base64'),
|
|
1329
|
+
...(name !== undefined ? { name } : {}),
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
const images = [];
|
|
1333
|
+
const unread = [];
|
|
1334
|
+
files.forEach((file, position) => {
|
|
1335
|
+
const image = kept.get(position);
|
|
1336
|
+
if (image !== undefined) {
|
|
1337
|
+
images.push(image);
|
|
1338
|
+
}
|
|
1339
|
+
else if (dropped.has(position)) {
|
|
1340
|
+
unread.push(file);
|
|
1341
|
+
}
|
|
1342
|
+
});
|
|
1343
|
+
return { images, unread };
|
|
1344
|
+
};
|
|
838
1345
|
/**
|
|
839
1346
|
* Whether `text` addresses a bot user by name. Slack writes a mention as
|
|
840
1347
|
* `<@U123>` markup whoever typed it, so this is the same question for an
|
|
@@ -877,8 +1384,9 @@ const renderInvocation = (input) => {
|
|
|
877
1384
|
truncated,
|
|
878
1385
|
};
|
|
879
1386
|
};
|
|
880
|
-
const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, oneShot) => {
|
|
1387
|
+
const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, oneShot, always) => {
|
|
881
1388
|
const invocation = renderInvocation(input);
|
|
1389
|
+
const offer = oneShot ? undefined : alwaysOffer(always, agentName);
|
|
882
1390
|
return [
|
|
883
1391
|
{
|
|
884
1392
|
type: 'section',
|
|
@@ -905,7 +1413,8 @@ const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, one
|
|
|
905
1413
|
elements: [{ type: 'mrkdwn', text: ':warning: Arguments shown are truncated — the full call is longer than this.' }],
|
|
906
1414
|
}]
|
|
907
1415
|
: []),
|
|
908
|
-
// Why there is no second button, when there is no second button
|
|
1416
|
+
// Why there is no second button, when there is no second button — and
|
|
1417
|
+
// what the second button does, when there is one.
|
|
909
1418
|
...(oneShot
|
|
910
1419
|
? [{
|
|
911
1420
|
type: 'context',
|
|
@@ -914,7 +1423,9 @@ const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, one
|
|
|
914
1423
|
text: ':lock: An approval covers this call only — nothing about it is remembered.',
|
|
915
1424
|
}],
|
|
916
1425
|
}]
|
|
917
|
-
:
|
|
1426
|
+
: offer !== undefined
|
|
1427
|
+
? [{ type: 'context', elements: [{ type: 'mrkdwn', text: offer }] }]
|
|
1428
|
+
: []),
|
|
918
1429
|
{
|
|
919
1430
|
type: 'actions',
|
|
920
1431
|
// The request id rides on every button rather than in the message
|
|
@@ -962,6 +1473,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
962
1473
|
const editIntervalMs = options.editIntervalMs ?? DEFAULT_EDIT_INTERVAL_MS;
|
|
963
1474
|
const createSocket = options.createSocketClient ?? defaultSocketClient;
|
|
964
1475
|
const createWeb = options.createWebClient ?? defaultWebClient;
|
|
1476
|
+
const fetchFile = options.fetchFile ?? defaultFetchFile;
|
|
1477
|
+
const fileDownloadTimeoutMs = options.fileDownloadTimeoutMs ?? DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS;
|
|
1478
|
+
const now = options.now ?? Date.now;
|
|
965
1479
|
const connections = [];
|
|
966
1480
|
// Every agent this adapter was asked to carry, connected or not — the
|
|
967
1481
|
// difference between "mine and broken" and "not mine".
|
|
@@ -1121,6 +1635,39 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1121
1635
|
return userId;
|
|
1122
1636
|
}
|
|
1123
1637
|
};
|
|
1638
|
+
/**
|
|
1639
|
+
* A display name as the model may read it: one line, bounded. It is text
|
|
1640
|
+
* its owner typed into their profile, and it lands in the speaker
|
|
1641
|
+
* position of a user turn (`Name: text`) or as `@Name` inside one — where
|
|
1642
|
+
* a newline, or a name long enough to be a paragraph, would let its owner
|
|
1643
|
+
* speak in a place nobody vouched for. Escaped rather than dropped, the
|
|
1644
|
+
* way memory entries are, so a strange name still reads as strange.
|
|
1645
|
+
*/
|
|
1646
|
+
const boundedDisplayName = (name) => {
|
|
1647
|
+
// Code points, not UTF-16 units: an emoji is one character of a name,
|
|
1648
|
+
// and a cut inside a surrogate pair is a malformed speaker.
|
|
1649
|
+
const characters = Array.from(escapeControlCharacters(name).trim());
|
|
1650
|
+
return characters.length > MAX_DISPLAY_NAME_LENGTH
|
|
1651
|
+
? `${characters.slice(0, MAX_DISPLAY_NAME_LENGTH - 1).join('')}…`
|
|
1652
|
+
: characters.join('');
|
|
1653
|
+
};
|
|
1654
|
+
/**
|
|
1655
|
+
* Who is speaking, as the model reads it. A principal's profile is
|
|
1656
|
+
* trusted the way their messages are, so it is their display name. With
|
|
1657
|
+
* a principals list in force everyone else is their stable id, as a
|
|
1658
|
+
* mention of them already is: named, not quoted. With no list at all
|
|
1659
|
+
* there is nobody to prefer, so every author keeps their name — the turn
|
|
1660
|
+
* is labelled `unknown` either way. "No list" is the key being absent:
|
|
1661
|
+
* an explicit empty list is how an agent is excluded from a shared one,
|
|
1662
|
+
* and it means nobody is preferred, not that nobody has been named.
|
|
1663
|
+
*/
|
|
1664
|
+
const authorFor = async (connection, userId) => {
|
|
1665
|
+
const principals = connection.config.principals;
|
|
1666
|
+
if (principals !== undefined && !principals.includes(userId)) {
|
|
1667
|
+
return userId;
|
|
1668
|
+
}
|
|
1669
|
+
return boundedDisplayName(await displayNameFor(connection, userId));
|
|
1670
|
+
};
|
|
1124
1671
|
// Mentions arrive as <@U123> markup; the model should read names.
|
|
1125
1672
|
/**
|
|
1126
1673
|
* `<@U…>` mentions become `@name` — for principals only. A display name
|
|
@@ -1139,12 +1686,218 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1139
1686
|
let result = withoutBot;
|
|
1140
1687
|
for (const id of new Set(ids)) {
|
|
1141
1688
|
if (principals.has(id)) {
|
|
1142
|
-
result = result.replaceAll(`<@${id}>`, `@${await displayNameFor(connection, id)}`);
|
|
1689
|
+
result = result.replaceAll(`<@${id}>`, `@${boundedDisplayName(await displayNameFor(connection, id))}`);
|
|
1143
1690
|
}
|
|
1144
1691
|
}
|
|
1145
1692
|
return result.trim();
|
|
1146
1693
|
};
|
|
1147
1694
|
const connectionFor = (agentId) => connections.find((candidate) => candidate.config.agentId === agentId);
|
|
1695
|
+
/** An agent's configuration, whether or not its socket came up. */
|
|
1696
|
+
const agentConfigFor = (agentId) => options.agents.find((candidate) => candidate.agentId === agentId);
|
|
1697
|
+
/**
|
|
1698
|
+
* Whether an agent takes a message from this sender at all — judged on
|
|
1699
|
+
* its configuration, not on a live connection: an agent whose socket
|
|
1700
|
+
* failed to start is still recognizable in a mention, and its door is
|
|
1701
|
+
* still its own. An agent this adapter was never configured for is
|
|
1702
|
+
* nobody's to refuse here, so it admits — the answer from before the
|
|
1703
|
+
* door existed.
|
|
1704
|
+
*/
|
|
1705
|
+
const admitsSender = (config, userId) => config === undefined
|
|
1706
|
+
|| config.admit !== 'principals'
|
|
1707
|
+
|| (config.principals ?? []).includes(userId);
|
|
1708
|
+
/**
|
|
1709
|
+
* How an agent listens, from its soul — read per message, so a roster
|
|
1710
|
+
* reload takes effect on the next one. `thread` when the soul says
|
|
1711
|
+
* nothing, which is every soul written before there was a choice.
|
|
1712
|
+
*/
|
|
1713
|
+
const listensOf = (agentId) => gatewayRef?.agents().find((agent) => agent.id === agentId)?.listens ?? 'thread';
|
|
1714
|
+
/**
|
|
1715
|
+
* Whether an agent that judges is still paying attention to a thread:
|
|
1716
|
+
* within `ATTENTION_MESSAGES` and `ATTENTION_MS` of when it last
|
|
1717
|
+
* answered a message that addressed it, both read from the session
|
|
1718
|
+
* rather than remembered here, so a restart forgets nothing. Anchored
|
|
1719
|
+
* to an addressed answer and not to any reply: a reply the agent chose
|
|
1720
|
+
* to give on a turn nobody asked for does not re-arm it, or a talkative
|
|
1721
|
+
* judge would keep itself attentive for good and cost a call per message
|
|
1722
|
+
* — the self-extension the spec declined. An agent never addressed in
|
|
1723
|
+
* the thread is not attentive: it was mentioned and its first turn has
|
|
1724
|
+
* not answered yet, or answered with nothing. Measured against the
|
|
1725
|
+
* message's own timestamp, not the clock, so a delivery that ran late is
|
|
1726
|
+
* judged as of when it was said. Bounded below as well as above: a
|
|
1727
|
+
* message said before the answer the window starts from is outside it
|
|
1728
|
+
* — Slack redelivers events a restarted daemon has no memory of, and a
|
|
1729
|
+
* negative age would otherwise pass the minutes bound however old the
|
|
1730
|
+
* message, on a turn the agent's answer already came after.
|
|
1731
|
+
*/
|
|
1732
|
+
const attentive = (routing, messageTs, pending) => {
|
|
1733
|
+
if (routing.lastAnsweredAt === undefined) {
|
|
1734
|
+
return false;
|
|
1735
|
+
}
|
|
1736
|
+
const answeredAt = Date.parse(routing.lastAnsweredAt);
|
|
1737
|
+
const saidAt = Number(messageTs) * 1000;
|
|
1738
|
+
if (Number.isNaN(answeredAt) || Number.isNaN(saidAt) || saidAt < answeredAt || saidAt - answeredAt > ATTENTION_MS) {
|
|
1739
|
+
return false;
|
|
1740
|
+
}
|
|
1741
|
+
return (routing.heardSinceAnswered ?? 0) + pending < ATTENTION_MESSAGES;
|
|
1742
|
+
};
|
|
1743
|
+
/**
|
|
1744
|
+
* Messages this adapter has dispatched to a session that the session
|
|
1745
|
+
* does not hold yet. The store counts what it holds, and a burst of
|
|
1746
|
+
* messages typed inside one second would each read the same count and
|
|
1747
|
+
* each be judged — the bound the window exists to give, blown by exactly
|
|
1748
|
+
* the thread that needs it. The renderer queue already knows: a renderer
|
|
1749
|
+
* is queued at dispatch and its turn starts (`turnStarted`) only once
|
|
1750
|
+
* the runner has saved the message — the same moment the store's count
|
|
1751
|
+
* takes it over — so the two never count one message twice.
|
|
1752
|
+
*/
|
|
1753
|
+
const pendingFor = (sessionId) => (renderers.get(sessionId) ?? []).filter((renderer) => !renderer.turnStarted).length
|
|
1754
|
+
+ (pendingObserves.get(sessionId) ?? 0);
|
|
1755
|
+
/**
|
|
1756
|
+
* Overhears placed on the gateway's chain and not yet written, per
|
|
1757
|
+
* session: the other kind of message the store does not hold yet. An
|
|
1758
|
+
* observe queued behind a running turn is heard only when the turn ends,
|
|
1759
|
+
* and a burst of messages to a colleague would otherwise count toward
|
|
1760
|
+
* nothing until then. Counted from placement to settlement.
|
|
1761
|
+
*/
|
|
1762
|
+
const pendingObserves = new Map();
|
|
1763
|
+
const placeObserve = (sessionId, observed) => {
|
|
1764
|
+
pendingObserves.set(sessionId, (pendingObserves.get(sessionId) ?? 0) + 1);
|
|
1765
|
+
const release = () => {
|
|
1766
|
+
const left = (pendingObserves.get(sessionId) ?? 1) - 1;
|
|
1767
|
+
if (left > 0) {
|
|
1768
|
+
pendingObserves.set(sessionId, left);
|
|
1769
|
+
}
|
|
1770
|
+
else {
|
|
1771
|
+
pendingObserves.delete(sessionId);
|
|
1772
|
+
}
|
|
1773
|
+
};
|
|
1774
|
+
void observed.then(release, release);
|
|
1775
|
+
return observed;
|
|
1776
|
+
};
|
|
1777
|
+
/**
|
|
1778
|
+
* Run `work` after everything already queued for this session's intake,
|
|
1779
|
+
* and hold the next link until it settles. The link is claimed
|
|
1780
|
+
* synchronously, so a message's place is fixed by when the adapter
|
|
1781
|
+
* learned of it and not by how long its lookups take — see
|
|
1782
|
+
* `handleInbound`, whose ordering this is.
|
|
1783
|
+
*/
|
|
1784
|
+
const onIntakeChain = (sessionId, work) => {
|
|
1785
|
+
const previous = intakeChains.get(sessionId) ?? Promise.resolve();
|
|
1786
|
+
const link = previous.then(work);
|
|
1787
|
+
const settled = link.then(() => undefined, () => undefined);
|
|
1788
|
+
intakeChains.set(sessionId, settled);
|
|
1789
|
+
void settled.then(() => {
|
|
1790
|
+
if (intakeChains.get(sessionId) === settled) {
|
|
1791
|
+
intakeChains.delete(sessionId);
|
|
1792
|
+
}
|
|
1793
|
+
});
|
|
1794
|
+
return link;
|
|
1795
|
+
};
|
|
1796
|
+
/**
|
|
1797
|
+
* What one agent REPLIED in a thread, heard by the thread's other agents.
|
|
1798
|
+
*
|
|
1799
|
+
* The other half of overhearing. What people say reaches an agent through
|
|
1800
|
+
* its socket (`admit`), but a colleague's reply is a bot message the
|
|
1801
|
+
* socket ignores on purpose, and its final text reaches Slack as a stream
|
|
1802
|
+
* of edits rather than one event — so it is not an inbound path at all.
|
|
1803
|
+
* The adapter that rendered the reply is the one thing that knows its
|
|
1804
|
+
* final text, and it observes that into the session every other agent
|
|
1805
|
+
* holds for the same thread, with no Slack event involved. Without this
|
|
1806
|
+
* an agent that followed a thread knew the questions its colleague was
|
|
1807
|
+
* asked and not the answers.
|
|
1808
|
+
*
|
|
1809
|
+
* Each hearer takes it on its own intake chain, claimed the moment the
|
|
1810
|
+
* reply is final: a message typed while the reply was still streaming
|
|
1811
|
+
* is already in that chain and is heard first, and one typed after the
|
|
1812
|
+
* final edit cannot overtake it. The observe itself is placed there and
|
|
1813
|
+
* not awaited inside the link — returned in a wrapper, since a promise
|
|
1814
|
+
* returned from the link is one the chain adopts — for the reason
|
|
1815
|
+
* `handleInbound` gives: a hearer with a long turn running must not hold
|
|
1816
|
+
* its next message's place hostage to that turn.
|
|
1817
|
+
*
|
|
1818
|
+
* Only once it is SAID, in full. The reservation is taken when the reply
|
|
1819
|
+
* is final, but the observe waits, inside the link, on `published` —
|
|
1820
|
+
* whether Slack took every edit and post of it — and a reply Slack
|
|
1821
|
+
* refused any part of is heard by nobody, since the thread did not see
|
|
1822
|
+
* it as written and the hearer has no way to know which part. That holds
|
|
1823
|
+
* the hearer's chain for the length of the final edit, which is the
|
|
1824
|
+
* same network round trip a person's message holds it for.
|
|
1825
|
+
*
|
|
1826
|
+
* Two boundaries decide who hears it, and neither is "same workspace".
|
|
1827
|
+
* Whether the hearer is in the THREAD is the gateway's answer on the
|
|
1828
|
+
* session's chain, as for every overhear: an agent never invited here
|
|
1829
|
+
* has no session under the key. Whether it is still in the CHANNEL is
|
|
1830
|
+
* Slack's, asked per reply: a message a person types reaches an agent
|
|
1831
|
+
* only through its own socket, so an app removed from a private channel
|
|
1832
|
+
* simply stops hearing — but a reply is forwarded by this process, and
|
|
1833
|
+
* would keep reaching an agent the operator took out of the room. That
|
|
1834
|
+
* is the one path where membership has to be checked rather than
|
|
1835
|
+
* assumed, and a lookup that fails leaves the agent not hearing, the
|
|
1836
|
+
* direction a boundary should fail in.
|
|
1837
|
+
*
|
|
1838
|
+
* Sent under the speaker's own label rather than `user`: an agent's
|
|
1839
|
+
* reply is at most its own word, and carries whatever its session has
|
|
1840
|
+
* been exposed to — a restatement of a stranger's text is still the
|
|
1841
|
+
* stranger's, which is exactly what `sessionWriteTrust` says.
|
|
1842
|
+
*
|
|
1843
|
+
* Only replies: a turn that said nothing posts `(no reply)`, which is
|
|
1844
|
+
* nothing to hear, and a failed turn's error note is the failure's, not
|
|
1845
|
+
* the agent's.
|
|
1846
|
+
*/
|
|
1847
|
+
const overhearReply = async (speaker, channel, thread, reply, spoken, published) => {
|
|
1848
|
+
const gateway = gatewayRef;
|
|
1849
|
+
if (!gateway?.observe || reply.trim().length === 0) {
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
const team = speaker.teamId;
|
|
1853
|
+
const metadata = {
|
|
1854
|
+
channel: 'slack',
|
|
1855
|
+
team,
|
|
1856
|
+
slackChannel: channel,
|
|
1857
|
+
slackUser: speaker.botUserId,
|
|
1858
|
+
slackThread: thread,
|
|
1859
|
+
[SENDER_TRUST_METADATA_KEY]: sessionWriteTrust(spoken),
|
|
1860
|
+
};
|
|
1861
|
+
// In the reply's own workspace only, because a session key carries a
|
|
1862
|
+
// team — the same reason `resolveFollowUpWinner` stays in-workspace.
|
|
1863
|
+
const hearers = connections.filter((candidate) => candidate.teamId === team && candidate.config.agentId !== speaker.config.agentId);
|
|
1864
|
+
await Promise.all(hearers.map(async (hearer) => {
|
|
1865
|
+
const sessionId = channelSessionKey({
|
|
1866
|
+
channel: 'slack',
|
|
1867
|
+
agentId: hearer.config.agentId,
|
|
1868
|
+
team,
|
|
1869
|
+
conversation: channel,
|
|
1870
|
+
thread,
|
|
1871
|
+
});
|
|
1872
|
+
try {
|
|
1873
|
+
const { observed } = await onIntakeChain(sessionId, async () => {
|
|
1874
|
+
if (!(await published)) {
|
|
1875
|
+
return { observed: undefined };
|
|
1876
|
+
}
|
|
1877
|
+
let member;
|
|
1878
|
+
try {
|
|
1879
|
+
member = (await hearer.web.conversations.info({ channel })).channel?.is_member;
|
|
1880
|
+
}
|
|
1881
|
+
catch (error) {
|
|
1882
|
+
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)}`);
|
|
1883
|
+
}
|
|
1884
|
+
if (member !== true) {
|
|
1885
|
+
return { observed: undefined };
|
|
1886
|
+
}
|
|
1887
|
+
// Named the way a person's message names its speaker: whoever
|
|
1888
|
+
// spoke, by display name. A bot user's is the app's own, set by
|
|
1889
|
+
// whoever installed it.
|
|
1890
|
+
const author = await displayNameFor(hearer, speaker.botUserId);
|
|
1891
|
+
const observed = gateway.observe?.({ sessionId, agentId: hearer.config.agentId, message: `${author}: ${reply}`, metadata });
|
|
1892
|
+
return { observed: observed === undefined ? undefined : placeObserve(sessionId, observed) };
|
|
1893
|
+
});
|
|
1894
|
+
await observed;
|
|
1895
|
+
}
|
|
1896
|
+
catch (error) {
|
|
1897
|
+
warn(`slack: ${hearer.config.agentId} could not overhear ${speaker.config.agentId}'s reply in ${channel}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1898
|
+
}
|
|
1899
|
+
}));
|
|
1900
|
+
};
|
|
1148
1901
|
/**
|
|
1149
1902
|
* The addressable outbound seam — the channel contract's first real
|
|
1150
1903
|
* `OutboundConnection` implementation. Validate-then-hand-over: every
|
|
@@ -1293,7 +2046,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1293
2046
|
+ `${event.origin ? ` on ${event.origin}` : ''}`
|
|
1294
2047
|
+ `${invocationSummary ? `: ${invocationSummary}` : ''}.`),
|
|
1295
2048
|
...(thread ? { thread_ts: thread } : {}),
|
|
1296
|
-
blocks: approvalBlocks(agentName, event.call.toolName, event.risk, event.requestId, event.call.input, event.origin, event.oneShot === true),
|
|
2049
|
+
blocks: approvalBlocks(agentName, event.call.toolName, event.risk, event.requestId, event.call.input, event.origin, event.oneShot === true, event.always),
|
|
1297
2050
|
});
|
|
1298
2051
|
post = posted.ts
|
|
1299
2052
|
? {
|
|
@@ -1305,6 +2058,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1305
2058
|
toolName: event.call.toolName,
|
|
1306
2059
|
risk: event.risk,
|
|
1307
2060
|
oneShot: event.oneShot === true,
|
|
2061
|
+
...(event.always !== undefined ? { always: event.always } : {}),
|
|
2062
|
+
agentId: event.agentId,
|
|
1308
2063
|
approvers,
|
|
1309
2064
|
}
|
|
1310
2065
|
: undefined;
|
|
@@ -1409,8 +2164,17 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1409
2164
|
? post.risk === 'dangerous'
|
|
1410
2165
|
? 'Allowed once — a dangerous tool is never remembered'
|
|
1411
2166
|
: 'Allowed once — nothing about this call could be remembered'
|
|
1412
|
-
:
|
|
1413
|
-
|
|
2167
|
+
: event.reason === 'decided' && event.answer === 'always'
|
|
2168
|
+
? alwaysOutcome(post.always, post.agentName, post.agentId)
|
|
2169
|
+
: OUTCOME_TEXT[`${event.reason}:${event.answer}`] ?? 'Resolved';
|
|
2170
|
+
// A mention only for an actor this post knows as a Slack user — its
|
|
2171
|
+
// approvers, which is the only set a click can come from. An approval
|
|
2172
|
+
// decided through the control API arrives as `api:…` or `dashboard`,
|
|
2173
|
+
// and `<@api:ops-bot>` is not a mention, it is a broken one; escaped
|
|
2174
|
+
// plain text says who it was.
|
|
2175
|
+
const by = event.actor
|
|
2176
|
+
? ` by ${post.approvers.has(event.actor) ? `<@${event.actor}>` : escapeSlackText(event.actor)}`
|
|
2177
|
+
: '';
|
|
1414
2178
|
const text = `*${escapeSlackText(post.agentName)}* — \`${escapeSlackText(post.toolName)}\` (${post.risk}): ${outcome}${by}.`;
|
|
1415
2179
|
try {
|
|
1416
2180
|
await post.connection.web.chat.update({
|
|
@@ -1445,9 +2209,13 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1445
2209
|
* cost the rest of the answer.
|
|
1446
2210
|
*/
|
|
1447
2211
|
const postAheadOf = async (behind, connection, channel, thread, chunks, between) => {
|
|
1448
|
-
|
|
1449
|
-
|
|
2212
|
+
const taken = behind ? await behind.take(chunks, between) : 'untaken';
|
|
2213
|
+
if (taken !== 'untaken') {
|
|
2214
|
+
return taken === 'complete';
|
|
1450
2215
|
}
|
|
2216
|
+
// Whether ALL of it landed, as `finalize` answers: a reply the thread
|
|
2217
|
+
// saw part of is not one anybody else may be told the whole of.
|
|
2218
|
+
let published = true;
|
|
1451
2219
|
for (const chunk of chunks) {
|
|
1452
2220
|
try {
|
|
1453
2221
|
await connection.web.chat.postMessage({
|
|
@@ -1457,12 +2225,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1457
2225
|
});
|
|
1458
2226
|
}
|
|
1459
2227
|
catch (error) {
|
|
2228
|
+
published = false;
|
|
1460
2229
|
warn(`slack: could not post part of a turn's outcome: ${error instanceof Error ? error.message : String(error)}`);
|
|
1461
2230
|
}
|
|
1462
2231
|
}
|
|
1463
2232
|
if (between) {
|
|
1464
2233
|
await between();
|
|
1465
2234
|
}
|
|
2235
|
+
return published;
|
|
1466
2236
|
};
|
|
1467
2237
|
/**
|
|
1468
2238
|
* The files an unrendered turn produced, taken off the session the moment
|
|
@@ -1519,6 +2289,26 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1519
2289
|
}
|
|
1520
2290
|
const channel = metadata.slackChannel;
|
|
1521
2291
|
const thread = typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined;
|
|
2292
|
+
if (routing.unaddressed === true && routing.reply === undefined) {
|
|
2293
|
+
// A turn nobody asked for that said nothing before it broke — the
|
|
2294
|
+
// daemon died inside it, and the next start failed it with no
|
|
2295
|
+
// renderer here to keep quiet for it. The same rule as the lazy
|
|
2296
|
+
// renderer's: the failure is in the log, and the files it produced
|
|
2297
|
+
// still land, since a file is not nothing to say.
|
|
2298
|
+
//
|
|
2299
|
+
// "Said nothing" as the session can tell it: no reply saved for the
|
|
2300
|
+
// turn. A line the old process had streamed into a placeholder and
|
|
2301
|
+
// never saved is invisible here — the session records what was
|
|
2302
|
+
// said, not what Slack took (roadmap 31's open item) — so such a
|
|
2303
|
+
// turn is failed without a note under that line. Taken over the
|
|
2304
|
+
// alternative on purpose: a note for every judged turn a restart
|
|
2305
|
+
// caught turns a silent turn, the common case, into the
|
|
2306
|
+
// interruption it existed to avoid; a line left standing happens
|
|
2307
|
+
// only to a turn that had already chosen to speak.
|
|
2308
|
+
warn(`slack: a turn nobody asked for failed before saying anything: ${event.error}`);
|
|
2309
|
+
await uploadUnrenderedFiles(connection, channel, thread, files);
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
1522
2312
|
await postAheadOf(behind, connection, channel, thread, messageChunks(`Something went wrong: ${event.error}`), () => uploadUnrenderedFiles(connection, channel, thread, files));
|
|
1523
2313
|
};
|
|
1524
2314
|
/**
|
|
@@ -1567,7 +2357,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1567
2357
|
await uploads();
|
|
1568
2358
|
return;
|
|
1569
2359
|
}
|
|
1570
|
-
|
|
2360
|
+
// A reply the thread's other agents hear like any other, taking their
|
|
2361
|
+
// place for it before the post, as `handleInbound` does.
|
|
2362
|
+
const published = postAheadOf(behind, connection, channel, thread, posting, uploads);
|
|
2363
|
+
const heard = routing.reply !== undefined && thread !== undefined
|
|
2364
|
+
? overhearReply(connection, channel, thread, routing.reply, routing, published)
|
|
2365
|
+
: undefined;
|
|
2366
|
+
await published;
|
|
2367
|
+
await heard;
|
|
1571
2368
|
log(`slack: posted the reply of a turn finished after a restart to ${channel}`);
|
|
1572
2369
|
};
|
|
1573
2370
|
const handleInteractive = async (connection, args) => {
|
|
@@ -1647,14 +2444,25 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1647
2444
|
* Records that `agentId` holds the thread from the message at `ts`
|
|
1648
2445
|
* onwards, wherever in the thread's history that message falls.
|
|
1649
2446
|
*
|
|
1650
|
-
*
|
|
1651
|
-
*
|
|
1652
|
-
*
|
|
1653
|
-
*
|
|
2447
|
+
* Every claim is kept, not only changes of hands. A claim by the agent
|
|
2448
|
+
* already holding the thread looks redundant until another agent's reply
|
|
2449
|
+
* is placed between it and the one before — a judge's reply posted below
|
|
2450
|
+
* the holder's older message and above its newer one — at which point it
|
|
2451
|
+
* is what keeps the newer message the holder's; dropped, the judge's
|
|
2452
|
+
* later insertion would take the thread from a reply that sits below it.
|
|
2453
|
+
* Only the same claim twice — the same message reaching a second
|
|
2454
|
+
* connection — is dropped.
|
|
2455
|
+
* The oldest claims are dropped past `THREAD_HANDOVER_DEPTH`, and the
|
|
1654
2456
|
* least recently written thread past `THREAD_ADDRESSEE_CAPACITY`; both
|
|
1655
2457
|
* losses cost a lookup, never an answer, since the sessions still hold
|
|
1656
2458
|
* the durable version.
|
|
1657
2459
|
*
|
|
2460
|
+
* `ts` is a message's place in the thread: the message that handed the
|
|
2461
|
+
* thread over at intake, or — once a reply has landed — the reply's own
|
|
2462
|
+
* (`ReplyOutcome.spokeAt`), which is what puts a judge's reply posted
|
|
2463
|
+
* below a colleague's placeholder after it, and one streamed above it
|
|
2464
|
+
* before, whichever turn finished first.
|
|
2465
|
+
*
|
|
1658
2466
|
* Slack timestamps compare as strings: `seconds.microseconds`, both
|
|
1659
2467
|
* parts fixed width, so lexicographic order is chronological order.
|
|
1660
2468
|
*/
|
|
@@ -1665,9 +2473,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1665
2473
|
at -= 1;
|
|
1666
2474
|
}
|
|
1667
2475
|
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.
|
|
2476
|
+
if (before && before.agentId === agentId && before.ts === ts) {
|
|
1671
2477
|
return;
|
|
1672
2478
|
}
|
|
1673
2479
|
handovers.splice(at, 0, { agentId, ts });
|
|
@@ -1724,10 +2530,17 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1724
2530
|
* so is one whose `auth.test` itself failed, since its bot id was never
|
|
1725
2531
|
* learned.
|
|
1726
2532
|
*/
|
|
1727
|
-
const agentNamedIn = (text, team
|
|
2533
|
+
const agentNamedIn = (text, team,
|
|
2534
|
+
/**
|
|
2535
|
+
* Whether a named agent would take this message at all. A message
|
|
2536
|
+
* naming two agents hands the thread to one that admits the sender,
|
|
2537
|
+
* not to the first one named: the agent that refuses will not answer,
|
|
2538
|
+
* and a handover to it is a thread nobody holds.
|
|
2539
|
+
*/
|
|
2540
|
+
admits = () => true) => {
|
|
1728
2541
|
let offline;
|
|
1729
2542
|
for (const [agentId, identity] of botIdentities) {
|
|
1730
|
-
if (identity.teamId !== team || !mentions(text, identity.botUserId)) {
|
|
2543
|
+
if (identity.teamId !== team || !mentions(text, identity.botUserId) || !admits(agentId)) {
|
|
1731
2544
|
continue;
|
|
1732
2545
|
}
|
|
1733
2546
|
if (connections.some((live) => live.config.agentId === agentId)) {
|
|
@@ -1896,10 +2709,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1896
2709
|
* the dedupe and the handover record included, which are the two pieces a
|
|
1897
2710
|
* later message reads.
|
|
1898
2711
|
*
|
|
1899
|
-
* `undefined` means the message is
|
|
1900
|
-
*
|
|
1901
|
-
*
|
|
1902
|
-
*
|
|
2712
|
+
* `undefined` means the message is nothing to this agent — a DM to
|
|
2713
|
+
* someone else, the room rather than a thread, its own words.
|
|
2714
|
+
* `overhear: true` means it is somebody else's to answer and this
|
|
2715
|
+
* agent's to hear, if it is in the thread at all, which only the sessions
|
|
2716
|
+
* can say. `settled: false` means only the sessions can decide even that
|
|
2717
|
+
* much, which is the one case that has to wait — and it is the cold
|
|
2718
|
+
* case, where by definition no message of this thread has been seen in
|
|
2719
|
+
* this process.
|
|
1903
2720
|
*/
|
|
1904
2721
|
const admit = (connection, args) => {
|
|
1905
2722
|
const event = args.event;
|
|
@@ -1912,6 +2729,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1912
2729
|
if (event.user === connection.botUserId) {
|
|
1913
2730
|
return undefined;
|
|
1914
2731
|
}
|
|
2732
|
+
// Bound once the guard above has run: the closures below cannot see
|
|
2733
|
+
// that narrowing.
|
|
2734
|
+
const sender = event.user;
|
|
1915
2735
|
const isDm = event.channel_type === 'im';
|
|
1916
2736
|
const team = args.body?.team_id ?? connection.teamId;
|
|
1917
2737
|
// A top-level mention has no thread_ts; its own ts roots the reply
|
|
@@ -1932,9 +2752,76 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1932
2752
|
// handover, Ava could take the next untagged reply while Bea's socket
|
|
1933
2753
|
// was still behind, and answer a question that had just been handed
|
|
1934
2754
|
// away. The message Ava must record is exactly the one Ava ignores.
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
2755
|
+
// Who the message is for, judged on the named agents' own terms rather
|
|
2756
|
+
// than this one's: agents may list different principals, and a sender
|
|
2757
|
+
// Ava admits but Bea refuses must not be able to hand Bea the thread
|
|
2758
|
+
// from Ava's socket — Bea's own socket refuses the same message, and
|
|
2759
|
+
// the map is shared. A message naming Bea and Cy is Cy's, who will
|
|
2760
|
+
// answer it and hold the thread. One naming only agents that refuse
|
|
2761
|
+
// the sender is still theirs and not this agent's to answer — so the
|
|
2762
|
+
// refusing agent stays `named`, and only the handover is withheld.
|
|
2763
|
+
const handoverTo = agentNamedIn(text, team, (agentId) => admitsSender(agentConfigFor(agentId), sender));
|
|
2764
|
+
const named = handoverTo ?? agentNamedIn(text, team);
|
|
2765
|
+
// The key one Slack MESSAGE is known by, whichever delivery carried
|
|
2766
|
+
// it; see the dedupe below for why it is not the event id.
|
|
2767
|
+
const eventKey = `${connection.config.agentId}:${event.channel}:${event.ts}`;
|
|
2768
|
+
// Recorded ahead of this agent's own refusal below, and on the named
|
|
2769
|
+
// agent's admission rather than this one's: a sender Ava refuses and
|
|
2770
|
+
// Bea admits has handed Bea the thread, and Ava's socket may be the
|
|
2771
|
+
// one that hears it first. If Ava withheld the record, a fast untagged
|
|
2772
|
+
// follow-up from someone Ava does admit would go to the old holder on
|
|
2773
|
+
// Ava's socket and to Bea once Bea's socket caught up — two answers to
|
|
2774
|
+
// one message. The refused message itself still takes no place in
|
|
2775
|
+
// Ava's transcript; only the map learns who holds the thread.
|
|
2776
|
+
if (threadKey && handoverTo) {
|
|
2777
|
+
rememberAddressee(threadKey, handoverTo, event.ts);
|
|
2778
|
+
}
|
|
2779
|
+
// Who may speak at all, judged before anything below remembers this
|
|
2780
|
+
// message. The adapter's own checks establish nothing about who is
|
|
2781
|
+
// typing; under `admit: 'principals'` the operator's list is the door,
|
|
2782
|
+
// not a label, and a sender not on it gets neither a turn nor a place
|
|
2783
|
+
// in a transcript the agent reads — nor a say in who holds the thread
|
|
2784
|
+
// beyond what the named agent's own admission above allowed.
|
|
2785
|
+
// Logged rather than answered — a reply is a conversation with someone
|
|
2786
|
+
// the operator chose not to have one with — and logged only for a
|
|
2787
|
+
// message this agent would have taken up: a mention, or an untagged
|
|
2788
|
+
// reply inside a thread this agent is known to hold. A channel
|
|
2789
|
+
// subscribed for thread follow-through delivers every post and every
|
|
2790
|
+
// reply in every thread, including the threads this agent was never
|
|
2791
|
+
// part of, and a line per stranger per reply is a log with nothing
|
|
2792
|
+
// left to read. Known from what this process has seen, as the
|
|
2793
|
+
// admitted path below judges it; a thread nothing here remembers is
|
|
2794
|
+
// one the sessions would have to be asked about, and a refused message
|
|
2795
|
+
// does not get a place in that queue. Once per MESSAGE, on the same key
|
|
2796
|
+
// the admitted path dedupes on: a mention arrives twice under both
|
|
2797
|
+
// subscriptions, and a delivery Slack did not see acknowledged arrives
|
|
2798
|
+
// again, and each copy would otherwise write its own line during
|
|
2799
|
+
// exactly the traffic an operator reads the log to understand.
|
|
2800
|
+
// Consuming the key here is safe because a refused message records
|
|
2801
|
+
// nothing a later copy could be needed for.
|
|
2802
|
+
if (!admitsSender(connection.config, sender)) {
|
|
2803
|
+
const refusal = `slack: ${connection.config.agentId} refused a message from ${sender}: not a listed principal, and admit is "principals"`;
|
|
2804
|
+
const held = threadKey !== undefined ? holderAt(threadKey, event.ts) : undefined;
|
|
2805
|
+
if (addressed || held === connection.config.agentId) {
|
|
2806
|
+
if (!alreadySeen(eventKey)) {
|
|
2807
|
+
log(refusal);
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
else if (held === undefined && event.thread_ts !== undefined && !alreadySeen(eventKey)) {
|
|
2811
|
+
// Nothing in memory says whose thread this is — after a restart, or
|
|
2812
|
+
// once the record was evicted — but the sessions may: the durable
|
|
2813
|
+
// half of the question the admitted path asks, and the same
|
|
2814
|
+
// verdict. Off the intake chain, because a refused message gets no
|
|
2815
|
+
// place in that queue, and the line is an audit, not a turn; `track`
|
|
2816
|
+
// holds it so `stop()` drains it like any other background work.
|
|
2817
|
+
const thread = event.thread_ts;
|
|
2818
|
+
track(followUpWinner({ team, conversation: event.channel, thread }, event.ts).then((winner) => {
|
|
2819
|
+
if (winner === connection.config.agentId) {
|
|
2820
|
+
log(refusal);
|
|
2821
|
+
}
|
|
2822
|
+
}));
|
|
2823
|
+
}
|
|
2824
|
+
return undefined;
|
|
1938
2825
|
}
|
|
1939
2826
|
if (!addressed) {
|
|
1940
2827
|
if (event.thread_ts === undefined) {
|
|
@@ -1942,13 +2829,6 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1942
2829
|
// or stands alone, is a follow-up to nothing.
|
|
1943
2830
|
return undefined;
|
|
1944
2831
|
}
|
|
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
2832
|
}
|
|
1953
2833
|
// One Slack MESSAGE, not one delivery. An app subscribed to both
|
|
1954
2834
|
// `app_mention` and `message.channels` is told about a mention twice —
|
|
@@ -1960,8 +2840,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1960
2840
|
// After the handover record and not before it: a redelivery re-records
|
|
1961
2841
|
// the same agent, which costs nothing, while deduping first would drop
|
|
1962
2842
|
// the record whenever the other copy of a doubly-delivered mention is
|
|
1963
|
-
// the one a connection sees second.
|
|
1964
|
-
|
|
2843
|
+
// the one a connection sees second. And before every stand-down below,
|
|
2844
|
+
// because standing down is no longer nothing: an agent that hears a
|
|
2845
|
+
// message it is not answering must hear it once.
|
|
1965
2846
|
if (alreadySeen(eventKey)) {
|
|
1966
2847
|
return undefined;
|
|
1967
2848
|
}
|
|
@@ -1980,6 +2861,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1980
2861
|
...(thread ? { thread } : {}),
|
|
1981
2862
|
...(threadKey ? { threadKey } : {}),
|
|
1982
2863
|
settled: true,
|
|
2864
|
+
overhear: false,
|
|
2865
|
+
judge: false,
|
|
1983
2866
|
};
|
|
1984
2867
|
if (addressed) {
|
|
1985
2868
|
// A message that named this agent and others is recorded above under
|
|
@@ -1989,9 +2872,33 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
1989
2872
|
// reading of a message that asked several.
|
|
1990
2873
|
return admission;
|
|
1991
2874
|
}
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
2875
|
+
if (named) {
|
|
2876
|
+
// Somebody else was asked. Tagging an agent hands it the question,
|
|
2877
|
+
// and the agent that had it is no longer being asked — two answers
|
|
2878
|
+
// to one message is what a thread with a roster in it must never
|
|
2879
|
+
// produce. But the agent that had it is still in the room, and a
|
|
2880
|
+
// person in that position hears the question asked of their
|
|
2881
|
+
// colleague: it goes into this agent's session, unanswered, so the
|
|
2882
|
+
// next thing it is asked is asked of someone who followed along.
|
|
2883
|
+
return { ...admission, overhear: true };
|
|
2884
|
+
}
|
|
2885
|
+
// An untagged reply. How this agent listens decides what it is: an
|
|
2886
|
+
// agent that answers only mentions hears it and nothing more, and one
|
|
2887
|
+
// that judges hears it and, if still attentive, takes a turn nobody
|
|
2888
|
+
// asked for on it — decided in the intake, since only the session can
|
|
2889
|
+
// say. Neither takes part in the holder rule below: that rule answers
|
|
2890
|
+
// "whose is an untagged reply", and for these two agents the soul has
|
|
2891
|
+
// already answered.
|
|
2892
|
+
const listens = listensOf(connection.config.agentId);
|
|
2893
|
+
if (listens === 'mentions') {
|
|
2894
|
+
return { ...admission, overhear: true };
|
|
2895
|
+
}
|
|
2896
|
+
if (listens === 'judge') {
|
|
2897
|
+
return { ...admission, overhear: true, judge: true };
|
|
2898
|
+
}
|
|
2899
|
+
// The record is what this process has actually seen, in the order it
|
|
2900
|
+
// saw it — right even inside the window before a handover reaches the
|
|
2901
|
+
// store.
|
|
1995
2902
|
const holder = threadKey === undefined ? undefined : holderAt(threadKey, event.ts);
|
|
1996
2903
|
if (holder !== undefined && threadKey !== undefined) {
|
|
1997
2904
|
if (holder !== connection.config.agentId) {
|
|
@@ -2004,7 +2911,10 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2004
2911
|
// membership the adapter does not track, and would have to be
|
|
2005
2912
|
// tracked in the durable path too, since a removed app is still a
|
|
2006
2913
|
// live connection with the newest session there.
|
|
2007
|
-
|
|
2914
|
+
//
|
|
2915
|
+
// Standing down is not leaving: the reply is the holder's, and this
|
|
2916
|
+
// agent hears it.
|
|
2917
|
+
return { ...admission, overhear: true };
|
|
2008
2918
|
}
|
|
2009
2919
|
rememberAddressee(threadKey, connection.config.agentId, event.ts);
|
|
2010
2920
|
return admission;
|
|
@@ -2047,42 +2957,127 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2047
2957
|
// reverse order in durable history. The chain is claimed here,
|
|
2048
2958
|
// synchronously, so a message's place in it is fixed by when Slack
|
|
2049
2959
|
// delivered it and not by how long anything it does takes.
|
|
2050
|
-
const
|
|
2051
|
-
const intake = previousIntake.then(async () => {
|
|
2960
|
+
const intake = onIntakeChain(sessionId, async () => {
|
|
2052
2961
|
// The durable half of addressing runs INSIDE the chain, for the same
|
|
2053
2962
|
// reason: it awaits, and a message that had to ask the sessions must
|
|
2054
2963
|
// not lose its place to one that did not. A message that turns out
|
|
2055
2964
|
// not to be this agent's leaves an empty link behind, which is all it
|
|
2056
2965
|
// should cost.
|
|
2966
|
+
let overhear = admitted.overhear;
|
|
2967
|
+
// Judged, not ruled: a turn nobody asked for, while the agent is
|
|
2968
|
+
// still attentive, and a free overhear past that. Read off the
|
|
2969
|
+
// chain — a session the invitation is still creating reads as not
|
|
2970
|
+
// there, and the message is heard rather than judged, which is the
|
|
2971
|
+
// safe side. A host that cannot say leaves the agent hearing.
|
|
2972
|
+
let judged = false;
|
|
2973
|
+
if (admitted.judge && gateway.sessionRouting) {
|
|
2974
|
+
// The pending count is taken BEFORE the store is read: a message
|
|
2975
|
+
// that settles between the two would be counted by neither the
|
|
2976
|
+
// other way round, and a window that runs one message short in a
|
|
2977
|
+
// race is a bound kept; one that runs a message long is not.
|
|
2978
|
+
const pending = pendingFor(sessionId);
|
|
2979
|
+
let routing;
|
|
2980
|
+
try {
|
|
2981
|
+
routing = await gateway.sessionRouting(sessionId);
|
|
2982
|
+
}
|
|
2983
|
+
catch (error) {
|
|
2984
|
+
warn(`slack: could not read whether ${connection.config.agentId} is attentive in ${event.channel}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2985
|
+
}
|
|
2986
|
+
if (routing && attentive(routing, event.ts, pending)) {
|
|
2987
|
+
judged = true;
|
|
2988
|
+
overhear = false;
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2057
2991
|
if (!admitted.settled) {
|
|
2058
2992
|
// One verdict for this message, shared with whichever other agents
|
|
2059
2993
|
// are asking about it — see `followUpWinner`.
|
|
2060
2994
|
const winner = await followUpWinner({ team, conversation: event.channel, thread: event.thread_ts ?? '' }, event.ts);
|
|
2061
2995
|
if (winner !== connection.config.agentId) {
|
|
2062
|
-
|
|
2996
|
+
// Not this agent's to answer — and, if it is in the thread,
|
|
2997
|
+
// this agent's to hear, same as the two warm stand-downs.
|
|
2998
|
+
overhear = true;
|
|
2063
2999
|
}
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
3000
|
+
else if (threadKey) {
|
|
3001
|
+
// Answered from the store rather than from memory: hold it, so
|
|
3002
|
+
// the rest of the thread is ordered by receipt like any other.
|
|
2067
3003
|
rememberAddressee(threadKey, connection.config.agentId, event.ts);
|
|
2068
3004
|
}
|
|
2069
3005
|
}
|
|
2070
3006
|
const cleaned = await humanizeMentions(connection, event.text ?? '');
|
|
2071
|
-
|
|
3007
|
+
// Fetched inside the chain, like the lookups below: it is network
|
|
3008
|
+
// I/O, and a message whose screenshot is slow to arrive must not lose
|
|
3009
|
+
// its place to the one typed after it. Only for a message this agent
|
|
3010
|
+
// will answer: an overheard one is appended as text with no turn run
|
|
3011
|
+
// on it, and its attachments reach the transcript by name, as every
|
|
3012
|
+
// attachment did before images.
|
|
3013
|
+
// Nor for one it will judge: the kernel refuses images on a turn
|
|
3014
|
+
// nobody asked for, since they reach the model ahead of the frame
|
|
3015
|
+
// that marks the message as somebody else's, and a judged message
|
|
3016
|
+
// names its attachments the way an overheard one does.
|
|
3017
|
+
const { images, unread } = overhear || judged
|
|
3018
|
+
? { images: [], unread: event.files ?? [] }
|
|
3019
|
+
: await readImageAttachments(event.files ?? [], connection.config.botToken, fetchFile, fileDownloadTimeoutMs, warn);
|
|
3020
|
+
// An image with nothing said is still a question ("what's this?"); a
|
|
3021
|
+
// file the turn could not open with nothing said is not one, and gets
|
|
3022
|
+
// no reply.
|
|
3023
|
+
if (cleaned.length === 0 && images.length === 0) {
|
|
2072
3024
|
return undefined;
|
|
2073
3025
|
}
|
|
2074
|
-
const author = await
|
|
3026
|
+
const author = await authorFor(connection, userId);
|
|
2075
3027
|
// 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
|
-
|
|
3028
|
+
// unambiguous. A bare image in a channel still says who sent it.
|
|
3029
|
+
const spoken = isDm ? cleaned : (cleaned.length > 0 ? `${author}: ${cleaned}` : `${author}:`);
|
|
3030
|
+
const userMessage = `${spoken}${attachmentNote(unread)}`;
|
|
3031
|
+
// Who sent this, judged against the operator's list — per message,
|
|
3032
|
+
// never remembered from the first one in the thread. A DM proves
|
|
3033
|
+
// nothing about who is typing, so a DM from an unlisted member is
|
|
3034
|
+
// `unknown` exactly as a channel mention would be. The same label
|
|
3035
|
+
// whether the agent answers or only hears: the text is in its
|
|
3036
|
+
// transcript either way.
|
|
3037
|
+
const senderTrust = (connection.config.principals ?? []).includes(userId) ? 'user' : 'unknown';
|
|
3038
|
+
const metadata = {
|
|
3039
|
+
channel: 'slack',
|
|
3040
|
+
team,
|
|
3041
|
+
slackChannel: event.channel,
|
|
3042
|
+
slackUser: userId,
|
|
3043
|
+
// Carried so a mid-turn approval question is asked in the thread
|
|
3044
|
+
// the turn belongs to rather than at the top of a busy channel.
|
|
3045
|
+
...(thread ? { slackThread: thread } : {}),
|
|
3046
|
+
[SENDER_TRUST_METADATA_KEY]: senderTrust,
|
|
3047
|
+
};
|
|
3048
|
+
if (overhear) {
|
|
3049
|
+
// Heard, not answered: no placeholder, no turn. Only into a
|
|
3050
|
+
// conversation this agent is already in — a session under this
|
|
3051
|
+
// thread's key exists exactly when it was invited here — and the
|
|
3052
|
+
// gateway is the one to say whether it is, on the session's chain:
|
|
3053
|
+
// a first mention whose dispatch is queued ahead of this message
|
|
3054
|
+
// has created the session by the time the observe runs, where a
|
|
3055
|
+
// membership read from here would find nothing and drop a
|
|
3056
|
+
// message said moments after the invitation. A host that cannot
|
|
3057
|
+
// take it leaves the agent hearing what it answers, as every
|
|
3058
|
+
// agent did before.
|
|
3059
|
+
if (!gateway.observe) {
|
|
3060
|
+
return undefined;
|
|
3061
|
+
}
|
|
3062
|
+
// Placed on the gateway's chain here, in receipt order, and not
|
|
3063
|
+
// awaited here: a dispatch is placed the same way, and awaiting
|
|
3064
|
+
// an overhear queued behind a long turn would hold the next
|
|
3065
|
+
// message's place in this chain hostage to that turn.
|
|
3066
|
+
return {
|
|
3067
|
+
observed: placeObserve(sessionId, gateway.observe({ sessionId, agentId: connection.config.agentId, message: userMessage, metadata })),
|
|
3068
|
+
};
|
|
2082
3069
|
}
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
3070
|
+
// A turn nobody asked for opens its placeholder on its first text,
|
|
3071
|
+
// if it ever has any — see `ReplyRenderer.lazy`.
|
|
3072
|
+
const renderer = new ReplyRenderer(connection.web, event.channel, thread, editIntervalMs, warn, { lazy: judged, now });
|
|
3073
|
+
if (!judged) {
|
|
3074
|
+
try {
|
|
3075
|
+
await renderer.open();
|
|
3076
|
+
}
|
|
3077
|
+
catch (error) {
|
|
3078
|
+
warn(`could not post to ${event.channel}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3079
|
+
return undefined;
|
|
3080
|
+
}
|
|
2086
3081
|
}
|
|
2087
3082
|
// The push and the dispatch happen in the same microtask, so queue
|
|
2088
3083
|
// order matches the gateway's single-flight turn order for this
|
|
@@ -2094,35 +3089,29 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2094
3089
|
sessionId,
|
|
2095
3090
|
agentId: connection.config.agentId,
|
|
2096
3091
|
userMessage,
|
|
3092
|
+
...(images.length > 0 ? { images } : {}),
|
|
3093
|
+
...(judged ? { addressed: false } : {}),
|
|
2097
3094
|
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
|
-
},
|
|
3095
|
+
metadata,
|
|
2112
3096
|
});
|
|
2113
3097
|
return { renderer, turn };
|
|
2114
3098
|
});
|
|
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
3099
|
const started = await intake;
|
|
2123
3100
|
if (!started) {
|
|
2124
3101
|
return;
|
|
2125
3102
|
}
|
|
3103
|
+
if ('observed' in started) {
|
|
3104
|
+
// Nothing to render; the one outcome worth a line is the host
|
|
3105
|
+
// refusing it — a turn parked across a restart, most likely — since
|
|
3106
|
+
// a message dropped on the floor is the thing this exists to end.
|
|
3107
|
+
try {
|
|
3108
|
+
await started.observed;
|
|
3109
|
+
}
|
|
3110
|
+
catch (error) {
|
|
3111
|
+
warn(`slack: ${connection.config.agentId} could not overhear a message in ${event.channel}: ${error instanceof Error ? error.message : String(error)}`);
|
|
3112
|
+
}
|
|
3113
|
+
return;
|
|
3114
|
+
}
|
|
2126
3115
|
const { renderer, turn } = started;
|
|
2127
3116
|
const removeFromQueue = () => {
|
|
2128
3117
|
const current = renderers.get(sessionId);
|
|
@@ -2149,10 +3138,41 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2149
3138
|
// the potentially slow final edit, uploads, and overflow posts.
|
|
2150
3139
|
removeFromQueue();
|
|
2151
3140
|
if (session) {
|
|
2152
|
-
|
|
3141
|
+
// The reply is final here, so this is when the thread's other agents
|
|
3142
|
+
// take their place for it — before the final edit and the overflow
|
|
3143
|
+
// posts, which are network I/O a later message must not overtake.
|
|
3144
|
+
// A DM has no thread, and nobody else in it.
|
|
3145
|
+
const reply = latestTurnReply(session);
|
|
3146
|
+
// A turn nobody asked for that said nothing posts nothing; every
|
|
3147
|
+
// other turn says `(no reply)` where its answer would have gone.
|
|
3148
|
+
const finalized = renderer.finalize(renderer.lazy ? reply ?? '' : lastAssistantReply(session));
|
|
3149
|
+
const heard = thread !== undefined && reply !== undefined
|
|
3150
|
+
? overhearReply(connection, event.channel, thread, reply, session, finalized.then((outcome) => outcome.published))
|
|
3151
|
+
: undefined;
|
|
3152
|
+
const { spoke, spokeAt } = await finalized;
|
|
3153
|
+
if (spoke && threadKey !== undefined) {
|
|
3154
|
+
// The voice that just answered, recorded where its reply sits in
|
|
3155
|
+
// the thread. For a judging agent this is the only record there
|
|
3156
|
+
// is — it took no part in the holder rule at intake — and without
|
|
3157
|
+
// it a thread-rule colleague that held the thread before would
|
|
3158
|
+
// keep answering beside it. For any other turn the intake wrote
|
|
3159
|
+
// one at the message answered, and this one says where the answer
|
|
3160
|
+
// landed, which is what settles two turns running at once: a
|
|
3161
|
+
// placeholder posted at intake sits above a reply a judge posted
|
|
3162
|
+
// while it was being filled, whichever turn finished first, and
|
|
3163
|
+
// the reader's next untagged message is for the lower of the two.
|
|
3164
|
+
// Late, either way, for a message typed while the turn was still
|
|
3165
|
+
// running, which the other may therefore still take.
|
|
3166
|
+
rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
|
|
3167
|
+
}
|
|
3168
|
+
await heard;
|
|
2153
3169
|
}
|
|
2154
3170
|
else {
|
|
2155
|
-
await renderer.fail(failure instanceof Error ? failure.message : String(failure));
|
|
3171
|
+
const { spoke, spokeAt } = await renderer.fail(failure instanceof Error ? failure.message : String(failure));
|
|
3172
|
+
if (spoke && threadKey !== undefined) {
|
|
3173
|
+
// A file it posted before breaking is still the last thing said.
|
|
3174
|
+
rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
|
|
3175
|
+
}
|
|
2156
3176
|
}
|
|
2157
3177
|
};
|
|
2158
3178
|
return {
|
|
@@ -2344,6 +3364,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2344
3364
|
approvalPosts.clear();
|
|
2345
3365
|
threadAddressee.clear();
|
|
2346
3366
|
coldVerdicts.clear();
|
|
3367
|
+
pendingObserves.clear();
|
|
2347
3368
|
botIdentities.clear();
|
|
2348
3369
|
rendering.clear();
|
|
2349
3370
|
resolvedWhileRendering.clear();
|