@absolutejs/voice 0.0.22-beta.30 → 0.0.22-beta.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/dist/handoff.d.ts CHANGED
@@ -1,14 +1,24 @@
1
- import type { VoiceHandoffAction, VoiceHandoffAdapter, VoiceHandoffConfig, VoiceHandoffInput, VoiceHandoffResult, VoiceSessionRecord } from './types';
1
+ import type { VoiceHandoffAction, VoiceHandoffAdapter, VoiceHandoffConfig, VoiceHandoffDeliveryStore, VoiceHandoffInput, VoiceHandoffResult, VoiceSessionRecord, StoredVoiceHandoffDelivery } from './types';
2
2
  type MaybePromise<T> = T | Promise<T>;
3
3
  export type VoiceHandoffDelivery = VoiceHandoffResult & {
4
4
  adapterId: string;
5
5
  adapterKind?: string;
6
6
  };
7
+ export type VoiceHandoffDeliveryRecord<TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown> = StoredVoiceHandoffDelivery<TContext, TSession, TResult>;
7
8
  export type VoiceHandoffFanoutResult = {
8
9
  action: VoiceHandoffAction;
9
10
  deliveries: Record<string, VoiceHandoffDelivery>;
10
11
  status: VoiceHandoffResult['status'];
11
12
  };
13
+ export type VoiceHandoffDeliveryRecordInput<TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown> = Omit<VoiceHandoffInput<TContext, TSession, TResult>, 'api'> & {
14
+ id?: string;
15
+ };
16
+ export type VoiceQueuedHandoffDeliveryOptions<TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown> = {
17
+ adapters: VoiceHandoffAdapter<TContext, TSession, TResult>[];
18
+ api: VoiceHandoffInput<TContext, TSession, TResult>['api'];
19
+ delivery: VoiceHandoffDeliveryRecord<TContext, TSession, TResult>;
20
+ failMode?: VoiceHandoffConfig<TContext, TSession, TResult>['failMode'];
21
+ };
12
22
  export type VoiceWebhookHandoffAdapterOptions<TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown> = {
13
23
  actions?: VoiceHandoffAction[];
14
24
  body?: (input: VoiceHandoffInput<TContext, TSession, TResult>) => MaybePromise<Record<string, unknown>>;
@@ -35,6 +45,10 @@ export declare const deliverVoiceHandoff: <TContext, TSession extends VoiceSessi
35
45
  config?: VoiceHandoffConfig<TContext, TSession, TResult>;
36
46
  handoff: VoiceHandoffInput<TContext, TSession, TResult>;
37
47
  }) => Promise<VoiceHandoffFanoutResult | undefined>;
48
+ export declare const createVoiceHandoffDeliveryRecord: <TContext, TSession extends VoiceSessionRecord, TResult>(input: VoiceHandoffDeliveryRecordInput<TContext, TSession, TResult>) => VoiceHandoffDeliveryRecord<TContext, TSession, TResult>;
49
+ export declare const applyVoiceHandoffDeliveryResult: <TContext, TSession extends VoiceSessionRecord, TResult>(delivery: VoiceHandoffDeliveryRecord<TContext, TSession, TResult>, result: VoiceHandoffFanoutResult) => VoiceHandoffDeliveryRecord<TContext, TSession, TResult>;
50
+ export declare const deliverVoiceHandoffDelivery: <TContext, TSession extends VoiceSessionRecord, TResult>(options: VoiceQueuedHandoffDeliveryOptions<TContext, TSession, TResult>) => Promise<VoiceHandoffDeliveryRecord<TContext, TSession, TResult>>;
51
+ export declare const createVoiceMemoryHandoffDeliveryStore: <TDelivery extends VoiceHandoffDeliveryRecord = VoiceHandoffDeliveryRecord>() => VoiceHandoffDeliveryStore<TDelivery>;
38
52
  export declare const createVoiceWebhookHandoffAdapter: <TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown>(options: VoiceWebhookHandoffAdapterOptions<TContext, TSession, TResult>) => VoiceHandoffAdapter<TContext, TSession, TResult>;
39
53
  export declare const createVoiceTwilioRedirectHandoffAdapter: <TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown>(options: VoiceTwilioRedirectHandoffAdapterOptions<TContext, TSession, TResult>) => VoiceHandoffAdapter<TContext, TSession, TResult>;
40
54
  export {};
package/dist/index.d.ts CHANGED
@@ -14,9 +14,9 @@ export { createVoiceS3ReviewStore } from './s3Store';
14
14
  export { createVoiceMemoryStore } from './memoryStore';
15
15
  export { createVoiceCRMActivitySink, createVoiceHelpdeskTicketSink, createVoiceIntegrationHTTPSink, createVoiceHubSpotTaskSink, createVoiceHubSpotTaskSyncSinks, createVoiceHubSpotTaskUpdateSink, createVoiceLinearIssueSink, createVoiceLinearIssueSyncSinks, createVoiceLinearIssueUpdateSink, createVoiceZendeskTicketSink, createVoiceZendeskTicketSyncSinks, createVoiceZendeskTicketUpdateSink, deliverVoiceIntegrationEventToSinks } from './opsSinks';
16
16
  export { createVoiceOpsWebhookEnvelope, createVoiceOpsWebhookReceiverRoutes, createVoiceOpsWebhookSink, verifyVoiceOpsWebhookSignature } from './opsWebhook';
17
- export { createVoiceTwilioRedirectHandoffAdapter, createVoiceWebhookHandoffAdapter, deliverVoiceHandoff } from './handoff';
17
+ export { applyVoiceHandoffDeliveryResult, createVoiceHandoffDeliveryRecord, createVoiceMemoryHandoffDeliveryStore, createVoiceTwilioRedirectHandoffAdapter, createVoiceWebhookHandoffAdapter, deliverVoiceHandoff, deliverVoiceHandoffDelivery } from './handoff';
18
18
  export { createVoiceHandoffHealthHTMLHandler, createVoiceHandoffHealthJSONHandler, createVoiceHandoffHealthRoutes, renderVoiceHandoffHealthHTML, summarizeVoiceHandoffHealth } from './handoffHealth';
19
- export { createVoiceIntegrationSinkWorker, createVoiceIntegrationSinkWorkerLoop, createVoiceOpsTaskWorker, createVoiceOpsTaskProcessorWorker, createVoiceOpsTaskProcessorWorkerLoop, createVoiceRedisIdempotencyStore, createVoiceRedisTaskLeaseCoordinator, createVoiceTraceSinkDeliveryWorker, createVoiceTraceSinkDeliveryWorkerLoop, createVoiceWebhookDeliveryWorker, createVoiceWebhookDeliveryWorkerLoop, summarizeVoiceTraceSinkDeliveries, summarizeVoiceOpsTaskQueue, summarizeVoiceIntegrationEvents } from './queue';
19
+ export { createVoiceHandoffDeliveryWorker, createVoiceHandoffDeliveryWorkerLoop, createVoiceIntegrationSinkWorker, createVoiceIntegrationSinkWorkerLoop, createVoiceOpsTaskWorker, createVoiceOpsTaskProcessorWorker, createVoiceOpsTaskProcessorWorkerLoop, createVoiceRedisIdempotencyStore, createVoiceRedisTaskLeaseCoordinator, createVoiceTraceSinkDeliveryWorker, createVoiceTraceSinkDeliveryWorkerLoop, createVoiceWebhookDeliveryWorker, createVoiceWebhookDeliveryWorkerLoop, summarizeVoiceHandoffDeliveries, summarizeVoiceTraceSinkDeliveries, summarizeVoiceOpsTaskQueue, summarizeVoiceIntegrationEvents } from './queue';
20
20
  export { assignVoiceOpsTask, applyVoiceOpsTaskAssignmentRule, applyVoiceOpsTaskPolicy, buildVoiceOpsTaskFromReview, buildVoiceOpsTaskFromSLABreach, claimVoiceOpsTask, completeVoiceOpsTask, createVoiceExternalObjectMap, createVoiceExternalObjectMapId, createVoiceCallCompletedEvent, createVoiceTaskSLABreachedEvent, deadLetterVoiceOpsTask, deliverVoiceIntegrationEvent, failVoiceOpsTask, hasVoiceOpsTaskSLABreach, heartbeatVoiceOpsTask, isVoiceOpsTaskOverdue, markVoiceOpsTaskSLABreached, matchesVoiceOpsTaskAssignmentRule, resolveVoiceOpsTaskAgeBucket, createVoiceIntegrationEvent, createVoiceReviewSavedEvent, resolveVoiceOpsTaskAssignment, resolveVoiceOpsTaskPolicy, requeueVoiceOpsTask, createVoiceTaskCreatedEvent, createVoiceTaskUpdatedEvent, listVoiceOpsTasks, reopenVoiceOpsTask, startVoiceOpsTask, summarizeVoiceOpsTaskAnalytics, summarizeVoiceOpsTasks, withVoiceIntegrationEventId, withVoiceOpsTaskId } from './ops';
21
21
  export { createVoiceSession } from './session';
22
22
  export { createVoiceCallReviewFromSession, recordVoiceRuntimeOps } from './runtimeOps';
@@ -42,13 +42,13 @@ export type { VoiceOpsPresetName, VoiceOpsPresetOverrides, VoiceResolvedOpsPrese
42
42
  export type { VoiceOutcomeRecipe, VoiceOutcomeRecipeName, VoiceOutcomeRecipeOptions } from './outcomeRecipes';
43
43
  export type { VoiceCRMActivitySinkOptions, VoiceHubSpotTaskSinkOptions, VoiceHubSpotTaskUpdateSinkOptions, VoiceHelpdeskTicketSinkOptions, VoiceIntegrationHTTPSinkOptions, VoiceIntegrationSink, VoiceIntegrationSinkDeliveryResult, VoiceLinearIssueSinkOptions, VoiceLinearIssueUpdateSinkOptions, VoiceZendeskTicketSinkOptions, VoiceZendeskTicketUpdateSinkOptions } from './opsSinks';
44
44
  export type { VoiceOpsWebhookEnvelope, VoiceOpsWebhookEntity, VoiceOpsWebhookLinkResolver, VoiceOpsWebhookReceiverRoutesOptions, VoiceOpsWebhookSinkOptions, VoiceOpsWebhookVerificationResult } from './opsWebhook';
45
- export type { VoiceHandoffDelivery, VoiceHandoffFanoutResult, VoiceTwilioRedirectHandoffAdapterOptions, VoiceWebhookHandoffAdapterOptions } from './handoff';
45
+ export type { VoiceHandoffDelivery, VoiceHandoffDeliveryRecord, VoiceHandoffDeliveryRecordInput, VoiceHandoffFanoutResult, VoiceQueuedHandoffDeliveryOptions, VoiceTwilioRedirectHandoffAdapterOptions, VoiceWebhookHandoffAdapterOptions } from './handoff';
46
46
  export type { VoiceHandoffHealthDelivery, VoiceHandoffHealthEvent, VoiceHandoffHealthHTMLHandlerOptions, VoiceHandoffHealthRoutesOptions, VoiceHandoffHealthStatus, VoiceHandoffHealthSummary, VoiceHandoffHealthSummaryOptions } from './handoffHealth';
47
47
  export type { StoredVoiceCallReviewArtifact, VoiceCallReviewArtifact, VoiceCallReviewConfig, VoiceCallReviewPostCallSummary, VoiceCallReviewRecorder, VoiceCallReviewRecorderOptions, VoiceCallReviewStore, VoiceCallReviewSummary, VoiceCallReviewTimelineEvent } from './testing/review';
48
48
  export type { VoiceFileRuntimeStorage, VoiceFileStoreOptions } from './fileStore';
49
49
  export type { StoredVoiceTraceEvent, VoiceTraceEvaluation, VoiceTraceEvaluationOptions, VoiceTraceEvent, VoiceTraceEventFilter, VoiceTraceEventStore, VoiceTraceEventType, VoiceTraceIssue, VoiceTraceIssueSeverity, VoiceTraceHTTPSinkOptions, VoiceTracePruneFilter, VoiceTracePruneOptions, VoiceTracePruneResult, VoiceTraceRedactionConfig, VoiceTraceRedactionOptions, VoiceTraceRedactionReplacement, VoiceResolvedTraceRedactionOptions, VoiceTraceSink, VoiceTraceSinkDeliveryQueueStatus, VoiceTraceSinkDeliveryRecord, VoiceTraceSinkDeliveryResult, VoiceTraceSinkDeliveryStatus, VoiceTraceSinkDeliveryStore, VoiceTraceSinkFanoutResult, VoiceTraceSinkStoreOptions, VoiceTraceSummary } from './trace';
50
50
  export type { VoicePostgresClient, VoicePostgresRuntimeStorage, VoicePostgresStoreOptions } from './postgresStore';
51
- export type { VoiceOpsTaskLease, VoiceOpsTaskWorker, VoiceOpsTaskWorkerOptions, VoiceIdempotencyStore, VoiceIntegrationEventQueueSummary, VoiceIntegrationSinkWorkerLoop, VoiceIntegrationSinkWorkerLoopOptions, VoiceIntegrationSinkWorkerOptions, VoiceIntegrationSinkWorkerResult, VoiceRedisIdempotencyClient, VoiceRedisIdempotencyStoreOptions, VoiceRedisTaskLeaseClient, VoiceRedisTaskLeaseCoordinator, VoiceRedisTaskLeaseCoordinatorOptions, VoiceTraceSinkDeliveryQueueSummary, VoiceTraceSinkDeliveryWorkerLoop, VoiceTraceSinkDeliveryWorkerLoopOptions, VoiceTraceSinkDeliveryWorkerOptions, VoiceTraceSinkDeliveryWorkerResult, VoiceOpsTaskClaimFilters, VoiceWebhookDeliveryWorkerLoop, VoiceWebhookDeliveryWorkerLoopOptions, VoiceWebhookDeliveryWorkerOptions, VoiceWebhookDeliveryWorkerResult, VoiceOpsTaskProcessorWorkerLoop, VoiceOpsTaskProcessorWorkerLoopOptions, VoiceOpsTaskProcessorWorkerOptions, VoiceOpsTaskProcessorWorkerResult, VoiceOpsTaskQueueSummary } from './queue';
51
+ export type { VoiceOpsTaskLease, VoiceOpsTaskWorker, VoiceOpsTaskWorkerOptions, VoiceHandoffDeliveryQueueSummary, VoiceHandoffDeliveryWorkerLoop, VoiceHandoffDeliveryWorkerLoopOptions, VoiceHandoffDeliveryWorkerOptions, VoiceHandoffDeliveryWorkerResult, VoiceIdempotencyStore, VoiceIntegrationEventQueueSummary, VoiceIntegrationSinkWorkerLoop, VoiceIntegrationSinkWorkerLoopOptions, VoiceIntegrationSinkWorkerOptions, VoiceIntegrationSinkWorkerResult, VoiceRedisIdempotencyClient, VoiceRedisIdempotencyStoreOptions, VoiceRedisTaskLeaseClient, VoiceRedisTaskLeaseCoordinator, VoiceRedisTaskLeaseCoordinatorOptions, VoiceTraceSinkDeliveryQueueSummary, VoiceTraceSinkDeliveryWorkerLoop, VoiceTraceSinkDeliveryWorkerLoopOptions, VoiceTraceSinkDeliveryWorkerOptions, VoiceTraceSinkDeliveryWorkerResult, VoiceOpsTaskClaimFilters, VoiceWebhookDeliveryWorkerLoop, VoiceWebhookDeliveryWorkerLoopOptions, VoiceWebhookDeliveryWorkerOptions, VoiceWebhookDeliveryWorkerResult, VoiceOpsTaskProcessorWorkerLoop, VoiceOpsTaskProcessorWorkerLoopOptions, VoiceOpsTaskProcessorWorkerOptions, VoiceOpsTaskProcessorWorkerResult, VoiceOpsTaskQueueSummary } from './queue';
52
52
  export type { VoiceS3ReviewStoreClient, VoiceS3ReviewStoreFile, VoiceS3ReviewStoreOptions } from './s3Store';
53
53
  export type { VoiceSQLiteRuntimeStorage, VoiceSQLiteStoreOptions } from './sqliteStore';
54
54
  export type { StoredVoiceIntegrationEvent, StoredVoiceExternalObjectMap, StoredVoiceOpsTask, VoiceExternalObjectMap, VoiceExternalObjectMapStore, VoiceOpsTaskAgeBucket, VoiceOpsTaskAnalyticsOptions, VoiceOpsTaskAnalyticsSummary, VoiceOpsTaskAssignmentRule, VoiceOpsTaskAssignmentRuleCondition, VoiceOpsTaskAssignmentRules, VoiceOpsTaskAssigneeAnalytics, VoiceOpsDispositionTaskPolicies, VoiceOpsSLABreachPolicy, VoiceIntegrationDeliveryStatus, VoiceIntegrationEvent, VoiceIntegrationEventStore, VoiceIntegrationSinkDelivery, VoiceIntegrationEventType, VoiceIntegrationWebhookConfig, VoiceOpsTask, VoiceOpsTaskHistoryEntry, VoiceOpsTaskKind, VoiceOpsTaskPolicy, VoiceOpsTaskPriority, VoiceOpsTaskStatus, VoiceOpsTaskStore, VoiceOpsTaskSummary, VoiceOpsTaskWorkerAnalytics } from './ops';
package/dist/index.js CHANGED
@@ -3019,6 +3019,14 @@ var aggregateHandoffStatus = (deliveries) => {
3019
3019
  }
3020
3020
  return "skipped";
3021
3021
  };
3022
+ var createHandoffDeliveryId = (input) => [
3023
+ "voice-handoff",
3024
+ input.sessionId,
3025
+ input.action,
3026
+ Date.now(),
3027
+ crypto.randomUUID()
3028
+ ].join(":");
3029
+ var resolveHandoffDeliveryError = (deliveries) => Object.values(deliveries).map((delivery) => delivery.error).find(Boolean);
3022
3030
  var defaultWebhookBody = (input) => ({
3023
3031
  action: input.action,
3024
3032
  metadata: input.metadata,
@@ -3067,6 +3075,73 @@ var deliverVoiceHandoff = async (input) => {
3067
3075
  status: aggregateHandoffStatus(deliveries)
3068
3076
  };
3069
3077
  };
3078
+ var createVoiceHandoffDeliveryRecord = (input) => {
3079
+ const now = Date.now();
3080
+ return {
3081
+ action: input.action,
3082
+ context: input.context,
3083
+ createdAt: now,
3084
+ deliveryAttempts: 0,
3085
+ deliveryStatus: "pending",
3086
+ id: input.id ?? createHandoffDeliveryId({
3087
+ action: input.action,
3088
+ sessionId: input.session.id
3089
+ }),
3090
+ metadata: input.metadata,
3091
+ reason: input.reason,
3092
+ result: input.result,
3093
+ session: input.session,
3094
+ sessionId: input.session.id,
3095
+ target: input.target,
3096
+ updatedAt: now
3097
+ };
3098
+ };
3099
+ var applyVoiceHandoffDeliveryResult = (delivery, result) => ({
3100
+ ...delivery,
3101
+ deliveredAt: result.status === "delivered" || result.status === "skipped" ? Date.now() : delivery.deliveredAt,
3102
+ deliveries: result.deliveries,
3103
+ deliveryAttempts: (delivery.deliveryAttempts ?? 0) + 1,
3104
+ deliveryError: result.status === "failed" ? resolveHandoffDeliveryError(result.deliveries) : undefined,
3105
+ deliveryStatus: result.status,
3106
+ updatedAt: Date.now()
3107
+ });
3108
+ var deliverVoiceHandoffDelivery = async (options) => {
3109
+ const result = await deliverVoiceHandoff({
3110
+ config: {
3111
+ adapters: options.adapters,
3112
+ failMode: options.failMode
3113
+ },
3114
+ handoff: {
3115
+ action: options.delivery.action,
3116
+ api: options.api,
3117
+ context: options.delivery.context,
3118
+ metadata: options.delivery.metadata,
3119
+ reason: options.delivery.reason,
3120
+ result: options.delivery.result,
3121
+ session: options.delivery.session,
3122
+ target: options.delivery.target
3123
+ }
3124
+ });
3125
+ return result ? applyVoiceHandoffDeliveryResult(options.delivery, result) : {
3126
+ ...options.delivery,
3127
+ deliveryAttempts: (options.delivery.deliveryAttempts ?? 0) + 1,
3128
+ deliveryStatus: "skipped",
3129
+ updatedAt: Date.now()
3130
+ };
3131
+ };
3132
+ var createVoiceMemoryHandoffDeliveryStore = () => {
3133
+ const deliveries = new Map;
3134
+ return {
3135
+ get: async (id) => deliveries.get(id),
3136
+ list: async () => [...deliveries.values()].sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id)),
3137
+ remove: async (id) => {
3138
+ deliveries.delete(id);
3139
+ },
3140
+ set: async (id, delivery) => {
3141
+ deliveries.set(id, delivery);
3142
+ }
3143
+ };
3144
+ };
3070
3145
  var createVoiceWebhookHandoffAdapter = (options) => ({
3071
3146
  actions: options.actions,
3072
3147
  handoff: async (input) => {
@@ -3609,6 +3684,21 @@ var createVoiceSession = (options) => {
3609
3684
  });
3610
3685
  };
3611
3686
  const runHandoff = async (input) => {
3687
+ const queuedDelivery = options.handoff?.deliveryQueue ? createVoiceHandoffDeliveryRecord({
3688
+ action: input.action,
3689
+ context: options.context,
3690
+ metadata: input.metadata,
3691
+ reason: input.reason,
3692
+ result: input.result,
3693
+ session: input.session,
3694
+ target: input.target
3695
+ }) : undefined;
3696
+ if (queuedDelivery) {
3697
+ await options.handoff?.deliveryQueue?.set(queuedDelivery.id, queuedDelivery);
3698
+ }
3699
+ if (options.handoff?.enqueueOnly) {
3700
+ return;
3701
+ }
3612
3702
  const result = await deliverVoiceHandoff({
3613
3703
  config: options.handoff,
3614
3704
  handoff: {
@@ -3625,6 +3715,10 @@ var createVoiceSession = (options) => {
3625
3715
  if (!result) {
3626
3716
  return;
3627
3717
  }
3718
+ if (queuedDelivery) {
3719
+ const updatedDelivery = applyVoiceHandoffDeliveryResult(queuedDelivery, result);
3720
+ await options.handoff?.deliveryQueue?.set(updatedDelivery.id, updatedDelivery);
3721
+ }
3628
3722
  await appendTrace({
3629
3723
  metadata: input.metadata,
3630
3724
  payload: {
@@ -6458,7 +6552,8 @@ var summarizeVoiceProviderHealth = async (input) => {
6458
6552
  rateLimited: false,
6459
6553
  recommended: false,
6460
6554
  runCount: 0,
6461
- status: "idle"
6555
+ status: "idle",
6556
+ timeoutCount: 0
6462
6557
  };
6463
6558
  entries.set(provider, entry);
6464
6559
  return entry;
@@ -6535,6 +6630,9 @@ var summarizeVoiceProviderHealth = async (input) => {
6535
6630
  }
6536
6631
  const entry = applyProviderHealth();
6537
6632
  entry.errorCount += 1;
6633
+ if (event.payload.timedOut === true) {
6634
+ entry.timeoutCount += 1;
6635
+ }
6538
6636
  entry.lastError = getString(event.payload.error);
6539
6637
  entry.lastErrorAt = event.at;
6540
6638
  entry.rateLimited ||= event.payload.rateLimited === true;
@@ -6559,7 +6657,8 @@ var summarizeVoiceProviderHealth = async (input) => {
6559
6657
  runCount: entry.runCount,
6560
6658
  status,
6561
6659
  suppressionRemainingMs: activeSuppression ? suppressionRemainingMs : undefined,
6562
- suppressedUntil: entry.suppressedUntil
6660
+ suppressedUntil: entry.suppressedUntil,
6661
+ timeoutCount: entry.timeoutCount
6563
6662
  };
6564
6663
  });
6565
6664
  const recommended = summaries.filter((entry) => entry.status === "healthy").sort((left, right) => (left.averageElapsedMs ?? Number.MAX_SAFE_INTEGER) - (right.averageElapsedMs ?? Number.MAX_SAFE_INTEGER))[0];
@@ -6583,6 +6682,7 @@ var renderVoiceProviderHealthHTML = (providers) => providers.length === 0 ? '<p
6583
6682
  `<div><dt>Runs</dt><dd>${String(provider.runCount)}</dd></div>`,
6584
6683
  `<div><dt>Avg latency</dt><dd>${String(provider.averageElapsedMs ?? 0)}ms</dd></div>`,
6585
6684
  `<div><dt>Errors</dt><dd>${String(provider.errorCount)}</dd></div>`,
6685
+ `<div><dt>Timeouts</dt><dd>${String(provider.timeoutCount)}</dd></div>`,
6586
6686
  `<div><dt>Fallbacks</dt><dd>${String(provider.fallbackCount)}</dd></div>`,
6587
6687
  "</dl>",
6588
6688
  suppressionSeconds ? `<p>Temporarily suppressed for ${String(suppressionSeconds)}s.</p>` : "",
@@ -8038,6 +8138,17 @@ var parseJSONValue = (value) => {
8038
8138
  return value;
8039
8139
  }
8040
8140
  };
8141
+
8142
+ class VoiceProviderTimeoutError extends Error {
8143
+ provider;
8144
+ timeoutMs;
8145
+ constructor(provider, timeoutMs) {
8146
+ super(`Voice provider ${provider} exceeded ${timeoutMs}ms latency budget.`);
8147
+ this.name = "VoiceProviderTimeoutError";
8148
+ this.provider = provider;
8149
+ this.timeoutMs = timeoutMs;
8150
+ }
8151
+ }
8041
8152
  var getMessageToolCalls = (message) => {
8042
8153
  const toolCalls = message.metadata?.toolCalls;
8043
8154
  return Array.isArray(toolCalls) ? toolCalls.filter((toolCall) => toolCall && typeof toolCall === "object" && typeof toolCall.name === "string") : [];
@@ -8115,6 +8226,10 @@ var createVoiceProviderRouter = (options) => {
8115
8226
  const failureThreshold = Math.max(1, healthOptions?.failureThreshold ?? 1);
8116
8227
  const cooldownMs = Math.max(0, healthOptions?.cooldownMs ?? 30000);
8117
8228
  const rateLimitCooldownMs = Math.max(0, healthOptions?.rateLimitCooldownMs ?? 60000);
8229
+ const getProviderTimeoutMs = (provider) => {
8230
+ const timeoutMs = options.providerProfiles?.[provider]?.timeoutMs ?? options.timeoutMs;
8231
+ return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : undefined;
8232
+ };
8118
8233
  const getHealth = (provider) => {
8119
8234
  const existing = healthState.get(provider);
8120
8235
  if (existing) {
@@ -8226,6 +8341,25 @@ var createVoiceProviderRouter = (options) => {
8226
8341
  const emit = async (event, input) => {
8227
8342
  await options.onProviderEvent?.(event, input);
8228
8343
  };
8344
+ const runProvider = async (provider, model, input) => {
8345
+ const timeoutMs = getProviderTimeoutMs(provider);
8346
+ if (!timeoutMs) {
8347
+ return model.generate(input);
8348
+ }
8349
+ let timeout;
8350
+ try {
8351
+ return await Promise.race([
8352
+ model.generate(input),
8353
+ new Promise((_, reject) => {
8354
+ timeout = setTimeout(() => reject(new VoiceProviderTimeoutError(provider, timeoutMs)), timeoutMs);
8355
+ })
8356
+ ]);
8357
+ } finally {
8358
+ if (timeout) {
8359
+ clearTimeout(timeout);
8360
+ }
8361
+ }
8362
+ };
8229
8363
  return {
8230
8364
  generate: async (input) => {
8231
8365
  const { order, selectedProvider } = await resolveOrder(input);
@@ -8240,12 +8374,14 @@ var createVoiceProviderRouter = (options) => {
8240
8374
  }
8241
8375
  const startedAt = Date.now();
8242
8376
  try {
8243
- const output = await model.generate(input);
8377
+ const output = await runProvider(provider, model, input);
8244
8378
  const providerHealth = recordProviderSuccess(provider);
8245
8379
  await emit({
8246
8380
  at: Date.now(),
8381
+ attempt: index + 1,
8247
8382
  elapsedMs: Date.now() - startedAt,
8248
8383
  fallbackProvider: provider === selectedProvider ? undefined : provider,
8384
+ latencyBudgetMs: getProviderTimeoutMs(provider),
8249
8385
  provider,
8250
8386
  providerHealth,
8251
8387
  recovered: provider !== selectedProvider,
@@ -8257,22 +8393,26 @@ var createVoiceProviderRouter = (options) => {
8257
8393
  lastError = error;
8258
8394
  const hasNextProvider = index < order.length - 1;
8259
8395
  const isProviderError = options.isProviderError?.(error, provider) ?? true;
8396
+ const timedOut = options.isTimeoutError?.(error, provider) ?? error instanceof VoiceProviderTimeoutError;
8260
8397
  const rateLimited = options.isRateLimitError?.(error, provider) ?? defaultIsRateLimitError(error);
8261
8398
  const shouldFallback = fallbackMode === "provider-error" ? isProviderError : fallbackMode === "rate-limit" ? isProviderError && rateLimited : false;
8262
8399
  const providerHealth = recordProviderError(provider, isProviderError, rateLimited);
8263
8400
  const nextProvider = hasNextProvider ? order[index + 1] : undefined;
8264
8401
  await emit({
8265
8402
  at: Date.now(),
8403
+ attempt: index + 1,
8266
8404
  elapsedMs: Date.now() - startedAt,
8267
8405
  error: errorMessage(error),
8268
8406
  fallbackProvider: shouldFallback ? nextProvider : undefined,
8407
+ latencyBudgetMs: getProviderTimeoutMs(provider),
8269
8408
  provider,
8270
8409
  providerHealth,
8271
8410
  rateLimited,
8272
8411
  selectedProvider,
8273
8412
  suppressionRemainingMs: getSuppressionRemainingMs(provider),
8274
8413
  suppressedUntil: providerHealth?.suppressedUntil,
8275
- status: "error"
8414
+ status: "error",
8415
+ timedOut
8276
8416
  }, input);
8277
8417
  if (!hasNextProvider || !shouldFallback) {
8278
8418
  throw error;
@@ -9625,6 +9765,8 @@ var shouldDeadLetterSinkEvent = (event, sinks, maxFailures) => typeof maxFailure
9625
9765
  var shouldDeadLetterTask = (task, maxFailures) => typeof maxFailures === "number" && maxFailures > 0 && (task.processingAttempts ?? 0) >= maxFailures;
9626
9766
  var shouldProcessTraceDeliveryStatus = (status, allowed) => allowed.includes(status);
9627
9767
  var shouldDeadLetterTraceDelivery = (delivery, maxFailures) => typeof maxFailures === "number" && maxFailures > 0 && (delivery.deliveryAttempts ?? 0) >= maxFailures;
9768
+ var shouldProcessHandoffDeliveryStatus = (status, allowed) => allowed.includes(status);
9769
+ var shouldDeadLetterHandoffDelivery = (delivery, maxFailures) => typeof maxFailures === "number" && maxFailures > 0 && (delivery.deliveryAttempts ?? 0) >= maxFailures;
9628
9770
  var summarizeVoiceIntegrationEvents = (events, input = {}) => {
9629
9771
  const buildSummary = async () => {
9630
9772
  const deadLetterIds = new Set(input.deadLetters ? (await input.deadLetters.list()).map((event) => event.id) : []);
@@ -9706,6 +9848,48 @@ var summarizeVoiceTraceSinkDeliveries = (deliveries, input = {}) => {
9706
9848
  };
9707
9849
  return buildSummary();
9708
9850
  };
9851
+ var summarizeVoiceHandoffDeliveries = (deliveries, input = {}) => {
9852
+ const buildSummary = async () => {
9853
+ const deadLetterIds = new Set(input.deadLetters ? (await input.deadLetters.list()).map((delivery) => delivery.id) : []);
9854
+ const byAction = new Map;
9855
+ const summary = {
9856
+ byAction: [],
9857
+ deadLettered: 0,
9858
+ delivered: 0,
9859
+ failed: 0,
9860
+ pending: 0,
9861
+ retryEligible: 0,
9862
+ skipped: 0,
9863
+ total: deliveries.length
9864
+ };
9865
+ for (const delivery of deliveries) {
9866
+ byAction.set(delivery.action, (byAction.get(delivery.action) ?? 0) + 1);
9867
+ if (deadLetterIds.has(delivery.id)) {
9868
+ summary.deadLettered += 1;
9869
+ }
9870
+ switch (delivery.deliveryStatus) {
9871
+ case "delivered":
9872
+ summary.delivered += 1;
9873
+ break;
9874
+ case "failed":
9875
+ summary.failed += 1;
9876
+ if ((delivery.deliveryAttempts ?? 0) > 0) {
9877
+ summary.retryEligible += 1;
9878
+ }
9879
+ break;
9880
+ case "skipped":
9881
+ summary.skipped += 1;
9882
+ break;
9883
+ case "pending":
9884
+ summary.pending += 1;
9885
+ break;
9886
+ }
9887
+ }
9888
+ summary.byAction = [...byAction.entries()].sort((left, right) => right[1] - left[1]);
9889
+ return summary;
9890
+ };
9891
+ return buildSummary();
9892
+ };
9709
9893
  var summarizeVoiceOpsTaskQueue = (tasks, input = {}) => {
9710
9894
  const buildSummary = async () => {
9711
9895
  const deadLetterIds = new Set(input.deadLetters ? (await input.deadLetters.list()).map((task) => task.id) : []);
@@ -10135,6 +10319,108 @@ var createVoiceTraceSinkDeliveryWorkerLoop = (options) => {
10135
10319
  tick
10136
10320
  };
10137
10321
  };
10322
+ var createVoiceHandoffDeliveryWorker = (options) => {
10323
+ const allowedStatuses = options.statuses ?? ["pending", "failed"];
10324
+ const leaseMs = Math.max(1, options.leaseMs ?? 30000);
10325
+ return {
10326
+ drain: async () => {
10327
+ const result = {
10328
+ alreadyProcessed: 0,
10329
+ attempted: 0,
10330
+ deadLettered: 0,
10331
+ delivered: 0,
10332
+ failed: 0,
10333
+ skipped: 0
10334
+ };
10335
+ const deliveries = [...await options.deliveries.list()].sort((left, right) => left.createdAt - right.createdAt);
10336
+ for (const delivery of deliveries) {
10337
+ if (!shouldProcessHandoffDeliveryStatus(delivery.deliveryStatus, allowedStatuses)) {
10338
+ continue;
10339
+ }
10340
+ if (shouldDeadLetterHandoffDelivery(delivery, options.maxFailures)) {
10341
+ await options.deadLetters?.set(delivery.id, delivery);
10342
+ await options.onDeadLetter?.(delivery);
10343
+ result.deadLettered += 1;
10344
+ continue;
10345
+ }
10346
+ const claimed = await options.leases.claim({
10347
+ leaseMs,
10348
+ taskId: delivery.id,
10349
+ workerId: options.workerId
10350
+ });
10351
+ if (!claimed) {
10352
+ continue;
10353
+ }
10354
+ try {
10355
+ const idempotencyKey = `${delivery.id}:handoff`;
10356
+ if (options.idempotency && await options.idempotency.has(idempotencyKey)) {
10357
+ result.alreadyProcessed += 1;
10358
+ continue;
10359
+ }
10360
+ result.attempted += 1;
10361
+ const updatedDelivery = await deliverVoiceHandoffDelivery({
10362
+ adapters: options.adapters,
10363
+ api: options.api,
10364
+ delivery,
10365
+ failMode: options.failMode
10366
+ });
10367
+ await options.deliveries.set(updatedDelivery.id, updatedDelivery);
10368
+ if (updatedDelivery.deliveryStatus === "delivered" || updatedDelivery.deliveryStatus === "skipped") {
10369
+ await options.idempotency?.set(idempotencyKey, {
10370
+ ttlSeconds: options.idempotencyTtlSeconds
10371
+ });
10372
+ }
10373
+ if (updatedDelivery.deliveryStatus === "delivered") {
10374
+ result.delivered += 1;
10375
+ } else if (updatedDelivery.deliveryStatus === "skipped") {
10376
+ result.skipped += 1;
10377
+ } else if (updatedDelivery.deliveryStatus === "failed") {
10378
+ result.failed += 1;
10379
+ if (shouldDeadLetterHandoffDelivery(updatedDelivery, options.maxFailures)) {
10380
+ await options.deadLetters?.set(updatedDelivery.id, updatedDelivery);
10381
+ await options.onDeadLetter?.(updatedDelivery);
10382
+ result.deadLettered += 1;
10383
+ }
10384
+ }
10385
+ } finally {
10386
+ await options.leases.release({
10387
+ taskId: delivery.id,
10388
+ workerId: options.workerId
10389
+ });
10390
+ }
10391
+ }
10392
+ return result;
10393
+ }
10394
+ };
10395
+ };
10396
+ var createVoiceHandoffDeliveryWorkerLoop = (options) => {
10397
+ const pollIntervalMs = Math.max(1, options.pollIntervalMs ?? 1000);
10398
+ let timer;
10399
+ let running = false;
10400
+ const tick = async () => options.worker.drain();
10401
+ return {
10402
+ isRunning: () => running,
10403
+ start: () => {
10404
+ if (timer) {
10405
+ return;
10406
+ }
10407
+ running = true;
10408
+ timer = setInterval(() => {
10409
+ tick().catch((error) => {
10410
+ options.onError?.(error);
10411
+ });
10412
+ }, pollIntervalMs);
10413
+ },
10414
+ stop: () => {
10415
+ if (timer) {
10416
+ clearInterval(timer);
10417
+ timer = undefined;
10418
+ }
10419
+ running = false;
10420
+ },
10421
+ tick
10422
+ };
10423
+ };
10138
10424
  var createVoiceOpsTaskWorker = (options) => {
10139
10425
  const leaseMs = Math.max(1, options.leaseMs ?? 30000);
10140
10426
  const getTask = async (taskId) => {
@@ -11532,6 +11818,7 @@ export {
11532
11818
  summarizeVoiceOpsTaskAnalytics,
11533
11819
  summarizeVoiceIntegrationEvents,
11534
11820
  summarizeVoiceHandoffHealth,
11821
+ summarizeVoiceHandoffDeliveries,
11535
11822
  summarizeVoiceAssistantRuns,
11536
11823
  summarizeVoiceAssistantHealth,
11537
11824
  startVoiceOpsTask,
@@ -11577,6 +11864,7 @@ export {
11577
11864
  deliverVoiceTraceEventsToSinks,
11578
11865
  deliverVoiceIntegrationEventToSinks,
11579
11866
  deliverVoiceIntegrationEvent,
11867
+ deliverVoiceHandoffDelivery,
11580
11868
  deliverVoiceHandoff,
11581
11869
  decodeTwilioMulawBase64,
11582
11870
  deadLetterVoiceOpsTask,
@@ -11641,6 +11929,7 @@ export {
11641
11929
  createVoiceMemoryTraceSinkDeliveryStore,
11642
11930
  createVoiceMemoryTraceEventStore,
11643
11931
  createVoiceMemoryStore,
11932
+ createVoiceMemoryHandoffDeliveryStore,
11644
11933
  createVoiceMemoryAssistantMemoryStore,
11645
11934
  createVoiceLinearIssueUpdateSink,
11646
11935
  createVoiceLinearIssueSyncSinks,
@@ -11656,6 +11945,9 @@ export {
11656
11945
  createVoiceHandoffHealthRoutes,
11657
11946
  createVoiceHandoffHealthJSONHandler,
11658
11947
  createVoiceHandoffHealthHTMLHandler,
11948
+ createVoiceHandoffDeliveryWorkerLoop,
11949
+ createVoiceHandoffDeliveryWorker,
11950
+ createVoiceHandoffDeliveryRecord,
11659
11951
  createVoiceFileTraceSinkDeliveryStore,
11660
11952
  createVoiceFileTraceEventStore,
11661
11953
  createVoiceFileTaskStore,
@@ -11706,6 +11998,7 @@ export {
11706
11998
  assignVoiceOpsTask,
11707
11999
  applyVoiceOpsTaskPolicy,
11708
12000
  applyVoiceOpsTaskAssignmentRule,
12001
+ applyVoiceHandoffDeliveryResult,
11709
12002
  applyRiskTieredPhraseHintCorrections,
11710
12003
  applyPhraseHintCorrections,
11711
12004
  TURN_PROFILE_DEFAULTS
@@ -36,9 +36,11 @@ export type GeminiVoiceAssistantModelOptions = {
36
36
  };
37
37
  export type VoiceProviderRouterEvent<TProvider extends string = string> = {
38
38
  at: number;
39
+ attempt: number;
39
40
  elapsedMs: number;
40
41
  error?: string;
41
42
  fallbackProvider?: TProvider;
43
+ latencyBudgetMs?: number;
42
44
  provider: TProvider;
43
45
  providerHealth?: VoiceProviderRouterProviderHealth<TProvider>;
44
46
  rateLimited?: boolean;
@@ -47,6 +49,7 @@ export type VoiceProviderRouterEvent<TProvider extends string = string> = {
47
49
  suppressionRemainingMs?: number;
48
50
  suppressedUntil?: number;
49
51
  status: 'error' | 'fallback' | 'success';
52
+ timedOut?: boolean;
50
53
  };
51
54
  export type VoiceProviderRouterFallbackMode = 'never' | 'provider-error' | 'rate-limit';
52
55
  export type VoiceProviderRouterPolicy<TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TProvider extends string = string> = 'ordered' | 'prefer-cheapest' | 'prefer-fastest' | 'prefer-selected' | {
@@ -58,6 +61,7 @@ export type VoiceProviderRouterProviderProfile = {
58
61
  cost?: number;
59
62
  latencyMs?: number;
60
63
  priority?: number;
64
+ timeoutMs?: number;
61
65
  };
62
66
  export type VoiceProviderRouterHealthOptions = {
63
67
  cooldownMs?: number;
@@ -79,10 +83,12 @@ export type VoiceProviderRouterOptions<TContext = unknown, TSession extends Voic
79
83
  fallbackMode?: VoiceProviderRouterFallbackMode;
80
84
  isProviderError?: (error: unknown, provider: TProvider) => boolean;
81
85
  isRateLimitError?: (error: unknown, provider: TProvider) => boolean;
86
+ isTimeoutError?: (error: unknown, provider: TProvider) => boolean;
82
87
  onProviderEvent?: (event: VoiceProviderRouterEvent<TProvider>, input: VoiceAgentModelInput<TContext, TSession>) => Promise<void> | void;
83
88
  policy?: VoiceProviderRouterPolicy<TContext, TSession, TProvider>;
84
89
  providerHealth?: boolean | VoiceProviderRouterHealthOptions;
85
90
  providerProfiles?: Partial<Record<TProvider, VoiceProviderRouterProviderProfile>>;
91
+ timeoutMs?: number;
86
92
  providers: Partial<Record<TProvider, VoiceAgentModel<TContext, TSession, TResult>>>;
87
93
  selectProvider?: (input: VoiceAgentModelInput<TContext, TSession>) => TProvider | undefined | Promise<TProvider | undefined>;
88
94
  };
@@ -15,6 +15,7 @@ export type VoiceProviderHealthSummary<TProvider extends string = string> = {
15
15
  status: VoiceProviderHealthStatus;
16
16
  suppressionRemainingMs?: number;
17
17
  suppressedUntil?: number;
18
+ timeoutCount: number;
18
19
  };
19
20
  export type VoiceProviderHealthSummaryOptions<TProvider extends string = string> = {
20
21
  events?: StoredVoiceTraceEvent[];
package/dist/queue.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { RedisClient } from 'bun';
2
2
  import type { VoiceIntegrationSink } from './opsSinks';
3
3
  import type { VoiceTraceRedactionConfig, VoiceTraceSink, VoiceTraceSinkDeliveryRecord, VoiceTraceSinkDeliveryStore, VoiceTraceSinkDeliveryQueueStatus } from './trace';
4
+ import type { StoredVoiceHandoffDelivery, VoiceHandoffAdapter, VoiceHandoffDeliveryQueueStatus, VoiceHandoffDeliveryStore, VoiceSessionHandle, VoiceSessionRecord } from './types';
4
5
  import type { VoiceOpsTaskPriority, StoredVoiceOpsTask, StoredVoiceIntegrationEvent, VoiceIntegrationDeliveryStatus, VoiceIntegrationEventStore, VoiceIntegrationWebhookConfig, VoiceOpsTaskKind, VoiceOpsTaskStatus, VoiceOpsTaskStore } from './ops';
5
6
  export type VoiceOpsTaskLease = {
6
7
  expiresAt: number;
@@ -159,6 +160,50 @@ export type VoiceTraceSinkDeliveryQueueSummary = {
159
160
  skipped: number;
160
161
  total: number;
161
162
  };
163
+ export type VoiceHandoffDeliveryWorkerOptions<TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown, TDelivery extends StoredVoiceHandoffDelivery<TContext, TSession, TResult> = StoredVoiceHandoffDelivery<TContext, TSession, TResult>> = {
164
+ adapters: VoiceHandoffAdapter<TContext, TSession, TResult>[];
165
+ api: VoiceSessionHandle<TContext, TSession, TResult>;
166
+ deadLetters?: VoiceHandoffDeliveryStore<TDelivery>;
167
+ deliveries: VoiceHandoffDeliveryStore<TDelivery>;
168
+ failMode?: 'record' | 'throw';
169
+ idempotency?: VoiceIdempotencyStore;
170
+ idempotencyTtlSeconds?: number;
171
+ leaseMs?: number;
172
+ leases: VoiceRedisTaskLeaseCoordinator;
173
+ maxFailures?: number;
174
+ onDeadLetter?: (delivery: TDelivery) => Promise<void> | void;
175
+ statuses?: VoiceHandoffDeliveryQueueStatus[];
176
+ workerId: string;
177
+ };
178
+ export type VoiceHandoffDeliveryWorkerResult = {
179
+ alreadyProcessed: number;
180
+ attempted: number;
181
+ deadLettered: number;
182
+ delivered: number;
183
+ failed: number;
184
+ skipped: number;
185
+ };
186
+ export type VoiceHandoffDeliveryWorkerLoopOptions<TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown, TDelivery extends StoredVoiceHandoffDelivery<TContext, TSession, TResult> = StoredVoiceHandoffDelivery<TContext, TSession, TResult>> = {
187
+ onError?: (error: unknown) => Promise<void> | void;
188
+ pollIntervalMs?: number;
189
+ worker: ReturnType<typeof createVoiceHandoffDeliveryWorker<TContext, TSession, TResult, TDelivery>>;
190
+ };
191
+ export type VoiceHandoffDeliveryWorkerLoop = {
192
+ isRunning: () => boolean;
193
+ start: () => void;
194
+ stop: () => void;
195
+ tick: () => Promise<VoiceHandoffDeliveryWorkerResult>;
196
+ };
197
+ export type VoiceHandoffDeliveryQueueSummary = {
198
+ byAction: Array<[StoredVoiceHandoffDelivery['action'], number]>;
199
+ deadLettered: number;
200
+ delivered: number;
201
+ failed: number;
202
+ pending: number;
203
+ retryEligible: number;
204
+ skipped: number;
205
+ total: number;
206
+ };
162
207
  export type VoiceOpsTaskWorkerOptions<TTask extends StoredVoiceOpsTask = StoredVoiceOpsTask> = {
163
208
  leaseMs?: number;
164
209
  leases: VoiceRedisTaskLeaseCoordinator;
@@ -252,6 +297,9 @@ export declare const summarizeVoiceIntegrationEvents: <TEvent extends StoredVoic
252
297
  export declare const summarizeVoiceTraceSinkDeliveries: <TDelivery extends VoiceTraceSinkDeliveryRecord = VoiceTraceSinkDeliveryRecord>(deliveries: TDelivery[], input?: {
253
298
  deadLetters?: VoiceTraceSinkDeliveryStore<TDelivery>;
254
299
  }) => Promise<VoiceTraceSinkDeliveryQueueSummary> | VoiceTraceSinkDeliveryQueueSummary;
300
+ export declare const summarizeVoiceHandoffDeliveries: <TDelivery extends StoredVoiceHandoffDelivery = StoredVoiceHandoffDelivery>(deliveries: TDelivery[], input?: {
301
+ deadLetters?: VoiceHandoffDeliveryStore<TDelivery>;
302
+ }) => Promise<VoiceHandoffDeliveryQueueSummary> | VoiceHandoffDeliveryQueueSummary;
255
303
  export declare const summarizeVoiceOpsTaskQueue: <TTask extends StoredVoiceOpsTask = StoredVoiceOpsTask>(tasks: TTask[], input?: {
256
304
  deadLetters?: VoiceOpsTaskStore<TTask>;
257
305
  }) => Promise<VoiceOpsTaskQueueSummary> | VoiceOpsTaskQueueSummary;
@@ -269,6 +317,10 @@ export declare const createVoiceTraceSinkDeliveryWorker: <TDelivery extends Voic
269
317
  drain: () => Promise<VoiceTraceSinkDeliveryWorkerResult>;
270
318
  };
271
319
  export declare const createVoiceTraceSinkDeliveryWorkerLoop: <TDelivery extends VoiceTraceSinkDeliveryRecord = VoiceTraceSinkDeliveryRecord>(options: VoiceTraceSinkDeliveryWorkerLoopOptions<TDelivery>) => VoiceTraceSinkDeliveryWorkerLoop;
320
+ export declare const createVoiceHandoffDeliveryWorker: <TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown, TDelivery extends StoredVoiceHandoffDelivery<TContext, TSession, TResult> = StoredVoiceHandoffDelivery<TContext, TSession, TResult>>(options: VoiceHandoffDeliveryWorkerOptions<TContext, TSession, TResult, TDelivery>) => {
321
+ drain: () => Promise<VoiceHandoffDeliveryWorkerResult>;
322
+ };
323
+ export declare const createVoiceHandoffDeliveryWorkerLoop: <TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown, TDelivery extends StoredVoiceHandoffDelivery<TContext, TSession, TResult> = StoredVoiceHandoffDelivery<TContext, TSession, TResult>>(options: VoiceHandoffDeliveryWorkerLoopOptions<TContext, TSession, TResult, TDelivery>) => VoiceHandoffDeliveryWorkerLoop;
272
324
  export declare const createVoiceOpsTaskWorker: <TTask extends StoredVoiceOpsTask = StoredVoiceOpsTask>(options: VoiceOpsTaskWorkerOptions<TTask>) => VoiceOpsTaskWorker<TTask>;
273
325
  export declare const createVoiceOpsTaskProcessorWorker: <TTask extends StoredVoiceOpsTask = StoredVoiceOpsTask>(options: VoiceOpsTaskProcessorWorkerOptions<TTask>) => {
274
326
  drain: () => Promise<VoiceOpsTaskProcessorWorkerResult>;
@@ -3601,6 +3601,17 @@ var parseJSONValue = (value) => {
3601
3601
  return value;
3602
3602
  }
3603
3603
  };
3604
+
3605
+ class VoiceProviderTimeoutError extends Error {
3606
+ provider;
3607
+ timeoutMs;
3608
+ constructor(provider, timeoutMs) {
3609
+ super(`Voice provider ${provider} exceeded ${timeoutMs}ms latency budget.`);
3610
+ this.name = "VoiceProviderTimeoutError";
3611
+ this.provider = provider;
3612
+ this.timeoutMs = timeoutMs;
3613
+ }
3614
+ }
3604
3615
  var getMessageToolCalls = (message) => {
3605
3616
  const toolCalls = message.metadata?.toolCalls;
3606
3617
  return Array.isArray(toolCalls) ? toolCalls.filter((toolCall) => toolCall && typeof toolCall === "object" && typeof toolCall.name === "string") : [];
@@ -3678,6 +3689,10 @@ var createVoiceProviderRouter = (options) => {
3678
3689
  const failureThreshold = Math.max(1, healthOptions?.failureThreshold ?? 1);
3679
3690
  const cooldownMs = Math.max(0, healthOptions?.cooldownMs ?? 30000);
3680
3691
  const rateLimitCooldownMs = Math.max(0, healthOptions?.rateLimitCooldownMs ?? 60000);
3692
+ const getProviderTimeoutMs = (provider) => {
3693
+ const timeoutMs = options.providerProfiles?.[provider]?.timeoutMs ?? options.timeoutMs;
3694
+ return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : undefined;
3695
+ };
3681
3696
  const getHealth = (provider) => {
3682
3697
  const existing = healthState.get(provider);
3683
3698
  if (existing) {
@@ -3789,6 +3804,25 @@ var createVoiceProviderRouter = (options) => {
3789
3804
  const emit = async (event, input) => {
3790
3805
  await options.onProviderEvent?.(event, input);
3791
3806
  };
3807
+ const runProvider = async (provider, model, input) => {
3808
+ const timeoutMs = getProviderTimeoutMs(provider);
3809
+ if (!timeoutMs) {
3810
+ return model.generate(input);
3811
+ }
3812
+ let timeout;
3813
+ try {
3814
+ return await Promise.race([
3815
+ model.generate(input),
3816
+ new Promise((_, reject) => {
3817
+ timeout = setTimeout(() => reject(new VoiceProviderTimeoutError(provider, timeoutMs)), timeoutMs);
3818
+ })
3819
+ ]);
3820
+ } finally {
3821
+ if (timeout) {
3822
+ clearTimeout(timeout);
3823
+ }
3824
+ }
3825
+ };
3792
3826
  return {
3793
3827
  generate: async (input) => {
3794
3828
  const { order, selectedProvider } = await resolveOrder(input);
@@ -3803,12 +3837,14 @@ var createVoiceProviderRouter = (options) => {
3803
3837
  }
3804
3838
  const startedAt = Date.now();
3805
3839
  try {
3806
- const output = await model.generate(input);
3840
+ const output = await runProvider(provider, model, input);
3807
3841
  const providerHealth = recordProviderSuccess(provider);
3808
3842
  await emit({
3809
3843
  at: Date.now(),
3844
+ attempt: index + 1,
3810
3845
  elapsedMs: Date.now() - startedAt,
3811
3846
  fallbackProvider: provider === selectedProvider ? undefined : provider,
3847
+ latencyBudgetMs: getProviderTimeoutMs(provider),
3812
3848
  provider,
3813
3849
  providerHealth,
3814
3850
  recovered: provider !== selectedProvider,
@@ -3820,22 +3856,26 @@ var createVoiceProviderRouter = (options) => {
3820
3856
  lastError = error;
3821
3857
  const hasNextProvider = index < order.length - 1;
3822
3858
  const isProviderError = options.isProviderError?.(error, provider) ?? true;
3859
+ const timedOut = options.isTimeoutError?.(error, provider) ?? error instanceof VoiceProviderTimeoutError;
3823
3860
  const rateLimited = options.isRateLimitError?.(error, provider) ?? defaultIsRateLimitError(error);
3824
3861
  const shouldFallback = fallbackMode === "provider-error" ? isProviderError : fallbackMode === "rate-limit" ? isProviderError && rateLimited : false;
3825
3862
  const providerHealth = recordProviderError(provider, isProviderError, rateLimited);
3826
3863
  const nextProvider = hasNextProvider ? order[index + 1] : undefined;
3827
3864
  await emit({
3828
3865
  at: Date.now(),
3866
+ attempt: index + 1,
3829
3867
  elapsedMs: Date.now() - startedAt,
3830
3868
  error: errorMessage(error),
3831
3869
  fallbackProvider: shouldFallback ? nextProvider : undefined,
3870
+ latencyBudgetMs: getProviderTimeoutMs(provider),
3832
3871
  provider,
3833
3872
  providerHealth,
3834
3873
  rateLimited,
3835
3874
  selectedProvider,
3836
3875
  suppressionRemainingMs: getSuppressionRemainingMs(provider),
3837
3876
  suppressedUntil: providerHealth?.suppressedUntil,
3838
- status: "error"
3877
+ status: "error",
3878
+ timedOut
3839
3879
  }, input);
3840
3880
  if (!hasNextProvider || !shouldFallback) {
3841
3881
  throw error;
@@ -4486,6 +4526,14 @@ var aggregateHandoffStatus = (deliveries) => {
4486
4526
  }
4487
4527
  return "skipped";
4488
4528
  };
4529
+ var createHandoffDeliveryId = (input) => [
4530
+ "voice-handoff",
4531
+ input.sessionId,
4532
+ input.action,
4533
+ Date.now(),
4534
+ crypto.randomUUID()
4535
+ ].join(":");
4536
+ var resolveHandoffDeliveryError = (deliveries) => Object.values(deliveries).map((delivery) => delivery.error).find(Boolean);
4489
4537
  var defaultWebhookBody = (input) => ({
4490
4538
  action: input.action,
4491
4539
  metadata: input.metadata,
@@ -4534,6 +4582,73 @@ var deliverVoiceHandoff = async (input) => {
4534
4582
  status: aggregateHandoffStatus(deliveries)
4535
4583
  };
4536
4584
  };
4585
+ var createVoiceHandoffDeliveryRecord = (input) => {
4586
+ const now = Date.now();
4587
+ return {
4588
+ action: input.action,
4589
+ context: input.context,
4590
+ createdAt: now,
4591
+ deliveryAttempts: 0,
4592
+ deliveryStatus: "pending",
4593
+ id: input.id ?? createHandoffDeliveryId({
4594
+ action: input.action,
4595
+ sessionId: input.session.id
4596
+ }),
4597
+ metadata: input.metadata,
4598
+ reason: input.reason,
4599
+ result: input.result,
4600
+ session: input.session,
4601
+ sessionId: input.session.id,
4602
+ target: input.target,
4603
+ updatedAt: now
4604
+ };
4605
+ };
4606
+ var applyVoiceHandoffDeliveryResult = (delivery, result) => ({
4607
+ ...delivery,
4608
+ deliveredAt: result.status === "delivered" || result.status === "skipped" ? Date.now() : delivery.deliveredAt,
4609
+ deliveries: result.deliveries,
4610
+ deliveryAttempts: (delivery.deliveryAttempts ?? 0) + 1,
4611
+ deliveryError: result.status === "failed" ? resolveHandoffDeliveryError(result.deliveries) : undefined,
4612
+ deliveryStatus: result.status,
4613
+ updatedAt: Date.now()
4614
+ });
4615
+ var deliverVoiceHandoffDelivery = async (options) => {
4616
+ const result = await deliverVoiceHandoff({
4617
+ config: {
4618
+ adapters: options.adapters,
4619
+ failMode: options.failMode
4620
+ },
4621
+ handoff: {
4622
+ action: options.delivery.action,
4623
+ api: options.api,
4624
+ context: options.delivery.context,
4625
+ metadata: options.delivery.metadata,
4626
+ reason: options.delivery.reason,
4627
+ result: options.delivery.result,
4628
+ session: options.delivery.session,
4629
+ target: options.delivery.target
4630
+ }
4631
+ });
4632
+ return result ? applyVoiceHandoffDeliveryResult(options.delivery, result) : {
4633
+ ...options.delivery,
4634
+ deliveryAttempts: (options.delivery.deliveryAttempts ?? 0) + 1,
4635
+ deliveryStatus: "skipped",
4636
+ updatedAt: Date.now()
4637
+ };
4638
+ };
4639
+ var createVoiceMemoryHandoffDeliveryStore = () => {
4640
+ const deliveries = new Map;
4641
+ return {
4642
+ get: async (id) => deliveries.get(id),
4643
+ list: async () => [...deliveries.values()].sort((left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id)),
4644
+ remove: async (id) => {
4645
+ deliveries.delete(id);
4646
+ },
4647
+ set: async (id, delivery) => {
4648
+ deliveries.set(id, delivery);
4649
+ }
4650
+ };
4651
+ };
4537
4652
  var createVoiceWebhookHandoffAdapter = (options) => ({
4538
4653
  actions: options.actions,
4539
4654
  handoff: async (input) => {
@@ -4972,6 +5087,21 @@ var createVoiceSession = (options) => {
4972
5087
  });
4973
5088
  };
4974
5089
  const runHandoff = async (input) => {
5090
+ const queuedDelivery = options.handoff?.deliveryQueue ? createVoiceHandoffDeliveryRecord({
5091
+ action: input.action,
5092
+ context: options.context,
5093
+ metadata: input.metadata,
5094
+ reason: input.reason,
5095
+ result: input.result,
5096
+ session: input.session,
5097
+ target: input.target
5098
+ }) : undefined;
5099
+ if (queuedDelivery) {
5100
+ await options.handoff?.deliveryQueue?.set(queuedDelivery.id, queuedDelivery);
5101
+ }
5102
+ if (options.handoff?.enqueueOnly) {
5103
+ return;
5104
+ }
4975
5105
  const result = await deliverVoiceHandoff({
4976
5106
  config: options.handoff,
4977
5107
  handoff: {
@@ -4988,6 +5118,10 @@ var createVoiceSession = (options) => {
4988
5118
  if (!result) {
4989
5119
  return;
4990
5120
  }
5121
+ if (queuedDelivery) {
5122
+ const updatedDelivery = applyVoiceHandoffDeliveryResult(queuedDelivery, result);
5123
+ await options.handoff?.deliveryQueue?.set(updatedDelivery.id, updatedDelivery);
5124
+ }
4991
5125
  await appendTrace({
4992
5126
  metadata: input.metadata,
4993
5127
  payload: {
package/dist/types.d.ts CHANGED
@@ -278,6 +278,34 @@ export type VoiceHandoffResult = {
278
278
  metadata?: Record<string, unknown>;
279
279
  status: VoiceHandoffStatus;
280
280
  };
281
+ export type VoiceHandoffDeliveryQueueStatus = VoiceHandoffStatus | 'pending';
282
+ export type StoredVoiceHandoffDelivery<TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown> = {
283
+ action: VoiceHandoffAction;
284
+ context: TContext;
285
+ createdAt: number;
286
+ deliveredAt?: number;
287
+ deliveries?: Record<string, VoiceHandoffResult & {
288
+ adapterId: string;
289
+ adapterKind?: string;
290
+ }>;
291
+ deliveryAttempts?: number;
292
+ deliveryError?: string;
293
+ deliveryStatus: VoiceHandoffDeliveryQueueStatus;
294
+ id: string;
295
+ metadata?: Record<string, unknown>;
296
+ reason?: string;
297
+ result?: TResult;
298
+ session: TSession;
299
+ sessionId: string;
300
+ target?: string;
301
+ updatedAt: number;
302
+ };
303
+ export type VoiceHandoffDeliveryStore<TDelivery extends StoredVoiceHandoffDelivery = StoredVoiceHandoffDelivery> = {
304
+ get: (id: string) => Promise<TDelivery | undefined> | TDelivery | undefined;
305
+ list: () => Promise<TDelivery[]> | TDelivery[];
306
+ remove: (id: string) => Promise<void> | void;
307
+ set: (id: string, delivery: TDelivery) => Promise<void> | void;
308
+ };
281
309
  export type VoiceHandoffInput<TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown> = {
282
310
  action: VoiceHandoffAction;
283
311
  api: VoiceSessionHandle<TContext, TSession, TResult>;
@@ -296,6 +324,8 @@ export type VoiceHandoffAdapter<TContext = unknown, TSession extends VoiceSessio
296
324
  };
297
325
  export type VoiceHandoffConfig<TContext = unknown, TSession extends VoiceSessionRecord = VoiceSessionRecord, TResult = unknown> = {
298
326
  adapters: VoiceHandoffAdapter<TContext, TSession, TResult>[];
327
+ deliveryQueue?: VoiceHandoffDeliveryStore<StoredVoiceHandoffDelivery<TContext, TSession, TResult>>;
328
+ enqueueOnly?: boolean;
299
329
  failMode?: 'record' | 'throw';
300
330
  };
301
331
  export type VoiceSessionStore<TSession extends VoiceSessionRecord = VoiceSessionRecord> = SessionStore<TSession, VoiceSessionSummary>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@absolutejs/voice",
3
- "version": "0.0.22-beta.30",
3
+ "version": "0.0.22-beta.32",
4
4
  "description": "Voice primitives and Elysia plugin for AbsoluteJS",
5
5
  "repository": {
6
6
  "type": "git",