@vellumai/assistant 0.3.3 → 0.3.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.
Files changed (163) hide show
  1. package/Dockerfile +2 -0
  2. package/README.md +45 -18
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +13 -0
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
  6. package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
  7. package/src/__tests__/approval-message-composer.test.ts +253 -0
  8. package/src/__tests__/call-domain.test.ts +12 -2
  9. package/src/__tests__/call-orchestrator.test.ts +391 -1
  10. package/src/__tests__/call-routes-http.test.ts +27 -2
  11. package/src/__tests__/channel-approval-routes.test.ts +397 -135
  12. package/src/__tests__/channel-approvals.test.ts +99 -3
  13. package/src/__tests__/channel-delivery-store.test.ts +30 -4
  14. package/src/__tests__/channel-guardian.test.ts +261 -22
  15. package/src/__tests__/channel-readiness-service.test.ts +257 -0
  16. package/src/__tests__/config-schema.test.ts +2 -1
  17. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  18. package/src/__tests__/daemon-lifecycle.test.ts +636 -0
  19. package/src/__tests__/dictation-mode-detection.test.ts +63 -0
  20. package/src/__tests__/entity-search.test.ts +615 -0
  21. package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
  22. package/src/__tests__/handlers-twilio-config.test.ts +480 -0
  23. package/src/__tests__/ipc-snapshot.test.ts +63 -0
  24. package/src/__tests__/messaging-send-tool.test.ts +65 -0
  25. package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
  26. package/src/__tests__/run-orchestrator.test.ts +22 -0
  27. package/src/__tests__/secret-scanner.test.ts +223 -0
  28. package/src/__tests__/session-runtime-assembly.test.ts +85 -1
  29. package/src/__tests__/shell-parser-property.test.ts +357 -2
  30. package/src/__tests__/sms-messaging-provider.test.ts +125 -0
  31. package/src/__tests__/system-prompt.test.ts +25 -1
  32. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  33. package/src/__tests__/twilio-routes.test.ts +39 -3
  34. package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
  35. package/src/__tests__/user-reference.test.ts +68 -0
  36. package/src/__tests__/web-search.test.ts +1 -1
  37. package/src/__tests__/work-item-output.test.ts +110 -0
  38. package/src/calls/call-domain.ts +8 -5
  39. package/src/calls/call-orchestrator.ts +85 -22
  40. package/src/calls/twilio-config.ts +17 -11
  41. package/src/calls/twilio-rest.ts +276 -0
  42. package/src/calls/twilio-routes.ts +39 -1
  43. package/src/cli/map.ts +6 -0
  44. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  45. package/src/commands/cc-command-registry.ts +14 -1
  46. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  47. package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
  48. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
  49. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
  50. package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
  51. package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
  52. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
  53. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
  54. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
  55. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
  56. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
  57. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
  58. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
  59. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
  60. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
  61. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
  62. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
  63. package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
  64. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
  65. package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
  66. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
  67. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
  68. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
  69. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
  70. package/src/config/bundled-skills/messaging/SKILL.md +24 -5
  71. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  72. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  73. package/src/config/bundled-skills/twitter/SKILL.md +19 -3
  74. package/src/config/defaults.ts +2 -1
  75. package/src/config/schema.ts +9 -3
  76. package/src/config/skills.ts +5 -32
  77. package/src/config/system-prompt.ts +40 -0
  78. package/src/config/templates/IDENTITY.md +2 -2
  79. package/src/config/user-reference.ts +29 -0
  80. package/src/config/vellum-skills/catalog.json +58 -0
  81. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
  82. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
  83. package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
  84. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  85. package/src/config/vellum-skills/twilio-setup/SKILL.md +76 -6
  86. package/src/daemon/auth-manager.ts +103 -0
  87. package/src/daemon/computer-use-session.ts +8 -1
  88. package/src/daemon/config-watcher.ts +253 -0
  89. package/src/daemon/handlers/config.ts +819 -22
  90. package/src/daemon/handlers/dictation.ts +182 -0
  91. package/src/daemon/handlers/identity.ts +14 -23
  92. package/src/daemon/handlers/index.ts +2 -0
  93. package/src/daemon/handlers/sessions.ts +2 -0
  94. package/src/daemon/handlers/shared.ts +3 -0
  95. package/src/daemon/handlers/skills.ts +6 -7
  96. package/src/daemon/handlers/work-items.ts +15 -7
  97. package/src/daemon/ipc-contract-inventory.json +10 -0
  98. package/src/daemon/ipc-contract.ts +114 -4
  99. package/src/daemon/ipc-handler.ts +87 -0
  100. package/src/daemon/lifecycle.ts +18 -4
  101. package/src/daemon/ride-shotgun-handler.ts +11 -1
  102. package/src/daemon/server.ts +111 -504
  103. package/src/daemon/session-agent-loop.ts +10 -15
  104. package/src/daemon/session-runtime-assembly.ts +115 -44
  105. package/src/daemon/session-tool-setup.ts +2 -0
  106. package/src/daemon/session.ts +19 -2
  107. package/src/inbound/public-ingress-urls.ts +3 -3
  108. package/src/memory/channel-guardian-store.ts +2 -1
  109. package/src/memory/db-connection.ts +28 -0
  110. package/src/memory/db-init.ts +1163 -0
  111. package/src/memory/db.ts +2 -2007
  112. package/src/memory/embedding-backend.ts +79 -11
  113. package/src/memory/indexer.ts +2 -0
  114. package/src/memory/job-handlers/media-processing.ts +100 -0
  115. package/src/memory/job-utils.ts +64 -4
  116. package/src/memory/jobs-store.ts +2 -1
  117. package/src/memory/jobs-worker.ts +11 -1
  118. package/src/memory/media-store.ts +759 -0
  119. package/src/memory/recall-cache.ts +107 -0
  120. package/src/memory/retriever.ts +36 -2
  121. package/src/memory/schema-migration.ts +984 -0
  122. package/src/memory/schema.ts +99 -0
  123. package/src/memory/search/entity.ts +208 -25
  124. package/src/memory/search/ranking.ts +6 -1
  125. package/src/memory/search/types.ts +26 -0
  126. package/src/messaging/provider-types.ts +2 -0
  127. package/src/messaging/providers/sms/adapter.ts +204 -0
  128. package/src/messaging/providers/sms/client.ts +93 -0
  129. package/src/messaging/providers/sms/types.ts +7 -0
  130. package/src/permissions/checker.ts +16 -2
  131. package/src/permissions/prompter.ts +14 -3
  132. package/src/permissions/trust-store.ts +7 -0
  133. package/src/runtime/approval-message-composer.ts +143 -0
  134. package/src/runtime/channel-approvals.ts +29 -7
  135. package/src/runtime/channel-guardian-service.ts +44 -18
  136. package/src/runtime/channel-readiness-service.ts +292 -0
  137. package/src/runtime/channel-readiness-types.ts +29 -0
  138. package/src/runtime/gateway-client.ts +2 -1
  139. package/src/runtime/http-server.ts +65 -28
  140. package/src/runtime/http-types.ts +3 -0
  141. package/src/runtime/routes/call-routes.ts +2 -1
  142. package/src/runtime/routes/channel-routes.ts +237 -103
  143. package/src/runtime/routes/run-routes.ts +7 -1
  144. package/src/runtime/run-orchestrator.ts +43 -3
  145. package/src/security/secret-scanner.ts +218 -0
  146. package/src/skills/frontmatter.ts +63 -0
  147. package/src/skills/slash-commands.ts +23 -0
  148. package/src/skills/vellum-catalog-remote.ts +107 -0
  149. package/src/tools/assets/materialize.ts +2 -2
  150. package/src/tools/browser/auto-navigate.ts +132 -24
  151. package/src/tools/browser/browser-manager.ts +67 -61
  152. package/src/tools/calls/call-start.ts +1 -0
  153. package/src/tools/claude-code/claude-code.ts +55 -3
  154. package/src/tools/credentials/vault.ts +1 -1
  155. package/src/tools/execution-target.ts +11 -1
  156. package/src/tools/executor.ts +10 -2
  157. package/src/tools/network/web-search.ts +1 -1
  158. package/src/tools/skills/vellum-catalog.ts +61 -156
  159. package/src/tools/terminal/parser.ts +21 -5
  160. package/src/tools/types.ts +2 -0
  161. package/src/twitter/router.ts +1 -1
  162. package/src/util/platform.ts +43 -1
  163. package/src/util/retry.ts +4 -4
@@ -44,6 +44,8 @@ import type {
44
44
  MessageProcessor,
45
45
  RuntimeAttachmentMetadata,
46
46
  } from '../http-types.js';
47
+ import type { GuardianRuntimeContext } from '../../daemon/session-runtime-assembly.js';
48
+ import { composeApprovalMessage } from '../approval-message-composer.js';
47
49
 
48
50
  const log = getLogger('runtime-http');
49
51
 
@@ -110,6 +112,19 @@ export interface GuardianContext {
110
112
  denialReason?: DenialReason;
111
113
  }
112
114
 
115
+ function toGuardianRuntimeContext(sourceChannel: string, ctx: GuardianContext): GuardianRuntimeContext {
116
+ return {
117
+ sourceChannel,
118
+ actorRole: ctx.actorRole,
119
+ guardianChatId: ctx.guardianChatId,
120
+ guardianExternalUserId: ctx.guardianExternalUserId,
121
+ requesterIdentifier: ctx.requesterIdentifier,
122
+ requesterExternalUserId: ctx.requesterExternalUserId,
123
+ requesterChatId: ctx.requesterChatId,
124
+ denialReason: ctx.denialReason,
125
+ };
126
+ }
127
+
113
128
  /** Guardian approval request expiry (30 minutes). */
114
129
  const GUARDIAN_APPROVAL_TTL_MS = 30 * 60 * 1000;
115
130
 
@@ -127,12 +142,21 @@ function effectivePromptText(
127
142
  return plainTextFallback;
128
143
  }
129
144
 
130
- // ---------------------------------------------------------------------------
131
- // Feature flag
132
- // ---------------------------------------------------------------------------
145
+ /**
146
+ * Build contextual deny guidance for guardian-gated auto-deny paths.
147
+ * This is passed through the confirmation pipeline so the assistant can
148
+ * produce a single, user-facing message with next steps.
149
+ */
150
+ function buildGuardianDenyContext(
151
+ toolName: string,
152
+ denialReason: DenialReason,
153
+ sourceChannel: string,
154
+ ): string {
155
+ if (denialReason === 'no_identity') {
156
+ return `Permission denied: ${composeApprovalMessage({ scenario: 'guardian_deny_no_identity', toolName, channel: sourceChannel })} Do not retry yet. Ask the user to message from a verifiable direct account/chat, and then retry after identity is available.`;
157
+ }
133
158
 
134
- export function isChannelApprovalsEnabled(): boolean {
135
- return process.env.CHANNEL_APPROVALS_ENABLED === 'true';
159
+ return `Permission denied: ${composeApprovalMessage({ scenario: 'guardian_deny_no_binding', toolName, channel: sourceChannel })} Do not retry yet. Offer to set up guardian verification. The setup flow will provide a verification token to send as /guardian_verify <token>.`;
136
160
  }
137
161
 
138
162
  // ---------------------------------------------------------------------------
@@ -169,13 +193,22 @@ export async function handleDeleteConversation(req: Request, assistantId: string
169
193
  return Response.json({ error: 'externalChatId is required' }, { status: 400 });
170
194
  }
171
195
 
172
- // Delete both legacy and scoped conversation key aliases to handle
173
- // migration scenarios where either or both keys may exist.
196
+ // Delete the assistant-scoped key unconditionally. The legacy key is
197
+ // canonical for the self assistant and must not be deleted from non-self
198
+ // routes, otherwise a non-self reset can accidentally reset self state.
174
199
  const legacyKey = `${sourceChannel}:${externalChatId}`;
175
200
  const scopedKey = `asst:${assistantId}:${sourceChannel}:${externalChatId}`;
176
- deleteConversationKey(legacyKey);
177
201
  deleteConversationKey(scopedKey);
178
- externalConversationStore.deleteBindingByChannelChat(sourceChannel, externalChatId);
202
+ if (assistantId === 'self') {
203
+ deleteConversationKey(legacyKey);
204
+ }
205
+ // external_conversation_bindings is currently assistant-agnostic
206
+ // (unique by sourceChannel + externalChatId). Restrict mutations to the
207
+ // canonical self-assistant route so multi-assistant legacy routes do not
208
+ // clobber each other's bindings.
209
+ if (assistantId === 'self') {
210
+ externalConversationStore.deleteBindingByChannelChat(sourceChannel, externalChatId);
211
+ }
179
212
 
180
213
  return Response.json({ ok: true });
181
214
  }
@@ -338,15 +371,19 @@ export async function handleChannelInbound(
338
371
  { sourceMessageId, assistantId },
339
372
  );
340
373
 
341
- // Upsert external conversation binding with sender metadata
342
- externalConversationStore.upsertBinding({
343
- conversationId: result.conversationId,
344
- sourceChannel,
345
- externalChatId,
346
- externalUserId: body.senderExternalUserId ?? null,
347
- displayName: body.senderName ?? null,
348
- username: body.senderUsername ?? null,
349
- });
374
+ // external_conversation_bindings is assistant-agnostic. Restrict writes to
375
+ // self so assistant-scoped legacy routes do not overwrite each other's
376
+ // channel binding metadata for the same chat.
377
+ if (assistantId === 'self') {
378
+ externalConversationStore.upsertBinding({
379
+ conversationId: result.conversationId,
380
+ sourceChannel,
381
+ externalChatId,
382
+ externalUserId: body.senderExternalUserId ?? null,
383
+ displayName: body.senderName ?? null,
384
+ username: body.senderUsername ?? null,
385
+ });
386
+ }
350
387
 
351
388
  const metadataHintsRaw = sourceMetadata?.hints;
352
389
  const metadataHints = Array.isArray(metadataHintsRaw)
@@ -374,16 +411,19 @@ export async function handleChannelInbound(
374
411
  token,
375
412
  body.senderExternalUserId,
376
413
  externalChatId,
414
+ body.senderUsername,
415
+ body.senderName,
377
416
  );
378
417
 
379
418
  const replyText = verifyResult.success
380
- ? 'Guardian verified successfully. Your identity is now linked to this bot.'
381
- : 'Verification failed. Please try again later.';
419
+ ? composeApprovalMessage({ scenario: 'guardian_verify_success' })
420
+ : verifyResult.reason;
382
421
 
383
422
  try {
384
423
  await deliverChannelReply(replyCallbackUrl, {
385
424
  chatId: externalChatId,
386
425
  text: replyText,
426
+ assistantId,
387
427
  }, bearerToken);
388
428
  } catch (err) {
389
429
  log.error({ err, externalChatId }, 'Failed to deliver guardian verification reply');
@@ -403,18 +443,26 @@ export async function handleChannelInbound(
403
443
  // When a guardian binding exists, non-guardian actors get stricter
404
444
  // side-effect controls and their approvals route to the guardian's chat.
405
445
  //
406
- // Guardian enforcement runs independently of CHANNEL_APPROVALS_ENABLED.
407
- // The approval flag only gates the interactive approval prompting UX;
408
- // actor-role resolution and fail-closed denial are always active.
409
- let guardianCtx: GuardianContext = { actorRole: 'guardian' };
446
+ // Guardian actor-role resolution always runs.
447
+ let guardianCtx: GuardianContext;
410
448
  if (body.senderExternalUserId) {
449
+ const requesterLabel = body.senderUsername
450
+ ? `@${body.senderUsername}`
451
+ : body.senderExternalUserId;
411
452
  const senderIsGuardian = isGuardian(assistantId, sourceChannel, body.senderExternalUserId);
412
- if (!senderIsGuardian) {
453
+ if (senderIsGuardian) {
454
+ const binding = getGuardianBinding(assistantId, sourceChannel);
455
+ guardianCtx = {
456
+ actorRole: 'guardian',
457
+ guardianChatId: binding?.guardianDeliveryChatId ?? externalChatId,
458
+ guardianExternalUserId: binding?.guardianExternalUserId ?? body.senderExternalUserId,
459
+ requesterIdentifier: requesterLabel,
460
+ requesterExternalUserId: body.senderExternalUserId,
461
+ requesterChatId: externalChatId,
462
+ };
463
+ } else {
413
464
  const binding = getGuardianBinding(assistantId, sourceChannel);
414
465
  if (binding) {
415
- const requesterLabel = body.senderUsername
416
- ? `@${body.senderUsername}`
417
- : body.senderExternalUserId;
418
466
  guardianCtx = {
419
467
  actorRole: 'non-guardian',
420
468
  guardianChatId: binding.guardianDeliveryChatId,
@@ -429,29 +477,28 @@ export async function handleChannelInbound(
429
477
  guardianCtx = {
430
478
  actorRole: 'unverified_channel',
431
479
  denialReason: 'no_binding',
480
+ requesterIdentifier: requesterLabel,
432
481
  requesterExternalUserId: body.senderExternalUserId,
433
482
  requesterChatId: externalChatId,
434
483
  };
435
484
  }
436
485
  }
437
486
  } else {
438
- // No sender identity available — fail-closed when guardian enforcement
439
- // is active for this channel. If a binding exists, unknown actors must
440
- // not be granted default guardian permissions.
441
- const binding = getGuardianBinding(assistantId, sourceChannel);
442
- if (binding) {
443
- guardianCtx = {
444
- actorRole: 'unverified_channel',
445
- denialReason: 'no_identity',
446
- requesterExternalUserId: undefined,
447
- requesterChatId: externalChatId,
448
- };
449
- }
487
+ // No sender identity available — treat as unverified and fail closed.
488
+ // Multi-actor channels must not grant default guardian permissions when
489
+ // the inbound actor cannot be identified.
490
+ guardianCtx = {
491
+ actorRole: 'unverified_channel',
492
+ denialReason: 'no_identity',
493
+ requesterIdentifier: body.senderUsername ? `@${body.senderUsername}` : undefined,
494
+ requesterExternalUserId: undefined,
495
+ requesterChatId: externalChatId,
496
+ };
450
497
  }
451
498
 
452
- // ── Approval interception (gated behind feature flag) ──
499
+ // ── Approval interception ──
500
+ // Keep this active whenever orchestrator + callback context are available.
453
501
  if (
454
- isChannelApprovalsEnabled() &&
455
502
  runOrchestrator &&
456
503
  replyCallbackUrl &&
457
504
  !result.duplicate
@@ -506,7 +553,9 @@ export async function handleChannelInbound(
506
553
  senderName: body.senderName,
507
554
  senderExternalUserId: body.senderExternalUserId,
508
555
  senderUsername: body.senderUsername,
556
+ guardianCtx,
509
557
  replyCallbackUrl,
558
+ assistantId,
510
559
  });
511
560
 
512
561
  const contentToCheck = content ?? '';
@@ -522,13 +571,15 @@ export async function handleChannelInbound(
522
571
  throw new IngressBlockedError(ingressCheck.userNotice!, ingressCheck.detectedTypes);
523
572
  }
524
573
 
525
- // When approval flow is enabled and we have an orchestrator, use the
526
- // orchestrator-backed path which properly intercepts confirmation_request
527
- // events and sends proactive approval prompts to the channel.
528
- const useApprovalPath =
529
- isChannelApprovalsEnabled() && runOrchestrator && replyCallbackUrl;
574
+ // Use the approval-aware orchestrator path whenever orchestration and a
575
+ // callback delivery target are available. This keeps approval handling
576
+ // consistent across all channels and avoids silent prompt timeouts.
577
+ const useApprovalPath = Boolean(
578
+ runOrchestrator &&
579
+ replyCallbackUrl,
580
+ );
530
581
 
531
- if (useApprovalPath) {
582
+ if (useApprovalPath && runOrchestrator && replyCallbackUrl) {
532
583
  processChannelMessageWithApprovals({
533
584
  orchestrator: runOrchestrator,
534
585
  conversationId: result.conversationId,
@@ -541,6 +592,8 @@ export async function handleChannelInbound(
541
592
  bearerToken,
542
593
  guardianCtx,
543
594
  assistantId,
595
+ metadataHints,
596
+ metadataUxBrief,
544
597
  });
545
598
  } else {
546
599
  // Fire-and-forget: process the message and deliver the reply in the background.
@@ -553,10 +606,12 @@ export async function handleChannelInbound(
553
606
  attachmentIds: hasAttachments ? attachmentIds : undefined,
554
607
  sourceChannel,
555
608
  externalChatId,
609
+ guardianCtx,
556
610
  metadataHints,
557
611
  metadataUxBrief,
558
612
  replyCallbackUrl,
559
613
  bearerToken,
614
+ assistantId,
560
615
  });
561
616
  }
562
617
  }
@@ -576,10 +631,12 @@ interface BackgroundProcessingParams {
576
631
  attachmentIds?: string[];
577
632
  sourceChannel: string;
578
633
  externalChatId: string;
634
+ guardianCtx: GuardianContext;
579
635
  metadataHints: string[];
580
636
  metadataUxBrief?: string;
581
637
  replyCallbackUrl?: string;
582
638
  bearerToken?: string;
639
+ assistantId?: string;
583
640
  }
584
641
 
585
642
  function processChannelMessageInBackground(params: BackgroundProcessingParams): void {
@@ -591,10 +648,12 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
591
648
  attachmentIds,
592
649
  sourceChannel,
593
650
  externalChatId,
651
+ guardianCtx,
594
652
  metadataHints,
595
653
  metadataUxBrief,
596
654
  replyCallbackUrl,
597
655
  bearerToken,
656
+ assistantId,
598
657
  } = params;
599
658
 
600
659
  (async () => {
@@ -609,6 +668,8 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
609
668
  hints: metadataHints.length > 0 ? metadataHints : undefined,
610
669
  uxBrief: metadataUxBrief,
611
670
  },
671
+ assistantId,
672
+ guardianContext: toGuardianRuntimeContext(sourceChannel, guardianCtx),
612
673
  },
613
674
  sourceChannel,
614
675
  );
@@ -616,7 +677,13 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
616
677
  channelDeliveryStore.markProcessed(eventId);
617
678
 
618
679
  if (replyCallbackUrl) {
619
- await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl, bearerToken);
680
+ await deliverReplyViaCallback(
681
+ conversationId,
682
+ externalChatId,
683
+ replyCallbackUrl,
684
+ bearerToken,
685
+ assistantId,
686
+ );
620
687
  }
621
688
  } catch (err) {
622
689
  log.error({ err, conversationId }, 'Background channel message processing failed');
@@ -665,6 +732,8 @@ interface ApprovalProcessingParams {
665
732
  bearerToken?: string;
666
733
  guardianCtx: GuardianContext;
667
734
  assistantId: string;
735
+ metadataHints: string[];
736
+ metadataUxBrief?: string;
668
737
  }
669
738
 
670
739
  /**
@@ -692,6 +761,8 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
692
761
  bearerToken,
693
762
  guardianCtx,
694
763
  assistantId,
764
+ metadataHints,
765
+ metadataUxBrief,
695
766
  } = params;
696
767
 
697
768
  const isNonGuardian = guardianCtx.actorRole === 'non-guardian';
@@ -706,6 +777,10 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
706
777
  {
707
778
  ...((isNonGuardian || isUnverifiedChannel) ? { forceStrictSideEffects: true } : {}),
708
779
  sourceChannel,
780
+ hints: metadataHints.length > 0 ? metadataHints : undefined,
781
+ uxBrief: metadataUxBrief,
782
+ assistantId,
783
+ guardianContext: toGuardianRuntimeContext(sourceChannel, guardianCtx),
709
784
  },
710
785
  );
711
786
 
@@ -714,6 +789,14 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
714
789
  const startTime = Date.now();
715
790
  const pollMaxWait = getEffectivePollMaxWait();
716
791
  let lastStatus = run.status;
792
+ // Track whether a post-decision delivery path is guaranteed for this
793
+ // run. Set to true only when the approval prompt is successfully
794
+ // delivered (guardian or standard path), meaning
795
+ // handleApprovalInterception will schedule schedulePostDecisionDelivery
796
+ // when a decision arrives. Auto-deny paths (unverified channel, prompt
797
+ // delivery failures) do NOT set this flag because no post-decision
798
+ // delivery is scheduled in those cases.
799
+ let hasPostDecisionDelivery = false;
717
800
 
718
801
  while (Date.now() - startTime < pollMaxWait) {
719
802
  await new Promise((resolve) => setTimeout(resolve, RUN_POLL_INTERVAL_MS));
@@ -726,18 +809,16 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
726
809
 
727
810
  if (isUnverifiedChannel && pending.length > 0) {
728
811
  // Unverified channel — auto-deny the sensitive action (fail-closed).
729
- handleChannelDecision(conversationId, { action: 'reject', source: 'plain_text' }, orchestrator);
730
- const denialText = guardianCtx.denialReason === 'no_identity'
731
- ? `The action "${pending[0].toolName}" requires guardian approval, but your identity could not be determined. The action has been denied. Please ensure your messaging client sends user identity information.`
732
- : `The action "${pending[0].toolName}" requires guardian approval, but no guardian has been set up for this channel. The action has been denied. Please ask an administrator to configure a guardian.`;
733
- try {
734
- await deliverChannelReply(replyCallbackUrl, {
735
- chatId: externalChatId,
736
- text: denialText,
737
- }, bearerToken);
738
- } catch (err) {
739
- log.error({ err, runId: run.id }, 'Failed to deliver unverified-channel denial notice');
740
- }
812
+ handleChannelDecision(
813
+ conversationId,
814
+ { action: 'reject', source: 'plain_text' },
815
+ orchestrator,
816
+ buildGuardianDenyContext(
817
+ pending[0].toolName,
818
+ guardianCtx.denialReason ?? 'no_binding',
819
+ sourceChannel,
820
+ ),
821
+ );
741
822
  } else if (isNonGuardian && guardianCtx.guardianChatId && pending.length > 0) {
742
823
  // Non-guardian actor: route the approval prompt to the guardian's chat
743
824
  const guardianPrompt = buildGuardianApprovalPrompt(
@@ -774,9 +855,11 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
774
855
  guardianCtx.guardianChatId,
775
856
  guardianText,
776
857
  uiMetadata,
858
+ assistantId,
777
859
  bearerToken,
778
860
  );
779
861
  guardianNotified = true;
862
+ hasPostDecisionDelivery = true;
780
863
  } catch (err) {
781
864
  log.error({ err, runId: run.id }, 'Failed to deliver guardian approval prompt');
782
865
  // Deny the approval and the underlying run — fail-closed. If
@@ -788,7 +871,8 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
788
871
  try {
789
872
  await deliverChannelReply(replyCallbackUrl, {
790
873
  chatId: guardianCtx.requesterChatId ?? externalChatId,
791
- text: `Your request to run "${pending[0].toolName}" could not be sent to the guardian for approval. The action has been denied.`,
874
+ text: composeApprovalMessage({ scenario: 'guardian_delivery_failed', toolName: pending[0].toolName }),
875
+ assistantId,
792
876
  }, bearerToken);
793
877
  } catch (notifyErr) {
794
878
  log.error({ err: notifyErr, runId: run.id }, 'Failed to notify requester of guardian delivery failure');
@@ -800,7 +884,8 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
800
884
  try {
801
885
  await deliverChannelReply(replyCallbackUrl, {
802
886
  chatId: guardianCtx.requesterChatId ?? externalChatId,
803
- text: `Your request to run "${pending[0].toolName}" has been sent to the guardian for approval.`,
887
+ text: composeApprovalMessage({ scenario: 'guardian_request_forwarded', toolName: pending[0].toolName }),
888
+ assistantId,
804
889
  }, bearerToken);
805
890
  } catch (err) {
806
891
  log.error({ err, runId: run.id }, 'Failed to notify requester of pending guardian approval');
@@ -823,8 +908,10 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
823
908
  externalChatId,
824
909
  promptTextForChannel,
825
910
  uiMetadata,
911
+ assistantId,
826
912
  bearerToken,
827
913
  );
914
+ hasPostDecisionDelivery = true;
828
915
  } catch (err) {
829
916
  // Fail-closed: if we cannot deliver the approval prompt, the
830
917
  // user will never see it and the run would hang indefinitely
@@ -867,7 +954,13 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
867
954
  // rather than permanently losing the reply.
868
955
  if (channelDeliveryStore.claimRunDelivery(run.id)) {
869
956
  try {
870
- await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl, bearerToken);
957
+ await deliverReplyViaCallback(
958
+ conversationId,
959
+ externalChatId,
960
+ replyCallbackUrl,
961
+ bearerToken,
962
+ assistantId,
963
+ );
871
964
  } catch (deliveryErr) {
872
965
  channelDeliveryStore.resetRunDeliveryClaim(run.id);
873
966
  throw deliveryErr;
@@ -884,23 +977,39 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
884
977
  updateApprovalDecision(approvalReq.id, { status: outcomeStatus });
885
978
  }
886
979
  }
887
- } else if (finalRun?.status === 'needs_confirmation') {
888
- // The run is waiting for an approval decision but the poll window has
889
- // elapsed. Mark the event as processed rather than failed — the run
890
- // will resume when the user clicks approve/reject, and
891
- // `handleApprovalInterception` will deliver the reply via its own
892
- // post-decision poll. Marking it failed would cause the generic retry
893
- // sweep to replay through `processMessage`, which throws "Session is
980
+ } else if (
981
+ finalRun?.status === 'needs_confirmation' ||
982
+ (hasPostDecisionDelivery && finalRun?.status === 'running')
983
+ ) {
984
+ // The run is either still waiting for an approval decision or was
985
+ // recently approved and has resumed execution. In both cases, mark
986
+ // the event as processed rather than failed:
987
+ //
988
+ // - needs_confirmation: the run will resume when the user clicks
989
+ // approve/reject, and `handleApprovalInterception` will deliver
990
+ // the reply via `schedulePostDecisionDelivery`.
991
+ //
992
+ // - running (after successful prompt delivery): an approval was
993
+ // applied near the poll deadline and the run resumed but hasn't
994
+ // reached terminal state yet. `handleApprovalInterception` has
995
+ // already scheduled post-decision delivery, so the final reply
996
+ // will be delivered. This condition is only true when the approval
997
+ // prompt was actually delivered (not in auto-deny paths), ensuring
998
+ // we don't suppress retry/dead-letter for cases where no
999
+ // post-decision delivery path exists.
1000
+ //
1001
+ // Marking either state as failed would cause the generic retry sweep
1002
+ // to replay through `processMessage`, which throws "Session is
894
1003
  // already processing a message" and dead-letters a valid conversation.
895
1004
  log.warn(
896
- { runId: run.id, status: finalRun.status, conversationId },
897
- 'Approval-path poll loop timed out while run awaits approval decision; marking event as processed',
1005
+ { runId: run.id, status: finalRun.status, conversationId, hasPostDecisionDelivery },
1006
+ 'Approval-path poll loop timed out while run is in approval-related state; marking event as processed',
898
1007
  );
899
1008
  channelDeliveryStore.markProcessed(eventId);
900
1009
  } else {
901
- // The run is in a non-terminal, non-approval state (e.g. running,
902
- // needs_secret, or disappeared). Record a processing failure so the
903
- // retry/dead-letter machinery can handle it.
1010
+ // The run is in a non-terminal, non-approval state (e.g. running
1011
+ // without prior approval, needs_secret, or disappeared). Record a
1012
+ // processing failure so the retry/dead-letter machinery can handle it.
904
1013
  const timeoutErr = new Error(
905
1014
  `Approval poll timeout: run did not reach terminal state within ${pollMaxWait}ms (status: ${finalRun?.status ?? 'null'})`,
906
1015
  );
@@ -1002,7 +1111,8 @@ async function handleApprovalInterception(
1002
1111
  try {
1003
1112
  await deliverChannelReply(replyCallbackUrl, {
1004
1113
  chatId: externalChatId,
1005
- text: `You have ${allPending.length} pending approval requests. Please use the approval buttons to respond to a specific request.`,
1114
+ text: composeApprovalMessage({ scenario: 'guardian_disambiguation', pendingCount: allPending.length }),
1115
+ assistantId,
1006
1116
  }, bearerToken);
1007
1117
  } catch (err) {
1008
1118
  log.error({ err, externalChatId }, 'Failed to deliver disambiguation notice');
@@ -1034,7 +1144,8 @@ async function handleApprovalInterception(
1034
1144
  try {
1035
1145
  await deliverChannelReply(replyCallbackUrl, {
1036
1146
  chatId: externalChatId,
1037
- text: 'Only the verified guardian can approve or deny this request.',
1147
+ text: composeApprovalMessage({ scenario: 'guardian_identity_mismatch' }),
1148
+ assistantId,
1038
1149
  }, bearerToken);
1039
1150
  } catch (err) {
1040
1151
  log.error({ err, externalChatId }, 'Failed to deliver guardian identity rejection notice');
@@ -1067,14 +1178,16 @@ async function handleApprovalInterception(
1067
1178
 
1068
1179
  if (result.applied) {
1069
1180
  // Notify the requester's chat about the outcome with the tool name
1070
- const toolLabel = guardianApproval.toolName;
1071
- const outcomeText = decision.action === 'reject'
1072
- ? `Your request to run "${toolLabel}" was denied by the guardian.`
1073
- : `Your request to run "${toolLabel}" was approved by the guardian.`;
1181
+ const outcomeText = composeApprovalMessage({
1182
+ scenario: 'guardian_decision_outcome',
1183
+ decision: decision.action === 'reject' ? 'denied' : 'approved',
1184
+ toolName: guardianApproval.toolName,
1185
+ });
1074
1186
  try {
1075
1187
  await deliverChannelReply(replyCallbackUrl, {
1076
1188
  chatId: guardianApproval.requesterChatId,
1077
1189
  text: outcomeText,
1190
+ assistantId,
1078
1191
  }, bearerToken);
1079
1192
  } catch (err) {
1080
1193
  log.error({ err, conversationId: guardianApproval.conversationId }, 'Failed to notify requester of guardian decision');
@@ -1090,6 +1203,7 @@ async function handleApprovalInterception(
1090
1203
  guardianApproval.requesterChatId,
1091
1204
  replyCallbackUrl,
1092
1205
  bearerToken,
1206
+ assistantId,
1093
1207
  );
1094
1208
  }
1095
1209
  }
@@ -1117,6 +1231,7 @@ async function handleApprovalInterception(
1117
1231
  externalChatId,
1118
1232
  reminderText,
1119
1233
  uiMetadata,
1234
+ assistantId,
1120
1235
  bearerToken,
1121
1236
  );
1122
1237
  } catch (err) {
@@ -1126,11 +1241,6 @@ async function handleApprovalInterception(
1126
1241
 
1127
1242
  return { handled: true, type: 'reminder_sent' };
1128
1243
  }
1129
-
1130
- // Callback with a run ID that no longer has a pending approval — stale button
1131
- if (decision?.runId) {
1132
- return { handled: true, type: 'stale_ignored' };
1133
- }
1134
1244
  }
1135
1245
 
1136
1246
  // ── Standard approval interception (existing flow) ──
@@ -1142,17 +1252,26 @@ async function handleApprovalInterception(
1142
1252
  if (guardianCtx.actorRole === 'unverified_channel') {
1143
1253
  const pending = getPendingConfirmationsByConversation(conversationId);
1144
1254
  if (pending.length > 0) {
1145
- handleChannelDecision(conversationId, { action: 'reject', source: 'plain_text' }, orchestrator);
1146
- const denialText = guardianCtx.denialReason === 'no_identity'
1147
- ? `The action "${pending[0].toolName}" requires guardian approval, but your identity could not be determined. The action has been denied.`
1148
- : `The action "${pending[0].toolName}" requires guardian approval, but no guardian has been set up for this channel. The action has been denied.`;
1149
- try {
1150
- await deliverChannelReply(replyCallbackUrl, {
1151
- chatId: externalChatId,
1152
- text: denialText,
1153
- }, bearerToken);
1154
- } catch (err) {
1155
- log.error({ err, conversationId }, 'Failed to deliver unverified-channel denial notice during interception');
1255
+ const denyResult = handleChannelDecision(
1256
+ conversationId,
1257
+ { action: 'reject', source: 'plain_text' },
1258
+ orchestrator,
1259
+ buildGuardianDenyContext(
1260
+ pending[0].toolName,
1261
+ guardianCtx.denialReason ?? 'no_binding',
1262
+ sourceChannel,
1263
+ ),
1264
+ );
1265
+ if (denyResult.applied && denyResult.runId) {
1266
+ schedulePostDecisionDelivery(
1267
+ orchestrator,
1268
+ denyResult.runId,
1269
+ conversationId,
1270
+ externalChatId,
1271
+ replyCallbackUrl,
1272
+ bearerToken,
1273
+ assistantId,
1274
+ );
1156
1275
  }
1157
1276
  return { handled: true, type: 'decision_applied' };
1158
1277
  }
@@ -1169,7 +1288,8 @@ async function handleApprovalInterception(
1169
1288
  try {
1170
1289
  await deliverChannelReply(replyCallbackUrl, {
1171
1290
  chatId: externalChatId,
1172
- text: 'Your request is pending guardian approval. Only the verified guardian can approve or deny this request.',
1291
+ text: composeApprovalMessage({ scenario: 'request_pending_guardian' }),
1292
+ assistantId,
1173
1293
  }, bearerToken);
1174
1294
  } catch (err) {
1175
1295
  log.error({ err, conversationId }, 'Failed to deliver guardian-pending notice to requester');
@@ -1195,7 +1315,8 @@ async function handleApprovalInterception(
1195
1315
  try {
1196
1316
  await deliverChannelReply(replyCallbackUrl, {
1197
1317
  chatId: externalChatId,
1198
- text: 'Your guardian approval request has expired and the action has been denied. Please try again.',
1318
+ text: composeApprovalMessage({ scenario: 'guardian_expired_requester', toolName: pending[0].toolName }),
1319
+ assistantId,
1199
1320
  }, bearerToken);
1200
1321
  } catch (err) {
1201
1322
  log.error({ err, conversationId }, 'Failed to deliver guardian-expiry notice to requester');
@@ -1247,6 +1368,7 @@ async function handleApprovalInterception(
1247
1368
  externalChatId,
1248
1369
  replyCallbackUrl,
1249
1370
  bearerToken,
1371
+ assistantId,
1250
1372
  );
1251
1373
  }
1252
1374
 
@@ -1269,6 +1391,7 @@ async function handleApprovalInterception(
1269
1391
  externalChatId,
1270
1392
  reminderText,
1271
1393
  uiMetadata,
1394
+ assistantId,
1272
1395
  bearerToken,
1273
1396
  );
1274
1397
  } catch (err) {
@@ -1296,6 +1419,7 @@ function schedulePostDecisionDelivery(
1296
1419
  externalChatId: string,
1297
1420
  replyCallbackUrl: string,
1298
1421
  bearerToken?: string,
1422
+ assistantId?: string,
1299
1423
  ): void {
1300
1424
  (async () => {
1301
1425
  try {
@@ -1307,7 +1431,13 @@ function schedulePostDecisionDelivery(
1307
1431
  if (current.status === 'completed' || current.status === 'failed') {
1308
1432
  if (channelDeliveryStore.claimRunDelivery(runId)) {
1309
1433
  try {
1310
- await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl, bearerToken);
1434
+ await deliverReplyViaCallback(
1435
+ conversationId,
1436
+ externalChatId,
1437
+ replyCallbackUrl,
1438
+ bearerToken,
1439
+ assistantId,
1440
+ );
1311
1441
  } catch (deliveryErr) {
1312
1442
  channelDeliveryStore.resetRunDeliveryClaim(runId);
1313
1443
  throw deliveryErr;
@@ -1331,6 +1461,7 @@ async function deliverReplyViaCallback(
1331
1461
  externalChatId: string,
1332
1462
  callbackUrl: string,
1333
1463
  bearerToken?: string,
1464
+ assistantId?: string,
1334
1465
  ): Promise<void> {
1335
1466
  const msgs = conversationStore.getMessages(conversationId);
1336
1467
  for (let i = msgs.length - 1; i >= 0; i--) {
@@ -1353,6 +1484,7 @@ async function deliverReplyViaCallback(
1353
1484
  chatId: externalChatId,
1354
1485
  text: rendered.text || undefined,
1355
1486
  attachments: replyAttachments.length > 0 ? replyAttachments : undefined,
1487
+ assistantId,
1356
1488
  }, bearerToken);
1357
1489
  }
1358
1490
  break;
@@ -1452,7 +1584,8 @@ export function sweepExpiredGuardianApprovals(
1452
1584
  // Notify the requester that the approval expired
1453
1585
  deliverChannelReply(deliverUrl, {
1454
1586
  chatId: approval.requesterChatId,
1455
- text: `Your guardian approval request for "${approval.toolName}" has expired and the action has been denied. Please try again.`,
1587
+ text: composeApprovalMessage({ scenario: 'guardian_expired_requester', toolName: approval.toolName }),
1588
+ assistantId: approval.assistantId,
1456
1589
  }, bearerToken).catch((err) => {
1457
1590
  log.error({ err, runId: approval.runId }, 'Failed to notify requester of guardian approval expiry');
1458
1591
  });
@@ -1460,7 +1593,8 @@ export function sweepExpiredGuardianApprovals(
1460
1593
  // Notify the guardian that the approval expired
1461
1594
  deliverChannelReply(deliverUrl, {
1462
1595
  chatId: approval.guardianChatId,
1463
- text: `The approval request for "${approval.toolName}" from user ${approval.requesterExternalUserId} has expired and was automatically denied.`,
1596
+ text: composeApprovalMessage({ scenario: 'guardian_expired_guardian', toolName: approval.toolName, requesterIdentifier: approval.requesterExternalUserId }),
1597
+ assistantId: approval.assistantId,
1464
1598
  }, bearerToken).catch((err) => {
1465
1599
  log.error({ err, runId: approval.runId }, 'Failed to notify guardian of approval expiry');
1466
1600
  });