@stratusagent/channel-slack 0.3.0 → 0.6.0
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 +66 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +521 -4
- package/dist/index.js.map +1 -1
- package/manifest/stratus-agent.manifest.json +1 -1
- package/package.json +4 -3
- package/dist/tsconfig.test.tsbuildinfo +0 -1
package/dist/index.js
CHANGED
|
@@ -85,12 +85,32 @@ class ReplyRenderer {
|
|
|
85
85
|
return;
|
|
86
86
|
}
|
|
87
87
|
if (event.type === 'tool.called') {
|
|
88
|
+
// Also a turn boundary. On the kernel path `provider.response` has
|
|
89
|
+
// already marked one, and marking it twice costs nothing; on a
|
|
90
|
+
// provider that hosts its own loop there is no provider.response at
|
|
91
|
+
// all, so this is the only thing between the text before a tool and
|
|
92
|
+
// the text after it — without it they fuse in the live message.
|
|
93
|
+
this.turnBreakPending = true;
|
|
88
94
|
this.toolLine = `⚙ ${event.call.toolName}…`;
|
|
89
95
|
this.scheduleEdit();
|
|
90
96
|
return;
|
|
91
97
|
}
|
|
98
|
+
if (event.type === 'tool.denied') {
|
|
99
|
+
// Denial settles a call that never reached tool.called, so nothing
|
|
100
|
+
// else clears the status line or marks the boundary for it — the
|
|
101
|
+
// message would keep claiming a refused tool is running, and the
|
|
102
|
+
// model's follow-up would fuse with the text before the attempt.
|
|
103
|
+
this.toolLine = undefined;
|
|
104
|
+
this.turnBreakPending = true;
|
|
105
|
+
this.scheduleEdit();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
92
108
|
if (event.type === 'tool.completed') {
|
|
93
109
|
this.toolLine = undefined;
|
|
110
|
+
// Also a boundary, and not only for symmetry: a call rejected before
|
|
111
|
+
// execution settles as tool.completed without ever having emitted
|
|
112
|
+
// tool.called, so this is the only mark that attempt leaves.
|
|
113
|
+
this.turnBreakPending = true;
|
|
94
114
|
// Clear the visible status promptly: the next provider turn may be
|
|
95
115
|
// non-streaming or slow to its first delta, and the message must not
|
|
96
116
|
// claim a finished tool is still running that whole time.
|
|
@@ -251,6 +271,102 @@ const lastAssistantReply = (session) => {
|
|
|
251
271
|
}
|
|
252
272
|
return '(no reply)';
|
|
253
273
|
};
|
|
274
|
+
const APPROVAL_ACTIONS = {
|
|
275
|
+
stratus_approve_once: 'once',
|
|
276
|
+
stratus_approve_always: 'always',
|
|
277
|
+
stratus_deny: 'deny',
|
|
278
|
+
};
|
|
279
|
+
/**
|
|
280
|
+
* How an approval request reads once it is settled. `always` deliberately
|
|
281
|
+
* says "this session" out loud: the button is the shortest path to widening
|
|
282
|
+
* what an agent does unattended, and an approver should see the scope they
|
|
283
|
+
* granted in the record of granting it.
|
|
284
|
+
*/
|
|
285
|
+
const OUTCOME_TEXT = {
|
|
286
|
+
'decided:once': 'Allowed once',
|
|
287
|
+
'decided:always': 'Allowed for the rest of this session',
|
|
288
|
+
'decided:deny': 'Denied',
|
|
289
|
+
'timeout:deny': 'Expired without an answer — denied',
|
|
290
|
+
// Covers both endings that reach here: the turn was aborted, and the
|
|
291
|
+
// daemon shut down with the request still outstanding.
|
|
292
|
+
'cancelled:deny': 'Cancelled before anyone answered — denied',
|
|
293
|
+
'undeliverable:deny': 'Could not be put to an approver — denied',
|
|
294
|
+
};
|
|
295
|
+
/** Comfortably inside Slack's 3000-character section limit. */
|
|
296
|
+
const APPROVAL_INPUT_LIMIT = 1400;
|
|
297
|
+
/** One line in a notification; the blocks carry the readable version. */
|
|
298
|
+
const APPROVAL_SUMMARY_LIMIT = 160;
|
|
299
|
+
/**
|
|
300
|
+
* Slack's three markup characters. Tool input is written by a model, so it
|
|
301
|
+
* reaches this message as untrusted text: unescaped, `<@U123>` becomes a
|
|
302
|
+
* real mention and `<!channel>` a real broadcast, letting an agent ping a
|
|
303
|
+
* workspace through the very prompt asking whether to trust it. Escaping is
|
|
304
|
+
* applied even inside a code block, where clients disagree about whether
|
|
305
|
+
* that markup still resolves.
|
|
306
|
+
*/
|
|
307
|
+
const escapeSlackText = (text) => text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
308
|
+
/**
|
|
309
|
+
* What the approver is actually being asked to allow.
|
|
310
|
+
*
|
|
311
|
+
* Without this the prompt names only the tool, and for anything whose
|
|
312
|
+
* danger lives in its arguments — the shell tool this engine exists for —
|
|
313
|
+
* `ls` and a destructive command produce an identical message. Approving
|
|
314
|
+
* something you cannot see is not approval.
|
|
315
|
+
*
|
|
316
|
+
* Truncation is announced rather than silent for the same reason: a
|
|
317
|
+
* decision made on a partial view should at least know it is partial.
|
|
318
|
+
*/
|
|
319
|
+
const renderInvocation = (input) => {
|
|
320
|
+
if (Object.keys(input).length === 0) {
|
|
321
|
+
return { summary: '', truncated: false };
|
|
322
|
+
}
|
|
323
|
+
const rendered = JSON.stringify(input, null, 2);
|
|
324
|
+
const truncated = rendered.length > APPROVAL_INPUT_LIMIT;
|
|
325
|
+
const oneLine = JSON.stringify(input);
|
|
326
|
+
return {
|
|
327
|
+
detail: truncated ? rendered.slice(0, APPROVAL_INPUT_LIMIT) : rendered,
|
|
328
|
+
summary: oneLine.length > APPROVAL_SUMMARY_LIMIT
|
|
329
|
+
? `${oneLine.slice(0, APPROVAL_SUMMARY_LIMIT)}…`
|
|
330
|
+
: oneLine,
|
|
331
|
+
truncated,
|
|
332
|
+
};
|
|
333
|
+
};
|
|
334
|
+
const approvalBlocks = (agentName, toolName, risk, requestId, input) => {
|
|
335
|
+
const invocation = renderInvocation(input);
|
|
336
|
+
return [
|
|
337
|
+
{
|
|
338
|
+
type: 'section',
|
|
339
|
+
text: {
|
|
340
|
+
type: 'mrkdwn',
|
|
341
|
+
text: `*${escapeSlackText(agentName)}* wants to run \`${escapeSlackText(toolName)}\` (${risk}).`,
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
...(invocation.detail
|
|
345
|
+
? [{
|
|
346
|
+
type: 'section',
|
|
347
|
+
text: { type: 'mrkdwn', text: `\`\`\`\n${escapeSlackText(invocation.detail)}\n\`\`\`` },
|
|
348
|
+
}]
|
|
349
|
+
: []),
|
|
350
|
+
...(invocation.truncated
|
|
351
|
+
? [{
|
|
352
|
+
type: 'context',
|
|
353
|
+
elements: [{ type: 'mrkdwn', text: ':warning: Arguments shown are truncated — the full call is longer than this.' }],
|
|
354
|
+
}]
|
|
355
|
+
: []),
|
|
356
|
+
{
|
|
357
|
+
type: 'actions',
|
|
358
|
+
// The request id rides on every button rather than in the message
|
|
359
|
+
// reference: Slack gives the value back verbatim, so the gateway is
|
|
360
|
+
// asked about the request that was actually rendered, never one
|
|
361
|
+
// re-derived from a channel and timestamp.
|
|
362
|
+
elements: [
|
|
363
|
+
{ type: 'button', action_id: 'stratus_approve_once', text: { type: 'plain_text', text: 'Allow once' }, value: requestId },
|
|
364
|
+
{ type: 'button', action_id: 'stratus_approve_always', text: { type: 'plain_text', text: 'Always allow' }, value: requestId, style: 'primary' },
|
|
365
|
+
{ type: 'button', action_id: 'stratus_deny', text: { type: 'plain_text', text: 'Deny' }, value: requestId, style: 'danger' },
|
|
366
|
+
],
|
|
367
|
+
},
|
|
368
|
+
];
|
|
369
|
+
};
|
|
254
370
|
/**
|
|
255
371
|
* The Slack channel adapter. One Slack app per agent — Slack has no way to
|
|
256
372
|
* give a single bot several identities with real avatars, presence, and
|
|
@@ -269,6 +385,9 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
269
385
|
const createSocket = options.createSocketClient ?? defaultSocketClient;
|
|
270
386
|
const createWeb = options.createWebClient ?? defaultWebClient;
|
|
271
387
|
const connections = [];
|
|
388
|
+
// Every agent this adapter was asked to carry, connected or not — the
|
|
389
|
+
// difference between "mine and broken" and "not mine".
|
|
390
|
+
const configuredAgents = new Set(options.agents.map((agent) => agent.agentId));
|
|
272
391
|
// Renderers queue per session: two messages in one thread share a
|
|
273
392
|
// session id, and the gateway serializes their turns — so events route
|
|
274
393
|
// to the FIRST registered renderer (the running turn), never a later
|
|
@@ -284,8 +403,38 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
284
403
|
// Socket Mode redelivers on missed acks; a slow turn must not run twice.
|
|
285
404
|
const seenEvents = new Set();
|
|
286
405
|
const seenOrder = [];
|
|
406
|
+
// Rendered approval requests, keyed by the gateway's request id — the
|
|
407
|
+
// same id the buttons carry back.
|
|
408
|
+
const approvalPosts = new Map();
|
|
409
|
+
// Requests whose message is mid-post. A request can settle — expire, or
|
|
410
|
+
// its turn be cancelled — while the postMessage that announces it is
|
|
411
|
+
// still in flight, and the retraction would then find nothing to retract
|
|
412
|
+
// and leave live-looking buttons behind forever. Resolutions arriving in
|
|
413
|
+
// that window are held here and applied once the post lands. Bounded by
|
|
414
|
+
// construction: an id is only ever in this set while one HTTP call is
|
|
415
|
+
// outstanding.
|
|
416
|
+
const rendering = new Set();
|
|
417
|
+
const resolvedWhileRendering = new Map();
|
|
287
418
|
let unsubscribe;
|
|
288
419
|
let gatewayRef;
|
|
420
|
+
// Everything the adapter owes Slack goes through here, so stop() drains
|
|
421
|
+
// it: an approval question or its retraction must reach the workspace
|
|
422
|
+
// before the sockets close, or a message keeps offering buttons that
|
|
423
|
+
// nothing is listening for.
|
|
424
|
+
const track = (work) => {
|
|
425
|
+
// Also the rejection boundary, because this is where the work stops
|
|
426
|
+
// being anyone's to await: the caller hands it over and returns to the
|
|
427
|
+
// event loop, so a rejection past this point is attached to nothing
|
|
428
|
+
// and takes the daemon down under Node's default. One call site below
|
|
429
|
+
// remembers to catch before handing over, which is exactly the kind of
|
|
430
|
+
// rule that holds until the next call site forgets it -- so it lives
|
|
431
|
+
// here instead, where there is one of it.
|
|
432
|
+
const settled = work.catch((error) => {
|
|
433
|
+
warn(`slack: background work failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
434
|
+
});
|
|
435
|
+
inflight.add(settled);
|
|
436
|
+
void settled.finally(() => inflight.delete(settled));
|
|
437
|
+
};
|
|
289
438
|
const alreadySeen = (key) => {
|
|
290
439
|
if (seenEvents.has(key)) {
|
|
291
440
|
return true;
|
|
@@ -329,6 +478,317 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
329
478
|
}
|
|
330
479
|
return result.trim();
|
|
331
480
|
};
|
|
481
|
+
const connectionFor = (agentId) => connections.find((candidate) => candidate.config.agentId === agentId);
|
|
482
|
+
/**
|
|
483
|
+
* Renders one parked call as buttons and remembers where it went.
|
|
484
|
+
*
|
|
485
|
+
* A request this adapter cannot route is DENIED here rather than left to
|
|
486
|
+
* expire: with no approver configured, or no conversation to ask in,
|
|
487
|
+
* waiting out the timeout tells the agent nothing it will not be told
|
|
488
|
+
* immediately, and holds the turn (and the thread it is answering) open
|
|
489
|
+
* for the whole window to get there.
|
|
490
|
+
*/
|
|
491
|
+
const renderApprovalRequest = async (event) => {
|
|
492
|
+
const gateway = gatewayRef;
|
|
493
|
+
if (!gateway) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const connection = connectionFor(event.agentId);
|
|
497
|
+
if (!connection) {
|
|
498
|
+
// Two different situations, and only one of them is this adapter's
|
|
499
|
+
// to answer.
|
|
500
|
+
//
|
|
501
|
+
// An agent it was configured for but could not connect (a failed
|
|
502
|
+
// auth, a dead app token) is its own: it was supposed to carry that
|
|
503
|
+
// agent's approvals and cannot, so it says so rather than letting
|
|
504
|
+
// every gated call wait out the timeout.
|
|
505
|
+
//
|
|
506
|
+
// An agent it was never configured for is NOT. Approval requests are
|
|
507
|
+
// a broadcast, and another channel may be the one that serves that
|
|
508
|
+
// agent — denying here would let Slack refuse a question somebody
|
|
509
|
+
// else was going to ask. `stratus serve` reports agents that no
|
|
510
|
+
// channel can ask for, at startup, where the whole roster is visible.
|
|
511
|
+
if (configuredAgents.has(event.agentId)) {
|
|
512
|
+
warn(`slack: ${event.agentId} has no live connection; denying ${event.call.toolName}`);
|
|
513
|
+
gateway.resolveApproval({ requestId: event.requestId, answer: 'deny', reason: 'undeliverable' });
|
|
514
|
+
}
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const approvers = new Set((connection.config.approvers ?? []).filter((id) => id.length > 0));
|
|
518
|
+
const metadata = event.metadata ?? {};
|
|
519
|
+
const sameChannel = metadata.channel === 'slack' && typeof metadata.slackChannel === 'string'
|
|
520
|
+
? metadata.slackChannel
|
|
521
|
+
: undefined;
|
|
522
|
+
const channel = sameChannel ?? connection.config.approvalChannel;
|
|
523
|
+
const thread = sameChannel && typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined;
|
|
524
|
+
// Undeliverable, not decided: nobody was asked, so filing this next to
|
|
525
|
+
// the denials somebody actually made would make the audit record lie
|
|
526
|
+
// about which is which.
|
|
527
|
+
const decline = (reason) => {
|
|
528
|
+
warn(`slack: ${reason}; denying ${event.call.toolName} for ${event.agentId}`);
|
|
529
|
+
gateway.resolveApproval({ requestId: event.requestId, answer: 'deny', reason: 'undeliverable' });
|
|
530
|
+
};
|
|
531
|
+
if (approvers.size === 0) {
|
|
532
|
+
decline(`no approvers configured for ${event.agentId}`);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (!channel) {
|
|
536
|
+
decline(`no conversation to ask in for ${event.agentId}`);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
const agentName = gateway.agents().find((agent) => agent.id === event.agentId)?.name ?? event.agentId;
|
|
540
|
+
// The notification preview is all some approvers see before deciding
|
|
541
|
+
// whether to open the thread, so it carries the arguments too — just
|
|
542
|
+
// the short form.
|
|
543
|
+
const invocationSummary = renderInvocation(event.call.input).summary;
|
|
544
|
+
// The post is the only awaited step, and the only window in which the
|
|
545
|
+
// request can settle behind this function's back — so it is the only
|
|
546
|
+
// step `rendering` covers, and nothing decides anything until it is
|
|
547
|
+
// over and the two possible outcomes can be told apart.
|
|
548
|
+
rendering.add(event.requestId);
|
|
549
|
+
let post;
|
|
550
|
+
let failure;
|
|
551
|
+
try {
|
|
552
|
+
const posted = await connection.web.chat.postMessage({
|
|
553
|
+
channel,
|
|
554
|
+
// Fallback text for notifications and clients that do not render
|
|
555
|
+
// blocks — an approval nobody can read is an approval nobody gives.
|
|
556
|
+
text: escapeSlackText(`${agentName} wants to run ${event.call.toolName} (${event.risk})`
|
|
557
|
+
+ `${invocationSummary ? `: ${invocationSummary}` : ''}.`),
|
|
558
|
+
...(thread ? { thread_ts: thread } : {}),
|
|
559
|
+
blocks: approvalBlocks(agentName, event.call.toolName, event.risk, event.requestId, event.call.input),
|
|
560
|
+
});
|
|
561
|
+
post = posted.ts
|
|
562
|
+
? {
|
|
563
|
+
connection,
|
|
564
|
+
channel: posted.channel ?? channel,
|
|
565
|
+
ts: posted.ts,
|
|
566
|
+
...(thread ? { thread } : {}),
|
|
567
|
+
agentName,
|
|
568
|
+
toolName: event.call.toolName,
|
|
569
|
+
risk: event.risk,
|
|
570
|
+
approvers,
|
|
571
|
+
}
|
|
572
|
+
: undefined;
|
|
573
|
+
failure = post ? undefined : `Slack accepted the approval request for ${event.agentId} without a timestamp`;
|
|
574
|
+
}
|
|
575
|
+
catch (error) {
|
|
576
|
+
failure = `could not post an approval request to ${channel} (${error instanceof Error ? error.message : String(error)})`;
|
|
577
|
+
}
|
|
578
|
+
rendering.delete(event.requestId);
|
|
579
|
+
if (post) {
|
|
580
|
+
approvalPosts.set(event.requestId, post);
|
|
581
|
+
}
|
|
582
|
+
const settledMeanwhile = resolvedWhileRendering.get(event.requestId);
|
|
583
|
+
if (settledMeanwhile) {
|
|
584
|
+
resolvedWhileRendering.delete(event.requestId);
|
|
585
|
+
// Already decided — never decline it a second time; just take the
|
|
586
|
+
// buttons off the message that just appeared.
|
|
587
|
+
if (post) {
|
|
588
|
+
await retractApprovalRequest(settledMeanwhile);
|
|
589
|
+
}
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (failure) {
|
|
593
|
+
decline(failure);
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
/**
|
|
597
|
+
* Retracts the buttons once a request is settled, however it settled.
|
|
598
|
+
* Every ending goes through here — a click, the timeout, a cancelled turn
|
|
599
|
+
* — so a message can never keep offering a decision that no longer has
|
|
600
|
+
* anywhere to land.
|
|
601
|
+
*/
|
|
602
|
+
/**
|
|
603
|
+
* Rewrites a prompt left behind by a daemon that is gone, using the
|
|
604
|
+
* click's own coordinates.
|
|
605
|
+
*
|
|
606
|
+
* The index of posted requests is in-memory and keyed by request id, so
|
|
607
|
+
* a new process starts empty and has no way to find the messages its
|
|
608
|
+
* predecessor posted. An interaction payload carries the channel and
|
|
609
|
+
* timestamp of the message it came from, which is the one handle on such
|
|
610
|
+
* a message that survives — so the click that discovers the prompt is
|
|
611
|
+
* dead is also what retires it. Unclicked orphans stay as they are;
|
|
612
|
+
* clearing those needs the posts to be durable, which is a larger change
|
|
613
|
+
* than this and belongs to both billing paths equally.
|
|
614
|
+
*/
|
|
615
|
+
const offersDecision = (blocks) => (blocks ?? []).some((block) => block.type === 'actions');
|
|
616
|
+
const retireOrphanedPrompt = async (connection, args) => {
|
|
617
|
+
const channel = args.body?.channel?.id;
|
|
618
|
+
const ts = args.body?.message?.ts;
|
|
619
|
+
if (!channel || !ts) {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
// The message decides, not a memory of it. Every ending rewrites this
|
|
623
|
+
// message into a plain section, so buttons still on it mean nothing
|
|
624
|
+
// has been written there yet — and no buttons means something has, and
|
|
625
|
+
// overwriting it would replace a real outcome and the person who made
|
|
626
|
+
// it. Reading the click's own copy answers that with no bookkeeping to
|
|
627
|
+
// disagree with, and no second API call to ask.
|
|
628
|
+
if (!offersDecision(args.body?.message?.blocks)) {
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const text = 'This request is no longer pending — the daemon that asked it is no longer running.';
|
|
632
|
+
try {
|
|
633
|
+
await connection.web.chat.update({
|
|
634
|
+
channel,
|
|
635
|
+
ts,
|
|
636
|
+
text,
|
|
637
|
+
blocks: [{ type: 'section', text: { type: 'mrkdwn', text } }],
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
warn(`slack: could not retire an orphaned approval request: ${error instanceof Error ? error.message : String(error)}`);
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
const retractApprovalRequest = async (event) => {
|
|
645
|
+
const post = approvalPosts.get(event.requestId);
|
|
646
|
+
if (!post) {
|
|
647
|
+
// Still being posted: hold the outcome so the message it is about to
|
|
648
|
+
// create does not keep offering a decision that is already made.
|
|
649
|
+
if (rendering.has(event.requestId)) {
|
|
650
|
+
resolvedWhileRendering.set(event.requestId, event);
|
|
651
|
+
}
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
approvalPosts.delete(event.requestId);
|
|
655
|
+
const outcome = OUTCOME_TEXT[`${event.reason}:${event.answer}`] ?? 'Resolved';
|
|
656
|
+
const by = event.actor ? ` by <@${event.actor}>` : '';
|
|
657
|
+
const text = `*${escapeSlackText(post.agentName)}* — \`${escapeSlackText(post.toolName)}\` (${post.risk}): ${outcome}${by}.`;
|
|
658
|
+
try {
|
|
659
|
+
await post.connection.web.chat.update({
|
|
660
|
+
channel: post.channel,
|
|
661
|
+
ts: post.ts,
|
|
662
|
+
text,
|
|
663
|
+
blocks: [{ type: 'section', text: { type: 'mrkdwn', text } }],
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
catch (error) {
|
|
667
|
+
warn(`slack: could not update a resolved approval request: ${error instanceof Error ? error.message : String(error)}`);
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
/**
|
|
671
|
+
* Says so in the thread when a turn fails with nobody rendering it.
|
|
672
|
+
*
|
|
673
|
+
* The intake path reports a failure through the renderer it opened, so a
|
|
674
|
+
* turn this process started is always answered. A turn it did NOT start
|
|
675
|
+
* has no renderer: one the startup sweep failed because the last daemon
|
|
676
|
+
* died mid-turn, or one whose recovery could not be finished. Those are
|
|
677
|
+
* exactly the cases where the person is still looking at the thread,
|
|
678
|
+
* and silence there reads as an agent that simply never replied.
|
|
679
|
+
*
|
|
680
|
+
* The routing has to come from the durable session, because the only
|
|
681
|
+
* in-memory copy died with the process that had it.
|
|
682
|
+
*/
|
|
683
|
+
const reportUnrenderedFailure = async (event) => {
|
|
684
|
+
const gateway = gatewayRef;
|
|
685
|
+
if (!gateway?.sessionRouting) {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
let routing;
|
|
689
|
+
try {
|
|
690
|
+
routing = await gateway.sessionRouting(event.sessionId);
|
|
691
|
+
}
|
|
692
|
+
catch (error) {
|
|
693
|
+
// A store that cannot answer is not a reason to lose the daemon, and
|
|
694
|
+
// the turn is already failed — there is nothing here to salvage
|
|
695
|
+
// beyond saying why the thread stayed quiet.
|
|
696
|
+
warn(`slack: could not read the routing for a failed turn: ${error instanceof Error ? error.message : String(error)}`);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const metadata = routing?.metadata;
|
|
700
|
+
// Another surface's session, or one with no conversation to speak
|
|
701
|
+
// into. Not this adapter's to answer either way.
|
|
702
|
+
if (!routing || metadata?.channel !== 'slack' || typeof metadata.slackChannel !== 'string') {
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
const connection = connectionFor(routing.agentId);
|
|
706
|
+
if (!connection) {
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const thread = typeof metadata.slackThread === 'string' ? metadata.slackThread : undefined;
|
|
710
|
+
try {
|
|
711
|
+
await connection.web.chat.postMessage({
|
|
712
|
+
channel: metadata.slackChannel,
|
|
713
|
+
text: `Something went wrong: ${event.error}`,
|
|
714
|
+
...(thread ? { thread_ts: thread } : {}),
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
catch (error) {
|
|
718
|
+
warn(`slack: could not report a failed turn: ${error instanceof Error ? error.message : String(error)}`);
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
const handleInteractive = async (connection, args) => {
|
|
722
|
+
// Ack first and unconditionally: Slack retries an unacked interaction,
|
|
723
|
+
// and a redelivered click on a request that is no longer pending would
|
|
724
|
+
// read as a second, rejected decision.
|
|
725
|
+
await args.ack();
|
|
726
|
+
const gateway = gatewayRef;
|
|
727
|
+
const action = args.body?.actions?.[0];
|
|
728
|
+
const answer = action?.action_id ? APPROVAL_ACTIONS[action.action_id] : undefined;
|
|
729
|
+
const requestId = action?.value;
|
|
730
|
+
if (!gateway || !answer || !requestId) {
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
const post = approvalPosts.get(requestId);
|
|
734
|
+
const clicker = args.body?.user?.id;
|
|
735
|
+
// An unknown request predates this daemon or was already settled; the
|
|
736
|
+
// click is answered where it was made, not silently dropped.
|
|
737
|
+
if (!post) {
|
|
738
|
+
await tellClicker(connection, args, 'That approval request is no longer pending.');
|
|
739
|
+
// And the message itself is corrected, so the next person to read it
|
|
740
|
+
// is not offered a decision nothing is waiting for — unless it is
|
|
741
|
+
// one still being posted, which is live with the index simply not
|
|
742
|
+
// caught up yet. Slack can show a message before postMessage
|
|
743
|
+
// resolves here, so a fast click lands inside that window, and
|
|
744
|
+
// stripping its buttons would strand the turn until the approval
|
|
745
|
+
// times out: the record written when the post lands does not put
|
|
746
|
+
// them back. Whether anything has already been written on the
|
|
747
|
+
// message is `retireOrphanedPrompt`'s own question, answered from
|
|
748
|
+
// the message rather than from a memory of it.
|
|
749
|
+
if (!rendering.has(requestId)) {
|
|
750
|
+
await retireOrphanedPrompt(connection, args);
|
|
751
|
+
}
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
// A different agent's app relaying a click would bypass the approver
|
|
755
|
+
// set bound to the request, since each app carries its own.
|
|
756
|
+
if (post.connection !== connection) {
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
if (!clicker || !post.approvers.has(clicker)) {
|
|
760
|
+
// The request stays pending: someone who may not decide clicking is
|
|
761
|
+
// not a decision, and consuming the request would let any channel
|
|
762
|
+
// member deny an agent's work.
|
|
763
|
+
await tellClicker(connection, args, `You are not an approver for ${post.agentName}, so that decision was not recorded.`);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
// The retraction rides on tool.approval-resolved, which the gateway
|
|
767
|
+
// emits from resolveApproval — so a decision and the message showing it
|
|
768
|
+
// cannot disagree, and the timeout path retracts through the same code.
|
|
769
|
+
if (!gateway.resolveApproval({ requestId, answer, actor: clicker })) {
|
|
770
|
+
await tellClicker(connection, args, 'That approval request is no longer pending.');
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
const tellClicker = async (connection, args, text) => {
|
|
774
|
+
const user = args.body?.user?.id;
|
|
775
|
+
const channel = args.body?.channel?.id;
|
|
776
|
+
if (!user || !channel) {
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const thread = args.body?.message?.thread_ts;
|
|
780
|
+
try {
|
|
781
|
+
await connection.web.chat.postEphemeral({
|
|
782
|
+
channel,
|
|
783
|
+
user,
|
|
784
|
+
text,
|
|
785
|
+
...(thread ? { thread_ts: thread } : {}),
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
catch (error) {
|
|
789
|
+
warn(`slack: could not send an ephemeral notice: ${error instanceof Error ? error.message : String(error)}`);
|
|
790
|
+
}
|
|
791
|
+
};
|
|
332
792
|
const handleInbound = async (connection, args) => {
|
|
333
793
|
// Ack immediately: Slack redelivers unacked envelopes, and a turn can
|
|
334
794
|
// outlive the ack window many times over.
|
|
@@ -396,7 +856,15 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
396
856
|
sessionId,
|
|
397
857
|
agentId: connection.config.agentId,
|
|
398
858
|
userMessage,
|
|
399
|
-
metadata: {
|
|
859
|
+
metadata: {
|
|
860
|
+
channel: 'slack',
|
|
861
|
+
team,
|
|
862
|
+
slackChannel: event.channel,
|
|
863
|
+
slackUser: userId,
|
|
864
|
+
// Carried so a mid-turn approval question is asked in the thread
|
|
865
|
+
// the turn belongs to rather than at the top of a busy channel.
|
|
866
|
+
...(thread ? { slackThread: thread } : {}),
|
|
867
|
+
},
|
|
400
868
|
});
|
|
401
869
|
return { renderer, turn };
|
|
402
870
|
});
|
|
@@ -448,6 +916,39 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
448
916
|
async start(gateway) {
|
|
449
917
|
gatewayRef = gateway;
|
|
450
918
|
unsubscribe = gateway.bus.subscribe((event) => {
|
|
919
|
+
// Approval traffic is not part of a reply: it gets its own message
|
|
920
|
+
// with its own lifetime, and routing it through the running turn's
|
|
921
|
+
// renderer would fold a question into the answer being streamed.
|
|
922
|
+
if (event.type === 'tool.approval-requested') {
|
|
923
|
+
track(renderApprovalRequest(event));
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
if (event.type === 'tool.approval-resolved') {
|
|
927
|
+
track(retractApprovalRequest(event));
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
// A failure with no renderer is not a turn this process is
|
|
931
|
+
// serving — it is one the startup sweep or a failed recovery
|
|
932
|
+
// closed out. Nothing downstream would say anything about it.
|
|
933
|
+
//
|
|
934
|
+
// A non-empty queue is not proof the failure belongs to a turn
|
|
935
|
+
// this adapter dispatched, only that it has one for this session.
|
|
936
|
+
// A renderer is queued at intake, before the gateway starts the
|
|
937
|
+
// turn it belongs to, so a message arriving while a recovery is
|
|
938
|
+
// still ahead of it on the session chain leaves the recovery's
|
|
939
|
+
// failure looking rendered when it is not. Suppressing is the
|
|
940
|
+
// conservative half of that trade: the alternative reports every
|
|
941
|
+
// ordinary failure twice.
|
|
942
|
+
//
|
|
943
|
+
// Telling them apart needs a turn identifier, which `StratusEvent`
|
|
944
|
+
// does not carry — the same gap 05's WS envelope closes with
|
|
945
|
+
// `{ sessionId, turnId, event }`. The routing a line below has it
|
|
946
|
+
// too: recovery events are already delivered to whichever renderer
|
|
947
|
+
// is at the head of the queue, whether or not it is theirs.
|
|
948
|
+
if (event.type === 'session.failed' && !renderers.get(event.sessionId)?.length) {
|
|
949
|
+
track(reportUnrenderedFailure(event));
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
451
952
|
if ('sessionId' in event) {
|
|
452
953
|
renderers.get(event.sessionId)?.[0]?.onEvent(event);
|
|
453
954
|
}
|
|
@@ -478,6 +979,11 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
478
979
|
};
|
|
479
980
|
socket.on('app_mention', onEvent);
|
|
480
981
|
socket.on('message', onEvent);
|
|
982
|
+
socket.on('interactive', (args) => {
|
|
983
|
+
track(handleInteractive(connection, args).catch((error) => {
|
|
984
|
+
warn(`slack interaction handling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
985
|
+
}));
|
|
986
|
+
});
|
|
481
987
|
await socket.start();
|
|
482
988
|
connections.push(connection);
|
|
483
989
|
log(`slack: ${config.agentId} connected (bot ${connection.botUserId} in team ${connection.teamId})`);
|
|
@@ -489,15 +995,26 @@ export const createSlackChannelAdapter = (options) => {
|
|
|
489
995
|
}
|
|
490
996
|
},
|
|
491
997
|
async stop() {
|
|
492
|
-
// Stop intake first
|
|
493
|
-
//
|
|
494
|
-
//
|
|
998
|
+
// Stop intake first, so no new INBOUND event can slip into
|
|
999
|
+
// `inflight` after the drain snapshot below.
|
|
1000
|
+
//
|
|
1001
|
+
// That is narrower than it used to claim. The snapshot is taken
|
|
1002
|
+
// once, and this unsubscribes from the bus only after it, so a turn
|
|
1003
|
+
// still finishing can emit an event whose subscriber tracks work
|
|
1004
|
+
// this drain never waits for — a retraction posted after stop()
|
|
1005
|
+
// returns, or dropped with the process. Draining until `inflight`
|
|
1006
|
+
// stays empty would close it, and would make stop() wait on turns
|
|
1007
|
+
// the gateway drains after this call rather than before; worth doing
|
|
1008
|
+
// deliberately, not as a side effect.
|
|
495
1009
|
await Promise.allSettled(connections.map((connection) => connection.socket.disconnect()));
|
|
496
1010
|
await Promise.allSettled([...inflight]);
|
|
497
1011
|
unsubscribe?.();
|
|
498
1012
|
unsubscribe = undefined;
|
|
499
1013
|
gatewayRef = undefined;
|
|
500
1014
|
connections.length = 0;
|
|
1015
|
+
approvalPosts.clear();
|
|
1016
|
+
rendering.clear();
|
|
1017
|
+
resolvedWhileRendering.clear();
|
|
501
1018
|
},
|
|
502
1019
|
};
|
|
503
1020
|
};
|