@vellumai/assistant 0.4.0 → 0.4.2

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.
@@ -4,6 +4,7 @@
4
4
  import { existsSync, readdirSync, statSync } from 'node:fs';
5
5
  import { join, relative } from 'node:path';
6
6
 
7
+ import { createAssistantMessage, createUserMessage } from '../../agent/message-types.js';
7
8
  import { CHANNEL_IDS, INTERFACE_IDS, parseChannelId, parseInterfaceId } from '../../channels/types.js';
8
9
  import { mergeToolResults,renderHistoryContent } from '../../daemon/handlers.js';
9
10
  import type { ServerMessage } from '../../daemon/ipc-protocol.js';
@@ -11,6 +12,8 @@ import * as attachmentsStore from '../../memory/attachments-store.js';
11
12
  import {
12
13
  createCanonicalGuardianRequest,
13
14
  generateCanonicalRequestCode,
15
+ listCanonicalGuardianRequests,
16
+ listPendingCanonicalGuardianRequestsByDestinationConversation,
14
17
  } from '../../memory/canonical-guardian-store.js';
15
18
  import {
16
19
  getConversationByKey,
@@ -21,8 +24,10 @@ import { getConfiguredProvider } from '../../providers/provider-send-message.js'
21
24
  import type { Provider } from '../../providers/types.js';
22
25
  import { getLogger } from '../../util/logger.js';
23
26
  import { buildAssistantEvent } from '../assistant-event.js';
27
+ import { routeGuardianReply } from '../guardian-reply-router.js';
24
28
  import { httpError } from '../http-errors.js';
25
29
  import type {
30
+ ApprovalConversationGenerator,
26
31
  MessageProcessor,
27
32
  NonBlockingMessageProcessor,
28
33
  RuntimeAttachmentMetadata,
@@ -35,6 +40,156 @@ const log = getLogger('conversation-routes');
35
40
 
36
41
  const SUGGESTION_CACHE_MAX = 100;
37
42
 
43
+ function collectLivePendingConfirmationRequestIds(
44
+ conversationId: string,
45
+ sourceChannel: string,
46
+ session: import('../../daemon/session.js').Session,
47
+ ): string[] {
48
+ const pendingInteractionRequestIds = pendingInteractions
49
+ .getByConversation(conversationId)
50
+ .filter(
51
+ (interaction) =>
52
+ interaction.kind === 'confirmation'
53
+ && interaction.session === session
54
+ && session.hasPendingConfirmation(interaction.requestId),
55
+ )
56
+ .map((interaction) => interaction.requestId);
57
+
58
+ // Query both by destination conversation (via deliveries table) and by
59
+ // source conversation (direct field). For desktop/HTTP sessions these
60
+ // often overlap, but the Set dedup below handles that.
61
+ const pendingCanonicalRequestIds = [
62
+ ...listPendingCanonicalGuardianRequestsByDestinationConversation(conversationId, sourceChannel)
63
+ .filter((request) => request.kind === 'tool_approval')
64
+ .map((request) => request.id),
65
+ ...listCanonicalGuardianRequests({
66
+ status: 'pending',
67
+ conversationId,
68
+ kind: 'tool_approval',
69
+ }).map((request) => request.id),
70
+ ].filter((requestId) => session.hasPendingConfirmation(requestId));
71
+
72
+ return Array.from(new Set([
73
+ ...pendingInteractionRequestIds,
74
+ ...pendingCanonicalRequestIds,
75
+ ]));
76
+ }
77
+
78
+ async function tryConsumeInlineApprovalReply(params: {
79
+ conversationId: string;
80
+ sourceChannel: string;
81
+ sourceInterface: string;
82
+ content: string;
83
+ attachments: Array<{
84
+ id: string;
85
+ filename: string;
86
+ mimeType: string;
87
+ data: string;
88
+ }>;
89
+ session: import('../../daemon/session.js').Session;
90
+ onEvent: (msg: ServerMessage) => void;
91
+ approvalConversationGenerator?: ApprovalConversationGenerator;
92
+ }): Promise<{ consumed: boolean; messageId?: string }> {
93
+ const {
94
+ conversationId,
95
+ sourceChannel,
96
+ sourceInterface,
97
+ content,
98
+ attachments,
99
+ session,
100
+ onEvent,
101
+ approvalConversationGenerator,
102
+ } = params;
103
+ const trimmedContent = content.trim();
104
+
105
+ // Try inline approval interception whenever a pending confirmation exists.
106
+ // We intentionally do not block on queue depth: after an auto-deny, users
107
+ // often retry with "approve"/"yes" while the queue is still draining, and
108
+ // requiring an empty queue can create a deny/retry cascade.
109
+ if (
110
+ !session.hasAnyPendingConfirmation()
111
+ || trimmedContent.length === 0
112
+ ) {
113
+ return { consumed: false };
114
+ }
115
+
116
+ const pendingRequestIds = collectLivePendingConfirmationRequestIds(conversationId, sourceChannel, session);
117
+ if (pendingRequestIds.length === 0) {
118
+ return { consumed: false };
119
+ }
120
+
121
+ const routerResult = await routeGuardianReply({
122
+ messageText: trimmedContent,
123
+ channel: sourceChannel,
124
+ actor: {
125
+ externalUserId: undefined,
126
+ channel: sourceChannel,
127
+ isTrusted: true,
128
+ },
129
+ conversationId,
130
+ pendingRequestIds,
131
+ approvalConversationGenerator,
132
+ });
133
+
134
+ if (!routerResult.consumed || routerResult.type === 'nl_keep_pending') {
135
+ return { consumed: false };
136
+ }
137
+
138
+ // Decision has been applied — transcript persistence is best-effort.
139
+ // If DB writes fail, we still return consumed: true so the approval text
140
+ // is not re-processed as a new user turn.
141
+ let messageId: string | undefined;
142
+ try {
143
+ const channelMeta = {
144
+ userMessageChannel: sourceChannel,
145
+ assistantMessageChannel: sourceChannel,
146
+ userMessageInterface: sourceInterface,
147
+ assistantMessageInterface: sourceInterface,
148
+ provenanceActorRole: 'guardian' as const,
149
+ };
150
+
151
+ const userMessage = createUserMessage(content, attachments);
152
+ const persistedUser = await conversationStore.addMessage(
153
+ conversationId,
154
+ 'user',
155
+ JSON.stringify(userMessage.content),
156
+ channelMeta,
157
+ );
158
+ messageId = persistedUser.id;
159
+
160
+ const replyText = (routerResult.replyText?.trim())
161
+ || (routerResult.decisionApplied ? 'Decision applied.' : 'Request already resolved.');
162
+ const assistantMessage = createAssistantMessage(replyText);
163
+ await conversationStore.addMessage(
164
+ conversationId,
165
+ 'assistant',
166
+ JSON.stringify(assistantMessage.content),
167
+ channelMeta,
168
+ );
169
+
170
+ // Avoid mutating in-memory history / emitting stream deltas while a run is active.
171
+ if (!session.isProcessing()) {
172
+ session.getMessages().push(userMessage, assistantMessage);
173
+ onEvent({ type: 'assistant_text_delta', text: replyText, sessionId: conversationId });
174
+ onEvent({ type: 'message_complete', sessionId: conversationId });
175
+ }
176
+ } catch (err) {
177
+ log.warn({ err, conversationId }, 'Failed to persist inline approval transcript entries');
178
+ }
179
+
180
+ return { consumed: true, messageId };
181
+ }
182
+
183
+ function resolveCanonicalRequestSourceType(sourceChannel: string | undefined): 'desktop' | 'channel' | 'voice' {
184
+ if (sourceChannel === 'voice') {
185
+ return 'voice';
186
+ }
187
+ if (sourceChannel === 'vellum') {
188
+ return 'desktop';
189
+ }
190
+ return 'channel';
191
+ }
192
+
38
193
  function getInterfaceFilesWithMtimes(interfacesDir: string | null): Array<{ path: string; mtimeMs: number }> {
39
194
  if (!interfacesDir || !existsSync(interfacesDir)) return [];
40
195
  const results: Array<{ path: string; mtimeMs: number }> = [];
@@ -178,12 +333,17 @@ function makeHubPublisher(
178
333
 
179
334
  // Create a canonical guardian request so IPC/HTTP handlers can find it
180
335
  // via applyCanonicalGuardianDecision.
336
+ const guardianContext = session.guardianContext;
337
+ const sourceChannel = guardianContext?.sourceChannel ?? 'vellum';
181
338
  createCanonicalGuardianRequest({
182
339
  id: msg.requestId,
183
340
  kind: 'tool_approval',
184
- sourceType: 'desktop',
185
- sourceChannel: 'vellum',
341
+ sourceType: resolveCanonicalRequestSourceType(sourceChannel),
342
+ sourceChannel,
186
343
  conversationId,
344
+ requesterExternalUserId: guardianContext?.requesterExternalUserId,
345
+ requesterChatId: guardianContext?.requesterChatId,
346
+ guardianExternalUserId: guardianContext?.guardianExternalUserId,
187
347
  toolName: msg.toolName,
188
348
  status: 'pending',
189
349
  requestCode: generateCanonicalRequestCode(),
@@ -221,6 +381,7 @@ export async function handleSendMessage(
221
381
  processMessage?: MessageProcessor;
222
382
  persistAndProcessMessage?: NonBlockingMessageProcessor;
223
383
  sendMessageDeps?: SendMessageDeps;
384
+ approvalConversationGenerator?: ApprovalConversationGenerator;
224
385
  },
225
386
  ): Promise<Response> {
226
387
  const body = await req.json() as {
@@ -290,6 +451,30 @@ export async function handleSendMessage(
290
451
  ? smDeps.resolveAttachments(attachmentIds)
291
452
  : [];
292
453
 
454
+ // Try to consume the message as an inline approval/rejection reply.
455
+ // On failure, degrade to the existing queue/auto-deny path rather than
456
+ // surfacing a 500 — mirrors the IPC handler's catch-and-fallback.
457
+ try {
458
+ const inlineReplyResult = await tryConsumeInlineApprovalReply({
459
+ conversationId: mapping.conversationId,
460
+ sourceChannel,
461
+ sourceInterface,
462
+ content: content ?? '',
463
+ attachments,
464
+ session,
465
+ onEvent,
466
+ approvalConversationGenerator: deps.approvalConversationGenerator,
467
+ });
468
+ if (inlineReplyResult.consumed) {
469
+ return Response.json(
470
+ { accepted: true, ...(inlineReplyResult.messageId ? { messageId: inlineReplyResult.messageId } : {}) },
471
+ { status: 202 },
472
+ );
473
+ }
474
+ } catch (err) {
475
+ log.warn({ err, conversationId: mapping.conversationId }, 'Inline approval consumption failed, falling through to normal send path');
476
+ }
477
+
293
478
  if (session.isProcessing()) {
294
479
  // If a tool confirmation is pending, auto-deny it so the agent
295
480
  // can finish the current turn and process this queued message.
@@ -1401,6 +1401,8 @@ function startPendingApprovalPromptWatcher(params: {
1401
1401
  sourceChannel: ChannelId;
1402
1402
  externalChatId: string;
1403
1403
  guardianTrustClass: GuardianContext['trustClass'];
1404
+ guardianExternalUserId?: string;
1405
+ requesterExternalUserId?: string;
1404
1406
  replyCallbackUrl: string;
1405
1407
  bearerToken?: string;
1406
1408
  assistantId?: string;
@@ -1411,6 +1413,8 @@ function startPendingApprovalPromptWatcher(params: {
1411
1413
  sourceChannel,
1412
1414
  externalChatId,
1413
1415
  guardianTrustClass,
1416
+ guardianExternalUserId,
1417
+ requesterExternalUserId,
1414
1418
  replyCallbackUrl,
1415
1419
  bearerToken,
1416
1420
  assistantId,
@@ -1419,7 +1423,12 @@ function startPendingApprovalPromptWatcher(params: {
1419
1423
 
1420
1424
  // Approval prompt delivery is guardian-only. Non-guardian and unverified
1421
1425
  // actors must never receive approval prompt broadcasts for the conversation.
1422
- if (guardianTrustClass !== 'guardian') {
1426
+ // We also require an explicit identity match against the bound guardian to
1427
+ // avoid broadcasting prompts when trustClass is stale/mis-scoped.
1428
+ const isBoundGuardianActor = guardianTrustClass === 'guardian'
1429
+ && !!guardianExternalUserId
1430
+ && requesterExternalUserId === guardianExternalUserId;
1431
+ if (!isBoundGuardianActor) {
1423
1432
  return () => {};
1424
1433
  }
1425
1434
 
@@ -1502,6 +1511,8 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
1502
1511
  sourceChannel,
1503
1512
  externalChatId,
1504
1513
  guardianTrustClass: guardianCtx.trustClass,
1514
+ guardianExternalUserId: guardianCtx.guardianExternalUserId,
1515
+ requesterExternalUserId: guardianCtx.requesterExternalUserId,
1505
1516
  replyCallbackUrl,
1506
1517
  bearerToken,
1507
1518
  assistantId,
@@ -102,6 +102,21 @@ export async function executeAppCreate(
102
102
  const preview = input.preview;
103
103
  const appType = input.type === 'site' ? 'site' as const : 'app' as const;
104
104
 
105
+ // Validate required fields — LLM input is not type-checked at runtime
106
+ if (typeof name !== 'string' || name.trim() === '') {
107
+ return { content: JSON.stringify({ error: 'name is required and must be a non-empty string' }), isError: true };
108
+ }
109
+ if (typeof htmlDefinition !== 'string') {
110
+ return { content: JSON.stringify({ error: 'html is required and must be a string containing the HTML definition' }), isError: true };
111
+ }
112
+ if (pages) {
113
+ for (const [filename, content] of Object.entries(pages)) {
114
+ if (typeof content !== 'string') {
115
+ return { content: JSON.stringify({ error: `pages["${filename}"] must be a string, got ${typeof content}` }), isError: true };
116
+ }
117
+ }
118
+ }
119
+
105
120
  const app = store.createApp({ name, description, schemaJson, htmlDefinition, pages, appType });
106
121
 
107
122
  if (input.set_as_home_base) {
@@ -1,7 +1,7 @@
1
1
  import { and, asc, eq, lte } from 'drizzle-orm';
2
2
  import { v4 as uuid } from 'uuid';
3
3
 
4
- import { getDb, rawChanges } from '../../memory/db.js';
4
+ import { getDb, rawRun } from '../../memory/db.js';
5
5
  import { reminders } from '../../memory/schema.js';
6
6
  import { cast,createRowMapper, parseJson } from '../../util/row-mapper.js';
7
7
 
@@ -105,14 +105,11 @@ export function listReminders(options?: { pendingOnly?: boolean }): ReminderRow[
105
105
  }
106
106
 
107
107
  export function cancelReminder(id: string): boolean {
108
- const db = getDb();
109
108
  const now = Date.now();
110
- db
111
- .update(reminders)
112
- .set({ status: 'cancelled', updatedAt: now })
113
- .where(and(eq(reminders.id, id), eq(reminders.status, 'pending')))
114
- .run();
115
- return rawChanges() > 0;
109
+ return rawRun(
110
+ 'UPDATE reminders SET status = ?, updated_at = ? WHERE id = ? AND status = ?',
111
+ 'cancelled', now, id, 'pending',
112
+ ) > 0;
116
113
  }
117
114
 
118
115
  /**
@@ -132,13 +129,12 @@ export function claimDueReminders(now: number): ReminderRow[] {
132
129
 
133
130
  const claimed: ReminderRow[] = [];
134
131
  for (const row of candidates) {
135
- db
136
- .update(reminders)
137
- .set({ status: 'firing', firedAt: now, updatedAt: now })
138
- .where(and(eq(reminders.id, row.id), eq(reminders.status, 'pending')))
139
- .run();
132
+ const changed = rawRun(
133
+ 'UPDATE reminders SET status = ?, fired_at = ?, updated_at = ? WHERE id = ? AND status = ?',
134
+ 'firing', now, now, row.id, 'pending',
135
+ );
140
136
 
141
- if (rawChanges() === 0) continue;
137
+ if (changed === 0) continue;
142
138
 
143
139
  claimed.push(parseRow({
144
140
  ...row,