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