@juspay/neurolink 9.72.0 → 9.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/browser/neurolink.min.js +349 -349
  3. package/dist/core/toolRouting.js +90 -4
  4. package/dist/core/toolRoutingCache.d.ts +64 -0
  5. package/dist/core/toolRoutingCache.js +154 -0
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +4 -0
  8. package/dist/lib/core/toolRouting.js +90 -4
  9. package/dist/lib/core/toolRoutingCache.d.ts +64 -0
  10. package/dist/lib/core/toolRoutingCache.js +155 -0
  11. package/dist/lib/index.d.ts +2 -0
  12. package/dist/lib/index.js +4 -0
  13. package/dist/lib/neurolink.d.ts +8 -6
  14. package/dist/lib/neurolink.js +187 -39
  15. package/dist/lib/types/livekit.d.ts +134 -0
  16. package/dist/lib/types/toolRouting.d.ts +88 -0
  17. package/dist/lib/voice/livekit/brain.js +1 -1
  18. package/dist/lib/voice/livekit/config.d.ts +12 -1
  19. package/dist/lib/voice/livekit/config.js +54 -0
  20. package/dist/lib/voice/livekit/eventBridge.js +4 -4
  21. package/dist/lib/voice/livekit/index.d.ts +9 -2
  22. package/dist/lib/voice/livekit/index.js +9 -2
  23. package/dist/lib/voice/livekit/realtimeEventBridge.d.ts +14 -0
  24. package/dist/lib/voice/livekit/realtimeEventBridge.js +161 -0
  25. package/dist/lib/voice/livekit/realtimeMcpTools.d.ts +31 -0
  26. package/dist/lib/voice/livekit/realtimeMcpTools.js +194 -0
  27. package/dist/lib/voice/livekit/realtimeVoiceAgent.d.ts +26 -0
  28. package/dist/lib/voice/livekit/realtimeVoiceAgent.js +362 -0
  29. package/dist/lib/voice/livekit/roomContext.d.ts +23 -0
  30. package/dist/lib/voice/livekit/roomContext.js +57 -0
  31. package/dist/lib/voice/livekit/roomDispatch.d.ts +24 -0
  32. package/dist/lib/voice/livekit/roomDispatch.js +31 -0
  33. package/dist/lib/voice/livekit/schemaSanitizer.d.ts +26 -0
  34. package/dist/lib/voice/livekit/schemaSanitizer.js +144 -0
  35. package/dist/lib/voice/livekit/vertexAuth.d.ts +30 -0
  36. package/dist/lib/voice/livekit/vertexAuth.js +73 -0
  37. package/dist/lib/voice/livekit/voiceAgent.js +47 -37
  38. package/dist/lib/voice/livekit/voiceAgentWorker.d.ts +2 -0
  39. package/dist/lib/voice/livekit/voiceAgentWorker.js +64 -0
  40. package/dist/neurolink.d.ts +8 -6
  41. package/dist/neurolink.js +187 -39
  42. package/dist/types/livekit.d.ts +134 -0
  43. package/dist/types/toolRouting.d.ts +88 -0
  44. package/dist/voice/livekit/brain.js +1 -1
  45. package/dist/voice/livekit/config.d.ts +12 -1
  46. package/dist/voice/livekit/config.js +54 -0
  47. package/dist/voice/livekit/eventBridge.js +4 -4
  48. package/dist/voice/livekit/index.d.ts +9 -2
  49. package/dist/voice/livekit/index.js +9 -2
  50. package/dist/voice/livekit/realtimeEventBridge.d.ts +14 -0
  51. package/dist/voice/livekit/realtimeEventBridge.js +160 -0
  52. package/dist/voice/livekit/realtimeMcpTools.d.ts +31 -0
  53. package/dist/voice/livekit/realtimeMcpTools.js +193 -0
  54. package/dist/voice/livekit/realtimeVoiceAgent.d.ts +26 -0
  55. package/dist/voice/livekit/realtimeVoiceAgent.js +361 -0
  56. package/dist/voice/livekit/roomContext.d.ts +23 -0
  57. package/dist/voice/livekit/roomContext.js +56 -0
  58. package/dist/voice/livekit/roomDispatch.d.ts +24 -0
  59. package/dist/voice/livekit/roomDispatch.js +30 -0
  60. package/dist/voice/livekit/schemaSanitizer.d.ts +26 -0
  61. package/dist/voice/livekit/schemaSanitizer.js +143 -0
  62. package/dist/voice/livekit/vertexAuth.d.ts +30 -0
  63. package/dist/voice/livekit/vertexAuth.js +72 -0
  64. package/dist/voice/livekit/voiceAgent.js +47 -37
  65. package/dist/voice/livekit/voiceAgentWorker.d.ts +2 -0
  66. package/dist/voice/livekit/voiceAgentWorker.js +64 -0
  67. package/package.json +5 -2
package/dist/neurolink.js CHANGED
@@ -31,6 +31,7 @@ import { repairToolPairs } from "./context/toolPairRepair.js";
31
31
  import { SYSTEM_LIMITS, DEFAULT_TOOL_ROUTING_TIMEOUT_MS, } from "./core/constants.js";
32
32
  import { ConversationMemoryManager } from "./core/conversationMemoryManager.js";
33
33
  import { buildToolRoutingCatalog, buildRoutingQueryFromHistory, resolveToolRoutingExclusions, } from "./core/toolRouting.js";
34
+ import { ToolRoutingCache } from "./core/toolRoutingCache.js";
34
35
  import { AIProviderFactory } from "./core/factory.js";
35
36
  import { createToolEventPayload } from "./core/toolEvents.js";
36
37
  import { ProviderRegistry } from "./factories/providerRegistry.js";
@@ -59,7 +60,7 @@ import { SpanSerializer } from "./observability/utils/spanSerializer.js";
59
60
  import { flushOpenTelemetry, getLangfuseContext, getLangfuseHealthStatus, initializeOpenTelemetry, isOpenTelemetryInitialized, runWithCurrentLangfuseContext, setLangfuseContext, shutdownOpenTelemetry, stampGuestRescueIdentity, } from "./services/server/ai/observability/instrumentation.js";
60
61
  import { TaskManager } from "./tasks/taskManager.js";
61
62
  import { createTaskTools } from "./tasks/tools/taskTools.js";
62
- import { ATTR } from "./telemetry/attributes.js";
63
+ import { ATTR, spanJsonAttribute } from "./telemetry/attributes.js";
63
64
  import { tracers } from "./telemetry/tracers.js";
64
65
  import { getConversationMessages, storeConversationTurn, } from "./utils/conversationMemory.js";
65
66
  // Enhanced error handling imports
@@ -441,6 +442,9 @@ export class NeuroLink {
441
442
  // The server catalog inside it can be supplied/replaced later via
442
443
  // setToolRoutingServers() for hosts that register tools after construction.
443
444
  toolRoutingConfig;
445
+ // Lazy-initialized routing decision cache (ITEM C). Created on first use so
446
+ // instances that don't use routing pay no overhead.
447
+ toolRoutingCacheInstance;
444
448
  // Add orchestration property
445
449
  enableOrchestration;
446
450
  // Authentication provider for secure access control
@@ -3019,7 +3023,14 @@ Current user's request: ${currentInput}`;
3019
3023
  generateSpan.setStatus({ code: SpanStatusCode.OK });
3020
3024
  return earlyResult;
3021
3025
  }
3022
- const result = await this.setLangfuseContextFromOptions(options, () => this.runStandardGenerateRequest(options, originalPrompt, generateSpan));
3026
+ // Pre-call tool routing for generate(): mirrors the stream() routing path.
3027
+ // Runs inside the generate's Langfuse context (setLangfuseContextFromOptions)
3028
+ // so the router's own generation span nests under this turn's trace.
3029
+ // After the early-result short-circuit so workflow/media turns skip it.
3030
+ const result = await this.setLangfuseContextFromOptions(options, async () => {
3031
+ await this.applyToolRoutingExclusions(options, options.input?.text ?? "");
3032
+ return this.runStandardGenerateRequest(options, originalPrompt, generateSpan);
3033
+ });
3023
3034
  generateSpan.setStatus({ code: SpanStatusCode.OK });
3024
3035
  return result;
3025
3036
  }
@@ -5688,12 +5699,13 @@ Current user's request: ${currentInput}`;
5688
5699
  }
5689
5700
  }
5690
5701
  /**
5691
- * Pre-call tool routing for stream(): runs the router LLM once per turn
5692
- * and appends the unpicked servers' registered tool names to
5693
- * `options.excludeTools` — the per-call denylist enforced by
5694
- * `baseProvider.applyToolFiltering`. No-op unless `toolRouting.enabled`
5695
- * is true and a non-empty server catalog has been supplied. Never throws
5696
- * (the resolver fails open to an empty exclusion list).
5702
+ * Pre-call tool routing for both stream() and generate() turns: runs the
5703
+ * router LLM once per turn and appends the unpicked servers' registered tool
5704
+ * names to `options.excludeTools` — the per-call denylist enforced by
5705
+ * `baseProvider.applyToolFiltering`. No-op unless `toolRouting.enabled` is
5706
+ * true and a non-empty server catalog has been supplied. Never throws (the
5707
+ * resolver fails open to an empty exclusion list). Accepts both StreamOptions
5708
+ * and GenerateOptions since both share the required fields.
5697
5709
  */
5698
5710
  async applyToolRoutingExclusions(options, userQuery) {
5699
5711
  const routingConfig = this.toolRoutingConfig;
@@ -5706,8 +5718,8 @@ Current user's request: ${currentInput}`;
5706
5718
  }
5707
5719
  // Whole setup is fail-open: catalog building (getCustomTools /
5708
5720
  // buildToolRoutingCatalog) and the router call degrade to no exclusions
5709
- // rather than killing the stream, honoring this method's "never throws"
5710
- // contract. Genuine stream cancellations still propagate.
5721
+ // rather than killing the stream/generate turn, honoring this method's
5722
+ // "never throws" contract. Genuine cancellations still propagate.
5711
5723
  try {
5712
5724
  const registeredToolNames = Array.from(this.getCustomTools().keys());
5713
5725
  const catalog = buildToolRoutingCatalog(servers, registeredToolNames);
@@ -5724,44 +5736,180 @@ Current user's request: ${currentInput}`;
5724
5736
  const routingQuery = recentMessages.length > 0
5725
5737
  ? buildRoutingQueryFromHistory(recentMessages, userQuery)
5726
5738
  : userQuery;
5727
- // The router call below re-enters the public generate(), whose finally
5728
- // block resets _disableToolCacheForCurrentRequest to false. That flag is
5729
- // stream-scoped (set at the top of this turn) and read by the main tool
5730
- // execution path that runs after routing, so save it before the router
5731
- // call and restore it afterward to keep the turn's cache setting intact.
5732
- const cacheDisabledForCurrentRequest = this._disableToolCacheForCurrentRequest;
5739
+ // --- ITEM C: routing decision cache ---
5740
+ const cacheConfig = routingConfig.cache;
5741
+ const cacheEnabled = cacheConfig?.enabled === true;
5742
+ const stickinessEnabled = routingConfig.stickiness?.enabled === true;
5743
+ // Lazy-init the cache instance once per NeuroLink instance.
5744
+ if ((cacheEnabled || stickinessEnabled) &&
5745
+ !this.toolRoutingCacheInstance) {
5746
+ this.toolRoutingCacheInstance = new ToolRoutingCache({
5747
+ ttlMs: cacheConfig?.ttlMs,
5748
+ maxEntries: cacheConfig?.maxEntries,
5749
+ stickyTurns: routingConfig.stickiness?.turns,
5750
+ });
5751
+ }
5752
+ const cache = this.toolRoutingCacheInstance;
5753
+ // Derive sessionId for stickiness — same extraction path as
5754
+ // fetchRecentRoutingHistory.
5755
+ const requestContext = options.context;
5756
+ const sessionId = typeof requestContext?.sessionId === "string"
5757
+ ? requestContext.sessionId
5758
+ : "";
5759
+ // Cache key: session + normalized routing query.
5760
+ // Require a non-empty sessionId to avoid anonymous sessions sharing a
5761
+ // ":query" namespace (cross-session cache leak).
5762
+ const cacheKey = cacheEnabled && sessionId ? `${sessionId}:${routingQuery}` : undefined;
5763
+ // --- ITEM E: build the emitDecision callback ---
5764
+ const emitDecision = (decision) => {
5765
+ try {
5766
+ const activeSpan = trace.getActiveSpan();
5767
+ if (!activeSpan) {
5768
+ return;
5769
+ }
5770
+ activeSpan.setAttribute("tool_routing.outcome", decision.outcome);
5771
+ activeSpan.setAttribute("tool_routing.routable_server_count", decision.routableServerCount);
5772
+ activeSpan.setAttribute("tool_routing.selected_server_ids", spanJsonAttribute(decision.selectedServerIds));
5773
+ activeSpan.setAttribute("tool_routing.excluded_server_ids", spanJsonAttribute(decision.excludedServerIds));
5774
+ activeSpan.setAttribute("tool_routing.hallucinated_ids", spanJsonAttribute(decision.hallucinatedIds));
5775
+ activeSpan.setAttribute("tool_routing.excluded_tool_count", decision.excludedToolCount);
5776
+ activeSpan.setAttribute("tool_routing.cache_hit", decision.cacheHit);
5777
+ activeSpan.setAttribute("tool_routing.duration_ms", decision.durationMs);
5778
+ }
5779
+ catch {
5780
+ // Telemetry must never affect routing behaviour.
5781
+ }
5782
+ };
5783
+ // Unified hit/miss block: always produces raw (pre-stickiness) exclusions
5784
+ // so stickiness can be applied identically on both paths below.
5733
5785
  let routedExcludeTools;
5734
- try {
5735
- routedExcludeTools = await resolveToolRoutingExclusions({
5736
- catalog,
5737
- alwaysIncludeServerIds: routingConfig.alwaysIncludeServerIds ?? [],
5738
- userQuery: routingQuery,
5739
- routerPromptPrefix: routingConfig.routerPromptPrefix,
5740
- routerModel: {
5741
- provider: routingConfig.routerModel?.provider ??
5742
- options.provider,
5743
- model: routingConfig.routerModel?.model ?? options.model,
5744
- region: routingConfig.routerModel?.region ?? options.region,
5745
- temperature: routingConfig.routerModel?.temperature,
5746
- },
5747
- timeoutMs: routingConfig.timeoutMs ?? DEFAULT_TOOL_ROUTING_TIMEOUT_MS,
5748
- // Forward the stream's abort signal so a cancelled stream aborts the
5749
- // router call promptly instead of waiting out the routing timeout.
5750
- generateFn: (generateOptions) => this.generate({
5751
- ...generateOptions,
5752
- abortSignal: options.abortSignal,
5753
- }),
5786
+ let selectedServerIds = [];
5787
+ let resolvedDecision;
5788
+ let fromCache = false;
5789
+ const cached = cacheEnabled && cache && cacheKey !== undefined
5790
+ ? cache.get(cacheKey)
5791
+ : undefined;
5792
+ if (cached) {
5793
+ // Cache HIT — use stored raw exclusions (pre-stickiness).
5794
+ fromCache = true;
5795
+ routedExcludeTools = [...cached.excludedToolNames];
5796
+ selectedServerIds = [...cached.selectedServerIds];
5797
+ logger.debug("[ToolRouting] Cache hit, skipping router LLM", {
5798
+ cacheKey,
5754
5799
  });
5800
+ // Emit cache-hit telemetry with full parity to the miss path.
5801
+ try {
5802
+ const activeSpan = trace.getActiveSpan();
5803
+ if (activeSpan) {
5804
+ const routableCount = catalog.filter((s) => !(routingConfig.alwaysIncludeServerIds ?? []).includes(s.id)).length;
5805
+ activeSpan.setAttribute("tool_routing.outcome", "cache-hit");
5806
+ activeSpan.setAttribute("tool_routing.routable_server_count", routableCount);
5807
+ activeSpan.setAttribute("tool_routing.cache_hit", true);
5808
+ activeSpan.setAttribute("tool_routing.selected_server_ids", spanJsonAttribute(selectedServerIds));
5809
+ activeSpan.setAttribute("tool_routing.excluded_tool_count", routedExcludeTools.length);
5810
+ }
5811
+ }
5812
+ catch {
5813
+ // Telemetry must never affect routing behaviour.
5814
+ }
5755
5815
  }
5756
- finally {
5757
- this._disableToolCacheForCurrentRequest =
5758
- cacheDisabledForCurrentRequest;
5816
+ else {
5817
+ // Cache MISS — run the router LLM.
5818
+ // The router call re-enters the public generate(), whose finally block
5819
+ // resets _disableToolCacheForCurrentRequest to false. That flag is
5820
+ // turn-scoped and read by the main tool execution path after routing, so
5821
+ // save it before the router call and restore it afterward.
5822
+ const cacheDisabledForCurrentRequest = this._disableToolCacheForCurrentRequest;
5823
+ try {
5824
+ // Intercept the decision so we can store it in the cache.
5825
+ const captureDecision = (decision) => {
5826
+ resolvedDecision = decision;
5827
+ emitDecision(decision);
5828
+ };
5829
+ routedExcludeTools = await resolveToolRoutingExclusions({
5830
+ catalog,
5831
+ alwaysIncludeServerIds: routingConfig.alwaysIncludeServerIds ?? [],
5832
+ userQuery: routingQuery,
5833
+ routerPromptPrefix: routingConfig.routerPromptPrefix,
5834
+ routerModel: {
5835
+ provider: routingConfig.routerModel?.provider ??
5836
+ options.provider,
5837
+ model: routingConfig.routerModel?.model ?? options.model,
5838
+ region: routingConfig.routerModel?.region ?? options.region,
5839
+ temperature: routingConfig.routerModel?.temperature,
5840
+ },
5841
+ timeoutMs: routingConfig.timeoutMs ?? DEFAULT_TOOL_ROUTING_TIMEOUT_MS,
5842
+ // Forward the abort signal so a cancelled turn aborts the router
5843
+ // call promptly instead of waiting out the routing timeout.
5844
+ generateFn: (generateOptions) => this.generate({
5845
+ ...generateOptions,
5846
+ abortSignal: options.abortSignal,
5847
+ }),
5848
+ emitDecision: captureDecision,
5849
+ });
5850
+ }
5851
+ finally {
5852
+ this._disableToolCacheForCurrentRequest =
5853
+ cacheDisabledForCurrentRequest;
5854
+ }
5855
+ if (resolvedDecision?.outcome === "applied") {
5856
+ selectedServerIds = resolvedDecision.selectedServerIds;
5857
+ }
5759
5858
  }
5760
5859
  // Aborted during the router call — skip applying now-stale exclusions;
5761
5860
  // the main generation path enforces the abort itself.
5762
5861
  if (options.abortSignal?.aborted) {
5763
5862
  return;
5764
5863
  }
5864
+ // Capture raw (pre-stickiness) exclusions for cache storage on MISS path.
5865
+ const rawExclusions = [...routedExcludeTools];
5866
+ // Apply stickiness on BOTH hit and miss paths: the sticky ids were
5867
+ // recorded on a prior turn and represent servers that should stay warm for
5868
+ // the current turn. Consuming (decrementing) them before recordSelection
5869
+ // ensures the window covers the correct set of future turns rather than
5870
+ // burning one count on the same turn the selection is recorded
5871
+ // (off-by-one fix).
5872
+ if (stickinessEnabled && cache && sessionId) {
5873
+ try {
5874
+ const stickyIds = cache.getStickyServerIds(sessionId);
5875
+ if (stickyIds.length > 0) {
5876
+ // Remove from routedExcludeTools any tool belonging to a sticky server.
5877
+ // Precompute prefixes to avoid rebuilding the set inside the predicate.
5878
+ const stickyPrefixes = stickyIds.map((id) => `${id}_`);
5879
+ routedExcludeTools = routedExcludeTools.filter((toolName) => !stickyPrefixes.some((prefix) => toolName.startsWith(prefix)));
5880
+ }
5881
+ }
5882
+ catch {
5883
+ // Stickiness failure is non-fatal.
5884
+ }
5885
+ }
5886
+ // --- ITEM C: store result for future turns (MISS path only) ---
5887
+ // Cache stores RAW (pre-stickiness) exclusions so the cached value is
5888
+ // correct regardless of which sessions hit it on subsequent turns.
5889
+ if (!fromCache && resolvedDecision?.outcome === "applied" && cache) {
5890
+ if (cacheEnabled && cacheKey !== undefined) {
5891
+ try {
5892
+ cache.set(cacheKey, {
5893
+ excludedToolNames: rawExclusions,
5894
+ selectedServerIds,
5895
+ });
5896
+ }
5897
+ catch {
5898
+ // Cache write failure is non-fatal.
5899
+ }
5900
+ }
5901
+ // Record selected servers for stickiness on subsequent turns. This must
5902
+ // come after getStickyServerIds so the window count is not consumed on
5903
+ // the same turn it is set (the off-by-one fix above).
5904
+ if (stickinessEnabled && sessionId) {
5905
+ try {
5906
+ cache.recordSelection(sessionId, selectedServerIds);
5907
+ }
5908
+ catch {
5909
+ // Stickiness failure is non-fatal.
5910
+ }
5911
+ }
5912
+ }
5765
5913
  if (routedExcludeTools.length > 0) {
5766
5914
  options.excludeTools = [
5767
5915
  ...(options.excludeTools ?? []),
@@ -155,6 +155,7 @@ export type LiveKitVoiceAgentConfig = {
155
155
  conversationIdPrefix?: string;
156
156
  /** Optional user id recorded alongside memory. */
157
157
  userId?: string;
158
+ greeting?: string;
158
159
  /** Silero VAD tuning (stricter = ignores background noise). */
159
160
  vad?: LiveKitVadConfig;
160
161
  /** Turn-detection tuning (VAD vs STT endpointing, delays). */
@@ -367,3 +368,136 @@ export type LiveKitEventBridgeHandle = {
367
368
  /** Remove all listeners and stop publishing. Idempotent. */
368
369
  dispose: () => void;
369
370
  };
371
+ /**
372
+ * Credentials for LiveKit server-side REST calls (room create, agent dispatch).
373
+ * `url` accepts `ws(s)://` or `http(s)://`; helpers convert it to https.
374
+ */
375
+ export type LiveKitServerCredentials = {
376
+ url: string;
377
+ apiKey: string;
378
+ apiSecret: string;
379
+ };
380
+ /**
381
+ * Realtime voice configuration resolved from the environment.
382
+ *
383
+ * In speech-to-speech mode one realtime model (Gemini Live on Vertex) does STT,
384
+ * reasoning, TTS, and turn detection — so there is no separate STT/TTS/VAD/EOU
385
+ * config. `resolveRealtimeVoiceConfig` fills every field from `process.env`
386
+ * (with defaults); `RealtimeVoiceAgentConfig` lets a caller override any of them.
387
+ */
388
+ export type RealtimeVoiceConfig = {
389
+ /** Vertex project id (from VERTEX_PROJECT / GOOGLE_AUTH_* / GOOGLE_CLOUD_PROJECT_ID). */
390
+ project: string | undefined;
391
+ /** Vertex location; native-audio Live is served on `global`, not regionally. */
392
+ location: string;
393
+ /** Realtime model id (e.g. "gemini-live-2.5-flash"). */
394
+ model: string;
395
+ /** Optional Gemini voice name; omit for the plugin default. */
396
+ voice: string | undefined;
397
+ /** Response modality: "AUDIO" (native S2S) or "TEXT" (half-cascade). */
398
+ responseModality: string;
399
+ /** System prompt / instructions for the agent. */
400
+ systemPrompt: string;
401
+ /** Opening line the agent speaks on connect ("" disables). */
402
+ greeting: string;
403
+ /** Whether to bridge Lighthouse MCP tools as Gemini function tools. */
404
+ toolsEnabled: boolean;
405
+ /** Full URL of the MCP server the tools are bridged from. */
406
+ mcpUrl: string;
407
+ /** Grace period after the caller leaves before the job shuts down (ms). */
408
+ emptyRoomGraceMs: number;
409
+ /** Deadline for a participant to join before the job shuts down (ms). */
410
+ joinDeadlineMs: number;
411
+ /** How long a HITL confirmation waits before being treated as a decline (ms). */
412
+ hitlTimeoutMs: number;
413
+ /** Interval for the RSS/heap metrics log (ms). */
414
+ metricsIntervalMs: number;
415
+ };
416
+ /** A single log record handed to a `RealtimeVoiceAgentConfig.onLog` sink. */
417
+ export type RealtimeVoiceLogEntry = {
418
+ level: "debug" | "info" | "warn" | "error";
419
+ message: string;
420
+ timestamp: number;
421
+ data?: unknown;
422
+ };
423
+ export type RealtimeVoiceLogContext = {
424
+ room: string;
425
+ };
426
+ /**
427
+ * Options for `defineRealtimeVoiceAgent`. Every field is optional: omitted
428
+ * values fall back to `resolveRealtimeVoiceConfig()` (i.e. the environment), so
429
+ * a caller can use `defineRealtimeVoiceAgent()` with no arguments and configure
430
+ * everything via env.
431
+ */
432
+ export type RealtimeVoiceAgentConfig = {
433
+ project?: string;
434
+ location?: string;
435
+ model?: string;
436
+ voice?: string;
437
+ responseModality?: string;
438
+ systemPrompt?: string;
439
+ greeting?: string;
440
+ /** MCP tool bridging overrides. */
441
+ tools?: {
442
+ enabled?: boolean;
443
+ mcpUrl?: string;
444
+ };
445
+ /** Data-channel topic for outbound events (default "ai-events"). */
446
+ eventsTopic?: string;
447
+ /** Data-channel topic for inbound control messages (default "ai-control"). */
448
+ controlTopic?: string;
449
+ /**
450
+ * Optional sink for the agent's own logs. When set, the realtime agent wires
451
+ * NeuroLink's logger to this callback for the duration of the call, so a host
452
+ * can forward worker logs into its logging pipeline. Each record is tagged
453
+ * with per-call context (the room name). Subject to the logger's level gate:
454
+ * without debug mode only `error` records are emitted (set `NEUROLINK_DEBUG`).
455
+ */
456
+ onLog?: (entry: RealtimeVoiceLogEntry, ctx: RealtimeVoiceLogContext) => void;
457
+ };
458
+ /** Auth token + base64 MCP execution context decoded from a room's metadata. */
459
+ export type LiveKitRoomCallContext = {
460
+ /** Lighthouse access JWT used as `x-auth-token` to the MCP server. */
461
+ authToken: string;
462
+ /** base64(JSON) MCP execution context used as `x-context` (or "" if absent). */
463
+ xContext: string;
464
+ };
465
+ /** Publishes a single voice event envelope onto the room data channel. */
466
+ export type RealtimeEventPublisher = (type: LiveKitVoiceEventType, data: Record<string, unknown>) => void;
467
+ /** Requests a HITL confirmation and resolves to the user's decision. */
468
+ export type RealtimeConfirmationRequester = (toolName: string, args: Record<string, unknown>) => Promise<boolean>;
469
+ /** Handle returned by `attachRealtimeEventBridge`. */
470
+ export type RealtimeEventBridgeHandle = {
471
+ /** Publish an outbound event to the browser (data packet or text stream). */
472
+ publishEvent: RealtimeEventPublisher;
473
+ /** Open a HITL prompt and await the browser's decision (timeout = decline). */
474
+ requestConfirmation: RealtimeConfirmationRequester;
475
+ /** Remove the control-channel listener and clear pending confirmations. */
476
+ dispose: () => void;
477
+ };
478
+ /** Inputs to `attachRealtimeEventBridge`. */
479
+ export type RealtimeEventBridgeParams = {
480
+ /** The LiveKit room for this call (from the job context). */
481
+ room: LiveKitBridgeRoom;
482
+ /** HITL confirmation timeout in ms before a request is auto-declined. */
483
+ hitlTimeoutMs?: number;
484
+ /** Outbound events topic (default "ai-events"). */
485
+ eventsTopic?: string;
486
+ /** Inbound control topic (default "ai-control"). */
487
+ controlTopic?: string;
488
+ /** Payloads larger than this are sent via the chunked text stream (default 12000). */
489
+ maxInlineBytes?: number;
490
+ };
491
+ /** Inputs to `buildRealtimeMcpTools`. */
492
+ export type BuildRealtimeMcpToolsParams = {
493
+ /** Full URL of the MCP server (e.g. ".../ai/mcp/v2"). */
494
+ mcpUrl: string;
495
+ /** Lighthouse access JWT forwarded as `x-auth-token`. */
496
+ authToken: string;
497
+ /** base64(JSON) execution context forwarded as `x-context`. */
498
+ xContext: string;
499
+ /** Publishes tool start/result events to the browser. */
500
+ publishEvent: RealtimeEventPublisher;
501
+ /** Opens a HITL confirmation for destructive tools and awaits the decision. */
502
+ requestConfirmation: RealtimeConfirmationRequester;
503
+ };
@@ -64,6 +64,30 @@ export type ToolRoutingConfig = {
64
64
  * always appended by the SDK regardless of this value.
65
65
  */
66
66
  routerPromptPrefix?: string;
67
+ /**
68
+ * LRU+TTL cache for routing decisions. When enabled, identical routing
69
+ * queries within the TTL window skip the router LLM entirely and reuse
70
+ * the cached exclusion list.
71
+ */
72
+ cache?: {
73
+ /** Whether the cache is active. Default: false. */
74
+ enabled?: boolean;
75
+ /** Time-to-live in milliseconds for each cached entry. Default: 60000. */
76
+ ttlMs?: number;
77
+ /** Maximum number of entries in the LRU cache. Default: 256. */
78
+ maxEntries?: number;
79
+ };
80
+ /**
81
+ * Session stickiness: once the router picks a set of servers for a session,
82
+ * those servers are kept warm (not excluded) for the next N turns to prevent
83
+ * flapping.
84
+ */
85
+ stickiness?: {
86
+ /** Whether session stickiness is active. Default: false. */
87
+ enabled?: boolean;
88
+ /** Number of turns for which a previously-selected server stays warm. Default: 3. */
89
+ turns?: number;
90
+ };
67
91
  };
68
92
  /** Catalog entry pairing a server descriptor with its registered tool names. */
69
93
  export type ToolRoutingCatalogEntry = {
@@ -72,6 +96,63 @@ export type ToolRoutingCatalogEntry = {
72
96
  /** Registered tool names for this server, i.e. `${serverId}_${toolName}`. */
73
97
  toolNames: string[];
74
98
  };
99
+ /** Internal cache entry for `ToolRoutingCache`. */
100
+ export type ToolRoutingCacheEntry = {
101
+ excludedToolNames: string[];
102
+ selectedServerIds: string[];
103
+ /** Absolute expiry timestamp (from the injected `now()` clock). */
104
+ expiresAt: number;
105
+ /** LRU eviction order — lower = older. Bumped on each get/set. */
106
+ accessOrder: number;
107
+ };
108
+ /** Internal stickiness entry for `ToolRoutingCache`. */
109
+ export type ToolRoutingStickiness = {
110
+ serverIds: string[];
111
+ /** Turn counter — decremented on each routing turn, removed when it hits 0. */
112
+ turnsRemaining: number;
113
+ };
114
+ /** Constructor options for `ToolRoutingCache`. */
115
+ export type ToolRoutingCacheOptions = {
116
+ /** Time-to-live in milliseconds for each cached entry. Default: 60_000. */
117
+ ttlMs?: number;
118
+ /** Maximum number of entries kept in the LRU before eviction. Default: 256. */
119
+ maxEntries?: number;
120
+ /** Number of turns a selected server remains sticky per session. Default: 3. */
121
+ stickyTurns?: number;
122
+ /**
123
+ * Clock function for TTL calculations. Defaults to `Date.now`.
124
+ * Inject a deterministic function in tests to control time.
125
+ */
126
+ now?: () => number;
127
+ };
128
+ /**
129
+ * Outcome classifier for a single routing resolution. Used in
130
+ * `ToolRoutingDecision` and emitted as an OTel span attribute.
131
+ */
132
+ export type ToolRoutingOutcome = "applied" | "skipped-no-query" | "skipped-single-server" | "empty-pick" | "failed-open-parse" | "failed-open-timeout" | "failed-open-error" | "cache-hit";
133
+ /**
134
+ * Machine-readable summary of one routing resolution. Emitted via the
135
+ * `emitDecision` callback so the caller can attach it as OTel span attributes
136
+ * or record it in any other telemetry sink.
137
+ */
138
+ export type ToolRoutingDecision = {
139
+ /** How the routing turn concluded. */
140
+ outcome: ToolRoutingOutcome;
141
+ /** Server ids the router kept (selected as relevant). */
142
+ selectedServerIds: string[];
143
+ /** Server ids whose tools were excluded (router considered them irrelevant). */
144
+ excludedServerIds: string[];
145
+ /** Server ids the router returned that did not exist in the catalog. */
146
+ hallucinatedIds: string[];
147
+ /** Total number of individual tool names added to the exclusion list. */
148
+ excludedToolCount: number;
149
+ /** Number of servers that were offered to the router (always-include excluded). */
150
+ routableServerCount: number;
151
+ /** True when the result was served from cache, skipping the router LLM. */
152
+ cacheHit: boolean;
153
+ /** Wall-clock time spent in the routing resolution in milliseconds. */
154
+ durationMs: number;
155
+ };
75
156
  /** Parameters for `resolveToolRoutingExclusions()`. */
76
157
  export type ToolRoutingResolutionParams = {
77
158
  /** Full catalog; always-include servers are filtered out internally. */
@@ -88,4 +169,11 @@ export type ToolRoutingResolutionParams = {
88
169
  timeoutMs: number;
89
170
  /** Invokes the router LLM — `NeuroLink.generate` bound by the caller. */
90
171
  generateFn: (options: GenerateOptions) => Promise<GenerateResult>;
172
+ /**
173
+ * Optional callback invoked once per resolution with a structured summary of
174
+ * the routing decision. Called on every return path (applied, skipped,
175
+ * failed-open). Must never throw — any error inside is swallowed by
176
+ * the resolver.
177
+ */
178
+ emitDecision?: (decision: ToolRoutingDecision) => void;
91
179
  };
@@ -61,7 +61,7 @@ export function createVoiceBrain(config) {
61
61
  });
62
62
  for await (const chunk of result.stream) {
63
63
  if (signal?.aborted) {
64
- logger.debug("[LiveKitBrain] Turn aborted mid-stream; stopping");
64
+ logger.debug("voice.brain.turnAborted", { reason: "signal-aborted" });
65
65
  return;
66
66
  }
67
67
  const delta = extractTextDelta(chunk);
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * See docs/features/livekit-voice-agent.md.
9
9
  */
10
- import type { LiveKitServerConfig, LiveKitBrainDefaults } from "../../types/index.js";
10
+ import type { LiveKitServerConfig, LiveKitBrainDefaults, RealtimeVoiceConfig } from "../../types/index.js";
11
11
  /**
12
12
  * Resolve LiveKit server connection settings from the environment.
13
13
  *
@@ -23,6 +23,17 @@ export declare function resolveLiveKitServerConfig(): LiveKitServerConfig;
23
23
  * `VOICE_LLM_MODEL`.
24
24
  */
25
25
  export declare function resolveBrainDefaults(): LiveKitBrainDefaults;
26
+ /**
27
+ * Resolve the realtime (Gemini Live speech-to-speech) voice configuration from
28
+ * the environment.
29
+ *
30
+ * Every value has a safe default so a worker can run with no realtime-specific
31
+ * env at all. Vertex `project` is resolved from `VERTEX_PROJECT`, then the
32
+ * service-account project (`GOOGLE_AUTH_BREEZE_PROJECT_ID`), then
33
+ * `GOOGLE_CLOUD_PROJECT_ID`. The MCP URL is `VOICE_MCP_URL` if set, otherwise
34
+ * `${LIGHTHOUSE_URL}/ai/mcp/v2`.
35
+ */
36
+ export declare function resolveRealtimeVoiceConfig(): RealtimeVoiceConfig;
26
37
  /**
27
38
  * Resolve the semantic end-of-utterance (EOU) turn-detection settings.
28
39
  *
@@ -25,6 +25,22 @@ function readEnv(name, fallback) {
25
25
  }
26
26
  return fallback;
27
27
  }
28
+ function readOptionalEnv(name) {
29
+ const value = process.env[name];
30
+ return typeof value === "string" && value.trim().length > 0
31
+ ? value.trim()
32
+ : undefined;
33
+ }
34
+ function readNumberEnv(name, fallback) {
35
+ const raw = process.env[name];
36
+ if (typeof raw === "string" && raw.trim().length > 0) {
37
+ const parsed = Number(raw.trim());
38
+ if (Number.isFinite(parsed) && parsed > 0) {
39
+ return parsed;
40
+ }
41
+ }
42
+ return fallback;
43
+ }
28
44
  /**
29
45
  * Resolve LiveKit server connection settings from the environment.
30
46
  *
@@ -51,6 +67,44 @@ export function resolveBrainDefaults() {
51
67
  model: readEnv("VOICE_LLM_MODEL", DEFAULT_LLM_MODEL),
52
68
  };
53
69
  }
70
+ const DEFAULT_REALTIME_MODEL = "gemini-live-2.5-flash";
71
+ const DEFAULT_REALTIME_LOCATION = "global";
72
+ const DEFAULT_RESPONSE_MODALITY = "AUDIO";
73
+ const DEFAULT_SYSTEM_PROMPT = "You are a concise voice assistant for a merchant dashboard. Keep replies short and spoken.";
74
+ const DEFAULT_GREETING = "Briefly greet the merchant and ask how you can help with their store today.";
75
+ const DEFAULT_LIGHTHOUSE_URL = "http://localhost:5173";
76
+ const DEFAULT_MCP_PATH = "/ai/mcp/v2";
77
+ /**
78
+ * Resolve the realtime (Gemini Live speech-to-speech) voice configuration from
79
+ * the environment.
80
+ *
81
+ * Every value has a safe default so a worker can run with no realtime-specific
82
+ * env at all. Vertex `project` is resolved from `VERTEX_PROJECT`, then the
83
+ * service-account project (`GOOGLE_AUTH_BREEZE_PROJECT_ID`), then
84
+ * `GOOGLE_CLOUD_PROJECT_ID`. The MCP URL is `VOICE_MCP_URL` if set, otherwise
85
+ * `${LIGHTHOUSE_URL}/ai/mcp/v2`.
86
+ */
87
+ export function resolveRealtimeVoiceConfig() {
88
+ const lighthouseUrl = readEnv("LIGHTHOUSE_URL", DEFAULT_LIGHTHOUSE_URL);
89
+ const mcpUrl = readEnv("VOICE_MCP_URL", `${lighthouseUrl}${DEFAULT_MCP_PATH}`);
90
+ return {
91
+ project: readOptionalEnv("VERTEX_PROJECT") ??
92
+ readOptionalEnv("GOOGLE_AUTH_BREEZE_PROJECT_ID") ??
93
+ readOptionalEnv("GOOGLE_CLOUD_PROJECT_ID"),
94
+ location: readEnv("VERTEX_LOCATION", DEFAULT_REALTIME_LOCATION),
95
+ model: readEnv("VOICE_LIVE_MODEL", DEFAULT_REALTIME_MODEL),
96
+ voice: readOptionalEnv("VOICE_S2S_VOICE"),
97
+ responseModality: readEnv("VOICE_RESPONSE_MODALITY", DEFAULT_RESPONSE_MODALITY).toUpperCase(),
98
+ systemPrompt: readEnv("VOICE_SYSTEM_PROMPT", DEFAULT_SYSTEM_PROMPT),
99
+ greeting: readEnv("VOICE_GREETING", DEFAULT_GREETING),
100
+ toolsEnabled: readEnv("VOICE_S2S_TOOLS", "").toLowerCase() === "true",
101
+ mcpUrl,
102
+ emptyRoomGraceMs: readNumberEnv("VOICE_EMPTY_ROOM_GRACE_MS", 30_000),
103
+ joinDeadlineMs: readNumberEnv("VOICE_JOIN_DEADLINE_MS", 60_000),
104
+ hitlTimeoutMs: readNumberEnv("VOICE_HITL_TIMEOUT_MS", 45_000),
105
+ metricsIntervalMs: readNumberEnv("VOICE_METRICS_INTERVAL_MS", 10_000),
106
+ };
107
+ }
54
108
  const EOU_TRUTHY = new Set(["1", "true", "english", "en", "on", "yes"]);
55
109
  /**
56
110
  * Resolve the semantic end-of-utterance (EOU) turn-detection settings.