@vellumai/assistant 0.3.27 → 0.4.0
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 +81 -4
- package/Dockerfile +2 -2
- package/bun.lock +4 -1
- package/docs/trusted-contact-access.md +9 -2
- package/package.json +6 -3
- package/scripts/ipc/generate-swift.ts +9 -5
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
- package/src/__tests__/agent-loop-thinking.test.ts +1 -1
- package/src/__tests__/agent-loop.test.ts +119 -0
- package/src/__tests__/approval-routes-http.test.ts +13 -5
- package/src/__tests__/asset-materialize-tool.test.ts +2 -0
- package/src/__tests__/asset-search-tool.test.ts +2 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
- package/src/__tests__/attachments-store.test.ts +2 -0
- package/src/__tests__/browser-skill-endstate.test.ts +3 -3
- package/src/__tests__/bundled-asset.test.ts +107 -0
- package/src/__tests__/call-controller.test.ts +30 -29
- package/src/__tests__/call-routes-http.test.ts +34 -32
- package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
- package/src/__tests__/canonical-guardian-store.test.ts +636 -0
- package/src/__tests__/channel-approval-routes.test.ts +174 -1
- package/src/__tests__/channel-invite-transport.test.ts +6 -6
- package/src/__tests__/channel-reply-delivery.test.ts +19 -0
- package/src/__tests__/channel-retry-sweep.test.ts +130 -0
- package/src/__tests__/clarification-resolver.test.ts +2 -0
- package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
- package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
- package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
- package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
- package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
- package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
- package/src/__tests__/config-schema.test.ts +5 -5
- package/src/__tests__/config-watcher.test.ts +3 -1
- package/src/__tests__/connection-policy.test.ts +14 -5
- package/src/__tests__/contacts-tools.test.ts +3 -1
- package/src/__tests__/contradiction-checker.test.ts +2 -0
- package/src/__tests__/conversation-pairing.test.ts +10 -0
- package/src/__tests__/conversation-routes.test.ts +1 -1
- package/src/__tests__/credential-security-invariants.test.ts +16 -6
- package/src/__tests__/credential-vault-unit.test.ts +2 -2
- package/src/__tests__/credential-vault.test.ts +5 -4
- package/src/__tests__/daemon-lifecycle.test.ts +9 -0
- package/src/__tests__/daemon-server-session-init.test.ts +27 -0
- package/src/__tests__/elevenlabs-config.test.ts +2 -0
- package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
- package/src/__tests__/encrypted-store.test.ts +10 -5
- package/src/__tests__/followup-tools.test.ts +3 -1
- package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
- package/src/__tests__/gmail-integration.test.ts +0 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
- package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
- package/src/__tests__/guardian-dispatch.test.ts +21 -19
- package/src/__tests__/guardian-grant-minting.test.ts +68 -1
- package/src/__tests__/guardian-outbound-http.test.ts +12 -9
- package/src/__tests__/guardian-routing-invariants.test.ts +1092 -0
- package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
- package/src/__tests__/handlers-slack-config.test.ts +3 -1
- package/src/__tests__/handlers-telegram-config.test.ts +3 -1
- package/src/__tests__/handlers-twilio-config.test.ts +3 -1
- package/src/__tests__/handlers-twitter-config.test.ts +3 -1
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
- package/src/__tests__/heartbeat-service.test.ts +20 -0
- package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
- package/src/__tests__/ingress-reconcile.test.ts +3 -1
- package/src/__tests__/ingress-routes-http.test.ts +231 -4
- package/src/__tests__/intent-routing.test.ts +2 -0
- package/src/__tests__/ipc-snapshot.test.ts +13 -0
- package/src/__tests__/mcp-cli.test.ts +77 -0
- package/src/__tests__/media-generate-image.test.ts +21 -0
- package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
- package/src/__tests__/memory-regressions.test.ts +20 -20
- package/src/__tests__/non-member-access-request.test.ts +212 -36
- package/src/__tests__/notification-decision-fallback.test.ts +63 -3
- package/src/__tests__/notification-decision-strategy.test.ts +78 -0
- package/src/__tests__/notification-guardian-path.test.ts +15 -15
- package/src/__tests__/oauth-connect-handler.test.ts +3 -1
- package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
- package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
- package/src/__tests__/onboarding-template-contract.test.ts +116 -21
- package/src/__tests__/pairing-routes.test.ts +171 -0
- package/src/__tests__/playbook-execution.test.ts +3 -1
- package/src/__tests__/playbook-tools.test.ts +3 -1
- package/src/__tests__/provider-error-scenarios.test.ts +59 -8
- package/src/__tests__/proxy-approval-callback.test.ts +2 -0
- package/src/__tests__/recording-handler.test.ts +11 -0
- package/src/__tests__/recording-intent-handler.test.ts +15 -0
- package/src/__tests__/recording-state-machine.test.ts +13 -2
- package/src/__tests__/registry.test.ts +7 -3
- package/src/__tests__/relay-server.test.ts +148 -28
- package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
- package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
- package/src/__tests__/runtime-events-sse.test.ts +4 -2
- package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
- package/src/__tests__/schedule-tools.test.ts +3 -1
- package/src/__tests__/secret-scanner-executor.test.ts +59 -0
- package/src/__tests__/secret-scanner.test.ts +8 -0
- package/src/__tests__/send-endpoint-busy.test.ts +4 -0
- package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
- package/src/__tests__/session-abort-tool-results.test.ts +23 -0
- package/src/__tests__/session-agent-loop.test.ts +16 -0
- package/src/__tests__/session-conflict-gate.test.ts +21 -0
- package/src/__tests__/session-load-history-repair.test.ts +27 -17
- package/src/__tests__/session-pre-run-repair.test.ts +23 -0
- package/src/__tests__/session-profile-injection.test.ts +21 -0
- package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
- package/src/__tests__/session-queue.test.ts +23 -0
- package/src/__tests__/session-runtime-assembly.test.ts +126 -59
- package/src/__tests__/session-skill-tools.test.ts +27 -5
- package/src/__tests__/session-slash-known.test.ts +23 -0
- package/src/__tests__/session-slash-queue.test.ts +23 -0
- package/src/__tests__/session-slash-unknown.test.ts +23 -0
- package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
- package/src/__tests__/session-workspace-injection.test.ts +21 -0
- package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
- package/src/__tests__/shell-credential-ref.test.ts +2 -0
- package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
- package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
- package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
- package/src/__tests__/skills.test.ts +8 -4
- package/src/__tests__/slack-channel-config.test.ts +3 -1
- package/src/__tests__/subagent-tools.test.ts +19 -0
- package/src/__tests__/swarm-recursion.test.ts +2 -0
- package/src/__tests__/swarm-session-integration.test.ts +2 -0
- package/src/__tests__/swarm-tool.test.ts +2 -0
- package/src/__tests__/system-prompt.test.ts +3 -1
- package/src/__tests__/task-compiler.test.ts +3 -1
- package/src/__tests__/task-management-tools.test.ts +3 -1
- package/src/__tests__/task-tools.test.ts +3 -1
- package/src/__tests__/terminal-sandbox.test.ts +13 -12
- package/src/__tests__/terminal-tools.test.ts +2 -0
- package/src/__tests__/tool-approval-handler.test.ts +15 -15
- package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
- package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
- package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
- package/src/__tests__/trusted-contact-verification.test.ts +91 -0
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
- package/src/__tests__/twitter-auth-handler.test.ts +3 -1
- package/src/__tests__/twitter-cli-routing.test.ts +3 -1
- package/src/__tests__/view-image-tool.test.ts +3 -1
- package/src/__tests__/voice-invite-redemption.test.ts +329 -0
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
- package/src/__tests__/voice-session-bridge.test.ts +10 -10
- package/src/__tests__/work-item-output.test.ts +3 -1
- package/src/__tests__/workspace-lifecycle.test.ts +13 -2
- package/src/agent/loop.ts +46 -3
- package/src/approvals/guardian-decision-primitive.ts +285 -0
- package/src/approvals/guardian-request-resolvers.ts +539 -0
- package/src/calls/call-controller.ts +26 -23
- package/src/calls/guardian-action-sweep.ts +10 -2
- package/src/calls/guardian-dispatch.ts +46 -40
- package/src/calls/relay-server.ts +358 -24
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +3 -3
- package/src/cli.ts +12 -0
- package/src/config/agent-schema.ts +14 -3
- package/src/config/calls-schema.ts +6 -6
- package/src/config/core-schema.ts +3 -3
- package/src/config/feature-flag-registry.json +8 -0
- package/src/config/mcp-schema.ts +1 -1
- package/src/config/memory-schema.ts +27 -19
- package/src/config/schema.ts +21 -21
- package/src/config/skills-schema.ts +7 -7
- package/src/config/system-prompt.ts +2 -1
- package/src/config/templates/BOOTSTRAP.md +47 -31
- package/src/config/templates/USER.md +5 -0
- package/src/config/update-bulletin-template-path.ts +4 -1
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +149 -21
- package/src/daemon/handlers/config-inbox.ts +4 -4
- package/src/daemon/handlers/guardian-actions.ts +45 -66
- package/src/daemon/handlers/sessions.ts +148 -4
- package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
- package/src/daemon/ipc-contract/messages.ts +16 -0
- package/src/daemon/ipc-contract-inventory.json +1 -0
- package/src/daemon/lifecycle.ts +22 -16
- package/src/daemon/pairing-store.ts +86 -3
- package/src/daemon/server.ts +18 -0
- package/src/daemon/session-agent-loop-handlers.ts +5 -4
- package/src/daemon/session-agent-loop.ts +33 -6
- package/src/daemon/session-lifecycle.ts +25 -17
- package/src/daemon/session-memory.ts +2 -2
- package/src/daemon/session-process.ts +68 -326
- package/src/daemon/session-runtime-assembly.ts +119 -25
- package/src/daemon/session-tool-setup.ts +3 -2
- package/src/daemon/session.ts +4 -3
- package/src/home-base/prebuilt/seed.ts +2 -1
- package/src/hooks/templates.ts +2 -1
- package/src/memory/canonical-guardian-store.ts +586 -0
- package/src/memory/channel-guardian-store.ts +2 -0
- package/src/memory/conversation-crud.ts +7 -7
- package/src/memory/db-init.ts +20 -0
- package/src/memory/embedding-local.ts +257 -39
- package/src/memory/embedding-runtime-manager.ts +471 -0
- package/src/memory/guardian-action-store.ts +7 -60
- package/src/memory/guardian-approvals.ts +9 -4
- package/src/memory/guardian-bindings.ts +25 -1
- package/src/memory/indexer.ts +3 -3
- package/src/memory/ingress-invite-store.ts +45 -0
- package/src/memory/job-handlers/backfill.ts +16 -9
- package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
- package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
- package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
- package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
- package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
- package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
- package/src/memory/migrations/index.ts +5 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/qdrant-client.ts +31 -22
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +56 -0
- package/src/notifications/copy-composer.ts +31 -4
- package/src/notifications/decision-engine.ts +57 -0
- package/src/permissions/defaults.ts +2 -0
- package/src/runtime/access-request-helper.ts +173 -0
- package/src/runtime/actor-trust-resolver.ts +221 -0
- package/src/runtime/channel-guardian-service.ts +12 -4
- package/src/runtime/channel-invite-transports/voice.ts +58 -0
- package/src/runtime/channel-retry-sweep.ts +18 -6
- package/src/runtime/guardian-context-resolver.ts +38 -71
- package/src/runtime/guardian-decision-types.ts +6 -0
- package/src/runtime/guardian-reply-router.ts +717 -0
- package/src/runtime/http-server.ts +8 -0
- package/src/runtime/ingress-service.ts +80 -3
- package/src/runtime/invite-redemption-service.ts +141 -2
- package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
- package/src/runtime/routes/channel-route-shared.ts +1 -1
- package/src/runtime/routes/channel-routes.ts +1 -1
- package/src/runtime/routes/conversation-routes.ts +20 -2
- package/src/runtime/routes/guardian-action-routes.ts +100 -109
- package/src/runtime/routes/guardian-approval-interception.ts +17 -6
- package/src/runtime/routes/inbound-message-handler.ts +205 -529
- package/src/runtime/routes/ingress-routes.ts +52 -4
- package/src/runtime/routes/pairing-routes.ts +3 -0
- package/src/runtime/tool-grant-request-helper.ts +195 -0
- package/src/tools/executor.ts +13 -1
- package/src/tools/guardian-control-plane-policy.ts +2 -2
- package/src/tools/sensitive-output-placeholders.ts +203 -0
- package/src/tools/tool-approval-handler.ts +53 -10
- package/src/tools/types.ts +13 -2
- package/src/util/bundled-asset.ts +31 -0
- package/src/util/canonicalize-identity.ts +52 -0
- package/src/util/logger.ts +20 -8
- package/src/util/platform.ts +10 -0
- package/src/util/voice-code.ts +29 -0
- package/src/daemon/guardian-invite-intent.ts +0 -124
|
@@ -7,14 +7,13 @@
|
|
|
7
7
|
* 3. Records guardian_action_delivery rows from pipeline delivery results
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { getActiveBinding } from '../memory/channel-guardian-store.js';
|
|
11
10
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} from '../memory/guardian-
|
|
11
|
+
createCanonicalGuardianDelivery,
|
|
12
|
+
createCanonicalGuardianRequest,
|
|
13
|
+
listCanonicalGuardianDeliveries,
|
|
14
|
+
listCanonicalGuardianRequests,
|
|
15
|
+
updateCanonicalGuardianDelivery,
|
|
16
|
+
} from '../memory/canonical-guardian-store.js';
|
|
18
17
|
import { emitNotificationSignal } from '../notifications/emit-signal.js';
|
|
19
18
|
import type { NotificationDeliveryResult } from '../notifications/types.js';
|
|
20
19
|
import { getLogger } from '../util/logger.js';
|
|
@@ -41,11 +40,10 @@ export interface GuardianDispatchParams {
|
|
|
41
40
|
|
|
42
41
|
function applyDeliveryStatus(deliveryId: string, result: NotificationDeliveryResult): void {
|
|
43
42
|
if (result.status === 'sent') {
|
|
44
|
-
|
|
43
|
+
updateCanonicalGuardianDelivery(deliveryId, { status: 'sent' });
|
|
45
44
|
return;
|
|
46
45
|
}
|
|
47
|
-
|
|
48
|
-
updateDeliveryStatus(deliveryId, 'failed', errorMessage);
|
|
46
|
+
updateCanonicalGuardianDelivery(deliveryId, { status: 'failed' });
|
|
49
47
|
}
|
|
50
48
|
|
|
51
49
|
/**
|
|
@@ -90,36 +88,53 @@ async function dispatchGuardianQuestionInner(params: GuardianDispatchParams): Pr
|
|
|
90
88
|
try {
|
|
91
89
|
const expiresAt = Date.now() + getUserConsultationTimeoutMs();
|
|
92
90
|
|
|
93
|
-
// Create the
|
|
94
|
-
const request =
|
|
95
|
-
|
|
96
|
-
|
|
91
|
+
// Create the canonical guardian request as the primary record.
|
|
92
|
+
const request = createCanonicalGuardianRequest({
|
|
93
|
+
kind: 'pending_question',
|
|
94
|
+
sourceType: 'voice',
|
|
97
95
|
sourceChannel: 'voice',
|
|
98
|
-
|
|
96
|
+
conversationId,
|
|
99
97
|
callSessionId,
|
|
100
98
|
pendingQuestionId: pendingQuestion.id,
|
|
101
99
|
questionText: pendingQuestion.questionText,
|
|
102
|
-
expiresAt,
|
|
103
100
|
toolName,
|
|
104
101
|
inputDigest,
|
|
102
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
105
103
|
});
|
|
106
104
|
|
|
107
105
|
log.info(
|
|
108
106
|
{ requestId: request.id, requestCode: request.requestCode, callSessionId },
|
|
109
|
-
'Created guardian
|
|
107
|
+
'Created canonical guardian request for voice dispatch',
|
|
110
108
|
);
|
|
111
109
|
|
|
112
|
-
// Count how many guardian requests are already pending for
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
110
|
+
// Count how many canonical guardian requests are already pending for
|
|
111
|
+
// this call session. Used as a candidate-affinity hint so the decision
|
|
112
|
+
// engine prefers reusing an existing thread.
|
|
113
|
+
const activeGuardianRequestCount = listCanonicalGuardianRequests({
|
|
114
|
+
status: 'pending',
|
|
115
|
+
sourceType: 'voice',
|
|
116
|
+
}).filter(r => r.callSessionId === callSessionId).length;
|
|
117
117
|
|
|
118
118
|
// Look up the vellum conversation used for the first guardian question
|
|
119
|
-
// in this call session. When found, pass it as an affinity hint
|
|
120
|
-
// notification pipeline deterministically routes to the same
|
|
121
|
-
// instead of letting the LLM choose a different thread.
|
|
122
|
-
|
|
119
|
+
// delivery in this call session. When found, pass it as an affinity hint
|
|
120
|
+
// so the notification pipeline deterministically routes to the same
|
|
121
|
+
// conversation instead of letting the LLM choose a different thread.
|
|
122
|
+
// Find earlier canonical requests for this call session and check their
|
|
123
|
+
// deliveries for a vellum destination conversation ID.
|
|
124
|
+
let existingGuardianConversationId: string | null = null;
|
|
125
|
+
const priorRequests = listCanonicalGuardianRequests({
|
|
126
|
+
sourceType: 'voice',
|
|
127
|
+
}).filter(r => r.callSessionId === callSessionId && r.id !== request.id);
|
|
128
|
+
for (const priorReq of priorRequests) {
|
|
129
|
+
const deliveries = listCanonicalGuardianDeliveries(priorReq.id);
|
|
130
|
+
const vellumDelivery = deliveries.find(
|
|
131
|
+
d => d.destinationChannel === 'vellum' && d.destinationConversationId,
|
|
132
|
+
);
|
|
133
|
+
if (vellumDelivery?.destinationConversationId) {
|
|
134
|
+
existingGuardianConversationId = vellumDelivery.destinationConversationId;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
123
138
|
const conversationAffinityHint = existingGuardianConversationId
|
|
124
139
|
? { vellum: existingGuardianConversationId }
|
|
125
140
|
: undefined;
|
|
@@ -158,7 +173,7 @@ async function dispatchGuardianQuestionInner(params: GuardianDispatchParams): Pr
|
|
|
158
173
|
dedupeKey: `guardian:${request.id}`,
|
|
159
174
|
onThreadCreated: (info) => {
|
|
160
175
|
if (info.sourceEventName !== 'guardian.question' || vellumDeliveryId) return;
|
|
161
|
-
const delivery =
|
|
176
|
+
const delivery = createCanonicalGuardianDelivery({
|
|
162
177
|
requestId: request.id,
|
|
163
178
|
destinationChannel: 'vellum',
|
|
164
179
|
destinationConversationId: info.conversationId,
|
|
@@ -167,13 +182,10 @@ async function dispatchGuardianQuestionInner(params: GuardianDispatchParams): Pr
|
|
|
167
182
|
},
|
|
168
183
|
});
|
|
169
184
|
|
|
170
|
-
const telegramBinding = getActiveBinding(assistantId, 'telegram');
|
|
171
|
-
const smsBinding = getActiveBinding(assistantId, 'sms');
|
|
172
|
-
|
|
173
185
|
for (const result of signalResult.deliveryResults) {
|
|
174
186
|
if (result.channel === 'vellum') {
|
|
175
187
|
if (!vellumDeliveryId) {
|
|
176
|
-
const delivery =
|
|
188
|
+
const delivery = createCanonicalGuardianDelivery({
|
|
177
189
|
requestId: request.id,
|
|
178
190
|
destinationChannel: 'vellum',
|
|
179
191
|
destinationConversationId: result.conversationId,
|
|
@@ -188,26 +200,20 @@ async function dispatchGuardianQuestionInner(params: GuardianDispatchParams): Pr
|
|
|
188
200
|
continue;
|
|
189
201
|
}
|
|
190
202
|
|
|
191
|
-
const
|
|
192
|
-
const delivery = createGuardianActionDelivery({
|
|
203
|
+
const delivery = createCanonicalGuardianDelivery({
|
|
193
204
|
requestId: request.id,
|
|
194
205
|
destinationChannel: result.channel,
|
|
195
206
|
destinationChatId: result.destination.length > 0 ? result.destination : undefined,
|
|
196
|
-
destinationExternalUserId: binding?.guardianExternalUserId,
|
|
197
207
|
});
|
|
198
208
|
applyDeliveryStatus(delivery.id, result);
|
|
199
209
|
}
|
|
200
210
|
|
|
201
211
|
if (!vellumDeliveryId) {
|
|
202
|
-
const fallback =
|
|
212
|
+
const fallback = createCanonicalGuardianDelivery({
|
|
203
213
|
requestId: request.id,
|
|
204
214
|
destinationChannel: 'vellum',
|
|
205
215
|
});
|
|
206
|
-
|
|
207
|
-
fallback.id,
|
|
208
|
-
'failed',
|
|
209
|
-
`No vellum delivery result from notification pipeline (${signalResult.reason})`,
|
|
210
|
-
);
|
|
216
|
+
updateCanonicalGuardianDelivery(fallback.id, { status: 'failed' });
|
|
211
217
|
log.warn(
|
|
212
218
|
{ requestId: request.id, reason: signalResult.reason },
|
|
213
219
|
'Notification pipeline did not produce a vellum delivery result',
|
|
@@ -10,21 +10,25 @@ import { randomInt } from 'node:crypto';
|
|
|
10
10
|
|
|
11
11
|
import type { ServerWebSocket } from 'bun';
|
|
12
12
|
|
|
13
|
+
import { isAssistantFeatureFlagEnabled } from '../config/assistant-feature-flags.js';
|
|
13
14
|
import { getConfig } from '../config/loader.js';
|
|
14
15
|
import * as conversationStore from '../memory/conversation-store.js';
|
|
16
|
+
import { findActiveVoiceInvites } from '../memory/ingress-invite-store.js';
|
|
15
17
|
import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
|
|
18
|
+
import { notifyGuardianOfAccessRequest } from '../runtime/access-request-helper.js';
|
|
19
|
+
import {
|
|
20
|
+
resolveActorTrust,
|
|
21
|
+
toGuardianRuntimeContextFromTrust,
|
|
22
|
+
} from '../runtime/actor-trust-resolver.js';
|
|
16
23
|
import {
|
|
17
24
|
getPendingChallenge,
|
|
18
25
|
validateAndConsumeChallenge,
|
|
19
26
|
} from '../runtime/channel-guardian-service.js';
|
|
20
|
-
import {
|
|
21
|
-
resolveGuardianContext,
|
|
22
|
-
toGuardianRuntimeContext,
|
|
23
|
-
} from '../runtime/guardian-context-resolver.js';
|
|
24
27
|
import {
|
|
25
28
|
composeVerificationVoice,
|
|
26
29
|
GUARDIAN_VERIFY_TEMPLATE_KEYS,
|
|
27
30
|
} from '../runtime/guardian-verification-templates.js';
|
|
31
|
+
import { redeemVoiceInviteCode } from '../runtime/ingress-service.js';
|
|
28
32
|
import { parseJsonSafe } from '../util/json.js';
|
|
29
33
|
import { getLogger } from '../util/logger.js';
|
|
30
34
|
import { normalizeAssistantId } from '../util/platform.js';
|
|
@@ -171,6 +175,12 @@ export class RelayConnection {
|
|
|
171
175
|
// Outbound guardian verification state (system calls the guardian)
|
|
172
176
|
private outboundGuardianVerificationSessionId: string | null = null;
|
|
173
177
|
|
|
178
|
+
// Inbound voice invite redemption state
|
|
179
|
+
private inviteRedemptionActive = false;
|
|
180
|
+
private inviteRedemptionAssistantId: string | null = null;
|
|
181
|
+
private inviteRedemptionFromNumber: string | null = null;
|
|
182
|
+
private inviteRedemptionCodeLength = 6;
|
|
183
|
+
|
|
174
184
|
constructor(ws: ServerWebSocket<RelayWebSocketData>, callSessionId: string) {
|
|
175
185
|
this.ws = ws;
|
|
176
186
|
this.callSessionId = callSessionId;
|
|
@@ -426,15 +436,13 @@ export class RelayConnection {
|
|
|
426
436
|
// calls msg.from is the caller; for outbound calls msg.to is the
|
|
427
437
|
// recipient (msg.from is the assistant's Twilio number).
|
|
428
438
|
const otherPartyNumber = isInbound ? msg.from : msg.to;
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
}),
|
|
437
|
-
);
|
|
439
|
+
const initialActorTrust = resolveActorTrust({
|
|
440
|
+
assistantId,
|
|
441
|
+
sourceChannel: 'voice',
|
|
442
|
+
externalChatId: otherPartyNumber,
|
|
443
|
+
senderExternalUserId: otherPartyNumber || undefined,
|
|
444
|
+
});
|
|
445
|
+
const initialGuardianContext = toGuardianRuntimeContextFromTrust(initialActorTrust, otherPartyNumber);
|
|
438
446
|
|
|
439
447
|
const controller = new CallController(this.callSessionId, this, session?.task ?? null, {
|
|
440
448
|
broadcast: globalBroadcast,
|
|
@@ -471,10 +479,185 @@ export class RelayConnection {
|
|
|
471
479
|
if (!isInbound && verificationConfig.enabled) {
|
|
472
480
|
await this.startVerification(session, verificationConfig);
|
|
473
481
|
} else if (isInbound) {
|
|
474
|
-
//
|
|
475
|
-
//
|
|
482
|
+
// ── Trusted-contact ACL enforcement for inbound voice ──
|
|
483
|
+
// Resolve the caller's trust classification before allowing the call
|
|
484
|
+
// to proceed. Guardian and trusted-contact callers pass through;
|
|
485
|
+
// unknown callers are denied with deterministic voice copy and an
|
|
486
|
+
// access request is created for the guardian — unless there is a
|
|
487
|
+
// pending voice guardian challenge, in which case the caller is
|
|
488
|
+
// expected to be unknown (no binding yet) and should enter the
|
|
489
|
+
// verification flow.
|
|
490
|
+
const actorTrust = resolveActorTrust({
|
|
491
|
+
assistantId,
|
|
492
|
+
sourceChannel: 'voice',
|
|
493
|
+
externalChatId: msg.from,
|
|
494
|
+
senderExternalUserId: msg.from || undefined,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// Check for a pending voice guardian challenge before the ACL deny
|
|
498
|
+
// gate. An unknown caller with a pending challenge is expected —
|
|
499
|
+
// they need to complete verification to establish a binding.
|
|
476
500
|
const pendingChallenge = getPendingChallenge(assistantId, 'voice');
|
|
477
501
|
|
|
502
|
+
if (actorTrust.trustClass === 'unknown' && !pendingChallenge) {
|
|
503
|
+
// Before denying, check if there is an active voice invite bound
|
|
504
|
+
// to the caller's phone number. If so, enter the invite redemption
|
|
505
|
+
// subflow instead of denying the call outright.
|
|
506
|
+
// Gated behind the voice-invite-redemption feature flag (defaults OFF).
|
|
507
|
+
const voiceInviteEnabled = isAssistantFeatureFlagEnabled(
|
|
508
|
+
'feature_flags.voice-invite-redemption.enabled',
|
|
509
|
+
config,
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
if (voiceInviteEnabled) {
|
|
513
|
+
let voiceInvites: ReturnType<typeof findActiveVoiceInvites> = [];
|
|
514
|
+
try {
|
|
515
|
+
voiceInvites = findActiveVoiceInvites({
|
|
516
|
+
assistantId,
|
|
517
|
+
expectedExternalUserId: msg.from,
|
|
518
|
+
});
|
|
519
|
+
} catch (err) {
|
|
520
|
+
log.warn({ err, callSessionId: this.callSessionId }, 'Failed to check voice invites for unknown caller');
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Exclude invites that are past their expiresAt even if the DB
|
|
524
|
+
// status hasn't been lazily flipped to 'expired' yet.
|
|
525
|
+
const now = Date.now();
|
|
526
|
+
const nonExpiredInvites = voiceInvites.filter(i => !i.expiresAt || i.expiresAt > now);
|
|
527
|
+
|
|
528
|
+
if (nonExpiredInvites.length > 0) {
|
|
529
|
+
log.info(
|
|
530
|
+
{ callSessionId: this.callSessionId, from: msg.from },
|
|
531
|
+
'Inbound voice ACL: unknown caller has active voice invite — entering redemption flow',
|
|
532
|
+
);
|
|
533
|
+
this.startInviteRedemption(assistantId, msg.from);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
log.info(
|
|
539
|
+
{ callSessionId: this.callSessionId, from: msg.from, trustClass: actorTrust.trustClass },
|
|
540
|
+
'Inbound voice ACL: unknown caller denied',
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
recordCallEvent(this.callSessionId, 'inbound_acl_denied', {
|
|
544
|
+
from: msg.from,
|
|
545
|
+
trustClass: actorTrust.trustClass,
|
|
546
|
+
denialReason: actorTrust.denialReason,
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// For revoked/pending members, notify the guardian so they can
|
|
550
|
+
// re-approve. Blocked members are intentionally excluded — the
|
|
551
|
+
// guardian already made an explicit decision to block them.
|
|
552
|
+
let guardianNotified = false;
|
|
553
|
+
if (actorTrust.memberRecord?.status !== 'blocked') {
|
|
554
|
+
try {
|
|
555
|
+
const accessResult = notifyGuardianOfAccessRequest({
|
|
556
|
+
canonicalAssistantId: assistantId,
|
|
557
|
+
sourceChannel: 'voice',
|
|
558
|
+
externalChatId: msg.from,
|
|
559
|
+
senderExternalUserId: actorTrust.canonicalSenderId ?? msg.from,
|
|
560
|
+
});
|
|
561
|
+
guardianNotified = accessResult.notified;
|
|
562
|
+
} catch (err) {
|
|
563
|
+
log.error({ err, callSessionId: this.callSessionId }, 'Failed to create access request for denied voice caller');
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Deny with deterministic voice copy and end the call.
|
|
568
|
+
// Mark as disconnecting so handlePrompt ignores caller input
|
|
569
|
+
// during the delay before the session ends.
|
|
570
|
+
const denialMessage = guardianNotified
|
|
571
|
+
? 'This number is not authorized. Your request has been forwarded to the account guardian.'
|
|
572
|
+
: 'This number is not authorized to use this assistant.';
|
|
573
|
+
this.sendTextToken(denialMessage, true);
|
|
574
|
+
|
|
575
|
+
this.connectionState = 'disconnecting';
|
|
576
|
+
|
|
577
|
+
updateCallSession(this.callSessionId, {
|
|
578
|
+
status: 'failed',
|
|
579
|
+
endedAt: Date.now(),
|
|
580
|
+
lastError: 'Inbound voice ACL: caller not authorized',
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
setTimeout(() => {
|
|
584
|
+
this.endSession('Inbound voice ACL denied');
|
|
585
|
+
}, 3000);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Members with policy: 'deny' have status: 'active' so resolveActorTrust
|
|
590
|
+
// classifies them as trusted_contact, but the guardian has explicitly
|
|
591
|
+
// denied their access. Block them the same way the text-channel path does.
|
|
592
|
+
if (actorTrust.memberRecord?.policy === 'deny') {
|
|
593
|
+
log.info(
|
|
594
|
+
{ callSessionId: this.callSessionId, from: msg.from, memberId: actorTrust.memberRecord.id, trustClass: actorTrust.trustClass },
|
|
595
|
+
'Inbound voice ACL: member policy deny',
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
recordCallEvent(this.callSessionId, 'inbound_acl_denied', {
|
|
599
|
+
from: msg.from,
|
|
600
|
+
trustClass: actorTrust.trustClass,
|
|
601
|
+
memberId: actorTrust.memberRecord.id,
|
|
602
|
+
memberPolicy: actorTrust.memberRecord.policy,
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
this.sendTextToken('This number is not authorized to use this assistant.', true);
|
|
606
|
+
|
|
607
|
+
this.connectionState = 'disconnecting';
|
|
608
|
+
|
|
609
|
+
updateCallSession(this.callSessionId, {
|
|
610
|
+
status: 'failed',
|
|
611
|
+
endedAt: Date.now(),
|
|
612
|
+
lastError: 'Inbound voice ACL: member policy deny',
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
setTimeout(() => {
|
|
616
|
+
this.endSession('Inbound voice ACL: member policy deny');
|
|
617
|
+
}, 3000);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// Members with policy: 'escalate' require guardian approval, but a live
|
|
622
|
+
// voice call cannot be paused for async approval. Fail-closed by denying
|
|
623
|
+
// the call with an appropriate message — mirrors the deny block above.
|
|
624
|
+
if (actorTrust.memberRecord?.policy === 'escalate') {
|
|
625
|
+
log.info(
|
|
626
|
+
{ callSessionId: this.callSessionId, from: msg.from, memberId: actorTrust.memberRecord.id, trustClass: actorTrust.trustClass },
|
|
627
|
+
'Inbound voice ACL: member policy escalate — cannot hold live call for guardian approval',
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
recordCallEvent(this.callSessionId, 'inbound_acl_denied', {
|
|
631
|
+
from: msg.from,
|
|
632
|
+
trustClass: actorTrust.trustClass,
|
|
633
|
+
memberId: actorTrust.memberRecord.id,
|
|
634
|
+
memberPolicy: actorTrust.memberRecord.policy,
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
this.sendTextToken('This number requires guardian approval for calls. Please have the account guardian update your permissions.', true);
|
|
638
|
+
|
|
639
|
+
this.connectionState = 'disconnecting';
|
|
640
|
+
|
|
641
|
+
updateCallSession(this.callSessionId, {
|
|
642
|
+
status: 'failed',
|
|
643
|
+
endedAt: Date.now(),
|
|
644
|
+
lastError: 'Inbound voice ACL: member policy escalate — voice calls cannot await guardian approval',
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
setTimeout(() => {
|
|
648
|
+
this.endSession('Inbound voice ACL: member policy escalate');
|
|
649
|
+
}, 3000);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Guardian and trusted-contact callers proceed normally.
|
|
654
|
+
// Update the controller's guardian context with the trust-resolved
|
|
655
|
+
// context so downstream policy gates have accurate actor metadata.
|
|
656
|
+
if (this.controller && actorTrust.trustClass !== 'unknown') {
|
|
657
|
+
const resolvedGuardianContext = toGuardianRuntimeContextFromTrust(actorTrust, msg.from);
|
|
658
|
+
this.controller.setGuardianContext(resolvedGuardianContext);
|
|
659
|
+
}
|
|
660
|
+
|
|
478
661
|
if (pendingChallenge) {
|
|
479
662
|
this.startInboundGuardianVerification(assistantId, msg.from);
|
|
480
663
|
} else {
|
|
@@ -731,16 +914,14 @@ export class RelayConnection {
|
|
|
731
914
|
} else {
|
|
732
915
|
// Inbound: proceed to normal call flow
|
|
733
916
|
if (this.controller) {
|
|
917
|
+
const verifiedActorTrust = resolveActorTrust({
|
|
918
|
+
assistantId: this.guardianChallengeAssistantId,
|
|
919
|
+
sourceChannel: 'voice',
|
|
920
|
+
externalChatId: this.guardianVerificationFromNumber,
|
|
921
|
+
senderExternalUserId: this.guardianVerificationFromNumber,
|
|
922
|
+
});
|
|
734
923
|
this.controller.setGuardianContext(
|
|
735
|
-
|
|
736
|
-
'voice',
|
|
737
|
-
resolveGuardianContext({
|
|
738
|
-
assistantId: this.guardianChallengeAssistantId,
|
|
739
|
-
sourceChannel: 'voice',
|
|
740
|
-
externalChatId: this.guardianVerificationFromNumber,
|
|
741
|
-
senderExternalUserId: this.guardianVerificationFromNumber,
|
|
742
|
-
}),
|
|
743
|
-
),
|
|
924
|
+
toGuardianRuntimeContextFromTrust(verifiedActorTrust, this.guardianVerificationFromNumber),
|
|
744
925
|
);
|
|
745
926
|
this.startNormalCallFlow(this.controller, true);
|
|
746
927
|
}
|
|
@@ -815,6 +996,126 @@ export class RelayConnection {
|
|
|
815
996
|
}
|
|
816
997
|
}
|
|
817
998
|
|
|
999
|
+
/**
|
|
1000
|
+
* Enter the invite redemption subflow for an inbound unknown caller
|
|
1001
|
+
* who has an active voice invite. Prompts the caller to enter their
|
|
1002
|
+
* invite code via DTMF or speech.
|
|
1003
|
+
*/
|
|
1004
|
+
private startInviteRedemption(assistantId: string, fromNumber: string): void {
|
|
1005
|
+
this.inviteRedemptionActive = true;
|
|
1006
|
+
this.inviteRedemptionAssistantId = assistantId;
|
|
1007
|
+
this.inviteRedemptionFromNumber = fromNumber;
|
|
1008
|
+
this.connectionState = 'verification_pending';
|
|
1009
|
+
this.verificationAttempts = 0;
|
|
1010
|
+
this.verificationMaxAttempts = 3;
|
|
1011
|
+
this.inviteRedemptionCodeLength = 6;
|
|
1012
|
+
this.dtmfBuffer = '';
|
|
1013
|
+
|
|
1014
|
+
recordCallEvent(this.callSessionId, 'invite_redemption_started', {
|
|
1015
|
+
assistantId,
|
|
1016
|
+
codeLength: 6,
|
|
1017
|
+
maxAttempts: this.verificationMaxAttempts,
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
this.sendTextToken(
|
|
1021
|
+
'Please enter your 6-digit invite code using your keypad, or speak the digits now.',
|
|
1022
|
+
true,
|
|
1023
|
+
);
|
|
1024
|
+
|
|
1025
|
+
log.info(
|
|
1026
|
+
{ callSessionId: this.callSessionId, assistantId },
|
|
1027
|
+
'Inbound voice invite redemption started',
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Validate an entered invite code against active voice invites for the
|
|
1033
|
+
* caller. On success, create/activate the ingress member and transition
|
|
1034
|
+
* to the normal call flow. On failure, allow retries up to max attempts.
|
|
1035
|
+
*/
|
|
1036
|
+
private attemptInviteCodeRedemption(enteredCode: string): void {
|
|
1037
|
+
if (!this.inviteRedemptionAssistantId || !this.inviteRedemptionFromNumber) {
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const result = redeemVoiceInviteCode({
|
|
1042
|
+
assistantId: this.inviteRedemptionAssistantId,
|
|
1043
|
+
callerExternalUserId: this.inviteRedemptionFromNumber,
|
|
1044
|
+
sourceChannel: 'voice',
|
|
1045
|
+
code: enteredCode,
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
if (result.ok) {
|
|
1049
|
+
this.connectionState = 'connected';
|
|
1050
|
+
this.inviteRedemptionActive = false;
|
|
1051
|
+
this.verificationAttempts = 0;
|
|
1052
|
+
this.dtmfBuffer = '';
|
|
1053
|
+
|
|
1054
|
+
recordCallEvent(this.callSessionId, 'invite_redemption_succeeded', {
|
|
1055
|
+
memberId: result.memberId,
|
|
1056
|
+
...(result.type === 'redeemed' ? { inviteId: result.inviteId } : {}),
|
|
1057
|
+
});
|
|
1058
|
+
log.info(
|
|
1059
|
+
{ callSessionId: this.callSessionId, memberId: result.memberId, type: result.type },
|
|
1060
|
+
'Voice invite redemption succeeded',
|
|
1061
|
+
);
|
|
1062
|
+
|
|
1063
|
+
if (this.controller) {
|
|
1064
|
+
const redeemedActorTrust = resolveActorTrust({
|
|
1065
|
+
assistantId: this.inviteRedemptionAssistantId,
|
|
1066
|
+
sourceChannel: 'voice',
|
|
1067
|
+
externalChatId: this.inviteRedemptionFromNumber,
|
|
1068
|
+
senderExternalUserId: this.inviteRedemptionFromNumber,
|
|
1069
|
+
});
|
|
1070
|
+
this.controller.setGuardianContext(
|
|
1071
|
+
toGuardianRuntimeContextFromTrust(redeemedActorTrust, this.inviteRedemptionFromNumber),
|
|
1072
|
+
);
|
|
1073
|
+
this.startNormalCallFlow(this.controller, true);
|
|
1074
|
+
}
|
|
1075
|
+
} else {
|
|
1076
|
+
this.verificationAttempts++;
|
|
1077
|
+
|
|
1078
|
+
if (this.verificationAttempts >= this.verificationMaxAttempts) {
|
|
1079
|
+
this.inviteRedemptionActive = false;
|
|
1080
|
+
|
|
1081
|
+
recordCallEvent(this.callSessionId, 'invite_redemption_failed', {
|
|
1082
|
+
attempts: this.verificationAttempts,
|
|
1083
|
+
});
|
|
1084
|
+
log.warn(
|
|
1085
|
+
{ callSessionId: this.callSessionId, attempts: this.verificationAttempts },
|
|
1086
|
+
'Voice invite redemption failed — max attempts reached',
|
|
1087
|
+
);
|
|
1088
|
+
|
|
1089
|
+
this.sendTextToken('Too many invalid attempts. Goodbye.', true);
|
|
1090
|
+
|
|
1091
|
+
updateCallSession(this.callSessionId, {
|
|
1092
|
+
status: 'failed',
|
|
1093
|
+
endedAt: Date.now(),
|
|
1094
|
+
lastError: 'Voice invite redemption failed — max attempts exceeded',
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
const failSession = getCallSession(this.callSessionId);
|
|
1098
|
+
if (failSession) {
|
|
1099
|
+
expirePendingQuestions(this.callSessionId);
|
|
1100
|
+
persistCallCompletionMessage(failSession.conversationId, this.callSessionId).catch((err) => {
|
|
1101
|
+
log.error({ err, conversationId: failSession.conversationId, callSessionId: this.callSessionId }, 'Failed to persist call completion message');
|
|
1102
|
+
});
|
|
1103
|
+
fireCallCompletionNotifier(failSession.conversationId, this.callSessionId);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
setTimeout(() => {
|
|
1107
|
+
this.endSession('Invite redemption failed');
|
|
1108
|
+
}, 2000);
|
|
1109
|
+
} else {
|
|
1110
|
+
log.info(
|
|
1111
|
+
{ callSessionId: this.callSessionId, attempt: this.verificationAttempts, maxAttempts: this.verificationMaxAttempts },
|
|
1112
|
+
'Voice invite redemption attempt failed — retrying',
|
|
1113
|
+
);
|
|
1114
|
+
this.sendTextToken('Invalid code. Please try again.', true);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
818
1119
|
private async handlePrompt(msg: RelayPromptMessage): Promise<void> {
|
|
819
1120
|
if (this.connectionState === 'disconnecting') {
|
|
820
1121
|
return;
|
|
@@ -845,6 +1146,26 @@ export class RelayConnection {
|
|
|
845
1146
|
return;
|
|
846
1147
|
}
|
|
847
1148
|
|
|
1149
|
+
// During invite redemption, attempt to parse spoken digits from the
|
|
1150
|
+
// transcript and validate against the caller's active voice invite.
|
|
1151
|
+
if (this.connectionState === 'verification_pending' && this.inviteRedemptionActive) {
|
|
1152
|
+
const spokenDigits = RelayConnection.parseDigitsFromSpeech(msg.voicePrompt);
|
|
1153
|
+
log.info(
|
|
1154
|
+
{ callSessionId: this.callSessionId, transcript: msg.voicePrompt, spokenDigits },
|
|
1155
|
+
'Speech received during invite redemption',
|
|
1156
|
+
);
|
|
1157
|
+
if (spokenDigits.length >= this.inviteRedemptionCodeLength) {
|
|
1158
|
+
const enteredCode = spokenDigits.slice(0, this.inviteRedemptionCodeLength);
|
|
1159
|
+
this.attemptInviteCodeRedemption(enteredCode);
|
|
1160
|
+
} else if (spokenDigits.length > 0) {
|
|
1161
|
+
this.sendTextToken(
|
|
1162
|
+
`I heard ${spokenDigits.length} digits. Please enter all ${this.inviteRedemptionCodeLength} digits of your code.`,
|
|
1163
|
+
true,
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
848
1169
|
// During outbound callee verification, ignore voice prompts — the callee
|
|
849
1170
|
// should be entering DTMF digits, not speaking.
|
|
850
1171
|
if (this.connectionState === 'verification_pending') {
|
|
@@ -957,6 +1278,19 @@ export class RelayConnection {
|
|
|
957
1278
|
return;
|
|
958
1279
|
}
|
|
959
1280
|
|
|
1281
|
+
// If invite redemption is pending, accumulate digits and validate
|
|
1282
|
+
// the code against the caller's active voice invite.
|
|
1283
|
+
if (this.connectionState === 'verification_pending' && this.inviteRedemptionActive) {
|
|
1284
|
+
this.dtmfBuffer += msg.digit;
|
|
1285
|
+
|
|
1286
|
+
if (this.dtmfBuffer.length >= this.inviteRedemptionCodeLength) {
|
|
1287
|
+
const enteredCode = this.dtmfBuffer.slice(0, this.inviteRedemptionCodeLength);
|
|
1288
|
+
this.dtmfBuffer = '';
|
|
1289
|
+
this.attemptInviteCodeRedemption(enteredCode);
|
|
1290
|
+
}
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
960
1294
|
// If outbound callee verification is pending, accumulate digits and check the code
|
|
961
1295
|
if (this.connectionState === 'verification_pending' && this.verificationCode) {
|
|
962
1296
|
this.dtmfBuffer += msg.digit;
|
package/src/calls/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type CallStatus = 'initiated' | 'ringing' | 'in_progress' | 'waiting_on_user' | 'completed' | 'failed' | 'cancelled';
|
|
2
|
-
export type CallEventType = 'call_started' | 'call_connected' | 'caller_spoke' | 'assistant_spoke' | 'user_question_asked' | 'user_answered' | 'user_instruction_relayed' | 'call_ended' | 'call_failed' | 'callee_verification_started' | 'callee_verification_succeeded' | 'callee_verification_failed' | 'guardian_voice_verification_started' | 'guardian_voice_verification_succeeded' | 'guardian_voice_verification_failed' | 'outbound_guardian_voice_verification_started' | 'outbound_guardian_voice_verification_succeeded' | 'outbound_guardian_voice_verification_failed' | 'guardian_consultation_timed_out' | 'guardian_unavailable_skipped' | 'guardian_consult_deferred' | 'guardian_consult_coalesced';
|
|
2
|
+
export type CallEventType = 'call_started' | 'call_connected' | 'caller_spoke' | 'assistant_spoke' | 'user_question_asked' | 'user_answered' | 'user_instruction_relayed' | 'call_ended' | 'call_failed' | 'callee_verification_started' | 'callee_verification_succeeded' | 'callee_verification_failed' | 'guardian_voice_verification_started' | 'guardian_voice_verification_succeeded' | 'guardian_voice_verification_failed' | 'outbound_guardian_voice_verification_started' | 'outbound_guardian_voice_verification_succeeded' | 'outbound_guardian_voice_verification_failed' | 'guardian_consultation_timed_out' | 'guardian_unavailable_skipped' | 'guardian_consult_deferred' | 'guardian_consult_coalesced' | 'inbound_acl_denied' | 'invite_redemption_started' | 'invite_redemption_succeeded' | 'invite_redemption_failed';
|
|
3
3
|
export type PendingQuestionStatus = 'pending' | 'answered' | 'expired' | 'cancelled';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -250,8 +250,8 @@ export async function startVoiceTurn(opts: VoiceTurnOptions): Promise<VoiceTurnH
|
|
|
250
250
|
// - guardian: permission prompts auto-allow (parity with guardian chat)
|
|
251
251
|
// - everyone else (including unknown): fail-closed strict side-effects
|
|
252
252
|
// with auto-deny confirmations.
|
|
253
|
-
const
|
|
254
|
-
const isGuardian =
|
|
253
|
+
const trustClass = opts.guardianContext?.trustClass;
|
|
254
|
+
const isGuardian = trustClass === 'guardian';
|
|
255
255
|
const forceStrictSideEffects = isGuardian ? undefined : true;
|
|
256
256
|
|
|
257
257
|
// Replace the [CALL_OPENING] marker with a neutral instruction before
|
|
@@ -264,7 +264,7 @@ export async function startVoiceTurn(opts: VoiceTurnOptions): Promise<VoiceTurnH
|
|
|
264
264
|
|
|
265
265
|
// Build the call-control protocol prompt so the model knows how to emit
|
|
266
266
|
// control markers (ASK_GUARDIAN, END_CALL, etc.) and recognize opener turns.
|
|
267
|
-
const isCallerGuardian = opts.guardianContext?.
|
|
267
|
+
const isCallerGuardian = opts.guardianContext?.trustClass === 'guardian';
|
|
268
268
|
|
|
269
269
|
const voiceCallControlPrompt = buildVoiceCallControlPrompt({
|
|
270
270
|
isInbound: opts.isInbound,
|
package/src/cli.ts
CHANGED
|
@@ -492,6 +492,18 @@ export async function startCli(): Promise<void> {
|
|
|
492
492
|
break;
|
|
493
493
|
}
|
|
494
494
|
|
|
495
|
+
case 'message_request_complete': {
|
|
496
|
+
// Request-level terminal for inline approval consumption.
|
|
497
|
+
// When no agent turn remains active, clear busy state and re-prompt.
|
|
498
|
+
if (msg.runStillActive !== true) {
|
|
499
|
+
spinner.stop();
|
|
500
|
+
generating = false;
|
|
501
|
+
process.stdout.write('\n\n');
|
|
502
|
+
prompt();
|
|
503
|
+
}
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
|
|
495
507
|
case 'generation_handoff': {
|
|
496
508
|
// The current request's generation is done; show usage and re-prompt.
|
|
497
509
|
// Always clear `generating` — this CLI client's generation is finished
|