@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.
- package/ARCHITECTURE.md +155 -15
- package/Dockerfile +1 -0
- package/README.md +40 -4
- package/docs/architecture/integrations.md +7 -11
- package/docs/architecture/security.md +80 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -0
- package/src/__tests__/approval-primitive.test.ts +540 -0
- package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
- package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
- package/src/__tests__/call-controller.test.ts +605 -104
- package/src/__tests__/channel-invite-transport.test.ts +264 -0
- package/src/__tests__/checker.test.ts +60 -0
- package/src/__tests__/cli.test.ts +42 -1
- package/src/__tests__/config-schema.test.ts +11 -127
- package/src/__tests__/config-watcher.test.ts +0 -8
- package/src/__tests__/daemon-lifecycle.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +8 -2
- package/src/__tests__/diff.test.ts +22 -0
- package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +779 -0
- package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
- package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
- package/src/__tests__/guardian-dispatch.test.ts +185 -1
- package/src/__tests__/guardian-grant-minting.test.ts +532 -0
- package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
- package/src/__tests__/invite-redemption-service.test.ts +306 -0
- package/src/__tests__/ipc-snapshot.test.ts +58 -0
- package/src/__tests__/notification-decision-fallback.test.ts +88 -0
- package/src/__tests__/remote-skill-policy.test.ts +215 -0
- package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
- package/src/__tests__/sandbox-host-parity.test.ts +6 -13
- package/src/__tests__/scoped-approval-grants.test.ts +521 -0
- package/src/__tests__/scoped-grant-security-matrix.test.ts +444 -0
- package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
- package/src/__tests__/session-load-history-repair.test.ts +169 -2
- package/src/__tests__/session-runtime-assembly.test.ts +33 -5
- package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
- package/src/__tests__/skill-feature-flags.test.ts +188 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
- package/src/__tests__/skill-mirror-parity.test.ts +1 -0
- package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
- package/src/__tests__/system-prompt.test.ts +1 -1
- package/src/__tests__/terminal-sandbox.test.ts +142 -9
- package/src/__tests__/terminal-tools.test.ts +2 -93
- package/src/__tests__/thread-seed-composer.test.ts +18 -0
- package/src/__tests__/tool-approval-handler.test.ts +350 -0
- package/src/__tests__/trust-store.test.ts +2 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +533 -0
- package/src/agent/loop.ts +36 -1
- package/src/approvals/approval-primitive.ts +381 -0
- package/src/approvals/guardian-decision-primitive.ts +191 -0
- package/src/calls/call-controller.ts +276 -212
- package/src/calls/call-domain.ts +56 -6
- package/src/calls/guardian-dispatch.ts +56 -0
- package/src/calls/relay-server.ts +13 -0
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +59 -4
- package/src/cli/core-commands.ts +0 -4
- package/src/cli.ts +76 -34
- package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
- package/src/config/assistant-feature-flags.ts +162 -0
- package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
- package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
- package/src/config/bundled-skills/notifications/SKILL.md +18 -0
- package/src/config/bundled-skills/reminder/SKILL.md +49 -2
- package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
- package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
- package/src/config/core-schema.ts +1 -1
- package/src/config/env-registry.ts +10 -0
- package/src/config/feature-flag-registry.json +61 -0
- package/src/config/loader.ts +22 -1
- package/src/config/sandbox-schema.ts +0 -39
- package/src/config/schema.ts +12 -2
- package/src/config/skill-state.ts +34 -0
- package/src/config/skills-schema.ts +26 -0
- package/src/config/skills.ts +9 -0
- package/src/config/system-prompt.ts +110 -46
- package/src/config/templates/SOUL.md +1 -1
- package/src/config/types.ts +19 -1
- package/src/config/vellum-skills/catalog.json +1 -1
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
- package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -1
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +104 -3
- package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/config-watcher.ts +0 -1
- package/src/daemon/daemon-control.ts +1 -1
- package/src/daemon/guardian-invite-intent.ts +124 -0
- package/src/daemon/handlers/avatar.ts +68 -0
- package/src/daemon/handlers/browser.ts +2 -2
- package/src/daemon/handlers/config-channels.ts +18 -0
- package/src/daemon/handlers/guardian-actions.ts +120 -0
- package/src/daemon/handlers/index.ts +4 -0
- package/src/daemon/handlers/sessions.ts +19 -0
- package/src/daemon/handlers/shared.ts +3 -1
- package/src/daemon/handlers/skills.ts +45 -2
- package/src/daemon/install-cli-launchers.ts +58 -13
- package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
- package/src/daemon/ipc-contract/sessions.ts +8 -2
- package/src/daemon/ipc-contract/settings.ts +25 -2
- package/src/daemon/ipc-contract/skills.ts +1 -0
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +4 -0
- package/src/daemon/lifecycle.ts +6 -2
- package/src/daemon/main.ts +1 -0
- package/src/daemon/server.ts +1 -0
- package/src/daemon/session-lifecycle.ts +52 -7
- package/src/daemon/session-memory.ts +45 -0
- package/src/daemon/session-process.ts +260 -422
- package/src/daemon/session-runtime-assembly.ts +12 -0
- package/src/daemon/session-skill-tools.ts +14 -1
- package/src/daemon/session-tool-setup.ts +5 -0
- package/src/daemon/session.ts +11 -0
- package/src/daemon/tool-side-effects.ts +35 -9
- package/src/index.ts +0 -2
- package/src/memory/conversation-display-order-migration.ts +44 -0
- package/src/memory/conversation-queries.ts +2 -0
- package/src/memory/conversation-store.ts +91 -0
- package/src/memory/db-init.ts +13 -1
- package/src/memory/embedding-local.ts +22 -8
- package/src/memory/guardian-action-store.ts +133 -2
- package/src/memory/guardian-verification.ts +1 -1
- package/src/memory/ingress-invite-store.ts +95 -1
- package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
- package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
- package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
- package/src/memory/migrations/index.ts +3 -0
- package/src/memory/schema.ts +35 -1
- package/src/memory/scoped-approval-grants.ts +518 -0
- package/src/messaging/providers/slack/client.ts +12 -0
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/notifications/decision-engine.ts +49 -12
- package/src/notifications/emit-signal.ts +7 -0
- package/src/notifications/signal.ts +7 -0
- package/src/notifications/thread-seed-composer.ts +2 -1
- package/src/permissions/checker.ts +27 -0
- package/src/runtime/channel-approval-types.ts +16 -6
- package/src/runtime/channel-approvals.ts +19 -15
- package/src/runtime/channel-invite-transport.ts +85 -0
- package/src/runtime/channel-invite-transports/telegram.ts +105 -0
- package/src/runtime/guardian-action-grant-minter.ts +154 -0
- package/src/runtime/guardian-action-message-composer.ts +30 -0
- package/src/runtime/guardian-decision-types.ts +91 -0
- package/src/runtime/http-server.ts +23 -1
- package/src/runtime/ingress-service.ts +22 -0
- package/src/runtime/invite-redemption-service.ts +181 -0
- package/src/runtime/invite-redemption-templates.ts +39 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/guardian-action-routes.ts +206 -0
- package/src/runtime/routes/guardian-approval-interception.ts +66 -74
- package/src/runtime/routes/inbound-message-handler.ts +568 -409
- package/src/runtime/routes/pairing-routes.ts +4 -0
- package/src/security/encrypted-store.ts +31 -17
- package/src/security/keychain.ts +176 -2
- package/src/security/secure-keys.ts +97 -0
- package/src/security/tool-approval-digest.ts +67 -0
- package/src/skills/remote-skill-policy.ts +131 -0
- package/src/tools/browser/browser-execution.ts +2 -2
- package/src/tools/browser/browser-manager.ts +46 -32
- package/src/tools/browser/browser-screencast.ts +2 -2
- package/src/tools/calls/call-start.ts +1 -1
- package/src/tools/executor.ts +22 -17
- package/src/tools/network/script-proxy/session-manager.ts +1 -5
- package/src/tools/skills/load.ts +22 -8
- package/src/tools/system/avatar-generator.ts +119 -0
- package/src/tools/system/navigate-settings.ts +65 -0
- package/src/tools/system/open-system-settings.ts +75 -0
- package/src/tools/system/voice-config.ts +121 -32
- package/src/tools/terminal/backends/native.ts +40 -19
- package/src/tools/terminal/backends/types.ts +3 -3
- package/src/tools/terminal/parser.ts +1 -1
- package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
- package/src/tools/terminal/sandbox.ts +1 -12
- package/src/tools/terminal/shell.ts +3 -31
- package/src/tools/tool-approval-handler.ts +141 -3
- package/src/tools/tool-manifest.ts +6 -0
- package/src/tools/types.ts +6 -0
- package/src/util/diff.ts +36 -13
- package/Dockerfile.sandbox +0 -5
- package/src/__tests__/doordash-client.test.ts +0 -187
- package/src/__tests__/doordash-session.test.ts +0 -154
- package/src/__tests__/signup-e2e.test.ts +0 -354
- package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
- package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
- package/src/cli/doordash.ts +0 -1057
- package/src/config/bundled-skills/doordash/SKILL.md +0 -163
- package/src/config/templates/LOOKS.md +0 -25
- package/src/doordash/cart-queries.ts +0 -787
- package/src/doordash/client.ts +0 -1016
- package/src/doordash/order-queries.ts +0 -85
- package/src/doordash/queries.ts +0 -13
- package/src/doordash/query-extractor.ts +0 -94
- package/src/doordash/search-queries.ts +0 -203
- package/src/doordash/session.ts +0 -84
- package/src/doordash/store-queries.ts +0 -246
- package/src/doordash/types.ts +0 -367
- 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' | '
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
346
|
+
if (!this.pendingConsultation) {
|
|
323
347
|
log.warn(
|
|
324
348
|
{ callSessionId: this.callSessionId, state: this.state },
|
|
325
|
-
'handleUserAnswer called but
|
|
349
|
+
'handleUserAnswer called but no pending consultation exists',
|
|
326
350
|
);
|
|
327
351
|
return false;
|
|
328
352
|
}
|
|
329
353
|
|
|
330
|
-
// Clear the consultation timeout
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
|
|
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
|
-
|
|
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,
|
|
388
|
-
*
|
|
389
|
-
*
|
|
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'
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 =
|
|
659
|
+
const askMatch = toolApprovalMeta
|
|
651
660
|
? null // structured approval takes precedence
|
|
652
661
|
: responseText.match(ASK_GUARDIAN_CAPTURE_REGEX);
|
|
653
662
|
|
|
654
|
-
const questionText =
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
if
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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,
|
|
712
|
-
'
|
|
719
|
+
{ callSessionId: this.callSessionId, questionId: this.pendingConsultation.questionId },
|
|
720
|
+
'Coalescing repeated ASK_GUARDIAN — same tool/action already pending',
|
|
713
721
|
);
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
-
*
|
|
858
|
-
*
|
|
859
|
-
*
|
|
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
|
|
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 (
|
|
864
|
-
|
|
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
|