@vellumai/assistant 0.3.16 → 0.3.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/ARCHITECTURE.md +70 -13
- package/README.md +6 -0
- package/docs/architecture/http-token-refresh.md +23 -1
- package/package.json +1 -1
- package/src/__tests__/access-request-decision.test.ts +4 -7
- package/src/__tests__/channel-guardian.test.ts +3 -1
- package/src/__tests__/checker.test.ts +79 -48
- package/src/__tests__/config-watcher.test.ts +11 -13
- package/src/__tests__/conversation-pairing.test.ts +103 -3
- package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
- package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
- package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
- package/src/__tests__/guardian-action-store.test.ts +182 -0
- package/src/__tests__/guardian-dispatch.test.ts +120 -0
- package/src/__tests__/ipc-snapshot.test.ts +21 -0
- package/src/__tests__/non-member-access-request.test.ts +1 -2
- package/src/__tests__/notification-broadcaster.test.ts +115 -4
- package/src/__tests__/notification-decision-strategy.test.ts +2 -1
- package/src/__tests__/notification-deep-link.test.ts +44 -1
- package/src/__tests__/notification-guardian-path.test.ts +157 -0
- package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
- package/src/__tests__/slack-channel-config.test.ts +3 -3
- package/src/__tests__/trust-store.test.ts +21 -21
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
- package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
- package/src/__tests__/trusted-contact-verification.test.ts +9 -9
- package/src/__tests__/update-bulletin-state.test.ts +1 -1
- package/src/__tests__/update-bulletin.test.ts +66 -3
- package/src/__tests__/update-template-contract.test.ts +6 -11
- package/src/__tests__/voice-session-bridge.test.ts +109 -9
- package/src/calls/call-controller.ts +129 -8
- package/src/calls/guardian-action-sweep.ts +1 -1
- package/src/calls/guardian-dispatch.ts +8 -0
- package/src/calls/voice-session-bridge.ts +4 -2
- package/src/cli/core-commands.ts +41 -1
- package/src/config/templates/UPDATES.md +5 -6
- package/src/config/update-bulletin-format.ts +2 -0
- package/src/config/update-bulletin-state.ts +1 -1
- package/src/config/update-bulletin-template-path.ts +6 -0
- package/src/config/update-bulletin.ts +21 -6
- package/src/daemon/config-watcher.ts +3 -2
- package/src/daemon/daemon-control.ts +64 -10
- package/src/daemon/handlers/config-slack-channel.ts +1 -1
- package/src/daemon/handlers/identity.ts +45 -25
- package/src/daemon/handlers/sessions.ts +1 -1
- package/src/daemon/ipc-contract/sessions.ts +1 -1
- package/src/daemon/ipc-contract/workspace.ts +12 -1
- package/src/daemon/ipc-contract-inventory.json +1 -0
- package/src/daemon/lifecycle.ts +8 -0
- package/src/daemon/server.ts +25 -3
- package/src/daemon/session-process.ts +438 -184
- package/src/daemon/tls-certs.ts +17 -12
- package/src/daemon/tool-side-effects.ts +1 -1
- package/src/memory/channel-delivery-store.ts +18 -20
- package/src/memory/channel-guardian-store.ts +39 -42
- package/src/memory/conversation-crud.ts +2 -2
- package/src/memory/conversation-queries.ts +2 -2
- package/src/memory/conversation-store.ts +24 -25
- package/src/memory/db-init.ts +9 -1
- package/src/memory/fts-reconciler.ts +41 -26
- package/src/memory/guardian-action-store.ts +57 -7
- package/src/memory/guardian-verification.ts +1 -0
- package/src/memory/jobs-worker.ts +2 -2
- package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
- package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
- package/src/memory/migrations/index.ts +4 -2
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +6 -1
- package/src/memory/search/semantic.ts +3 -3
- package/src/notifications/README.md +158 -17
- package/src/notifications/broadcaster.ts +68 -50
- package/src/notifications/conversation-pairing.ts +96 -18
- package/src/notifications/decision-engine.ts +6 -3
- package/src/notifications/deliveries-store.ts +12 -0
- package/src/notifications/emit-signal.ts +1 -0
- package/src/notifications/thread-candidates.ts +60 -25
- package/src/notifications/types.ts +2 -1
- package/src/permissions/checker.ts +1 -16
- package/src/permissions/defaults.ts +14 -4
- package/src/runtime/guardian-action-followup-executor.ts +1 -1
- package/src/runtime/http-server.ts +11 -11
- package/src/runtime/routes/access-request-decision.ts +1 -1
- package/src/runtime/routes/debug-routes.ts +4 -4
- package/src/runtime/routes/guardian-approval-interception.ts +4 -4
- package/src/runtime/routes/inbound-message-handler.ts +6 -6
- package/src/runtime/routes/integration-routes.ts +2 -2
- package/src/tools/permission-checker.ts +1 -2
- package/src/tools/secret-detection-handler.ts +1 -1
- package/src/tools/system/voice-config.ts +1 -1
- package/src/version.ts +29 -2
|
@@ -15,21 +15,21 @@ import * as conversationStore from '../memory/conversation-store.js';
|
|
|
15
15
|
import { provenanceFromGuardianContext } from '../memory/conversation-store.js';
|
|
16
16
|
import {
|
|
17
17
|
finalizeFollowup,
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
getExpiredDeliveriesByConversation,
|
|
19
|
+
getFollowupDeliveriesByConversation,
|
|
20
20
|
getGuardianActionRequest,
|
|
21
|
-
|
|
21
|
+
getPendingDeliveriesByConversation,
|
|
22
22
|
progressFollowupState,
|
|
23
23
|
resolveGuardianActionRequest,
|
|
24
24
|
startFollowupFromExpiredRequest,
|
|
25
25
|
} from '../memory/guardian-action-store.js';
|
|
26
|
+
import { extractPreferences } from '../notifications/preference-extractor.js';
|
|
27
|
+
import { createPreference } from '../notifications/preferences-store.js';
|
|
28
|
+
import type { Message } from '../providers/types.js';
|
|
26
29
|
import { processGuardianFollowUpTurn } from '../runtime/guardian-action-conversation-turn.js';
|
|
27
30
|
import { executeFollowupAction } from '../runtime/guardian-action-followup-executor.js';
|
|
28
31
|
import { composeGuardianActionMessageGenerative } from '../runtime/guardian-action-message-composer.js';
|
|
29
32
|
import type { GuardianActionCopyGenerator, GuardianFollowUpConversationGenerator } from '../runtime/http-types.js';
|
|
30
|
-
import { extractPreferences } from '../notifications/preference-extractor.js';
|
|
31
|
-
import { createPreference } from '../notifications/preferences-store.js';
|
|
32
|
-
import type { Message } from '../providers/types.js';
|
|
33
33
|
import { getLogger } from '../util/logger.js';
|
|
34
34
|
import { resolveGuardianVerificationIntent } from './guardian-verification-intent.js';
|
|
35
35
|
import type { UsageStats } from './ipc-contract.js';
|
|
@@ -383,213 +383,467 @@ export async function processMessage(
|
|
|
383
383
|
session.currentPage = currentPage;
|
|
384
384
|
|
|
385
385
|
// ── Guardian action answer interception (mac channel) ──
|
|
386
|
-
// If this conversation has
|
|
386
|
+
// If this conversation has pending guardian action deliveries, treat the
|
|
387
387
|
// user message as the guardian's answer instead of running the agent loop.
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
388
|
+
// When multiple deliveries exist in the same reused thread, require a
|
|
389
|
+
// request-code prefix for disambiguation (matching the channel path pattern).
|
|
390
|
+
const pendingDeliveries = getPendingDeliveriesByConversation(session.conversationId);
|
|
391
|
+
if (pendingDeliveries.length > 0) {
|
|
392
|
+
// Before auto-selecting a lone pending delivery, check if expired or
|
|
393
|
+
// follow-up deliveries also exist in this conversation. When the total
|
|
394
|
+
// count across all states exceeds 1, require request-code disambiguation
|
|
395
|
+
// even if only one pending delivery exists — otherwise a reply prefixed
|
|
396
|
+
// with an expired/follow-up request code would be silently routed to
|
|
397
|
+
// the pending delivery instead of being correctly rejected or routed.
|
|
398
|
+
const crossStateExpired = getExpiredDeliveriesByConversation(session.conversationId);
|
|
399
|
+
const crossStateFollowup = getFollowupDeliveriesByConversation(session.conversationId);
|
|
400
|
+
const totalCrossStateCount = pendingDeliveries.length + crossStateExpired.length + crossStateFollowup.length;
|
|
401
|
+
let matchedDelivery = (pendingDeliveries.length === 1 && totalCrossStateCount === 1) ? pendingDeliveries[0] : null;
|
|
402
|
+
let answerText = content;
|
|
403
|
+
|
|
404
|
+
// Strip the request code prefix from the answer text when the single
|
|
405
|
+
// pending delivery is auto-matched (content may include a code prefix
|
|
406
|
+
// if the guardian uses it out of habit after a previous disambiguation round).
|
|
407
|
+
if (matchedDelivery) {
|
|
408
|
+
const req = getGuardianActionRequest(matchedDelivery.requestId);
|
|
409
|
+
if (req && content.toUpperCase().startsWith(req.requestCode)) {
|
|
410
|
+
answerText = content.slice(req.requestCode.length).trim();
|
|
411
|
+
}
|
|
412
|
+
}
|
|
402
413
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
414
|
+
// Multiple deliveries across any state: require request code prefix for disambiguation
|
|
415
|
+
if (!matchedDelivery && pendingDeliveries.length >= 1) {
|
|
416
|
+
for (const d of pendingDeliveries) {
|
|
417
|
+
const req = getGuardianActionRequest(d.requestId);
|
|
418
|
+
if (req && content.toUpperCase().startsWith(req.requestCode)) {
|
|
419
|
+
matchedDelivery = d;
|
|
420
|
+
answerText = content.slice(req.requestCode.length).trim();
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// If no pending delivery matched, check whether the code targets an
|
|
426
|
+
// expired or follow-up delivery. If so, skip the pending section entirely
|
|
427
|
+
// and let the message fall through to the expired/follow-up handlers below.
|
|
428
|
+
if (!matchedDelivery) {
|
|
429
|
+
let matchesOtherState = false;
|
|
430
|
+
for (const d of [...crossStateExpired, ...crossStateFollowup]) {
|
|
431
|
+
const req = getGuardianActionRequest(d.requestId);
|
|
432
|
+
if (req && content.toUpperCase().startsWith(req.requestCode)) {
|
|
433
|
+
matchesOtherState = true;
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (!matchesOtherState) {
|
|
439
|
+
const guardianIfCtx = session.getTurnInterfaceContext();
|
|
440
|
+
const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
|
|
441
|
+
const userMsg = createUserMessage(content, attachments);
|
|
442
|
+
const persisted = await conversationStore.addMessage(
|
|
443
|
+
session.conversationId,
|
|
444
|
+
'user',
|
|
445
|
+
JSON.stringify(userMsg.content),
|
|
446
|
+
guardianChannelMeta,
|
|
447
|
+
);
|
|
448
|
+
session.messages.push(userMsg);
|
|
449
|
+
|
|
450
|
+
// Include codes from all states so the guardian sees all options
|
|
451
|
+
const allDeliveries = [...pendingDeliveries, ...crossStateExpired, ...crossStateFollowup];
|
|
452
|
+
const codes = allDeliveries
|
|
453
|
+
.map((d) => { const req = getGuardianActionRequest(d.requestId); return req ? req.requestCode : null; })
|
|
454
|
+
.filter((code): code is string => typeof code === 'string' && code.length > 0);
|
|
455
|
+
const disambiguationText = `You have multiple pending guardian questions. Please prefix your reply with the reference code (${codes.join(', ')}) to indicate which question you are answering.`;
|
|
456
|
+
const disambiguationMsg = createAssistantMessage(disambiguationText);
|
|
457
|
+
await conversationStore.addMessage(
|
|
458
|
+
session.conversationId,
|
|
459
|
+
'assistant',
|
|
460
|
+
JSON.stringify(disambiguationMsg.content),
|
|
461
|
+
guardianChannelMeta,
|
|
462
|
+
);
|
|
463
|
+
session.messages.push(disambiguationMsg);
|
|
464
|
+
onEvent({ type: 'assistant_text_delta', text: disambiguationText });
|
|
465
|
+
onEvent({ type: 'message_complete', sessionId: session.conversationId });
|
|
466
|
+
return persisted.id;
|
|
467
|
+
}
|
|
468
|
+
// Code matched an expired/follow-up delivery — fall through to those handlers
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (matchedDelivery) {
|
|
473
|
+
const guardianRequest = getGuardianActionRequest(matchedDelivery.requestId);
|
|
474
|
+
if (guardianRequest && guardianRequest.status === 'pending') {
|
|
475
|
+
const guardianIfCtx = session.getTurnInterfaceContext();
|
|
476
|
+
const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
|
|
477
|
+
const userMsg = createUserMessage(content, attachments);
|
|
478
|
+
const persisted = await conversationStore.addMessage(
|
|
433
479
|
session.conversationId,
|
|
434
|
-
'
|
|
435
|
-
JSON.stringify(
|
|
480
|
+
'user',
|
|
481
|
+
JSON.stringify(userMsg.content),
|
|
436
482
|
guardianChannelMeta,
|
|
437
483
|
);
|
|
438
|
-
session.messages.push(
|
|
439
|
-
|
|
484
|
+
session.messages.push(userMsg);
|
|
485
|
+
|
|
486
|
+
// Attempt to deliver the answer to the call first. Only resolve
|
|
487
|
+
// the guardian action request if answerCall succeeds, so that a
|
|
488
|
+
// failed delivery leaves the request pending for retry from
|
|
489
|
+
// another channel.
|
|
490
|
+
const answerResult = await answerCall({ callSessionId: guardianRequest.callSessionId, answer: answerText });
|
|
491
|
+
|
|
492
|
+
if ('ok' in answerResult && answerResult.ok) {
|
|
493
|
+
const resolved = resolveGuardianActionRequest(guardianRequest.id, answerText, 'vellum');
|
|
494
|
+
const replyText = resolved
|
|
495
|
+
? 'Your answer has been relayed to the call.'
|
|
496
|
+
: await composeGuardianActionMessageGenerative({ scenario: 'guardian_stale_answered' }, {}, _guardianActionCopyGenerator);
|
|
497
|
+
const replyMsg = createAssistantMessage(replyText);
|
|
498
|
+
await conversationStore.addMessage(
|
|
499
|
+
session.conversationId,
|
|
500
|
+
'assistant',
|
|
501
|
+
JSON.stringify(replyMsg.content),
|
|
502
|
+
guardianChannelMeta,
|
|
503
|
+
);
|
|
504
|
+
session.messages.push(replyMsg);
|
|
505
|
+
onEvent({ type: 'assistant_text_delta', text: replyText });
|
|
506
|
+
} else {
|
|
507
|
+
const errorDetail = 'error' in answerResult ? answerResult.error : 'Unknown error';
|
|
508
|
+
log.warn({ callSessionId: guardianRequest.callSessionId, error: errorDetail }, 'answerCall failed for mac guardian answer');
|
|
509
|
+
const failureText = await composeGuardianActionMessageGenerative(
|
|
510
|
+
{ scenario: 'guardian_answer_delivery_failed' },
|
|
511
|
+
{},
|
|
512
|
+
_guardianActionCopyGenerator,
|
|
513
|
+
);
|
|
514
|
+
const failMsg = createAssistantMessage(failureText);
|
|
515
|
+
await conversationStore.addMessage(
|
|
516
|
+
session.conversationId,
|
|
517
|
+
'assistant',
|
|
518
|
+
JSON.stringify(failMsg.content),
|
|
519
|
+
guardianChannelMeta,
|
|
520
|
+
);
|
|
521
|
+
session.messages.push(failMsg);
|
|
522
|
+
onEvent({ type: 'assistant_text_delta', text: failureText });
|
|
523
|
+
}
|
|
524
|
+
onEvent({ type: 'message_complete', sessionId: session.conversationId });
|
|
525
|
+
return persisted.id;
|
|
440
526
|
}
|
|
441
|
-
onEvent({ type: 'message_complete', sessionId: session.conversationId });
|
|
442
|
-
return persisted.id;
|
|
443
527
|
}
|
|
444
528
|
}
|
|
445
529
|
|
|
446
530
|
// ── Expired guardian action late answer interception (mac channel) ──
|
|
447
531
|
// If no pending delivery was found, check for expired requests eligible
|
|
448
|
-
// for follow-up (status='expired', followup_state='none').
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
532
|
+
// for follow-up (status='expired', followup_state='none'). When multiple
|
|
533
|
+
// expired deliveries exist, require request-code prefix for disambiguation.
|
|
534
|
+
const expiredDeliveries = getExpiredDeliveriesByConversation(session.conversationId);
|
|
535
|
+
if (expiredDeliveries.length > 0) {
|
|
536
|
+
// Cross-state disambiguation: check total deliveries across all states
|
|
537
|
+
// (pending + expired + follow-up). When the total exceeds 1, require
|
|
538
|
+
// request-code disambiguation even for a lone expired delivery — otherwise
|
|
539
|
+
// a reply targeting a different state's delivery gets silently misrouted.
|
|
540
|
+
const expCrossStatePending = getPendingDeliveriesByConversation(session.conversationId);
|
|
541
|
+
const expCrossStateFollowup = getFollowupDeliveriesByConversation(session.conversationId);
|
|
542
|
+
const expTotalCrossStateCount = expiredDeliveries.length + expCrossStatePending.length + expCrossStateFollowup.length;
|
|
543
|
+
let matchedExpired = (expiredDeliveries.length === 1 && expTotalCrossStateCount === 1) ? expiredDeliveries[0] : null;
|
|
544
|
+
let expiredAnswerText = content;
|
|
545
|
+
|
|
546
|
+
// Strip the request code prefix from the answer text when the single
|
|
547
|
+
// expired delivery is auto-matched (content may include a code prefix
|
|
548
|
+
// if the pending section fell through via matchesOtherState).
|
|
549
|
+
if (matchedExpired) {
|
|
550
|
+
const req = getGuardianActionRequest(matchedExpired.requestId);
|
|
551
|
+
if (req && content.toUpperCase().startsWith(req.requestCode)) {
|
|
552
|
+
expiredAnswerText = content.slice(req.requestCode.length).trim();
|
|
553
|
+
}
|
|
554
|
+
}
|
|
463
555
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
556
|
+
// Multiple expired deliveries (or cross-state disambiguation needed):
|
|
557
|
+
// require request code prefix for disambiguation
|
|
558
|
+
if (!matchedExpired && expiredDeliveries.length >= 1) {
|
|
559
|
+
for (const d of expiredDeliveries) {
|
|
560
|
+
const req = getGuardianActionRequest(d.requestId);
|
|
561
|
+
if (req && content.toUpperCase().startsWith(req.requestCode)) {
|
|
562
|
+
matchedExpired = d;
|
|
563
|
+
expiredAnswerText = content.slice(req.requestCode.length).trim();
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (!matchedExpired) {
|
|
569
|
+
// Before disambiguating, check if the code matches a follow-up or
|
|
570
|
+
// pending delivery. If so, fall through to let those handlers process it.
|
|
571
|
+
let matchesFollowupState = false;
|
|
572
|
+
for (const d of [...expCrossStateFollowup, ...expCrossStatePending]) {
|
|
573
|
+
const req = getGuardianActionRequest(d.requestId);
|
|
574
|
+
if (req && content.toUpperCase().startsWith(req.requestCode)) {
|
|
575
|
+
matchesFollowupState = true;
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (!matchesFollowupState) {
|
|
581
|
+
const guardianIfCtx = session.getTurnInterfaceContext();
|
|
582
|
+
const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
|
|
583
|
+
const userMsg = createUserMessage(content, attachments);
|
|
584
|
+
const persisted = await conversationStore.addMessage(
|
|
585
|
+
session.conversationId,
|
|
586
|
+
'user',
|
|
587
|
+
JSON.stringify(userMsg.content),
|
|
588
|
+
guardianChannelMeta,
|
|
589
|
+
);
|
|
590
|
+
session.messages.push(userMsg);
|
|
591
|
+
|
|
592
|
+
// Include codes from all states so the guardian sees all options
|
|
593
|
+
const allExpiredDeliveries = [...expiredDeliveries, ...expCrossStatePending, ...expCrossStateFollowup];
|
|
594
|
+
const codes = allExpiredDeliveries
|
|
595
|
+
.map((d) => { const req = getGuardianActionRequest(d.requestId); return req ? req.requestCode : null; })
|
|
596
|
+
.filter((code): code is string => typeof code === 'string' && code.length > 0);
|
|
597
|
+
const disambiguationText = await composeGuardianActionMessageGenerative(
|
|
598
|
+
{ scenario: 'guardian_expired_disambiguation', requestCodes: codes },
|
|
599
|
+
{ requiredKeywords: codes },
|
|
600
|
+
_guardianActionCopyGenerator,
|
|
601
|
+
);
|
|
602
|
+
const disambiguationMsg = createAssistantMessage(disambiguationText);
|
|
603
|
+
await conversationStore.addMessage(
|
|
604
|
+
session.conversationId,
|
|
605
|
+
'assistant',
|
|
606
|
+
JSON.stringify(disambiguationMsg.content),
|
|
607
|
+
guardianChannelMeta,
|
|
608
|
+
);
|
|
609
|
+
session.messages.push(disambiguationMsg);
|
|
610
|
+
onEvent({ type: 'assistant_text_delta', text: disambiguationText });
|
|
611
|
+
onEvent({ type: 'message_complete', sessionId: session.conversationId });
|
|
612
|
+
return persisted.id;
|
|
613
|
+
}
|
|
614
|
+
// Code matched a follow-up or pending delivery — fall through to those handlers
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (matchedExpired) {
|
|
619
|
+
const expiredRequest = getGuardianActionRequest(matchedExpired.requestId);
|
|
620
|
+
if (expiredRequest && expiredRequest.status === 'expired' && expiredRequest.followupState === 'none') {
|
|
621
|
+
const guardianIfCtx = session.getTurnInterfaceContext();
|
|
622
|
+
const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
|
|
623
|
+
const userMsg = createUserMessage(content, attachments);
|
|
624
|
+
const persisted = await conversationStore.addMessage(
|
|
493
625
|
session.conversationId,
|
|
494
|
-
'
|
|
495
|
-
JSON.stringify(
|
|
626
|
+
'user',
|
|
627
|
+
JSON.stringify(userMsg.content),
|
|
496
628
|
guardianChannelMeta,
|
|
497
629
|
);
|
|
498
|
-
session.messages.push(
|
|
499
|
-
|
|
630
|
+
session.messages.push(userMsg);
|
|
631
|
+
|
|
632
|
+
const followupResult = startFollowupFromExpiredRequest(expiredRequest.id, expiredAnswerText);
|
|
633
|
+
if (followupResult) {
|
|
634
|
+
const followupText = await composeGuardianActionMessageGenerative(
|
|
635
|
+
{
|
|
636
|
+
scenario: 'guardian_late_answer_followup',
|
|
637
|
+
questionText: expiredRequest.questionText,
|
|
638
|
+
lateAnswerText: expiredAnswerText,
|
|
639
|
+
},
|
|
640
|
+
{},
|
|
641
|
+
_guardianActionCopyGenerator,
|
|
642
|
+
);
|
|
643
|
+
const replyMsg = createAssistantMessage(followupText);
|
|
644
|
+
await conversationStore.addMessage(
|
|
645
|
+
session.conversationId,
|
|
646
|
+
'assistant',
|
|
647
|
+
JSON.stringify(replyMsg.content),
|
|
648
|
+
guardianChannelMeta,
|
|
649
|
+
);
|
|
650
|
+
session.messages.push(replyMsg);
|
|
651
|
+
onEvent({ type: 'assistant_text_delta', text: followupText });
|
|
652
|
+
} else {
|
|
653
|
+
// Follow-up already started or conflict — send stale message
|
|
654
|
+
const staleText = await composeGuardianActionMessageGenerative(
|
|
655
|
+
{ scenario: 'guardian_stale_expired' },
|
|
656
|
+
{},
|
|
657
|
+
_guardianActionCopyGenerator,
|
|
658
|
+
);
|
|
659
|
+
const staleMsg = createAssistantMessage(staleText);
|
|
660
|
+
await conversationStore.addMessage(
|
|
661
|
+
session.conversationId,
|
|
662
|
+
'assistant',
|
|
663
|
+
JSON.stringify(staleMsg.content),
|
|
664
|
+
guardianChannelMeta,
|
|
665
|
+
);
|
|
666
|
+
session.messages.push(staleMsg);
|
|
667
|
+
onEvent({ type: 'assistant_text_delta', text: staleText });
|
|
668
|
+
}
|
|
669
|
+
onEvent({ type: 'message_complete', sessionId: session.conversationId });
|
|
670
|
+
return persisted.id;
|
|
500
671
|
}
|
|
501
|
-
onEvent({ type: 'message_complete', sessionId: session.conversationId });
|
|
502
|
-
return persisted.id;
|
|
503
672
|
}
|
|
504
673
|
}
|
|
505
674
|
|
|
506
675
|
// ── Guardian follow-up conversation interception (mac channel) ──
|
|
507
676
|
// When a request is in `awaiting_guardian_choice` state, the guardian has
|
|
508
677
|
// already been asked "call back or send a message?". Their next message
|
|
509
|
-
// is the reply — route it through the conversation engine.
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
guardianReply: content,
|
|
530
|
-
},
|
|
531
|
-
_guardianFollowUpGenerator,
|
|
532
|
-
);
|
|
533
|
-
|
|
534
|
-
// Apply the disposition to the follow-up state machine.
|
|
535
|
-
// Both progressFollowupState and finalizeFollowup are compare-and-set:
|
|
536
|
-
// they return null when the transition was not applied (e.g. a concurrent
|
|
537
|
-
// reply already advanced the state). In that case we notify the guardian
|
|
538
|
-
// that the request was already resolved and skip action execution.
|
|
539
|
-
let stateApplied = true;
|
|
540
|
-
if (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back') {
|
|
541
|
-
stateApplied = progressFollowupState(followupRequest.id, 'dispatching', turnResult.disposition) !== null;
|
|
542
|
-
} else if (turnResult.disposition === 'decline') {
|
|
543
|
-
stateApplied = finalizeFollowup(followupRequest.id, 'declined') !== null;
|
|
678
|
+
// is the reply — route it through the conversation engine. When multiple
|
|
679
|
+
// follow-up deliveries exist, require request-code prefix for disambiguation.
|
|
680
|
+
const followupDeliveries = getFollowupDeliveriesByConversation(session.conversationId);
|
|
681
|
+
if (followupDeliveries.length > 0) {
|
|
682
|
+
// Cross-state disambiguation: check total deliveries across all states
|
|
683
|
+
// (pending + expired + follow-up). When the total exceeds 1, require
|
|
684
|
+
// request-code disambiguation even for a lone follow-up delivery.
|
|
685
|
+
const fuCrossStatePending = getPendingDeliveriesByConversation(session.conversationId);
|
|
686
|
+
const fuCrossStateExpired = getExpiredDeliveriesByConversation(session.conversationId);
|
|
687
|
+
const fuTotalCrossStateCount = followupDeliveries.length + fuCrossStatePending.length + fuCrossStateExpired.length;
|
|
688
|
+
let matchedFollowup = (followupDeliveries.length === 1 && fuTotalCrossStateCount === 1) ? followupDeliveries[0] : null;
|
|
689
|
+
let followupReplyText = content;
|
|
690
|
+
|
|
691
|
+
// Strip the request code prefix from the reply text when the single
|
|
692
|
+
// follow-up delivery is auto-matched (content may include a code prefix
|
|
693
|
+
// if the pending section fell through via matchesOtherState).
|
|
694
|
+
if (matchedFollowup) {
|
|
695
|
+
const req = getGuardianActionRequest(matchedFollowup.requestId);
|
|
696
|
+
if (req && content.toUpperCase().startsWith(req.requestCode)) {
|
|
697
|
+
followupReplyText = content.slice(req.requestCode.length).trim();
|
|
544
698
|
}
|
|
545
|
-
|
|
699
|
+
}
|
|
546
700
|
|
|
547
|
-
|
|
548
|
-
|
|
701
|
+
// Multiple follow-up deliveries (or cross-state disambiguation needed):
|
|
702
|
+
// require request code prefix for disambiguation
|
|
703
|
+
if (!matchedFollowup && followupDeliveries.length >= 1) {
|
|
704
|
+
for (const d of followupDeliveries) {
|
|
705
|
+
const req = getGuardianActionRequest(d.requestId);
|
|
706
|
+
if (req && content.toUpperCase().startsWith(req.requestCode)) {
|
|
707
|
+
matchedFollowup = d;
|
|
708
|
+
followupReplyText = content.slice(req.requestCode.length).trim();
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
549
711
|
}
|
|
550
712
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
session.messages.push(replyMsg);
|
|
562
|
-
onEvent({ type: 'assistant_text_delta', text: replyText });
|
|
563
|
-
onEvent({ type: 'message_complete', sessionId: session.conversationId });
|
|
564
|
-
|
|
565
|
-
// Execute the action and send a completion/failure message (fire-and-forget).
|
|
566
|
-
// The initial reply above acknowledges the guardian's choice; the executor
|
|
567
|
-
// carries out the actual call_back or message_back and posts a second message.
|
|
568
|
-
if (stateApplied && (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back')) {
|
|
569
|
-
void (async () => {
|
|
570
|
-
try {
|
|
571
|
-
const execResult = await executeFollowupAction(
|
|
572
|
-
followupRequest.id,
|
|
573
|
-
turnResult.disposition as 'call_back' | 'message_back',
|
|
574
|
-
_guardianActionCopyGenerator,
|
|
575
|
-
);
|
|
576
|
-
const completionMsg = createAssistantMessage(execResult.guardianReplyText);
|
|
577
|
-
await conversationStore.addMessage(
|
|
578
|
-
session.conversationId,
|
|
579
|
-
'assistant',
|
|
580
|
-
JSON.stringify(completionMsg.content),
|
|
581
|
-
guardianChannelMeta,
|
|
582
|
-
);
|
|
583
|
-
session.messages.push(completionMsg);
|
|
584
|
-
onEvent({ type: 'assistant_text_delta', text: execResult.guardianReplyText });
|
|
585
|
-
onEvent({ type: 'message_complete', sessionId: session.conversationId });
|
|
586
|
-
} catch (execErr) {
|
|
587
|
-
log.error({ err: execErr, requestId: followupRequest.id }, 'Follow-up action execution or completion message failed');
|
|
713
|
+
if (!matchedFollowup) {
|
|
714
|
+
// Before disambiguating, check if the code matches an expired or
|
|
715
|
+
// pending delivery. If so, fall through to let the normal agent loop
|
|
716
|
+
// handle it (expired/pending blocks already ran and didn't match).
|
|
717
|
+
let matchesOtherFollowupState = false;
|
|
718
|
+
for (const d of [...fuCrossStateExpired, ...fuCrossStatePending]) {
|
|
719
|
+
const req = getGuardianActionRequest(d.requestId);
|
|
720
|
+
if (req && content.toUpperCase().startsWith(req.requestCode)) {
|
|
721
|
+
matchesOtherFollowupState = true;
|
|
722
|
+
break;
|
|
588
723
|
}
|
|
589
|
-
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (!matchesOtherFollowupState) {
|
|
727
|
+
const guardianIfCtx = session.getTurnInterfaceContext();
|
|
728
|
+
const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
|
|
729
|
+
const userMsg = createUserMessage(content, attachments);
|
|
730
|
+
const persisted = await conversationStore.addMessage(
|
|
731
|
+
session.conversationId,
|
|
732
|
+
'user',
|
|
733
|
+
JSON.stringify(userMsg.content),
|
|
734
|
+
guardianChannelMeta,
|
|
735
|
+
);
|
|
736
|
+
session.messages.push(userMsg);
|
|
737
|
+
|
|
738
|
+
// Include codes from all states so the guardian sees all options
|
|
739
|
+
const allFollowupDeliveries = [...followupDeliveries, ...fuCrossStatePending, ...fuCrossStateExpired];
|
|
740
|
+
const codes = allFollowupDeliveries
|
|
741
|
+
.map((d) => { const req = getGuardianActionRequest(d.requestId); return req ? req.requestCode : null; })
|
|
742
|
+
.filter((code): code is string => typeof code === 'string' && code.length > 0);
|
|
743
|
+
const disambiguationText = await composeGuardianActionMessageGenerative(
|
|
744
|
+
{ scenario: 'guardian_followup_disambiguation', requestCodes: codes },
|
|
745
|
+
{ requiredKeywords: codes },
|
|
746
|
+
_guardianActionCopyGenerator,
|
|
747
|
+
);
|
|
748
|
+
const disambiguationMsg = createAssistantMessage(disambiguationText);
|
|
749
|
+
await conversationStore.addMessage(
|
|
750
|
+
session.conversationId,
|
|
751
|
+
'assistant',
|
|
752
|
+
JSON.stringify(disambiguationMsg.content),
|
|
753
|
+
guardianChannelMeta,
|
|
754
|
+
);
|
|
755
|
+
session.messages.push(disambiguationMsg);
|
|
756
|
+
onEvent({ type: 'assistant_text_delta', text: disambiguationText });
|
|
757
|
+
onEvent({ type: 'message_complete', sessionId: session.conversationId });
|
|
758
|
+
return persisted.id;
|
|
759
|
+
}
|
|
760
|
+
// Code matched an expired or pending delivery — fall through to agent loop
|
|
590
761
|
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (matchedFollowup) {
|
|
765
|
+
const followupRequest = getGuardianActionRequest(matchedFollowup.requestId);
|
|
766
|
+
if (followupRequest && followupRequest.followupState === 'awaiting_guardian_choice') {
|
|
767
|
+
const guardianIfCtx = session.getTurnInterfaceContext();
|
|
768
|
+
const guardianChannelMeta = { userMessageChannel: 'vellum' as const, assistantMessageChannel: 'vellum' as const, userMessageInterface: guardianIfCtx?.userMessageInterface ?? 'vellum', assistantMessageInterface: guardianIfCtx?.assistantMessageInterface ?? 'vellum', provenanceActorRole: 'guardian' as const };
|
|
769
|
+
const userMsg = createUserMessage(content, attachments);
|
|
770
|
+
const persisted = await conversationStore.addMessage(
|
|
771
|
+
session.conversationId,
|
|
772
|
+
'user',
|
|
773
|
+
JSON.stringify(userMsg.content),
|
|
774
|
+
guardianChannelMeta,
|
|
775
|
+
);
|
|
776
|
+
session.messages.push(userMsg);
|
|
777
|
+
|
|
778
|
+
const turnResult = await processGuardianFollowUpTurn(
|
|
779
|
+
{
|
|
780
|
+
questionText: followupRequest.questionText,
|
|
781
|
+
lateAnswerText: followupRequest.lateAnswerText ?? '',
|
|
782
|
+
guardianReply: followupReplyText,
|
|
783
|
+
},
|
|
784
|
+
_guardianFollowUpGenerator,
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
// Apply the disposition to the follow-up state machine.
|
|
788
|
+
// Both progressFollowupState and finalizeFollowup are compare-and-set:
|
|
789
|
+
// they return null when the transition was not applied (e.g. a concurrent
|
|
790
|
+
// reply already advanced the state). In that case we notify the guardian
|
|
791
|
+
// that the request was already resolved and skip action execution.
|
|
792
|
+
let stateApplied = true;
|
|
793
|
+
if (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back') {
|
|
794
|
+
stateApplied = progressFollowupState(followupRequest.id, 'dispatching', turnResult.disposition) !== undefined;
|
|
795
|
+
} else if (turnResult.disposition === 'decline') {
|
|
796
|
+
stateApplied = finalizeFollowup(followupRequest.id, 'declined') !== undefined;
|
|
797
|
+
}
|
|
798
|
+
// keep_pending: no state change — guardian can reply again
|
|
799
|
+
|
|
800
|
+
if (!stateApplied) {
|
|
801
|
+
log.warn({ requestId: followupRequest.id, disposition: turnResult.disposition }, 'Follow-up state transition failed (already resolved)');
|
|
802
|
+
}
|
|
591
803
|
|
|
592
|
-
|
|
804
|
+
const replyText = stateApplied
|
|
805
|
+
? turnResult.replyText
|
|
806
|
+
: await composeGuardianActionMessageGenerative({ scenario: 'guardian_stale_followup' }, {}, _guardianActionCopyGenerator);
|
|
807
|
+
const replyMsg = createAssistantMessage(replyText);
|
|
808
|
+
await conversationStore.addMessage(
|
|
809
|
+
session.conversationId,
|
|
810
|
+
'assistant',
|
|
811
|
+
JSON.stringify(replyMsg.content),
|
|
812
|
+
guardianChannelMeta,
|
|
813
|
+
);
|
|
814
|
+
session.messages.push(replyMsg);
|
|
815
|
+
onEvent({ type: 'assistant_text_delta', text: replyText });
|
|
816
|
+
onEvent({ type: 'message_complete', sessionId: session.conversationId });
|
|
817
|
+
|
|
818
|
+
// Execute the action and send a completion/failure message (fire-and-forget).
|
|
819
|
+
// The initial reply above acknowledges the guardian's choice; the executor
|
|
820
|
+
// carries out the actual call_back or message_back and posts a second message.
|
|
821
|
+
if (stateApplied && (turnResult.disposition === 'call_back' || turnResult.disposition === 'message_back')) {
|
|
822
|
+
void (async () => {
|
|
823
|
+
try {
|
|
824
|
+
const execResult = await executeFollowupAction(
|
|
825
|
+
followupRequest.id,
|
|
826
|
+
turnResult.disposition as 'call_back' | 'message_back',
|
|
827
|
+
_guardianActionCopyGenerator,
|
|
828
|
+
);
|
|
829
|
+
const completionMsg = createAssistantMessage(execResult.guardianReplyText);
|
|
830
|
+
await conversationStore.addMessage(
|
|
831
|
+
session.conversationId,
|
|
832
|
+
'assistant',
|
|
833
|
+
JSON.stringify(completionMsg.content),
|
|
834
|
+
guardianChannelMeta,
|
|
835
|
+
);
|
|
836
|
+
session.messages.push(completionMsg);
|
|
837
|
+
onEvent({ type: 'assistant_text_delta', text: execResult.guardianReplyText });
|
|
838
|
+
onEvent({ type: 'message_complete', sessionId: session.conversationId });
|
|
839
|
+
} catch (execErr) {
|
|
840
|
+
log.error({ err: execErr, requestId: followupRequest.id }, 'Follow-up action execution or completion message failed');
|
|
841
|
+
}
|
|
842
|
+
})();
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
return persisted.id;
|
|
846
|
+
}
|
|
593
847
|
}
|
|
594
848
|
}
|
|
595
849
|
|