@vellumai/assistant 0.11.3 → 0.11.4-staging.2
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 +11 -6
- package/docs/architecture/memory.md +11 -0
- package/docs/architecture/turn-actor.md +70 -0
- package/docs/flux-turn-detection-spike.md +243 -0
- package/docs/stt-provider-onboarding.md +3 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +34 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +2 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/openapi.yaml +139 -37
- package/package.json +1 -1
- package/scripts/voice-ttft-spike.ts +3 -3
- package/src/__tests__/app-compiler.test.ts +38 -3
- package/src/__tests__/attachments-store.test.ts +21 -12
- package/src/__tests__/byok-default-profile-ensure.test.ts +17 -0
- package/src/__tests__/call-setup-flow-name-capture.test.ts +0 -1
- package/src/__tests__/call-site-routing-provider.test.ts +1 -1
- package/src/__tests__/channel-availability-routes.test.ts +14 -1
- package/src/__tests__/channel-capabilities-dedupe.test.ts +214 -0
- package/src/__tests__/channel-delivery-store.test.ts +14 -14
- package/src/__tests__/config-loader-backfill.test.ts +3 -3
- package/src/__tests__/config-schema.test.ts +25 -10
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +8 -11
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +8 -11
- package/src/__tests__/conversation-agent-loop.test.ts +28 -20
- package/src/__tests__/conversation-attention-store.test.ts +63 -0
- package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +0 -4
- package/src/__tests__/conversation-fork-crud.test.ts +69 -0
- package/src/__tests__/conversation-fork-referential.test.ts +67 -0
- package/src/__tests__/conversation-fork-retrospective.test.ts +24 -0
- package/src/__tests__/conversation-notifiers-provenance.test.ts +59 -0
- package/src/__tests__/conversation-queue.test.ts +177 -4
- package/src/__tests__/conversation-runtime-assembly.test.ts +134 -102
- package/src/__tests__/conversation-runtime-workspace.test.ts +14 -10
- package/src/__tests__/credential-prompt-route.test.ts +7 -10
- package/src/__tests__/custom-profile-ensure.test.ts +5 -1
- package/src/__tests__/discord-access-request-privacy.test.ts +5 -1
- package/src/__tests__/discord-requester-notice-privacy.test.ts +3 -3
- package/src/__tests__/document-append-idempotency.test.ts +233 -0
- package/src/__tests__/edit-propagation.test.ts +0 -7
- package/src/__tests__/helpers/mock-actor-context.ts +49 -0
- package/src/__tests__/helpers/mock-conversation.ts +13 -1
- package/src/__tests__/injector-chain.test.ts +63 -41
- package/src/__tests__/injector-disk-pressure.test.ts +11 -23
- package/src/__tests__/llm-context-resolution.test.ts +73 -1
- package/src/__tests__/llm-schema.test.ts +5 -2
- package/src/__tests__/mcp-list-plugin-servers.test.ts +250 -0
- package/src/__tests__/memory-retrieval-hook.test.ts +6 -5
- package/src/__tests__/messages-read-boundary-guard.test.ts +134 -0
- package/src/__tests__/mtime-cache.test.ts +1 -1
- package/src/__tests__/non-member-access-request.test.ts +0 -20
- package/src/__tests__/outbound-slack-persistence.test.ts +40 -1
- package/src/__tests__/plugin-import-boundary-guard.test.ts +5 -0
- package/src/__tests__/plugin-secret-pattern-contribution.test.ts +1 -1
- package/src/__tests__/post-compaction-reinjection-idempotency.test.ts +14 -7
- package/src/__tests__/provider-commit-message-generator.test.ts +20 -0
- package/src/__tests__/run-conversation-turn-persistence.test.ts +434 -105
- package/src/__tests__/scoped-approval-grants.test.ts +11 -6
- package/src/__tests__/secret-ingress-channel.test.ts +0 -1
- package/src/__tests__/skills.test.ts +32 -0
- package/src/__tests__/slack-edit-ordering-characterization.test.ts +0 -1
- package/src/__tests__/subagent-call-site-routing.test.ts +31 -19
- package/src/__tests__/subagent-spawn-and-await.test.ts +14 -10
- package/src/__tests__/turn-events-store.test.ts +43 -0
- package/src/__tests__/ui-shape-teaching.test.ts +33 -0
- package/src/__tests__/ui-voice-picker-surface.test.ts +128 -0
- package/src/__tests__/user-plugin-loader.test.ts +1 -1
- package/src/__tests__/visible-app-context.test.ts +16 -9
- package/src/__tests__/voice-config-update.test.ts +40 -0
- package/src/__tests__/worker-entrypoint-guards.test.ts +54 -0
- package/src/__tests__/worker-plugin-surface.test.ts +77 -0
- package/src/__tests__/workspace-migration-142-consolidate-voice-front-door.test.ts +158 -0
- package/src/__tests__/workspace-migration-143-repair-deprecated-codex-model-id.test.ts +133 -0
- package/src/__tests__/workspace-migration-144-convert-stranded-subscription-openai-profiles.test.ts +316 -0
- package/src/__tests__/workspace-migration-145-collapse-profile-bindings-to-entries.test.ts +325 -0
- package/src/__tests__/workspace-migration-146-repair-retired-fireworks-deepseek-flash-model-id.test.ts +235 -0
- package/src/acp/__tests__/acp-claude-oauth.test.ts +10 -2
- package/src/acp/__tests__/auth-required.test.ts +161 -0
- package/src/acp/acp-claude-oauth.ts +19 -2
- package/src/acp/agent-process.test.ts +100 -0
- package/src/acp/agent-process.ts +29 -26
- package/src/acp/auth-required.ts +102 -0
- package/src/acp/session-manager.test.ts +119 -0
- package/src/acp/session-manager.ts +68 -2
- package/src/api/events/acp-auth-required.ts +55 -0
- package/src/api/index.ts +7 -0
- package/src/api/surfaces.ts +7 -3
- package/src/apps/app-store.ts +3 -0
- package/src/bundler/package-resolver.ts +2 -30
- package/src/calls/__tests__/voice-session-bridge.test.ts +173 -1
- package/src/calls/__tests__/voice-triage-escalate.test.ts +94 -0
- package/src/calls/call-controller.ts +19 -3
- package/src/calls/call-setup-flow.ts +0 -1
- package/src/calls/media-stream-stt-session.ts +15 -0
- package/src/calls/voice-session-bridge.ts +71 -16
- package/src/calls/voice-triage-escalate.ts +104 -2
- package/src/channels/__tests__/plugin-channel-declarations.test.ts +161 -0
- package/src/channels/config.ts +13 -0
- package/src/channels/plugin-channel-declarations.ts +108 -0
- package/src/channels/types.ts +30 -0
- package/src/cli/AGENTS.md +5 -2
- package/src/cli/commands/credentials.help.ts +2 -2
- package/src/cli/commands/inference-providers.ts +1 -1
- package/src/cli/commands/mcp.help.ts +13 -4
- package/src/cli/commands/mcp.ts +9 -0
- package/src/cli/commands/memory/__tests__/memory-v3.test.ts +128 -5
- package/src/cli/commands/memory/index.help.ts +43 -1
- package/src/cli/commands/memory/memory-v3.ts +64 -0
- package/src/cli/commands/stt.help.ts +27 -2
- package/src/cli/lib/__tests__/upgrade-plugin.test.ts +39 -0
- package/src/cli/lib/bundled-marketplace.json +13 -0
- package/src/cli/lib/upgrade-plugin.ts +42 -0
- package/src/config/__tests__/default-profile-catalog.test.ts +34 -2
- package/src/config/__tests__/default-provider.test.ts +6 -1
- package/src/config/__tests__/profile-materialization.test.ts +75 -19
- package/src/config/bundled-skills/acp/SKILL.md +6 -7
- package/src/config/bundled-skills/document-editor/SKILL.md +2 -2
- package/src/config/bundled-skills/document-editor/TOOLS.json +2 -2
- package/src/config/bundled-skills/media-processing/services/preprocess.ts +14 -4
- package/src/config/bundled-skills/settings/TOOLS.json +3 -3
- package/src/config/bundled-skills/settings/tools/navigate-settings-tab.test.ts +65 -0
- package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +7 -1
- package/src/config/bundled-skills/settings/tools/shared.ts +16 -0
- package/src/config/bundled-skills/settings/tools/voice-config-update.ts +19 -1
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +22 -1
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +9 -2
- package/src/config/call-site-defaults.ts +4 -5
- package/src/config/default-profile-catalog.ts +83 -12
- package/src/config/default-profile-names.ts +4 -1
- package/src/config/default-provider-resolution.ts +4 -0
- package/src/config/llm-context-resolution.ts +11 -3
- package/src/config/llm-resolver.ts +28 -1
- package/src/config/profile-materialization.ts +70 -22
- package/src/config/schemas/__tests__/live-voice.test.ts +107 -4
- package/src/config/schemas/call-site-catalog.ts +4 -4
- package/src/config/schemas/live-voice.ts +57 -23
- package/src/config/schemas/llm.ts +59 -32
- package/src/config/schemas/mcp.ts +23 -0
- package/src/config/schemas/plugin-updates.ts +6 -2
- package/src/config/schemas/stt.ts +1 -0
- package/src/context/outbound-sanitize.ts +96 -1
- package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +82 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +15 -10
- package/src/daemon/conversation-agent-loop.ts +17 -6
- package/src/daemon/conversation-messaging.ts +5 -1
- package/src/daemon/conversation-notifiers.ts +9 -1
- package/src/daemon/conversation-process.ts +36 -6
- package/src/daemon/conversation-runtime-assembly.ts +3 -4
- package/src/daemon/conversation-surfaces.ts +27 -5
- package/src/daemon/conversation-tool-setup.ts +1 -2
- package/src/daemon/conversation.ts +48 -0
- package/src/daemon/interactive-turn-sender.ts +59 -0
- package/src/daemon/mcp-reload-service.ts +36 -6
- package/src/daemon/process-message.ts +24 -24
- package/src/daemon/providers-setup.ts +6 -3
- package/src/daemon/trust-context-types.ts +29 -0
- package/src/daemon/wake-conversation-ops.ts +3 -2
- package/src/documents/document-store.ts +138 -5
- package/src/hooks/hook-loader.ts +3 -3
- package/src/hooks/registry.ts +50 -6
- package/src/inbound/__tests__/oauth-callback-url.test.ts +83 -0
- package/src/inbound/oauth-callback-url.ts +61 -0
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +1 -104
- package/src/live-voice/__tests__/live-voice-events.test.ts +7 -8
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +932 -0
- package/src/live-voice/__tests__/live-voice-metrics.test.ts +115 -8
- package/src/live-voice/__tests__/live-voice-photo.test.ts +100 -0
- package/src/live-voice/__tests__/live-voice-progress.test.ts +60 -194
- package/src/live-voice/__tests__/live-voice-stt.test.ts +14 -0
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +29 -0
- package/src/live-voice/__tests__/live-voice-tts-session.test.ts +0 -483
- package/src/live-voice/__tests__/live-voice-vad.test.ts +0 -16
- package/src/live-voice/__tests__/progress-narration.test.ts +214 -0
- package/src/live-voice/live-voice-archive.ts +2 -0
- package/src/live-voice/live-voice-metrics.ts +57 -32
- package/src/live-voice/live-voice-photo.ts +1 -2
- package/src/live-voice/live-voice-session.ts +535 -314
- package/src/live-voice/progress-narration.ts +277 -0
- package/src/live-voice/protocol.ts +21 -1
- package/src/mcp/__tests__/effective-config.test.ts +238 -0
- package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +0 -1
- package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +200 -0
- package/src/mcp/__tests__/mcp-oauth-provider.test.ts +9 -9
- package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +95 -0
- package/src/mcp/client.ts +16 -11
- package/src/mcp/effective-config.ts +113 -0
- package/src/mcp/manager.ts +11 -6
- package/src/mcp/mcp-auth-orchestrator.ts +13 -22
- package/src/mcp/mcp-oauth-provider.ts +205 -240
- package/src/monitoring/__tests__/plugin-auto-update.test.ts +166 -3
- package/src/monitoring/plugin-auto-update.ts +128 -24
- package/src/notifications/signal.ts +1 -0
- package/src/permissions/confirmation-guardian-request.test.ts +15 -11
- package/src/permissions/confirmation-guardian-request.ts +2 -2
- package/src/permissions/question-guardian-request.test.ts +14 -6
- package/src/permissions/question-guardian-request.ts +1 -2
- package/src/persistence/attachments-store.ts +8 -1
- package/src/persistence/bookmark-crud.ts +3 -7
- package/src/persistence/conversation-attention-store.ts +16 -45
- package/src/persistence/conversation-crud.ts +33 -4
- package/src/persistence/conversation-lineage.ts +9 -0
- package/src/persistence/conversation-queries.ts +108 -41
- package/src/persistence/delivery-crud.ts +38 -29
- package/src/persistence/external-conversation-store.ts +32 -4
- package/src/persistence/llm-request-log-store.ts +4 -10
- package/src/persistence/llm-usage-store.ts +8 -3
- package/src/persistence/message-reads.test.ts +197 -0
- package/src/persistence/message-reads.ts +211 -0
- package/src/persistence/migrations/366-chatgpt-subscription-row-identity.test.ts +120 -0
- package/src/persistence/migrations/366-chatgpt-subscription-row-identity.ts +62 -0
- package/src/persistence/real-user-turn-filter.ts +27 -3
- package/src/persistence/steps.ts +9 -0
- package/src/plugin-api/__tests__/oauth-callback-url-export.test.ts +29 -0
- package/src/plugin-api/conversation-turn.ts +168 -5
- package/src/plugin-api/index.ts +21 -5
- package/src/plugin-api/vision-support.test.ts +1 -1
- package/src/plugins/__tests__/mcp-servers.test.ts +371 -0
- package/src/plugins/defaults/memory/AGENTS.md +4 -0
- package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +204 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +72 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +4 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-provider-path.test.ts +4 -1
- package/src/plugins/defaults/memory/buffer-format.ts +165 -0
- package/src/plugins/defaults/memory/context-search/sources/conversations.ts +6 -0
- package/src/plugins/defaults/memory/graph/image-ref-utils.ts +3 -0
- package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -30
- package/src/plugins/defaults/memory/graph-topology/pending-buffer.test.ts +34 -0
- package/src/plugins/defaults/memory/graph-topology/pending-buffer.ts +8 -12
- package/src/plugins/defaults/memory/hooks/post-compact.ts +1 -4
- package/src/plugins/defaults/memory/indexer.ts +3 -1
- package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +19 -7
- package/src/plugins/defaults/memory/src/__tests__/memory-v3-gate-stats.test.ts +281 -0
- package/src/plugins/defaults/memory/src/memory-v3-routes.ts +207 -0
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +33 -0
- package/src/plugins/defaults/memory/substrate/__tests__/static-context.test.ts +199 -2
- package/src/plugins/defaults/memory/substrate/consolidation-job.ts +25 -18
- package/src/plugins/defaults/memory/substrate/skill-content.ts +8 -1
- package/src/plugins/defaults/memory/substrate/static-context.ts +160 -4
- package/src/plugins/defaults/memory/substrate/sweep-job.ts +2 -4
- package/src/plugins/defaults/memory/v1/graph/extraction.ts +3 -1
- package/src/plugins/defaults/memory/v3/prune.ts +2 -0
- package/src/plugins/defaults/memory/v3/selection-log-store.ts +2 -0
- package/src/plugins/defaults/memory/worker.ts +6 -3
- package/src/plugins/external-plugin-loader.ts +47 -0
- package/src/plugins/mcp-servers.ts +361 -0
- package/src/plugins/mtime-cache.ts +23 -49
- package/src/plugins/worker-plugin-surface.ts +33 -0
- package/src/providers/__tests__/connection-model-compat.test.ts +1 -1
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +214 -2
- package/src/providers/__tests__/preflight-resolved-config.test.ts +57 -0
- package/src/providers/__tests__/retry-callsite.test.ts +5 -2
- package/src/providers/call-site-routing.ts +30 -3
- package/src/providers/connection-resolution.ts +194 -11
- package/src/providers/inference/auth.ts +6 -0
- package/src/providers/inference/connection-availability.ts +24 -2
- package/src/providers/inference/connections.ts +2 -0
- package/src/providers/model-catalog.ts +3 -3
- package/src/providers/model-intents.ts +28 -8
- package/src/providers/openai/chat-completions-provider.ts +5 -6
- package/src/providers/openai/codex-models.ts +2 -1
- package/src/providers/provider-send-message.ts +32 -3
- package/src/providers/speech-to-text/__tests__/deepgram-flux-frames.test.ts +433 -0
- package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +620 -0
- package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +34 -0
- package/src/providers/speech-to-text/__tests__/resolve.test.ts +285 -6
- package/src/providers/speech-to-text/deepgram-flux-frames.ts +395 -0
- package/src/providers/speech-to-text/deepgram-flux-realtime.ts +719 -0
- package/src/providers/speech-to-text/provider-catalog.ts +99 -8
- package/src/providers/speech-to-text/resolve.ts +25 -2
- package/src/routes/worker.ts +17 -5
- package/src/runtime/access-request-helper.ts +9 -12
- package/src/runtime/agent-wake.ts +3 -3
- package/src/runtime/pre-first-message-gate.ts +4 -0
- package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +12 -4
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +219 -1
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +52 -0
- package/src/runtime/routes/__tests__/default-provider-routes.test.ts +61 -0
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +44 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +102 -1
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +44 -0
- package/src/runtime/routes/__tests__/stt-routes.test.ts +25 -0
- package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +62 -1
- package/src/runtime/routes/channel-availability-routes.ts +32 -14
- package/src/runtime/routes/channel-route-shared.ts +0 -6
- package/src/runtime/routes/chatgpt-subscription-auth-routes.ts +6 -6
- package/src/runtime/routes/conversation-list-routes.ts +112 -1
- package/src/runtime/routes/conversation-query-routes.ts +40 -27
- package/src/runtime/routes/credential-prompt-routes.ts +4 -7
- package/src/runtime/routes/default-provider-routes.ts +15 -0
- package/src/runtime/routes/inbound-message-handler.ts +17 -41
- package/src/runtime/routes/inbound-stages/acl-enforcement.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -9
- package/src/runtime/routes/inbound-stages/admission-policy.ts +1 -17
- package/src/runtime/routes/inbound-stages/bootstrap-intercept.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/bootstrap-intercept.ts +2 -3
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +1 -3
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/guardian-reply-intercept.ts +3 -4
- package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +0 -1
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +11 -20
- package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +2 -3
- package/src/runtime/routes/inference-profiles-routes.ts +20 -11
- package/src/runtime/routes/inference-provider-connection-routes.ts +77 -15
- package/src/runtime/routes/log-export-routes.ts +3 -0
- package/src/runtime/routes/mcp-auth-routes.ts +148 -57
- package/src/runtime/routes/plugins-routes.ts +21 -3
- package/src/runtime/routes/stt-routes.ts +31 -25
- package/src/runtime/routes/surface-conversation-resolver.ts +3 -0
- package/src/runtime/routes/user-route-dispatcher.ts +39 -14
- package/src/runtime/routes/user-route-import.ts +108 -0
- package/src/schedule/worker.ts +6 -0
- package/src/security/oauth2.ts +6 -22
- package/src/stt/__tests__/daemon-batch-transcriber.test.ts +22 -0
- package/src/stt/__tests__/types.test.ts +94 -0
- package/src/stt/daemon-batch-transcriber.ts +10 -0
- package/src/stt/stt-stream-session.ts +8 -4
- package/src/stt/types.ts +103 -0
- package/src/subagent/manager.ts +1 -3
- package/src/subagent/types.ts +7 -6
- package/src/tools/acp/spawn.test.ts +97 -0
- package/src/tools/acp/spawn.ts +32 -0
- package/src/tools/document/document-tool.ts +12 -3
- package/src/tools/registry.ts +2 -1
- package/src/tools/ui-surface/surface-shape-docs.ts +11 -0
- package/src/tools/workflows/run-workflow.ts +1 -2
- package/src/tts/__tests__/reasoning-tag-filter.test.ts +78 -0
- package/src/tts/reasoning-tag-filter.ts +89 -0
- package/src/util/think-tag-stream.ts +95 -0
- package/src/workspace/byok-default-profile-ensure.ts +76 -24
- package/src/workspace/custom-profile-ensure.ts +4 -24
- package/src/workspace/migrations/142-consolidate-voice-front-door.ts +70 -0
- package/src/workspace/migrations/143-repair-deprecated-codex-model-id.ts +134 -0
- package/src/workspace/migrations/144-convert-stranded-subscription-openai-profiles.ts +265 -0
- package/src/workspace/migrations/145-collapse-profile-bindings-to-entries.ts +328 -0
- package/src/workspace/migrations/146-repair-retired-fireworks-deepseek-flash-model-id.ts +195 -0
- package/src/workspace/migrations/__tests__/141-stt-english-default-to-multilingual.test.ts +0 -10
- package/src/workspace/migrations/registry.ts +10 -0
- package/src/workspace/provider-commit-message-generator.ts +7 -5
- package/src/live-voice/__tests__/front-decision.test.ts +0 -645
- package/src/live-voice/front-decision.ts +0 -476
|
@@ -17,7 +17,6 @@ import type {
|
|
|
17
17
|
import {
|
|
18
18
|
getConversationTurnTeardown,
|
|
19
19
|
resolveProcessingWaitMs,
|
|
20
|
-
VOICE_NO_SETUP_FLOWS_RULE,
|
|
21
20
|
waitForPriorTurnTeardown,
|
|
22
21
|
} from "../calls/voice-session-bridge.js";
|
|
23
22
|
import {
|
|
@@ -33,6 +32,8 @@ import {
|
|
|
33
32
|
} from "../calls/voice-triage-escalate.js";
|
|
34
33
|
import { getConfig } from "../config/loader.js";
|
|
35
34
|
import {
|
|
35
|
+
type LiveVoiceFluxConfig,
|
|
36
|
+
LiveVoiceFluxConfigSchema,
|
|
36
37
|
type LiveVoiceFrontModelConfig,
|
|
37
38
|
LiveVoiceFrontModelConfigSchema,
|
|
38
39
|
} from "../config/schemas/live-voice.js";
|
|
@@ -49,6 +50,7 @@ import {
|
|
|
49
50
|
listProviderIds,
|
|
50
51
|
pinnedListeningLanguage,
|
|
51
52
|
supportsBoundary,
|
|
53
|
+
supportsProviderTurnDetection,
|
|
52
54
|
} from "../providers/speech-to-text/provider-catalog.js";
|
|
53
55
|
import type { ResolveStreamingTranscriberOptions } from "../providers/speech-to-text/resolve.js";
|
|
54
56
|
import { broadcastMessage } from "../runtime/assistant-event-hub.js";
|
|
@@ -71,6 +73,10 @@ import type {
|
|
|
71
73
|
import { getSubagentManager } from "../subagent/index.js";
|
|
72
74
|
import { liveVoiceEndScreen } from "../telemetry/live-voice-funnel.js";
|
|
73
75
|
import { getToolOwner } from "../tools/registry.js";
|
|
76
|
+
import {
|
|
77
|
+
createReasoningTagFilter,
|
|
78
|
+
type ReasoningTagFilter,
|
|
79
|
+
} from "../tts/reasoning-tag-filter.js";
|
|
74
80
|
import { extractSpeakableSegments } from "../tts/speakable-segments.js";
|
|
75
81
|
import { createAbortReason } from "../util/abort-reasons.js";
|
|
76
82
|
import { hasLocalizedEntry } from "../util/language-subtag.js";
|
|
@@ -81,11 +87,6 @@ import {
|
|
|
81
87
|
dismissesUiSurface,
|
|
82
88
|
revealsUiSurface,
|
|
83
89
|
} from "./activity-label.js";
|
|
84
|
-
import {
|
|
85
|
-
createVoiceFrontDecider,
|
|
86
|
-
type VoiceFrontDecider,
|
|
87
|
-
type VoiceProgressTextInput,
|
|
88
|
-
} from "./front-decision.js";
|
|
89
90
|
import { LiveActivityReporter } from "./live-activity-reporter.js";
|
|
90
91
|
import type {
|
|
91
92
|
LiveVoiceAudioArchiveResult,
|
|
@@ -97,9 +98,9 @@ import {
|
|
|
97
98
|
type LiveVoiceMetricsClock,
|
|
98
99
|
LiveVoiceMetricsCollector,
|
|
99
100
|
type LiveVoiceMetricsEvent,
|
|
100
|
-
type LiveVoiceSpokenAckKind,
|
|
101
101
|
type LiveVoiceTurnSeedMarks,
|
|
102
102
|
type VoiceEndpointAction,
|
|
103
|
+
type VoiceEndpointSource,
|
|
103
104
|
} from "./live-voice-metrics.js";
|
|
104
105
|
import { persistLiveVoicePhoto } from "./live-voice-photo.js";
|
|
105
106
|
import {
|
|
@@ -113,6 +114,11 @@ import type {
|
|
|
113
114
|
LiveVoiceTtsOptions,
|
|
114
115
|
LiveVoiceTtsResult,
|
|
115
116
|
} from "./live-voice-tts.js";
|
|
117
|
+
import {
|
|
118
|
+
createVoiceProgressNarrator,
|
|
119
|
+
type VoiceProgressNarrator,
|
|
120
|
+
type VoiceProgressTextInput,
|
|
121
|
+
} from "./progress-narration.js";
|
|
116
122
|
import {
|
|
117
123
|
APPROVAL_PENDING_PHRASE_BY_LANGUAGE,
|
|
118
124
|
approvalPendingPhraseFor,
|
|
@@ -161,6 +167,14 @@ const FINALIZE_GRACE_MS = 1_000;
|
|
|
161
167
|
// cancellation cannot kill a reply mid-sentence. Mirrors the
|
|
162
168
|
// liveVoice.vad.bargeInMinSpeechMs schema default; 0 disables the guard for
|
|
163
169
|
// instant barge-in.
|
|
170
|
+
//
|
|
171
|
+
// Local energy detection owns barge-in in every mode, including when a
|
|
172
|
+
// turn-detecting provider owns the turn boundary: an interrupt during TTS has
|
|
173
|
+
// to feel instant, and no provider roundtrip beats a local gate on the audio
|
|
174
|
+
// already in hand. The echo-adaptive part of this guard also has to stay
|
|
175
|
+
// upstream of the provider, which hears only the microphone: it cannot tell
|
|
176
|
+
// our own TTS bleeding through imperfect echo cancellation from the caller,
|
|
177
|
+
// and would report a user turn nobody took.
|
|
164
178
|
const DEFAULT_BARGE_IN_MIN_SPEECH_MS = 250;
|
|
165
179
|
// The playback echo gate learns microphone energy while assistant audio is
|
|
166
180
|
// expected at the speaker. Input must rise above the learned level by this
|
|
@@ -203,6 +217,12 @@ const BARGE_IN_GAP_TOLERANCE_MS = 200;
|
|
|
203
217
|
// (1 / (1 + ratio) ≈ 20%): once the run is mostly silence it resets, so genuine
|
|
204
218
|
// choppy speech still lands but periodic noise cannot accumulate into one.
|
|
205
219
|
const BARGE_IN_MAX_TOLERATED_SILENCE_RATIO = 4;
|
|
220
|
+
// Slack added to the configured end-of-turn timeout before the session stops
|
|
221
|
+
// waiting for an event that is not coming and falls the utterance back onto
|
|
222
|
+
// the silence-boundary path. A turn-detecting provider force-ends its own turn
|
|
223
|
+
// at that timeout, so anything past it plus a frame's network and parse hop
|
|
224
|
+
// means the stream is gone, not slow.
|
|
225
|
+
const PROVIDER_TURN_END_FALLBACK_MARGIN_MS = 1_000;
|
|
206
226
|
// At most this many TTS segment jobs are open (provider stream started,
|
|
207
227
|
// frames not yet fully emitted) per turn: the emitting job plus one
|
|
208
228
|
// prefetching job. The prefetch buffers its chunks in memory until promoted;
|
|
@@ -328,19 +348,24 @@ export interface LiveVoiceSessionOptions {
|
|
|
328
348
|
*/
|
|
329
349
|
finalizeGraceMs?: number;
|
|
330
350
|
/**
|
|
331
|
-
*
|
|
351
|
+
* Voice front-door endpointing and progress narration tuning. The
|
|
332
352
|
* production factory seeds this from `liveVoice.frontModel` config when
|
|
333
353
|
* unset; absent fields fall back to the schema defaults (the constructor
|
|
334
354
|
* schema-parses the partial into a complete config).
|
|
335
355
|
*/
|
|
336
356
|
frontModelConfig?: Partial<LiveVoiceFrontModelConfig>;
|
|
337
357
|
/**
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
* and narration falls back to its static phrases.
|
|
358
|
+
* Deepgram Flux turn-detection tuning. The production factory seeds this
|
|
359
|
+
* from `liveVoice.flux` config when unset; absent fields fall back to the
|
|
360
|
+
* schema defaults, which leave `turnEnd.enabled` false.
|
|
342
361
|
*/
|
|
343
|
-
|
|
362
|
+
fluxConfig?: Partial<LiveVoiceFluxConfig>;
|
|
363
|
+
/**
|
|
364
|
+
* Progress narration service. The production factory constructs it from
|
|
365
|
+
* `liveVoice.frontModel.progress` config when unset. `null` disables
|
|
366
|
+
* generated narration, so progress updates use their static fallback.
|
|
367
|
+
*/
|
|
368
|
+
progressNarrator?: VoiceProgressNarrator | null;
|
|
344
369
|
/**
|
|
345
370
|
* Spawns the background continuation for a barged-in turn. The factory wires
|
|
346
371
|
* the real SubagentManager-backed implementation; tests inject a stub.
|
|
@@ -418,6 +443,22 @@ interface UtteranceCycle {
|
|
|
418
443
|
// Consecutive semantic-endpointing "hold" extensions this utterance has
|
|
419
444
|
// consumed, bounded by `endpointMaxExtensions`.
|
|
420
445
|
endpointExtensionCount: number;
|
|
446
|
+
// The provider's end-of-turn never arrived for this cycle, so it fell back
|
|
447
|
+
// to the silence-boundary path and stays there: a late event must not
|
|
448
|
+
// commit a boundary the fallback already owns.
|
|
449
|
+
providerTurnEndTimedOut: boolean;
|
|
450
|
+
// `vadSpeechGeneration` as of the local silence boundary that handed this
|
|
451
|
+
// cycle to the provider, or null while no boundary has fired yet (a turn
|
|
452
|
+
// model routinely beats the trailing-silence countdown, and that fast commit
|
|
453
|
+
// is the point).
|
|
454
|
+
// The staleness signal of last resort: it stands in for the provider's own
|
|
455
|
+
// turn numbering when the provider sends none (see isStaleProviderTurnEnd).
|
|
456
|
+
turnBoundaryGeneration: number | null;
|
|
457
|
+
// Index of the newest provider turn opened in this cycle, from the turn
|
|
458
|
+
// model's own numbering, or null when the provider does not number its
|
|
459
|
+
// turns. An end-of-turn closing an older index describes a turn the
|
|
460
|
+
// provider has already superseded (see isStaleProviderTurnEnd).
|
|
461
|
+
openProviderTurnIndex: number | null;
|
|
421
462
|
// The transcript the most recent hold verdict judged (unified front-door).
|
|
422
463
|
// A final segment arriving during the extension window that extends this
|
|
423
464
|
// text replays the boundary immediately — the hold was judged on stale
|
|
@@ -668,27 +709,11 @@ interface ActiveAssistantTurn {
|
|
|
668
709
|
// context. A bump since then means a stop/interrupt/supersede deliberately
|
|
669
710
|
// dropped the continuation, so a rollback must not resurrect it.
|
|
670
711
|
pendingContextStopGeneration: number;
|
|
671
|
-
// The agent run started a definitive tool use this turn — tool use implies
|
|
672
|
-
// a guaranteed-slow turn, so acknowledgment logic can key off this.
|
|
673
|
-
toolUseStarted: boolean;
|
|
674
|
-
// The model's first streamed delta reached this session — the spoken-ack
|
|
675
|
-
// timer is moot once real output is flowing.
|
|
676
|
-
firstDeltaSeen: boolean;
|
|
677
712
|
// Counts assistant text deltas seen this turn. A narration generation
|
|
678
713
|
// captures it at launch and discards its result if it moved: text the model
|
|
679
714
|
// produced mid-generation makes the narration stale, and proves the model
|
|
680
715
|
// is alive — which is exactly what narration exists to paper over.
|
|
681
716
|
deltaEpoch: number;
|
|
682
|
-
// A spoken ack was enqueued this turn. Every ack trigger shares this
|
|
683
|
-
// one-per-turn budget so a slow turn never stacks fillers.
|
|
684
|
-
ackSpoken: boolean;
|
|
685
|
-
// An ack generation is awaiting the decider. While true, the ack has not
|
|
686
|
-
// yet stamped `lastFloorHolderAtMs`, so narration must treat it as a
|
|
687
|
-
// floor-holder-in-waiting and stand down —
|
|
688
|
-
// otherwise a progress phrase could land back-to-back with the ack.
|
|
689
|
-
ackGenerationPending: boolean;
|
|
690
|
-
// Pending slow-first-delta ack timer; null once cleared or fired.
|
|
691
|
-
ackTimer: ReturnType<typeof setTimeout> | null;
|
|
692
717
|
// Triage-and-escalate (Voice Mode): the front-door leg gave the escalate
|
|
693
718
|
// verdict and the strong "escalated" leg has taken over this same turn.
|
|
694
719
|
// Guards the
|
|
@@ -696,6 +721,10 @@ interface ActiveAssistantTurn {
|
|
|
696
721
|
// the hand-off idempotent.
|
|
697
722
|
escalationHandedOff: boolean;
|
|
698
723
|
ttsBuffer: string;
|
|
724
|
+
// Strips inline <think> reasoning spans from the spoken stream. Stateful
|
|
725
|
+
// per turn because spans and tags cross delta boundaries; display frames
|
|
726
|
+
// keep the raw text.
|
|
727
|
+
ttsReasoningFilter: ReasoningTagFilter;
|
|
699
728
|
// A non-empty speakable segment reached the TTS queue — gates the eager
|
|
700
729
|
// first-segment flush that trades clause quality for speech onset.
|
|
701
730
|
ttsSegmentEnqueued: boolean;
|
|
@@ -783,6 +812,20 @@ const LIVE_VOICE_CONTROL_PROMPT_BASE =
|
|
|
783
812
|
const LIVE_VOICE_SCREEN_REVEAL_TEACHING =
|
|
784
813
|
"The call renders as a full-screen overlay covering the app. Whenever you put something on screen, the overlay minimizes by itself as soon as you finish speaking, and the user is looking at what you made. So speak as if you are showing it to them right now (for example, close with something like: take a look), and never say you cannot show it, that this is a voice call, or that they should check it later. Never emit bracketed markers of any kind. ";
|
|
785
814
|
|
|
815
|
+
// The setup-flow case, spelled out because it is the one the model gets wrong
|
|
816
|
+
// on its own: connecting an account reads as something a call cannot do, so it
|
|
817
|
+
// declines and offers to do it later in text, which is the exact thing the base
|
|
818
|
+
// prompt above forbids.
|
|
819
|
+
//
|
|
820
|
+
// It can do it. The connect card is a ui surface like any other, so showing it
|
|
821
|
+
// minimizes the room on the same latch (`revealsUiSurface`), and the user is at
|
|
822
|
+
// the screen the browser window opens on. Appended alongside the screen-reveal
|
|
823
|
+
// teaching, to the same legs, for the same reason: the front-door leg is
|
|
824
|
+
// toolless, so a setup flow is not its to run, and its capability digest
|
|
825
|
+
// already tells it to escalate anything needing a tool rather than refuse.
|
|
826
|
+
const LIVE_VOICE_SETUP_FLOW_TEACHING =
|
|
827
|
+
"This includes connecting accounts. If a task needs an account connected or a sign-in completed, put the connection up on screen and let the user do it now. Do not decline it, and do not defer it to text chat. Say what you are connecting and that it is in front of them. ";
|
|
828
|
+
|
|
786
829
|
// System-level guidance appended to a barge-in turn's control prompt so the
|
|
787
830
|
// model treats the new utterance as a continuation of the request it was cut
|
|
788
831
|
// off answering, rather than a fresh follow-up. Reaches the model only; it is
|
|
@@ -839,21 +882,21 @@ function buildLiveDeliveryNote(request: string, answer: string): string {
|
|
|
839
882
|
return `The user interrupted you earlier, and in the background you finished ${what}. The call is still live and nobody is speaking, so tell them briefly that it is done and give them the result. Do not re-run any tool calls; the work is already complete, and do not repeat it verbatim if it no longer fits. What you produced was:\n\n${answer}`;
|
|
840
883
|
}
|
|
841
884
|
|
|
842
|
-
// Assemble a leg's model-facing control prompt: the base live-voice rules,
|
|
843
|
-
//
|
|
844
|
-
// LIVE_VOICE_SCREEN_REVEAL_TEACHING),
|
|
845
|
-
//
|
|
846
|
-
//
|
|
847
|
-
//
|
|
848
|
-
// never render as user bubbles.
|
|
885
|
+
// Assemble a leg's model-facing control prompt: the base live-voice rules, the
|
|
886
|
+
// screen-reveal and setup-flow teaching (both withheld from the front-door leg,
|
|
887
|
+
// see LIVE_VOICE_SCREEN_REVEAL_TEACHING), plus any pending barge-in merge
|
|
888
|
+
// context, completed-continuation context, and/or the announcement instruction.
|
|
889
|
+
// A turn can carry several (a barge-in follow-up that also has a continuation
|
|
890
|
+
// result waiting); the notes are model-only and never render as user bubbles.
|
|
849
891
|
function buildVoiceControlPrompt(
|
|
850
892
|
turn: ActiveAssistantTurn,
|
|
851
893
|
leg: { frontDoor?: boolean },
|
|
852
894
|
): string {
|
|
853
895
|
let prompt =
|
|
854
896
|
LIVE_VOICE_CONTROL_PROMPT_BASE +
|
|
855
|
-
(leg.frontDoor === true
|
|
856
|
-
|
|
897
|
+
(leg.frontDoor === true
|
|
898
|
+
? ""
|
|
899
|
+
: LIVE_VOICE_SCREEN_REVEAL_TEACHING + LIVE_VOICE_SETUP_FLOW_TEACHING);
|
|
857
900
|
if (turn.language !== undefined) {
|
|
858
901
|
prompt = `${prompt}\n\nThe caller has been speaking the language with code "${turn.language}" this turn. Reply in that language unless they clearly switch to another.`;
|
|
859
902
|
}
|
|
@@ -904,6 +947,9 @@ function createUtteranceCycle(): UtteranceCycle {
|
|
|
904
947
|
finalTranscriptSegments: [],
|
|
905
948
|
latestPartialText: null,
|
|
906
949
|
endpointExtensionCount: 0,
|
|
950
|
+
providerTurnEndTimedOut: false,
|
|
951
|
+
turnBoundaryGeneration: null,
|
|
952
|
+
openProviderTurnIndex: null,
|
|
907
953
|
heldSpeculativeContent: null,
|
|
908
954
|
languageTally: new Map(),
|
|
909
955
|
latestPartialLanguages: null,
|
|
@@ -1200,13 +1246,38 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1200
1246
|
private readonly finalizeGraceMs: number;
|
|
1201
1247
|
// Rotates through the progress fallback phrases across the session's turns.
|
|
1202
1248
|
private progressPhraseCounter = 0;
|
|
1203
|
-
//
|
|
1204
|
-
//
|
|
1205
|
-
private readonly
|
|
1249
|
+
// Progress-only phrasing service. It never makes routing decisions and
|
|
1250
|
+
// never emits an answer.
|
|
1251
|
+
private readonly progressNarrator: VoiceProgressNarrator | null;
|
|
1206
1252
|
// Complete front-model tunables: the constructor schema-parses the partial
|
|
1207
1253
|
// option once, so every field carries its `liveVoice.frontModel` schema
|
|
1208
1254
|
// default when unset.
|
|
1209
1255
|
private readonly frontModelConfig: LiveVoiceFrontModelConfig;
|
|
1256
|
+
// Complete Flux tunables (the constructor schema-parses the partial option
|
|
1257
|
+
// once, so every field carries its `liveVoice.flux` schema default).
|
|
1258
|
+
private readonly fluxConfig: LiveVoiceFluxConfig;
|
|
1259
|
+
/**
|
|
1260
|
+
* Per-session latch: the provider's committed end-of-turn owns the turn
|
|
1261
|
+
* boundary instead of the silence boundary's front-door hold verdict. Set
|
|
1262
|
+
* when the config flag is on AND the resolved streaming provider declares
|
|
1263
|
+
* `turnDetection: "provider"` in the STT catalog AND the session runs server
|
|
1264
|
+
* VAD. Push-to-talk is excluded deliberately: there the client's release IS
|
|
1265
|
+
* the boundary, and answering while the caller still holds the button is not
|
|
1266
|
+
* turn detection, it is a bug. False leaves every other code path exactly as
|
|
1267
|
+
* it is, with no provider turn detection in the picture.
|
|
1268
|
+
*/
|
|
1269
|
+
private providerTurnEndActive = false;
|
|
1270
|
+
// Wall-clock of the newest above-gate audio chunk, tracked in every
|
|
1271
|
+
// server-VAD session. It is the local VAD's speech-stop mark: the one anchor
|
|
1272
|
+
// the reported end-of-turn latency is measured from whichever decider
|
|
1273
|
+
// commits the turn, and the anchor for the provider fallback deadline. Null
|
|
1274
|
+
// in push-to-talk, where no local VAD runs.
|
|
1275
|
+
private localSpeechStopAtMs: number | null = null;
|
|
1276
|
+
// Fail-open deadline for a provider end-of-turn that never arrives. On
|
|
1277
|
+
// expiry the utterance falls back to the silence-boundary path, so a dead
|
|
1278
|
+
// provider stream degrades to the silence-path behavior instead of a hung
|
|
1279
|
+
// turn.
|
|
1280
|
+
private providerTurnEndTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1210
1281
|
// Effective trailing-silence threshold, mirroring the detector's private
|
|
1211
1282
|
// copy (constructor seed + update_config), reported to the endpoint decider.
|
|
1212
1283
|
private silenceThresholdMs: number;
|
|
@@ -1276,10 +1347,11 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1276
1347
|
this.echoDrainSlackMs =
|
|
1277
1348
|
options.echoDrainSlackMs ?? DEFAULT_ECHO_DRAIN_SLACK_MS;
|
|
1278
1349
|
this.finalizeGraceMs = options.finalizeGraceMs ?? FINALIZE_GRACE_MS;
|
|
1279
|
-
this.
|
|
1350
|
+
this.progressNarrator = options.progressNarrator ?? null;
|
|
1280
1351
|
this.frontModelConfig = LiveVoiceFrontModelConfigSchema.parse(
|
|
1281
1352
|
options.frontModelConfig ?? {},
|
|
1282
1353
|
);
|
|
1354
|
+
this.fluxConfig = LiveVoiceFluxConfigSchema.parse(options.fluxConfig ?? {});
|
|
1283
1355
|
const turnDetectorConfig: TurnDetectorConfig = {
|
|
1284
1356
|
...(options.turnDetectorConfig ?? {}),
|
|
1285
1357
|
...(context.startFrame.silenceThresholdMs !== undefined
|
|
@@ -1455,6 +1527,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1455
1527
|
this.liveActivityReporter.end();
|
|
1456
1528
|
this.turnDetector?.dispose();
|
|
1457
1529
|
this.clearEndpointExtensionTimer();
|
|
1530
|
+
this.clearProviderTurnEndTimer();
|
|
1458
1531
|
// There is no longer anyone on the call to speak to, so a queued
|
|
1459
1532
|
// announcement cannot be spoken — but the answer behind it is finished
|
|
1460
1533
|
// work the user asked for, so it takes the same conversation route a
|
|
@@ -1506,11 +1579,28 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1506
1579
|
}
|
|
1507
1580
|
|
|
1508
1581
|
try {
|
|
1509
|
-
// One
|
|
1510
|
-
//
|
|
1511
|
-
// language and the recorded sharedTranscriberLanguage
|
|
1512
|
-
// when config changes while the resolver awaits
|
|
1513
|
-
|
|
1582
|
+
// One config snapshot serves the dial, the re-arm comparison and the
|
|
1583
|
+
// latch seed below: passing the language to the resolver keeps the
|
|
1584
|
+
// stream's actual language and the recorded sharedTranscriberLanguage
|
|
1585
|
+
// identical even when config changes while the resolver awaits
|
|
1586
|
+
// credentials.
|
|
1587
|
+
const stt = getConfig().services.stt;
|
|
1588
|
+
const sttLanguage = stt.language;
|
|
1589
|
+
// Seed the latch from the CONFIGURED provider before the dial, not
|
|
1590
|
+
// only from the resolved one after it. `start()` sends `ready` without
|
|
1591
|
+
// waiting on this resolve, so the caller can speak and close an entire
|
|
1592
|
+
// silence boundary while the provider handshake is still in flight. A
|
|
1593
|
+
// latch still on its default `false` at that boundary hands the first
|
|
1594
|
+
// turn of the session to the silence path and then ignores the
|
|
1595
|
+
// provider end-of-turn that follows, which is invisible from the
|
|
1596
|
+
// outside: the session looks like a turn-detecting session while its
|
|
1597
|
+
// opening turn is not one. The resolved provider reconciles this guess
|
|
1598
|
+
// below.
|
|
1599
|
+
this.setProviderTurnEndActive(
|
|
1600
|
+
this.fluxConfig.turnEnd.enabled &&
|
|
1601
|
+
supportsProviderTurnDetection(stt.provider as SttProviderId) &&
|
|
1602
|
+
this.turnDetector !== null,
|
|
1603
|
+
);
|
|
1514
1604
|
const transcriber = await this.resolveTranscriber({
|
|
1515
1605
|
sampleRate: this.context.startFrame.audio.sampleRate,
|
|
1516
1606
|
...(sttLanguage ? { language: sttLanguage } : {}),
|
|
@@ -1522,6 +1612,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1522
1612
|
}
|
|
1523
1613
|
|
|
1524
1614
|
if (!transcriber) {
|
|
1615
|
+
// No stream answered, so no end-of-turn ever will: the guess above
|
|
1616
|
+
// must not outlive the dial that disproved it.
|
|
1617
|
+
this.setProviderTurnEndActive(false);
|
|
1525
1618
|
return {
|
|
1526
1619
|
status: "unavailable",
|
|
1527
1620
|
message: unavailableTranscriberMessage(),
|
|
@@ -1530,6 +1623,16 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1530
1623
|
|
|
1531
1624
|
utterance.transcriber = transcriber;
|
|
1532
1625
|
utterance.dialedSttProvider = transcriber.providerId;
|
|
1626
|
+
// Reconcile the pre-dial guess with the provider that actually
|
|
1627
|
+
// answered. The resolver reads the provider from config, which cannot
|
|
1628
|
+
// change under a live session, so this normally confirms the guess; it
|
|
1629
|
+
// clears it when the dial fell back to another provider or resolved
|
|
1630
|
+
// one the config did not name.
|
|
1631
|
+
this.setProviderTurnEndActive(
|
|
1632
|
+
this.fluxConfig.turnEnd.enabled &&
|
|
1633
|
+
supportsProviderTurnDetection(transcriber.providerId) &&
|
|
1634
|
+
this.turnDetector !== null,
|
|
1635
|
+
);
|
|
1533
1636
|
if (
|
|
1534
1637
|
this.turnDetector &&
|
|
1535
1638
|
typeof transcriber.finalizeUtterance === "function"
|
|
@@ -1563,6 +1666,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1563
1666
|
if (this.isUtteranceStale(utterance)) {
|
|
1564
1667
|
return { status: "stale" };
|
|
1565
1668
|
}
|
|
1669
|
+
// The dial threw, so the pre-dial guess is disproved the same way the
|
|
1670
|
+
// unavailable case disproves it: nothing will send an end-of-turn.
|
|
1671
|
+
this.setProviderTurnEndActive(false);
|
|
1566
1672
|
return {
|
|
1567
1673
|
status: "error",
|
|
1568
1674
|
message: `Live voice transcription could not be started: ${errorMessage(
|
|
@@ -1690,6 +1796,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1690
1796
|
|
|
1691
1797
|
this.state = "failed";
|
|
1692
1798
|
this.clearEndpointExtensionTimer();
|
|
1799
|
+
this.clearProviderTurnEndTimer();
|
|
1693
1800
|
await this.sendFrame({
|
|
1694
1801
|
type: "error",
|
|
1695
1802
|
code:
|
|
@@ -1763,6 +1870,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
1763
1870
|
const hasSpeech = energyClassification === "speech";
|
|
1764
1871
|
detector.onMediaChunk(hasSpeech);
|
|
1765
1872
|
this.trackBargeInGuard(energyClassification, chunk);
|
|
1873
|
+
if (hasSpeech) {
|
|
1874
|
+
this.localSpeechStopAtMs = Date.now();
|
|
1875
|
+
}
|
|
1766
1876
|
|
|
1767
1877
|
// Playback echo is neither user audio nor useful pre-roll. Dropping it
|
|
1768
1878
|
// prevents the assistant's reply from reaching transcription as a ghost
|
|
@@ -2163,6 +2273,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
2163
2273
|
// utterance keeps accumulating and the detector fires a fresh turn-end.
|
|
2164
2274
|
this.vadSpeechGeneration += 1;
|
|
2165
2275
|
this.clearEndpointExtensionTimer();
|
|
2276
|
+
// ...and so is a fallback deadline armed for the boundary the caller just
|
|
2277
|
+
// spoke through. The next silence boundary arms a fresh one.
|
|
2278
|
+
this.clearProviderTurnEndTimer();
|
|
2166
2279
|
|
|
2167
2280
|
// Speech resumed while a speculative leg was awaiting its verdict: the
|
|
2168
2281
|
// pause was mid-thought after all. Discard silently (no frames were ever
|
|
@@ -2319,7 +2432,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
2319
2432
|
// Tagged reason: provider catch-sites classify untagged caller aborts as
|
|
2320
2433
|
// retryable transport failures (ERROR log + futile retry against the
|
|
2321
2434
|
// aborted signal). This signal reaches the brain leg and any in-flight
|
|
2322
|
-
//
|
|
2435
|
+
// progress narration.
|
|
2323
2436
|
turn.abortController.abort(
|
|
2324
2437
|
createAbortReason("voice_session_aborted", "live-voice-barge-in"),
|
|
2325
2438
|
);
|
|
@@ -3072,8 +3185,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
3072
3185
|
this.pendingBargeIn = null;
|
|
3073
3186
|
if (reason === "max-duration") {
|
|
3074
3187
|
// A max-duration boundary always releases: drop any pending hold
|
|
3075
|
-
// replay so it cannot re-fire a
|
|
3188
|
+
// replay (or provider fallback deadline) so it cannot re-fire a
|
|
3189
|
+
// boundary this one already owns.
|
|
3076
3190
|
this.clearEndpointExtensionTimer();
|
|
3191
|
+
this.clearProviderTurnEndTimer();
|
|
3077
3192
|
}
|
|
3078
3193
|
const utterance = this.currentUtterance;
|
|
3079
3194
|
if (!utterance || utterance.released || utterance.completed) {
|
|
@@ -3095,6 +3210,20 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
3095
3210
|
// release (ptt_release forced the boundary) is the user saying "answer
|
|
3096
3211
|
// now" — never second-guess it.
|
|
3097
3212
|
if (reason === "silence" && !manualRelease) {
|
|
3213
|
+
// The provider owns this boundary: its end-of-turn commits the
|
|
3214
|
+
// utterance, so the silence timer only arms the fail-open deadline. An
|
|
3215
|
+
// utterance whose deadline already elapsed falls through to the hold
|
|
3216
|
+
// path below, which is the whole point of the fallback.
|
|
3217
|
+
if (this.providerTurnEndActive && !utterance.providerTurnEndTimedOut) {
|
|
3218
|
+
// Stamp the speech run this boundary closed. This is the staleness
|
|
3219
|
+
// signal of last resort, used only for a provider that does not
|
|
3220
|
+
// number its turns: where the provider's own turn index is on the
|
|
3221
|
+
// wire it answers the question directly (see
|
|
3222
|
+
// isStaleProviderTurnEnd).
|
|
3223
|
+
utterance.turnBoundaryGeneration = this.vadSpeechGeneration;
|
|
3224
|
+
this.armProviderTurnEndFallbackTimer(utterance);
|
|
3225
|
+
return;
|
|
3226
|
+
}
|
|
3098
3227
|
if (await this.launchSpeculativeAssistantTurn(utterance)) {
|
|
3099
3228
|
return;
|
|
3100
3229
|
}
|
|
@@ -3125,6 +3254,217 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
3125
3254
|
}, this.frontModelConfig.endpointExtensionMs);
|
|
3126
3255
|
}
|
|
3127
3256
|
|
|
3257
|
+
// Arms the fail-open deadline for a provider end-of-turn. A turn-detecting
|
|
3258
|
+
// provider force-ends its own turn after `eotTimeoutMs` of silence, so once
|
|
3259
|
+
// that budget plus a margin has passed since the caller stopped speaking, no
|
|
3260
|
+
// end-of-turn is coming and the utterance replays this boundary on the
|
|
3261
|
+
// silence path.
|
|
3262
|
+
// `waitMsOverride` collapses that wait when the caller already knows no
|
|
3263
|
+
// end-of-turn is coming (see setProviderTurnEndActive).
|
|
3264
|
+
private armProviderTurnEndFallbackTimer(
|
|
3265
|
+
utterance: UtteranceCycle,
|
|
3266
|
+
waitMsOverride?: number,
|
|
3267
|
+
): void {
|
|
3268
|
+
this.clearProviderTurnEndTimer();
|
|
3269
|
+
const budgetMs =
|
|
3270
|
+
this.fluxConfig.eotTimeoutMs + PROVIDER_TURN_END_FALLBACK_MARGIN_MS;
|
|
3271
|
+
const waitMs =
|
|
3272
|
+
waitMsOverride ?? Math.max(0, budgetMs - this.msSinceLocalSpeechStop());
|
|
3273
|
+
this.providerTurnEndTimer = setTimeout(() => {
|
|
3274
|
+
this.providerTurnEndTimer = null;
|
|
3275
|
+
if (
|
|
3276
|
+
this.currentUtterance !== utterance ||
|
|
3277
|
+
utterance.released ||
|
|
3278
|
+
utterance.completed ||
|
|
3279
|
+
utterance.assistantTurnStarted ||
|
|
3280
|
+
// Speech resumed (the detector owns the next boundary). Onset also
|
|
3281
|
+
// clears this timer, so this is a belt to that suspender.
|
|
3282
|
+
this.turnDetector?.isActive
|
|
3283
|
+
) {
|
|
3284
|
+
return;
|
|
3285
|
+
}
|
|
3286
|
+
utterance.providerTurnEndTimedOut = true;
|
|
3287
|
+
log.warn(
|
|
3288
|
+
{ budgetMs, waitMs },
|
|
3289
|
+
"No provider end-of-turn is coming; falling back to the silence boundary for this utterance",
|
|
3290
|
+
);
|
|
3291
|
+
this.handleVadUtteranceEnd("silence");
|
|
3292
|
+
}, waitMs);
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
/**
|
|
3296
|
+
* Flips the provider end-of-turn latch, unwinding an optimistic arm.
|
|
3297
|
+
*
|
|
3298
|
+
* `beginUtterance` arms the latch from the configured provider before the
|
|
3299
|
+
* dial resolves, so a silence boundary can already have deferred to the
|
|
3300
|
+
* provider by the time the resolved one says otherwise. That deferred
|
|
3301
|
+
* boundary is parked on the fail-open deadline, a whole end-of-turn budget
|
|
3302
|
+
* away, waiting for an event that will now never arrive. Collapse the wait to
|
|
3303
|
+
* zero rather than burn the budget: the deadline body re-checks the cycle
|
|
3304
|
+
* and replays the silence boundary, which with the latch down takes the
|
|
3305
|
+
* ordinary hold path. Replaying through the deadline instead of calling
|
|
3306
|
+
* `handleVadUtteranceEnd` directly keeps the release off the arming
|
|
3307
|
+
* caller's stack, which is still mid-dial.
|
|
3308
|
+
*
|
|
3309
|
+
* A cleared latch with no deadline armed needs no unwind: either no
|
|
3310
|
+
* boundary ever deferred, or the caller resumed speaking and
|
|
3311
|
+
* `handleVadSpeechStart` already dropped the deadline, leaving the detector
|
|
3312
|
+
* owning the next boundary. The cycle's `turnBoundaryGeneration` stamp is
|
|
3313
|
+
* left as it is: `isStaleProviderTurnEnd` is only ever consulted from
|
|
3314
|
+
* `handleProviderTurnEnd`, which returns immediately once the latch is down.
|
|
3315
|
+
*/
|
|
3316
|
+
private setProviderTurnEndActive(active: boolean): void {
|
|
3317
|
+
if (active === this.providerTurnEndActive) {
|
|
3318
|
+
return;
|
|
3319
|
+
}
|
|
3320
|
+
this.providerTurnEndActive = active;
|
|
3321
|
+
if (active || this.providerTurnEndTimer === null) {
|
|
3322
|
+
return;
|
|
3323
|
+
}
|
|
3324
|
+
const utterance = this.currentUtterance;
|
|
3325
|
+
this.clearProviderTurnEndTimer();
|
|
3326
|
+
if (utterance && !utterance.released && !utterance.completed) {
|
|
3327
|
+
this.armProviderTurnEndFallbackTimer(utterance, 0);
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
/**
|
|
3332
|
+
* Record the provider turn a cycle is currently inside. Providers without
|
|
3333
|
+
* turn numbering send no index, which leaves the cycle on the local speech
|
|
3334
|
+
* generation as its only staleness signal (see isStaleProviderTurnEnd).
|
|
3335
|
+
*/
|
|
3336
|
+
private recordProviderTurnStart(
|
|
3337
|
+
utterance: UtteranceCycle,
|
|
3338
|
+
turnIndex: number | undefined,
|
|
3339
|
+
): void {
|
|
3340
|
+
if (turnIndex === undefined) {
|
|
3341
|
+
return;
|
|
3342
|
+
}
|
|
3343
|
+
utterance.openProviderTurnIndex = turnIndex;
|
|
3344
|
+
}
|
|
3345
|
+
|
|
3346
|
+
/**
|
|
3347
|
+
* A provider end-of-turn that lost its race with the caller's next breath:
|
|
3348
|
+
* it describes speech the caller has already spoken past. Acting on one
|
|
3349
|
+
* would send utterance_end and stop the transcriber while local VAD is
|
|
3350
|
+
* still routing the resumed speech into this same cycle, cutting off the
|
|
3351
|
+
* next words or folding them into the wrong turn.
|
|
3352
|
+
*
|
|
3353
|
+
* The provider's own turn numbering answers this directly and is preferred
|
|
3354
|
+
* wherever it is available: the cycle records the newest turn the provider
|
|
3355
|
+
* has opened, so an end-of-turn for an older index closes a turn the
|
|
3356
|
+
* provider itself has already superseded. An end-of-turn for the turn still
|
|
3357
|
+
* in progress is never stale, however many times the caller drew breath
|
|
3358
|
+
* inside it: the mid-thought pause is exactly the case a provider's turn
|
|
3359
|
+
* model exists to judge, and its verdict covers the resumed speech too.
|
|
3360
|
+
*
|
|
3361
|
+
* With no turn numbering the local speech generation is the only signal
|
|
3362
|
+
* left. The silence boundary stamps the cycle with the generation it
|
|
3363
|
+
* closed and `handleVadSpeechStart` bumps that generation when the caller
|
|
3364
|
+
* resumes, so a turn-end arriving on a newer generation is treated as
|
|
3365
|
+
* stale. That is conservative: it also drops the fast end-of-turn this
|
|
3366
|
+
* feature exists for, which the turn index would have committed. Before
|
|
3367
|
+
* any boundary has fired the stamp is null and nothing is stale.
|
|
3368
|
+
*/
|
|
3369
|
+
private isStaleProviderTurnEnd(
|
|
3370
|
+
utterance: UtteranceCycle,
|
|
3371
|
+
turnIndex: number | undefined,
|
|
3372
|
+
): boolean {
|
|
3373
|
+
if (turnIndex !== undefined && utterance.openProviderTurnIndex !== null) {
|
|
3374
|
+
return turnIndex < utterance.openProviderTurnIndex;
|
|
3375
|
+
}
|
|
3376
|
+
return (
|
|
3377
|
+
utterance.turnBoundaryGeneration !== null &&
|
|
3378
|
+
utterance.turnBoundaryGeneration !== this.vadSpeechGeneration
|
|
3379
|
+
);
|
|
3380
|
+
}
|
|
3381
|
+
|
|
3382
|
+
private clearProviderTurnEndTimer(): void {
|
|
3383
|
+
if (this.providerTurnEndTimer !== null) {
|
|
3384
|
+
clearTimeout(this.providerTurnEndTimer);
|
|
3385
|
+
this.providerTurnEndTimer = null;
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
// Time since the local VAD last heard above-gate audio: the speech-stop
|
|
3390
|
+
// mark both the fallback deadline and the reported end-of-turn latency are
|
|
3391
|
+
// measured from. Zero when no speech has been heard at all.
|
|
3392
|
+
private msSinceLocalSpeechStop(): number {
|
|
3393
|
+
if (this.localSpeechStopAtMs === null) {
|
|
3394
|
+
return 0;
|
|
3395
|
+
}
|
|
3396
|
+
return Math.max(0, Date.now() - this.localSpeechStopAtMs);
|
|
3397
|
+
}
|
|
3398
|
+
|
|
3399
|
+
/**
|
|
3400
|
+
* The provider committed an end of turn: the caller has finished, so the utterance
|
|
3401
|
+
* commits now instead of waiting out the trailing-silence timer and asking
|
|
3402
|
+
* the front door whether the pause was mid-thought. This runs the ordinary
|
|
3403
|
+
* post-release path (utterance_end, then release), so the front-door leg it
|
|
3404
|
+
* dispatches is non-speculative: `speculativeHoldAllowed` is false, its
|
|
3405
|
+
* decision rule is built with `includeHold: false`, and the model is told
|
|
3406
|
+
* the caller has finished. Escalation and progress narration are untouched;
|
|
3407
|
+
* only the hold verdict is bypassed.
|
|
3408
|
+
*/
|
|
3409
|
+
private async handleProviderTurnEnd(
|
|
3410
|
+
utterance: UtteranceCycle,
|
|
3411
|
+
turnIndex: number | undefined,
|
|
3412
|
+
): Promise<void> {
|
|
3413
|
+
if (
|
|
3414
|
+
!this.providerTurnEndActive ||
|
|
3415
|
+
this.currentUtterance !== utterance ||
|
|
3416
|
+
utterance.released ||
|
|
3417
|
+
utterance.completed ||
|
|
3418
|
+
utterance.assistantTurnStarted ||
|
|
3419
|
+
// The fallback already took this utterance back to the silence path.
|
|
3420
|
+
utterance.providerTurnEndTimedOut
|
|
3421
|
+
) {
|
|
3422
|
+
return;
|
|
3423
|
+
}
|
|
3424
|
+
if (this.isStaleProviderTurnEnd(utterance, turnIndex)) {
|
|
3425
|
+
// At `info`, not `debug`: a drop is rare and is the only signal of the
|
|
3426
|
+
// one known latency-outlier mode, so an operator measuring the two
|
|
3427
|
+
// paths has to be able to see it at the default log level.
|
|
3428
|
+
log.info(
|
|
3429
|
+
{
|
|
3430
|
+
turnIndex,
|
|
3431
|
+
openTurnIndex: utterance.openProviderTurnIndex,
|
|
3432
|
+
boundaryGeneration: utterance.turnBoundaryGeneration,
|
|
3433
|
+
speechGeneration: this.vadSpeechGeneration,
|
|
3434
|
+
},
|
|
3435
|
+
"Dropping a stale provider end-of-turn: the caller resumed speaking past the boundary it closed",
|
|
3436
|
+
);
|
|
3437
|
+
// The cycle stays open and local VAD keeps routing the resumed speech
|
|
3438
|
+
// into it. The detector's next silence boundary re-stamps the
|
|
3439
|
+
// generation and re-arms the fail-open deadline, so the turn still
|
|
3440
|
+
// commits: on the end-of-turn for the resumed speech, or on that
|
|
3441
|
+
// deadline. Re-arm here only if the detector has somehow already gone
|
|
3442
|
+
// idle, so the deadline handleVadSpeechStart cleared can never strand
|
|
3443
|
+
// the cycle with nothing left to close it.
|
|
3444
|
+
if (this.turnDetector?.isActive !== true) {
|
|
3445
|
+
utterance.turnBoundaryGeneration = this.vadSpeechGeneration;
|
|
3446
|
+
this.armProviderTurnEndFallbackTimer(utterance);
|
|
3447
|
+
}
|
|
3448
|
+
return;
|
|
3449
|
+
}
|
|
3450
|
+
this.clearProviderTurnEndTimer();
|
|
3451
|
+
this.markEndpointDecision(
|
|
3452
|
+
utterance,
|
|
3453
|
+
"release",
|
|
3454
|
+
this.msSinceLocalSpeechStop(),
|
|
3455
|
+
"provider",
|
|
3456
|
+
);
|
|
3457
|
+
await this.sendFrame({ type: "utterance_end", reason: "silence" });
|
|
3458
|
+
await this.releaseUtterance();
|
|
3459
|
+
// Leave the local detector idle, which is where every other commit path
|
|
3460
|
+
// leaves it. A provider can close a turn while the trailing-silence
|
|
3461
|
+
// countdown is still running, and barge-in fires from the detector's speech
|
|
3462
|
+
// ONSET: a detector left mid-turn reports no onset, so the caller could
|
|
3463
|
+
// not interrupt the reply they just triggered. The forced boundary
|
|
3464
|
+
// reaches an already-released utterance and returns.
|
|
3465
|
+
this.turnDetector?.forceEnd();
|
|
3466
|
+
}
|
|
3467
|
+
|
|
3128
3468
|
/**
|
|
3129
3469
|
* Unified front-door: dispatch the assistant turn speculatively at the
|
|
3130
3470
|
* silence boundary, judging the transcript accumulated so far — the same
|
|
@@ -3204,13 +3544,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
3204
3544
|
if (!alreadyReleased) {
|
|
3205
3545
|
void this.releaseUtterance();
|
|
3206
3546
|
}
|
|
3207
|
-
if (this.streamTtsAudio) {
|
|
3208
|
-
this.armAckTimer(turn);
|
|
3209
|
-
}
|
|
3210
3547
|
if (
|
|
3211
3548
|
this.frontModelConfig.progress.enabled &&
|
|
3212
3549
|
this.streamTtsAudio &&
|
|
3213
|
-
this.
|
|
3550
|
+
this.progressNarrator
|
|
3214
3551
|
) {
|
|
3215
3552
|
this.armProgressIdleTimer(turn);
|
|
3216
3553
|
}
|
|
@@ -3344,6 +3681,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
3344
3681
|
!utterance.completed
|
|
3345
3682
|
) {
|
|
3346
3683
|
this.clearEndpointExtensionTimer();
|
|
3684
|
+
this.clearProviderTurnEndTimer();
|
|
3347
3685
|
await this.sendFrame({ type: "utterance_end", reason: "silence" });
|
|
3348
3686
|
}
|
|
3349
3687
|
await this.releaseUtterance();
|
|
@@ -3358,6 +3696,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
3358
3696
|
if (utterance.phase === "transcriber_closed") {
|
|
3359
3697
|
utterance.released = true;
|
|
3360
3698
|
this.markUtteranceReleased(utterance);
|
|
3699
|
+
this.markEndpointCommit(utterance);
|
|
3361
3700
|
await this.startAssistantTurnIfReady();
|
|
3362
3701
|
await this.drainOutboundFrames();
|
|
3363
3702
|
return;
|
|
@@ -3369,6 +3708,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
3369
3708
|
|
|
3370
3709
|
utterance.released = true;
|
|
3371
3710
|
this.markUtteranceReleased(utterance);
|
|
3711
|
+
this.markEndpointCommit(utterance);
|
|
3372
3712
|
|
|
3373
3713
|
if (utterance.phase === "pending") {
|
|
3374
3714
|
// The transcriber is still starting; beginUtterance completes the release.
|
|
@@ -3546,6 +3886,24 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
3546
3886
|
// Per-cycle transcribers are torn down with stop(); the finalize
|
|
3547
3887
|
// completion signal has no cycle to advance here.
|
|
3548
3888
|
return;
|
|
3889
|
+
case "turn-start":
|
|
3890
|
+
// Barge-in is deliberately untouched: local VAD still owns it,
|
|
3891
|
+
// because a provider roundtrip cannot beat a local energy gate on an
|
|
3892
|
+
// interrupt during playback (see DEFAULT_BARGE_IN_MIN_SPEECH_MS). The
|
|
3893
|
+
// index is recorded so a later end-of-turn can be told apart from one
|
|
3894
|
+
// this turn superseded (see isStaleProviderTurnEnd).
|
|
3895
|
+
this.recordProviderTurnStart(utterance, event.turnIndex);
|
|
3896
|
+
return;
|
|
3897
|
+
case "eager-turn-end":
|
|
3898
|
+
case "turn-resumed":
|
|
3899
|
+
// Speculative end-of-turn stays off (the config leaves
|
|
3900
|
+
// `eagerEotThreshold` unset, so Deepgram never emits these). Wiring
|
|
3901
|
+
// them onto the speculative dispatch machinery is a deferred
|
|
3902
|
+
// follow-up.
|
|
3903
|
+
return;
|
|
3904
|
+
case "turn-end":
|
|
3905
|
+
await this.handleProviderTurnEnd(utterance, event.turnIndex);
|
|
3906
|
+
return;
|
|
3549
3907
|
case "error":
|
|
3550
3908
|
await this.sendTranscriberErrorFrame(event);
|
|
3551
3909
|
return;
|
|
@@ -3567,6 +3925,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
3567
3925
|
}
|
|
3568
3926
|
await this.startAssistantTurnIfReady();
|
|
3569
3927
|
return;
|
|
3928
|
+
default: {
|
|
3929
|
+
const _exhaustive: never = event;
|
|
3930
|
+
return;
|
|
3931
|
+
}
|
|
3570
3932
|
}
|
|
3571
3933
|
}
|
|
3572
3934
|
|
|
@@ -3670,6 +4032,34 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
3670
4032
|
await this.startAssistantTurnIfReady();
|
|
3671
4033
|
return;
|
|
3672
4034
|
}
|
|
4035
|
+
case "turn-start": {
|
|
4036
|
+
// Barge-in is deliberately untouched: local VAD still owns it,
|
|
4037
|
+
// because a provider roundtrip cannot beat a local energy gate on an
|
|
4038
|
+
// interrupt during playback (see DEFAULT_BARGE_IN_MIN_SPEECH_MS). The
|
|
4039
|
+
// index is recorded so a later end-of-turn can be told apart from one
|
|
4040
|
+
// this turn superseded (see isStaleProviderTurnEnd).
|
|
4041
|
+
const target = this.pendingTranscriptCycle();
|
|
4042
|
+
if (target) {
|
|
4043
|
+
this.recordProviderTurnStart(target, event.turnIndex);
|
|
4044
|
+
}
|
|
4045
|
+
return;
|
|
4046
|
+
}
|
|
4047
|
+
case "eager-turn-end":
|
|
4048
|
+
case "turn-resumed":
|
|
4049
|
+
// Speculative end-of-turn stays off (the config leaves
|
|
4050
|
+
// `eagerEotThreshold` unset, so Deepgram never emits these). Wiring
|
|
4051
|
+
// them onto the speculative dispatch machinery is a deferred
|
|
4052
|
+
// follow-up.
|
|
4053
|
+
return;
|
|
4054
|
+
case "turn-end": {
|
|
4055
|
+
// Same routing as transcript events: the cycle awaiting its
|
|
4056
|
+
// transcript owns the turn the provider just closed.
|
|
4057
|
+
const target = this.pendingTranscriptCycle();
|
|
4058
|
+
if (target) {
|
|
4059
|
+
await this.handleProviderTurnEnd(target, event.turnIndex);
|
|
4060
|
+
}
|
|
4061
|
+
return;
|
|
4062
|
+
}
|
|
3673
4063
|
case "error":
|
|
3674
4064
|
await this.sendTranscriberErrorFrame(event);
|
|
3675
4065
|
return;
|
|
@@ -3709,6 +4099,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
3709
4099
|
await this.startAssistantTurnIfReady();
|
|
3710
4100
|
return;
|
|
3711
4101
|
}
|
|
4102
|
+
default: {
|
|
4103
|
+
const _exhaustive: never = event;
|
|
4104
|
+
return;
|
|
4105
|
+
}
|
|
3712
4106
|
}
|
|
3713
4107
|
}
|
|
3714
4108
|
|
|
@@ -3844,8 +4238,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
3844
4238
|
this.assistantPlaybackTailUntilMs = 0;
|
|
3845
4239
|
this.takeVadPreRoll();
|
|
3846
4240
|
this.vadPendingTurnEnd = null;
|
|
3847
|
-
// ...and abandons any semantic-endpointing hold still awaiting replay
|
|
4241
|
+
// ...and abandons any semantic-endpointing hold still awaiting replay,
|
|
4242
|
+
// along with any provider end-of-turn the session was still waiting on.
|
|
3848
4243
|
this.clearEndpointExtensionTimer();
|
|
4244
|
+
this.clearProviderTurnEndTimer();
|
|
3849
4245
|
// A client interrupt is a hard reset: any barge-in merge context waiting for
|
|
3850
4246
|
// the next turn is now stale (the interrupted utterance may be discarded
|
|
3851
4247
|
// without ever reaching finalizePendingUtterance).
|
|
@@ -4112,14 +4508,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
4112
4508
|
consumedAnnouncement: pending?.announcement ?? null,
|
|
4113
4509
|
pendingContextStopGeneration: this.detachStopGeneration,
|
|
4114
4510
|
continuationDelivery: opts?.continuationDelivery ?? null,
|
|
4115
|
-
toolUseStarted: false,
|
|
4116
|
-
firstDeltaSeen: false,
|
|
4117
4511
|
deltaEpoch: 0,
|
|
4118
|
-
ackSpoken: false,
|
|
4119
|
-
ackGenerationPending: false,
|
|
4120
|
-
ackTimer: null,
|
|
4121
4512
|
escalationHandedOff: false,
|
|
4122
4513
|
ttsBuffer: "",
|
|
4514
|
+
ttsReasoningFilter: createReasoningTagFilter(),
|
|
4123
4515
|
ttsSegmentEnqueued: false,
|
|
4124
4516
|
ttsJobs: [],
|
|
4125
4517
|
ttsQueue: Promise.resolve(),
|
|
@@ -4142,8 +4534,8 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
4142
4534
|
// Verdict-deadline fail-open: the deferred-everything window is only
|
|
4143
4535
|
// safe while the verdict is fast. If the leg produces no verdict
|
|
4144
4536
|
// within the endpoint budget (provider TTFT tail), commit anyway so
|
|
4145
|
-
// the thinking frame shows and
|
|
4146
|
-
//
|
|
4537
|
+
// the thinking frame shows and progress narration can begin, bounding
|
|
4538
|
+
// the structural silence. A verdict that arrives
|
|
4147
4539
|
// after the commit still works: escalate hands off normally, and a
|
|
4148
4540
|
// late hold token is stripped like any stray token (the utterance is
|
|
4149
4541
|
// already released, so holding is no longer possible).
|
|
@@ -4166,22 +4558,14 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
4166
4558
|
}, this.frontModelConfig.endpointDecisionTimeoutMs);
|
|
4167
4559
|
}
|
|
4168
4560
|
|
|
4169
|
-
// Slow-first-delta spoken ack: if the model produces no delta within the
|
|
4170
|
-
// budget, a short front-model-phrased sentence holds the floor. Only
|
|
4171
|
-
// meaningful on TTS sessions — a text-only session has no audio gap to
|
|
4172
|
-
// bridge.
|
|
4173
|
-
if (this.streamTtsAudio && !opts?.speculative) {
|
|
4174
|
-
this.armAckTimer(activeTurn);
|
|
4175
|
-
}
|
|
4176
|
-
|
|
4177
4561
|
// Progress narration speaks into the turn's audible dead air on a
|
|
4178
|
-
// cadence, wherever in the turn it occurs; without TTS or a
|
|
4562
|
+
// cadence, wherever in the turn it occurs; without TTS or a narrator there
|
|
4179
4563
|
// is nothing to speak (the idle trigger's static fallback still needs a
|
|
4180
4564
|
// generation attempt to fall back from).
|
|
4181
4565
|
if (
|
|
4182
4566
|
this.frontModelConfig.progress.enabled &&
|
|
4183
4567
|
this.streamTtsAudio &&
|
|
4184
|
-
this.
|
|
4568
|
+
this.progressNarrator &&
|
|
4185
4569
|
!opts?.speculative
|
|
4186
4570
|
) {
|
|
4187
4571
|
this.armProgressIdleTimer(activeTurn);
|
|
@@ -4218,9 +4602,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
4218
4602
|
* escalate verdict the post-verdict stream buffers into the capped bridge
|
|
4219
4603
|
* and `escalateTurn` starts a second "escalated" leg that shares this same
|
|
4220
4604
|
* ActiveAssistantTurn. The persisted assistant row is reduced to the capped
|
|
4221
|
-
* bridge by the bridge's teardown transcript-hygiene pass
|
|
4222
|
-
* conversation-hub broadcast
|
|
4223
|
-
*
|
|
4605
|
+
* bridge by the bridge's teardown transcript-hygiene pass, and the shared
|
|
4606
|
+
* conversation-hub broadcast releases the same capped bridge through the
|
|
4607
|
+
* bridge's front-door stream gate, so no verdict token reaches a hub
|
|
4608
|
+
* subscriber mid-turn.
|
|
4224
4609
|
*/
|
|
4225
4610
|
private async startAssistantLeg(
|
|
4226
4611
|
activeTurn: ActiveAssistantTurn,
|
|
@@ -4254,7 +4639,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
4254
4639
|
return;
|
|
4255
4640
|
}
|
|
4256
4641
|
this.markFirstAssistantDelta(utterance, turnId);
|
|
4257
|
-
this.
|
|
4642
|
+
this.markAssistantDelta(activeTurn);
|
|
4258
4643
|
// Send-time abort gate: a delta queued behind a backed-up outbound
|
|
4259
4644
|
// frame must not be written once barge-in aborts the turn, or the
|
|
4260
4645
|
// cancelled reply's text leaks ahead of turn_cancelled. Key off this
|
|
@@ -4492,7 +4877,6 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
4492
4877
|
if (current?.token !== token) {
|
|
4493
4878
|
return;
|
|
4494
4879
|
}
|
|
4495
|
-
current.toolUseStarted = true;
|
|
4496
4880
|
// Foreground wins the workspace: the continuation runs with full
|
|
4497
4881
|
// subagent abilities (it can write files, run commands), so the
|
|
4498
4882
|
// moment a live turn starts a consequential tool the two could
|
|
@@ -4541,21 +4925,6 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
4541
4925
|
current.progress.stateEpoch += 1;
|
|
4542
4926
|
this.refreshActivity(current);
|
|
4543
4927
|
log.debug({ turnId, toolName }, "Live voice turn started tool use");
|
|
4544
|
-
// Definitive tool use means the turn is guaranteed slow: speak
|
|
4545
|
-
// the floor-holding ack now instead of waiting out the
|
|
4546
|
-
// first-delta timer (same one-ack-per-turn `ackSpoken` budget).
|
|
4547
|
-
// Post-handoff the escalation bridge phrase holds the floor and
|
|
4548
|
-
// front-door deltas never set firstDeltaSeen, so the escalated
|
|
4549
|
-
// leg's tool starts must not stack another filler on the bridge.
|
|
4550
|
-
if (
|
|
4551
|
-
this.streamTtsAudio &&
|
|
4552
|
-
!current.firstDeltaSeen &&
|
|
4553
|
-
!current.ackSpoken &&
|
|
4554
|
-
!current.escalationHandedOff
|
|
4555
|
-
) {
|
|
4556
|
-
this.clearAckTimer(current);
|
|
4557
|
-
this.speakAck(current, "tool_use", toolName);
|
|
4558
|
-
}
|
|
4559
4928
|
this.maybeNarrateProgress(current, "ops");
|
|
4560
4929
|
},
|
|
4561
4930
|
tool_result: (event) => {
|
|
@@ -4709,9 +5078,8 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
4709
5078
|
return;
|
|
4710
5079
|
}
|
|
4711
5080
|
activeTurn.escalationHandedOff = true;
|
|
4712
|
-
// The escalation bridge below holds the floor, so
|
|
4713
|
-
//
|
|
4714
|
-
// filler on top of it.
|
|
5081
|
+
// The escalation bridge below holds the floor, so pending progress
|
|
5082
|
+
// narration would only stack a second filler on top of it.
|
|
4715
5083
|
this.clearFillerTimers(activeTurn);
|
|
4716
5084
|
// Abort the front-door leg so a model that keeps generating past the
|
|
4717
5085
|
// bridge cap adds no latency before the escalated leg starts.
|
|
@@ -4728,7 +5096,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
4728
5096
|
: cappedBridge;
|
|
4729
5097
|
if (!usesFallbackBridge) {
|
|
4730
5098
|
this.markFirstAssistantDelta(activeTurn.utterance, activeTurn.turnId);
|
|
4731
|
-
this.
|
|
5099
|
+
this.markAssistantDelta(activeTurn);
|
|
4732
5100
|
void this.sendFrame(
|
|
4733
5101
|
{ type: "assistant_text_delta", text: spokenBridge },
|
|
4734
5102
|
() => !activeTurn.abortController.signal.aborted && !this.isClosed,
|
|
@@ -4774,7 +5142,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
4774
5142
|
if (
|
|
4775
5143
|
this.frontModelConfig.progress.enabled &&
|
|
4776
5144
|
this.streamTtsAudio &&
|
|
4777
|
-
this.
|
|
5145
|
+
this.progressNarrator
|
|
4778
5146
|
) {
|
|
4779
5147
|
this.armProgressIdleTimer(activeTurn);
|
|
4780
5148
|
}
|
|
@@ -4846,42 +5214,11 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
4846
5214
|
);
|
|
4847
5215
|
}
|
|
4848
5216
|
|
|
4849
|
-
//
|
|
4850
|
-
//
|
|
4851
|
-
//
|
|
4852
|
-
private
|
|
4853
|
-
const { token } = activeTurn;
|
|
4854
|
-
activeTurn.ackTimer = setTimeout(() => {
|
|
4855
|
-
activeTurn.ackTimer = null;
|
|
4856
|
-
if (
|
|
4857
|
-
!this.isActiveAssistantTurn(token) ||
|
|
4858
|
-
activeTurn.firstDeltaSeen ||
|
|
4859
|
-
activeTurn.ackSpoken
|
|
4860
|
-
) {
|
|
4861
|
-
return;
|
|
4862
|
-
}
|
|
4863
|
-
this.speakAck(activeTurn, "first_delta");
|
|
4864
|
-
}, this.frontModelConfig.ackFirstDeltaTimeoutMs);
|
|
4865
|
-
}
|
|
4866
|
-
|
|
4867
|
-
private clearAckTimer(turn: ActiveAssistantTurn): void {
|
|
4868
|
-
if (turn.ackTimer !== null) {
|
|
4869
|
-
clearTimeout(turn.ackTimer);
|
|
4870
|
-
turn.ackTimer = null;
|
|
4871
|
-
}
|
|
4872
|
-
}
|
|
4873
|
-
|
|
4874
|
-
// The model produced real output for this turn: the pending slow-first-delta
|
|
4875
|
-
// ack is moot, and any in-flight narration generation is stale (it captures
|
|
4876
|
-
// deltaEpoch to notice). The narration idle timer stays armed — the dead air
|
|
4877
|
-
// narration exists to fill is almost always MID-turn (between tool phases,
|
|
4878
|
-
// during long silent LLM calls after the model's opening words), so it must
|
|
4879
|
-
// keep watching after the model speaks and re-trigger once the audio drains
|
|
4880
|
-
// and silence stretches again.
|
|
4881
|
-
private markFirstDeltaForAck(turn: ActiveAssistantTurn): void {
|
|
4882
|
-
turn.firstDeltaSeen = true;
|
|
5217
|
+
// A real assistant delta makes any in-flight progress narration stale. The
|
|
5218
|
+
// idle timer stays armed so it can detect a later stretch of mid-turn
|
|
5219
|
+
// silence.
|
|
5220
|
+
private markAssistantDelta(turn: ActiveAssistantTurn): void {
|
|
4883
5221
|
turn.deltaEpoch += 1;
|
|
4884
|
-
this.clearAckTimer(turn);
|
|
4885
5222
|
}
|
|
4886
5223
|
|
|
4887
5224
|
// Arms (or re-arms) the dead-air narration timer. The countdown measures
|
|
@@ -4957,14 +5294,9 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
4957
5294
|
}
|
|
4958
5295
|
}
|
|
4959
5296
|
|
|
4960
|
-
// Clears
|
|
4961
|
-
//
|
|
4962
|
-
// lifecycle: escalation hand-off (which then re-arms narration for the
|
|
4963
|
-
// escalated leg), barge-in, cancellation, tts-completion, finalize. Real
|
|
4964
|
-
// output moots only the ack (markFirstDeltaForAck) — the narration timer
|
|
4965
|
-
// keeps watching for mid-turn dead air.
|
|
5297
|
+
// Clears progress narration and verdict timers for events that end the
|
|
5298
|
+
// current filler lifecycle.
|
|
4966
5299
|
private clearFillerTimers(turn: ActiveAssistantTurn): void {
|
|
4967
|
-
this.clearAckTimer(turn);
|
|
4968
5300
|
this.clearProgressIdleTimer(turn);
|
|
4969
5301
|
if (turn.verdictDeadlineTimer !== null) {
|
|
4970
5302
|
clearTimeout(turn.verdictDeadlineTimer);
|
|
@@ -4972,26 +5304,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
4972
5304
|
}
|
|
4973
5305
|
}
|
|
4974
5306
|
|
|
4975
|
-
//
|
|
4976
|
-
// Once the turn is stale, the brain's first delta has arrived, an
|
|
4977
|
-
// escalation hand-off has enqueued its bridge phrase (which holds the floor
|
|
4978
|
-
// itself), or the turn has completed outright — a tool-only/no-text turn
|
|
4979
|
-
// completes with firstDeltaSeen still false — an ack is moot and must not
|
|
4980
|
-
// speak over real output, stack on the escalation bridge, or voice a
|
|
4981
|
-
// finished turn.
|
|
4982
|
-
private turnCanSpeakFiller(turn: ActiveAssistantTurn): boolean {
|
|
4983
|
-
return (
|
|
4984
|
-
this.isActiveAssistantTurn(turn.token) &&
|
|
4985
|
-
!turn.firstDeltaSeen &&
|
|
4986
|
-
!turn.escalationHandedOff &&
|
|
4987
|
-
!turn.assistantCompleted
|
|
4988
|
-
);
|
|
4989
|
-
}
|
|
4990
|
-
|
|
4991
|
-
// Narration, unlike the ack, is not confined to the pre-first-delta window:
|
|
4992
|
-
// the dead air it exists to fill is almost always mid-turn, after the
|
|
4993
|
-
// model's opening words. It may speak whenever the live turn is audibly
|
|
4994
|
-
// silent right now — nothing streaming, queued, or still playing — and has
|
|
5307
|
+
// Narration may speak whenever the live turn is audibly silent and has
|
|
4995
5308
|
// not completed. Escalated turns qualify too: the bridge phrase holds the
|
|
4996
5309
|
// floor only while its audio plays (covered by the audible-idle check),
|
|
4997
5310
|
// and the strong leg's tool loops are the longest dead air in the system.
|
|
@@ -5028,31 +5341,20 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5028
5341
|
return false;
|
|
5029
5342
|
}
|
|
5030
5343
|
|
|
5031
|
-
// Gatekeeper for
|
|
5032
|
-
//
|
|
5033
|
-
// narration), one generation at a time, and — per trigger — only once the
|
|
5034
|
-
// ops trigger has `opsThreshold` ops accumulated or the idle trigger has
|
|
5035
|
-
// something new to report. No per-turn count cap: the cadence guards bound
|
|
5036
|
-
// the rate, and going quiet deep into a long turn is the failure mode
|
|
5037
|
-
// narration exists to prevent. Every failing guard short-circuits silently;
|
|
5038
|
-
// a skipped ops trigger keeps its accumulated count, so the next tool event
|
|
5039
|
-
// or idle tick retries.
|
|
5344
|
+
// Gatekeeper for progress narration. It speaks only while the turn is
|
|
5345
|
+
// audibly silent, with one generation at a time and the configured spacing.
|
|
5040
5346
|
private maybeNarrateProgress(
|
|
5041
5347
|
turn: ActiveAssistantTurn,
|
|
5042
5348
|
trigger: "ops" | "idle" | "op_complete",
|
|
5043
5349
|
): void {
|
|
5044
5350
|
const cfg = this.frontModelConfig.progress;
|
|
5045
5351
|
const { progress } = turn;
|
|
5046
|
-
const
|
|
5352
|
+
const progressNarrator = this.progressNarrator;
|
|
5047
5353
|
if (
|
|
5048
5354
|
!cfg.enabled ||
|
|
5049
5355
|
!this.streamTtsAudio ||
|
|
5050
|
-
!
|
|
5356
|
+
!progressNarrator ||
|
|
5051
5357
|
!this.turnCanNarrateProgress(turn) ||
|
|
5052
|
-
// A pending ack generation is a floor-holder-in-waiting: starting a
|
|
5053
|
-
// narration generation now would only be discarded by the post-await
|
|
5054
|
-
// re-check once the ack enqueues — a guaranteed wasted provider call.
|
|
5055
|
-
turn.ackGenerationPending ||
|
|
5056
5358
|
progress.narrationInFlight ||
|
|
5057
5359
|
(progress.lastFloorHolderAtMs !== null &&
|
|
5058
5360
|
Date.now() - progress.lastFloorHolderAtMs < cfg.minGapMs) ||
|
|
@@ -5061,25 +5363,20 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5061
5363
|
) {
|
|
5062
5364
|
return;
|
|
5063
5365
|
}
|
|
5064
|
-
void this.speakProgressUpdate(turn,
|
|
5366
|
+
void this.speakProgressUpdate(turn, progressNarrator, trigger);
|
|
5065
5367
|
}
|
|
5066
5368
|
|
|
5067
|
-
// Generate and speak one progress narration.
|
|
5068
|
-
//
|
|
5069
|
-
//
|
|
5070
|
-
// by `progress.generationTimeoutMs` and resolves null on every failure
|
|
5071
|
-
// mode; on null the idle trigger falls back to a static phrase (silence is
|
|
5072
|
-
// actively harmful there) while the tool-activity triggers stay silent — a
|
|
5073
|
-
// generic filler is not worth it when narration was merely opportunistic.
|
|
5369
|
+
// Generate and speak one audio-only progress narration. On a null result,
|
|
5370
|
+
// idle narration falls back to a static phrase while activity-triggered
|
|
5371
|
+
// narration stays silent.
|
|
5074
5372
|
private async speakProgressUpdate(
|
|
5075
5373
|
turn: ActiveAssistantTurn,
|
|
5076
|
-
|
|
5374
|
+
progressNarrator: VoiceProgressNarrator,
|
|
5077
5375
|
trigger: "ops" | "idle" | "op_complete",
|
|
5078
5376
|
): Promise<void> {
|
|
5079
5377
|
const { progress } = turn;
|
|
5080
5378
|
progress.narrationInFlight = true;
|
|
5081
|
-
// Any delta that lands while
|
|
5082
|
-
// generated text stale — and proves the model is speaking again.
|
|
5379
|
+
// Any delta that lands while generation is in flight makes the text stale.
|
|
5083
5380
|
const deltaEpochAtLaunch = turn.deltaEpoch;
|
|
5084
5381
|
// The activity this update describes. Tool events that land mid-generation
|
|
5085
5382
|
// are news the generated text cannot carry, so they must leave the idle
|
|
@@ -5097,7 +5394,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5097
5394
|
(op): op is TurnProgressOp & { completedAtMs: number } =>
|
|
5098
5395
|
op.completedAtMs !== undefined,
|
|
5099
5396
|
)
|
|
5100
|
-
// `ops` is in start order; the
|
|
5397
|
+
// `ops` is in start order; the narrator wants completion
|
|
5101
5398
|
// order, which differs when parallel tools finish out of order.
|
|
5102
5399
|
.sort((a, b) => a.completedAtMs - b.completedAtMs)
|
|
5103
5400
|
.map((op) => ({
|
|
@@ -5117,26 +5414,14 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5117
5414
|
updateIndex: progress.updatesSpoken + 1,
|
|
5118
5415
|
...(turn.language !== undefined ? { languageHint: turn.language } : {}),
|
|
5119
5416
|
};
|
|
5120
|
-
const generated = await
|
|
5417
|
+
const generated = await progressNarrator
|
|
5121
5418
|
.generateProgressText(input, turn.abortController.signal)
|
|
5122
|
-
// The
|
|
5419
|
+
// The narrator contract never rejects. Keep test stubs fail-soft too.
|
|
5123
5420
|
.catch(() => null);
|
|
5124
|
-
//
|
|
5125
|
-
// condition may have flipped while generating, and a delta that landed
|
|
5126
|
-
// mid-generation (deltaEpoch moved) makes the text stale even when the
|
|
5127
|
-
// resulting audio has already drained by now. The ack-generation and
|
|
5128
|
-
// floor re-checks close the race with a generated ack that
|
|
5129
|
-
// STARTED after this generation did — the entry guard rejects a
|
|
5130
|
-
// narration while an ack generation is already pending, but cannot see
|
|
5131
|
-
// one that begins mid-generation: while that ack is pending it has not
|
|
5132
|
-
// yet stamped `lastFloorHolderAtMs`, and once it enqueues the minGapMs
|
|
5133
|
-
// spacing must be re-applied from that stamp — either way, enqueueing
|
|
5134
|
-
// now would stack back-to-back filler. A bail here is silent and keeps
|
|
5135
|
-
// the update budget, exactly like the stale-turn bail.
|
|
5421
|
+
// Re-check liveness and staleness after the provider call.
|
|
5136
5422
|
if (
|
|
5137
5423
|
!this.turnCanNarrateProgress(turn) ||
|
|
5138
5424
|
turn.deltaEpoch !== deltaEpochAtLaunch ||
|
|
5139
|
-
turn.ackGenerationPending ||
|
|
5140
5425
|
(progress.lastFloorHolderAtMs !== null &&
|
|
5141
5426
|
Date.now() - progress.lastFloorHolderAtMs <
|
|
5142
5427
|
this.frontModelConfig.progress.minGapMs)
|
|
@@ -5144,7 +5429,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5144
5429
|
return;
|
|
5145
5430
|
}
|
|
5146
5431
|
let raw = generated;
|
|
5147
|
-
//
|
|
5432
|
+
// Generated text is in the turn's language; only the static
|
|
5148
5433
|
// fallback comes from a localized table and may need the "en" override.
|
|
5149
5434
|
let fillerLanguage: string | undefined;
|
|
5150
5435
|
if (raw === null) {
|
|
@@ -5163,8 +5448,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5163
5448
|
progress.opsSinceNarration = 0;
|
|
5164
5449
|
progress.narratedEpoch = stateEpochAtLaunch;
|
|
5165
5450
|
progress.updatesSpoken += 1;
|
|
5166
|
-
//
|
|
5167
|
-
// enqueued (decider text or static fallback alike).
|
|
5451
|
+
// Record only when narration audio actually enqueued.
|
|
5168
5452
|
this.markProgressSpoken(turn);
|
|
5169
5453
|
// Restart the dead-air countdown from this narration.
|
|
5170
5454
|
this.armProgressIdleTimer(turn);
|
|
@@ -5173,93 +5457,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5173
5457
|
}
|
|
5174
5458
|
}
|
|
5175
5459
|
|
|
5176
|
-
//
|
|
5177
|
-
// `ackSpoken` budget). The ack is audio-only — no assistant_text_delta frame
|
|
5178
|
-
// — so captions and the persisted assistant message carry only the model's
|
|
5179
|
-
// own output. It enqueues as a complete standalone sentence on the turn's
|
|
5180
|
-
// ordered TTS queue, so the model's audio always follows it cleanly; it
|
|
5181
|
-
// never consumes the eager first-clause flush reserved for the model's real
|
|
5182
|
-
// first segment.
|
|
5183
|
-
private speakAck(
|
|
5184
|
-
activeTurn: ActiveAssistantTurn,
|
|
5185
|
-
kind: LiveVoiceSpokenAckKind,
|
|
5186
|
-
toolName?: string,
|
|
5187
|
-
): void {
|
|
5188
|
-
if (!this.frontDecider) {
|
|
5189
|
-
// No decider, no ack: a canned line is worse than the silence, which
|
|
5190
|
-
// progress narration already covers once the turn runs long.
|
|
5191
|
-
return;
|
|
5192
|
-
}
|
|
5193
|
-
activeTurn.ackSpoken = true;
|
|
5194
|
-
// Optimistic ackSpoken above keeps the one-per-turn budget honest for
|
|
5195
|
-
// any other ack trigger while the generation is in flight; the async
|
|
5196
|
-
// path undoes it if the ack turns out moot.
|
|
5197
|
-
void this.speakGeneratedAck(activeTurn, this.frontDecider, kind, toolName);
|
|
5198
|
-
}
|
|
5199
|
-
|
|
5200
|
-
// Ask the front model for one short contextual sentence. The decider
|
|
5201
|
-
// internally bounds the call by `ackGenerationTimeoutMs` and resolves null
|
|
5202
|
-
// on every failure mode, so the enqueue never waits beyond that budget; a
|
|
5203
|
-
// null generation speaks nothing.
|
|
5204
|
-
private async speakGeneratedAck(
|
|
5205
|
-
activeTurn: ActiveAssistantTurn,
|
|
5206
|
-
frontDecider: VoiceFrontDecider,
|
|
5207
|
-
kind: LiveVoiceSpokenAckKind,
|
|
5208
|
-
toolName?: string,
|
|
5209
|
-
): Promise<void> {
|
|
5210
|
-
activeTurn.ackGenerationPending = true;
|
|
5211
|
-
try {
|
|
5212
|
-
const transcript = activeTurn.utterance.finalTranscriptSegments
|
|
5213
|
-
.join(" ")
|
|
5214
|
-
.trim();
|
|
5215
|
-
const generated = await frontDecider
|
|
5216
|
-
.generateAckText(
|
|
5217
|
-
{
|
|
5218
|
-
transcriptSoFar: transcript,
|
|
5219
|
-
toolName,
|
|
5220
|
-
...(activeTurn.language !== undefined
|
|
5221
|
-
? { languageHint: activeTurn.language }
|
|
5222
|
-
: {}),
|
|
5223
|
-
},
|
|
5224
|
-
activeTurn.abortController.signal,
|
|
5225
|
-
)
|
|
5226
|
-
// The decider contract never rejects; belt-and-braces for a stub.
|
|
5227
|
-
.catch(() => null);
|
|
5228
|
-
// Liveness re-check after the await: any turnCanSpeakFiller condition
|
|
5229
|
-
// may have flipped while generating, mooting the ack. A null generation
|
|
5230
|
-
// (no provider, timeout, unusable output) speaks nothing. Either way,
|
|
5231
|
-
// undo the optimistic ackSpoken so the flag reflects reality and a
|
|
5232
|
-
// later trigger this turn can still hold the floor.
|
|
5233
|
-
if (generated === null || !this.turnCanSpeakFiller(activeTurn)) {
|
|
5234
|
-
activeTurn.ackSpoken = false;
|
|
5235
|
-
return;
|
|
5236
|
-
}
|
|
5237
|
-
this.enqueueAckPhrase(activeTurn, generated, kind);
|
|
5238
|
-
} finally {
|
|
5239
|
-
activeTurn.ackGenerationPending = false;
|
|
5240
|
-
}
|
|
5241
|
-
}
|
|
5242
|
-
|
|
5243
|
-
// Records the ack-spoken metric only when a phrase actually enqueues; a
|
|
5244
|
-
// phrase that sanitizes away releases the turn's ack budget again.
|
|
5245
|
-
private enqueueAckPhrase(
|
|
5246
|
-
activeTurn: ActiveAssistantTurn,
|
|
5247
|
-
raw: string,
|
|
5248
|
-
kind: LiveVoiceSpokenAckKind,
|
|
5249
|
-
): void {
|
|
5250
|
-
if (this.enqueueFillerPhrase(activeTurn, raw)) {
|
|
5251
|
-
this.markAckSpoken(activeTurn, kind);
|
|
5252
|
-
return;
|
|
5253
|
-
}
|
|
5254
|
-
activeTurn.ackSpoken = false;
|
|
5255
|
-
}
|
|
5256
|
-
|
|
5257
|
-
// Sanitize and enqueue one filler sentence (spoken ack or progress
|
|
5258
|
-
// narration) on the turn's ordered TTS queue — the shared tail of every
|
|
5259
|
-
// filler path. Returns whether a phrase actually enqueued; per-kind metric
|
|
5260
|
-
// marks and bookkeeping are the caller's. `language` is a per-segment
|
|
5261
|
-
// hint override (see fixedPhraseLanguage); omit it for generated text,
|
|
5262
|
-
// which is already in the turn's language.
|
|
5460
|
+
// Sanitize and enqueue one progress sentence on the ordered TTS queue.
|
|
5263
5461
|
private enqueueFillerPhrase(
|
|
5264
5462
|
turn: ActiveAssistantTurn,
|
|
5265
5463
|
raw: string,
|
|
@@ -5303,7 +5501,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5303
5501
|
return;
|
|
5304
5502
|
}
|
|
5305
5503
|
|
|
5306
|
-
activeTurn.ttsBuffer += text;
|
|
5504
|
+
activeTurn.ttsBuffer += activeTurn.ttsReasoningFilter.push(text);
|
|
5307
5505
|
this.flushTtsBuffer(token, false);
|
|
5308
5506
|
}
|
|
5309
5507
|
|
|
@@ -5314,6 +5512,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5314
5512
|
}
|
|
5315
5513
|
|
|
5316
5514
|
this.clearFillerTimers(activeTurn);
|
|
5515
|
+
activeTurn.ttsBuffer += activeTurn.ttsReasoningFilter.flush();
|
|
5317
5516
|
this.flushTtsBuffer(token, true);
|
|
5318
5517
|
activeTurn.ttsQueue = activeTurn.ttsQueue
|
|
5319
5518
|
.catch(() => {})
|
|
@@ -5420,8 +5619,8 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5420
5619
|
return;
|
|
5421
5620
|
}
|
|
5422
5621
|
|
|
5423
|
-
//
|
|
5424
|
-
//
|
|
5622
|
+
// Progress narration leaves the eager first-clause flush available for
|
|
5623
|
+
// the model's real first segment.
|
|
5425
5624
|
if (options.countsAsFirstSegment ?? true) {
|
|
5426
5625
|
activeTurn.ttsSegmentEnqueued = true;
|
|
5427
5626
|
}
|
|
@@ -5724,23 +5923,42 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
|
|
|
5724
5923
|
utterance: UtteranceCycle,
|
|
5725
5924
|
action: VoiceEndpointAction,
|
|
5726
5925
|
latencyMs: number,
|
|
5926
|
+
// Absent means the front door decided, which is what the collector
|
|
5927
|
+
// defaults to; the provider path names itself so one metrics frame can
|
|
5928
|
+
// compare the two.
|
|
5929
|
+
source?: VoiceEndpointSource,
|
|
5727
5930
|
): void {
|
|
5728
5931
|
const turnId = this.ensureTurnId(utterance);
|
|
5729
5932
|
if (!this.startMetricsTurnIfNeeded(utterance, turnId)) {
|
|
5730
5933
|
return;
|
|
5731
5934
|
}
|
|
5732
|
-
this.metrics.markEndpointDecision(turnId, {
|
|
5935
|
+
this.metrics.markEndpointDecision(turnId, {
|
|
5936
|
+
action,
|
|
5937
|
+
latencyMs,
|
|
5938
|
+
...(source ? { source } : {}),
|
|
5939
|
+
});
|
|
5733
5940
|
}
|
|
5734
5941
|
|
|
5735
|
-
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
|
|
5740
|
-
|
|
5942
|
+
/**
|
|
5943
|
+
* The turn's end-of-turn latency, stamped at the moment it commits. Called
|
|
5944
|
+
* from `releaseUtterance`, which every committed turn passes through
|
|
5945
|
+
* whichever decider released it, so the front-door and provider samples span
|
|
5946
|
+
* the same thing from the same anchor over the same population. That is
|
|
5947
|
+
* what `endpointDecisionMaxLatencyMs` cannot offer, and why this exists
|
|
5948
|
+
* alongside it.
|
|
5949
|
+
*
|
|
5950
|
+
* Skipped with no speech-stop mark, which is push-to-talk: there the
|
|
5951
|
+
* client's release is the boundary and no local VAD runs.
|
|
5952
|
+
*/
|
|
5953
|
+
private markEndpointCommit(utterance: UtteranceCycle): void {
|
|
5954
|
+
if (this.localSpeechStopAtMs === null) {
|
|
5955
|
+
return;
|
|
5956
|
+
}
|
|
5957
|
+
const turnId = this.ensureTurnId(utterance);
|
|
5958
|
+
if (!this.startMetricsTurnIfNeeded(utterance, turnId)) {
|
|
5741
5959
|
return;
|
|
5742
5960
|
}
|
|
5743
|
-
this.metrics.
|
|
5961
|
+
this.metrics.markEndpointCommit(turnId, this.msSinceLocalSpeechStop());
|
|
5744
5962
|
}
|
|
5745
5963
|
|
|
5746
5964
|
private markProgressSpoken(activeTurn: ActiveAssistantTurn): void {
|
|
@@ -6096,14 +6314,16 @@ export function createLiveVoiceSession(
|
|
|
6096
6314
|
options: LiveVoiceSessionOptions = {},
|
|
6097
6315
|
): LiveVoiceSession {
|
|
6098
6316
|
// Workspace-tunable server-VAD thresholds. The `liveVoice.vad` schema
|
|
6099
|
-
// defaults
|
|
6100
|
-
//
|
|
6101
|
-
//
|
|
6102
|
-
//
|
|
6103
|
-
//
|
|
6317
|
+
// defaults are 800 energy / 1200 ms silence / 30 s max turn / 250 ms
|
|
6318
|
+
// barge-in guard. Three of the four match their in-code fallback; the
|
|
6319
|
+
// trailing-silence threshold does not, so a session built with a
|
|
6320
|
+
// `liveVoice` config waits 1200 ms and one built without waits the in-code
|
|
6321
|
+
// DEFAULT_SILENCE_THRESHOLD_MS of 800. Optional-chained because hand-built
|
|
6322
|
+
// test configs may predate the liveVoice namespace; absent config falls
|
|
6323
|
+
// through to the in-code defaults.
|
|
6104
6324
|
const liveVoiceConfig = getConfig().liveVoice;
|
|
6105
6325
|
const vadConfig = liveVoiceConfig?.vad;
|
|
6106
|
-
// Parsed once here into a complete config, shared by
|
|
6326
|
+
// Parsed once here into a complete config, shared by endpointing and the
|
|
6107
6327
|
// session (the constructor's own parse is then a no-op re-validation).
|
|
6108
6328
|
const frontModelConfig = LiveVoiceFrontModelConfigSchema.parse(
|
|
6109
6329
|
options.frontModelConfig ?? liveVoiceConfig?.frontModel ?? {},
|
|
@@ -6128,13 +6348,14 @@ export function createLiveVoiceSession(
|
|
|
6128
6348
|
options.echoEmaHalfLifeMs ?? vadConfig?.echoEmaHalfLifeMs,
|
|
6129
6349
|
echoDrainSlackMs: options.echoDrainSlackMs ?? vadConfig?.echoDrainSlackMs,
|
|
6130
6350
|
frontModelConfig,
|
|
6131
|
-
//
|
|
6132
|
-
//
|
|
6133
|
-
|
|
6134
|
-
|
|
6135
|
-
|
|
6136
|
-
|
|
6137
|
-
|
|
6351
|
+
// Absent config leaves the schema defaults, which keep provider turn
|
|
6352
|
+
// detection off.
|
|
6353
|
+
fluxConfig: options.fluxConfig ?? liveVoiceConfig?.flux,
|
|
6354
|
+
// An explicit option, including null, always wins for the test seam.
|
|
6355
|
+
progressNarrator:
|
|
6356
|
+
options.progressNarrator !== undefined
|
|
6357
|
+
? options.progressNarrator
|
|
6358
|
+
: createVoiceProgressNarrator({ config: frontModelConfig.progress }),
|
|
6138
6359
|
resolveCredentialReadiness:
|
|
6139
6360
|
options.resolveCredentialReadiness === undefined
|
|
6140
6361
|
? defaultResolveLiveVoiceCredentialReadiness
|