@vellumai/assistant 0.3.18 → 0.3.20

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 (202) hide show
  1. package/ARCHITECTURE.md +155 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/docs/architecture/integrations.md +7 -11
  5. package/docs/architecture/security.md +80 -0
  6. package/package.json +1 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -0
  8. package/src/__tests__/approval-primitive.test.ts +540 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
  10. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
  11. package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
  12. package/src/__tests__/call-controller.test.ts +605 -104
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/checker.test.ts +60 -0
  15. package/src/__tests__/cli.test.ts +42 -1
  16. package/src/__tests__/config-schema.test.ts +11 -127
  17. package/src/__tests__/config-watcher.test.ts +0 -8
  18. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  19. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  20. package/src/__tests__/diff.test.ts +22 -0
  21. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  22. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +779 -0
  23. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  24. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  25. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  26. package/src/__tests__/guardian-dispatch.test.ts +185 -1
  27. package/src/__tests__/guardian-grant-minting.test.ts +532 -0
  28. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  29. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  30. package/src/__tests__/ipc-snapshot.test.ts +58 -0
  31. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  32. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  33. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  34. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  35. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  36. package/src/__tests__/scoped-grant-security-matrix.test.ts +444 -0
  37. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  38. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  39. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  40. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  41. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  42. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  43. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  44. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  45. package/src/__tests__/system-prompt.test.ts +1 -1
  46. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  47. package/src/__tests__/terminal-tools.test.ts +2 -93
  48. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  49. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  50. package/src/__tests__/trust-store.test.ts +2 -0
  51. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  52. package/src/__tests__/voice-scoped-grant-consumer.test.ts +533 -0
  53. package/src/agent/loop.ts +36 -1
  54. package/src/approvals/approval-primitive.ts +381 -0
  55. package/src/approvals/guardian-decision-primitive.ts +191 -0
  56. package/src/calls/call-controller.ts +276 -212
  57. package/src/calls/call-domain.ts +56 -6
  58. package/src/calls/guardian-dispatch.ts +56 -0
  59. package/src/calls/relay-server.ts +13 -0
  60. package/src/calls/types.ts +1 -1
  61. package/src/calls/voice-session-bridge.ts +59 -4
  62. package/src/cli/core-commands.ts +0 -4
  63. package/src/cli.ts +76 -34
  64. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  65. package/src/config/assistant-feature-flags.ts +162 -0
  66. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  67. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  68. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  69. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  70. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  71. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  72. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  73. package/src/config/core-schema.ts +1 -1
  74. package/src/config/env-registry.ts +10 -0
  75. package/src/config/feature-flag-registry.json +61 -0
  76. package/src/config/loader.ts +22 -1
  77. package/src/config/sandbox-schema.ts +0 -39
  78. package/src/config/schema.ts +12 -2
  79. package/src/config/skill-state.ts +34 -0
  80. package/src/config/skills-schema.ts +26 -0
  81. package/src/config/skills.ts +9 -0
  82. package/src/config/system-prompt.ts +110 -46
  83. package/src/config/templates/SOUL.md +1 -1
  84. package/src/config/types.ts +19 -1
  85. package/src/config/vellum-skills/catalog.json +1 -1
  86. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  87. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  88. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -1
  89. package/src/config/vellum-skills/trusted-contacts/SKILL.md +104 -3
  90. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  91. package/src/daemon/config-watcher.ts +0 -1
  92. package/src/daemon/daemon-control.ts +1 -1
  93. package/src/daemon/guardian-invite-intent.ts +124 -0
  94. package/src/daemon/handlers/avatar.ts +68 -0
  95. package/src/daemon/handlers/browser.ts +2 -2
  96. package/src/daemon/handlers/config-channels.ts +18 -0
  97. package/src/daemon/handlers/guardian-actions.ts +120 -0
  98. package/src/daemon/handlers/index.ts +4 -0
  99. package/src/daemon/handlers/sessions.ts +19 -0
  100. package/src/daemon/handlers/shared.ts +3 -1
  101. package/src/daemon/handlers/skills.ts +45 -2
  102. package/src/daemon/install-cli-launchers.ts +58 -13
  103. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  104. package/src/daemon/ipc-contract/sessions.ts +8 -2
  105. package/src/daemon/ipc-contract/settings.ts +25 -2
  106. package/src/daemon/ipc-contract/skills.ts +1 -0
  107. package/src/daemon/ipc-contract-inventory.json +10 -0
  108. package/src/daemon/ipc-contract.ts +4 -0
  109. package/src/daemon/lifecycle.ts +6 -2
  110. package/src/daemon/main.ts +1 -0
  111. package/src/daemon/server.ts +1 -0
  112. package/src/daemon/session-lifecycle.ts +52 -7
  113. package/src/daemon/session-memory.ts +45 -0
  114. package/src/daemon/session-process.ts +260 -422
  115. package/src/daemon/session-runtime-assembly.ts +12 -0
  116. package/src/daemon/session-skill-tools.ts +14 -1
  117. package/src/daemon/session-tool-setup.ts +5 -0
  118. package/src/daemon/session.ts +11 -0
  119. package/src/daemon/tool-side-effects.ts +35 -9
  120. package/src/index.ts +0 -2
  121. package/src/memory/conversation-display-order-migration.ts +44 -0
  122. package/src/memory/conversation-queries.ts +2 -0
  123. package/src/memory/conversation-store.ts +91 -0
  124. package/src/memory/db-init.ts +13 -1
  125. package/src/memory/embedding-local.ts +22 -8
  126. package/src/memory/guardian-action-store.ts +133 -2
  127. package/src/memory/guardian-verification.ts +1 -1
  128. package/src/memory/ingress-invite-store.ts +95 -1
  129. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  130. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  131. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  132. package/src/memory/migrations/index.ts +3 -0
  133. package/src/memory/schema.ts +35 -1
  134. package/src/memory/scoped-approval-grants.ts +518 -0
  135. package/src/messaging/providers/slack/client.ts +12 -0
  136. package/src/messaging/providers/slack/types.ts +5 -0
  137. package/src/notifications/decision-engine.ts +49 -12
  138. package/src/notifications/emit-signal.ts +7 -0
  139. package/src/notifications/signal.ts +7 -0
  140. package/src/notifications/thread-seed-composer.ts +2 -1
  141. package/src/permissions/checker.ts +27 -0
  142. package/src/runtime/channel-approval-types.ts +16 -6
  143. package/src/runtime/channel-approvals.ts +19 -15
  144. package/src/runtime/channel-invite-transport.ts +85 -0
  145. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  146. package/src/runtime/guardian-action-grant-minter.ts +154 -0
  147. package/src/runtime/guardian-action-message-composer.ts +30 -0
  148. package/src/runtime/guardian-decision-types.ts +91 -0
  149. package/src/runtime/http-server.ts +23 -1
  150. package/src/runtime/ingress-service.ts +22 -0
  151. package/src/runtime/invite-redemption-service.ts +181 -0
  152. package/src/runtime/invite-redemption-templates.ts +39 -0
  153. package/src/runtime/routes/call-routes.ts +2 -1
  154. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  155. package/src/runtime/routes/guardian-approval-interception.ts +66 -74
  156. package/src/runtime/routes/inbound-message-handler.ts +568 -409
  157. package/src/runtime/routes/pairing-routes.ts +4 -0
  158. package/src/security/encrypted-store.ts +31 -17
  159. package/src/security/keychain.ts +176 -2
  160. package/src/security/secure-keys.ts +97 -0
  161. package/src/security/tool-approval-digest.ts +67 -0
  162. package/src/skills/remote-skill-policy.ts +131 -0
  163. package/src/tools/browser/browser-execution.ts +2 -2
  164. package/src/tools/browser/browser-manager.ts +46 -32
  165. package/src/tools/browser/browser-screencast.ts +2 -2
  166. package/src/tools/calls/call-start.ts +1 -1
  167. package/src/tools/executor.ts +22 -17
  168. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  169. package/src/tools/skills/load.ts +22 -8
  170. package/src/tools/system/avatar-generator.ts +119 -0
  171. package/src/tools/system/navigate-settings.ts +65 -0
  172. package/src/tools/system/open-system-settings.ts +75 -0
  173. package/src/tools/system/voice-config.ts +121 -32
  174. package/src/tools/terminal/backends/native.ts +40 -19
  175. package/src/tools/terminal/backends/types.ts +3 -3
  176. package/src/tools/terminal/parser.ts +1 -1
  177. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  178. package/src/tools/terminal/sandbox.ts +1 -12
  179. package/src/tools/terminal/shell.ts +3 -31
  180. package/src/tools/tool-approval-handler.ts +141 -3
  181. package/src/tools/tool-manifest.ts +6 -0
  182. package/src/tools/types.ts +6 -0
  183. package/src/util/diff.ts +36 -13
  184. package/Dockerfile.sandbox +0 -5
  185. package/src/__tests__/doordash-client.test.ts +0 -187
  186. package/src/__tests__/doordash-session.test.ts +0 -154
  187. package/src/__tests__/signup-e2e.test.ts +0 -354
  188. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  189. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  190. package/src/cli/doordash.ts +0 -1057
  191. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  192. package/src/config/templates/LOOKS.md +0 -25
  193. package/src/doordash/cart-queries.ts +0 -787
  194. package/src/doordash/client.ts +0 -1016
  195. package/src/doordash/order-queries.ts +0 -85
  196. package/src/doordash/queries.ts +0 -13
  197. package/src/doordash/query-extractor.ts +0 -94
  198. package/src/doordash/search-queries.ts +0 -203
  199. package/src/doordash/session.ts +0 -84
  200. package/src/doordash/store-queries.ts +0 -246
  201. package/src/doordash/types.ts +0 -367
  202. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -12,10 +12,15 @@ import { getGatewayInternalBaseUrl } from '../config/env.js';
12
12
  import type { ServerMessage } from '../daemon/ipc-contract.js';
13
13
  import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
14
14
  import {
15
+ backfillSupersessionMetadata,
16
+ expireGuardianActionRequest,
17
+ getByPendingQuestionId,
15
18
  getDeliveriesByRequestId,
16
19
  getPendingRequestByCallSessionId,
17
20
  markTimedOutWithReason,
18
21
  } from '../memory/guardian-action-store.js';
22
+ import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
23
+ import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
19
24
  import { getLogger } from '../util/logger.js';
20
25
  import { readHttpToken } from '../util/platform.js';
21
26
  import { getMaxCallDurationMs, getUserConsultationTimeoutMs, SILENCE_TIMEOUT_MS } from './call-constants.js';
@@ -37,7 +42,19 @@ import { startVoiceTurn, type VoiceTurnHandle } from './voice-session-bridge.js'
37
42
 
38
43
  const log = getLogger('call-controller');
39
44
 
40
- type ControllerState = 'idle' | 'processing' | 'waiting_on_user' | 'speaking';
45
+ type ControllerState = 'idle' | 'processing' | 'speaking';
46
+
47
+ /**
48
+ * Tracks a pending guardian consultation independently of the controller's
49
+ * turn state. This allows the call to continue normal turn processing
50
+ * (idle -> processing -> speaking) while a consultation is outstanding.
51
+ */
52
+ interface PendingConsultation {
53
+ questionText: string;
54
+ questionId: string;
55
+ toolApprovalMeta: { toolName: string; inputDigest: string } | null;
56
+ timer: ReturnType<typeof setTimeout>;
57
+ }
41
58
 
42
59
  const ASK_GUARDIAN_CAPTURE_REGEX = /\[ASK_GUARDIAN:\s*(.+?)\]/;
43
60
  const ASK_GUARDIAN_MARKER_REGEX = /\[ASK_GUARDIAN:\s*.+?\]/g;
@@ -174,15 +191,18 @@ export class CallController {
174
191
  private silenceTimer: ReturnType<typeof setTimeout> | null = null;
175
192
  private durationTimer: ReturnType<typeof setTimeout> | null = null;
176
193
  private durationWarningTimer: ReturnType<typeof setTimeout> | null = null;
177
- private consultationTimer: ReturnType<typeof setTimeout> | null = null;
194
+ /**
195
+ * Tracks the currently pending guardian consultation, if any. Decoupled
196
+ * from the controller's turn state so callers can continue to trigger
197
+ * normal turns while consultation is outstanding.
198
+ */
199
+ private pendingConsultation: PendingConsultation | null = null;
178
200
  private durationEndTimer: ReturnType<typeof setTimeout> | null = null;
179
201
  private task: string | null;
180
202
  /** True when the call session was created via the inbound path (no outbound task). */
181
203
  private isInbound: boolean;
182
- /** Instructions queued while an LLM turn is in-flight or during waiting_on_user */
204
+ /** Instructions queued while an LLM turn is in-flight or during pending consultation */
183
205
  private pendingInstructions: string[] = [];
184
- /** Caller utterances queued while waiting_on_user to prevent re-entrant turns */
185
- private pendingCallerUtterances: Array<{transcript: string, speaker?: PromptSpeakerContext}> = [];
186
206
  /** Ensures the call opener is triggered at most once per call. */
187
207
  private initialGreetingStarted = false;
188
208
  /** Marks that the next caller turn should be treated as an opening acknowledgment. */
@@ -246,6 +266,15 @@ export class CallController {
246
266
  return this.state;
247
267
  }
248
268
 
269
+ /**
270
+ * Returns the question ID of the currently pending guardian consultation,
271
+ * or null if no consultation is active. Used by answerCall to match
272
+ * incoming answers to the correct consultation record.
273
+ */
274
+ getPendingConsultationQuestionId(): string | null {
275
+ return this.pendingConsultation?.questionId ?? null;
276
+ }
277
+
249
278
  /**
250
279
  * Update guardian trust context for subsequent LLM turns.
251
280
  */
@@ -268,19 +297,10 @@ export class CallController {
268
297
 
269
298
  /**
270
299
  * Handle a final caller utterance from the ConversationRelay.
300
+ * Caller utterances always trigger normal turns, even when a guardian
301
+ * consultation is pending — the consultation is tracked separately.
271
302
  */
272
303
  async handleCallerUtterance(transcript: string, speaker?: PromptSpeakerContext): Promise<void> {
273
- // Do not start a new turn while waiting for guardian input — queue
274
- // the utterance so it can be processed after the answer arrives.
275
- if (this.state === 'waiting_on_user') {
276
- log.warn(
277
- { callSessionId: this.callSessionId },
278
- 'Caller utterance received while waiting_on_user — queued for after answer.',
279
- );
280
- this.pendingCallerUtterances.push({ transcript, speaker });
281
- return;
282
- }
283
-
284
304
  const interruptedInFlight = this.state === 'processing' || this.state === 'speaking';
285
305
  // If we're already processing or speaking, abort the in-flight generation
286
306
  if (interruptedInFlight) {
@@ -316,66 +336,39 @@ export class CallController {
316
336
  }
317
337
 
318
338
  /**
319
- * Called when the user (in the chat UI) answers a pending question.
339
+ * Called when the guardian (via chat UI or channel) answers a pending
340
+ * consultation question. Acceptance is gated on having an active
341
+ * pending consultation record, not on controller turn state — so
342
+ * answers can arrive while the controller is idle, processing, or
343
+ * speaking.
320
344
  */
321
345
  async handleUserAnswer(answerText: string): Promise<boolean> {
322
- if (this.state !== 'waiting_on_user') {
346
+ if (!this.pendingConsultation) {
323
347
  log.warn(
324
348
  { callSessionId: this.callSessionId, state: this.state },
325
- 'handleUserAnswer called but controller is not in waiting_on_user state',
349
+ 'handleUserAnswer called but no pending consultation exists',
326
350
  );
327
351
  return false;
328
352
  }
329
353
 
330
- // Clear the consultation timeout
331
- if (this.consultationTimer) {
332
- clearTimeout(this.consultationTimer);
333
- this.consultationTimer = null;
334
- }
335
-
336
- // Defensive: await any lingering turn promise before starting a new one.
337
- if (this.currentTurnPromise) {
338
- const teardownPromise = this.currentTurnPromise;
339
- this.currentTurnPromise = null;
340
- await Promise.race([
341
- teardownPromise.catch(() => {}),
342
- new Promise<void>(resolve => setTimeout(resolve, 2000)),
343
- ]);
344
- }
354
+ // Clear the consultation timeout and record
355
+ clearTimeout(this.pendingConsultation.timer);
356
+ this.pendingConsultation = null;
345
357
 
346
- this.state = 'processing';
347
358
  updateCallSession(this.callSessionId, { status: 'in_progress' });
348
359
 
349
- // Merge any instructions that were queued during the waiting_on_user
350
- // state into a single user message alongside the answer to avoid
351
- // consecutive user-role messages (which violate API role-alternation
352
- // requirements).
353
- const parts: string[] = [];
354
- for (const instr of this.pendingInstructions) {
355
- parts.push(`[USER_INSTRUCTION: ${instr}]`);
356
- }
357
- this.pendingInstructions = [];
358
- parts.push(`[USER_ANSWERED: ${answerText}]`);
360
+ // Inject the answer as a queued instruction so it merges into the
361
+ // next turn naturally, respecting role-alternation. If the controller
362
+ // is idle the instruction flush will fire a turn immediately.
363
+ this.pendingInstructions.push(`[USER_ANSWERED: ${answerText}]`);
359
364
 
360
- const content = parts.join('\n');
365
+ // If the controller is idle, flush instructions immediately to
366
+ // deliver the answer. If processing/speaking, the answer will be
367
+ // delivered when the current turn completes via flushPendingInstructions.
368
+ if (this.state === 'idle') {
369
+ this.flushPendingInstructions();
370
+ }
361
371
 
362
- // Fire-and-forget: unblock the caller so the HTTP response and answer
363
- // persistence happen immediately, before LLM streaming begins.
364
- this.runTurn(content)
365
- .then(() => {
366
- // If the answer turn ended the call (e.g. [END_CALL]), don't drain
367
- // queued utterances — just discard them to avoid starting a fresh
368
- // turn on a dead session.
369
- if (this.state === 'idle' && this.isCallCompleted()) {
370
- this.pendingCallerUtterances = [];
371
- return;
372
- }
373
- this.drainPendingCallerUtterances();
374
- })
375
- .catch((err) => {
376
- this.pendingCallerUtterances = [];
377
- log.error({ err, callSessionId: this.callSessionId }, 'runTurn failed after user answer');
378
- });
379
372
  return true;
380
373
  }
381
374
 
@@ -384,17 +377,16 @@ export class CallController {
384
377
  * The instruction is formatted as a dedicated marker that the system prompt
385
378
  * tells the model to treat as high-priority steering input.
386
379
  *
387
- * When the LLM is actively processing or speaking, or when the controller
388
- * is waiting on a user answer, the instruction is queued and spliced into
389
- * the conversation at the correct chronological position once the current
390
- * turn completes.
380
+ * When the LLM is actively processing or speaking, the instruction is
381
+ * queued and spliced into the conversation at the correct chronological
382
+ * position once the current turn completes.
391
383
  */
392
384
  async handleUserInstruction(instructionText: string): Promise<void> {
393
385
  recordCallEvent(this.callSessionId, 'user_instruction_relayed', { instruction: instructionText });
394
386
 
395
387
  // Queue the instruction when it cannot be safely appended right now
396
- if (this.state === 'processing' || this.state === 'speaking' || this.state === 'waiting_on_user') {
397
- this.pendingInstructions.push(instructionText);
388
+ if (this.state === 'processing' || this.state === 'speaking') {
389
+ this.pendingInstructions.push(`[USER_INSTRUCTION: ${instructionText}]`);
398
390
  return;
399
391
  }
400
392
 
@@ -430,12 +422,27 @@ export class CallController {
430
422
  if (this.silenceTimer) clearTimeout(this.silenceTimer);
431
423
  if (this.durationTimer) clearTimeout(this.durationTimer);
432
424
  if (this.durationWarningTimer) clearTimeout(this.durationWarningTimer);
433
- if (this.consultationTimer) clearTimeout(this.consultationTimer);
425
+ if (this.pendingConsultation) { clearTimeout(this.pendingConsultation.timer); this.pendingConsultation = null; }
434
426
  if (this.durationEndTimer) { clearTimeout(this.durationEndTimer); this.durationEndTimer = null; }
435
427
  this.llmRunVersion++;
436
428
  this.abortCurrentTurn();
437
429
  this.currentTurnPromise = null;
438
430
  unregisterCallController(this.callSessionId);
431
+
432
+ // Revoke any scoped approval grants bound to this call session.
433
+ // Revoke by both callSessionId and conversationId because the
434
+ // guardian-approval-interception minting path sets callSessionId: null
435
+ // but always sets conversationId.
436
+ try {
437
+ let revoked = revokeScopedApprovalGrantsForContext({ callSessionId: this.callSessionId });
438
+ revoked += revokeScopedApprovalGrantsForContext({ conversationId: this.conversationId });
439
+ if (revoked > 0) {
440
+ log.info({ callSessionId: this.callSessionId, conversationId: this.conversationId, revokedCount: revoked }, 'Revoked scoped grants on call end');
441
+ }
442
+ } catch (err) {
443
+ log.warn({ err, callSessionId: this.callSessionId }, 'Failed to revoke scoped grants on call end');
444
+ }
445
+
439
446
  log.info({ callSessionId: this.callSessionId }, 'CallController destroyed');
440
447
  }
441
448
 
@@ -574,6 +581,7 @@ export class CallController {
574
581
  // Start the voice turn through the session bridge
575
582
  startVoiceTurn({
576
583
  conversationId: this.conversationId,
584
+ callSessionId: this.callSessionId,
577
585
  content,
578
586
  assistantId: this.assistantId,
579
587
  guardianContext: this.guardianContext ?? undefined,
@@ -635,23 +643,24 @@ export class CallController {
635
643
  // `}]` inside JSON string values does not truncate the payload or
636
644
  // leak partial JSON into TTS output.
637
645
  const approvalMatch = extractBalancedJson(responseText);
638
- let approvalQuestion: string | null = null;
646
+ let toolApprovalMeta: { question: string; toolName: string; inputDigest: string } | null = null;
639
647
  if (approvalMatch) {
640
648
  try {
641
- const parsed = JSON.parse(approvalMatch.json) as { question?: string };
642
- if (parsed.question) {
643
- approvalQuestion = parsed.question;
649
+ const parsed = JSON.parse(approvalMatch.json) as { question?: string; toolName?: string; input?: Record<string, unknown> };
650
+ if (parsed.question && parsed.toolName && parsed.input) {
651
+ const digest = computeToolApprovalDigest(parsed.toolName, parsed.input);
652
+ toolApprovalMeta = { question: parsed.question, toolName: parsed.toolName, inputDigest: digest };
644
653
  }
645
654
  } catch {
646
655
  log.warn({ callSessionId: this.callSessionId }, 'Failed to parse ASK_GUARDIAN_APPROVAL JSON payload');
647
656
  }
648
657
  }
649
658
 
650
- const askMatch = approvalQuestion
659
+ const askMatch = toolApprovalMeta
651
660
  ? null // structured approval takes precedence
652
661
  : responseText.match(ASK_GUARDIAN_CAPTURE_REGEX);
653
662
 
654
- const questionText = approvalQuestion ?? (askMatch ? askMatch[1] : null);
663
+ const questionText = toolApprovalMeta?.question ?? (askMatch ? askMatch[1] : null);
655
664
 
656
665
  if (questionText) {
657
666
  if (this.isCallerGuardian()) {
@@ -673,103 +682,101 @@ export class CallController {
673
682
  + `The unanswered question was: "${questionText}"`,
674
683
  );
675
684
  // Fall through to normal turn completion (idle + flushPendingInstructions)
685
+ } else if (this.pendingInstructions.some((instr) => instr.startsWith('[USER_ANSWERED:'))) {
686
+ // A guardian answer arrived mid-turn and is queued in
687
+ // pendingInstructions but hasn't been flushed yet. The in-flight
688
+ // LLM response was generated without knowledge of this answer, so
689
+ // creating a new consultation now would supersede the old one and
690
+ // desynchronize the flow. Skip this consultation — the answer will
691
+ // be flushed on the next turn, and if the model still needs to
692
+ // consult a guardian, it will emit another ASK_GUARDIAN then.
693
+ log.info({ callSessionId: this.callSessionId }, 'Deferring ASK_GUARDIAN — queued USER_ANSWERED pending');
694
+ recordCallEvent(this.callSessionId, 'guardian_consult_deferred', { question: questionText });
695
+ // Fall through to normal turn completion (idle + flushPendingInstructions)
676
696
  } else {
677
- const pendingQuestion = createPendingQuestion(this.callSessionId, questionText);
678
- this.state = 'waiting_on_user';
679
- updateCallSession(this.callSessionId, { status: 'waiting_on_user' });
680
- recordCallEvent(this.callSessionId, 'user_question_asked', { question: questionText });
681
-
682
- // Notify the conversation that a question was asked
683
- const session = getCallSession(this.callSessionId);
684
- if (session) {
685
- fireCallQuestionNotifier(session.conversationId, this.callSessionId, questionText);
686
-
687
- // Dispatch guardian action request to all configured channels
688
- void dispatchGuardianQuestion({
689
- callSessionId: this.callSessionId,
690
- conversationId: session.conversationId,
691
- assistantId: this.assistantId,
692
- pendingQuestion,
693
- });
694
- }
695
-
696
- // Set a consultation timeout
697
- this.consultationTimer = setTimeout(() => {
698
- if (this.state !== 'waiting_on_user') return;
699
-
700
- log.info({ callSessionId: this.callSessionId }, 'User consultation timed out');
701
-
702
- // Mark the linked guardian action request as timed out and
703
- // send expiry notices to guardian destinations. Deliveries
704
- // must be captured before markTimedOutWithReason changes
705
- // their status.
706
- const pendingActionRequest = getPendingRequestByCallSessionId(this.callSessionId);
707
- if (pendingActionRequest) {
708
- const deliveries = getDeliveriesByRequestId(pendingActionRequest.id);
709
- markTimedOutWithReason(pendingActionRequest.id, 'call_timeout');
697
+ // Determine the effective tool metadata for this ask. If the new
698
+ // ask has structured tool metadata, use it; otherwise inherit from
699
+ // the prior pending consultation (preserves tool scope on re-asks).
700
+ const effectiveToolMeta = toolApprovalMeta
701
+ ? { toolName: toolApprovalMeta.toolName, inputDigest: toolApprovalMeta.inputDigest }
702
+ : this.pendingConsultation?.toolApprovalMeta ?? null;
703
+
704
+ // Coalesce repeated identical asks: if a consultation is already
705
+ // pending for the same tool/action (or same informational question),
706
+ // avoid churning requests and just keep the existing one.
707
+ if (this.pendingConsultation) {
708
+ const isSameToolAction =
709
+ effectiveToolMeta && this.pendingConsultation.toolApprovalMeta
710
+ ? effectiveToolMeta.toolName === this.pendingConsultation.toolApprovalMeta.toolName
711
+ && effectiveToolMeta.inputDigest === this.pendingConsultation.toolApprovalMeta.inputDigest
712
+ : !effectiveToolMeta && !this.pendingConsultation.toolApprovalMeta
713
+ && questionText === this.pendingConsultation.questionText;
714
+
715
+ if (isSameToolAction) {
716
+ // Same tool/action — coalesce. Keep the existing consultation
717
+ // alive and skip creating a new request.
710
718
  log.info(
711
- { callSessionId: this.callSessionId, requestId: pendingActionRequest.id },
712
- 'Marked guardian action request as timed out',
719
+ { callSessionId: this.callSessionId, questionId: this.pendingConsultation.questionId },
720
+ 'Coalescing repeated ASK_GUARDIAN same tool/action already pending',
713
721
  );
714
- void sendGuardianExpiryNotices(
715
- deliveries,
716
- pendingActionRequest.assistantId,
717
- getGatewayInternalBaseUrl(),
718
- readHttpToken() ?? undefined,
719
- ).catch((err) => {
720
- log.error(
721
- { err, callSessionId: this.callSessionId, requestId: pendingActionRequest.id },
722
- 'Failed to send guardian action expiry notices after call timeout',
722
+ recordCallEvent(this.callSessionId, 'guardian_consult_coalesced', { question: questionText });
723
+ // Fall through to normal turn completion (idle + flushPendingInstructions)
724
+ } else {
725
+ // Materially different intent — supersede the old consultation.
726
+ clearTimeout(this.pendingConsultation.timer);
727
+
728
+ // Expire the previous consultation's storage records so stale
729
+ // guardian answers cannot match the old request.
730
+ expirePendingQuestions(this.callSessionId);
731
+ const previousRequest = getPendingRequestByCallSessionId(this.callSessionId);
732
+ if (previousRequest) {
733
+ // Immediately expire with 'superseded' reason to prevent
734
+ // stale answers from resolving the old request.
735
+ expireGuardianActionRequest(previousRequest.id, 'superseded');
736
+ log.info(
737
+ { callSessionId: this.callSessionId, requestId: previousRequest.id },
738
+ 'Superseded guardian action request (materially different intent)',
723
739
  );
724
- });
725
- }
726
-
727
- // Expire pending questions and update call state
728
- expirePendingQuestions(this.callSessionId);
729
- this.state = 'idle';
730
- updateCallSession(this.callSessionId, { status: 'in_progress' });
731
- this.guardianUnavailableForCall = true;
732
- recordCallEvent(this.callSessionId, 'guardian_consultation_timed_out', { question: questionText });
733
-
734
- // Restart silence detection before firing the generated turn
735
- this.resetSilenceTimer();
736
-
737
- // Build a generated turn instruction instead of hardcoded text.
738
- // Merge any queued instructions and caller utterances into the
739
- // timeout turn to avoid concurrent-turn races.
740
- const timeoutInstruction =
741
- `[GUARDIAN_TIMEOUT] Your guardian did not respond in time to your question: "${questionText}". `
742
- + `Apologize to the caller for the delay, let them know you were unable to reach your guardian, `
743
- + `ask if they would like to leave a message or receive a callback, `
744
- + `and ask if there are any other questions you can help with right now.`;
745
-
746
- const parts: string[] = [];
747
- for (const instr of this.pendingInstructions) {
748
- parts.push(`[USER_INSTRUCTION: ${instr}]`);
749
- }
750
- this.pendingInstructions = [];
751
- parts.push(`[USER_INSTRUCTION: ${timeoutInstruction}]`);
752
-
753
- if (this.pendingCallerUtterances.length > 0) {
754
- const latest = this.pendingCallerUtterances[this.pendingCallerUtterances.length - 1];
755
- this.pendingCallerUtterances = [];
756
- const callerContent = this.formatCallerUtterance(latest.transcript, latest.speaker);
757
- if (callerContent.length > 0) {
758
- parts.push(callerContent);
759
740
  }
760
- }
761
741
 
762
- const content = parts.join('\n');
763
- this.runTurn(content).catch((err) =>
764
- log.error({ err, callSessionId: this.callSessionId }, 'runTurn failed after guardian consultation timeout'),
765
- );
766
- }, getUserConsultationTimeoutMs());
767
- return;
742
+ this.pendingConsultation = null;
743
+
744
+ // Dispatch the new consultation with effective tool metadata.
745
+ // The previous request ID is passed through so the dispatch
746
+ // can backfill supersession chain metadata (superseded_by_request_id)
747
+ // once the new request has been created.
748
+ this.dispatchNewConsultation(questionText, effectiveToolMeta, previousRequest?.id ?? null);
749
+ }
750
+ } else {
751
+ // No prior consultation — dispatch fresh
752
+ this.dispatchNewConsultation(questionText, effectiveToolMeta, null);
753
+ }
768
754
  }
769
755
  }
770
756
 
771
757
  // Check for END_CALL marker
772
758
  if (responseText.includes(END_CALL_MARKER)) {
759
+ // Clear any pending consultation before completing the call.
760
+ // Without this, the consultation timeout can fire on an already-ended
761
+ // call, overwriting 'completed' status back to 'in_progress' and
762
+ // starting a new LLM turn on a dead session. Similarly, a late
763
+ // handleUserAnswer could be accepted since pendingConsultation is
764
+ // still non-null.
765
+ if (this.pendingConsultation) {
766
+ clearTimeout(this.pendingConsultation.timer);
767
+
768
+ // Expire store-side consultation records so clients don't observe
769
+ // a completed call with a dangling pendingQuestion, and guardian
770
+ // replies are cleanly rejected instead of hitting answerCall failures.
771
+ expirePendingQuestions(this.callSessionId);
772
+ const previousRequest = getPendingRequestByCallSessionId(this.callSessionId);
773
+ if (previousRequest) {
774
+ expireGuardianActionRequest(previousRequest.id, 'cancelled');
775
+ }
776
+
777
+ this.pendingConsultation = null;
778
+ }
779
+
773
780
  const currentSession = getCallSession(this.callSessionId);
774
781
  const shouldNotifyCompletion = currentSession
775
782
  ? currentSession.status !== 'completed' && currentSession.status !== 'failed' && currentSession.status !== 'cancelled'
@@ -854,14 +861,114 @@ export class CallController {
854
861
  }
855
862
 
856
863
  /**
857
- * Check whether the underlying call session has already ended.
858
- * Used to guard against post-completion work (e.g. draining queued
859
- * utterances after an [END_CALL] turn).
864
+ * Create a new consultation: persist a pending question, dispatch
865
+ * guardian action request to channels, and start the consultation timer.
866
+ *
867
+ * If `supersededRequestId` is provided, backfills the supersession
868
+ * chain after the new request is created.
860
869
  */
861
- private isCallCompleted(): boolean {
870
+ private dispatchNewConsultation(
871
+ questionText: string,
872
+ effectiveToolMeta: { toolName: string; inputDigest: string } | null,
873
+ supersededRequestId: string | null,
874
+ ): void {
875
+ const pendingQuestion = createPendingQuestion(this.callSessionId, questionText);
876
+ updateCallSession(this.callSessionId, { status: 'waiting_on_user' });
877
+ recordCallEvent(this.callSessionId, 'user_question_asked', { question: questionText });
878
+
879
+ // Notify the conversation that a question was asked
862
880
  const session = getCallSession(this.callSessionId);
863
- if (!session) return true;
864
- return session.status === 'completed' || session.status === 'failed' || session.status === 'cancelled';
881
+ if (session) {
882
+ fireCallQuestionNotifier(session.conversationId, this.callSessionId, questionText);
883
+
884
+ // Dispatch guardian action request to all configured channels
885
+ // Capture the pending question ID in a closure for stable lookup
886
+ // after the async dispatch completes — avoids a racy
887
+ // getPendingRequestByCallSessionId lookup that could return a
888
+ // different request if another supersession occurs during the gap.
889
+ const stablePendingQuestionId = pendingQuestion.id;
890
+ void dispatchGuardianQuestion({
891
+ callSessionId: this.callSessionId,
892
+ conversationId: session.conversationId,
893
+ assistantId: this.assistantId,
894
+ pendingQuestion,
895
+ toolName: effectiveToolMeta?.toolName,
896
+ inputDigest: effectiveToolMeta?.inputDigest,
897
+ }).then(() => {
898
+ // Backfill supersession chain: now that the new request exists in
899
+ // the store, update the old request's superseded_by_request_id.
900
+ if (supersededRequestId) {
901
+ const newRequest = getByPendingQuestionId(stablePendingQuestionId);
902
+ if (newRequest) {
903
+ backfillSupersessionMetadata(supersededRequestId, newRequest.id);
904
+ }
905
+ }
906
+ });
907
+ }
908
+
909
+ // Set a consultation timeout tied to this specific consultation
910
+ // record, not the global controller state.
911
+ const consultationTimer = setTimeout(() => {
912
+ // Only fire if this consultation is still the active one
913
+ if (!this.pendingConsultation || this.pendingConsultation.questionId !== pendingQuestion.id) return;
914
+
915
+ log.info({ callSessionId: this.callSessionId }, 'Guardian consultation timed out');
916
+
917
+ // Mark the linked guardian action request as timed out and
918
+ // send expiry notices to guardian destinations. Deliveries
919
+ // must be captured before markTimedOutWithReason changes
920
+ // their status.
921
+ const pendingActionRequest = getPendingRequestByCallSessionId(this.callSessionId);
922
+ if (pendingActionRequest) {
923
+ const deliveries = getDeliveriesByRequestId(pendingActionRequest.id);
924
+ markTimedOutWithReason(pendingActionRequest.id, 'call_timeout');
925
+ log.info(
926
+ { callSessionId: this.callSessionId, requestId: pendingActionRequest.id },
927
+ 'Marked guardian action request as timed out',
928
+ );
929
+ void sendGuardianExpiryNotices(
930
+ deliveries,
931
+ pendingActionRequest.assistantId,
932
+ getGatewayInternalBaseUrl(),
933
+ readHttpToken() ?? undefined,
934
+ ).catch((err) => {
935
+ log.error(
936
+ { err, callSessionId: this.callSessionId, requestId: pendingActionRequest.id },
937
+ 'Failed to send guardian action expiry notices after call timeout',
938
+ );
939
+ });
940
+ }
941
+
942
+ // Expire pending questions and update call state
943
+ expirePendingQuestions(this.callSessionId);
944
+ this.pendingConsultation = null;
945
+ updateCallSession(this.callSessionId, { status: 'in_progress' });
946
+ this.guardianUnavailableForCall = true;
947
+ recordCallEvent(this.callSessionId, 'guardian_consultation_timed_out', { question: questionText });
948
+
949
+ // Inject timeout instruction so the model addresses it on the
950
+ // next turn. If idle, flush immediately; otherwise it merges
951
+ // into the next turn completion.
952
+ const timeoutInstruction =
953
+ `[GUARDIAN_TIMEOUT] Your guardian did not respond in time to your question: "${questionText}". `
954
+ + `Apologize to the caller for the delay, let them know you were unable to reach your guardian, `
955
+ + `ask if they would like to leave a message or receive a callback, `
956
+ + `and ask if there are any other questions you can help with right now.`;
957
+
958
+ this.pendingInstructions.push(timeoutInstruction);
959
+
960
+ if (this.state === 'idle') {
961
+ this.resetSilenceTimer();
962
+ this.flushPendingInstructions();
963
+ }
964
+ }, getUserConsultationTimeoutMs());
965
+
966
+ this.pendingConsultation = {
967
+ questionText,
968
+ questionId: pendingQuestion.id,
969
+ toolApprovalMeta: effectiveToolMeta,
970
+ timer: consultationTimer,
971
+ };
865
972
  }
866
973
 
867
974
  /**
@@ -871,7 +978,7 @@ export class CallController {
871
978
  if (this.pendingInstructions.length === 0) return;
872
979
 
873
980
  const parts = this.pendingInstructions.map(
874
- (instr) => `[USER_INSTRUCTION: ${instr}]`,
981
+ (instr) => instr.startsWith('[') ? instr : `[USER_INSTRUCTION: ${instr}]`,
875
982
  );
876
983
  this.pendingInstructions = [];
877
984
 
@@ -885,49 +992,6 @@ export class CallController {
885
992
  );
886
993
  }
887
994
 
888
- /**
889
- * Drain caller utterances that were queued while waiting_on_user.
890
- * Only the most recent utterance is processed — older ones are discarded
891
- * as stale since the caller likely moved on.
892
- *
893
- * @param contentPrefix — optional string (e.g. instruction markers) to
894
- * prepend to the turn content so instructions and the caller utterance
895
- * are sent as a single turn, avoiding concurrent-turn races.
896
- */
897
- private drainPendingCallerUtterances(contentPrefix?: string): void {
898
- if (this.pendingCallerUtterances.length === 0) return;
899
-
900
- // Keep only the most recent utterance; discard stale older ones
901
- const latest = this.pendingCallerUtterances[this.pendingCallerUtterances.length - 1];
902
- this.pendingCallerUtterances = [];
903
-
904
- if (contentPrefix) {
905
- // Merge prefix content with the caller utterance into a single turn
906
- let callerContent = this.formatCallerUtterance(latest.transcript, latest.speaker);
907
-
908
- // Preserve opening-ack semantics when draining bypasses handleCallerUtterance
909
- if (this.awaitingOpeningAck) {
910
- callerContent = callerContent.length > 0
911
- ? `${CALL_OPENING_ACK_MARKER}\n${callerContent}`
912
- : CALL_OPENING_ACK_MARKER;
913
- this.awaitingOpeningAck = false;
914
- this.lastSentWasOpener = false;
915
- }
916
-
917
- const combined = `${contentPrefix}\n${callerContent}`;
918
- this.resetSilenceTimer();
919
- this.runTurn(combined).catch((err) =>
920
- log.error({ err, callSessionId: this.callSessionId }, 'runTurn failed after draining queued caller utterance with prefix'),
921
- );
922
- return;
923
- }
924
-
925
- // Fire-and-forget so we don't block the current turn's cleanup.
926
- this.handleCallerUtterance(latest.transcript, latest.speaker).catch((err) =>
927
- log.error({ err, callSessionId: this.callSessionId }, 'runTurn failed after draining queued caller utterance'),
928
- );
929
- }
930
-
931
995
  private startDurationTimer(): void {
932
996
  const maxDurationMs = getMaxCallDurationMs();
933
997
  const warningMs = maxDurationMs - 2 * 60 * 1000; // 2 minutes before max