@vellumai/assistant 0.4.31 → 0.4.32
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 +1 -1
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -7
- package/src/__tests__/anthropic-provider.test.ts +86 -1
- package/src/__tests__/assistant-feature-flags-integration.test.ts +2 -2
- package/src/__tests__/checker.test.ts +37 -98
- package/src/__tests__/commit-message-enrichment-service.test.ts +15 -0
- package/src/__tests__/config-schema.test.ts +6 -5
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +1 -19
- package/src/__tests__/followup-tools.test.ts +0 -30
- package/src/__tests__/gemini-provider.test.ts +79 -1
- package/src/__tests__/ipc-snapshot.test.ts +0 -4
- package/src/__tests__/managed-proxy-context.test.ts +163 -0
- package/src/__tests__/memory-lifecycle-e2e.test.ts +2 -2
- package/src/__tests__/memory-regressions.test.ts +6 -6
- package/src/__tests__/openai-provider.test.ts +82 -0
- package/src/__tests__/provider-fail-open-selection.test.ts +134 -1
- package/src/__tests__/provider-managed-proxy-integration.test.ts +269 -0
- package/src/__tests__/recurrence-types.test.ts +0 -15
- package/src/__tests__/schedule-tools.test.ts +28 -44
- package/src/__tests__/skill-feature-flags.test.ts +2 -2
- package/src/__tests__/task-management-tools.test.ts +111 -0
- package/src/__tests__/twilio-config.test.ts +0 -3
- package/src/amazon/session.ts +30 -91
- package/src/calls/call-controller.ts +423 -571
- package/src/calls/finalize-call.ts +20 -0
- package/src/calls/relay-access-wait.ts +340 -0
- package/src/calls/relay-server.ts +267 -902
- package/src/calls/relay-setup-router.ts +307 -0
- package/src/calls/relay-verification.ts +280 -0
- package/src/calls/twilio-config.ts +1 -8
- package/src/calls/voice-control-protocol.ts +184 -0
- package/src/calls/voice-session-bridge.ts +1 -8
- package/src/config/agent-schema.ts +1 -1
- package/src/config/bundled-skills/followups/TOOLS.json +0 -4
- package/src/config/bundled-skills/schedule/SKILL.md +1 -1
- package/src/config/bundled-skills/schedule/TOOLS.json +2 -10
- package/src/config/core-schema.ts +1 -1
- package/src/config/env.ts +0 -10
- package/src/config/feature-flag-registry.json +1 -1
- package/src/config/loader.ts +19 -0
- package/src/config/schema.ts +2 -2
- package/src/daemon/handlers/session-history.ts +398 -0
- package/src/daemon/handlers/session-user-message.ts +982 -0
- package/src/daemon/handlers/sessions.ts +9 -1338
- package/src/daemon/ipc-contract/sessions.ts +0 -6
- package/src/daemon/ipc-contract-inventory.json +0 -1
- package/src/daemon/lifecycle.ts +0 -29
- package/src/home-base/app-link-store.ts +0 -7
- package/src/memory/conversation-attention-store.ts +1 -1
- package/src/memory/conversation-store.ts +0 -51
- package/src/memory/db-init.ts +5 -1
- package/src/memory/job-handlers/conflict.ts +24 -0
- package/src/memory/migrations/105-contacts-and-triage.ts +4 -7
- package/src/memory/migrations/134-contacts-notes-column.ts +50 -33
- package/src/memory/migrations/registry.ts +6 -0
- package/src/memory/recall-cache.ts +0 -5
- package/src/memory/schema/calls.ts +274 -0
- package/src/memory/schema/contacts.ts +125 -0
- package/src/memory/schema/conversations.ts +129 -0
- package/src/memory/schema/guardian.ts +172 -0
- package/src/memory/schema/index.ts +8 -0
- package/src/memory/schema/infrastructure.ts +205 -0
- package/src/memory/schema/memory-core.ts +196 -0
- package/src/memory/schema/notifications.ts +191 -0
- package/src/memory/schema/tasks.ts +78 -0
- package/src/memory/schema.ts +1 -1385
- package/src/memory/slack-thread-store.ts +0 -69
- package/src/notifications/decisions-store.ts +2 -105
- package/src/notifications/deliveries-store.ts +0 -11
- package/src/notifications/preferences-store.ts +1 -58
- package/src/permissions/checker.ts +6 -17
- package/src/providers/anthropic/client.ts +6 -2
- package/src/providers/gemini/client.ts +13 -2
- package/src/providers/managed-proxy/constants.ts +55 -0
- package/src/providers/managed-proxy/context.ts +77 -0
- package/src/providers/registry.ts +112 -0
- package/src/runtime/auth/__tests__/guard-tests.test.ts +51 -23
- package/src/runtime/http-server.ts +83 -722
- package/src/runtime/http-types.ts +0 -16
- package/src/runtime/middleware/auth.ts +0 -12
- package/src/runtime/routes/app-routes.ts +33 -0
- package/src/runtime/routes/approval-routes.ts +32 -0
- package/src/runtime/routes/attachment-routes.ts +32 -0
- package/src/runtime/routes/brain-graph-routes.ts +27 -0
- package/src/runtime/routes/call-routes.ts +41 -0
- package/src/runtime/routes/channel-readiness-routes.ts +20 -0
- package/src/runtime/routes/channel-routes.ts +70 -0
- package/src/runtime/routes/contact-routes.ts +63 -0
- package/src/runtime/routes/conversation-attention-routes.ts +15 -0
- package/src/runtime/routes/conversation-routes.ts +190 -193
- package/src/runtime/routes/debug-routes.ts +15 -0
- package/src/runtime/routes/events-routes.ts +16 -0
- package/src/runtime/routes/global-search-routes.ts +15 -0
- package/src/runtime/routes/guardian-action-routes.ts +22 -0
- package/src/runtime/routes/guardian-bootstrap-routes.ts +20 -0
- package/src/runtime/routes/guardian-refresh-routes.ts +20 -0
- package/src/runtime/routes/identity-routes.ts +20 -0
- package/src/runtime/routes/inbound-message-handler.ts +8 -0
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +6 -6
- package/src/runtime/routes/integration-routes.ts +83 -0
- package/src/runtime/routes/invite-routes.ts +31 -0
- package/src/runtime/routes/migration-routes.ts +30 -0
- package/src/runtime/routes/pairing-routes.ts +18 -0
- package/src/runtime/routes/secret-routes.ts +20 -0
- package/src/runtime/routes/surface-action-routes.ts +26 -0
- package/src/runtime/routes/trust-rules-routes.ts +31 -0
- package/src/runtime/routes/twilio-routes.ts +79 -0
- package/src/schedule/recurrence-types.ts +1 -11
- package/src/tools/followups/followup_create.ts +9 -3
- package/src/tools/mcp/mcp-tool-factory.ts +0 -17
- package/src/tools/memory/definitions.ts +0 -6
- package/src/tools/network/script-proxy/session-manager.ts +38 -3
- package/src/tools/schedule/create.ts +1 -3
- package/src/tools/schedule/update.ts +9 -6
- package/src/twitter/session.ts +29 -77
- package/src/util/cookie-session.ts +114 -0
- package/src/__tests__/conversation-routes.test.ts +0 -99
- package/src/__tests__/task-tools.test.ts +0 -685
- package/src/contacts/startup-migration.ts +0 -21
|
@@ -27,10 +27,8 @@ import {
|
|
|
27
27
|
getSilenceTimeoutMs,
|
|
28
28
|
getUserConsultationTimeoutMs,
|
|
29
29
|
} from "./call-constants.js";
|
|
30
|
-
import { persistCallCompletionMessage } from "./call-conversation-messages.js";
|
|
31
30
|
import { addPointerMessage, formatDuration } from "./call-pointer-messages.js";
|
|
32
31
|
import {
|
|
33
|
-
fireCallCompletionNotifier,
|
|
34
32
|
fireCallQuestionNotifier,
|
|
35
33
|
fireCallTranscriptNotifier,
|
|
36
34
|
registerCallController,
|
|
@@ -43,10 +41,20 @@ import {
|
|
|
43
41
|
recordCallEvent,
|
|
44
42
|
updateCallSession,
|
|
45
43
|
} from "./call-store.js";
|
|
44
|
+
import { finalizeCall } from "./finalize-call.js";
|
|
46
45
|
import { sendGuardianExpiryNotices } from "./guardian-action-sweep.js";
|
|
47
46
|
import { dispatchGuardianQuestion } from "./guardian-dispatch.js";
|
|
48
47
|
import type { RelayConnection } from "./relay-server.js";
|
|
49
48
|
import type { PromptSpeakerContext } from "./speaker-identification.js";
|
|
49
|
+
import {
|
|
50
|
+
ASK_GUARDIAN_CAPTURE_REGEX,
|
|
51
|
+
CALL_OPENING_ACK_MARKER,
|
|
52
|
+
CALL_OPENING_MARKER,
|
|
53
|
+
couldBeControlMarker,
|
|
54
|
+
END_CALL_MARKER,
|
|
55
|
+
extractBalancedJson,
|
|
56
|
+
stripInternalSpeechMarkers,
|
|
57
|
+
} from "./voice-control-protocol.js";
|
|
50
58
|
import {
|
|
51
59
|
startVoiceTurn,
|
|
52
60
|
type VoiceTurnHandle,
|
|
@@ -70,133 +78,6 @@ interface PendingGuardianInput {
|
|
|
70
78
|
timer: ReturnType<typeof setTimeout>;
|
|
71
79
|
}
|
|
72
80
|
|
|
73
|
-
const ASK_GUARDIAN_CAPTURE_REGEX = /\[ASK_GUARDIAN:\s*(.+?)\]/;
|
|
74
|
-
const ASK_GUARDIAN_MARKER_REGEX = /\[ASK_GUARDIAN:\s*.+?\]/g;
|
|
75
|
-
|
|
76
|
-
// Flexible prefix for ASK_GUARDIAN_APPROVAL — tolerates variable whitespace
|
|
77
|
-
// after the colon so the marker is recognized even if the model omits the
|
|
78
|
-
// space or inserts a newline.
|
|
79
|
-
const ASK_GUARDIAN_APPROVAL_PREFIX_RE = /\[ASK_GUARDIAN_APPROVAL:\s*/;
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Extract a balanced JSON object from text that starts with an
|
|
83
|
-
* ASK_GUARDIAN_APPROVAL prefix. Uses brace counting with string-literal
|
|
84
|
-
* awareness so that `}` or `}]` inside JSON string values does not
|
|
85
|
-
* terminate the match prematurely.
|
|
86
|
-
*
|
|
87
|
-
* Returns the extracted JSON string, the full marker text
|
|
88
|
-
* (prefix + JSON + "]"), and the start index — or null when:
|
|
89
|
-
* - no prefix is found,
|
|
90
|
-
* - braces are unbalanced (still streaming), or
|
|
91
|
-
* - the closing `]` has not yet arrived (prevents stripping
|
|
92
|
-
* the marker body while the bracket leaks into TTS in a later delta).
|
|
93
|
-
*/
|
|
94
|
-
function extractBalancedJson(
|
|
95
|
-
text: string,
|
|
96
|
-
): { json: string; fullMatch: string; startIndex: number } | null {
|
|
97
|
-
const prefixMatch = ASK_GUARDIAN_APPROVAL_PREFIX_RE.exec(text);
|
|
98
|
-
if (!prefixMatch) return null;
|
|
99
|
-
|
|
100
|
-
const prefixIdx = prefixMatch.index;
|
|
101
|
-
const jsonStart = prefixIdx + prefixMatch[0].length;
|
|
102
|
-
if (jsonStart >= text.length || text[jsonStart] !== "{") return null;
|
|
103
|
-
|
|
104
|
-
let depth = 0;
|
|
105
|
-
let inString = false;
|
|
106
|
-
let escape = false;
|
|
107
|
-
|
|
108
|
-
for (let i = jsonStart; i < text.length; i++) {
|
|
109
|
-
const ch = text[i];
|
|
110
|
-
|
|
111
|
-
if (escape) {
|
|
112
|
-
escape = false;
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (ch === "\\" && inString) {
|
|
117
|
-
escape = true;
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
if (ch === '"') {
|
|
122
|
-
inString = !inString;
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (inString) continue;
|
|
127
|
-
|
|
128
|
-
if (ch === "{") {
|
|
129
|
-
depth++;
|
|
130
|
-
} else if (ch === "}") {
|
|
131
|
-
depth--;
|
|
132
|
-
if (depth === 0) {
|
|
133
|
-
const jsonEnd = i + 1;
|
|
134
|
-
const json = text.slice(jsonStart, jsonEnd);
|
|
135
|
-
// Skip any whitespace between the closing '}' and the expected ']'.
|
|
136
|
-
// Models sometimes emit formatted markers with spaces or newlines
|
|
137
|
-
// before the bracket (e.g. `{ ... }\n]` or `{ ... } ]`).
|
|
138
|
-
let bracketIdx = jsonEnd;
|
|
139
|
-
while (bracketIdx < text.length && /\s/.test(text[bracketIdx])) {
|
|
140
|
-
bracketIdx++;
|
|
141
|
-
}
|
|
142
|
-
// Require the closing ']' to be present before considering this
|
|
143
|
-
// a complete match. If it hasn't arrived yet (streaming), return
|
|
144
|
-
// null so the caller keeps buffering.
|
|
145
|
-
if (bracketIdx >= text.length || text[bracketIdx] !== "]") {
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
148
|
-
const fullMatchEnd = bracketIdx + 1;
|
|
149
|
-
const fullMatch = text.slice(prefixIdx, fullMatchEnd);
|
|
150
|
-
return { json, fullMatch, startIndex: prefixIdx };
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return null; // Unbalanced braces — still streaming
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Strip all balanced ASK_GUARDIAN_APPROVAL markers from text, handling
|
|
160
|
-
* nested braces, string literals, and flexible whitespace correctly.
|
|
161
|
-
* Only strips complete markers (prefix + balanced JSON + closing `]`).
|
|
162
|
-
*/
|
|
163
|
-
function stripGuardianApprovalMarkers(text: string): string {
|
|
164
|
-
let result = text;
|
|
165
|
-
for (;;) {
|
|
166
|
-
const match = extractBalancedJson(result);
|
|
167
|
-
if (!match) break;
|
|
168
|
-
result =
|
|
169
|
-
result.slice(0, match.startIndex) +
|
|
170
|
-
result.slice(match.startIndex + match.fullMatch.length);
|
|
171
|
-
}
|
|
172
|
-
return result;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const USER_ANSWERED_MARKER_REGEX = /\[USER_ANSWERED:\s*.+?\]/g;
|
|
176
|
-
const USER_INSTRUCTION_MARKER_REGEX = /\[USER_INSTRUCTION:\s*.+?\]/g;
|
|
177
|
-
const CALL_OPENING_MARKER_REGEX = /\[CALL_OPENING\]/g;
|
|
178
|
-
const CALL_OPENING_ACK_MARKER_REGEX = /\[CALL_OPENING_ACK\]/g;
|
|
179
|
-
const END_CALL_MARKER_REGEX = /\[END_CALL\]/g;
|
|
180
|
-
const GUARDIAN_TIMEOUT_MARKER_REGEX = /\[GUARDIAN_TIMEOUT\]/g;
|
|
181
|
-
const GUARDIAN_UNAVAILABLE_MARKER_REGEX = /\[GUARDIAN_UNAVAILABLE\]/g;
|
|
182
|
-
const CALL_OPENING_MARKER = "[CALL_OPENING]";
|
|
183
|
-
const CALL_OPENING_ACK_MARKER = "[CALL_OPENING_ACK]";
|
|
184
|
-
const END_CALL_MARKER = "[END_CALL]";
|
|
185
|
-
|
|
186
|
-
function stripInternalSpeechMarkers(text: string): string {
|
|
187
|
-
let result = stripGuardianApprovalMarkers(text);
|
|
188
|
-
result = result
|
|
189
|
-
.replace(ASK_GUARDIAN_MARKER_REGEX, "")
|
|
190
|
-
.replace(USER_ANSWERED_MARKER_REGEX, "")
|
|
191
|
-
.replace(USER_INSTRUCTION_MARKER_REGEX, "")
|
|
192
|
-
.replace(CALL_OPENING_MARKER_REGEX, "")
|
|
193
|
-
.replace(CALL_OPENING_ACK_MARKER_REGEX, "")
|
|
194
|
-
.replace(END_CALL_MARKER_REGEX, "")
|
|
195
|
-
.replace(GUARDIAN_TIMEOUT_MARKER_REGEX, "")
|
|
196
|
-
.replace(GUARDIAN_UNAVAILABLE_MARKER_REGEX, "");
|
|
197
|
-
return result;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
81
|
export class CallController {
|
|
201
82
|
private callSessionId: string;
|
|
202
83
|
private relay: RelayConnection;
|
|
@@ -562,476 +443,463 @@ export class CallController {
|
|
|
562
443
|
try {
|
|
563
444
|
this.state = "speaking";
|
|
564
445
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
446
|
+
const fullResponseText = await this.streamTtsTokens(
|
|
447
|
+
content,
|
|
448
|
+
runVersion,
|
|
449
|
+
runSignal,
|
|
450
|
+
);
|
|
451
|
+
if (!this.isCurrentRun(runVersion)) return;
|
|
571
452
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
453
|
+
this.handleTurnCompletion(fullResponseText);
|
|
454
|
+
} catch (err: unknown) {
|
|
455
|
+
this.currentTurnHandle = null;
|
|
456
|
+
// Aborted requests are expected (interruptions, rapid utterances)
|
|
457
|
+
if (this.isExpectedAbortError(err) || runSignal.aborted) {
|
|
458
|
+
log.debug(
|
|
459
|
+
{
|
|
460
|
+
callSessionId: this.callSessionId,
|
|
461
|
+
errName: err instanceof Error ? err.name : typeof err,
|
|
462
|
+
stale: !this.isCurrentRun(runVersion),
|
|
463
|
+
},
|
|
464
|
+
"Voice turn aborted",
|
|
465
|
+
);
|
|
466
|
+
if (this.isCurrentRun(runVersion)) {
|
|
467
|
+
this.state = "idle";
|
|
468
|
+
this.resetSilenceTimer();
|
|
469
|
+
}
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (!this.isCurrentRun(runVersion)) {
|
|
473
|
+
log.debug(
|
|
474
|
+
{
|
|
475
|
+
callSessionId: this.callSessionId,
|
|
476
|
+
errName: err instanceof Error ? err.name : typeof err,
|
|
477
|
+
},
|
|
478
|
+
"Ignoring stale voice turn error from superseded turn",
|
|
479
|
+
);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
log.error({ err, callSessionId: this.callSessionId }, "Voice turn error");
|
|
483
|
+
this.relay.sendTextToken(
|
|
484
|
+
"I'm sorry, I encountered a technical issue. Could you repeat that?",
|
|
485
|
+
true,
|
|
486
|
+
);
|
|
487
|
+
this.state = "idle";
|
|
488
|
+
this.resetSilenceTimer();
|
|
489
|
+
this.flushPendingInstructions();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
586
492
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
493
|
+
/**
|
|
494
|
+
* Stream TTS tokens from the session pipeline, buffering to strip
|
|
495
|
+
* control markers before they reach the relay. Returns the full
|
|
496
|
+
* accumulated response text for post-turn marker detection.
|
|
497
|
+
*/
|
|
498
|
+
private async streamTtsTokens(
|
|
499
|
+
content: string,
|
|
500
|
+
runVersion: number,
|
|
501
|
+
runSignal: AbortSignal,
|
|
502
|
+
): Promise<string> {
|
|
503
|
+
// Buffer incoming tokens so we can strip control markers ([ASK_GUARDIAN:...], [END_CALL])
|
|
504
|
+
// before they reach TTS. We hold text whenever an unmatched '[' appears, since it
|
|
505
|
+
// could be the start of a control marker.
|
|
506
|
+
let ttsBuffer = "";
|
|
507
|
+
let fullResponseText = "";
|
|
508
|
+
|
|
509
|
+
const flushSafeText = (): void => {
|
|
510
|
+
if (!this.isCurrentRun(runVersion)) return;
|
|
511
|
+
if (ttsBuffer.length === 0) return;
|
|
512
|
+
const bracketIdx = ttsBuffer.indexOf("[");
|
|
513
|
+
if (bracketIdx === -1) {
|
|
514
|
+
// No bracket at all — safe to flush everything
|
|
515
|
+
this.relay.sendTextToken(ttsBuffer, false);
|
|
516
|
+
ttsBuffer = "";
|
|
517
|
+
} else {
|
|
518
|
+
// Flush everything before the bracket
|
|
519
|
+
if (bracketIdx > 0) {
|
|
520
|
+
this.relay.sendTextToken(ttsBuffer.slice(0, bracketIdx), false);
|
|
521
|
+
ttsBuffer = ttsBuffer.slice(bracketIdx);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Only hold the buffer if the bracket text could be the start of a
|
|
525
|
+
// known control marker. Otherwise flush immediately so ordinary
|
|
526
|
+
// bracketed text (e.g. "[A]", "[note]") doesn't stall TTS.
|
|
527
|
+
const afterBracket = ttsBuffer;
|
|
528
|
+
const couldBeControl = couldBeControlMarker(afterBracket);
|
|
529
|
+
|
|
530
|
+
if (!couldBeControl) {
|
|
531
|
+
// Not a control marker prefix — flush up to the next '[' (if any)
|
|
532
|
+
const nextBracket = ttsBuffer.indexOf("[", 1);
|
|
533
|
+
if (nextBracket === -1) {
|
|
534
|
+
this.relay.sendTextToken(ttsBuffer, false);
|
|
535
|
+
ttsBuffer = "";
|
|
536
|
+
} else {
|
|
537
|
+
this.relay.sendTextToken(ttsBuffer.slice(0, nextBracket), false);
|
|
538
|
+
ttsBuffer = ttsBuffer.slice(nextBracket);
|
|
626
539
|
}
|
|
627
|
-
// Otherwise hold it — might be a control marker still being streamed
|
|
628
540
|
}
|
|
629
|
-
|
|
541
|
+
// Otherwise hold it — might be a control marker still being streamed
|
|
542
|
+
}
|
|
543
|
+
};
|
|
630
544
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
545
|
+
// Use a promise to track completion of the voice turn
|
|
546
|
+
const turnComplete = new Promise<void>((resolve, reject) => {
|
|
547
|
+
const onTextDelta = (text: string): void => {
|
|
548
|
+
if (!this.isCurrentRun(runVersion)) return;
|
|
549
|
+
fullResponseText += text;
|
|
550
|
+
ttsBuffer += text;
|
|
551
|
+
ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
|
|
552
|
+
flushSafeText();
|
|
553
|
+
};
|
|
640
554
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
555
|
+
const onComplete = (): void => {
|
|
556
|
+
resolve();
|
|
557
|
+
};
|
|
644
558
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
559
|
+
const onError = (message: string): void => {
|
|
560
|
+
reject(new Error(message));
|
|
561
|
+
};
|
|
648
562
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
563
|
+
// Start the voice turn through the session bridge
|
|
564
|
+
startVoiceTurn({
|
|
565
|
+
conversationId: this.conversationId,
|
|
566
|
+
callSessionId: this.callSessionId,
|
|
567
|
+
content,
|
|
568
|
+
assistantId: this.assistantId,
|
|
569
|
+
trustContext: this.trustContext ?? undefined,
|
|
570
|
+
isInbound: this.isInbound,
|
|
571
|
+
task: this.task,
|
|
572
|
+
onTextDelta,
|
|
573
|
+
onComplete,
|
|
574
|
+
onError,
|
|
575
|
+
signal: runSignal,
|
|
576
|
+
})
|
|
577
|
+
.then((handle) => {
|
|
578
|
+
if (this.isCurrentRun(runVersion)) {
|
|
579
|
+
this.currentTurnHandle = handle;
|
|
580
|
+
} else {
|
|
581
|
+
// Turn was superseded before handle arrived; abort immediately
|
|
582
|
+
handle.abort();
|
|
583
|
+
}
|
|
662
584
|
})
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
} else {
|
|
667
|
-
// Turn was superseded before handle arrived; abort immediately
|
|
668
|
-
handle.abort();
|
|
669
|
-
}
|
|
670
|
-
})
|
|
671
|
-
.catch((err) => {
|
|
672
|
-
reject(err);
|
|
673
|
-
});
|
|
585
|
+
.catch((err) => {
|
|
586
|
+
reject(err);
|
|
587
|
+
});
|
|
674
588
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
589
|
+
// Defensive: if the turn is aborted (e.g. barge-in) and the event
|
|
590
|
+
// sink callbacks are never invoked, resolve the promise so it
|
|
591
|
+
// doesn't hang forever.
|
|
592
|
+
runSignal.addEventListener(
|
|
593
|
+
"abort",
|
|
594
|
+
() => {
|
|
595
|
+
resolve();
|
|
596
|
+
},
|
|
597
|
+
{ once: true },
|
|
598
|
+
);
|
|
599
|
+
});
|
|
686
600
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
601
|
+
// Eagerly mark the rejection as handled so runtimes (e.g. bun) don't
|
|
602
|
+
// flag it as an unhandled rejection when onError fires synchronously
|
|
603
|
+
// inside the Promise constructor before this await adds its handler.
|
|
604
|
+
// The await below still re-throws, caught by the outer try-catch.
|
|
605
|
+
turnComplete.catch(() => {});
|
|
606
|
+
await turnComplete;
|
|
607
|
+
if (!this.isCurrentRun(runVersion)) return fullResponseText;
|
|
608
|
+
|
|
609
|
+
// Final sweep: strip any remaining control markers from the buffer
|
|
610
|
+
ttsBuffer = stripInternalSpeechMarkers(ttsBuffer);
|
|
611
|
+
if (ttsBuffer.length > 0) {
|
|
612
|
+
this.relay.sendTextToken(ttsBuffer, false);
|
|
613
|
+
}
|
|
694
614
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
if (ttsBuffer.length > 0) {
|
|
698
|
-
this.relay.sendTextToken(ttsBuffer, false);
|
|
699
|
-
}
|
|
615
|
+
// Signal end of this turn's speech
|
|
616
|
+
this.relay.sendTextToken("", true);
|
|
700
617
|
|
|
701
|
-
|
|
702
|
-
|
|
618
|
+
// Mark the greeting's first response as awaiting ack
|
|
619
|
+
if (this.lastSentWasOpener && fullResponseText.length > 0) {
|
|
620
|
+
this.awaitingOpeningAck = true;
|
|
621
|
+
this.lastSentWasOpener = false;
|
|
622
|
+
}
|
|
703
623
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
this.awaitingOpeningAck = true;
|
|
707
|
-
this.lastSentWasOpener = false;
|
|
708
|
-
}
|
|
624
|
+
return fullResponseText;
|
|
625
|
+
}
|
|
709
626
|
|
|
710
|
-
|
|
627
|
+
/**
|
|
628
|
+
* Handle post-turn marker detection and dispatch: guardian consultation
|
|
629
|
+
* (ASK_GUARDIAN_APPROVAL / ASK_GUARDIAN), call finalization (END_CALL),
|
|
630
|
+
* and normal idle transition.
|
|
631
|
+
*/
|
|
632
|
+
private handleTurnCompletion(fullResponseText: string): void {
|
|
633
|
+
const responseText = fullResponseText;
|
|
711
634
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
}
|
|
635
|
+
// Record the assistant response event
|
|
636
|
+
recordCallEvent(this.callSessionId, "assistant_spoke", {
|
|
637
|
+
text: responseText,
|
|
638
|
+
});
|
|
639
|
+
const spokenText = stripInternalSpeechMarkers(responseText).trim();
|
|
640
|
+
if (spokenText.length > 0) {
|
|
641
|
+
const session = getCallSession(this.callSessionId);
|
|
642
|
+
if (session) {
|
|
643
|
+
fireCallTranscriptNotifier(
|
|
644
|
+
session.conversationId,
|
|
645
|
+
this.callSessionId,
|
|
646
|
+
"assistant",
|
|
647
|
+
spokenText,
|
|
648
|
+
);
|
|
727
649
|
}
|
|
650
|
+
}
|
|
728
651
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
);
|
|
751
|
-
toolApprovalMeta = {
|
|
752
|
-
question: parsed.question,
|
|
753
|
-
toolName: parsed.toolName,
|
|
754
|
-
inputDigest: digest,
|
|
755
|
-
};
|
|
756
|
-
}
|
|
757
|
-
} catch {
|
|
758
|
-
log.warn(
|
|
759
|
-
{ callSessionId: this.callSessionId },
|
|
760
|
-
"Failed to parse ASK_GUARDIAN_APPROVAL JSON payload",
|
|
652
|
+
// Check for structured tool-approval ASK_GUARDIAN_APPROVAL first,
|
|
653
|
+
// then informational ASK_GUARDIAN. Uses brace-balanced extraction so
|
|
654
|
+
// `}]` inside JSON string values does not truncate the payload or
|
|
655
|
+
// leak partial JSON into TTS output.
|
|
656
|
+
const approvalMatch = extractBalancedJson(responseText);
|
|
657
|
+
let toolApprovalMeta: {
|
|
658
|
+
question: string;
|
|
659
|
+
toolName: string;
|
|
660
|
+
inputDigest: string;
|
|
661
|
+
} | null = null;
|
|
662
|
+
if (approvalMatch) {
|
|
663
|
+
try {
|
|
664
|
+
const parsed = JSON.parse(approvalMatch.json) as {
|
|
665
|
+
question?: string;
|
|
666
|
+
toolName?: string;
|
|
667
|
+
input?: Record<string, unknown>;
|
|
668
|
+
};
|
|
669
|
+
if (parsed.question && parsed.toolName && parsed.input) {
|
|
670
|
+
const digest = computeToolApprovalDigest(
|
|
671
|
+
parsed.toolName,
|
|
672
|
+
parsed.input,
|
|
761
673
|
);
|
|
674
|
+
toolApprovalMeta = {
|
|
675
|
+
question: parsed.question,
|
|
676
|
+
toolName: parsed.toolName,
|
|
677
|
+
inputDigest: digest,
|
|
678
|
+
};
|
|
762
679
|
}
|
|
680
|
+
} catch {
|
|
681
|
+
log.warn(
|
|
682
|
+
{ callSessionId: this.callSessionId },
|
|
683
|
+
"Failed to parse ASK_GUARDIAN_APPROVAL JSON payload",
|
|
684
|
+
);
|
|
763
685
|
}
|
|
686
|
+
}
|
|
764
687
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
688
|
+
const askMatch = toolApprovalMeta
|
|
689
|
+
? null // structured approval takes precedence
|
|
690
|
+
: responseText.match(ASK_GUARDIAN_CAPTURE_REGEX);
|
|
768
691
|
|
|
769
|
-
|
|
770
|
-
|
|
692
|
+
const questionText =
|
|
693
|
+
toolApprovalMeta?.question ?? (askMatch ? askMatch[1] : null);
|
|
771
694
|
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
695
|
+
if (questionText) {
|
|
696
|
+
if (this.isCallerGuardian()) {
|
|
697
|
+
// Caller IS the guardian — don't dispatch cross-channel.
|
|
698
|
+
// Queue an instruction so the next turn asks them directly.
|
|
699
|
+
log.info(
|
|
700
|
+
{ callSessionId: this.callSessionId },
|
|
701
|
+
"Caller is guardian — skipping ASK_GUARDIAN dispatch, asking directly",
|
|
702
|
+
);
|
|
703
|
+
this.pendingInstructions.push(
|
|
704
|
+
`You just tried to use [ASK_GUARDIAN] but the person on the phone IS your guardian. Ask them directly: "${questionText}"`,
|
|
705
|
+
);
|
|
706
|
+
// Fall through to normal turn completion (idle + flushPendingInstructions)
|
|
707
|
+
} else if (this.guardianUnavailableForCall) {
|
|
708
|
+
// Guardian already timed out earlier in this call — skip the full
|
|
709
|
+
// consultation wait and immediately tell the model to proceed
|
|
710
|
+
// without guardian input.
|
|
711
|
+
log.info(
|
|
712
|
+
{ callSessionId: this.callSessionId },
|
|
713
|
+
"Guardian unavailable for call — skipping ASK_GUARDIAN wait",
|
|
714
|
+
);
|
|
715
|
+
recordCallEvent(this.callSessionId, "guardian_unavailable_skipped", {
|
|
716
|
+
question: questionText,
|
|
717
|
+
});
|
|
718
|
+
this.pendingInstructions.push(
|
|
719
|
+
`[GUARDIAN_UNAVAILABLE] You tried to consult your guardian again, but they were already unreachable earlier in this call. ` +
|
|
720
|
+
`Do NOT use [ASK_GUARDIAN] again. Instead, let the caller know you cannot reach the guardian right now, ` +
|
|
721
|
+
`and continue the conversation by asking if there is anything else you can help with or if they would like a callback. ` +
|
|
722
|
+
`The unanswered question was: "${questionText}"`,
|
|
723
|
+
);
|
|
724
|
+
// Fall through to normal turn completion (idle + flushPendingInstructions)
|
|
725
|
+
} else if (
|
|
726
|
+
this.pendingInstructions.some((instr) =>
|
|
727
|
+
instr.startsWith("[USER_ANSWERED:"),
|
|
728
|
+
)
|
|
729
|
+
) {
|
|
730
|
+
// A guardian answer arrived mid-turn and is queued in
|
|
731
|
+
// pendingInstructions but hasn't been flushed yet. The in-flight
|
|
732
|
+
// LLM response was generated without knowledge of this answer, so
|
|
733
|
+
// creating a new consultation now would supersede the old one and
|
|
734
|
+
// desynchronize the flow. Skip this consultation — the answer will
|
|
735
|
+
// be flushed on the next turn, and if the model still needs to
|
|
736
|
+
// consult a guardian, it will emit another ASK_GUARDIAN then.
|
|
737
|
+
log.info(
|
|
738
|
+
{ callSessionId: this.callSessionId },
|
|
739
|
+
"Deferring ASK_GUARDIAN — queued USER_ANSWERED pending",
|
|
740
|
+
);
|
|
741
|
+
recordCallEvent(this.callSessionId, "guardian_consult_deferred", {
|
|
742
|
+
question: questionText,
|
|
743
|
+
});
|
|
744
|
+
// Fall through to normal turn completion (idle + flushPendingInstructions)
|
|
745
|
+
} else {
|
|
746
|
+
// Determine the effective tool metadata for this ask. If the new
|
|
747
|
+
// ask has structured tool metadata, use it; otherwise inherit from
|
|
748
|
+
// the prior pending consultation (preserves tool scope on re-asks).
|
|
749
|
+
const effectiveToolMeta = toolApprovalMeta
|
|
750
|
+
? {
|
|
751
|
+
toolName: toolApprovalMeta.toolName,
|
|
752
|
+
inputDigest: toolApprovalMeta.inputDigest,
|
|
753
|
+
}
|
|
754
|
+
: (this.pendingGuardianInput?.toolApprovalMeta ?? null);
|
|
755
|
+
|
|
756
|
+
// Coalesce repeated identical asks: if a consultation is already
|
|
757
|
+
// pending for the same tool/action (or same informational question),
|
|
758
|
+
// avoid churning requests and just keep the existing one.
|
|
759
|
+
if (this.pendingGuardianInput) {
|
|
760
|
+
const isSameToolAction =
|
|
761
|
+
effectiveToolMeta && this.pendingGuardianInput.toolApprovalMeta
|
|
762
|
+
? effectiveToolMeta.toolName ===
|
|
763
|
+
this.pendingGuardianInput.toolApprovalMeta.toolName &&
|
|
764
|
+
effectiveToolMeta.inputDigest ===
|
|
765
|
+
this.pendingGuardianInput.toolApprovalMeta.inputDigest
|
|
766
|
+
: !effectiveToolMeta &&
|
|
767
|
+
!this.pendingGuardianInput.toolApprovalMeta;
|
|
768
|
+
|
|
769
|
+
if (isSameToolAction) {
|
|
770
|
+
// Same tool/action — coalesce. Keep the existing consultation
|
|
771
|
+
// alive and skip creating a new request.
|
|
772
|
+
log.info(
|
|
773
|
+
{
|
|
774
|
+
callSessionId: this.callSessionId,
|
|
775
|
+
questionId: this.pendingGuardianInput.questionId,
|
|
776
|
+
},
|
|
777
|
+
"Coalescing repeated ASK_GUARDIAN — same tool/action already pending",
|
|
778
|
+
);
|
|
779
|
+
recordCallEvent(this.callSessionId, "guardian_consult_coalesced", {
|
|
780
|
+
question: questionText,
|
|
781
|
+
});
|
|
782
|
+
// Fall through to normal turn completion (idle + flushPendingInstructions)
|
|
783
|
+
} else {
|
|
784
|
+
// Materially different intent — supersede the old consultation.
|
|
785
|
+
clearTimeout(this.pendingGuardianInput.timer);
|
|
786
|
+
|
|
787
|
+
// Expire the previous consultation's storage records so stale
|
|
788
|
+
// guardian answers cannot match the old request.
|
|
789
|
+
expirePendingQuestions(this.callSessionId);
|
|
790
|
+
const previousRequest = getPendingCanonicalRequestByCallSessionId(
|
|
791
|
+
this.callSessionId,
|
|
792
|
+
);
|
|
793
|
+
if (previousRequest) {
|
|
794
|
+
// Immediately expire with 'superseded' reason to prevent
|
|
795
|
+
// stale answers from resolving the old request.
|
|
796
|
+
expireCanonicalGuardianRequest(previousRequest.id);
|
|
849
797
|
log.info(
|
|
850
798
|
{
|
|
851
799
|
callSessionId: this.callSessionId,
|
|
852
|
-
|
|
800
|
+
requestId: previousRequest.id,
|
|
853
801
|
},
|
|
854
|
-
"
|
|
855
|
-
);
|
|
856
|
-
recordCallEvent(
|
|
857
|
-
this.callSessionId,
|
|
858
|
-
"guardian_consult_coalesced",
|
|
859
|
-
{ question: questionText },
|
|
860
|
-
);
|
|
861
|
-
// Fall through to normal turn completion (idle + flushPendingInstructions)
|
|
862
|
-
} else {
|
|
863
|
-
// Materially different intent — supersede the old consultation.
|
|
864
|
-
clearTimeout(this.pendingGuardianInput.timer);
|
|
865
|
-
|
|
866
|
-
// Expire the previous consultation's storage records so stale
|
|
867
|
-
// guardian answers cannot match the old request.
|
|
868
|
-
expirePendingQuestions(this.callSessionId);
|
|
869
|
-
const previousRequest = getPendingCanonicalRequestByCallSessionId(
|
|
870
|
-
this.callSessionId,
|
|
871
|
-
);
|
|
872
|
-
if (previousRequest) {
|
|
873
|
-
// Immediately expire with 'superseded' reason to prevent
|
|
874
|
-
// stale answers from resolving the old request.
|
|
875
|
-
expireCanonicalGuardianRequest(previousRequest.id);
|
|
876
|
-
log.info(
|
|
877
|
-
{
|
|
878
|
-
callSessionId: this.callSessionId,
|
|
879
|
-
requestId: previousRequest.id,
|
|
880
|
-
},
|
|
881
|
-
"Superseded guardian action request (materially different intent)",
|
|
882
|
-
);
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
this.pendingGuardianInput = null;
|
|
886
|
-
|
|
887
|
-
// Dispatch the new consultation with effective tool metadata.
|
|
888
|
-
// The previous request ID is passed through so the dispatch
|
|
889
|
-
// can backfill supersession chain metadata (superseded_by_request_id)
|
|
890
|
-
// once the new request has been created.
|
|
891
|
-
this.dispatchNewConsultation(
|
|
892
|
-
questionText,
|
|
893
|
-
effectiveToolMeta,
|
|
894
|
-
previousRequest?.id ?? null,
|
|
802
|
+
"Superseded guardian action request (materially different intent)",
|
|
895
803
|
);
|
|
896
804
|
}
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
805
|
+
|
|
806
|
+
this.pendingGuardianInput = null;
|
|
807
|
+
|
|
808
|
+
// Dispatch the new consultation with effective tool metadata.
|
|
809
|
+
// The previous request ID is passed through so the dispatch
|
|
810
|
+
// can backfill supersession chain metadata (superseded_by_request_id)
|
|
811
|
+
// once the new request has been created.
|
|
812
|
+
this.dispatchNewConsultation(
|
|
813
|
+
questionText,
|
|
814
|
+
effectiveToolMeta,
|
|
815
|
+
previousRequest?.id ?? null,
|
|
816
|
+
);
|
|
900
817
|
}
|
|
818
|
+
} else {
|
|
819
|
+
// No prior consultation — dispatch fresh
|
|
820
|
+
this.dispatchNewConsultation(questionText, effectiveToolMeta, null);
|
|
901
821
|
}
|
|
902
822
|
}
|
|
823
|
+
}
|
|
903
824
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
this.pendingGuardianInput = null;
|
|
825
|
+
// Check for END_CALL marker
|
|
826
|
+
if (responseText.includes(END_CALL_MARKER)) {
|
|
827
|
+
// Clear any pending consultation before completing the call.
|
|
828
|
+
// Without this, the consultation timeout can fire on an already-ended
|
|
829
|
+
// call, overwriting 'completed' status back to 'in_progress' and
|
|
830
|
+
// starting a new LLM turn on a dead session. Similarly, a late
|
|
831
|
+
// handleUserAnswer could be accepted since pendingGuardianInput is
|
|
832
|
+
// still non-null.
|
|
833
|
+
if (this.pendingGuardianInput) {
|
|
834
|
+
clearTimeout(this.pendingGuardianInput.timer);
|
|
835
|
+
|
|
836
|
+
// Expire store-side consultation records so clients don't observe
|
|
837
|
+
// a completed call with a dangling pendingQuestion, and guardian
|
|
838
|
+
// replies are cleanly rejected instead of hitting answerCall failures.
|
|
839
|
+
expirePendingQuestions(this.callSessionId);
|
|
840
|
+
const previousRequest = getPendingCanonicalRequestByCallSessionId(
|
|
841
|
+
this.callSessionId,
|
|
842
|
+
);
|
|
843
|
+
if (previousRequest) {
|
|
844
|
+
expireCanonicalGuardianRequest(previousRequest.id);
|
|
927
845
|
}
|
|
928
846
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
? currentSession.status !== "completed" &&
|
|
932
|
-
currentSession.status !== "failed" &&
|
|
933
|
-
currentSession.status !== "cancelled"
|
|
934
|
-
: false;
|
|
935
|
-
|
|
936
|
-
this.relay.endSession("Call completed");
|
|
937
|
-
updateCallSession(this.callSessionId, {
|
|
938
|
-
status: "completed",
|
|
939
|
-
endedAt: Date.now(),
|
|
940
|
-
});
|
|
941
|
-
recordCallEvent(this.callSessionId, "call_ended", {
|
|
942
|
-
reason: "completed",
|
|
943
|
-
});
|
|
847
|
+
this.pendingGuardianInput = null;
|
|
848
|
+
}
|
|
944
849
|
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
fireCallCompletionNotifier(
|
|
961
|
-
currentSession.conversationId,
|
|
962
|
-
this.callSessionId,
|
|
963
|
-
);
|
|
964
|
-
}
|
|
850
|
+
const currentSession = getCallSession(this.callSessionId);
|
|
851
|
+
const shouldNotifyCompletion = currentSession
|
|
852
|
+
? currentSession.status !== "completed" &&
|
|
853
|
+
currentSession.status !== "failed" &&
|
|
854
|
+
currentSession.status !== "cancelled"
|
|
855
|
+
: false;
|
|
856
|
+
|
|
857
|
+
this.relay.endSession("Call completed");
|
|
858
|
+
updateCallSession(this.callSessionId, {
|
|
859
|
+
status: "completed",
|
|
860
|
+
endedAt: Date.now(),
|
|
861
|
+
});
|
|
862
|
+
recordCallEvent(this.callSessionId, "call_ended", {
|
|
863
|
+
reason: "completed",
|
|
864
|
+
});
|
|
965
865
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
? Date.now() - currentSession.startedAt
|
|
970
|
-
: 0;
|
|
971
|
-
addPointerMessage(
|
|
972
|
-
currentSession.initiatedFromConversationId,
|
|
973
|
-
"completed",
|
|
974
|
-
currentSession.toNumber,
|
|
975
|
-
{
|
|
976
|
-
duration: durationMs > 0 ? formatDuration(durationMs) : undefined,
|
|
977
|
-
},
|
|
978
|
-
).catch((err) => {
|
|
979
|
-
log.warn(
|
|
980
|
-
{
|
|
981
|
-
conversationId: currentSession.initiatedFromConversationId,
|
|
982
|
-
err,
|
|
983
|
-
},
|
|
984
|
-
"Skipping pointer write — origin conversation may no longer exist",
|
|
985
|
-
);
|
|
986
|
-
});
|
|
987
|
-
}
|
|
988
|
-
this.state = "idle";
|
|
989
|
-
return;
|
|
866
|
+
// Notify the voice conversation
|
|
867
|
+
if (shouldNotifyCompletion && currentSession) {
|
|
868
|
+
finalizeCall(this.callSessionId, currentSession.conversationId);
|
|
990
869
|
}
|
|
991
870
|
|
|
992
|
-
//
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
if (this.isExpectedAbortError(err) || runSignal.aborted) {
|
|
1002
|
-
log.debug(
|
|
871
|
+
// Post a pointer message in the initiating conversation
|
|
872
|
+
if (currentSession?.initiatedFromConversationId) {
|
|
873
|
+
const durationMs = currentSession.startedAt
|
|
874
|
+
? Date.now() - currentSession.startedAt
|
|
875
|
+
: 0;
|
|
876
|
+
addPointerMessage(
|
|
877
|
+
currentSession.initiatedFromConversationId,
|
|
878
|
+
"completed",
|
|
879
|
+
currentSession.toNumber,
|
|
1003
880
|
{
|
|
1004
|
-
|
|
1005
|
-
errName: err instanceof Error ? err.name : typeof err,
|
|
1006
|
-
stale: !this.isCurrentRun(runVersion),
|
|
881
|
+
duration: durationMs > 0 ? formatDuration(durationMs) : undefined,
|
|
1007
882
|
},
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
log.debug(
|
|
1018
|
-
{
|
|
1019
|
-
callSessionId: this.callSessionId,
|
|
1020
|
-
errName: err instanceof Error ? err.name : typeof err,
|
|
1021
|
-
},
|
|
1022
|
-
"Ignoring stale voice turn error from superseded turn",
|
|
1023
|
-
);
|
|
1024
|
-
return;
|
|
883
|
+
).catch((err) => {
|
|
884
|
+
log.warn(
|
|
885
|
+
{
|
|
886
|
+
conversationId: currentSession.initiatedFromConversationId,
|
|
887
|
+
err,
|
|
888
|
+
},
|
|
889
|
+
"Skipping pointer write — origin conversation may no longer exist",
|
|
890
|
+
);
|
|
891
|
+
});
|
|
1025
892
|
}
|
|
1026
|
-
log.error({ err, callSessionId: this.callSessionId }, "Voice turn error");
|
|
1027
|
-
this.relay.sendTextToken(
|
|
1028
|
-
"I'm sorry, I encountered a technical issue. Could you repeat that?",
|
|
1029
|
-
true,
|
|
1030
|
-
);
|
|
1031
893
|
this.state = "idle";
|
|
1032
|
-
|
|
1033
|
-
this.flushPendingInstructions();
|
|
894
|
+
return;
|
|
1034
895
|
}
|
|
896
|
+
|
|
897
|
+
// Normal turn complete — restart silence detection and flush any
|
|
898
|
+
// instructions that arrived while the LLM was active.
|
|
899
|
+
this.state = "idle";
|
|
900
|
+
this.currentTurnHandle = null;
|
|
901
|
+
this.resetSilenceTimer();
|
|
902
|
+
this.flushPendingInstructions();
|
|
1035
903
|
}
|
|
1036
904
|
|
|
1037
905
|
private isExpectedAbortError(err: unknown): boolean {
|
|
@@ -1268,23 +1136,7 @@ export class CallController {
|
|
|
1268
1136
|
reason: "max_duration",
|
|
1269
1137
|
});
|
|
1270
1138
|
if (shouldNotifyCompletion && currentSession) {
|
|
1271
|
-
|
|
1272
|
-
currentSession.conversationId,
|
|
1273
|
-
this.callSessionId,
|
|
1274
|
-
).catch((err) => {
|
|
1275
|
-
log.error(
|
|
1276
|
-
{
|
|
1277
|
-
err,
|
|
1278
|
-
conversationId: currentSession.conversationId,
|
|
1279
|
-
callSessionId: this.callSessionId,
|
|
1280
|
-
},
|
|
1281
|
-
"Failed to persist call completion message",
|
|
1282
|
-
);
|
|
1283
|
-
});
|
|
1284
|
-
fireCallCompletionNotifier(
|
|
1285
|
-
currentSession.conversationId,
|
|
1286
|
-
this.callSessionId,
|
|
1287
|
-
);
|
|
1139
|
+
finalizeCall(this.callSessionId, currentSession.conversationId);
|
|
1288
1140
|
}
|
|
1289
1141
|
|
|
1290
1142
|
// Post a pointer message in the initiating conversation
|