@dsh-enhanced/assistant-delivery 0.1.14 → 0.1.18

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/lib/service.js CHANGED
@@ -361,6 +361,82 @@ function currentPermissionEvents(agent) {
361
361
  function signalAborted(signal) {
362
362
  return signal?.aborted === true;
363
363
  }
364
+ const userQuestionTombstoneLimit = 1_024;
365
+ function isUserQuestionApiProxy(value) {
366
+ if (value === null || typeof value !== 'object')
367
+ return false;
368
+ const candidate = value;
369
+ return candidate.events !== null && typeof candidate.events === 'object'
370
+ && typeof candidate.events.mux === 'function'
371
+ && typeof candidate.respond === 'function';
372
+ }
373
+ function requestedUserQuestionFrame(value) {
374
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
375
+ return undefined;
376
+ const envelope = value;
377
+ if (typeof envelope.rpcId !== 'string' || envelope.rpcId.length === 0
378
+ || envelope.payload === null || typeof envelope.payload !== 'object'
379
+ || Array.isArray(envelope.payload))
380
+ return undefined;
381
+ const payload = envelope.payload;
382
+ if (payload.type !== 'question/requested' || typeof payload.sessionId !== 'string'
383
+ || payload.sessionId.length === 0 || !Array.isArray(payload.questions))
384
+ return undefined;
385
+ return {
386
+ rpcId: envelope.rpcId,
387
+ sessionId: payload.sessionId,
388
+ questions: Object.freeze([...payload.questions]),
389
+ };
390
+ }
391
+ function resolvedUserQuestionFrame(value) {
392
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
393
+ return undefined;
394
+ const envelope = value;
395
+ if (envelope.payload === null || typeof envelope.payload !== 'object' || Array.isArray(envelope.payload)) {
396
+ return undefined;
397
+ }
398
+ const payload = envelope.payload;
399
+ if (payload.type !== 'question/resolved' || typeof payload.questionRpcId !== 'string'
400
+ || payload.questionRpcId.length === 0
401
+ || (payload.outcome !== 'answered' && payload.outcome !== 'cancelled'))
402
+ return undefined;
403
+ return { questionRpcId: payload.questionRpcId };
404
+ }
405
+ function userQuestionRequestHash(sessionId, questions) {
406
+ try {
407
+ const encoded = JSON.stringify([sessionId, questions]);
408
+ return typeof encoded === 'string' ? createHash('sha256').update(encoded).digest('hex') : undefined;
409
+ }
410
+ catch {
411
+ return undefined;
412
+ }
413
+ }
414
+ function cancelledUserQuestionResponse(rpcId) {
415
+ return {
416
+ type: 'client-response',
417
+ rpcId,
418
+ result: {
419
+ ok: false,
420
+ error: {
421
+ code: 'cancelled',
422
+ message: 'the user cancelled ask_user_question',
423
+ details: {},
424
+ },
425
+ },
426
+ };
427
+ }
428
+ function sameUserQuestionAuthority(left, right) {
429
+ return right !== undefined
430
+ && right.adapter === left.adapter
431
+ && right.binding.id === left.binding.id
432
+ && right.binding.version === left.binding.version
433
+ && right.binding.generation === left.binding.generation
434
+ && right.binding.sessionId === left.binding.sessionId
435
+ && right.ownerId === left.ownerId
436
+ && right.ownerVersion === left.ownerVersion
437
+ && JSON.stringify(right.binding.conversation) === JSON.stringify(left.binding.conversation)
438
+ && JSON.stringify(right.binding.principal) === JSON.stringify(left.binding.principal);
439
+ }
364
440
  function sameToolApprovalAuthority(left, right) {
365
441
  return right !== undefined
366
442
  && right.adapter === left.adapter
@@ -444,6 +520,8 @@ export class AssistantDeliveryService extends Service {
444
520
  conversationTransitions = new Map();
445
521
  agentApprovalBindings = new WeakMap();
446
522
  toolApprovalControllers = new Set();
523
+ drainingUserQuestionAdapters = new WeakSet();
524
+ userQuestionBridge;
447
525
  modelSelectionWaiters = new Map();
448
526
  preferenceFeedbackSink;
449
527
  evaluationSink;
@@ -596,6 +674,15 @@ export class AssistantDeliveryService extends Service {
596
674
  });
597
675
  ctx.inject(['approval'], approvalCtx => approvalCtx.on('approval/request', (request, next) => this.requestToolApproval(approvalCtx, request, next)));
598
676
  ctx.inject(['tools'], toolsCtx => registerDeliveryTools(toolsCtx, this));
677
+ // ApiProxy owns the only `userQuestions` provider. Delivery merely bridges
678
+ // its server-request mux frames to an authenticated channel adapter and
679
+ // echoes a client response; it must never register a competing provider.
680
+ ctx.inject(['apiProxy'], apiCtx => {
681
+ const apiProxy = apiCtx.apiProxy;
682
+ if (!isUserQuestionApiProxy(apiProxy))
683
+ return;
684
+ return this.attachUserQuestionApiProxy(apiProxy);
685
+ });
599
686
  // Presentation writes are intentionally reverse-bound. Delivery creates
600
687
  // the publisher closure and hands it only to the exact live domain service;
601
688
  // a public service-slot caller can no longer forge a terminal projection.
@@ -630,6 +717,7 @@ export class AssistantDeliveryService extends Service {
630
717
  this.automationPresentationBinding?.dispose();
631
718
  this.evolutionPresentationBinding?.dispose();
632
719
  this.cancelModelSelectionWaiters();
720
+ this.stopUserQuestionBridge(new Error('assistant-delivery is stopping'));
633
721
  for (const controller of this.toolApprovalControllers) {
634
722
  controller.abort(new Error('assistant-delivery is stopping'));
635
723
  }
@@ -1131,7 +1219,318 @@ export class AssistantDeliveryService extends Service {
1131
1219
  }
1132
1220
  registerAdapter(adapter) {
1133
1221
  this.assertActive();
1134
- return this.registry.register(adapter);
1222
+ return this.registry.register(adapter).then(unregister => {
1223
+ this.drainingUserQuestionAdapters.delete(adapter);
1224
+ this.dispatchPendingUserQuestions();
1225
+ let registered = true;
1226
+ return async () => {
1227
+ if (!registered)
1228
+ return;
1229
+ registered = false;
1230
+ this.drainingUserQuestionAdapters.add(adapter);
1231
+ this.abortUserQuestionAttemptsForAdapter(adapter, new Error('delivery adapter is unregistering'));
1232
+ await unregister();
1233
+ this.dispatchPendingUserQuestions();
1234
+ };
1235
+ });
1236
+ }
1237
+ attachUserQuestionApiProxy(proxy) {
1238
+ this.stopUserQuestionBridge(new Error('ApiProxy question bridge replaced'));
1239
+ if (!this.active)
1240
+ return () => { };
1241
+ const bridge = {
1242
+ proxy,
1243
+ controller: new AbortController(),
1244
+ pending: new Map(),
1245
+ completed: new Set(),
1246
+ };
1247
+ this.userQuestionBridge = bridge;
1248
+ void this.consumeUserQuestionMux(bridge);
1249
+ let attached = true;
1250
+ return () => {
1251
+ if (!attached)
1252
+ return;
1253
+ attached = false;
1254
+ this.stopUserQuestionBridge(new Error('ApiProxy question bridge disposed'), bridge);
1255
+ };
1256
+ }
1257
+ stopUserQuestionBridge(reason, expected) {
1258
+ const bridge = expected ?? this.userQuestionBridge;
1259
+ if (bridge === undefined || this.userQuestionBridge !== bridge)
1260
+ return;
1261
+ this.userQuestionBridge = undefined;
1262
+ bridge.controller.abort(reason);
1263
+ for (const pending of bridge.pending.values()) {
1264
+ const attempt = pending.attempt;
1265
+ attempt?.detachBridgeAbort();
1266
+ attempt?.controller.abort(reason);
1267
+ delete pending.attempt;
1268
+ }
1269
+ bridge.pending.clear();
1270
+ bridge.completed.clear();
1271
+ }
1272
+ async consumeUserQuestionMux(bridge) {
1273
+ try {
1274
+ const stream = bridge.proxy.events.mux({
1275
+ rpcId: randomUUID(),
1276
+ payload: {},
1277
+ }, bridge.controller.signal);
1278
+ if (stream === null || typeof stream !== 'object'
1279
+ || typeof stream[Symbol.asyncIterator] !== 'function')
1280
+ return;
1281
+ for await (const frame of stream) {
1282
+ if (!this.active || this.userQuestionBridge !== bridge || bridge.controller.signal.aborted)
1283
+ return;
1284
+ const resolved = resolvedUserQuestionFrame(frame);
1285
+ if (resolved !== undefined) {
1286
+ this.resolvePendingUserQuestion(bridge, resolved.questionRpcId);
1287
+ continue;
1288
+ }
1289
+ const requested = requestedUserQuestionFrame(frame);
1290
+ if (requested !== undefined)
1291
+ this.requestUserQuestionFromMux(bridge, requested);
1292
+ }
1293
+ }
1294
+ catch {
1295
+ // ApiProxy is an in-process service. If its stream is torn down, its
1296
+ // Cordis injection disposer will replace this bridge; never leak a
1297
+ // rejected iterator into the Host lifecycle.
1298
+ }
1299
+ finally {
1300
+ if (this.userQuestionBridge === bridge && !bridge.controller.signal.aborted) {
1301
+ this.stopUserQuestionBridge(new Error('ApiProxy question mux ended'), bridge);
1302
+ }
1303
+ }
1304
+ }
1305
+ requestUserQuestionFromMux(bridge, frame) {
1306
+ if (bridge.completed.has(frame.rpcId))
1307
+ return;
1308
+ const requestHash = userQuestionRequestHash(frame.sessionId, frame.questions);
1309
+ if (requestHash === undefined)
1310
+ return;
1311
+ const existing = bridge.pending.get(frame.rpcId);
1312
+ if (existing !== undefined) {
1313
+ if (existing.sessionId !== frame.sessionId || existing.requestHash !== requestHash) {
1314
+ this.queueUserQuestionResponse(bridge, existing, cancelledUserQuestionResponse(existing.rpcId));
1315
+ }
1316
+ else {
1317
+ this.dispatchUserQuestion(bridge, existing);
1318
+ }
1319
+ return;
1320
+ }
1321
+ const pending = {
1322
+ rpcId: frame.rpcId,
1323
+ sessionId: frame.sessionId,
1324
+ questions: frame.questions,
1325
+ requestHash,
1326
+ state: 'waiting',
1327
+ responseAttempts: 0,
1328
+ };
1329
+ bridge.pending.set(pending.rpcId, pending);
1330
+ this.dispatchUserQuestion(bridge, pending);
1331
+ }
1332
+ resolvePendingUserQuestion(bridge, rpcId) {
1333
+ this.markUserQuestionCompleted(bridge, rpcId);
1334
+ const pending = bridge.pending.get(rpcId);
1335
+ if (pending === undefined)
1336
+ return;
1337
+ bridge.pending.delete(rpcId);
1338
+ const attempt = pending.attempt;
1339
+ attempt?.detachBridgeAbort();
1340
+ attempt?.controller.abort(new Error('ApiProxy question request resolved'));
1341
+ delete pending.attempt;
1342
+ }
1343
+ markUserQuestionCompleted(bridge, rpcId) {
1344
+ if (bridge.completed.size >= userQuestionTombstoneLimit) {
1345
+ const oldest = bridge.completed.values().next().value;
1346
+ if (oldest !== undefined)
1347
+ bridge.completed.delete(oldest);
1348
+ }
1349
+ bridge.completed.add(rpcId);
1350
+ }
1351
+ dispatchPendingUserQuestions() {
1352
+ const bridge = this.userQuestionBridge;
1353
+ if (!this.active || bridge === undefined || bridge.controller.signal.aborted)
1354
+ return;
1355
+ for (const pending of bridge.pending.values())
1356
+ this.dispatchUserQuestion(bridge, pending);
1357
+ }
1358
+ dispatchUserQuestion(bridge, pending) {
1359
+ if (!this.active || this.userQuestionBridge !== bridge || bridge.controller.signal.aborted
1360
+ || bridge.pending.get(pending.rpcId) !== pending || pending.state !== 'waiting')
1361
+ return;
1362
+ if (pending.response !== undefined) {
1363
+ this.sendUserQuestionResponse(bridge, pending);
1364
+ return;
1365
+ }
1366
+ const route = this.resolveUserQuestionRoute(pending.sessionId);
1367
+ if (route.state === 'deferred')
1368
+ return;
1369
+ if (route.state === 'invalid') {
1370
+ this.queueUserQuestionResponse(bridge, pending, cancelledUserQuestionResponse(pending.rpcId));
1371
+ return;
1372
+ }
1373
+ const controller = new AbortController();
1374
+ const abortForBridge = () => controller.abort(bridge.controller.signal.reason);
1375
+ if (bridge.controller.signal.aborted)
1376
+ abortForBridge();
1377
+ else
1378
+ bridge.controller.signal.addEventListener('abort', abortForBridge, { once: true });
1379
+ const attempt = {
1380
+ controller,
1381
+ authority: route.authority,
1382
+ detachBridgeAbort: () => bridge.controller.signal.removeEventListener('abort', abortForBridge),
1383
+ };
1384
+ pending.state = 'dispatching';
1385
+ pending.attempt = attempt;
1386
+ const input = Object.freeze({
1387
+ operationId: pending.rpcId,
1388
+ bindingId: route.authority.binding.id,
1389
+ bindingVersion: route.authority.binding.version,
1390
+ bindingGeneration: route.authority.binding.generation,
1391
+ sessionId: pending.sessionId,
1392
+ target: Object.freeze({
1393
+ conversation: Object.freeze({ ...route.authority.binding.conversation }),
1394
+ principal: Object.freeze({ ...route.authority.binding.principal }),
1395
+ }),
1396
+ questions: pending.questions,
1397
+ });
1398
+ void Promise.resolve()
1399
+ .then(() => route.authority.adapter.requestUserQuestion(input, controller.signal))
1400
+ .then(outcome => this.completeUserQuestionAttempt(bridge, pending, attempt, outcome), () => this.completeUserQuestionAttempt(bridge, pending, attempt, { outcome: 'unavailable' }));
1401
+ }
1402
+ completeUserQuestionAttempt(bridge, pending, attempt, outcome) {
1403
+ if (this.userQuestionBridge !== bridge || bridge.pending.get(pending.rpcId) !== pending
1404
+ || pending.attempt !== attempt)
1405
+ return;
1406
+ delete pending.attempt;
1407
+ attempt.detachBridgeAbort();
1408
+ if (attempt.controller.signal.aborted) {
1409
+ pending.state = 'waiting';
1410
+ this.dispatchUserQuestion(bridge, pending);
1411
+ return;
1412
+ }
1413
+ if (!this.active || bridge.controller.signal.aborted)
1414
+ return;
1415
+ const route = this.resolveUserQuestionRoute(pending.sessionId);
1416
+ if (route.state === 'deferred') {
1417
+ pending.state = 'waiting';
1418
+ return;
1419
+ }
1420
+ if (route.state === 'invalid' || !sameUserQuestionAuthority(attempt.authority, route.authority)) {
1421
+ this.queueUserQuestionResponse(bridge, pending, cancelledUserQuestionResponse(pending.rpcId));
1422
+ return;
1423
+ }
1424
+ if (outcome !== null && typeof outcome === 'object'
1425
+ && outcome.outcome === 'answered'
1426
+ && typeof outcome.answer === 'object'
1427
+ && outcome.answer !== null) {
1428
+ const answer = outcome.answer;
1429
+ this.queueUserQuestionResponse(bridge, pending, {
1430
+ type: 'client-response',
1431
+ rpcId: pending.rpcId,
1432
+ result: { ok: true, value: { sessionId: pending.sessionId, answer } },
1433
+ });
1434
+ return;
1435
+ }
1436
+ this.queueUserQuestionResponse(bridge, pending, cancelledUserQuestionResponse(pending.rpcId));
1437
+ }
1438
+ queueUserQuestionResponse(bridge, pending, response) {
1439
+ if (this.userQuestionBridge !== bridge || bridge.pending.get(pending.rpcId) !== pending)
1440
+ return;
1441
+ const attempt = pending.attempt;
1442
+ attempt?.detachBridgeAbort();
1443
+ attempt?.controller.abort(new Error('ApiProxy question response is being sent'));
1444
+ delete pending.attempt;
1445
+ pending.response = response;
1446
+ pending.responseAttempts = 0;
1447
+ pending.state = 'responding';
1448
+ this.sendUserQuestionResponse(bridge, pending);
1449
+ }
1450
+ async sendUserQuestionResponse(bridge, pending) {
1451
+ const response = pending.response;
1452
+ if (response === undefined || this.userQuestionBridge !== bridge
1453
+ || bridge.pending.get(pending.rpcId) !== pending || pending.state !== 'responding')
1454
+ return;
1455
+ let receipt;
1456
+ try {
1457
+ receipt = await bridge.proxy.respond(response);
1458
+ }
1459
+ catch {
1460
+ if (this.userQuestionBridge === bridge && bridge.pending.get(pending.rpcId) === pending) {
1461
+ pending.responseAttempts += 1;
1462
+ const retry = setTimeout(() => {
1463
+ void this.sendUserQuestionResponse(bridge, pending);
1464
+ }, Math.min(250 * 2 ** Math.min(pending.responseAttempts - 1, 5), 5_000));
1465
+ retry.unref();
1466
+ }
1467
+ return;
1468
+ }
1469
+ if (this.userQuestionBridge !== bridge || bridge.pending.get(pending.rpcId) !== pending)
1470
+ return;
1471
+ if (!receipt.accepted && receipt.reason === 'bad-response') {
1472
+ if (response.result.ok) {
1473
+ pending.response = cancelledUserQuestionResponse(pending.rpcId);
1474
+ pending.responseAttempts = 0;
1475
+ void this.sendUserQuestionResponse(bridge, pending);
1476
+ }
1477
+ else {
1478
+ delete pending.response;
1479
+ pending.state = 'waiting';
1480
+ }
1481
+ return;
1482
+ }
1483
+ bridge.pending.delete(pending.rpcId);
1484
+ this.markUserQuestionCompleted(bridge, pending.rpcId);
1485
+ }
1486
+ abortUserQuestionAttemptsForAdapter(adapter, reason) {
1487
+ const bridge = this.userQuestionBridge;
1488
+ if (bridge === undefined)
1489
+ return;
1490
+ for (const pending of bridge.pending.values()) {
1491
+ if (pending.attempt?.authority.adapter === adapter)
1492
+ pending.attempt.controller.abort(reason);
1493
+ }
1494
+ }
1495
+ resolveUserQuestionRoute(sessionId) {
1496
+ try {
1497
+ return this.resolveUserQuestionRouteFromStore(sessionId);
1498
+ }
1499
+ catch {
1500
+ // A transient local store failure is not proof that the owner or binding
1501
+ // was revoked. Keep the ApiProxy request pending for replay/re-registration.
1502
+ return { state: 'deferred' };
1503
+ }
1504
+ }
1505
+ resolveUserQuestionRouteFromStore(sessionId) {
1506
+ const binding = this.deliveryStore.getBindingBySession(sessionId);
1507
+ // The mux carries every Web session. Absence from Delivery is not a
1508
+ // revocation signal and must never let this optional bridge cancel a
1509
+ // question owned only by the Web UI.
1510
+ if (binding === undefined)
1511
+ return { state: 'deferred' };
1512
+ const active = this.deliveryStore.getActiveBinding(binding.conversation);
1513
+ const owner = this.deliveryStore.getPrincipal(binding.principal);
1514
+ if (binding?.status !== 'active' || binding.sessionId !== sessionId
1515
+ || active?.id !== binding.id || active.version !== binding.version
1516
+ || active.generation !== binding.generation || active.sessionId !== binding.sessionId
1517
+ || JSON.stringify(active.principal) !== JSON.stringify(binding.principal)
1518
+ || JSON.stringify(active.conversation) !== JSON.stringify(binding.conversation)
1519
+ || owner?.status !== 'active' || owner.role !== 'owner'
1520
+ || JSON.stringify(owner.principal) !== JSON.stringify(binding.principal)) {
1521
+ return { state: 'invalid' };
1522
+ }
1523
+ const adapter = this.registry.get(binding.conversation.channel, binding.conversation.account);
1524
+ if (adapter === undefined || this.drainingUserQuestionAdapters.has(adapter)
1525
+ || adapter.capabilities.userQuestions !== true || adapter.requestUserQuestion === undefined) {
1526
+ return { state: 'deferred' };
1527
+ }
1528
+ return { state: 'ready', authority: {
1529
+ adapter,
1530
+ binding,
1531
+ ownerId: owner.id,
1532
+ ownerVersion: owner.version,
1533
+ } };
1135
1534
  }
1136
1535
  currentPreferenceTurn(agent) {
1137
1536
  this.assertActive();