@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.
- package/bun.lock +0 -83
- package/package.json +2 -3
- package/src/__tests__/channel-approval-routes.test.ts +55 -5
- package/src/__tests__/daemon-server-session-init.test.ts +54 -1
- package/src/__tests__/guardian-control-plane-policy.test.ts +6 -2
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +4 -2
- package/src/__tests__/guardian-routing-invariants.test.ts +50 -9
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +161 -2
- package/src/__tests__/send-endpoint-busy.test.ts +413 -3
- package/src/approvals/guardian-decision-primitive.ts +22 -1
- package/src/daemon/handlers/sessions.ts +125 -11
- package/src/daemon/response-tier.ts +6 -5
- package/src/daemon/server.ts +17 -2
- package/src/daemon/session-agent-loop.ts +33 -22
- package/src/memory/app-store.ts +6 -0
- package/src/memory/embedding-local.ts +25 -13
- package/src/memory/embedding-runtime-manager.ts +24 -6
- package/src/runtime/guardian-context-resolver.ts +5 -1
- package/src/runtime/guardian-reply-router.ts +12 -0
- package/src/runtime/http-server.ts +1 -0
- package/src/runtime/routes/conversation-routes.ts +187 -2
- package/src/runtime/routes/inbound-message-handler.ts +12 -1
- package/src/tools/apps/executors.ts +15 -0
- package/src/tools/reminder/reminder-store.ts +10 -14
|
@@ -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:
|
|
185
|
-
sourceChannel
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 (
|
|
137
|
+
if (changed === 0) continue;
|
|
142
138
|
|
|
143
139
|
claimed.push(parseRow({
|
|
144
140
|
...row,
|