@memberjunction/server 6.2.0-edge.2 → 6.2.0-edge.3

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 (150) hide show
  1. package/README.md +30 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/config.d.ts +2189 -71
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +123 -3
  6. package/dist/config.js.map +1 -1
  7. package/dist/context.d.ts.map +1 -1
  8. package/dist/context.js +11 -23
  9. package/dist/context.js.map +1 -1
  10. package/dist/generated/graphql-schemas/__mj.d.ts +928 -0
  11. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  12. package/dist/generated/graphql-schemas/__mj.js +8482 -3649
  13. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  14. package/dist/generic/ResolverBase.d.ts +43 -3
  15. package/dist/generic/ResolverBase.d.ts.map +1 -1
  16. package/dist/generic/ResolverBase.js +142 -27
  17. package/dist/generic/ResolverBase.js.map +1 -1
  18. package/dist/generic/RunViewResolver.d.ts +4 -0
  19. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  20. package/dist/generic/RunViewResolver.js +33 -1
  21. package/dist/generic/RunViewResolver.js.map +1 -1
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +115 -49
  25. package/dist/index.js.map +1 -1
  26. package/dist/postgresqlCompat.d.ts +8 -0
  27. package/dist/postgresqlCompat.d.ts.map +1 -0
  28. package/dist/postgresqlCompat.js +19 -0
  29. package/dist/postgresqlCompat.js.map +1 -0
  30. package/dist/postgresqlPoolSettings.d.ts +108 -0
  31. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  32. package/dist/postgresqlPoolSettings.js +134 -0
  33. package/dist/postgresqlPoolSettings.js.map +1 -0
  34. package/dist/resolvers/AdhocQueryResolver.d.ts +23 -18
  35. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  36. package/dist/resolvers/AdhocQueryResolver.js +77 -167
  37. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  38. package/dist/resolvers/EntityRecordNameResolver.d.ts +7 -23
  39. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  40. package/dist/resolvers/EntityRecordNameResolver.js +9 -47
  41. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  42. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  43. package/dist/resolvers/GetDataResolver.js +9 -0
  44. package/dist/resolvers/GetDataResolver.js.map +1 -1
  45. package/dist/resolvers/HumanHandoffResolver.d.ts +107 -0
  46. package/dist/resolvers/HumanHandoffResolver.d.ts.map +1 -0
  47. package/dist/resolvers/HumanHandoffResolver.js +324 -0
  48. package/dist/resolvers/HumanHandoffResolver.js.map +1 -0
  49. package/dist/resolvers/MeetingResolver.d.ts +69 -0
  50. package/dist/resolvers/MeetingResolver.d.ts.map +1 -0
  51. package/dist/resolvers/MeetingResolver.js +381 -0
  52. package/dist/resolvers/MeetingResolver.js.map +1 -0
  53. package/dist/resolvers/RealtimeBridgeResolver.d.ts +34 -1
  54. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  55. package/dist/resolvers/RealtimeBridgeResolver.js +202 -7
  56. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  57. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +1 -83
  58. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  59. package/dist/resolvers/RealtimeClientSessionResolver.js +4 -206
  60. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  61. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  62. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  63. package/dist/resolvers/adhocTimeout.js +12 -0
  64. package/dist/resolvers/adhocTimeout.js.map +1 -0
  65. package/dist/resolvers/meetings/index.d.ts +5 -0
  66. package/dist/resolvers/meetings/index.d.ts.map +1 -0
  67. package/dist/resolvers/meetings/index.js +5 -0
  68. package/dist/resolvers/meetings/index.js.map +1 -0
  69. package/dist/resolvers/meetings/meetingHelpers.d.ts +17 -0
  70. package/dist/resolvers/meetings/meetingHelpers.d.ts.map +1 -0
  71. package/dist/resolvers/meetings/meetingHelpers.js +212 -0
  72. package/dist/resolvers/meetings/meetingHelpers.js.map +1 -0
  73. package/dist/resolvers/meetings/meetingMutations.d.ts +12 -0
  74. package/dist/resolvers/meetings/meetingMutations.d.ts.map +1 -0
  75. package/dist/resolvers/meetings/meetingMutations.js +418 -0
  76. package/dist/resolvers/meetings/meetingMutations.js.map +1 -0
  77. package/dist/resolvers/meetings/meetingQueries.d.ts +7 -0
  78. package/dist/resolvers/meetings/meetingQueries.d.ts.map +1 -0
  79. package/dist/resolvers/meetings/meetingQueries.js +113 -0
  80. package/dist/resolvers/meetings/meetingQueries.js.map +1 -0
  81. package/dist/resolvers/meetings/meetingTypes.d.ts +89 -0
  82. package/dist/resolvers/meetings/meetingTypes.d.ts.map +1 -0
  83. package/dist/resolvers/meetings/meetingTypes.js +345 -0
  84. package/dist/resolvers/meetings/meetingTypes.js.map +1 -0
  85. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  86. package/dist/rest/ViewOperationsHandler.js +8 -5
  87. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  88. package/dist/services/TaskGraphActionRunner.d.ts +8 -0
  89. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  90. package/dist/services/TaskGraphActionRunner.js +27 -1
  91. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  92. package/dist/services/WorkQueueHostService.d.ts +36 -0
  93. package/dist/services/WorkQueueHostService.d.ts.map +1 -0
  94. package/dist/services/WorkQueueHostService.js +97 -0
  95. package/dist/services/WorkQueueHostService.js.map +1 -0
  96. package/dist/services/workQueueConfig.d.ts +103 -0
  97. package/dist/services/workQueueConfig.d.ts.map +1 -0
  98. package/dist/services/workQueueConfig.js +48 -0
  99. package/dist/services/workQueueConfig.js.map +1 -0
  100. package/dist/types.d.ts +5 -0
  101. package/dist/types.d.ts.map +1 -1
  102. package/dist/types.js.map +1 -1
  103. package/package.json +95 -94
  104. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  105. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  106. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  107. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
  108. package/src/__tests__/HumanHandoffResolver.test.ts +233 -0
  109. package/src/__tests__/MeetingResolver.test.ts +374 -0
  110. package/src/__tests__/RealtimeBridgeResolver.test.ts +240 -2
  111. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +96 -0
  112. package/src/__tests__/ResolverBase.filterEscaping.test.ts +21 -2
  113. package/src/__tests__/ResolverBase.includeBinaryFields.test.ts +291 -0
  114. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +41 -0
  115. package/src/__tests__/WorkQueueHostService.test.ts +133 -0
  116. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  117. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  118. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  119. package/src/config.ts +130 -3
  120. package/src/context.ts +11 -24
  121. package/src/generated/graphql-schemas/__mj.ts +22138 -18770
  122. package/src/generic/ResolverBase.ts +171 -27
  123. package/src/generic/RunViewResolver.ts +33 -2
  124. package/src/index.ts +127 -50
  125. package/src/postgresqlCompat.ts +21 -0
  126. package/src/postgresqlPoolSettings.ts +213 -0
  127. package/src/resolvers/AdhocQueryResolver.ts +88 -188
  128. package/src/resolvers/EntityRecordNameResolver.ts +9 -51
  129. package/src/resolvers/GetDataResolver.ts +10 -0
  130. package/src/resolvers/HumanHandoffResolver.ts +304 -0
  131. package/src/resolvers/MeetingResolver.ts +438 -0
  132. package/src/resolvers/RealtimeBridgeResolver.ts +227 -13
  133. package/src/resolvers/RealtimeClientSessionResolver.ts +6 -252
  134. package/src/resolvers/adhocTimeout.ts +12 -0
  135. package/src/resolvers/meetings/index.ts +4 -0
  136. package/src/resolvers/meetings/meetingHelpers.ts +307 -0
  137. package/src/resolvers/meetings/meetingMutations.ts +535 -0
  138. package/src/resolvers/meetings/meetingQueries.ts +172 -0
  139. package/src/resolvers/meetings/meetingTypes.ts +238 -0
  140. package/src/rest/ViewOperationsHandler.ts +8 -5
  141. package/src/services/TaskGraphActionRunner.ts +29 -1
  142. package/src/services/WorkQueueHostService.ts +129 -0
  143. package/src/services/workQueueConfig.ts +52 -0
  144. package/src/types.ts +5 -0
  145. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -33
  146. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  147. package/dist/resolvers/adhoc-query-helpers.js +0 -50
  148. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  149. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  150. package/src/resolvers/adhoc-query-helpers.ts +0 -67
@@ -1,15 +1,24 @@
1
1
  import { Resolver, Mutation, Query, Arg, Ctx, ObjectType, InputType, Field } from 'type-graphql';
2
2
  import { randomUUID } from 'crypto';
3
- import { LogError, LogStatusEx, UserInfo, IMetadataProvider } from '@memberjunction/core';
4
- import { LiveKitTokenService, LiveKitAgentRoomCoordinator, LiveKitEgressService } from '@memberjunction/livekit-room-server';
3
+ import { LogError, LogStatusEx, UserInfo, IMetadataProvider, RunView } from '@memberjunction/core';
4
+ import { EscapeSQLString } from '@memberjunction/global';
5
+ import type { TurnAddressingMode } from '@memberjunction/ai-bridge-base';
6
+ import {
7
+ LiveKitTokenService,
8
+ LiveKitAgentRoomCoordinator,
9
+ LiveKitEgressService,
10
+ LiveKitUserIdentity,
11
+ RoomHandoffEngine,
12
+ } from '@memberjunction/livekit-room-server';
5
13
  import { AppContext } from '../types.js';
6
14
  import { ResolverBase } from '../generic/ResolverBase.js';
7
15
  import { GetReadWriteProvider } from '../util.js';
8
- import { CreateBridgeRealtimeSession, FinalizeBridgeCoAgentRuns, GetRealtimeModelVoices, CreateBridgeRoomTranscriptSink, RealtimeTurnModeratorDecision } from '@memberjunction/ai-agents';
16
+ import { CreateBridgeRealtimeSession, FinalizeBridgeCoAgentRuns, GetRealtimeModelVoices, CreateBridgeRoomTranscriptSink, RealtimeTurnModeratorDecision, GetBridgeRealtimeRuntime } from '@memberjunction/ai-agents';
9
17
  import { AIBridgeEngine } from '@memberjunction/ai-bridge-server';
10
18
  import { SessionManager } from '../agentSessions/SessionManager.js';
11
19
  import { NotificationEngine } from '@memberjunction/notifications';
12
20
  import { RegisterMeetingRecordingFile, CorrelateRecordingStart } from './meetingRecordingRegistration.js';
21
+ import { RoomAuthorizationService } from '@memberjunction/livekit-room-server';
13
22
 
14
23
  /**
15
24
  * Binds the agent realtime-session factory onto the LiveKit room coordinator's model-session creation seam.
@@ -21,6 +30,20 @@ import { RegisterMeetingRecordingFile, CorrelateRecordingStart } from './meeting
21
30
  */
22
31
  LiveKitAgentRoomCoordinator.Instance.SetSessionFactory(CreateBridgeRealtimeSession);
23
32
 
33
+ /**
34
+ * Binds the turn-taking tool handler seam (same module-load rationale as the factory above). A full-duplex model
35
+ * in a shared room calls `i_am_addressed` / `yield_turn`; the bridge engine owns what those mean, and this installs
36
+ * the engine's handler as the model session runtime's local tool handler (`@memberjunction/ai-agents`).
37
+ */
38
+ LiveKitAgentRoomCoordinator.Instance.SetTurnToolBinder((session, handler) => {
39
+ const runtime = GetBridgeRealtimeRuntime(session);
40
+ if (!runtime) {
41
+ LogError('[RealtimeBridge] the model session has no bridge runtime; i_am_addressed / yield_turn will not execute for it.');
42
+ return;
43
+ }
44
+ runtime.SetLocalToolHandler(handler);
45
+ });
46
+
24
47
  /**
25
48
  * Binds the co-agent run finalizer onto the bridge engine (same module-load rationale as the factory above).
26
49
  * Lets the engine finalize a session's dangling co-agent observability run when it reaps a bridge WITHOUT a
@@ -127,6 +150,35 @@ export class StartLiveKitAgentRoomSessionInput {
127
150
 
128
151
  @Field(() => String, { nullable: true })
129
152
  TurnMode?: string;
153
+
154
+ /**
155
+ * Start the agent able to bring someone into this room: a person (who is offered the conversation and may accept), a
156
+ * phone number, or another agent. Opt-in, and honoured only when this server has the handoff collaborators configured
157
+ * (the LiveKit SIP extension); otherwise the agent starts as it always did.
158
+ */
159
+ @Field(() => Boolean, { nullable: true })
160
+ EnableHandoff?: boolean;
161
+
162
+ /**
163
+ * How this agent decides it was addressed: `Auto` (default — the model's own judgement when it is full-duplex,
164
+ * name matching otherwise), `ModelSide`, or `Regex`.
165
+ */
166
+ @Field(() => String, { nullable: true })
167
+ TurnAddressing?: string;
168
+ }
169
+
170
+ /** The live turn-taking state of a room (floor holder, human speaking, hand-off, loop cap, recent events, seated agents). */
171
+ @ObjectType()
172
+ export class LiveKitRoomTurnStateResult {
173
+ @Field(() => Boolean)
174
+ Success: boolean;
175
+
176
+ @Field(() => String, { nullable: true })
177
+ ErrorMessage?: string;
178
+
179
+ /** JSON of the room's `RoomTurnSnapshot`; `null` when the room holds no agents. Parsed by the typed client. */
180
+ @Field(() => String, { nullable: true })
181
+ StateJSON?: string;
130
182
  }
131
183
 
132
184
  @ObjectType()
@@ -231,6 +283,11 @@ export class RealtimeBridgeResolver extends ResolverBase {
231
283
  if (!user) {
232
284
  return failure('Unable to determine current user.');
233
285
  }
286
+ const provider = GetReadWriteProvider(context.providers) as IMetadataProvider;
287
+ const auth = await RoomAuthorizationService.Instance.AuthorizeRoomAccess(input.RoomName, user, provider);
288
+ if (!auth.Authorized) {
289
+ return failure(auth.Reason ?? 'Unauthorized room access.');
290
+ }
234
291
  const tokenService = new LiveKitTokenService();
235
292
  const minted = await tokenService.MintClientToken(input.RoomName, this.participantIdentity(user), input.DisplayName ?? user.Name ?? user.Email);
236
293
  return { Success: true, ...minted };
@@ -264,9 +321,31 @@ export class RealtimeBridgeResolver extends ResolverBase {
264
321
  if (!user) {
265
322
  return failure('Unable to determine current user.');
266
323
  }
267
- const provider = GetReadWriteProvider(context.providers) as unknown as IMetadataProvider;
324
+ const provider = GetReadWriteProvider(context.providers) as IMetadataProvider;
268
325
  const roomName = input.RoomName?.trim() || `mj-${randomUUID()}`;
269
326
 
327
+ if (input.RoomName?.trim()) {
328
+ const auth = await RoomAuthorizationService.Instance.AuthorizeRoomAccess(roomName, user, provider);
329
+ if (!auth.Authorized) {
330
+ return failure(auth.Reason ?? 'Unauthorized room access.', roomName);
331
+ }
332
+ }
333
+
334
+ // Opt-in: an agent that can bring a person, a number or another agent into this room (the same room, so a visitor
335
+ // is escalated without leaving it). Falls through to the standard start when the server cannot do handoffs.
336
+ const handoffSessionBridgeID = await this.startWithHandoff(input, roomName, user, provider);
337
+ if (handoffSessionBridgeID) {
338
+ const handoffToken = await new LiveKitTokenService().MintClientToken(roomName, this.participantIdentity(user), user.Name ?? user.Email);
339
+ return {
340
+ Success: true,
341
+ SessionBridgeID: handoffSessionBridgeID,
342
+ RoomName: roomName,
343
+ ServerUrl: handoffToken.ServerUrl,
344
+ ClientToken: handoffToken.Token,
345
+ Identity: handoffToken.Identity,
346
+ };
347
+ }
348
+
270
349
  // Resolve the AIAgentSession the bridge will reference. The bridge row FK-references
271
350
  // AIAgentSession(ID), so we must use an EXISTING session — either one the caller supplied, or a
272
351
  // freshly-created one. Previously this minted a bare random UUID with no backing row, so the
@@ -293,6 +372,7 @@ export class RealtimeBridgeResolver extends ResolverBase {
293
372
  RealtimeModelID: input.RealtimeModelID,
294
373
  RealtimeVoice: input.RealtimeVoice,
295
374
  TurnMode: this.normalizeTurnMode(input.TurnMode),
375
+ TurnAddressing: this.normalizeTurnAddressing(input.TurnAddressing),
296
376
  ContextUser: user,
297
377
  MetadataProvider: provider,
298
378
  });
@@ -332,7 +412,15 @@ export class RealtimeBridgeResolver extends ResolverBase {
332
412
  if (!user) {
333
413
  return false;
334
414
  }
335
- const provider = GetReadWriteProvider(context.providers) as unknown as IMetadataProvider;
415
+ const provider = GetReadWriteProvider(context.providers) as IMetadataProvider;
416
+ const roomName = LiveKitAgentRoomCoordinator.Instance.GetRoomForBridge(sessionBridgeID);
417
+ if (roomName) {
418
+ const auth = await RoomAuthorizationService.Instance.AuthorizeRoomAccess(roomName, user, provider);
419
+ if (!auth.Authorized) {
420
+ LogError(`StopLiveKitAgentRoomSession unauthorized: user ${user.ID} cannot access room ${roomName} (${auth.Reason})`);
421
+ return false;
422
+ }
423
+ }
336
424
  return await LiveKitAgentRoomCoordinator.Instance.StopAgentRoomSession(sessionBridgeID, 'Explicit', user, provider);
337
425
  } catch (error) {
338
426
  LogError(`StopLiveKitAgentRoomSession failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -358,7 +446,12 @@ export class RealtimeBridgeResolver extends ResolverBase {
358
446
  if (!user) {
359
447
  return false;
360
448
  }
361
- const provider = GetReadWriteProvider(context.providers) as unknown as IMetadataProvider;
449
+ const provider = GetReadWriteProvider(context.providers) as IMetadataProvider;
450
+ const auth = await RoomAuthorizationService.Instance.AuthorizeRoomAccess(roomName, user, provider);
451
+ if (!auth.Authorized) {
452
+ LogError(`EndLiveKitRoom unauthorized: user ${user.ID} cannot access room ${roomName} (${auth.Reason})`);
453
+ return false;
454
+ }
362
455
  await LiveKitAgentRoomCoordinator.Instance.StopAllAgentsInRoom(roomName, 'Explicit', user, provider);
363
456
  return true;
364
457
  } catch (error) {
@@ -367,6 +460,37 @@ export class RealtimeBridgeResolver extends ResolverBase {
367
460
  }
368
461
  }
369
462
 
463
+ /**
464
+ * A room's live turn-taking state — who holds the floor, whether a person is speaking, any pending hand-off,
465
+ * the agent-to-agent loop-cap progress, the most recent floor/backchannel/yield events, and who is seated. The
466
+ * agent test bed polls it. Read-only. `StateJSON` is `null` when no agent is in the room.
467
+ *
468
+ * @param roomName The LiveKit room.
469
+ */
470
+ @Query(() => LiveKitRoomTurnStateResult)
471
+ async GetLiveKitRoomTurnState(
472
+ @Arg('roomName', () => String) roomName: string,
473
+ @Ctx() context: AppContext = {} as AppContext,
474
+ ): Promise<LiveKitRoomTurnStateResult> {
475
+ try {
476
+ const user = this.GetUserFromPayload(context.userPayload);
477
+ if (!user) {
478
+ return { Success: false, ErrorMessage: 'Unable to determine current user.' };
479
+ }
480
+ const provider = GetReadWriteProvider(context.providers) as IMetadataProvider;
481
+ const auth = await RoomAuthorizationService.Instance.AuthorizeRoomAccess(roomName, user, provider);
482
+ if (!auth.Authorized) {
483
+ return { Success: false, ErrorMessage: auth.Reason ?? 'Unauthorized room access.' };
484
+ }
485
+ const snapshot = LiveKitAgentRoomCoordinator.Instance.GetRoomTurnState(roomName);
486
+ return { Success: true, StateJSON: snapshot ? JSON.stringify(snapshot) : undefined };
487
+ } catch (error) {
488
+ const msg = error instanceof Error ? error.message : String(error);
489
+ LogError(`GetLiveKitRoomTurnState failed: ${msg}`);
490
+ return { Success: false, ErrorMessage: msg };
491
+ }
492
+ }
493
+
370
494
  /**
371
495
  * Lists active Realtime models with the voices each driver supports — the source for the dev model/voice
372
496
  * picker (gated client-side by the `Realtime: Advanced Session Controls` authorization). Read-only; returns
@@ -381,7 +505,7 @@ export class RealtimeBridgeResolver extends ResolverBase {
381
505
  if (!user) {
382
506
  return [];
383
507
  }
384
- const provider = GetReadWriteProvider(context.providers) as unknown as IMetadataProvider;
508
+ const provider = GetReadWriteProvider(context.providers) as IMetadataProvider;
385
509
  return await GetRealtimeModelVoices(user, provider);
386
510
  } catch (error) {
387
511
  LogError(`GetRealtimeModelVoices failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -411,7 +535,12 @@ export class RealtimeBridgeResolver extends ResolverBase {
411
535
  if (!user) {
412
536
  return false;
413
537
  }
414
- const provider = GetReadWriteProvider(context.providers) as unknown as IMetadataProvider;
538
+ const provider = GetReadWriteProvider(context.providers) as IMetadataProvider;
539
+ const auth = await RoomAuthorizationService.Instance.AuthorizeRoomAccess(roomName, user, provider);
540
+ if (!auth.Authorized) {
541
+ LogError(`InviteUsersToLiveKitRoom unauthorized: user ${user.ID} cannot access room ${roomName} (${auth.Reason})`);
542
+ return false;
543
+ }
415
544
  await NotificationEngine.Instance.Config(false, user, provider);
416
545
 
417
546
  const inviter = user.Name?.trim() || user.Email || 'Someone';
@@ -453,11 +582,15 @@ export class RealtimeBridgeResolver extends ResolverBase {
453
582
  if (!user) {
454
583
  return { Success: false, ErrorMessage: 'Unable to determine current user.', EgressID: '', Status: '' };
455
584
  }
585
+ const provider = GetReadWriteProvider(context.providers) as IMetadataProvider;
586
+ const auth = await RoomAuthorizationService.Instance.AuthorizeRoomAccess(input.RoomName, user, provider);
587
+ if (!auth.Authorized) {
588
+ return { Success: false, ErrorMessage: auth.Reason ?? 'Unauthorized room access.', EgressID: '', Status: '' };
589
+ }
456
590
  const info = await new LiveKitEgressService().StartRoomRecording({ RoomName: input.RoomName, Layout: input.Layout });
457
591
 
458
592
  // Best-effort: correlate the live recording with the room's Meeting-Room Conversation (if it exists
459
593
  // yet) by stamping its EgressID. Never fail the start on this — the stop-flow resolves/creates it.
460
- const provider = GetReadWriteProvider(context.providers) as unknown as IMetadataProvider;
461
594
  void CorrelateRecordingStart(input.RoomName, info.EgressID, user, provider);
462
595
 
463
596
  return { Success: true, EgressID: info.EgressID, Status: info.Status };
@@ -481,8 +614,43 @@ export class RealtimeBridgeResolver extends ResolverBase {
481
614
  if (!user) {
482
615
  return { Success: false, ErrorMessage: 'Unable to determine current user.', EgressID: egressID, Status: '' };
483
616
  }
484
- const info = await new LiveKitEgressService().StopRecording(egressID);
485
- const provider = GetReadWriteProvider(context.providers) as unknown as IMetadataProvider;
617
+ const provider = GetReadWriteProvider(context.providers) as IMetadataProvider;
618
+
619
+ // Map egress ID to room name: check LiveKit egress service first, fallback to Conversation if available.
620
+ const egressService = new LiveKitEgressService();
621
+ let roomName = await egressService.GetRoomForEgress(egressID);
622
+ if (!roomName && provider) {
623
+ try {
624
+ const rv = RunView.FromMetadataProvider(provider);
625
+ const convResult = await rv.RunView<{ ExternalID: string }>(
626
+ {
627
+ EntityName: 'MJ: Conversations',
628
+ ExtraFilter: `EgressID='${EscapeSQLString(egressID)}' AND (IsArchived IS NULL OR IsArchived=0)`,
629
+ Fields: ['ExternalID'],
630
+ OrderBy: '__mj_CreatedAt DESC',
631
+ MaxRows: 1,
632
+ ResultType: 'simple',
633
+ },
634
+ user,
635
+ );
636
+ if (convResult.Success && convResult.Results.length > 0 && convResult.Results[0].ExternalID) {
637
+ roomName = convResult.Results[0].ExternalID;
638
+ }
639
+ } catch (err) {
640
+ LogError(`StopLiveKitRecording: error looking up Conversation for egress ID '${egressID}'`, undefined, err);
641
+ }
642
+ }
643
+
644
+ if (roomName) {
645
+ const auth = await RoomAuthorizationService.Instance.AuthorizeRoomAccess(roomName, user, provider);
646
+ if (!auth.Authorized) {
647
+ return { Success: false, ErrorMessage: auth.Reason ?? 'Unauthorized room access.', EgressID: egressID, Status: '' };
648
+ }
649
+ } else {
650
+ return { Success: false, ErrorMessage: `Unable to determine room for egress ID '${egressID}'.`, EgressID: egressID, Status: '' };
651
+ }
652
+
653
+ const info = await egressService.StopRecording(egressID);
486
654
 
487
655
  // Register the completed egress MP4 as a Files row on the Meeting-Room Conversation. Best-effort:
488
656
  // any failure (e.g. storage provider not configured) leaves RecordingFileID unset but still
@@ -504,9 +672,55 @@ export class RealtimeBridgeResolver extends ResolverBase {
504
672
  }
505
673
  }
506
674
 
507
- /** Builds a stable, lowercased participant identity from the authenticated user. */
675
+ /**
676
+ * Starts the agent through the handoff-capable room starter when the caller asked for it and the server can do it.
677
+ * Returns the bridge id, or `undefined` to mean "use the standard start" (not requested, no starter, no agent named).
678
+ */
679
+ private async startWithHandoff(
680
+ input: StartLiveKitAgentRoomSessionInput,
681
+ roomName: string,
682
+ user: UserInfo,
683
+ provider: IMetadataProvider,
684
+ ): Promise<string | undefined> {
685
+ if (!input.EnableHandoff) {
686
+ return undefined;
687
+ }
688
+ const starter = RoomHandoffEngine.Instance.Deps.AgentStarter;
689
+ const agentID = input.TargetAgentID?.trim() || input.AgentID?.trim();
690
+ if (!starter || !agentID) {
691
+ LogError('StartLiveKitAgentRoomSession: handoff was requested but this server has no handoff-capable agent starter; starting the agent without it.');
692
+ return undefined;
693
+ }
694
+ const started = await starter({
695
+ RoomName: roomName,
696
+ AgentID: agentID,
697
+ AgentName: input.AgentName?.trim() ?? '',
698
+ CallerLabel: user.Name?.trim() || user.Email || 'Web visitor',
699
+ ContextUser: user,
700
+ Provider: provider,
701
+ RealtimeModelID: input.RealtimeModelID,
702
+ RealtimeVoice: input.RealtimeVoice,
703
+ });
704
+ return started.SessionBridgeID;
705
+ }
706
+
707
+ /** Builds a stable, lowercased participant identity from the authenticated user (shared with the handoff engine, which watches for it). */
508
708
  private participantIdentity(user: UserInfo): string {
509
- return `user-${user.ID}`.toLowerCase();
709
+ return LiveKitUserIdentity(user.ID);
710
+ }
711
+
712
+ /** Normalizes an addressing-mode string; unknown/absent leaves the coordinator's default (`Auto`). */
713
+ private normalizeTurnAddressing(mode?: string): TurnAddressingMode | undefined {
714
+ switch ((mode ?? '').trim().toLowerCase()) {
715
+ case 'auto':
716
+ return 'Auto';
717
+ case 'modelside':
718
+ return 'ModelSide';
719
+ case 'regex':
720
+ return 'Regex';
721
+ default:
722
+ return undefined;
723
+ }
510
724
  }
511
725
 
512
726
  /** Normalizes a turn-mode string to the bridge's accepted values. */
@@ -48,6 +48,8 @@ import {
48
48
  FindIgnoredRealtimeConfigKeys,
49
49
  ParseRealtimeTypeConfiguration,
50
50
  ResolveEffectiveRealtimeConfig,
51
+ ResolveRealtimeCoAgentID,
52
+ FilterAllowedAgentsByCanRun,
51
53
  RealtimeAllowedAgent,
52
54
  RealtimeDirectActionsConfig,
53
55
  REALTIME_ADVANCED_SESSION_CONTROLS_AUTHORIZATION,
@@ -56,7 +58,7 @@ import {
56
58
  writeRealtimeRecordingSegment,
57
59
  deleteRealtimeRecordingSegments,
58
60
  } from '@memberjunction/ai-agents';
59
- import { AgentExecutionProgressCallback, MJAIAgentEntityExtended, AppContextSnapshot } from '@memberjunction/ai-core-plus';
61
+ import { AgentExecutionProgressCallback, AppContextSnapshot } from '@memberjunction/ai-core-plus';
60
62
  import { ChatMessage, RealtimeToolDefinition } from '@memberjunction/ai';
61
63
  import { ResolverBase } from '../generic/ResolverBase.js';
62
64
  import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
@@ -71,32 +73,6 @@ import { ResolveWidgetGuestRunContext, ResolveScopedAnonymousRunUser } from '../
71
73
  */
72
74
  const SIGNIFICANT_PROGRESS_STEPS = ['prompt_execution', 'action_execution', 'subagent_execution', 'decision_processing'];
73
75
 
74
- /**
75
- * The seeded name of the internal orchestration agent that fronts a target agent in realtime
76
- * sessions (voice + interactive channels). This is the GLOBAL DEFAULT co-agent — the final step of
77
- * the co-agent resolution chain (see {@link RealtimeClientSessionResolver.resolveCoAgentID}).
78
- * Deployments can override it per agent (`AIAgent.DefaultCoAgentID`), per agent type (an
79
- * `AIAgentCoAgent` row with `TargetAgentTypeID` + `IsDefault`), or per call (the `coAgentId`
80
- * mutation argument) without
81
- * touching this seed.
82
- */
83
- const REALTIME_CO_AGENT_NAME = 'Realtime Co-Agent';
84
-
85
- /**
86
- * DEPRECATED legacy seed name of {@link REALTIME_CO_AGENT_NAME}, from before the agent's rename
87
- * from "Voice Co-Agent" to "Realtime Co-Agent". Deployments that have not re-synced the agent seed
88
- * still carry this name, so {@link RealtimeClientSessionResolver.resolveGlobalCoAgentID} falls
89
- * back to it (with a deprecation log) when no agent named {@link REALTIME_CO_AGENT_NAME} exists.
90
- */
91
- const LEGACY_REALTIME_CO_AGENT_NAME = 'Voice Co-Agent';
92
-
93
- /**
94
- * The seeded name of the Realtime agent TYPE. Every co-agent candidate (explicit or metadata
95
- * default) must be an Active agent of this type — a co-agent drives a streaming full-duplex
96
- * realtime session, which only `RealtimeAgentType`-driven agents support.
97
- */
98
- const REALTIME_AGENT_TYPE_NAME = 'Realtime';
99
-
100
76
  /** Entity name — centralised so the `MJ:`-prefix convention is applied in exactly one place. */
101
77
  const SESSION_ENTITY = 'MJ: AI Agent Sessions';
102
78
  const CO_AGENT_ENTITY = 'MJ: AI Agent Co Agents';
@@ -414,7 +390,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
414
390
  * session is created.
415
391
  * 2. Resolve the co-agent id via the metadata-driven resolution chain (runtime `coAgentId` →
416
392
  * agent's `DefaultCoAgentID` → type-level `AIAgentCoAgent` default row → global Realtime Co-Agent) —
417
- * see {@link RealtimeClientSessionResolver.resolveCoAgentID} for the full contract.
393
+ * see {@link ResolveRealtimeCoAgentID} for the full contract.
418
394
  * 3. Create the durable `AIAgentSession` (run by the co-agent), storing `targetAgentID` in its
419
395
  * config server-side — this is the authoritative target for all later relays.
420
396
  * 4. Mint the {@link import('@memberjunction/ai').ClientRealtimeSessionConfig} via the service.
@@ -463,7 +439,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
463
439
  ): Promise<StartRealtimeClientSessionResult> {
464
440
  const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
465
441
 
466
- const coAgentID = await this.resolveCoAgentID(targetAgentId, coAgentId, contextUser, provider);
442
+ const coAgentID = await ResolveRealtimeCoAgentID(targetAgentId, coAgentId, contextUser, provider);
467
443
  // PAIRING CONSTRAINTS: a co-agent with pairing rows is restricted to that target list
468
444
  // (with the IsDefault row standing in when no runtime target was supplied); zero rows =
469
445
  // universal, today's behavior untouched. Resolves the AUTHORITATIVE target id.
@@ -578,7 +554,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
578
554
  TargetAgentID: config.targetAgentID,
579
555
  // Multi-target (Move 4): the session's persisted allowed-agent union — a model-named
580
556
  // colleague in the call is validated against this; absent ⇒ single-target behavior.
581
- AllowedAgents: await this.filterAllowedAgentsByCanRun(config.allowedAgents, contextUser),
557
+ AllowedAgents: await FilterAllowedAgentsByCanRun(config.allowedAgents, contextUser),
582
558
  DirectActions: config.directActions,
583
559
  // Attribution follows the VISITOR even when `runUser` is elevated: the delegated run
584
560
  // row and its context-memory scope must stay the person's, not the system user's.
@@ -1250,39 +1226,6 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1250
1226
  }
1251
1227
  }
1252
1228
 
1253
- /**
1254
- * Narrows a session's colleague union (`allowedAgents`) to the agents the CALLER may run.
1255
- *
1256
- * {@link assertCanRunTarget} gates the LEAD target at session start, but the union it travels
1257
- * with was never gated at all — a model-named colleague resolves straight to a delegated run.
1258
- * That was survivable while the run carried the caller's own identity, because base-agent
1259
- * re-checks `CanRun` against `contextUser`. Once the run user is elevated for a scoped anonymous
1260
- * caller (issue #3371) that check sees the SYSTEM user, so this is the only remaining place the
1261
- * caller's own authority is applied to a colleague. It therefore runs for EVERY caller, elevated
1262
- * or not — the authorization identity must never depend on the elevation decision.
1263
- *
1264
- * `HasPermission` reads AIEngineBase's in-memory caches (no DB round trip) and already fails
1265
- * closed on error, so an unresolvable agent drops OUT of the union rather than becoming runnable.
1266
- * A filtered-out colleague is not an error: the delegation layer reports it as "not available in
1267
- * this session" and lists what remains, which is the same answer the model gets for a typo.
1268
- *
1269
- * @param allowedAgents The session's persisted colleague union (absent/empty ⇒ single-target).
1270
- * @param contextUser The ORIGINAL caller — never the elevated run user.
1271
- * @returns The subset the caller may run, preserving order.
1272
- */
1273
- private async filterAllowedAgentsByCanRun(
1274
- allowedAgents: RealtimeAllowedAgent[] | undefined,
1275
- contextUser: UserInfo,
1276
- ): Promise<RealtimeAllowedAgent[] | undefined> {
1277
- if (!allowedAgents || allowedAgents.length === 0) {
1278
- return allowedAgents;
1279
- }
1280
- const verdicts = await Promise.all(
1281
- allowedAgents.map((a) => AIAgentPermissionHelper.HasPermission(a.agentId, contextUser, 'run')),
1282
- );
1283
- return allowedAgents.filter((_, i) => verdicts[i]);
1284
- }
1285
-
1286
1229
  /**
1287
1230
  * Resolves the AUTHORITATIVE target agent id under the co-agent's PAIRING CONSTRAINTS
1288
1231
  * (`MJ: AI Agent Co Agents`, ordered by `Sequence`):
@@ -1525,195 +1468,6 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1525
1468
  }
1526
1469
  }
1527
1470
 
1528
- /**
1529
- * Resolves the co-agent (the Realtime-type agent that voices the target agent) for a new
1530
- * client-direct session via the metadata-driven **CO-AGENT RESOLUTION CHAIN** — first match
1531
- * wins, evaluated in precedence order:
1532
- *
1533
- * 1. **Runtime parameter** — the mutation's explicit `coAgentId`. A per-call override; an
1534
- * invalid candidate (unknown, not Active, or not of the Realtime agent type) **throws**
1535
- * (fail loud — the caller asked for something specific, mirroring `preferredModelId`).
1536
- * 2. **Per-agent persona** — the target agent's `AIAgent.DefaultCoAgentID`. An invalid
1537
- * reference logs a warning and **falls through** to the next step (stale metadata should
1538
- * degrade gracefully, never break calls).
1539
- * 3. **Per-type default** — an Active `AIAgentCoAgent` row of Type `'CoAgent'` whose
1540
- * `TargetAgentTypeID` is the target agent's type and `IsDefault = 1` (lowest `Sequence`
1541
- * wins a tie). Same tolerant warn-and-fall-through semantics as step 2.
1542
- * 4. **Global default** — the seeded {@link REALTIME_CO_AGENT_NAME} agent, looked up by name
1543
- * (with a deprecated fallback to {@link LEGACY_REALTIME_CO_AGENT_NAME}). Throws when absent
1544
- * entirely (the realtime feature is unconfigured in this deployment).
1545
- *
1546
- * Every candidate from steps 1–3 is validated by {@link findValidCoAgent}: it must exist in
1547
- * {@link AIEngine}'s cached agents, have Status `Active`, and be of the
1548
- * {@link REALTIME_AGENT_TYPE_NAME} agent type. Step 4 keeps its original name-lookup contract.
1549
- *
1550
- * @param targetAgentId The agent the co-agent will voice on behalf of (drives steps 2–3).
1551
- * @param explicitCoAgentId The runtime `coAgentId` mutation argument, when supplied (step 1).
1552
- * @returns The resolved co-agent's id (canonical casing from the metadata cache).
1553
- */
1554
- private async resolveCoAgentID(
1555
- targetAgentId: string | undefined,
1556
- explicitCoAgentId: string | undefined,
1557
- contextUser: UserInfo,
1558
- provider: IMetadataProvider,
1559
- ): Promise<string> {
1560
- await AIEngine.Instance.Config(false, contextUser, provider);
1561
-
1562
- // Step 1 — explicit runtime parameter: fail LOUD on any problem.
1563
- if (explicitCoAgentId) {
1564
- const { agent, problem } = this.findValidCoAgent(explicitCoAgentId);
1565
- if (!agent) {
1566
- throw new Error(`Invalid coAgentId '${explicitCoAgentId}': ${problem}`);
1567
- }
1568
- return agent.ID;
1569
- }
1570
-
1571
- const targetAgent = targetAgentId
1572
- ? (AIEngine.Instance.Agents ?? []).find(a => UUIDsEqual(a.ID, targetAgentId))
1573
- : undefined;
1574
-
1575
- // Step 2 — the target agent's own DefaultCoAgentID (per-agent persona): warn + fall through.
1576
- const fromAgent = this.resolveMetadataDefault(
1577
- targetAgent?.DefaultCoAgentID,
1578
- `agent '${targetAgent?.Name}' (DefaultCoAgentID)`,
1579
- );
1580
- if (fromAgent) {
1581
- return fromAgent;
1582
- }
1583
-
1584
- // Step 3 — the type-level AIAgentCoAgent default row (per-type default): warn + fall through.
1585
- const agentType = targetAgent?.TypeID
1586
- ? (AIEngine.Instance.AgentTypes ?? []).find(t => UUIDsEqual(t.ID, targetAgent.TypeID))
1587
- : undefined;
1588
- const typeDefaultCoAgentID = agentType
1589
- ? this.findTypeDefaultCoAgentID(agentType.ID, contextUser, provider)
1590
- : undefined;
1591
- const fromType = this.resolveMetadataDefault(
1592
- await typeDefaultCoAgentID,
1593
- `agent type '${agentType?.Name}' (AIAgentCoAgent type-default row)`,
1594
- );
1595
- if (fromType) {
1596
- return fromType;
1597
- }
1598
-
1599
- // Step 4 — global default: the seeded Realtime Co-Agent, by name (original behavior).
1600
- return this.resolveGlobalCoAgentID();
1601
- }
1602
-
1603
- /**
1604
- * Chain step 3 lookup: the TYPE-LEVEL default co-agent for an agent type — the Active
1605
- * `AIAgentCoAgent` row of Type `'CoAgent'` whose `TargetAgentTypeID` matches, with
1606
- * `IsDefault = 1` preferred and the lowest `Sequence` breaking ties (so a deployment can
1607
- * stage multiple type-level candidates deterministically). Reads {@link AIEngineBase}'s
1608
- * cached rows — no RunView. Tolerant: a failed cache read logs and returns `undefined`
1609
- * (the chain falls through to the global default).
1610
- */
1611
- private async findTypeDefaultCoAgentID(
1612
- agentTypeID: string,
1613
- contextUser: UserInfo,
1614
- provider: IMetadataProvider,
1615
- ): Promise<string | undefined> {
1616
- try {
1617
- const engine = await this.configuredAIEngineBase(contextUser, provider);
1618
- const candidates = (engine.AgentCoAgents ?? [])
1619
- .filter((r) =>
1620
- r.Type === 'CoAgent' &&
1621
- r.Status === 'Active' &&
1622
- r.TargetAgentTypeID != null &&
1623
- UUIDsEqual(r.TargetAgentTypeID, agentTypeID))
1624
- .sort((a, b) =>
1625
- (a.IsDefault === b.IsDefault ? 0 : a.IsDefault ? -1 : 1) ||
1626
- (a.Sequence ?? 0) - (b.Sequence ?? 0));
1627
- return candidates[0]?.CoAgentID;
1628
- } catch (error) {
1629
- LogError(
1630
- `StartRealtimeClientSession: ${CO_AGENT_ENTITY} cache read failed while resolving the type-level ` +
1631
- `default co-agent for agent type ${agentTypeID} (${(error as Error).message}) — falling through.`,
1632
- );
1633
- return undefined;
1634
- }
1635
- }
1636
-
1637
- /**
1638
- * Validates one metadata-level co-agent default (chain steps 2/3). Returns the resolved
1639
- * co-agent id when the reference is valid; logs a warning and returns `null` (caller falls
1640
- * through to the next chain step) when the reference is set but invalid — metadata drift must
1641
- * degrade, not break live calls. A `null`/absent reference returns `null` silently.
1642
- */
1643
- private resolveMetadataDefault(candidateId: string | null | undefined, source: string): string | null {
1644
- if (!candidateId) {
1645
- return null;
1646
- }
1647
- const { agent, problem } = this.findValidCoAgent(candidateId);
1648
- if (agent) {
1649
- return agent.ID;
1650
- }
1651
- LogError(
1652
- `StartRealtimeClientSession: ignoring co-agent default '${candidateId}' from ${source} — ${problem} ` +
1653
- 'Falling through to the next step of the co-agent resolution chain.',
1654
- );
1655
- return null;
1656
- }
1657
-
1658
- /**
1659
- * Validates a co-agent candidate against {@link AIEngine}'s cached metadata. A valid co-agent
1660
- * must (a) exist, (b) have Status `Active`, and (c) be of the {@link REALTIME_AGENT_TYPE_NAME}
1661
- * agent type. Returns the cached agent on success, or a human-readable `problem` on failure —
1662
- * the CALLER decides whether that's fatal (explicit runtime param) or tolerated (metadata default).
1663
- */
1664
- private findValidCoAgent(candidateId: string): { agent?: MJAIAgentEntityExtended; problem?: string } {
1665
- const agent = (AIEngine.Instance.Agents ?? []).find(a => UUIDsEqual(a.ID, candidateId));
1666
- if (!agent) {
1667
- return { problem: 'no agent with that ID exists.' };
1668
- }
1669
- if (agent.Status !== 'Active') {
1670
- return { problem: `agent '${agent.Name}' is not Active (Status: ${agent.Status}).` };
1671
- }
1672
- const realtimeType = (AIEngine.Instance.AgentTypes ?? []).find(
1673
- t => t.Name?.trim().toLowerCase() === REALTIME_AGENT_TYPE_NAME.toLowerCase(),
1674
- );
1675
- if (!realtimeType) {
1676
- return { problem: `the '${REALTIME_AGENT_TYPE_NAME}' agent type is not configured in this deployment.` };
1677
- }
1678
- if (!agent.TypeID || !UUIDsEqual(agent.TypeID, realtimeType.ID)) {
1679
- return { problem: `agent '${agent.Name}' is not of the '${REALTIME_AGENT_TYPE_NAME}' agent type.` };
1680
- }
1681
- return { agent };
1682
- }
1683
-
1684
- /**
1685
- * Resolves the seeded Realtime Co-Agent's id from {@link AIEngine}'s cached agents — the GLOBAL
1686
- * DEFAULT (step 4) of the co-agent resolution chain. The engine is already configured by
1687
- * {@link resolveCoAgentID}. Looks up the current {@link REALTIME_CO_AGENT_NAME} first, then
1688
- * falls back to the DEPRECATED {@link LEGACY_REALTIME_CO_AGENT_NAME} (pre-rename seed) with a
1689
- * deprecation log so un-resynced deployments keep working. Throws a clear error when neither
1690
- * name is present in metadata.
1691
- */
1692
- private resolveGlobalCoAgentID(): string {
1693
- const coAgent = this.findAgentByName(REALTIME_CO_AGENT_NAME);
1694
- if (coAgent) {
1695
- return coAgent.ID;
1696
- }
1697
- const legacy = this.findAgentByName(LEGACY_REALTIME_CO_AGENT_NAME);
1698
- if (legacy) {
1699
- LogStatus(
1700
- `StartRealtimeClientSession: resolved the global co-agent via its DEPRECATED legacy name ` +
1701
- `'${LEGACY_REALTIME_CO_AGENT_NAME}'. Re-sync the agent seed metadata to rename it to ` +
1702
- `'${REALTIME_CO_AGENT_NAME}'.`,
1703
- );
1704
- return legacy.ID;
1705
- }
1706
- throw new Error(
1707
- `The '${REALTIME_CO_AGENT_NAME}' agent is not configured; cannot start a realtime voice session.`,
1708
- );
1709
- }
1710
-
1711
- /** Case/whitespace-insensitive agent lookup by Name in {@link AIEngine}'s cached agents. */
1712
- private findAgentByName(name: string): MJAIAgentEntityExtended | undefined {
1713
- const wanted = name.toLowerCase();
1714
- return (AIEngine.Instance.Agents ?? []).find(a => a.Name?.trim().toLowerCase() === wanted);
1715
- }
1716
-
1717
1471
  /**
1718
1472
  * Mints the client session config for a just-created session. On failure, closes the session so
1719
1473
  * no half-open record leaks, then throws the service's error message.
@@ -0,0 +1,12 @@
1
+ /** The timeout an ad-hoc query gets when the caller names none. */
2
+ const DEFAULT_TIMEOUT_SECONDS = 30;
3
+
4
+ /**
5
+ * The timeout an ad-hoc query runs with: what the caller asked for (or the default), but never
6
+ * longer than the server's `requestTimeout`, so a caller cannot hold a read-only connection longer
7
+ * than any other request may. `requestTimeoutMs` of 0 or less means the server sets no limit.
8
+ */
9
+ export function ClampAdhocTimeoutSeconds(requested: number | undefined, requestTimeoutMs: number): number {
10
+ const seconds = requested != null && requested > 0 ? requested : DEFAULT_TIMEOUT_SECONDS;
11
+ return requestTimeoutMs > 0 ? Math.min(seconds, Math.max(1, Math.floor(requestTimeoutMs / 1000))) : seconds;
12
+ }