@vellumai/assistant 0.3.26 → 0.3.28

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 (82) hide show
  1. package/ARCHITECTURE.md +48 -1
  2. package/Dockerfile +2 -2
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +6 -2
  5. package/src/__tests__/agent-loop.test.ts +119 -0
  6. package/src/__tests__/bundled-asset.test.ts +107 -0
  7. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  8. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  9. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  10. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  11. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  12. package/src/__tests__/guardian-dispatch.test.ts +19 -19
  13. package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
  14. package/src/__tests__/mcp-cli.test.ts +77 -0
  15. package/src/__tests__/non-member-access-request.test.ts +31 -29
  16. package/src/__tests__/notification-decision-fallback.test.ts +61 -3
  17. package/src/__tests__/notification-decision-strategy.test.ts +17 -0
  18. package/src/__tests__/notification-guardian-path.test.ts +13 -15
  19. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  20. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  21. package/src/__tests__/secret-scanner.test.ts +8 -0
  22. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  23. package/src/__tests__/session-runtime-assembly.test.ts +76 -47
  24. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  25. package/src/agent/loop.ts +46 -3
  26. package/src/approvals/guardian-decision-primitive.ts +285 -0
  27. package/src/approvals/guardian-request-resolvers.ts +539 -0
  28. package/src/calls/guardian-dispatch.ts +46 -40
  29. package/src/calls/relay-server.ts +147 -2
  30. package/src/calls/types.ts +1 -1
  31. package/src/config/system-prompt.ts +2 -1
  32. package/src/config/templates/BOOTSTRAP.md +47 -31
  33. package/src/config/templates/USER.md +5 -0
  34. package/src/config/update-bulletin-template-path.ts +4 -1
  35. package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
  36. package/src/daemon/handlers/guardian-actions.ts +45 -66
  37. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  38. package/src/daemon/lifecycle.ts +3 -16
  39. package/src/daemon/server.ts +18 -0
  40. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  41. package/src/daemon/session-agent-loop.ts +32 -5
  42. package/src/daemon/session-process.ts +68 -307
  43. package/src/daemon/session-runtime-assembly.ts +112 -24
  44. package/src/daemon/session-tool-setup.ts +1 -0
  45. package/src/daemon/session.ts +1 -0
  46. package/src/home-base/prebuilt/seed.ts +2 -1
  47. package/src/hooks/templates.ts +2 -1
  48. package/src/memory/canonical-guardian-store.ts +524 -0
  49. package/src/memory/channel-guardian-store.ts +1 -0
  50. package/src/memory/db-init.ts +16 -0
  51. package/src/memory/guardian-action-store.ts +7 -60
  52. package/src/memory/guardian-approvals.ts +9 -4
  53. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  54. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  55. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  56. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  57. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  58. package/src/memory/migrations/index.ts +4 -0
  59. package/src/memory/migrations/registry.ts +5 -0
  60. package/src/memory/schema-migration.ts +1 -0
  61. package/src/memory/schema.ts +52 -0
  62. package/src/notifications/copy-composer.ts +16 -4
  63. package/src/notifications/decision-engine.ts +57 -0
  64. package/src/permissions/defaults.ts +2 -0
  65. package/src/runtime/access-request-helper.ts +137 -0
  66. package/src/runtime/actor-trust-resolver.ts +225 -0
  67. package/src/runtime/channel-guardian-service.ts +12 -4
  68. package/src/runtime/guardian-context-resolver.ts +32 -7
  69. package/src/runtime/guardian-decision-types.ts +6 -0
  70. package/src/runtime/guardian-reply-router.ts +687 -0
  71. package/src/runtime/http-server.ts +8 -0
  72. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  73. package/src/runtime/routes/conversation-routes.ts +18 -0
  74. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  75. package/src/runtime/routes/inbound-message-handler.ts +170 -525
  76. package/src/runtime/tool-grant-request-helper.ts +195 -0
  77. package/src/tools/executor.ts +13 -1
  78. package/src/tools/sensitive-output-placeholders.ts +203 -0
  79. package/src/tools/tool-approval-handler.ts +44 -1
  80. package/src/tools/types.ts +11 -0
  81. package/src/util/bundled-asset.ts +31 -0
  82. package/src/util/canonicalize-identity.ts +52 -0
@@ -0,0 +1,687 @@
1
+ /**
2
+ * Shared guardian reply router for inbound channel messages.
3
+ *
4
+ * Provides a single entry point (`routeGuardianReply`) for all inbound
5
+ * guardian reply processing across Telegram, SMS, and WhatsApp. Routes
6
+ * through a priority-ordered pipeline:
7
+ *
8
+ * 1. Deterministic callback/ref parsing (button presses with `apr:<requestId>:<action>`)
9
+ * 2. Request code parsing (6-char alphanumeric prefix matching)
10
+ * 3. NL classification via the conversational approval engine
11
+ *
12
+ * All decisions flow through `applyCanonicalGuardianDecision` from M2,
13
+ * which handles identity validation, expiry checks, CAS resolution,
14
+ * kind-specific resolver dispatch, and grant minting.
15
+ *
16
+ * The router is intentionally kept separate from the inbound message handler
17
+ * to allow for incremental migration and independent testability.
18
+ */
19
+
20
+ import {
21
+ applyCanonicalGuardianDecision,
22
+ type CanonicalDecisionResult,
23
+ } from '../approvals/guardian-decision-primitive.js';
24
+ import type { ActorContext, ChannelDeliveryContext } from '../approvals/guardian-request-resolvers.js';
25
+ import {
26
+ type CanonicalGuardianRequest,
27
+ getCanonicalGuardianRequest,
28
+ getCanonicalGuardianRequestByCode,
29
+ listCanonicalGuardianRequests,
30
+ } from '../memory/canonical-guardian-store.js';
31
+ import { getLogger } from '../util/logger.js';
32
+ import { runApprovalConversationTurn } from './approval-conversation-turn.js';
33
+ import type { ApprovalAction } from './channel-approval-types.js';
34
+ import type {
35
+ ApprovalConversationContext,
36
+ ApprovalConversationGenerator,
37
+ } from './http-types.js';
38
+
39
+ const log = getLogger('guardian-reply-router');
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Types
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /** Context for an inbound message that may be a guardian reply. */
46
+ export interface GuardianReplyContext {
47
+ /** The raw message text (trimmed). */
48
+ messageText: string;
49
+ /** Source channel (telegram, sms, whatsapp, etc.). */
50
+ channel: string;
51
+ /** Actor identity context for the sender. */
52
+ actor: ActorContext;
53
+ /** Conversation ID for this message (may be the guardian's conversation). */
54
+ conversationId: string;
55
+ /** Callback data from button presses (e.g. `apr:<requestId>:<action>`). */
56
+ callbackData?: string;
57
+ /** IDs of known pending canonical requests for this guardian. */
58
+ pendingRequestIds?: string[];
59
+ /** Conversation generator for NL classification (injected by daemon). */
60
+ approvalConversationGenerator?: ApprovalConversationGenerator;
61
+ /** Optional channel delivery context for resolver-driven side effects. */
62
+ channelDeliveryContext?: ChannelDeliveryContext;
63
+ }
64
+
65
+ export type GuardianReplyResultType =
66
+ | 'canonical_decision_applied'
67
+ | 'canonical_decision_stale'
68
+ | 'canonical_resolver_failed'
69
+ | 'code_only_clarification'
70
+ | 'disambiguation_needed'
71
+ | 'nl_keep_pending'
72
+ | 'not_consumed';
73
+
74
+ /** Result from the guardian reply router. */
75
+ export interface GuardianReplyResult {
76
+ /** Whether a decision was applied to a canonical request. */
77
+ decisionApplied: boolean;
78
+ /** Reply text to send back to the guardian (if any). */
79
+ replyText?: string;
80
+ /** Whether the message was consumed and should not enter the agent pipeline. */
81
+ consumed: boolean;
82
+ /** The type of outcome for diagnostics. */
83
+ type: GuardianReplyResultType;
84
+ /** The canonical request ID that was targeted (if any). */
85
+ requestId?: string;
86
+ /** Detailed result from the canonical decision primitive (when a decision was attempted). */
87
+ canonicalResult?: CanonicalDecisionResult;
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Callback data parser — format: "apr:<requestId>:<action>"
92
+ // ---------------------------------------------------------------------------
93
+
94
+ const VALID_ACTIONS: ReadonlySet<string> = new Set([
95
+ 'approve_once',
96
+ 'approve_always',
97
+ 'reject',
98
+ ]);
99
+
100
+ interface ParsedCallback {
101
+ requestId: string;
102
+ action: ApprovalAction;
103
+ }
104
+
105
+ function parseCallbackAction(data: string): ParsedCallback | null {
106
+ const parts = data.split(':');
107
+ if (parts.length < 3 || parts[0] !== 'apr') return null;
108
+ const requestId = parts[1];
109
+ const action = parts.slice(2).join(':');
110
+ if (!requestId || !VALID_ACTIONS.has(action)) return null;
111
+ return { requestId, action: action as ApprovalAction };
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Request code parser
116
+ // ---------------------------------------------------------------------------
117
+
118
+ /**
119
+ * 6-char alphanumeric request code at the start of a message.
120
+ * Returns the matching canonical request and the remaining text after
121
+ * the code prefix.
122
+ *
123
+ * When `scopeConversationId` is provided, the matched request must belong
124
+ * to that conversation — otherwise the code is treated as unmatched so
125
+ * that requests from other sessions are never accidentally consumed.
126
+ */
127
+ interface CodeParseResult {
128
+ request: CanonicalGuardianRequest;
129
+ remainingText: string;
130
+ }
131
+
132
+ function parseRequestCode(text: string, scopeConversationId?: string): CodeParseResult | null {
133
+ // Request codes are 6 hex chars (A-F, 0-9), uppercase
134
+ const upper = text.toUpperCase();
135
+ const match = upper.match(/^([A-F0-9]{6})(?:\s|$)/);
136
+ if (!match) return null;
137
+
138
+ const code = match[1];
139
+ const request = getCanonicalGuardianRequestByCode(code);
140
+ if (!request) return null;
141
+
142
+ // Scope to the current conversation when requested, so a code belonging
143
+ // to a different session/conversation is not consumed here. Requests with
144
+ // null conversationId are global/unscoped and match any conversation.
145
+ if (scopeConversationId && request.conversationId && request.conversationId !== scopeConversationId) {
146
+ log.info(
147
+ { event: 'router_code_conversation_mismatch', code, requestId: request.id, expected: scopeConversationId, actual: request.conversationId },
148
+ 'Request code matched a canonical request from a different conversation — ignoring',
149
+ );
150
+ return null;
151
+ }
152
+
153
+ const remainingText = text.slice(code.length).trim();
154
+ return { request, remainingText };
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Helpers
159
+ // ---------------------------------------------------------------------------
160
+
161
+ /** Find all pending canonical requests for a guardian actor. */
162
+ function findPendingCanonicalRequests(
163
+ actor: ActorContext,
164
+ pendingRequestIds?: string[],
165
+ conversationId?: string,
166
+ ): CanonicalGuardianRequest[] {
167
+ // When explicit IDs are provided, look them up directly
168
+ if (pendingRequestIds) {
169
+ if (pendingRequestIds.length === 0) {
170
+ return [];
171
+ }
172
+ return pendingRequestIds
173
+ .map(getCanonicalGuardianRequest)
174
+ .filter((r): r is CanonicalGuardianRequest => r?.status === 'pending');
175
+ }
176
+
177
+ // Query by guardian identity when available
178
+ if (actor.externalUserId) {
179
+ return listCanonicalGuardianRequests({
180
+ status: 'pending',
181
+ guardianExternalUserId: actor.externalUserId,
182
+ });
183
+ }
184
+
185
+ // For desktop/trusted actors without an externalUserId, query by
186
+ // conversationId so the NL path can discover pending requests.
187
+ if (conversationId) {
188
+ return listCanonicalGuardianRequests({
189
+ status: 'pending',
190
+ conversationId,
191
+ });
192
+ }
193
+
194
+ // Trusted actors without a conversationId: return all pending requests
195
+ // so desktop sessions can always discover pending guardian work.
196
+ if (actor.isTrusted) {
197
+ return listCanonicalGuardianRequests({ status: 'pending' });
198
+ }
199
+
200
+ return [];
201
+ }
202
+
203
+ /** Map an approval action string to the NL engine's allowed actions for guardians. */
204
+ function guardianAllowedActions(): ApprovalAction[] {
205
+ return ['approve_once', 'reject'];
206
+ }
207
+
208
+ function notConsumed(): GuardianReplyResult {
209
+ return { decisionApplied: false, consumed: false, type: 'not_consumed' };
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Core router
214
+ // ---------------------------------------------------------------------------
215
+
216
+ /**
217
+ * Route an inbound guardian reply through the canonical decision pipeline.
218
+ *
219
+ * This is the single entry point for all inbound guardian reply processing.
220
+ * It handles messages from any channel (Telegram, SMS, WhatsApp) and
221
+ * routes through priority-ordered matching:
222
+ *
223
+ * 1. Deterministic callback parsing (button presses)
224
+ * 2. Request code parsing (6-char alphanumeric prefix)
225
+ * 3. NL classification via the conversational approval engine
226
+ *
227
+ * All decisions flow through `applyCanonicalGuardianDecision`.
228
+ */
229
+ export async function routeGuardianReply(
230
+ ctx: GuardianReplyContext,
231
+ ): Promise<GuardianReplyResult> {
232
+ const { messageText, actor, conversationId, callbackData, approvalConversationGenerator, channelDeliveryContext } = ctx;
233
+ const pendingRequests = findPendingCanonicalRequests(actor, ctx.pendingRequestIds, conversationId);
234
+
235
+ // ── 1. Deterministic callback parsing (button presses) ──
236
+ // No conversationId scoping here — the guardian's reply comes from a
237
+ // different conversation than the requester's. Identity validation in
238
+ // applyCanonicalGuardianDecision is sufficient to prevent unauthorized
239
+ // cross-user decisions.
240
+ if (callbackData) {
241
+ const parsed = parseCallbackAction(callbackData);
242
+ if (parsed) {
243
+ return applyDecision(parsed.requestId, parsed.action, actor, undefined, channelDeliveryContext);
244
+ }
245
+ }
246
+
247
+ // ── 2. Request code parsing (6-char alphanumeric prefix) ──
248
+ // No conversationId scoping — same rationale as the callback path above.
249
+ // The guardian's conversation differs from the requester's.
250
+ if (messageText.length > 0) {
251
+ const codeResult = parseRequestCode(messageText);
252
+ if (codeResult) {
253
+ const { request } = codeResult;
254
+
255
+ if (request.status !== 'pending') {
256
+ log.info(
257
+ { event: 'router_code_already_resolved', requestId: request.id, status: request.status },
258
+ 'Request code matched a non-pending canonical request',
259
+ );
260
+ return {
261
+ decisionApplied: false,
262
+ consumed: true,
263
+ type: 'canonical_decision_stale',
264
+ requestId: request.id,
265
+ replyText: failureReplyText('already_resolved', request.requestCode),
266
+ };
267
+ }
268
+
269
+ // Code-only messages (no decision text after the code) are treated as
270
+ // clarification inquiries — the guardian may be asking "what is this?"
271
+ // rather than intending to approve. Return helpful context instead of
272
+ // silently defaulting to approve_once.
273
+ if (!codeResult.remainingText || codeResult.remainingText.trim().length === 0) {
274
+ // Identity check: only expose request details to the assigned guardian
275
+ // or trusted (desktop) actors. Mirrors the identity check in
276
+ // applyCanonicalGuardianDecision to prevent leaking request details
277
+ // (toolName, questionText) to unauthorized senders.
278
+ if (
279
+ request.guardianExternalUserId &&
280
+ !actor.isTrusted &&
281
+ actor.externalUserId !== request.guardianExternalUserId
282
+ ) {
283
+ log.warn(
284
+ {
285
+ event: 'router_code_only_identity_mismatch',
286
+ requestId: request.id,
287
+ expectedGuardian: request.guardianExternalUserId,
288
+ actualActor: actor.externalUserId,
289
+ },
290
+ 'Code-only clarification blocked: actor identity does not match expected guardian',
291
+ );
292
+ return {
293
+ decisionApplied: false,
294
+ consumed: true,
295
+ type: 'code_only_clarification',
296
+ requestId: request.id,
297
+ replyText: 'Request not found.',
298
+ };
299
+ }
300
+
301
+ log.info(
302
+ { event: 'router_code_only_clarification', requestId: request.id, code: request.requestCode },
303
+ 'Code-only message treated as clarification inquiry',
304
+ );
305
+ return {
306
+ decisionApplied: false,
307
+ consumed: true,
308
+ type: 'code_only_clarification',
309
+ requestId: request.id,
310
+ replyText: composeCodeOnlyClarification(request),
311
+ };
312
+ }
313
+
314
+ // Remaining text present — infer the decision action from it.
315
+ // If the text indicates rejection, use reject; otherwise approve_once.
316
+ const action = inferActionFromText(codeResult.remainingText);
317
+
318
+ return applyDecision(request.id, action, actor, codeResult.remainingText, channelDeliveryContext);
319
+ }
320
+ }
321
+
322
+ // ── 2.5. Deterministic plain-text decisions for known pending targets ──
323
+ // Desktop sessions intentionally do not enable NL classification; when the
324
+ // caller has exactly one known pending request and sends an explicit
325
+ // approve/reject phrase ("approve", "yes", "reject", "no"), apply the
326
+ // decision directly instead of falling through to legacy handlers.
327
+ if (messageText.length > 0 && pendingRequests.length > 0) {
328
+ const inferredAction = inferDecisionActionFromFreeText(messageText);
329
+ if (inferredAction) {
330
+ if (pendingRequests.length === 1) {
331
+ return applyDecision(
332
+ pendingRequests[0].id,
333
+ inferredAction,
334
+ actor,
335
+ messageText,
336
+ channelDeliveryContext,
337
+ );
338
+ }
339
+
340
+ const disambiguationReply = composeDisambiguationReply(pendingRequests);
341
+ return {
342
+ decisionApplied: false,
343
+ consumed: true,
344
+ type: 'disambiguation_needed',
345
+ replyText: disambiguationReply,
346
+ };
347
+ }
348
+ }
349
+
350
+ // ── 3. NL classification via the conversational approval engine ──
351
+ if (messageText.length > 0 && approvalConversationGenerator) {
352
+ if (pendingRequests.length === 0) {
353
+ return notConsumed();
354
+ }
355
+
356
+ // Use all pending requests for the guardian without conversation scoping.
357
+ // Guardian requests for channel/voice flows are created on the requester's
358
+ // conversation, not the guardian's reply thread, so filtering by
359
+ // conversationId would incorrectly drop valid pending requests. Identity-
360
+ // based filtering in findPendingCanonicalRequests already constrains
361
+ // results to the correct guardian.
362
+ const pendingRequestsForClassification = pendingRequests;
363
+
364
+ // Build the conversation context for the NL engine
365
+ const engineContext: ApprovalConversationContext = {
366
+ toolName: pendingRequestsForClassification[0].toolName ?? 'unknown',
367
+ allowedActions: guardianAllowedActions(),
368
+ role: 'guardian',
369
+ pendingApprovals: pendingRequestsForClassification.map(r => ({
370
+ requestId: r.id,
371
+ toolName: r.toolName ?? 'unknown',
372
+ })),
373
+ userMessage: messageText,
374
+ };
375
+
376
+ const engineResult = await runApprovalConversationTurn(
377
+ engineContext,
378
+ approvalConversationGenerator,
379
+ );
380
+
381
+ if (engineResult.disposition === 'keep_pending') {
382
+ // When the engine returns keep_pending with multiple pending requests,
383
+ // this likely means the NL classification understood a decision intent
384
+ // but runApprovalConversationTurn fail-closed because no targetRequestId
385
+ // was provided. In this case, produce a disambiguation reply instead of
386
+ // a generic "I couldn't process that" message.
387
+ if (pendingRequestsForClassification.length > 1) {
388
+ log.info(
389
+ { event: 'router_nl_disambiguation_needed', pendingCount: pendingRequestsForClassification.length },
390
+ 'Engine returned keep_pending with multiple pending requests — producing disambiguation',
391
+ );
392
+ const disambiguationReply = composeDisambiguationReply(pendingRequestsForClassification, undefined);
393
+ return {
394
+ decisionApplied: false,
395
+ consumed: true,
396
+ type: 'disambiguation_needed',
397
+ replyText: disambiguationReply,
398
+ };
399
+ }
400
+ return {
401
+ decisionApplied: false,
402
+ replyText: engineResult.replyText,
403
+ consumed: true,
404
+ type: 'nl_keep_pending',
405
+ };
406
+ }
407
+
408
+ // Decision-bearing disposition from the engine
409
+ let decisionAction = engineResult.disposition as ApprovalAction;
410
+
411
+ // Guardians cannot approve_always — the canonical primitive enforces
412
+ // this too, but enforce it here for clarity.
413
+ if (decisionAction === 'approve_always') {
414
+ decisionAction = 'approve_once';
415
+ }
416
+
417
+ // Resolve the target request
418
+ const targetId = engineResult.targetRequestId
419
+ ?? (pendingRequestsForClassification.length === 1 ? pendingRequestsForClassification[0].id : undefined);
420
+
421
+ if (!targetId) {
422
+ // Multi-pending and engine didn't pick a target — need disambiguation.
423
+ // Fail-closed: never auto-resolve when the target is ambiguous.
424
+ log.info(
425
+ { event: 'router_nl_disambiguation_needed', pendingCount: pendingRequestsForClassification.length },
426
+ 'NL engine returned a decision but no target for multi-pending requests',
427
+ );
428
+ const disambiguationReply = composeDisambiguationReply(pendingRequestsForClassification, engineResult.replyText);
429
+ return {
430
+ decisionApplied: false,
431
+ consumed: true,
432
+ type: 'disambiguation_needed',
433
+ replyText: disambiguationReply,
434
+ };
435
+ }
436
+
437
+ const result = await applyDecision(targetId, decisionAction, actor, messageText, channelDeliveryContext);
438
+
439
+ // Attach the engine's reply text for stale/expired/identity-mismatch cases,
440
+ // but preserve the explicit failure text when the resolver failed — the engine
441
+ // reply is typically an affirmative confirmation that would be misleading.
442
+ if (engineResult.replyText && result.type !== 'canonical_resolver_failed') {
443
+ result.replyText = engineResult.replyText;
444
+ }
445
+
446
+ return result;
447
+ }
448
+
449
+ // No matching strategy and no engine — not consumed
450
+ return notConsumed();
451
+ }
452
+
453
+ // ---------------------------------------------------------------------------
454
+ // Decision application
455
+ // ---------------------------------------------------------------------------
456
+
457
+ /**
458
+ * Apply a decision to a canonical request through the unified primitive.
459
+ */
460
+ async function applyDecision(
461
+ requestId: string,
462
+ action: ApprovalAction,
463
+ actor: ActorContext,
464
+ userText?: string,
465
+ channelDeliveryContext?: ChannelDeliveryContext,
466
+ ): Promise<GuardianReplyResult> {
467
+ const canonicalResult = await applyCanonicalGuardianDecision({
468
+ requestId,
469
+ action,
470
+ actorContext: actor,
471
+ userText,
472
+ channelDeliveryContext,
473
+ });
474
+
475
+ if (canonicalResult.applied) {
476
+ if (canonicalResult.resolverFailed) {
477
+ log.warn(
478
+ {
479
+ event: 'router_resolver_failed',
480
+ requestId,
481
+ action,
482
+ reason: canonicalResult.resolverFailureReason,
483
+ },
484
+ 'Guardian reply router: resolver failed to execute side effects',
485
+ );
486
+
487
+ return {
488
+ decisionApplied: false,
489
+ consumed: true,
490
+ type: 'canonical_resolver_failed',
491
+ replyText: `Decision recorded but could not be completed: ${canonicalResult.resolverFailureReason ?? 'unknown error'}. Please try again.`,
492
+ requestId,
493
+ canonicalResult,
494
+ };
495
+ }
496
+
497
+ log.info(
498
+ {
499
+ event: 'router_decision_applied',
500
+ requestId,
501
+ action,
502
+ grantMinted: canonicalResult.grantMinted,
503
+ },
504
+ 'Guardian reply router applied canonical decision',
505
+ );
506
+
507
+ return {
508
+ decisionApplied: true,
509
+ consumed: true,
510
+ type: 'canonical_decision_applied',
511
+ requestId,
512
+ canonicalResult,
513
+ };
514
+ }
515
+
516
+ log.info(
517
+ {
518
+ event: 'router_decision_not_applied',
519
+ requestId,
520
+ action,
521
+ reason: canonicalResult.reason,
522
+ },
523
+ `Guardian reply router: canonical decision not applied (${canonicalResult.reason})`,
524
+ );
525
+
526
+ // When the canonical request doesn't exist, allow the message to fall
527
+ // through so the legacy handleApprovalInterception handler can process it.
528
+ if (canonicalResult.reason === 'not_found') {
529
+ return notConsumed();
530
+ }
531
+
532
+ return {
533
+ decisionApplied: false,
534
+ consumed: true,
535
+ type: 'canonical_decision_stale',
536
+ requestId,
537
+ canonicalResult,
538
+ replyText: failureReplyText(canonicalResult.reason),
539
+ };
540
+ }
541
+
542
+ // ---------------------------------------------------------------------------
543
+ // Text-to-action inference
544
+ // ---------------------------------------------------------------------------
545
+
546
+ const CODE_REJECT_PATTERNS = /^(no|deny|reject|decline|cancel|block)\b/i;
547
+ const EXPLICIT_APPROVE_PHRASES: ReadonlySet<string> = new Set([
548
+ 'approve',
549
+ 'approved',
550
+ 'approve once',
551
+ 'yes',
552
+ 'y',
553
+ 'allow',
554
+ 'go ahead',
555
+ 'proceed',
556
+ 'do it',
557
+ ]);
558
+ const EXPLICIT_REJECT_PHRASES: ReadonlySet<string> = new Set([
559
+ 'reject',
560
+ 'deny',
561
+ 'decline',
562
+ 'no',
563
+ 'n',
564
+ 'block',
565
+ 'cancel',
566
+ ]);
567
+
568
+ function normalizeDecisionPhrase(text: string): string {
569
+ return text
570
+ .trim()
571
+ .toLowerCase()
572
+ .replace(/[.!?]+$/g, '')
573
+ .replace(/\s+/g, ' ');
574
+ }
575
+
576
+ /**
577
+ * Strict free-text decision parser used when no request code is present.
578
+ * Returns null unless the message starts with an explicit approve/reject cue.
579
+ */
580
+ function inferDecisionActionFromFreeText(text: string): ApprovalAction | null {
581
+ const normalized = normalizeDecisionPhrase(text);
582
+ if (!normalized) return null;
583
+ if (EXPLICIT_REJECT_PHRASES.has(normalized)) return 'reject';
584
+ if (EXPLICIT_APPROVE_PHRASES.has(normalized)) return 'approve_once';
585
+ return null;
586
+ }
587
+
588
+ /**
589
+ * Infer a guardian decision action from free-text after a request code.
590
+ * Defaults to approve_once unless clear rejection language is detected.
591
+ */
592
+ function inferActionFromText(text: string): ApprovalAction {
593
+ if (!text || text.trim().length === 0) {
594
+ return 'approve_once';
595
+ }
596
+
597
+ if (CODE_REJECT_PATTERNS.test(text.trim())) {
598
+ return 'reject';
599
+ }
600
+
601
+ return 'approve_once';
602
+ }
603
+
604
+ // ---------------------------------------------------------------------------
605
+ // Failure reason reply text
606
+ // ---------------------------------------------------------------------------
607
+
608
+ type CanonicalFailureReason = 'already_resolved' | 'identity_mismatch' | 'invalid_action' | 'expired';
609
+
610
+ /**
611
+ * Map a canonical decision failure reason to a distinct, actionable reply
612
+ * so the guardian understands exactly what happened and what to do next.
613
+ */
614
+ function failureReplyText(reason: CanonicalFailureReason, requestCode?: string | null): string {
615
+ switch (reason) {
616
+ case 'already_resolved':
617
+ return 'This request has already been resolved.';
618
+ case 'expired':
619
+ return 'This request has expired.';
620
+ case 'identity_mismatch':
621
+ return "You don't have permission to decide on this request.";
622
+ case 'invalid_action':
623
+ return requestCode
624
+ ? `I found request ${requestCode}, but I need to know your decision. Reply "${requestCode} approve" or "${requestCode} reject".`
625
+ : "I couldn't determine your intended action. Reply with the request code followed by 'approve' or 'reject' (e.g., \"ABC123 approve\").";
626
+ default:
627
+ return "I couldn't process that request. Please try again.";
628
+ }
629
+ }
630
+
631
+ // ---------------------------------------------------------------------------
632
+ // Code-only clarification
633
+ // ---------------------------------------------------------------------------
634
+
635
+ /**
636
+ * Compose a clarification response when a guardian sends only a request
637
+ * code without any decision text. Provides context about the request and
638
+ * tells the guardian how to approve or reject it.
639
+ */
640
+ function composeCodeOnlyClarification(request: CanonicalGuardianRequest): string {
641
+ const code = request.requestCode ?? 'unknown';
642
+ const toolLabel = request.toolName ?? 'an action';
643
+ const lines: string[] = [
644
+ `I found request ${code} for ${toolLabel}.`,
645
+ ];
646
+ if (request.questionText) {
647
+ lines.push(`Details: ${request.questionText}`);
648
+ }
649
+ lines.push(`Reply "${code} approve" to approve or "${code} reject" to reject.`);
650
+ return lines.join('\n');
651
+ }
652
+
653
+ // ---------------------------------------------------------------------------
654
+ // Disambiguation reply
655
+ // ---------------------------------------------------------------------------
656
+
657
+ /**
658
+ * Compose a disambiguation reply that includes concrete decision examples
659
+ * using actual request codes from the pending requests. Always includes
660
+ * explicit instructions so the guardian knows exactly how to proceed.
661
+ */
662
+ function composeDisambiguationReply(
663
+ pendingRequests: CanonicalGuardianRequest[],
664
+ engineReplyText?: string,
665
+ ): string {
666
+ const lines: string[] = [];
667
+
668
+ if (engineReplyText) {
669
+ lines.push(engineReplyText);
670
+ lines.push('');
671
+ }
672
+
673
+ lines.push(`You have ${pendingRequests.length} pending requests. Please specify which one:`);
674
+
675
+ for (const req of pendingRequests) {
676
+ const toolLabel = req.toolName ?? 'action';
677
+ const code = req.requestCode ?? req.id.slice(0, 6).toUpperCase();
678
+ lines.push(` - ${code}: ${toolLabel}`);
679
+ }
680
+
681
+ // Include a concrete example using the first request's code
682
+ const exampleCode = pendingRequests[0].requestCode ?? pendingRequests[0].id.slice(0, 6).toUpperCase();
683
+ lines.push('');
684
+ lines.push(`Reply "${exampleCode} approve" to approve a specific request.`);
685
+
686
+ return lines.join('\n');
687
+ }