@stratusagent/channel-slack 0.14.1 → 0.14.2
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 +1 -1
- package/dist/index.d.ts +18 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +452 -22
- package/dist/index.js.map +1 -1
- package/dist/texts.d.ts +30 -0
- package/dist/texts.d.ts.map +1 -0
- package/dist/texts.js +101 -0
- package/dist/texts.js.map +1 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createRequire } from 'node:module';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { filePathsOf, isLatestTurn, latestTurnReply, MAX_IDEMPOTENCY_KEY_LENGTH, turnFailureFor, turnFilesFor, turnReplyFor, CONVERSATION_METADATA_KEY, SENDER_TRUST_METADATA_KEY, sessionWriteTrust, imageDimensions, isImageAttachmentMediaType, IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENT_MAX_DIMENSION, IMAGE_ATTACHMENTS_MAX_REPLAY_COUNT, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES, escapeControlCharacters, HostRefusalError, RunAbortedError, leastTrusted, sessionTrustOf, SESSION_TRUST_METADATA_KEY, admitsSender, channelSessionKey, senderTrustFor, } from '@stratusagent/core';
|
|
7
7
|
import { codeRunsOf, toSlackMrkdwn } from "./mrkdwn.js";
|
|
8
|
+
import { textingParts } from "./texts.js";
|
|
8
9
|
/**
|
|
9
10
|
* The longest display name the model is shown. Slack caps profile names
|
|
10
11
|
* well under this; the bound is for a profile that is not a name.
|
|
@@ -116,6 +117,66 @@ const DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
|
116
117
|
* is reported by name rather than as the whole window running out.
|
|
117
118
|
*/
|
|
118
119
|
const DEFAULT_STOP_WAIT_MS = 10_000;
|
|
120
|
+
/**
|
|
121
|
+
* How long an approval prompt waits for the words its turn wrote before the
|
|
122
|
+
* call to land first (card 950). Long enough for an ordinary post, short
|
|
123
|
+
* enough that a slow or refusing Slack never keeps the question from an
|
|
124
|
+
* approver: the turn is parked on that answer.
|
|
125
|
+
*/
|
|
126
|
+
const DEFAULT_APPROVAL_PROMPT_WAIT_MS = 5_000;
|
|
127
|
+
/**
|
|
128
|
+
* How long a task works before its progress message opens (card 949): long
|
|
129
|
+
* enough that a quick answer, a lookup or two, is just its reply; short
|
|
130
|
+
* enough that a task taking minutes shows what it is doing while it does.
|
|
131
|
+
*/
|
|
132
|
+
const DEFAULT_PROGRESS_AFTER_MS = 10_000;
|
|
133
|
+
/** Room kept for a progress message's first line and its `…` when its notes are trimmed. */
|
|
134
|
+
const PROGRESS_HEADER_ROOM = 200;
|
|
135
|
+
/**
|
|
136
|
+
* The most a progress message holds under its first line. Words longer than
|
|
137
|
+
* this are content, not a step — an analysis written before a tool call —
|
|
138
|
+
* and are posted whole as messages of their own, as before card 949, rather
|
|
139
|
+
* than cut to fit and lost.
|
|
140
|
+
*/
|
|
141
|
+
const PROGRESS_NOTE_ROOM = SLACK_MAX_MESSAGE_CHARS - PROGRESS_HEADER_ROOM;
|
|
142
|
+
const stepCount = (steps) => `${steps} ${steps === 1 ? 'step' : 'steps'}`;
|
|
143
|
+
/** A tool's name as code, which no backtick of its own can close early. */
|
|
144
|
+
const toolCode = (toolName) => `\`${escapeSlackText(toolName.replace(/`/g, ''))}\``;
|
|
145
|
+
/**
|
|
146
|
+
* The first line of a progress message: what the turn is doing, from the
|
|
147
|
+
* tool calls it made and is making. Words the model wrote are under it, as
|
|
148
|
+
* notes; this line is the part that cannot claim more than happened.
|
|
149
|
+
*/
|
|
150
|
+
const progressHeader = (state) => {
|
|
151
|
+
switch (state.end) {
|
|
152
|
+
case 'done':
|
|
153
|
+
return `✓ Done · ${stepCount(state.steps)}`;
|
|
154
|
+
case 'stopped':
|
|
155
|
+
return `■ Stopped after ${stepCount(state.steps)}`;
|
|
156
|
+
case 'failed':
|
|
157
|
+
return `✗ Failed after ${stepCount(state.steps)}`;
|
|
158
|
+
default:
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
if (state.waitingOn !== undefined) {
|
|
162
|
+
return `⏸ Waiting for approval to run ${toolCode(state.waitingOn)}`;
|
|
163
|
+
}
|
|
164
|
+
const latest = [...state.running.values()].at(-1);
|
|
165
|
+
if (latest !== undefined) {
|
|
166
|
+
return `⚙ Working · step ${state.steps}: ${toolCode(latest)}`;
|
|
167
|
+
}
|
|
168
|
+
return `⚙ Working · ${stepCount(state.steps)} done`;
|
|
169
|
+
};
|
|
170
|
+
/** Resolves when `work` settles or after `ms`, whichever is first; never rejects. */
|
|
171
|
+
const settledWithin = (work, ms) => new Promise((resolve) => {
|
|
172
|
+
const timer = setTimeout(resolve, ms);
|
|
173
|
+
timer.unref?.();
|
|
174
|
+
const done = () => {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
resolve();
|
|
177
|
+
};
|
|
178
|
+
work.then(done, done);
|
|
179
|
+
});
|
|
119
180
|
/** What a turn that produced no text puts in its message, wherever it is posted from. */
|
|
120
181
|
const NO_REPLY_TEXT = '(no reply)';
|
|
121
182
|
/**
|
|
@@ -447,6 +508,40 @@ class ReplyRenderer {
|
|
|
447
508
|
* once that is known, so the thread's other agents hear it in its place.
|
|
448
509
|
*/
|
|
449
510
|
onInterim;
|
|
511
|
+
/**
|
|
512
|
+
* Card 949, `final` mode: a task says what it is about to do in a
|
|
513
|
+
* message of its own (its first words before a tool), then keeps ONE
|
|
514
|
+
* progress message up to date — a first line built from its tool events,
|
|
515
|
+
* and under it whatever else it wrote between tools — and posts its reply
|
|
516
|
+
* as a message of its own. Narration that used to be a post per step is
|
|
517
|
+
* an edit, so a long task is three messages, not twenty.
|
|
518
|
+
*
|
|
519
|
+
* Whether this turn's first words have gone out, so later words are notes.
|
|
520
|
+
*/
|
|
521
|
+
firstWordsSaid = false;
|
|
522
|
+
/** Whether the progress message has been asked for; it may not have landed yet. */
|
|
523
|
+
progressOpen = false;
|
|
524
|
+
/** The progress message, once Slack took it. */
|
|
525
|
+
progressRef;
|
|
526
|
+
/** What the first line says, from this turn's own tool events only. */
|
|
527
|
+
progress = { steps: 0, running: new Map(), waitingOn: undefined, end: undefined };
|
|
528
|
+
/**
|
|
529
|
+
* The notes under that line, as sent: converted, oldest dropped past one
|
|
530
|
+
* message's room. `shown` once a write that held the note has landed: a
|
|
531
|
+
* note trimmed while its write was still queued never reached anyone.
|
|
532
|
+
*/
|
|
533
|
+
progressNotes = [];
|
|
534
|
+
/** Whether older notes were dropped for room, so the message says so. */
|
|
535
|
+
progressNotesCut = false;
|
|
536
|
+
/** Whether the last thing this turn said is a note rather than a message of its own. */
|
|
537
|
+
lastSaidWasNote = false;
|
|
538
|
+
progressTimer;
|
|
539
|
+
progressOpenTimer;
|
|
540
|
+
lastProgressAt = 0;
|
|
541
|
+
turnStartedAt = 0;
|
|
542
|
+
/** How the turn ended, for the progress message: a stop or a failure says so before it finalizes. */
|
|
543
|
+
ending = 'done';
|
|
544
|
+
progressAfterMs;
|
|
450
545
|
/** The lowest trust this turn's session was lowered to while it ran (`session.tainted`). */
|
|
451
546
|
taintedTo;
|
|
452
547
|
turnBreakPending = false;
|
|
@@ -513,6 +608,12 @@ class ReplyRenderer {
|
|
|
513
608
|
* could not be opened.
|
|
514
609
|
*/
|
|
515
610
|
streaming;
|
|
611
|
+
/**
|
|
612
|
+
* Whether the reply, and what the turn says before a tool, are posted a
|
|
613
|
+
* paragraph to a message (`texts` mode). Never with `streaming`: there
|
|
614
|
+
* is one placeholder to stream into.
|
|
615
|
+
*/
|
|
616
|
+
texts;
|
|
516
617
|
/**
|
|
517
618
|
* The thread the loading status belongs to: the reply's thread, or the
|
|
518
619
|
* message itself in a DM, whose reply has no thread to key one on.
|
|
@@ -545,6 +646,13 @@ class ReplyRenderer {
|
|
|
545
646
|
statusTimer;
|
|
546
647
|
/** The tool the turn is running, for the status line; the streamed `toolLine` is the other mode's. */
|
|
547
648
|
runningTool;
|
|
649
|
+
/**
|
|
650
|
+
* Calls started and not yet settled, by call id, oldest first. Core runs
|
|
651
|
+
* a response's read-only calls together, so one can settle while another
|
|
652
|
+
* still runs: both lines name the newest call still running, and clear
|
|
653
|
+
* only once none is.
|
|
654
|
+
*/
|
|
655
|
+
activeTools = new Map();
|
|
548
656
|
web;
|
|
549
657
|
/**
|
|
550
658
|
* The one person in the conversation when this turn is a DM: whoever sent
|
|
@@ -567,11 +675,13 @@ class ReplyRenderer {
|
|
|
567
675
|
this.lazy = options.lazy ?? false;
|
|
568
676
|
this.now = options.now ?? Date.now;
|
|
569
677
|
this.streaming = options.streaming ?? true;
|
|
678
|
+
this.texts = !this.streaming && options.texts === true;
|
|
570
679
|
this.statusThread = options.statusThread ?? threadTs;
|
|
571
680
|
this.statusWarned = options.statusWarned ?? { value: false };
|
|
572
681
|
this.after = options.after ?? Promise.resolve();
|
|
573
682
|
this.dmWith = options.dmWith;
|
|
574
683
|
this.onInterim = options.onInterim;
|
|
684
|
+
this.progressAfterMs = options.progressAfterMs ?? DEFAULT_PROGRESS_AFTER_MS;
|
|
575
685
|
}
|
|
576
686
|
/**
|
|
577
687
|
* Show Slack's loading status for this turn, and keep it up until the
|
|
@@ -662,6 +772,9 @@ class ReplyRenderer {
|
|
|
662
772
|
* included, so this turn's placeholder starts empty.
|
|
663
773
|
*/
|
|
664
774
|
beginTurn() {
|
|
775
|
+
if (!this.turnStarted) {
|
|
776
|
+
this.turnStartedAt = Date.now();
|
|
777
|
+
}
|
|
665
778
|
this.turnStarted = true;
|
|
666
779
|
if (this.pendingEdit) {
|
|
667
780
|
clearTimeout(this.pendingEdit);
|
|
@@ -669,6 +782,7 @@ class ReplyRenderer {
|
|
|
669
782
|
}
|
|
670
783
|
this.buffer = '';
|
|
671
784
|
this.interim = '';
|
|
785
|
+
this.activeTools.clear();
|
|
672
786
|
this.toolLine = undefined;
|
|
673
787
|
this.runningTool = undefined;
|
|
674
788
|
this.turnBreakPending = false;
|
|
@@ -745,9 +859,16 @@ class ReplyRenderer {
|
|
|
745
859
|
// all, so this is the only thing between the text before a tool and
|
|
746
860
|
// the text after it — without it they fuse in the live message.
|
|
747
861
|
this.turnBreakPending = true;
|
|
748
|
-
this.
|
|
749
|
-
this.
|
|
862
|
+
this.activeTools.set(event.call.id, event.call.toolName);
|
|
863
|
+
this.showActiveTool();
|
|
750
864
|
this.flushInterim(ofThisTurn);
|
|
865
|
+
if (ofThisTurn && this.mayShowProgress()) {
|
|
866
|
+
this.progress.steps += 1;
|
|
867
|
+
this.progress.running.set(event.call.id, event.call.toolName);
|
|
868
|
+
this.progress.waitingOn = undefined;
|
|
869
|
+
this.openProgressInTime();
|
|
870
|
+
this.scheduleProgress();
|
|
871
|
+
}
|
|
751
872
|
this.scheduleEdit();
|
|
752
873
|
return;
|
|
753
874
|
}
|
|
@@ -756,21 +877,27 @@ class ReplyRenderer {
|
|
|
756
877
|
// else clears the status line or marks the boundary for it — the
|
|
757
878
|
// message would keep claiming a refused tool is running, and the
|
|
758
879
|
// model's follow-up would fuse with the text before the attempt.
|
|
759
|
-
this.
|
|
760
|
-
this.
|
|
880
|
+
this.activeTools.delete(event.call.id);
|
|
881
|
+
this.showActiveTool();
|
|
761
882
|
this.turnBreakPending = true;
|
|
762
883
|
// Words written before a call that was refused were still said
|
|
763
884
|
// before a tool, and must not run into the next response's.
|
|
764
885
|
this.flushInterim(ofThisTurn);
|
|
886
|
+
if (ofThisTurn) {
|
|
887
|
+
this.settleProgressStep(event.call.id);
|
|
888
|
+
}
|
|
765
889
|
this.scheduleEdit();
|
|
766
890
|
return;
|
|
767
891
|
}
|
|
768
892
|
if (event.type === 'tool.completed') {
|
|
769
|
-
this.
|
|
770
|
-
this.
|
|
893
|
+
this.activeTools.delete(event.result.callId);
|
|
894
|
+
this.showActiveTool();
|
|
771
895
|
// Settled here too for a call rejected before it ran, which never
|
|
772
896
|
// reached `tool.called`; after one that did, this is empty.
|
|
773
897
|
this.flushInterim(ofThisTurn);
|
|
898
|
+
if (ofThisTurn) {
|
|
899
|
+
this.settleProgressStep(event.result.callId);
|
|
900
|
+
}
|
|
774
901
|
// Also a boundary, and not only for symmetry: a call rejected before
|
|
775
902
|
// execution settles as tool.completed without ever having emitted
|
|
776
903
|
// tool.called, so this is the only mark that attempt leaves.
|
|
@@ -861,8 +988,18 @@ class ReplyRenderer {
|
|
|
861
988
|
const text = this.interim.trim();
|
|
862
989
|
this.interim = '';
|
|
863
990
|
if (this.streaming || this.lazy || !this.turnStarted || !ofThisTurn || text.length === 0) {
|
|
864
|
-
return;
|
|
991
|
+
return undefined;
|
|
865
992
|
}
|
|
993
|
+
// Only words before the turn's first tool are a message of their own:
|
|
994
|
+
// after one, a step has run, and what follows is progress whether or
|
|
995
|
+
// not the turn opened with a line.
|
|
996
|
+
if (this.firstWordsSaid || this.progressOpen || this.progress.steps > 0) {
|
|
997
|
+
const note = toSlackMrkdwn(text);
|
|
998
|
+
if (note.length <= PROGRESS_NOTE_ROOM) {
|
|
999
|
+
return this.noteProgress(text, note);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
this.firstWordsSaid = true;
|
|
866
1003
|
const handover = this.handover;
|
|
867
1004
|
const after = this.after;
|
|
868
1005
|
let heard = '';
|
|
@@ -877,7 +1014,7 @@ class ReplyRenderer {
|
|
|
877
1014
|
.then(async () => {
|
|
878
1015
|
const landed = [];
|
|
879
1016
|
try {
|
|
880
|
-
for (const chunk of
|
|
1017
|
+
for (const chunk of this.chunksOf(text)) {
|
|
881
1018
|
const posted = await this.web.chat.postMessage({
|
|
882
1019
|
channel: this.channel,
|
|
883
1020
|
text: chunk,
|
|
@@ -894,12 +1031,13 @@ class ReplyRenderer {
|
|
|
894
1031
|
// reply, where repeating a few lines beats losing the rest.
|
|
895
1032
|
if (landed.length > 0) {
|
|
896
1033
|
this.interimPosted = true;
|
|
897
|
-
heard = landed.length ===
|
|
1034
|
+
heard = landed.length === this.chunksOf(text).length ? text : landed.join('\n\n');
|
|
898
1035
|
}
|
|
899
1036
|
settleLanded(landed.length > 0);
|
|
900
1037
|
}
|
|
901
1038
|
this.lastInterim = text;
|
|
902
1039
|
this.interimSaid.push(text);
|
|
1040
|
+
this.lastSaidWasNote = false;
|
|
903
1041
|
// A post of the app's in this thread takes the status down while
|
|
904
1042
|
// the turn is still working; put it back.
|
|
905
1043
|
this.refreshLoading();
|
|
@@ -908,6 +1046,230 @@ class ReplyRenderer {
|
|
|
908
1046
|
// Settled whatever happened above, or the other agents' intake would
|
|
909
1047
|
// wait on it for good. A second settle is a no-op.
|
|
910
1048
|
.finally(() => settleLanded(false));
|
|
1049
|
+
return landedNow;
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Whether this turn keeps a progress message (card 949): `final` mode,
|
|
1053
|
+
* a turn somebody asked for, and its own — the same turns that post
|
|
1054
|
+
* their words before a tool. A streaming turn's placeholder already
|
|
1055
|
+
* shows its tool lines, and a turn nobody asked for must not speak
|
|
1056
|
+
* before it decides to.
|
|
1057
|
+
*/
|
|
1058
|
+
mayShowProgress() {
|
|
1059
|
+
return !this.streaming && !this.lazy && this.turnStarted && !this.finalized;
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Words the turn wrote between tools after its first line: a note in the
|
|
1063
|
+
* progress message, opening it if need be, rather than a post of their
|
|
1064
|
+
* own. Heard by the thread's other agents and stripped from a reply that
|
|
1065
|
+
* repeats them, as a message of their own would be. A note Slack refused
|
|
1066
|
+
* is taken out again, so a later edit never shows words nobody heard.
|
|
1067
|
+
*/
|
|
1068
|
+
noteProgress(text, converted) {
|
|
1069
|
+
this.progressOpen = true;
|
|
1070
|
+
this.clearProgressOpenTimer();
|
|
1071
|
+
const note = { text: converted };
|
|
1072
|
+
this.progressNotes.push(note);
|
|
1073
|
+
this.trimProgressNotes();
|
|
1074
|
+
let heard = '';
|
|
1075
|
+
let settleLanded;
|
|
1076
|
+
const landedNow = new Promise((resolve) => {
|
|
1077
|
+
settleLanded = resolve;
|
|
1078
|
+
});
|
|
1079
|
+
this.onInterim?.(() => heard, landedNow);
|
|
1080
|
+
// Said once a write that held it has landed, whichever write that was,
|
|
1081
|
+
// and only then. Its own write can land after a slow Slack let later
|
|
1082
|
+
// notes trim it out unseen, or be refused after an earlier write showed
|
|
1083
|
+
// it. Unseen, it is not said: the others do not hear it, and a reply
|
|
1084
|
+
// that repeats it keeps it.
|
|
1085
|
+
const settle = () => {
|
|
1086
|
+
if (!note.shown) {
|
|
1087
|
+
this.progressNotes = this.progressNotes.filter((kept) => kept !== note);
|
|
1088
|
+
settleLanded(false);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
this.interimPosted = true;
|
|
1092
|
+
heard = text;
|
|
1093
|
+
this.lastInterim = text;
|
|
1094
|
+
this.interimSaid.push(text);
|
|
1095
|
+
this.lastSaidWasNote = true;
|
|
1096
|
+
settleLanded(true);
|
|
1097
|
+
};
|
|
1098
|
+
this.queueProgressWrite(settle, settle);
|
|
1099
|
+
return landedNow;
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Keep the notes to what one message holds with its first line: the
|
|
1103
|
+
* newest, since the oldest are what a reader has already seen. Past
|
|
1104
|
+
* that the message opens with `…`, and memory stays bounded however
|
|
1105
|
+
* long the task runs.
|
|
1106
|
+
*/
|
|
1107
|
+
trimProgressNotes() {
|
|
1108
|
+
let size = this.progressNotes.reduce((total, note) => total + note.text.length + 2, 0);
|
|
1109
|
+
while (size > PROGRESS_NOTE_ROOM && this.progressNotes.length > 1) {
|
|
1110
|
+
size -= (this.progressNotes.shift()?.text.length ?? 0) + 2;
|
|
1111
|
+
this.progressNotesCut = true;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
progressText(omitLast = false) {
|
|
1115
|
+
const notes = omitLast ? this.progressNotes.slice(0, -1) : this.progressNotes;
|
|
1116
|
+
const parts = [progressHeader(this.progress), ...(this.progressNotesCut ? ['…'] : []), ...notes.map((note) => note.text)];
|
|
1117
|
+
return truncateForSlack(parts.join('\n\n'));
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Put the progress message as it stands now into the thread: posted the
|
|
1121
|
+
* first time, edited after. On the upload chain, behind the turn ahead
|
|
1122
|
+
* and any handover, so it lands below this turn's first line and above
|
|
1123
|
+
* its files and reply, and reads its state when it runs, not when asked.
|
|
1124
|
+
*/
|
|
1125
|
+
queueProgressWrite(landed, refused) {
|
|
1126
|
+
const handover = this.handover;
|
|
1127
|
+
const after = this.after;
|
|
1128
|
+
this.uploadChain = this.uploadChain
|
|
1129
|
+
.then(() => handover)
|
|
1130
|
+
.then(() => after)
|
|
1131
|
+
.then(async () => {
|
|
1132
|
+
this.lastProgressAt = Date.now();
|
|
1133
|
+
const held = [...this.progressNotes];
|
|
1134
|
+
await this.writeProgress(this.progressText());
|
|
1135
|
+
for (const note of held) {
|
|
1136
|
+
note.shown = true;
|
|
1137
|
+
}
|
|
1138
|
+
landed?.();
|
|
1139
|
+
})
|
|
1140
|
+
.catch((error) => {
|
|
1141
|
+
refused?.();
|
|
1142
|
+
this.warn(`could not update the progress message: ${error instanceof Error ? error.message : String(error)}`);
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
async writeProgress(text) {
|
|
1146
|
+
const ref = this.progressRef;
|
|
1147
|
+
if (ref) {
|
|
1148
|
+
await this.web.chat.update({ channel: ref.channel, ts: ref.ts, text });
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
const posted = await this.web.chat.postMessage({
|
|
1152
|
+
channel: this.channel,
|
|
1153
|
+
text,
|
|
1154
|
+
...(this.threadTs ? { thread_ts: this.threadTs } : {}),
|
|
1155
|
+
});
|
|
1156
|
+
if (posted.ts) {
|
|
1157
|
+
this.progressRef = { channel: posted.channel ?? this.channel, ts: posted.ts };
|
|
1158
|
+
}
|
|
1159
|
+
this.placeText(posted.ts);
|
|
1160
|
+
// A post of the app's in this thread takes the status down while the
|
|
1161
|
+
// turn is still working; put it back.
|
|
1162
|
+
this.refreshLoading();
|
|
1163
|
+
}
|
|
1164
|
+
/** Edit the progress message for a step that started or settled, at most once per edit interval. */
|
|
1165
|
+
scheduleProgress() {
|
|
1166
|
+
if (!this.progressOpen || this.finalized || this.progressTimer) {
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
const delay = Math.max(0, this.editIntervalMs - (Date.now() - this.lastProgressAt));
|
|
1170
|
+
this.progressTimer = setTimeout(() => {
|
|
1171
|
+
this.progressTimer = undefined;
|
|
1172
|
+
if (!this.finalized) {
|
|
1173
|
+
this.queueProgressWrite();
|
|
1174
|
+
}
|
|
1175
|
+
}, delay);
|
|
1176
|
+
this.progressTimer.unref?.();
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Open the progress message once the turn has worked `progressAfterMs`:
|
|
1180
|
+
* now, if it has, or when it will have. Asked on a tool call, so there
|
|
1181
|
+
* is always a step to show; a quick answer ends first and opens none.
|
|
1182
|
+
*/
|
|
1183
|
+
openProgressInTime() {
|
|
1184
|
+
if (this.progressOpen || this.progressOpenTimer) {
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
const open = () => {
|
|
1188
|
+
this.progressOpen = true;
|
|
1189
|
+
this.queueProgressWrite();
|
|
1190
|
+
};
|
|
1191
|
+
const wait = this.progressAfterMs - (Date.now() - this.turnStartedAt);
|
|
1192
|
+
if (wait <= 0) {
|
|
1193
|
+
open();
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
this.progressOpenTimer = setTimeout(() => {
|
|
1197
|
+
this.progressOpenTimer = undefined;
|
|
1198
|
+
if (!this.finalized && !this.progressOpen) {
|
|
1199
|
+
open();
|
|
1200
|
+
}
|
|
1201
|
+
}, wait);
|
|
1202
|
+
this.progressOpenTimer.unref?.();
|
|
1203
|
+
}
|
|
1204
|
+
/** A step of this turn's finished or was refused. */
|
|
1205
|
+
settleProgressStep(callId) {
|
|
1206
|
+
if (!this.mayShowProgress()) {
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
this.progress.running.delete(callId);
|
|
1210
|
+
this.progress.waitingOn = undefined;
|
|
1211
|
+
this.scheduleProgress();
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* A call of this turn's is waiting on a person: the progress message says
|
|
1215
|
+
* so until the call runs or is refused. The prompt itself is a message
|
|
1216
|
+
* of its own, below it.
|
|
1217
|
+
*/
|
|
1218
|
+
awaitingApproval(toolName, ofThisTurn) {
|
|
1219
|
+
if (!ofThisTurn || !this.mayShowProgress()) {
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
this.progress.waitingOn = toolName;
|
|
1223
|
+
this.scheduleProgress();
|
|
1224
|
+
}
|
|
1225
|
+
clearProgressOpenTimer() {
|
|
1226
|
+
if (this.progressOpenTimer) {
|
|
1227
|
+
clearTimeout(this.progressOpenTimer);
|
|
1228
|
+
this.progressOpenTimer = undefined;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
clearProgressTimers() {
|
|
1232
|
+
this.clearProgressOpenTimer();
|
|
1233
|
+
if (this.progressTimer) {
|
|
1234
|
+
clearTimeout(this.progressTimer);
|
|
1235
|
+
this.progressTimer = undefined;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* The progress message's last edit: how the task ended, and every note
|
|
1240
|
+
* but one that became the reply. Resolves once Slack has answered; a
|
|
1241
|
+
* refusal leaves the last state that landed, and is logged.
|
|
1242
|
+
*/
|
|
1243
|
+
async closeProgress(omitLast) {
|
|
1244
|
+
this.clearProgressTimers();
|
|
1245
|
+
const ref = this.progressRef;
|
|
1246
|
+
if (!ref) {
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
this.progress.end = this.ending;
|
|
1250
|
+
try {
|
|
1251
|
+
await this.web.chat.update({ channel: ref.channel, ts: ref.ts, text: this.progressText(omitLast) });
|
|
1252
|
+
}
|
|
1253
|
+
catch (error) {
|
|
1254
|
+
this.warn(`could not finish the progress message: ${error instanceof Error ? error.message : String(error)}`);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Post what the model said before a call that is about to ask for
|
|
1259
|
+
* approval, and resolve once it has landed (or could not), or at once
|
|
1260
|
+
* when there is nothing to post (card 950).
|
|
1261
|
+
*
|
|
1262
|
+
* The prompt is posted before the call starts, so waiting for
|
|
1263
|
+
* `tool.called` put the question above the line saying what the agent
|
|
1264
|
+
* set out to do, and the approver answered without it. The same flush
|
|
1265
|
+
* `tool.called` would have done, only earlier: what it posts is stripped
|
|
1266
|
+
* from the reply as before, and the flush at `tool.called` finds nothing.
|
|
1267
|
+
*/
|
|
1268
|
+
sayBeforeAsking(ofThisTurn) {
|
|
1269
|
+
if (this.finalized) {
|
|
1270
|
+
return undefined;
|
|
1271
|
+
}
|
|
1272
|
+
return this.flushInterim(ofThisTurn);
|
|
911
1273
|
}
|
|
912
1274
|
/**
|
|
913
1275
|
* Everything this turn said in the thread, for its other agents to hear:
|
|
@@ -942,6 +1304,12 @@ class ReplyRenderer {
|
|
|
942
1304
|
}
|
|
943
1305
|
return rest === this.lastInterim ? '' : rest;
|
|
944
1306
|
}
|
|
1307
|
+
/** Point both status lines at the newest call still running, or clear them. */
|
|
1308
|
+
showActiveTool() {
|
|
1309
|
+
const running = [...this.activeTools.values()].at(-1);
|
|
1310
|
+
this.runningTool = running;
|
|
1311
|
+
this.toolLine = running === undefined ? undefined : `⚙ ${running}…`;
|
|
1312
|
+
}
|
|
945
1313
|
currentText() {
|
|
946
1314
|
const parts = [this.buffer.trim(), this.toolLine].filter((part) => Boolean(part && part.length > 0));
|
|
947
1315
|
return parts.length > 0 ? parts.join('\n\n') : PLACEHOLDER_TEXT;
|
|
@@ -1051,6 +1419,7 @@ class ReplyRenderer {
|
|
|
1051
1419
|
const hadStatus = this.statusTimer !== undefined;
|
|
1052
1420
|
this.stopLoading();
|
|
1053
1421
|
this.finalized = true;
|
|
1422
|
+
this.clearProgressTimers();
|
|
1054
1423
|
if (!this.streaming) {
|
|
1055
1424
|
await this.after;
|
|
1056
1425
|
await this.statusChain;
|
|
@@ -1079,10 +1448,25 @@ class ReplyRenderer {
|
|
|
1079
1448
|
// tool call) is not posted again, and a turn that said things along
|
|
1080
1449
|
// the way is not followed by `(no reply)`.
|
|
1081
1450
|
await this.uploadChain;
|
|
1451
|
+
// Words the turn ended on, written before its last tool call, are its
|
|
1452
|
+
// answer (card 949): a reply that only repeats them, or an empty one,
|
|
1453
|
+
// posts them as the reply, and the progress message lets them go. Left
|
|
1454
|
+
// as its last note, the answer would sit in the message that says how
|
|
1455
|
+
// the work went, above where anyone looks for it.
|
|
1456
|
+
let promoted = false;
|
|
1082
1457
|
if (this.interimPosted && !this.ref) {
|
|
1083
|
-
|
|
1084
|
-
|
|
1458
|
+
const rest = this.unsaid(reply);
|
|
1459
|
+
promoted = rest.length === 0 && this.lastSaidWasNote && this.lastInterim !== undefined;
|
|
1460
|
+
reply = promoted && this.lastInterim !== undefined ? this.lastInterim : rest;
|
|
1461
|
+
}
|
|
1462
|
+
// Alongside the reply rather than ahead of it: an edit keeps its place,
|
|
1463
|
+
// and the answer should not wait on a round trip for a status line.
|
|
1464
|
+
// Words promoted to the reply are the exception: they leave the
|
|
1465
|
+
// progress message only once the reply holding them has landed, so a
|
|
1466
|
+
// reply Slack refuses does not take the answer out of the thread.
|
|
1467
|
+
const closed = promoted ? undefined : this.closeProgress(false);
|
|
1085
1468
|
if (!this.ref && this.interimPosted && reply.trim().length === 0) {
|
|
1469
|
+
await (closed ?? this.closeProgress(false));
|
|
1086
1470
|
// Nothing posts after this to take the status down, so it is cleared.
|
|
1087
1471
|
if (hadStatus) {
|
|
1088
1472
|
this.setStatus('');
|
|
@@ -1091,7 +1475,7 @@ class ReplyRenderer {
|
|
|
1091
1475
|
return this.outcome(true, true);
|
|
1092
1476
|
}
|
|
1093
1477
|
const text = reply.trim().length > 0 ? reply : NO_REPLY_TEXT;
|
|
1094
|
-
const chunks =
|
|
1478
|
+
const chunks = this.chunksOf(text);
|
|
1095
1479
|
// No placeholder — a handover could not open a fresh one — and the
|
|
1096
1480
|
// reply is a message of its own rather than nothing at all.
|
|
1097
1481
|
const rest = this.ref ? chunks.slice(1) : chunks;
|
|
@@ -1121,6 +1505,7 @@ class ReplyRenderer {
|
|
|
1121
1505
|
// avoid, so it is taken back like an abandoned attempt's. One Slack
|
|
1122
1506
|
// will not let go stands, and a message the thread can see is the
|
|
1123
1507
|
// last thing said whatever it says.
|
|
1508
|
+
await (closed ?? this.closeProgress(published));
|
|
1124
1509
|
let stranded = false;
|
|
1125
1510
|
if (this.lazy && this.ref !== undefined && !landed && !this.edited) {
|
|
1126
1511
|
stranded = !(await this.retract());
|
|
@@ -1142,6 +1527,10 @@ class ReplyRenderer {
|
|
|
1142
1527
|
}
|
|
1143
1528
|
return this.outcome(published, landed || this.edited || this.uploaded || stranded, !published);
|
|
1144
1529
|
}
|
|
1530
|
+
/** This turn's text as the messages it is posted as, in this agent's reply mode. */
|
|
1531
|
+
chunksOf(text) {
|
|
1532
|
+
return messageChunks(text, this.texts ? 'texts' : undefined);
|
|
1533
|
+
}
|
|
1145
1534
|
placeText(ts) {
|
|
1146
1535
|
if (ts !== undefined && (this.textAt === undefined || ts > this.textAt)) {
|
|
1147
1536
|
this.textAt = ts;
|
|
@@ -1197,6 +1586,7 @@ class ReplyRenderer {
|
|
|
1197
1586
|
const hadStatus = this.statusTimer !== undefined;
|
|
1198
1587
|
this.stopLoading();
|
|
1199
1588
|
this.finalized = true;
|
|
1589
|
+
this.clearProgressTimers();
|
|
1200
1590
|
// The barrier `finalizeInOrder` takes, for the same reason: the turn
|
|
1201
1591
|
// queued behind this one waits on `posted`, and settling it before
|
|
1202
1592
|
// the reply ahead has posted would let that turn's reply overtake it.
|
|
@@ -1232,6 +1622,7 @@ class ReplyRenderer {
|
|
|
1232
1622
|
if (this.lazy && !this.ref && this.buffer.trim().length === 0) {
|
|
1233
1623
|
return this.finalize('');
|
|
1234
1624
|
}
|
|
1625
|
+
this.ending = 'stopped';
|
|
1235
1626
|
return this.finalize(`Stopped by <@${by}>.`);
|
|
1236
1627
|
}
|
|
1237
1628
|
/**
|
|
@@ -1257,6 +1648,8 @@ class ReplyRenderer {
|
|
|
1257
1648
|
this.warn(`a turn nobody asked for failed before saying anything: ${message}`);
|
|
1258
1649
|
return this.finalize('');
|
|
1259
1650
|
}
|
|
1651
|
+
// A spent budget is a limit working, not a fault: the task stopped.
|
|
1652
|
+
this.ending = refused ? 'stopped' : 'failed';
|
|
1260
1653
|
return this.finalize(refused ? message : `Something went wrong: ${message}`);
|
|
1261
1654
|
}
|
|
1262
1655
|
/**
|
|
@@ -1493,8 +1886,12 @@ const messageText = (text) => truncateForSlack(toSlackMrkdwn(text));
|
|
|
1493
1886
|
* Agent text as however many Slack messages it takes. Converted before it
|
|
1494
1887
|
* is split, so a chunk is measured at the length it will be sent at, and so
|
|
1495
1888
|
* no rewrite is ever asked to span a boundary.
|
|
1889
|
+
*
|
|
1890
|
+
* In `texts` mode a reply is first cut into the few messages it is texted
|
|
1891
|
+
* as, each then converted and sized on its own: the cuts never fall inside
|
|
1892
|
+
* code, and nothing else in the conversion crosses a line.
|
|
1496
1893
|
*/
|
|
1497
|
-
const messageChunks = (text) => splitForSlack(toSlackMrkdwn(
|
|
1894
|
+
const messageChunks = (text, replies) => (replies === 'texts' ? textingParts(text) : [text]).flatMap((part) => splitForSlack(toSlackMrkdwn(part)));
|
|
1498
1895
|
const APPROVAL_ACTIONS = {
|
|
1499
1896
|
stratus_approve_once: 'once',
|
|
1500
1897
|
stratus_approve_always: 'always',
|
|
@@ -2442,7 +2839,10 @@ const credentialModal = (post, requestId) => ({
|
|
|
2442
2839
|
type: 'input',
|
|
2443
2840
|
block_id: CREDENTIAL_BLOCK,
|
|
2444
2841
|
label: { type: 'plain_text', text: `Value for ${post.name}` },
|
|
2445
|
-
|
|
2842
|
+
// Several lines: a one-line field flattens the line breaks of a pasted
|
|
2843
|
+
// key file, such as a PEM private key, and the key stops working.
|
|
2844
|
+
element: { type: 'plain_text_input', action_id: CREDENTIAL_INPUT, multiline: true },
|
|
2845
|
+
hint: { type: 'plain_text', text: 'Paste the whole value. A key file, such as a PEM private key, keeps its line breaks.' },
|
|
2446
2846
|
},
|
|
2447
2847
|
],
|
|
2448
2848
|
});
|
|
@@ -2545,6 +2945,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2545
2945
|
const fetchFile = onlySlackFileHost(options.fetchFile ?? defaultFetchFile);
|
|
2546
2946
|
const fileDownloadTimeoutMs = options.fileDownloadTimeoutMs ?? DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS;
|
|
2547
2947
|
const stopWaitMs = options.stopWaitMs ?? DEFAULT_STOP_WAIT_MS;
|
|
2948
|
+
const approvalPromptWaitMs = options.approvalPromptWaitMs ?? DEFAULT_APPROVAL_PROMPT_WAIT_MS;
|
|
2949
|
+
const progressAfterMs = options.progressAfterMs ?? DEFAULT_PROGRESS_AFTER_MS;
|
|
2548
2950
|
const now = options.now ?? Date.now;
|
|
2549
2951
|
const connections = [];
|
|
2550
2952
|
// The agents this adapter carries: `options.agents` until a reload reads
|
|
@@ -2775,6 +3177,12 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
2775
3177
|
// outstanding.
|
|
2776
3178
|
const rendering = new Set();
|
|
2777
3179
|
const resolvedWhileRendering = new Map();
|
|
3180
|
+
// Requests whose prompt is waiting for its turn's line to land first
|
|
3181
|
+
// (card 950), and those of them settled in that wait: a request answered
|
|
3182
|
+
// or cancelled before anyone was asked has no prompt to post. Bounded the
|
|
3183
|
+
// same way: an id is here only for one bounded wait.
|
|
3184
|
+
const askingAfterLine = new Set();
|
|
3185
|
+
const settledBeforeAsked = new Set();
|
|
2778
3186
|
let unsubscribe;
|
|
2779
3187
|
let gatewayRef;
|
|
2780
3188
|
// Set as `stop()` begins, so a reload still connecting an app leaves
|
|
@@ -4990,7 +5398,7 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
4990
5398
|
// last. `NO_REPLY_TEXT` is what a rendered turn with nothing to say
|
|
4991
5399
|
// puts in its own message too. With nothing queued behind, there is no
|
|
4992
5400
|
// order to keep and nothing to say, so the files go on their own.
|
|
4993
|
-
const chunks = routing.reply ? messageChunks(routing.reply) : [];
|
|
5401
|
+
const chunks = routing.reply ? messageChunks(routing.reply, connection.config.replies) : [];
|
|
4994
5402
|
const posting = chunks.length > 0 ? chunks : (behind && files.length > 0 ? [NO_REPLY_TEXT] : []);
|
|
4995
5403
|
let landed = [];
|
|
4996
5404
|
const uploads = async () => {
|
|
@@ -6958,6 +7366,8 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
6958
7366
|
lazy: judged,
|
|
6959
7367
|
now,
|
|
6960
7368
|
streaming,
|
|
7369
|
+
progressAfterMs,
|
|
7370
|
+
texts: connection.config.replies === 'texts',
|
|
6961
7371
|
statusThread: thread ?? event.ts,
|
|
6962
7372
|
statusWarned: connection.statusWarned,
|
|
6963
7373
|
...(ahead ? { after: ahead } : {}),
|
|
@@ -7530,18 +7940,14 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
7530
7940
|
// reaches none of them, so a read stopping before it stops short.
|
|
7531
7941
|
liveFrom = NOT_LIVE_YET;
|
|
7532
7942
|
unsubscribe = gateway.bus.subscribe((event) => {
|
|
7533
|
-
// Approval traffic is not part of a reply: it gets its own message
|
|
7534
|
-
// with its own lifetime, and routing it through the running turn's
|
|
7535
|
-
// renderer would fold a question into the answer being streamed.
|
|
7536
|
-
if (event.type === 'tool.approval-requested') {
|
|
7537
|
-
track(renderApprovalRequest(event));
|
|
7538
|
-
return;
|
|
7539
|
-
}
|
|
7540
7943
|
if (event.type === 'credential.provided') {
|
|
7541
7944
|
track(settleCredentialPost(event));
|
|
7542
7945
|
return;
|
|
7543
7946
|
}
|
|
7544
7947
|
if (event.type === 'tool.approval-resolved') {
|
|
7948
|
+
if (askingAfterLine.has(event.requestId)) {
|
|
7949
|
+
settledBeforeAsked.add(event.requestId);
|
|
7950
|
+
}
|
|
7545
7951
|
// The question was posted into the turn's thread, which took the
|
|
7546
7952
|
// loading status down with it; the turn works on once answered.
|
|
7547
7953
|
track(retractApprovalRequest(event).finally(() => renderers.get(event.sessionId)?.[0]?.refreshLoading()));
|
|
@@ -7594,6 +8000,30 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
7594
8000
|
// turn from a renderer still waiting to start, and the renderer
|
|
7595
8001
|
// keeps what it is handed, as it always has.
|
|
7596
8002
|
const foreign = exact && !rendered;
|
|
8003
|
+
// Approval traffic is not part of a reply: it gets its own message
|
|
8004
|
+
// with its own lifetime, and routing it through the running turn's
|
|
8005
|
+
// renderer would fold a question into the answer being streamed.
|
|
8006
|
+
// What the turn wrote before the call goes out first, though, so
|
|
8007
|
+
// the approver reads the line saying what the agent is doing above
|
|
8008
|
+
// the question it leads to (card 950). The wait is bounded: a line
|
|
8009
|
+
// Slack is slow to take must not keep the question from anyone.
|
|
8010
|
+
if (event.type === 'tool.approval-requested') {
|
|
8011
|
+
head?.awaitingApproval(event.call.toolName, !foreign);
|
|
8012
|
+
const said = head?.sayBeforeAsking(!foreign);
|
|
8013
|
+
if (!said) {
|
|
8014
|
+
track(renderApprovalRequest(event));
|
|
8015
|
+
return;
|
|
8016
|
+
}
|
|
8017
|
+
askingAfterLine.add(event.requestId);
|
|
8018
|
+
track(settledWithin(said, approvalPromptWaitMs).then(async () => {
|
|
8019
|
+
askingAfterLine.delete(event.requestId);
|
|
8020
|
+
if (settledBeforeAsked.delete(event.requestId)) {
|
|
8021
|
+
return;
|
|
8022
|
+
}
|
|
8023
|
+
await renderApprovalRequest(event);
|
|
8024
|
+
}));
|
|
8025
|
+
return;
|
|
8026
|
+
}
|
|
7597
8027
|
if (event.type === 'tool.completed' && event.result.ok && (foreign || !head)) {
|
|
7598
8028
|
const produced = collectFilePaths(event.result);
|
|
7599
8029
|
if (produced.length > 0) {
|