@vellumai/assistant 0.4.3 → 0.4.4

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 (183) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +40 -3
  3. package/README.md +43 -35
  4. package/package.json +1 -1
  5. package/scripts/ipc/generate-swift.ts +1 -0
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  7. package/src/__tests__/actor-token-service.test.ts +1099 -0
  8. package/src/__tests__/agent-loop.test.ts +51 -0
  9. package/src/__tests__/approval-routes-http.test.ts +2 -0
  10. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  11. package/src/__tests__/assistant-id-boundary-guard.test.ts +125 -0
  12. package/src/__tests__/call-controller.test.ts +49 -0
  13. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  14. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  15. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  16. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  17. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  18. package/src/__tests__/channel-guardian.test.ts +0 -87
  19. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  20. package/src/__tests__/checker.test.ts +33 -12
  21. package/src/__tests__/config-schema.test.ts +4 -0
  22. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  23. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  24. package/src/__tests__/conversation-routes.test.ts +12 -3
  25. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  26. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  27. package/src/__tests__/guardian-actions-endpoint.test.ts +19 -14
  28. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  29. package/src/__tests__/guardian-outbound-http.test.ts +4 -4
  30. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  31. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  32. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  33. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  34. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  35. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  36. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  37. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  38. package/src/__tests__/non-member-access-request.test.ts +131 -8
  39. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  40. package/src/__tests__/notification-decision-strategy.test.ts +62 -2
  41. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  42. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  43. package/src/__tests__/relay-server.test.ts +841 -39
  44. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  45. package/src/__tests__/session-agent-loop.test.ts +1 -0
  46. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  47. package/src/__tests__/session-init.benchmark.test.ts +0 -1
  48. package/src/__tests__/session-surfaces-task-progress.test.ts +1 -1
  49. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  50. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  51. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  52. package/src/__tests__/tool-executor.test.ts +21 -2
  53. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  54. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  55. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  56. package/src/__tests__/twilio-config.test.ts +2 -13
  57. package/src/agent/loop.ts +1 -1
  58. package/src/approvals/guardian-decision-primitive.ts +10 -2
  59. package/src/approvals/guardian-request-resolvers.ts +128 -9
  60. package/src/calls/call-constants.ts +21 -0
  61. package/src/calls/call-controller.ts +9 -2
  62. package/src/calls/call-domain.ts +28 -7
  63. package/src/calls/call-pointer-message-composer.ts +154 -0
  64. package/src/calls/call-pointer-messages.ts +106 -27
  65. package/src/calls/guardian-dispatch.ts +4 -2
  66. package/src/calls/relay-server.ts +424 -12
  67. package/src/calls/twilio-config.ts +4 -11
  68. package/src/calls/twilio-routes.ts +1 -1
  69. package/src/calls/types.ts +3 -1
  70. package/src/cli.ts +5 -4
  71. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  72. package/src/config/bundled-skills/app-builder/SKILL.md +146 -10
  73. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  74. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  75. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  76. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  77. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  78. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  79. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  80. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  81. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  82. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  83. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +1 -0
  84. package/src/config/calls-schema.ts +24 -0
  85. package/src/config/env.ts +22 -0
  86. package/src/config/feature-flag-registry.json +8 -0
  87. package/src/config/schema.ts +2 -2
  88. package/src/config/skills.ts +11 -0
  89. package/src/config/system-prompt.ts +11 -1
  90. package/src/config/templates/SOUL.md +2 -0
  91. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  92. package/src/config/vellum-skills/trusted-contacts/SKILL.md +10 -9
  93. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  94. package/src/daemon/call-pointer-generators.ts +59 -0
  95. package/src/daemon/computer-use-session.ts +2 -5
  96. package/src/daemon/handlers/apps.ts +76 -20
  97. package/src/daemon/handlers/config-channels.ts +5 -55
  98. package/src/daemon/handlers/config-inbox.ts +9 -3
  99. package/src/daemon/handlers/config-ingress.ts +28 -3
  100. package/src/daemon/handlers/config-telegram.ts +12 -0
  101. package/src/daemon/handlers/config.ts +2 -6
  102. package/src/daemon/handlers/pairing.ts +2 -0
  103. package/src/daemon/handlers/sessions.ts +48 -3
  104. package/src/daemon/handlers/shared.ts +17 -2
  105. package/src/daemon/ipc-contract/integrations.ts +1 -99
  106. package/src/daemon/ipc-contract/messages.ts +47 -1
  107. package/src/daemon/ipc-contract/notifications.ts +11 -0
  108. package/src/daemon/ipc-contract-inventory.json +2 -4
  109. package/src/daemon/lifecycle.ts +17 -0
  110. package/src/daemon/server.ts +14 -1
  111. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  112. package/src/daemon/session-agent-loop.ts +22 -11
  113. package/src/daemon/session-lifecycle.ts +1 -1
  114. package/src/daemon/session-process.ts +11 -1
  115. package/src/daemon/session-runtime-assembly.ts +3 -0
  116. package/src/daemon/session-surfaces.ts +3 -2
  117. package/src/daemon/session.ts +88 -1
  118. package/src/daemon/tool-side-effects.ts +22 -0
  119. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  120. package/src/home-base/prebuilt/index.html +40 -0
  121. package/src/inbound/platform-callback-registration.ts +157 -0
  122. package/src/memory/canonical-guardian-store.ts +1 -1
  123. package/src/memory/db-init.ts +4 -0
  124. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  125. package/src/memory/migrations/index.ts +1 -0
  126. package/src/memory/schema.ts +16 -0
  127. package/src/messaging/provider-types.ts +24 -0
  128. package/src/messaging/provider.ts +7 -0
  129. package/src/messaging/providers/gmail/adapter.ts +127 -0
  130. package/src/messaging/providers/sms/adapter.ts +40 -37
  131. package/src/notifications/adapters/macos.ts +45 -2
  132. package/src/notifications/broadcaster.ts +16 -0
  133. package/src/notifications/copy-composer.ts +39 -1
  134. package/src/notifications/decision-engine.ts +22 -9
  135. package/src/notifications/destination-resolver.ts +16 -2
  136. package/src/notifications/emit-signal.ts +16 -8
  137. package/src/notifications/guardian-question-mode.ts +419 -0
  138. package/src/notifications/signal.ts +14 -3
  139. package/src/permissions/checker.ts +13 -1
  140. package/src/permissions/prompter.ts +14 -0
  141. package/src/providers/anthropic/client.ts +20 -0
  142. package/src/providers/provider-send-message.ts +15 -3
  143. package/src/runtime/access-request-helper.ts +71 -1
  144. package/src/runtime/actor-token-service.ts +234 -0
  145. package/src/runtime/actor-token-store.ts +236 -0
  146. package/src/runtime/channel-approvals.ts +5 -3
  147. package/src/runtime/channel-readiness-service.ts +23 -64
  148. package/src/runtime/channel-readiness-types.ts +3 -4
  149. package/src/runtime/channel-retry-sweep.ts +4 -1
  150. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  151. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  152. package/src/runtime/guardian-context-resolver.ts +82 -0
  153. package/src/runtime/guardian-outbound-actions.ts +0 -3
  154. package/src/runtime/guardian-reply-router.ts +67 -30
  155. package/src/runtime/guardian-vellum-migration.ts +57 -0
  156. package/src/runtime/http-server.ts +65 -12
  157. package/src/runtime/http-types.ts +13 -0
  158. package/src/runtime/invite-redemption-service.ts +8 -0
  159. package/src/runtime/local-actor-identity.ts +76 -0
  160. package/src/runtime/middleware/actor-token.ts +271 -0
  161. package/src/runtime/routes/approval-routes.ts +82 -7
  162. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  163. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  164. package/src/runtime/routes/conversation-routes.ts +140 -52
  165. package/src/runtime/routes/events-routes.ts +20 -5
  166. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  167. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  168. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  169. package/src/runtime/routes/inbound-message-handler.ts +143 -2
  170. package/src/runtime/routes/integration-routes.ts +7 -15
  171. package/src/runtime/routes/pairing-routes.ts +163 -0
  172. package/src/runtime/routes/twilio-routes.ts +934 -0
  173. package/src/runtime/tool-grant-request-helper.ts +3 -1
  174. package/src/security/oauth2.ts +27 -2
  175. package/src/security/token-manager.ts +46 -10
  176. package/src/tools/browser/browser-execution.ts +4 -3
  177. package/src/tools/browser/browser-handoff.ts +10 -18
  178. package/src/tools/browser/browser-manager.ts +80 -25
  179. package/src/tools/browser/browser-screencast.ts +35 -119
  180. package/src/tools/permission-checker.ts +15 -4
  181. package/src/tools/tool-approval-handler.ts +242 -18
  182. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  183. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -86,25 +86,14 @@ describe('twilio-config', () => {
86
86
  expect(config.phoneNumber).toBe('+15558888888');
87
87
  });
88
88
 
89
- test('resolves assistant-scoped phone number when assistantId is provided', () => {
89
+ test('returns global phone number when assistantPhoneNumbers mapping exists', () => {
90
90
  mockLoadConfigResult = {
91
91
  sms: {
92
92
  phoneNumber: '+15551234567',
93
93
  assistantPhoneNumbers: { 'ast-1': '+15557777777' },
94
94
  },
95
95
  };
96
- const config = getTwilioConfig('ast-1');
97
- expect(config.phoneNumber).toBe('+15557777777');
98
- });
99
-
100
- test('falls back to global phone number when assistant has no dedicated number', () => {
101
- mockLoadConfigResult = {
102
- sms: {
103
- phoneNumber: '+15551234567',
104
- assistantPhoneNumbers: { 'ast-1': '+15557777777' },
105
- },
106
- };
107
- const config = getTwilioConfig('ast-unknown');
96
+ const config = getTwilioConfig();
108
97
  expect(config.phoneNumber).toBe('+15551234567');
109
98
  });
110
99
  });
package/src/agent/loop.ts CHANGED
@@ -41,7 +41,7 @@ export type AgentEvent =
41
41
 
42
42
  const DEFAULT_CONFIG: AgentLoopConfig = {
43
43
  maxTokens: 16000,
44
- maxToolUseTurns: 60,
44
+ maxToolUseTurns: 0,
45
45
  minTurnIntervalMs: 150,
46
46
  };
47
47
 
@@ -53,6 +53,7 @@ import {
53
53
  type ActorContext,
54
54
  type ChannelDeliveryContext,
55
55
  getResolver,
56
+ type ResolverEmissionContext,
56
57
  } from './guardian-request-resolvers.js';
57
58
 
58
59
  const log = getLogger('guardian-decision-primitive');
@@ -295,10 +296,12 @@ export interface ApplyCanonicalGuardianDecisionParams {
295
296
  userText?: string;
296
297
  /** Optional channel delivery context — present when the decision arrived via a channel message. */
297
298
  channelDeliveryContext?: ChannelDeliveryContext;
299
+ /** Optional emission context threaded to handleConfirmationResponse for correct source attribution. */
300
+ emissionContext?: ResolverEmissionContext;
298
301
  }
299
302
 
300
303
  export type CanonicalDecisionResult =
301
- | { applied: true; requestId: string; grantMinted: boolean; resolverFailed?: boolean; resolverFailureReason?: string }
304
+ | { applied: true; requestId: string; grantMinted: boolean; resolverFailed?: boolean; resolverFailureReason?: string; resolverReplyText?: string }
302
305
  | { applied: false; reason: 'not_found' | 'already_resolved' | 'identity_mismatch' | 'invalid_action' | 'expired'; detail?: string };
303
306
 
304
307
  /**
@@ -319,7 +322,7 @@ export type CanonicalDecisionResult =
319
322
  export async function applyCanonicalGuardianDecision(
320
323
  params: ApplyCanonicalGuardianDecisionParams,
321
324
  ): Promise<CanonicalDecisionResult> {
322
- const { requestId, action, actorContext, userText, channelDeliveryContext } = params;
325
+ const { requestId, action, actorContext, userText, channelDeliveryContext, emissionContext } = params;
323
326
 
324
327
  // 1. Look up the canonical request
325
328
  const request = getCanonicalGuardianRequest(requestId);
@@ -427,6 +430,7 @@ export async function applyCanonicalGuardianDecision(
427
430
  // 5. Dispatch to kind-specific resolver
428
431
  let resolverFailed = false;
429
432
  let resolverFailureReason: string | undefined;
433
+ let resolverReplyText: string | undefined;
430
434
  const resolver = getResolver(request.kind);
431
435
  if (resolver) {
432
436
  const resolverResult = await resolver.resolve({
@@ -434,6 +438,7 @@ export async function applyCanonicalGuardianDecision(
434
438
  decision: { action: effectiveAction, userText },
435
439
  actor: actorContext,
436
440
  channelDeliveryContext,
441
+ emissionContext,
437
442
  });
438
443
 
439
444
  if (!resolverResult.ok) {
@@ -452,6 +457,8 @@ export async function applyCanonicalGuardianDecision(
452
457
  // still being informed that the resolver had an issue.
453
458
  resolverFailed = true;
454
459
  resolverFailureReason = resolverResult.reason;
460
+ } else {
461
+ resolverReplyText = resolverResult.guardianReplyText;
455
462
  }
456
463
  } else {
457
464
  log.info(
@@ -494,5 +501,6 @@ export async function applyCanonicalGuardianDecision(
494
501
  requestId,
495
502
  grantMinted,
496
503
  ...(resolverFailed ? { resolverFailed, resolverFailureReason } : {}),
504
+ ...(resolverReplyText ? { resolverReplyText } : {}),
497
505
  };
498
506
  }
@@ -12,7 +12,8 @@
12
12
  */
13
13
 
14
14
  import { answerCall } from '../calls/call-domain.js';
15
- import type { CanonicalGuardianRequest } from '../memory/canonical-guardian-store.js';
15
+ import { getGatewayInternalBaseUrl } from '../config/env.js';
16
+ import { type CanonicalGuardianRequest,getCanonicalGuardianRequest } from '../memory/canonical-guardian-store.js';
16
17
  import { upsertMember } from '../memory/ingress-member-store.js';
17
18
  import { emitNotificationSignal } from '../notifications/emit-signal.js';
18
19
  import { addRule } from '../permissions/trust-store.js';
@@ -22,7 +23,9 @@ import { createOutboundSession } from '../runtime/channel-guardian-service.js';
22
23
  import { deliverChannelReply } from '../runtime/gateway-client.js';
23
24
  import * as pendingInteractions from '../runtime/pending-interactions.js';
24
25
  import { getTool } from '../tools/registry.js';
26
+ import { TC_GRANT_WAIT_MAX_MS } from '../tools/tool-approval-handler.js';
25
27
  import { getLogger } from '../util/logger.js';
28
+ import { readHttpToken } from '../util/platform.js';
26
29
 
27
30
  const log = getLogger('guardian-request-resolvers');
28
31
 
@@ -64,6 +67,13 @@ export interface ChannelDeliveryContext {
64
67
  bearerToken?: string;
65
68
  }
66
69
 
70
+ /** Emission context threaded from callers to handleConfirmationResponse. */
71
+ export interface ResolverEmissionContext {
72
+ source?: 'button' | 'inline_nl' | 'auto_deny' | 'timeout' | 'system';
73
+ causedByRequestId?: string;
74
+ decisionText?: string;
75
+ }
76
+
67
77
  /** Context passed to each resolver after CAS resolution succeeds. */
68
78
  export interface ResolverContext {
69
79
  /** The canonical request record (already resolved to its terminal status). */
@@ -74,13 +84,27 @@ export interface ResolverContext {
74
84
  actor: ActorContext;
75
85
  /** Optional channel delivery context — present when the decision arrived via a channel message. */
76
86
  channelDeliveryContext?: ChannelDeliveryContext;
87
+ /** Optional emission context threaded to handleConfirmationResponse for correct source attribution. */
88
+ emissionContext?: ResolverEmissionContext;
77
89
  }
78
90
 
79
91
  /** Discriminated result from a resolver. */
80
92
  export type ResolverResult =
81
- | { ok: true; applied: true; grantMinted?: boolean }
93
+ | { ok: true; applied: true; grantMinted?: boolean; guardianReplyText?: string }
82
94
  | { ok: false; reason: string };
83
95
 
96
+ function resolveDeliverCallbackUrlForChannel(channel: string): string | null {
97
+ switch (channel) {
98
+ case 'telegram':
99
+ case 'sms':
100
+ case 'whatsapp':
101
+ case 'slack':
102
+ return `${getGatewayInternalBaseUrl()}/deliver/${channel}`;
103
+ default:
104
+ return null;
105
+ }
106
+ }
107
+
84
108
  /** Interface that kind-specific resolvers implement. */
85
109
  export interface GuardianRequestResolver {
86
110
  /** The request kind this resolver handles (matches canonical_guardian_requests.kind). */
@@ -139,11 +163,13 @@ const pendingInteractionResolver: GuardianRequestResolver = {
139
163
  decision.action === 'approve_always' &&
140
164
  details &&
141
165
  details.persistentDecisionsAllowed !== false &&
142
- details.allowlistOptions?.length &&
143
- details.scopeOptions?.length
166
+ details.allowlistOptions?.length
144
167
  ) {
145
168
  const pattern = details.allowlistOptions[0].pattern;
146
- const scope = details.scopeOptions[0].scope;
169
+ // Non-scoped tools (web_fetch, network_request, etc.) have empty
170
+ // scopeOptions — default to 'everywhere' so approve_always still
171
+ // persists a trust rule instead of silently degrading to one-time.
172
+ const scope = details.scopeOptions?.length ? details.scopeOptions[0].scope : 'everywhere';
147
173
  const tool = getTool(details.toolName);
148
174
  const executionTarget = tool?.origin === 'skill' ? details.executionTarget : undefined;
149
175
  addRule(details.toolName, pattern, scope, 'allow', 100, { executionTarget });
@@ -166,7 +192,7 @@ const pendingInteractionResolver: GuardianRequestResolver = {
166
192
 
167
193
  // Map action to the permission system's UserDecision type and notify session.
168
194
  const userDecision = decision.action === 'reject' ? 'deny' as const : 'allow' as const;
169
- resolved.session.handleConfirmationResponse(request.id, userDecision);
195
+ resolved.session.handleConfirmationResponse(request.id, userDecision, undefined, undefined, undefined, ctx.emissionContext);
170
196
 
171
197
  log.info(
172
198
  {
@@ -283,8 +309,11 @@ const accessRequestResolver: GuardianRequestResolver = {
283
309
  const channel = request.sourceChannel ?? 'unknown';
284
310
  const requesterExternalUserId = request.requesterExternalUserId ?? '';
285
311
  const requesterChatId = request.requesterChatId ?? request.requesterExternalUserId ?? '';
312
+ const requesterLabel = requesterExternalUserId || requesterChatId || 'the requester';
286
313
  const decidedByExternalUserId = ctx.actor.externalUserId ?? '';
287
314
  const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
315
+ const desktopDeliverUrl = resolveDeliverCallbackUrlForChannel(channel);
316
+ const desktopBearerToken = readHttpToken() ?? undefined;
288
317
 
289
318
  if (decision.action === 'reject') {
290
319
  log.info(
@@ -341,9 +370,23 @@ const accessRequestResolver: GuardianRequestResolver = {
341
370
  contextPayload: deniedPayload,
342
371
  dedupeKey: `trusted-contact:denied:${request.id}`,
343
372
  });
373
+ } else if (desktopDeliverUrl && requesterChatId) {
374
+ try {
375
+ await deliverChannelReply(desktopDeliverUrl, {
376
+ chatId: requesterChatId,
377
+ text: 'Your access request has been denied by the guardian.',
378
+ assistantId,
379
+ }, desktopBearerToken);
380
+ } catch (err) {
381
+ log.error({ err, requesterChatId }, 'Failed to notify requester of access request denial (desktop decision path)');
382
+ }
344
383
  }
345
384
 
346
- return { ok: true, applied: true };
385
+ return {
386
+ ok: true,
387
+ applied: true,
388
+ ...(ctx.actor.isTrusted ? { guardianReplyText: `Access denied for ${requesterLabel}.` } : {}),
389
+ };
347
390
  }
348
391
 
349
392
  // Voice approvals: directly activate the trusted contact without minting
@@ -404,6 +447,7 @@ const accessRequestResolver: GuardianRequestResolver = {
404
447
 
405
448
  // Deliver the verification code to the guardian and notify the requester
406
449
  // when channel delivery context is available (channel message path).
450
+ let requesterNotified = false;
407
451
  if (channelDeliveryContext) {
408
452
  let codeDelivered = true;
409
453
 
@@ -434,6 +478,7 @@ const accessRequestResolver: GuardianRequestResolver = {
434
478
  + 'Please enter the 6-digit verification code you receive from the guardian.',
435
479
  assistantId,
436
480
  }, channelDeliveryContext.bearerToken);
481
+ requesterNotified = true;
437
482
  } catch (err) {
438
483
  log.error({ err, requesterChatId }, 'Failed to notify requester of access request approval');
439
484
  }
@@ -473,9 +518,29 @@ const accessRequestResolver: GuardianRequestResolver = {
473
518
  dedupeKey: `trusted-contact:verification-sent:${session.sessionId}`,
474
519
  });
475
520
  }
521
+ } else if (desktopDeliverUrl && requesterChatId) {
522
+ try {
523
+ await deliverChannelReply(desktopDeliverUrl, {
524
+ chatId: requesterChatId,
525
+ text: 'Your access request has been approved! '
526
+ + 'Please enter the 6-digit verification code you receive from the guardian.',
527
+ assistantId,
528
+ }, desktopBearerToken);
529
+ requesterNotified = true;
530
+ } catch (err) {
531
+ log.error({ err, requesterChatId }, 'Failed to notify requester of access request approval (desktop decision path)');
532
+ }
476
533
  }
477
534
 
478
- return { ok: true, applied: true };
535
+ const verificationReplyText = requesterNotified
536
+ ? `Access approved for ${requesterLabel}. Give them this verification code: ${session.secret}. The code expires in 10 minutes.`
537
+ : `Access approved for ${requesterLabel}. Give them this verification code: ${session.secret}. The code expires in 10 minutes. I could not notify them automatically, so please tell them to send the code manually.`;
538
+
539
+ return {
540
+ ok: true,
541
+ applied: true,
542
+ ...(ctx.actor.isTrusted ? { guardianReplyText: verificationReplyText } : {}),
543
+ };
479
544
  },
480
545
  };
481
546
 
@@ -534,7 +599,61 @@ const toolGrantRequestResolver: GuardianRequestResolver = {
534
599
  'Tool grant request resolver: approved (grant minting deferred to canonical primitive)',
535
600
  );
536
601
 
537
- if (channelDeliveryContext && requesterChatId) {
602
+ // Re-read the canonical request to check whether an inline grant waiter
603
+ // has already claimed this request. When followupState is
604
+ // 'inline_wait_active', the requester's original tool call is blocking
605
+ // on the grant and will resume automatically — sending a "please retry"
606
+ // notification would be stale and confusing (and could cause duplicate
607
+ // attempts or one-time-grant denials).
608
+ //
609
+ // Staleness guard: the inline_wait_active marker is persisted in DB and
610
+ // can outlive the actual waiter if the daemon crashes or restarts during
611
+ // the wait. To avoid permanently suppressing the retry notification, we
612
+ // treat the marker as stale if the encoded start timestamp is older than
613
+ // the maximum wait budget plus a 30s buffer.
614
+ const INLINE_WAIT_STALENESS_BUFFER_MS = 30_000;
615
+ const freshRequest = getCanonicalGuardianRequest(request.id);
616
+ const followupState = freshRequest?.followupState ?? '';
617
+ let inlineWaitActive = followupState.startsWith('inline_wait_active');
618
+ if (inlineWaitActive && freshRequest) {
619
+ // The followupState encodes the wall-clock epoch when the inline wait
620
+ // started (e.g. 'inline_wait_active:1700000000000'). We use this
621
+ // instead of updatedAt because resolveCanonicalGuardianRequest sets
622
+ // updatedAt = now during CAS resolution, making updatedAt always fresh
623
+ // by the time this resolver runs.
624
+ const colonIdx = followupState.indexOf(':');
625
+ const waitStartMs = colonIdx !== -1 ? Number(followupState.slice(colonIdx + 1)) : NaN;
626
+ const markerAgeMs = Number.isFinite(waitStartMs)
627
+ ? Date.now() - waitStartMs
628
+ : Infinity; // Treat unparseable timestamps as stale for safety.
629
+ const stalenessThresholdMs = TC_GRANT_WAIT_MAX_MS + INLINE_WAIT_STALENESS_BUFFER_MS;
630
+ if (markerAgeMs > stalenessThresholdMs) {
631
+ log.warn(
632
+ {
633
+ event: 'resolver_tool_grant_request_stale_inline_wait',
634
+ requestId: request.id,
635
+ toolName: request.toolName,
636
+ markerAgeMs,
637
+ stalenessThresholdMs,
638
+ waitStartMs,
639
+ },
640
+ 'inline_wait_active marker is stale (daemon likely crashed during wait) — sending retry notification',
641
+ );
642
+ inlineWaitActive = false;
643
+ }
644
+ }
645
+
646
+ if (inlineWaitActive) {
647
+ log.info(
648
+ {
649
+ event: 'resolver_tool_grant_request_skip_retry_notification',
650
+ requestId: request.id,
651
+ toolName: request.toolName,
652
+ followupState: freshRequest?.followupState,
653
+ },
654
+ 'Skipping requester retry notification — inline grant wait is active and will resume the original invocation',
655
+ );
656
+ } else if (channelDeliveryContext && requesterChatId) {
538
657
  try {
539
658
  await deliverChannelReply(channelDeliveryContext.replyCallbackUrl, {
540
659
  chatId: requesterChatId,
@@ -49,6 +49,27 @@ export function getAccessRequestPollIntervalMs(): number {
49
49
  return getConfig().calls.accessRequestPollIntervalMs;
50
50
  }
51
51
 
52
+ export function getGuardianWaitUpdateInitialIntervalMs(): number {
53
+ return getConfig().calls.guardianWaitUpdateInitialIntervalMs;
54
+ }
55
+
56
+ export function getGuardianWaitUpdateInitialWindowMs(): number {
57
+ return getConfig().calls.guardianWaitUpdateInitialWindowMs;
58
+ }
59
+
60
+ export function getGuardianWaitUpdateSteadyMinIntervalMs(): number {
61
+ return getConfig().calls.guardianWaitUpdateSteadyMinIntervalMs;
62
+ }
63
+
64
+ export function getGuardianWaitUpdateSteadyMaxIntervalMs(): number {
65
+ return getConfig().calls.guardianWaitUpdateSteadyMaxIntervalMs;
66
+ }
67
+
68
+ export function getSilenceTimeoutMs(): number {
69
+ return 30 * 1000; // 30 seconds
70
+ }
71
+
72
+ /** @deprecated Use getSilenceTimeoutMs() for mockability in tests. */
52
73
  export const SILENCE_TIMEOUT_MS = 30 * 1000; // 30 seconds
53
74
 
54
75
  // Legacy named exports for backward compatibility (use functions above for config-backed values)
@@ -22,7 +22,7 @@ import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
22
22
  import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
23
23
  import { getLogger } from '../util/logger.js';
24
24
  import { readHttpToken } from '../util/platform.js';
25
- import { getMaxCallDurationMs, getUserConsultationTimeoutMs, SILENCE_TIMEOUT_MS } from './call-constants.js';
25
+ import { getMaxCallDurationMs, getSilenceTimeoutMs, getUserConsultationTimeoutMs } from './call-constants.js';
26
26
  import { persistCallCompletionMessage } from './call-conversation-messages.js';
27
27
  import { addPointerMessage, formatDuration } from './call-pointer-messages.js';
28
28
  import { fireCallCompletionNotifier, fireCallQuestionNotifier, fireCallTranscriptNotifier,registerCallController, unregisterCallController } from './call-state.js';
@@ -1049,8 +1049,15 @@ export class CallController {
1049
1049
  private resetSilenceTimer(): void {
1050
1050
  if (this.silenceTimer) clearTimeout(this.silenceTimer);
1051
1051
  this.silenceTimer = setTimeout(() => {
1052
+ // During guardian wait states, the relay heartbeat timer handles
1053
+ // periodic updates — suppress the generic "Are you still there?"
1054
+ // which is confusing when the caller is waiting on a decision.
1055
+ if (this.relay.getConnectionState() === 'awaiting_guardian_decision') {
1056
+ log.debug({ callSessionId: this.callSessionId }, 'Silence timeout suppressed during guardian wait');
1057
+ return;
1058
+ }
1052
1059
  log.info({ callSessionId: this.callSessionId }, 'Silence timeout triggered');
1053
1060
  this.relay.sendTextToken('Are you still there?', true);
1054
- }, SILENCE_TIMEOUT_MS);
1061
+ }, getSilenceTimeoutMs());
1055
1062
  }
1056
1063
  }
@@ -9,6 +9,7 @@ import { getTwilioUserPhoneNumber } from '../config/env.js';
9
9
  import { loadConfig } from '../config/loader.js';
10
10
  import { VALID_CALLER_IDENTITY_MODES } from '../config/schema.js';
11
11
  import type { AssistantConfig } from '../config/types.js';
12
+ import { resolveCallbackUrl } from '../inbound/platform-callback-registration.js';
12
13
  import { getTwilioStatusCallbackUrl,getTwilioVoiceWebhookUrl } from '../inbound/public-ingress-urls.js';
13
14
  import { getOrCreateConversation } from '../memory/conversation-key-store.js';
14
15
  import { queueGenerateConversationTitle } from '../memory/conversation-title-service.js';
@@ -101,8 +102,7 @@ export type CallerIdentityResult =
101
102
  * - Otherwise, always use `assistant_number` (implicit default).
102
103
  *
103
104
  * For `assistant_number`: uses the Twilio phone number from
104
- * `getTwilioConfig(assistantId)` so multi-assistant mappings are honored.
105
- * No eligibility check is performed — this is a fast path.
105
+ * `getTwilioConfig()`. No eligibility check is performed — this is a fast path.
106
106
  * For `user_number`: uses `config.calls.callerIdentity.userNumber` or the
107
107
  * secure key `credential:twilio:user_phone_number`, then validates that the
108
108
  * number is usable as an outbound caller ID via the Twilio API.
@@ -134,7 +134,7 @@ export async function resolveCallerIdentity(
134
134
 
135
135
  if (mode === 'assistant_number') {
136
136
  const twilioConfig = getTwilioConfig(assistantId);
137
- log.info({ mode, source, fromNumber: twilioConfig.phoneNumber, assistantId }, 'Resolved caller identity');
137
+ log.info({ mode, source, fromNumber: twilioConfig.phoneNumber }, 'Resolved caller identity');
138
138
  return { ok: true, mode, fromNumber: twilioConfig.phoneNumber, source };
139
139
  }
140
140
 
@@ -358,11 +358,23 @@ export async function startCall(input: StartCallInput): Promise<StartCallResult
358
358
 
359
359
  log.info({ callSessionId: session.id, voiceConversationId, initiatedFrom: conversationId, to: phoneNumber, from: fromNumber, task }, 'Initiating outbound call');
360
360
 
361
+ const webhookUrl = await resolveCallbackUrl(
362
+ () => getTwilioVoiceWebhookUrl(ingressConfig, session.id),
363
+ 'webhooks/twilio/voice',
364
+ 'twilio_voice',
365
+ { callSessionId: session.id },
366
+ );
367
+ const statusCallbackUrl = await resolveCallbackUrl(
368
+ () => getTwilioStatusCallbackUrl(ingressConfig),
369
+ 'webhooks/twilio/status',
370
+ 'twilio_status',
371
+ );
372
+
361
373
  const { callSid } = await provider.initiateCall({
362
374
  from: fromNumber,
363
375
  to: phoneNumber,
364
- webhookUrl: getTwilioVoiceWebhookUrl(ingressConfig, session.id),
365
- statusCallbackUrl: getTwilioStatusCallbackUrl(ingressConfig),
376
+ webhookUrl,
377
+ statusCallbackUrl,
366
378
  });
367
379
 
368
380
  updateCallSession(session.id, { providerCallSid: callSid });
@@ -687,8 +699,17 @@ export async function startGuardianVerificationCall(
687
699
  });
688
700
  sessionId = session.id;
689
701
 
690
- const webhookUrl = getTwilioVoiceWebhookUrl(config, session.id);
691
- const statusCallbackUrl = getTwilioStatusCallbackUrl(config);
702
+ const webhookUrl = await resolveCallbackUrl(
703
+ () => getTwilioVoiceWebhookUrl(config, session.id),
704
+ 'webhooks/twilio/voice',
705
+ 'twilio_voice',
706
+ { callSessionId: session.id },
707
+ );
708
+ const statusCallbackUrl = await resolveCallbackUrl(
709
+ () => getTwilioStatusCallbackUrl(config),
710
+ 'webhooks/twilio/status',
711
+ 'twilio_status',
712
+ );
692
713
 
693
714
  const { callSid } = await provider.initiateCall({
694
715
  from: identityResult.fromNumber,
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Layered call pointer message composition system.
3
+ *
4
+ * Generates pointer/status copy through a priority chain:
5
+ * 1. Generator-produced text (when provided by daemon and audience is trusted)
6
+ * 2. Deterministic fallback templates (preserving existing semantics)
7
+ *
8
+ * Follows the same pattern as approval-message-composer.ts and
9
+ * guardian-action-message-composer.ts.
10
+ */
11
+ import type { PointerCopyGenerator } from '../runtime/http-types.js';
12
+ import { getLogger } from '../util/logger.js';
13
+
14
+ const log = getLogger('call-pointer-message-composer');
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Types
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export type CallPointerMessageScenario =
21
+ | 'started'
22
+ | 'completed'
23
+ | 'failed'
24
+ | 'guardian_verification_succeeded'
25
+ | 'guardian_verification_failed';
26
+
27
+ export interface CallPointerMessageContext {
28
+ scenario: CallPointerMessageScenario;
29
+ phoneNumber: string;
30
+ duration?: string;
31
+ reason?: string;
32
+ verificationCode?: string;
33
+ channel?: string;
34
+ }
35
+
36
+ export interface ComposeCallPointerMessageOptions {
37
+ fallbackText?: string;
38
+ requiredFacts?: string[];
39
+ maxTokens?: number;
40
+ timeoutMs?: number;
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Constants (exported for the daemon-injected generator implementation)
45
+ // ---------------------------------------------------------------------------
46
+
47
+ export const POINTER_COPY_TIMEOUT_MS = 3_000;
48
+ export const POINTER_COPY_MAX_TOKENS = 120;
49
+ export const POINTER_COPY_SYSTEM_PROMPT =
50
+ 'You are an assistant writing a brief status update about a phone call. '
51
+ + 'Keep it concise (1-2 sentences), natural, and informative. '
52
+ + 'Preserve all factual details exactly (phone numbers, durations, failure reasons, verification status). '
53
+ + 'Do not mention internal systems or technical details. '
54
+ + 'Return plain text only.';
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Public API
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /**
61
+ * Compose pointer copy using the daemon-injected generator when available,
62
+ * with deterministic fallback for reliability.
63
+ *
64
+ * The generator parameter is the daemon-provided function that knows about
65
+ * providers. When absent (or in test env), only the deterministic fallback
66
+ * is used.
67
+ */
68
+ export async function composeCallPointerMessageGenerative(
69
+ context: CallPointerMessageContext,
70
+ options: ComposeCallPointerMessageOptions = {},
71
+ generator?: PointerCopyGenerator,
72
+ ): Promise<string> {
73
+ const fallbackText = options.fallbackText?.trim() || getPointerFallbackMessage(context);
74
+
75
+ if (process.env.NODE_ENV === 'test') {
76
+ return fallbackText;
77
+ }
78
+
79
+ if (generator) {
80
+ try {
81
+ const generated = await generator(context, options);
82
+ if (generated) return generated;
83
+ } catch (err) {
84
+ log.warn({ err, scenario: context.scenario }, 'Failed to generate pointer copy, using fallback');
85
+ }
86
+ }
87
+
88
+ return fallbackText;
89
+ }
90
+
91
+ /** @internal Exported for use by the daemon-injected generator implementation. */
92
+ export function buildPointerGenerationPrompt(
93
+ context: CallPointerMessageContext,
94
+ fallbackText: string,
95
+ requiredFacts: string[] | undefined,
96
+ ): string {
97
+ const factClause = requiredFacts && requiredFacts.length > 0
98
+ ? `Required facts to include: ${requiredFacts.join(', ')}.\n`
99
+ : '';
100
+ return [
101
+ 'Rewrite the following call status message as a natural, conversational update.',
102
+ 'Keep the same concrete facts (phone number, duration, failure reason, verification status).',
103
+ factClause,
104
+ `Context JSON: ${JSON.stringify(context)}`,
105
+ `Fallback message: ${fallbackText}`,
106
+ ].filter(Boolean).join('\n\n');
107
+ }
108
+
109
+ /** @internal Exported for use by the daemon-injected generator implementation. */
110
+ export function includesRequiredFacts(text: string, requiredFacts: string[] | undefined): boolean {
111
+ if (!requiredFacts || requiredFacts.length === 0) return true;
112
+ return requiredFacts.every((fact) => text.includes(fact));
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Deterministic fallback templates
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Return a scenario-specific deterministic fallback message.
121
+ *
122
+ * These preserve the exact semantics of the original hard-coded pointer
123
+ * templates from call-pointer-messages.ts.
124
+ */
125
+ export function getPointerFallbackMessage(context: CallPointerMessageContext): string {
126
+ switch (context.scenario) {
127
+ case 'started':
128
+ return context.verificationCode
129
+ ? `\u{1F4DE} Call to ${context.phoneNumber} started. Verification code: ${context.verificationCode}`
130
+ : `\u{1F4DE} Call to ${context.phoneNumber} started.`;
131
+ case 'completed':
132
+ return context.duration
133
+ ? `\u{1F4DE} Call to ${context.phoneNumber} completed (${context.duration}).`
134
+ : `\u{1F4DE} Call to ${context.phoneNumber} completed.`;
135
+ case 'failed':
136
+ return context.reason
137
+ ? `\u{1F4DE} Call to ${context.phoneNumber} failed: ${context.reason}.`
138
+ : `\u{1F4DE} Call to ${context.phoneNumber} failed.`;
139
+ case 'guardian_verification_succeeded': {
140
+ const ch = context.channel ?? 'voice';
141
+ return `\u{2705} Guardian verification (${ch}) for ${context.phoneNumber} succeeded.`;
142
+ }
143
+ case 'guardian_verification_failed': {
144
+ const ch = context.channel ?? 'voice';
145
+ return context.reason
146
+ ? `\u{274C} Guardian verification (${ch}) for ${context.phoneNumber} failed: ${context.reason}.`
147
+ : `\u{274C} Guardian verification (${ch}) for ${context.phoneNumber} failed.`;
148
+ }
149
+ default: {
150
+ const _exhaustive: never = context.scenario;
151
+ return `Call status update. ${String(_exhaustive)}`;
152
+ }
153
+ }
154
+ }