@vellumai/assistant 0.10.5-staging.2 → 0.10.5
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/package.json +1 -1
- package/src/__tests__/compactor-media-strip.test.ts +177 -0
- package/src/__tests__/conversation-history-web-search.test.ts +7 -0
- package/src/__tests__/guardian-question-mode.test.ts +57 -0
- package/src/__tests__/notification-decision-fallback.test.ts +94 -0
- package/src/__tests__/pre-model-call-sanitize.test.ts +1 -1
- package/src/__tests__/slack-notification-approval-card.test.ts +89 -2
- package/src/agent/ax-tree-compaction.test.ts +4 -1
- package/src/agent/loop.ts +1 -176
- package/src/context/compactor.ts +29 -15
- package/src/context/outbound-sanitize.ts +202 -0
- package/src/daemon/handlers/shared.ts +1 -23
- package/src/daemon/host-cu-proxy.ts +1 -1
- package/src/messaging/providers/slack/send.test.ts +79 -0
- package/src/messaging/providers/slack/send.ts +48 -12
- package/src/notifications/README.md +1 -1
- package/src/notifications/adapters/slack.ts +44 -13
- package/src/notifications/broadcaster.ts +21 -15
- package/src/notifications/decision-engine.ts +121 -31
- package/src/notifications/guardian-question-mode.ts +58 -10
- package/src/prompts/templates/SOUL.md +40 -14
- package/src/runtime/slack-reply-session.test.ts +81 -0
- package/src/runtime/slack-reply-session.ts +13 -0
- package/src/util/__tests__/text-spacing.test.ts +53 -0
- package/src/util/text-spacing.ts +53 -0
|
@@ -151,13 +151,15 @@ export interface SlackSendResult {
|
|
|
151
151
|
* Those errors fault the Block Kit payload, not the target — the retry repeats
|
|
152
152
|
* the *same* operation (same `chat.update` ts, same `chat.postMessage` thread)
|
|
153
153
|
* without blocks, so it edits/posts in place rather than spawning a second
|
|
154
|
-
* message.
|
|
154
|
+
* message. `fallbackText` replaces the message text on that retry (used to
|
|
155
|
+
* re-attach reply instructions whose Block Kit equivalent was dropped). Any
|
|
156
|
+
* other error propagates to the caller.
|
|
155
157
|
*/
|
|
156
158
|
async function sendWithBlockFallback(
|
|
157
159
|
method: string,
|
|
158
160
|
baseBody: Record<string, unknown>,
|
|
159
161
|
blocks: KnownBlock[],
|
|
160
|
-
options: { fallbackWithoutBlocks: boolean },
|
|
162
|
+
options: { fallbackWithoutBlocks: boolean; fallbackText?: string },
|
|
161
163
|
): Promise<SlackSendResult> {
|
|
162
164
|
try {
|
|
163
165
|
const result = await callSlackApi(
|
|
@@ -177,13 +179,36 @@ async function sendWithBlockFallback(
|
|
|
177
179
|
{ method, slackError: err.slackError },
|
|
178
180
|
"Slack rejected blocks; retrying without blocks",
|
|
179
181
|
);
|
|
180
|
-
const
|
|
182
|
+
const retryBody =
|
|
183
|
+
options.fallbackText !== undefined
|
|
184
|
+
? { ...baseBody, text: options.fallbackText }
|
|
185
|
+
: baseBody;
|
|
186
|
+
const result = await callSlackApi(method, retryBody);
|
|
181
187
|
return { ok: true, ts: result.ts };
|
|
182
188
|
}
|
|
183
189
|
throw err;
|
|
184
190
|
}
|
|
185
191
|
}
|
|
186
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Text for the block-free retry of an approval prompt. Dropping an approval's
|
|
195
|
+
* blocks removes its Approve/Reject buttons, so the retry re-attaches the
|
|
196
|
+
* plain-text reply instructions to keep the message actionable. Returns
|
|
197
|
+
* `undefined` when the approval has no usable instructions — such a prompt
|
|
198
|
+
* must not be retried bare, since a message with no way to respond is worse
|
|
199
|
+
* than a failed delivery the delivery layer can surface.
|
|
200
|
+
*/
|
|
201
|
+
function buildApprovalFallbackText(
|
|
202
|
+
text: string,
|
|
203
|
+
approval: ApprovalUIMetadata,
|
|
204
|
+
): string | undefined {
|
|
205
|
+
const instructions = approval.plainTextFallback.trim();
|
|
206
|
+
if (instructions.length === 0) {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
return text.includes(instructions) ? text : `${text}\n\n${instructions}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
187
212
|
/**
|
|
188
213
|
* Send a Slack text message with optional Block Kit formatting.
|
|
189
214
|
*
|
|
@@ -219,29 +244,40 @@ export async function sendSlackReply(
|
|
|
219
244
|
}
|
|
220
245
|
|
|
221
246
|
const postBase: Record<string, unknown> = { channel: chatId, text };
|
|
222
|
-
if (options?.threadTs)
|
|
247
|
+
if (options?.threadTs) {
|
|
248
|
+
postBase.thread_ts = options.threadTs;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Approval prompts carry their action buttons in `blocks`. When Slack
|
|
252
|
+
// rejects the payload, the block-free retry re-attaches the plain-text
|
|
253
|
+
// reply instructions so the recipient can still act by text reply; an
|
|
254
|
+
// approval without usable instructions is never retried bare.
|
|
255
|
+
const approvalFallbackText = options?.approval
|
|
256
|
+
? buildApprovalFallbackText(text, options.approval)
|
|
257
|
+
: undefined;
|
|
258
|
+
const fallbackOptions = {
|
|
259
|
+
fallbackWithoutBlocks:
|
|
260
|
+
!options?.approval || approvalFallbackText !== undefined,
|
|
261
|
+
fallbackText: approvalFallbackText,
|
|
262
|
+
};
|
|
223
263
|
|
|
224
264
|
if (options?.ephemeral) {
|
|
225
|
-
if (!options.user)
|
|
265
|
+
if (!options.user) {
|
|
226
266
|
throw new Error("user is required for ephemeral messages");
|
|
267
|
+
}
|
|
227
268
|
return sendWithBlockFallback(
|
|
228
269
|
"chat.postEphemeral",
|
|
229
270
|
{ ...postBase, user: options.user },
|
|
230
271
|
blocks,
|
|
231
|
-
|
|
272
|
+
fallbackOptions,
|
|
232
273
|
);
|
|
233
274
|
}
|
|
234
275
|
|
|
235
|
-
// Approval prompts carry their action buttons in `blocks`; dropping them when
|
|
236
|
-
// Slack rejects the payload would post a card with no way to respond, so only
|
|
237
|
-
// non-approval posts fall back to a block-free retry.
|
|
238
276
|
const result = await sendWithBlockFallback(
|
|
239
277
|
"chat.postMessage",
|
|
240
278
|
postBase,
|
|
241
279
|
blocks,
|
|
242
|
-
|
|
243
|
-
fallbackWithoutBlocks: !options?.approval,
|
|
244
|
-
},
|
|
280
|
+
fallbackOptions,
|
|
245
281
|
);
|
|
246
282
|
log.info({ chatId, hasThreadTs: !!options?.threadTs }, "Slack message sent");
|
|
247
283
|
return result;
|
|
@@ -57,7 +57,7 @@ Hard invariants that the LLM cannot override:
|
|
|
57
57
|
|
|
58
58
|
**Post-generation enforcement** (`decision-engine.ts`):
|
|
59
59
|
|
|
60
|
-
- **Guardian question request-code enforcement** — `enforceGuardianRequestCode()` ensures request-code instructions (approve/reject or free-text answer) appear in all `guardian.question` notification copy, even when the LLM omits them.
|
|
60
|
+
- **Guardian question request-code enforcement** — `enforceGuardianRequestCode()` ensures request-code instructions (approve/reject or free-text answer) appear in all `guardian.question` notification copy, even when the LLM omits them. Exception: for approval-mode questions the Slack adapter renders an interactive card with Approve/Reject buttons, so Slack copy is instead **stripped** of request-code instructions and bare code mentions (`stripGuardianRequestCodeInstructions()`).
|
|
61
61
|
- **Access-request instruction enforcement** — `enforceAccessRequestInstructions()` validates that `ingress.access_request` copy contains: (1) the request-code approve/reject directive, (2) the exact "open invite flow" phrase. If any required element is missing, the full deterministic contract text is appended. This prevents model-generated copy from dropping security-critical action directives.
|
|
62
62
|
|
|
63
63
|
**Pre-send gate checks** (`deterministic-checks.ts`) — these all depend on the decision, so they run here, after it:
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* using native Card blocks for approval notifications.
|
|
4
4
|
*
|
|
5
5
|
* Approval notifications (access requests, tool approvals) render as a
|
|
6
|
-
* single Slack Card block with Approve/Reject action buttons
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* text sections.
|
|
6
|
+
* single Slack Card block with Approve/Reject action buttons. Text that
|
|
7
|
+
* exceeds the card body's 200-character cap continues in a companion
|
|
8
|
+
* section block below the card. Non-approval notifications use standard
|
|
9
|
+
* Block Kit text sections.
|
|
10
10
|
*
|
|
11
11
|
* Card block reference:
|
|
12
12
|
* https://docs.slack.dev/reference/block-kit/blocks/card-block
|
|
@@ -204,11 +204,40 @@ function buildAccessRequestCardBlocks(
|
|
|
204
204
|
// Tool approval card
|
|
205
205
|
// ---------------------------------------------------------------------------
|
|
206
206
|
|
|
207
|
+
/** Slack caps a card block's `body` text at 200 characters. */
|
|
208
|
+
const CARD_BODY_MAX_LENGTH = 200;
|
|
209
|
+
|
|
210
|
+
/** Marker signalling the card body continues in the section below. */
|
|
211
|
+
const CARD_BODY_CONTINUATION_MARKER = " ↓";
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Split message text so the head fits Slack's card body cap (marker
|
|
215
|
+
* included) and the tail continues in a companion section below the card.
|
|
216
|
+
* Splits on the last whitespace inside the budget when there is one, so
|
|
217
|
+
* neither piece cuts mid-word. Text within the cap needs no split.
|
|
218
|
+
*/
|
|
219
|
+
function splitAtCardBodyLimit(text: string): { head: string; tail?: string } {
|
|
220
|
+
if (text.length <= CARD_BODY_MAX_LENGTH) {
|
|
221
|
+
return { head: text };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const budget = CARD_BODY_MAX_LENGTH - CARD_BODY_CONTINUATION_MARKER.length;
|
|
225
|
+
const window = text.slice(0, budget + 1);
|
|
226
|
+
const lastWhitespace = window.search(/\s\S*$/);
|
|
227
|
+
const cut = lastWhitespace > 0 ? lastWhitespace : budget;
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
head: text.slice(0, cut).trimEnd() + CARD_BODY_CONTINUATION_MARKER,
|
|
231
|
+
tail: text.slice(cut).trimStart(),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
207
235
|
/**
|
|
208
236
|
* Build Slack blocks for a tool approval notification using a native Card block.
|
|
209
237
|
*
|
|
210
238
|
* Layout:
|
|
211
239
|
* Card — title + subtitle (tool + requester) + body (notification text) + actions
|
|
240
|
+
* Section — continuation of body text exceeding the card's 200-char cap
|
|
212
241
|
*/
|
|
213
242
|
function buildToolApprovalCardBlocks(
|
|
214
243
|
payload: ChannelDeliveryPayload,
|
|
@@ -227,28 +256,25 @@ function buildToolApprovalCardBlocks(
|
|
|
227
256
|
subtitle = truncate(toolName, 150);
|
|
228
257
|
}
|
|
229
258
|
|
|
230
|
-
const
|
|
259
|
+
const { head, tail } = splitAtCardBodyLimit(messageText);
|
|
231
260
|
const card: CardBlock = {
|
|
232
261
|
type: "card",
|
|
233
262
|
title: {
|
|
234
263
|
type: "mrkdwn",
|
|
235
264
|
text: details ? "Tool Approval" : "Approval Request",
|
|
236
265
|
},
|
|
237
|
-
body: {
|
|
238
|
-
type: "mrkdwn",
|
|
239
|
-
text: needsOverflow ? truncate(messageText, 197) + " ↓" : messageText,
|
|
240
|
-
},
|
|
266
|
+
body: { type: "mrkdwn", text: head },
|
|
241
267
|
actions: buildCardActions(approval),
|
|
242
268
|
};
|
|
243
269
|
if (subtitle) card.subtitle = { type: "mrkdwn", text: subtitle };
|
|
244
270
|
blocks.push(card);
|
|
245
271
|
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
if (
|
|
272
|
+
// The companion section carries only the remainder — repeating the full
|
|
273
|
+
// text would render the same message twice (once in the card, once below).
|
|
274
|
+
if (tail) {
|
|
249
275
|
blocks.push({
|
|
250
276
|
type: "section",
|
|
251
|
-
text: { type: "mrkdwn", text: truncate(
|
|
277
|
+
text: { type: "mrkdwn", text: truncate(`… ${tail}`, 3000) },
|
|
252
278
|
});
|
|
253
279
|
}
|
|
254
280
|
|
|
@@ -305,9 +331,14 @@ export class SlackAdapter implements ChannelAdapter {
|
|
|
305
331
|
const messageText = resolveMessageText(payload);
|
|
306
332
|
|
|
307
333
|
try {
|
|
334
|
+
// `approval` rides along with the prebuilt card blocks so the send
|
|
335
|
+
// layer treats a rejected Block Kit payload as an approval prompt:
|
|
336
|
+
// its block-free retry re-attaches `plainTextFallback` reply
|
|
337
|
+
// instructions instead of posting text with no way to respond.
|
|
308
338
|
const result = payload.approvalContext
|
|
309
339
|
? await sendSlackReply(chatId, messageText, {
|
|
310
340
|
blocks: buildApprovalNotificationBlocks(payload, messageText),
|
|
341
|
+
approval: payload.approvalContext,
|
|
311
342
|
})
|
|
312
343
|
: await sendSlackReply(chatId, messageText, { useBlocks: true });
|
|
313
344
|
|
|
@@ -28,10 +28,7 @@ import {
|
|
|
28
28
|
updateDeliveryStatus,
|
|
29
29
|
} from "./deliveries-store.js";
|
|
30
30
|
import { resolveDestinations } from "./destination-resolver.js";
|
|
31
|
-
import {
|
|
32
|
-
parseGuardianQuestionPayload,
|
|
33
|
-
resolveGuardianInstructionModeFromPayload,
|
|
34
|
-
} from "./guardian-question-mode.js";
|
|
31
|
+
import { parseInteractiveApprovalPayload } from "./guardian-question-mode.js";
|
|
35
32
|
import { nonEmpty } from "./notification-utils.js";
|
|
36
33
|
import type { NotificationSignal } from "./signal.js";
|
|
37
34
|
import type {
|
|
@@ -59,13 +56,17 @@ function resolveApprovalContext(
|
|
|
59
56
|
signal: NotificationSignal,
|
|
60
57
|
): ApprovalUIMetadata | undefined {
|
|
61
58
|
const payload = signal.contextPayload;
|
|
62
|
-
if (!payload)
|
|
59
|
+
if (!payload) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
63
62
|
|
|
64
63
|
if (signal.sourceEventName === "ingress.access_request") {
|
|
65
64
|
const requestId = nonEmpty(
|
|
66
65
|
typeof payload.requestId === "string" ? payload.requestId : undefined,
|
|
67
66
|
);
|
|
68
|
-
if (!requestId)
|
|
67
|
+
if (!requestId) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
69
70
|
return {
|
|
70
71
|
requestId,
|
|
71
72
|
actions: APPROVAL_ACTIONS,
|
|
@@ -74,12 +75,11 @@ function resolveApprovalContext(
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
if (signal.sourceEventName === "guardian.question") {
|
|
77
|
-
const parsed =
|
|
78
|
-
if (!parsed)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const requestId =
|
|
82
|
-
if (!requestId) return undefined;
|
|
78
|
+
const parsed = parseInteractiveApprovalPayload(payload);
|
|
79
|
+
if (!parsed) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const requestId = parsed.requestId;
|
|
83
83
|
|
|
84
84
|
// Extract tool context so channel adapters can render structured
|
|
85
85
|
// approval cards without re-parsing contextPayload.
|
|
@@ -184,8 +184,12 @@ export class NotificationBroadcaster {
|
|
|
184
184
|
// event fires immediately, before slower channel sends (e.g. Telegram 30s
|
|
185
185
|
// timeout) can delay it past the macOS deep-link retry window.
|
|
186
186
|
const orderedChannels = [...decision.selectedChannels].sort((a, b) => {
|
|
187
|
-
if (a === "vellum")
|
|
188
|
-
|
|
187
|
+
if (a === "vellum") {
|
|
188
|
+
return -1;
|
|
189
|
+
}
|
|
190
|
+
if (b === "vellum") {
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
189
193
|
return 0;
|
|
190
194
|
});
|
|
191
195
|
|
|
@@ -552,7 +556,9 @@ export class NotificationBroadcaster {
|
|
|
552
556
|
function resolveSourceConversationId(
|
|
553
557
|
sourceContextId: string | undefined,
|
|
554
558
|
): string | undefined {
|
|
555
|
-
if (!sourceContextId)
|
|
559
|
+
if (!sourceContextId) {
|
|
560
|
+
return undefined;
|
|
561
|
+
}
|
|
556
562
|
try {
|
|
557
563
|
return getConversation(sourceContextId) ? sourceContextId : undefined;
|
|
558
564
|
} catch {
|
|
@@ -47,8 +47,10 @@ import { createDecision } from "./decisions-store.js";
|
|
|
47
47
|
import {
|
|
48
48
|
buildGuardianRequestCodeInstruction,
|
|
49
49
|
hasGuardianRequestCodeInstruction,
|
|
50
|
+
parseInteractiveApprovalPayload,
|
|
50
51
|
resolveGuardianQuestionInstructionMode,
|
|
51
52
|
stripConflictingGuardianRequestInstructions,
|
|
53
|
+
stripGuardianRequestCodeInstructions,
|
|
52
54
|
} from "./guardian-question-mode.js";
|
|
53
55
|
import { nonEmpty, readPayloadString } from "./notification-utils.js";
|
|
54
56
|
import { getPreferenceSummary } from "./preference-summary.js";
|
|
@@ -143,6 +145,7 @@ function buildSystemPrompt(
|
|
|
143
145
|
` - Avoid meta-send phrasing (e.g. "I'd like to send a notification", "May I go ahead with that?"). Write the recipient-facing message directly.`,
|
|
144
146
|
` - Avoid intermediary-instruction phrasing like "consider telling the guardian", "ask the recipient to", or "the assistant should remind them". Rewrite it as final copy the recipient can act on directly.`,
|
|
145
147
|
` - For telegram: 1-2 concise sentences.`,
|
|
148
|
+
` - For slack approval requests: the message renders inside an interactive card with Approve/Reject buttons. Describe only what needs approval — never include approval/reference codes, code-reply instructions, or directions to respond in another app.`,
|
|
146
149
|
`- \`conversationSeedMessage\` is the opening message in the internal notification conversation and also the expanded detail shown in the home feed. It should be richer and more contextual than the popup body.`,
|
|
147
150
|
` - For vellum (desktop): use structured markdown for readability. Break content into bullet points, numbered lists, or short sections with **bold** labels. Avoid long unbroken paragraphs — scan-friendly formatting is preferred.`,
|
|
148
151
|
` - Never dump raw JSON. Include only human-readable context.`,
|
|
@@ -382,11 +385,19 @@ function validateDecisionOutput(
|
|
|
382
385
|
availableChannels: NotificationChannel[],
|
|
383
386
|
candidateSet?: ConversationCandidateSet,
|
|
384
387
|
): NotificationDecision | null {
|
|
385
|
-
if (typeof input.shouldNotify !== "boolean")
|
|
386
|
-
|
|
387
|
-
|
|
388
|
+
if (typeof input.shouldNotify !== "boolean") {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
if (typeof input.reasoningSummary !== "string") {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
if (typeof input.dedupeKey !== "string") {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
388
397
|
|
|
389
|
-
if (!Array.isArray(input.selectedChannels))
|
|
398
|
+
if (!Array.isArray(input.selectedChannels)) {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
390
401
|
const validatedChannels = (input.selectedChannels as unknown[]).filter(
|
|
391
402
|
(ch): ch is NotificationChannel =>
|
|
392
403
|
typeof ch === "string" &&
|
|
@@ -482,7 +493,9 @@ export function validateConversationActions(
|
|
|
482
493
|
): Partial<Record<NotificationChannel, ConversationAction>> {
|
|
483
494
|
const result: Partial<Record<NotificationChannel, ConversationAction>> = {};
|
|
484
495
|
|
|
485
|
-
if (!raw || typeof raw !== "object")
|
|
496
|
+
if (!raw || typeof raw !== "object") {
|
|
497
|
+
return result;
|
|
498
|
+
}
|
|
486
499
|
|
|
487
500
|
const actionsObj = raw as Record<string, unknown>;
|
|
488
501
|
const channelSet = new Set(validChannels);
|
|
@@ -502,8 +515,12 @@ export function validateConversationActions(
|
|
|
502
515
|
}
|
|
503
516
|
|
|
504
517
|
for (const [ch, actionRaw] of Object.entries(actionsObj)) {
|
|
505
|
-
if (!channelSet.has(ch as NotificationChannel))
|
|
506
|
-
|
|
518
|
+
if (!channelSet.has(ch as NotificationChannel)) {
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (!actionRaw || typeof actionRaw !== "object") {
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
507
524
|
|
|
508
525
|
const channel = ch as NotificationChannel;
|
|
509
526
|
const action = actionRaw as Record<string, unknown>;
|
|
@@ -558,8 +575,9 @@ function ensureGuardianRequestCodeInCopy(
|
|
|
558
575
|
requestCode,
|
|
559
576
|
mode,
|
|
560
577
|
);
|
|
561
|
-
if (hasGuardianRequestCodeInstruction(sanitized, requestCode, mode))
|
|
578
|
+
if (hasGuardianRequestCodeInstruction(sanitized, requestCode, mode)) {
|
|
562
579
|
return sanitized;
|
|
580
|
+
}
|
|
563
581
|
return sanitized.length > 0
|
|
564
582
|
? `${sanitized}\n\n${instruction}`
|
|
565
583
|
: instruction;
|
|
@@ -577,36 +595,79 @@ function ensureGuardianRequestCodeInCopy(
|
|
|
577
595
|
};
|
|
578
596
|
}
|
|
579
597
|
|
|
598
|
+
/**
|
|
599
|
+
* Remove request-code reply instructions from copy for a channel whose
|
|
600
|
+
* approval UI makes them redundant. Instruction-only fields keep their
|
|
601
|
+
* original text rather than becoming empty — downstream treats an empty
|
|
602
|
+
* body as missing copy.
|
|
603
|
+
*/
|
|
604
|
+
function stripGuardianRequestCodeInCopy(
|
|
605
|
+
copy: RenderedChannelCopy,
|
|
606
|
+
requestCode: string,
|
|
607
|
+
): RenderedChannelCopy {
|
|
608
|
+
const strip = (text: string): string => {
|
|
609
|
+
const stripped = stripGuardianRequestCodeInstructions(text, requestCode);
|
|
610
|
+
return stripped.length > 0 ? stripped : text;
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
return {
|
|
614
|
+
...copy,
|
|
615
|
+
body: strip(copy.body),
|
|
616
|
+
deliveryText: copy.deliveryText
|
|
617
|
+
? strip(copy.deliveryText)
|
|
618
|
+
: copy.deliveryText,
|
|
619
|
+
conversationSeedMessage: copy.conversationSeedMessage
|
|
620
|
+
? strip(copy.conversationSeedMessage)
|
|
621
|
+
: copy.conversationSeedMessage,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
580
625
|
/**
|
|
581
626
|
* Guardian questions that share a conversation require explicit request-code
|
|
582
627
|
* targeting. Enforce request-code instructions in rendered copy so guardians
|
|
583
628
|
* can always disambiguate replies even when model copy omits them.
|
|
629
|
+
*
|
|
630
|
+
* Slack is the exception for approval-mode questions: the Slack adapter
|
|
631
|
+
* renders those as an interactive card with Approve/Reject buttons (see
|
|
632
|
+
* `resolveApprovalContext` in broadcaster.ts, gated on the same
|
|
633
|
+
* `parseInteractiveApprovalPayload`), so code-reply instructions are
|
|
634
|
+
* redundant noise there and are stripped instead of enforced in.
|
|
584
635
|
*/
|
|
585
636
|
function enforceGuardianRequestCode(
|
|
586
637
|
decision: NotificationDecision,
|
|
587
638
|
signal: NotificationSignal,
|
|
588
639
|
): NotificationDecision {
|
|
589
|
-
if (signal.sourceEventName !== "guardian.question")
|
|
640
|
+
if (signal.sourceEventName !== "guardian.question") {
|
|
641
|
+
return decision;
|
|
642
|
+
}
|
|
590
643
|
const rawCode = signal.contextPayload.requestCode;
|
|
591
|
-
if (typeof rawCode !== "string" || rawCode.trim().length === 0)
|
|
644
|
+
if (typeof rawCode !== "string" || rawCode.trim().length === 0) {
|
|
592
645
|
return decision;
|
|
646
|
+
}
|
|
593
647
|
|
|
594
648
|
const requestCode = rawCode.trim().toUpperCase();
|
|
595
649
|
const modeResolution = resolveGuardianQuestionInstructionMode(
|
|
596
650
|
signal.contextPayload,
|
|
597
651
|
);
|
|
652
|
+
const slackRendersApprovalButtons =
|
|
653
|
+
parseInteractiveApprovalPayload(signal.contextPayload) != null;
|
|
598
654
|
const nextCopy: Partial<Record<NotificationChannel, RenderedChannelCopy>> = {
|
|
599
655
|
...decision.renderedCopy,
|
|
600
656
|
};
|
|
601
657
|
|
|
602
658
|
for (const channel of Object.keys(nextCopy) as NotificationChannel[]) {
|
|
603
659
|
const copy = nextCopy[channel];
|
|
604
|
-
if (!copy)
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
660
|
+
if (!copy) {
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
nextCopy[channel] =
|
|
664
|
+
channel === "slack" && slackRendersApprovalButtons
|
|
665
|
+
? stripGuardianRequestCodeInCopy(copy, requestCode)
|
|
666
|
+
: ensureGuardianRequestCodeInCopy(
|
|
667
|
+
copy,
|
|
668
|
+
requestCode,
|
|
669
|
+
modeResolution.mode,
|
|
670
|
+
);
|
|
610
671
|
}
|
|
611
672
|
|
|
612
673
|
return {
|
|
@@ -624,14 +685,18 @@ function ensureSeedContentBlocks(
|
|
|
624
685
|
decision: NotificationDecision,
|
|
625
686
|
blocks: unknown[] | null | undefined,
|
|
626
687
|
): NotificationDecision {
|
|
627
|
-
if (!blocks)
|
|
688
|
+
if (!blocks) {
|
|
689
|
+
return decision;
|
|
690
|
+
}
|
|
628
691
|
|
|
629
692
|
const nextCopy: Partial<Record<NotificationChannel, RenderedChannelCopy>> = {
|
|
630
693
|
...decision.renderedCopy,
|
|
631
694
|
};
|
|
632
695
|
for (const channel of Object.keys(nextCopy) as NotificationChannel[]) {
|
|
633
696
|
const copy = nextCopy[channel];
|
|
634
|
-
if (!copy)
|
|
697
|
+
if (!copy) {
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
635
700
|
if (!copy.seedContentBlocks) {
|
|
636
701
|
nextCopy[channel] = { ...copy, seedContentBlocks: blocks };
|
|
637
702
|
}
|
|
@@ -651,7 +716,9 @@ function enforceToolApprovalSeedBlocks(
|
|
|
651
716
|
decision: NotificationDecision,
|
|
652
717
|
signal: NotificationSignal,
|
|
653
718
|
): NotificationDecision {
|
|
654
|
-
if (signal.sourceEventName !== "guardian.question")
|
|
719
|
+
if (signal.sourceEventName !== "guardian.question") {
|
|
720
|
+
return decision;
|
|
721
|
+
}
|
|
655
722
|
return ensureSeedContentBlocks(
|
|
656
723
|
decision,
|
|
657
724
|
buildToolApprovalSeedContentBlocks(signal.contextPayload),
|
|
@@ -675,7 +742,9 @@ function enforceAccessRequestInstructions(
|
|
|
675
742
|
decision: NotificationDecision,
|
|
676
743
|
signal: NotificationSignal,
|
|
677
744
|
): NotificationDecision {
|
|
678
|
-
if (signal.sourceEventName !== "ingress.access_request")
|
|
745
|
+
if (signal.sourceEventName !== "ingress.access_request") {
|
|
746
|
+
return decision;
|
|
747
|
+
}
|
|
679
748
|
|
|
680
749
|
const rawCode = signal.contextPayload.requestCode;
|
|
681
750
|
const hasRequestCode =
|
|
@@ -691,7 +760,9 @@ function enforceAccessRequestInstructions(
|
|
|
691
760
|
|
|
692
761
|
for (const channel of Object.keys(nextCopy) as NotificationChannel[]) {
|
|
693
762
|
const copy = nextCopy[channel];
|
|
694
|
-
if (!copy)
|
|
763
|
+
if (!copy) {
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
695
766
|
nextCopy[channel] = ensureAccessRequestInstructionsInCopy(
|
|
696
767
|
copy,
|
|
697
768
|
requestCode,
|
|
@@ -704,7 +775,9 @@ function enforceAccessRequestInstructions(
|
|
|
704
775
|
|
|
705
776
|
for (const channel of Object.keys(nextCopy) as NotificationChannel[]) {
|
|
706
777
|
const copy = nextCopy[channel];
|
|
707
|
-
if (!copy)
|
|
778
|
+
if (!copy) {
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
708
781
|
nextCopy[channel] = ensureInviteFlowDirectiveInCopy(
|
|
709
782
|
copy,
|
|
710
783
|
inviteDirective,
|
|
@@ -755,7 +828,9 @@ function ensureInviteFlowDirectiveInCopy(
|
|
|
755
828
|
): RenderedChannelCopy {
|
|
756
829
|
const ensureText = (text: string | undefined): string => {
|
|
757
830
|
const base = typeof text === "string" ? text.trim() : "";
|
|
758
|
-
if (hasInviteFlowDirective(base))
|
|
831
|
+
if (hasInviteFlowDirective(base)) {
|
|
832
|
+
return base;
|
|
833
|
+
}
|
|
759
834
|
return base.length > 0 ? `${base}\n\n${inviteDirective}` : inviteDirective;
|
|
760
835
|
};
|
|
761
836
|
|
|
@@ -1138,15 +1213,21 @@ export function enforceRoutingIntent(
|
|
|
1138
1213
|
// Preserve the decision's selected channels first, then add connected
|
|
1139
1214
|
// channels until we reach two channels total.
|
|
1140
1215
|
for (const ch of selectedConnected) {
|
|
1141
|
-
if (seen.has(ch))
|
|
1216
|
+
if (seen.has(ch)) {
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1142
1219
|
expanded.push(ch);
|
|
1143
1220
|
seen.add(ch);
|
|
1144
1221
|
}
|
|
1145
1222
|
for (const ch of connectedChannels) {
|
|
1146
|
-
if (seen.has(ch))
|
|
1223
|
+
if (seen.has(ch)) {
|
|
1224
|
+
continue;
|
|
1225
|
+
}
|
|
1147
1226
|
expanded.push(ch);
|
|
1148
1227
|
seen.add(ch);
|
|
1149
|
-
if (expanded.length >= 2)
|
|
1228
|
+
if (expanded.length >= 2) {
|
|
1229
|
+
break;
|
|
1230
|
+
}
|
|
1150
1231
|
}
|
|
1151
1232
|
|
|
1152
1233
|
const enforced = { ...decision };
|
|
@@ -1184,15 +1265,20 @@ export function enforceGuardianCallConversationAffinity(
|
|
|
1184
1265
|
decision: NotificationDecision,
|
|
1185
1266
|
signal: NotificationSignal,
|
|
1186
1267
|
): NotificationDecision {
|
|
1187
|
-
if (signal.sourceEventName !== "guardian.question")
|
|
1268
|
+
if (signal.sourceEventName !== "guardian.question") {
|
|
1269
|
+
return decision;
|
|
1270
|
+
}
|
|
1188
1271
|
|
|
1189
1272
|
const callSessionId = signal.contextPayload?.callSessionId;
|
|
1190
|
-
if (typeof callSessionId !== "string" || callSessionId.trim().length === 0)
|
|
1273
|
+
if (typeof callSessionId !== "string" || callSessionId.trim().length === 0) {
|
|
1191
1274
|
return decision;
|
|
1275
|
+
}
|
|
1192
1276
|
|
|
1193
1277
|
// If an affinity hint already exists for vellum, the second+ dispatch
|
|
1194
1278
|
// will be handled by enforceConversationAffinity — nothing to do here.
|
|
1195
|
-
if (signal.conversationAffinityHint?.vellum)
|
|
1279
|
+
if (signal.conversationAffinityHint?.vellum) {
|
|
1280
|
+
return decision;
|
|
1281
|
+
}
|
|
1196
1282
|
|
|
1197
1283
|
const enforced = { ...decision };
|
|
1198
1284
|
const conversationActions: Partial<
|
|
@@ -1226,13 +1312,17 @@ function enforceConversationAffinity(
|
|
|
1226
1312
|
decision: NotificationDecision,
|
|
1227
1313
|
affinityHint: Partial<Record<string, string>> | undefined,
|
|
1228
1314
|
): NotificationDecision {
|
|
1229
|
-
if (!affinityHint)
|
|
1315
|
+
if (!affinityHint) {
|
|
1316
|
+
return decision;
|
|
1317
|
+
}
|
|
1230
1318
|
|
|
1231
1319
|
const entries = Object.entries(affinityHint).filter(
|
|
1232
1320
|
([, conversationId]) =>
|
|
1233
1321
|
typeof conversationId === "string" && conversationId.length > 0,
|
|
1234
1322
|
);
|
|
1235
|
-
if (entries.length === 0)
|
|
1323
|
+
if (entries.length === 0) {
|
|
1324
|
+
return decision;
|
|
1325
|
+
}
|
|
1236
1326
|
|
|
1237
1327
|
const enforced = { ...decision };
|
|
1238
1328
|
const conversationActions: Partial<
|