@vellumai/assistant 0.4.1 → 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-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 +129 -3
- package/src/approvals/guardian-decision-primitive.ts +22 -1
- package/src/daemon/handlers/sessions.ts +125 -11
- 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 +31 -10
- package/src/runtime/routes/inbound-message-handler.ts +12 -1
- package/src/tools/apps/executors.ts +15 -0
package/src/daemon/server.ts
CHANGED
|
@@ -93,6 +93,16 @@ function resolveTurnInterface(sourceInterface?: string): InterfaceId {
|
|
|
93
93
|
return 'vellum';
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
function resolveCanonicalRequestSourceType(sourceChannel: string | undefined): 'desktop' | 'channel' | 'voice' {
|
|
97
|
+
if (sourceChannel === 'voice') {
|
|
98
|
+
return 'voice';
|
|
99
|
+
}
|
|
100
|
+
if (sourceChannel === 'vellum') {
|
|
101
|
+
return 'desktop';
|
|
102
|
+
}
|
|
103
|
+
return 'channel';
|
|
104
|
+
}
|
|
105
|
+
|
|
96
106
|
/**
|
|
97
107
|
* Build an onEvent callback that registers pending interactions when the agent
|
|
98
108
|
* loop emits confirmation_request or secret_request events. This ensures that
|
|
@@ -121,12 +131,17 @@ function makePendingInteractionRegistrar(
|
|
|
121
131
|
|
|
122
132
|
// Create a canonical guardian request so IPC/HTTP handlers can find it
|
|
123
133
|
// via applyCanonicalGuardianDecision.
|
|
134
|
+
const guardianContext = session.guardianContext;
|
|
135
|
+
const sourceChannel = guardianContext?.sourceChannel ?? 'vellum';
|
|
124
136
|
createCanonicalGuardianRequest({
|
|
125
137
|
id: msg.requestId,
|
|
126
138
|
kind: 'tool_approval',
|
|
127
|
-
sourceType:
|
|
128
|
-
sourceChannel
|
|
139
|
+
sourceType: resolveCanonicalRequestSourceType(sourceChannel),
|
|
140
|
+
sourceChannel,
|
|
129
141
|
conversationId,
|
|
142
|
+
requesterExternalUserId: guardianContext?.requesterExternalUserId,
|
|
143
|
+
requesterChatId: guardianContext?.requesterChatId,
|
|
144
|
+
guardianExternalUserId: guardianContext?.guardianExternalUserId,
|
|
130
145
|
toolName: msg.toolName,
|
|
131
146
|
status: 'pending',
|
|
132
147
|
requestCode: generateCanonicalRequestCode(),
|
|
@@ -20,7 +20,7 @@ import { commitAppTurnChanges } from '../memory/app-git-service.js';
|
|
|
20
20
|
import { getApp, listAppFiles } from '../memory/app-store.js';
|
|
21
21
|
import * as conversationStore from '../memory/conversation-store.js';
|
|
22
22
|
import { getConversationOriginChannel, getConversationOriginInterface, provenanceFromGuardianContext } from '../memory/conversation-store.js';
|
|
23
|
-
import { isReplaceableTitle, queueGenerateConversationTitle, queueRegenerateConversationTitle } from '../memory/conversation-title-service.js';
|
|
23
|
+
import { GENERATING_TITLE, isReplaceableTitle, queueGenerateConversationTitle, queueRegenerateConversationTitle, UNTITLED_FALLBACK } from '../memory/conversation-title-service.js';
|
|
24
24
|
import { stripMemoryRecallMessages } from '../memory/retriever.js';
|
|
25
25
|
import type { PermissionPrompter } from '../permissions/prompter.js';
|
|
26
26
|
import type { ContentBlock,Message } from '../providers/types.js';
|
|
@@ -211,10 +211,42 @@ export async function runAgentLoopImpl(
|
|
|
211
211
|
ctx.messages.pop();
|
|
212
212
|
conversationStore.deleteMessageById(userMessageId);
|
|
213
213
|
}
|
|
214
|
+
// Replace loading placeholder so the thread isn't stuck as "Generating title..."
|
|
215
|
+
const blockedConv = conversationStore.getConversation(ctx.conversationId);
|
|
216
|
+
if (blockedConv?.title === GENERATING_TITLE) {
|
|
217
|
+
conversationStore.updateConversationTitle(ctx.conversationId, UNTITLED_FALLBACK, 1);
|
|
218
|
+
onEvent({ type: 'session_title_updated', sessionId: ctx.conversationId, title: UNTITLED_FALLBACK });
|
|
219
|
+
}
|
|
214
220
|
onEvent({ type: 'error', message: `Message blocked by hook "${preMessageResult.blockedBy}"` });
|
|
215
221
|
return;
|
|
216
222
|
}
|
|
217
223
|
|
|
224
|
+
// Generate title early — the user message alone is sufficient context.
|
|
225
|
+
// Firing after hook gating but before the main LLM call removes the
|
|
226
|
+
// delay of waiting for the full assistant response. The second-pass
|
|
227
|
+
// regeneration at turn 3 will refine the title with more context.
|
|
228
|
+
// Deferred via setTimeout so the main agent loop LLM call is queued
|
|
229
|
+
// first, avoiding rate-limit slot contention. No abort signal — title
|
|
230
|
+
// generation should complete even if the user cancels the response,
|
|
231
|
+
// since the user message is already persisted.
|
|
232
|
+
const currentConvForTitle = conversationStore.getConversation(ctx.conversationId);
|
|
233
|
+
if (isReplaceableTitle(currentConvForTitle?.title ?? null)) {
|
|
234
|
+
setTimeout(() => {
|
|
235
|
+
queueGenerateConversationTitle({
|
|
236
|
+
conversationId: ctx.conversationId,
|
|
237
|
+
provider: ctx.provider,
|
|
238
|
+
userMessage: options?.titleText ?? content,
|
|
239
|
+
onTitleUpdated: (title) => {
|
|
240
|
+
onEvent({
|
|
241
|
+
type: 'session_title_updated',
|
|
242
|
+
sessionId: ctx.conversationId,
|
|
243
|
+
title,
|
|
244
|
+
});
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
}, 0);
|
|
248
|
+
}
|
|
249
|
+
|
|
218
250
|
const isFirstMessage = ctx.messages.length === 1;
|
|
219
251
|
|
|
220
252
|
const compacted = await ctx.contextWindowManager.maybeCompact(
|
|
@@ -721,27 +753,6 @@ export async function runAgentLoopImpl(
|
|
|
721
753
|
});
|
|
722
754
|
}
|
|
723
755
|
|
|
724
|
-
// Generate title if the current conversation title is still a replaceable
|
|
725
|
-
// placeholder. This replaces the previous `isFirstMessage` gate so that
|
|
726
|
-
// assistant-seeded/system-seeded threads also receive generated titles.
|
|
727
|
-
const currentConv = conversationStore.getConversation(ctx.conversationId);
|
|
728
|
-
if (isReplaceableTitle(currentConv?.title ?? null)) {
|
|
729
|
-
queueGenerateConversationTitle({
|
|
730
|
-
conversationId: ctx.conversationId,
|
|
731
|
-
provider: ctx.provider,
|
|
732
|
-
userMessage: options?.titleText ?? content,
|
|
733
|
-
assistantResponse: state.firstAssistantText || undefined,
|
|
734
|
-
onTitleUpdated: (title) => {
|
|
735
|
-
onEvent({
|
|
736
|
-
type: 'session_title_updated',
|
|
737
|
-
sessionId: ctx.conversationId,
|
|
738
|
-
title,
|
|
739
|
-
});
|
|
740
|
-
},
|
|
741
|
-
signal: abortController.signal,
|
|
742
|
-
});
|
|
743
|
-
}
|
|
744
|
-
|
|
745
756
|
// Second title pass: after 3 completed turns, re-generate the title
|
|
746
757
|
// using the last 3 messages for better context. Only fires when the
|
|
747
758
|
// current title was auto-generated (isAutoTitle = 1).
|
package/src/memory/app-store.ts
CHANGED
|
@@ -147,6 +147,9 @@ function savePages(appId: string, pages: Record<string, string>): void {
|
|
|
147
147
|
mkdirSync(pagesDir, { recursive: true });
|
|
148
148
|
for (const [filename, content] of Object.entries(pages)) {
|
|
149
149
|
validatePageFilename(filename);
|
|
150
|
+
if (typeof content !== 'string') {
|
|
151
|
+
throw new Error(`Page content for "${filename}" must be a string, got ${typeof content}`);
|
|
152
|
+
}
|
|
150
153
|
writeFileSync(join(pagesDir, filename), content, 'utf-8');
|
|
151
154
|
}
|
|
152
155
|
}
|
|
@@ -194,6 +197,9 @@ export function createApp(params: {
|
|
|
194
197
|
// Write htmlDefinition to {appId}/index.html on disk
|
|
195
198
|
const appDir = join(dir, app.id);
|
|
196
199
|
mkdirSync(appDir, { recursive: true });
|
|
200
|
+
if (typeof params.htmlDefinition !== 'string') {
|
|
201
|
+
throw new Error(`htmlDefinition must be a string, got ${typeof params.htmlDefinition}`);
|
|
202
|
+
}
|
|
197
203
|
writeFileSync(join(appDir, 'index.html'), params.htmlDefinition, 'utf-8');
|
|
198
204
|
|
|
199
205
|
// Write preview to companion file to keep the JSON small
|
|
@@ -81,7 +81,11 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
|
|
|
81
81
|
}
|
|
82
82
|
this.pendingRequests.set(id, { resolve });
|
|
83
83
|
this.workerProc.stdin.write(JSON.stringify({ id, texts }) + '\n');
|
|
84
|
-
|
|
84
|
+
try {
|
|
85
|
+
this.workerProc.stdin.flush();
|
|
86
|
+
} catch {
|
|
87
|
+
// Worker may have exited — pending request will be resolved by stdout reader cleanup
|
|
88
|
+
}
|
|
85
89
|
});
|
|
86
90
|
}
|
|
87
91
|
|
|
@@ -138,8 +142,10 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
|
|
|
138
142
|
// Wait for the worker to signal it's ready (model loaded)
|
|
139
143
|
await this.waitForReady();
|
|
140
144
|
} catch (err) {
|
|
141
|
-
// Worker failed to start — collect stderr
|
|
145
|
+
// Worker failed to start — kill it to avoid deadlock, then collect stderr
|
|
142
146
|
this.workerProc = null;
|
|
147
|
+
this.stdoutReaderActive = false;
|
|
148
|
+
try { proc.kill(); } catch { /* may already be dead */ }
|
|
143
149
|
const exitCode = await proc.exited.catch(() => undefined);
|
|
144
150
|
const stderr = await new Response(proc.stderr).text().catch(() => '');
|
|
145
151
|
if (stderr.trim()) {
|
|
@@ -180,7 +186,9 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
|
|
|
180
186
|
if (this.stdoutReaderActive || !this.workerProc) return;
|
|
181
187
|
this.stdoutReaderActive = true;
|
|
182
188
|
|
|
183
|
-
|
|
189
|
+
// Capture reference to detect if a new worker was spawned during cleanup
|
|
190
|
+
const proc = this.workerProc;
|
|
191
|
+
const reader = proc.stdout.getReader();
|
|
184
192
|
const decoder = new TextDecoder();
|
|
185
193
|
|
|
186
194
|
(async () => {
|
|
@@ -195,17 +203,21 @@ export class LocalEmbeddingBackend implements EmbeddingBackend {
|
|
|
195
203
|
// Reader cancelled or stream errored
|
|
196
204
|
}
|
|
197
205
|
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
206
|
+
// Only clean up if this reader's proc is still the active one.
|
|
207
|
+
// A new worker may have been spawned during the async cleanup window.
|
|
208
|
+
if (this.workerProc === proc) {
|
|
209
|
+
// Worker exited — reject all pending requests and clean up
|
|
210
|
+
for (const [, pending] of this.pendingRequests) {
|
|
211
|
+
pending.resolve({ error: 'Embedding worker process exited unexpectedly' });
|
|
212
|
+
}
|
|
213
|
+
this.pendingRequests.clear();
|
|
214
|
+
this.workerProc = null;
|
|
215
|
+
this.stdoutReaderActive = false;
|
|
216
|
+
this.removePidFile();
|
|
217
|
+
this.stdoutBuffer = '';
|
|
218
|
+
// Allow re-initialization on next embed() call
|
|
219
|
+
this.initGuard.reset();
|
|
201
220
|
}
|
|
202
|
-
this.pendingRequests.clear();
|
|
203
|
-
this.workerProc = null;
|
|
204
|
-
this.stdoutReaderActive = false;
|
|
205
|
-
this.removePidFile();
|
|
206
|
-
this.stdoutBuffer = '';
|
|
207
|
-
// Allow re-initialization on next embed() call
|
|
208
|
-
this.initGuard.reset();
|
|
209
221
|
})();
|
|
210
222
|
}
|
|
211
223
|
|
|
@@ -27,12 +27,13 @@ const log = getLogger('embedding-runtime-manager');
|
|
|
27
27
|
const ONNXRUNTIME_NODE_VERSION = '1.21.0';
|
|
28
28
|
const ONNXRUNTIME_COMMON_VERSION = '1.21.0';
|
|
29
29
|
const TRANSFORMERS_VERSION = '3.8.1';
|
|
30
|
+
const JINJA_VERSION = '0.5.5';
|
|
30
31
|
|
|
31
32
|
/** Bun version to download when system bun is not available. */
|
|
32
33
|
const BUN_VERSION = '1.2.0';
|
|
33
34
|
|
|
34
35
|
/** Composite version string for cache invalidation. */
|
|
35
|
-
const RUNTIME_VERSION = `ort-${ONNXRUNTIME_NODE_VERSION}_hf-${TRANSFORMERS_VERSION}`;
|
|
36
|
+
const RUNTIME_VERSION = `ort-${ONNXRUNTIME_NODE_VERSION}_hf-${TRANSFORMERS_VERSION}_jinja-${JINJA_VERSION}`;
|
|
36
37
|
|
|
37
38
|
const WORKER_FILENAME = 'embed-worker.mjs';
|
|
38
39
|
|
|
@@ -104,6 +105,7 @@ function generateWorkerScript(): string {
|
|
|
104
105
|
return `\
|
|
105
106
|
// embed-worker.mjs — Auto-generated by EmbeddingRuntimeManager
|
|
106
107
|
// Runs in a separate bun process, communicates via JSON-lines over stdin/stdout.
|
|
108
|
+
process.title = 'embed-worker';
|
|
107
109
|
import { pipeline, env } from '@huggingface/transformers';
|
|
108
110
|
|
|
109
111
|
const model = process.argv[2];
|
|
@@ -272,6 +274,12 @@ export class EmbeddingRuntimeManager {
|
|
|
272
274
|
const tmpDir = join(this.baseDir, `.installing-${Date.now()}`);
|
|
273
275
|
mkdirSync(tmpDir, { recursive: true });
|
|
274
276
|
|
|
277
|
+
// Declared outside try so catch/finally can reference them for cleanup
|
|
278
|
+
const modelCacheDir = join(this.baseDir, 'model-cache');
|
|
279
|
+
const existingBinDir = join(this.baseDir, 'bin');
|
|
280
|
+
let tmpModelCache: string | null = null;
|
|
281
|
+
let tmpBinDir: string | null = null;
|
|
282
|
+
|
|
275
283
|
try {
|
|
276
284
|
// Step 1: Download npm packages (and bun if needed) in parallel
|
|
277
285
|
const nodeModules = join(tmpDir, 'node_modules');
|
|
@@ -291,6 +299,11 @@ export class EmbeddingRuntimeManager {
|
|
|
291
299
|
join(nodeModules, '@huggingface', 'transformers'),
|
|
292
300
|
signal,
|
|
293
301
|
),
|
|
302
|
+
downloadAndExtract(
|
|
303
|
+
npmTarballUrl('@huggingface/jinja', JINJA_VERSION),
|
|
304
|
+
join(nodeModules, '@huggingface', 'jinja'),
|
|
305
|
+
signal,
|
|
306
|
+
),
|
|
294
307
|
];
|
|
295
308
|
|
|
296
309
|
// Download bun binary if not already available on the system
|
|
@@ -358,19 +371,15 @@ export class EmbeddingRuntimeManager {
|
|
|
358
371
|
|
|
359
372
|
// Step 6: Atomic swap — remove old install and rename temp to final
|
|
360
373
|
// Preserve model-cache/, bin/ (downloaded bun), and .gitignore
|
|
361
|
-
const modelCacheDir = join(this.baseDir, 'model-cache');
|
|
362
374
|
const hadModelCache = existsSync(modelCacheDir);
|
|
363
|
-
let tmpModelCache: string | null = null;
|
|
364
375
|
if (hadModelCache) {
|
|
365
376
|
tmpModelCache = join(this.baseDir, `.model-cache-preserve-${Date.now()}`);
|
|
366
377
|
renameSync(modelCacheDir, tmpModelCache);
|
|
367
378
|
}
|
|
368
379
|
|
|
369
380
|
// Preserve downloaded bun binary if it exists and we didn't just download a new one
|
|
370
|
-
const existingBinDir = join(this.baseDir, 'bin');
|
|
371
381
|
const newBinDir = join(tmpDir, 'bin');
|
|
372
382
|
const hadBinDir = existsSync(existingBinDir) && !existsSync(newBinDir);
|
|
373
|
-
let tmpBinDir: string | null = null;
|
|
374
383
|
if (hadBinDir) {
|
|
375
384
|
tmpBinDir = join(this.baseDir, `.bin-preserve-${Date.now()}`);
|
|
376
385
|
renameSync(existingBinDir, tmpBinDir);
|
|
@@ -399,11 +408,20 @@ export class EmbeddingRuntimeManager {
|
|
|
399
408
|
|
|
400
409
|
log.info({ runtimeVersion: RUNTIME_VERSION }, 'Embedding runtime installed successfully');
|
|
401
410
|
} catch (err) {
|
|
411
|
+
// Restore preserved directories if the swap failed
|
|
412
|
+
if (tmpModelCache && existsSync(tmpModelCache) && !existsSync(modelCacheDir)) {
|
|
413
|
+
try { renameSync(tmpModelCache, modelCacheDir); } catch { /* best effort */ }
|
|
414
|
+
}
|
|
415
|
+
if (tmpBinDir && existsSync(tmpBinDir) && !existsSync(existingBinDir)) {
|
|
416
|
+
try { renameSync(tmpBinDir, existingBinDir); } catch { /* best effort */ }
|
|
417
|
+
}
|
|
402
418
|
log.error({ err }, 'Failed to install embedding runtime');
|
|
403
419
|
throw err;
|
|
404
420
|
} finally {
|
|
405
|
-
// Clean up temp directory
|
|
421
|
+
// Clean up temp directory and any leftover preserve dirs
|
|
406
422
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
423
|
+
if (tmpModelCache) rmSync(tmpModelCache, { recursive: true, force: true });
|
|
424
|
+
if (tmpBinDir) rmSync(tmpBinDir, { recursive: true, force: true });
|
|
407
425
|
}
|
|
408
426
|
}
|
|
409
427
|
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { ChannelId } from '../channels/types.js';
|
|
9
9
|
import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
|
|
10
|
+
import { canonicalizeInboundIdentity } from '../util/canonicalize-identity.js';
|
|
10
11
|
import {
|
|
11
12
|
type DenialReason,
|
|
12
13
|
resolveActorTrust,
|
|
@@ -41,11 +42,14 @@ export type ResolveGuardianContextInput = ResolveActorTrustInput;
|
|
|
41
42
|
*/
|
|
42
43
|
export function resolveGuardianContext(input: ResolveGuardianContextInput): GuardianContext {
|
|
43
44
|
const trust = resolveActorTrust(input);
|
|
45
|
+
const canonicalGuardianExternalUserId = trust.guardianBindingMatch?.guardianExternalUserId
|
|
46
|
+
? canonicalizeInboundIdentity(input.sourceChannel, trust.guardianBindingMatch.guardianExternalUserId) ?? undefined
|
|
47
|
+
: undefined;
|
|
44
48
|
return {
|
|
45
49
|
trustClass: trust.trustClass,
|
|
46
50
|
guardianChatId: trust.guardianBindingMatch?.guardianDeliveryChatId ??
|
|
47
51
|
(trust.trustClass === 'guardian' ? input.externalChatId : undefined),
|
|
48
|
-
guardianExternalUserId:
|
|
52
|
+
guardianExternalUserId: canonicalGuardianExternalUserId,
|
|
49
53
|
requesterIdentifier: trust.actorMetadata.identifier,
|
|
50
54
|
requesterDisplayName: trust.actorMetadata.displayName,
|
|
51
55
|
requesterSenderDisplayName: trust.actorMetadata.senderDisplayName,
|
|
@@ -237,6 +237,7 @@ export async function routeGuardianReply(
|
|
|
237
237
|
): Promise<GuardianReplyResult> {
|
|
238
238
|
const { messageText, actor, conversationId, callbackData, approvalConversationGenerator, channelDeliveryContext } = ctx;
|
|
239
239
|
const pendingRequests = findPendingCanonicalRequests(actor, ctx.pendingRequestIds, conversationId);
|
|
240
|
+
const scopedPendingRequestIds = ctx.pendingRequestIds ? new Set(ctx.pendingRequestIds) : null;
|
|
240
241
|
|
|
241
242
|
// ── 1. Deterministic callback parsing (button presses) ──
|
|
242
243
|
// No conversationId scoping here — the guardian's reply comes from a
|
|
@@ -257,6 +258,17 @@ export async function routeGuardianReply(
|
|
|
257
258
|
const codeResult = parseRequestCode(messageText);
|
|
258
259
|
if (codeResult) {
|
|
259
260
|
const { request } = codeResult;
|
|
261
|
+
if (scopedPendingRequestIds && !scopedPendingRequestIds.has(request.id)) {
|
|
262
|
+
log.info(
|
|
263
|
+
{
|
|
264
|
+
event: 'router_code_out_of_scope',
|
|
265
|
+
requestId: request.id,
|
|
266
|
+
pendingHintCount: scopedPendingRequestIds.size,
|
|
267
|
+
},
|
|
268
|
+
'Request code matched a pending request outside the caller-provided scope; ignoring',
|
|
269
|
+
);
|
|
270
|
+
return notConsumed();
|
|
271
|
+
}
|
|
260
272
|
|
|
261
273
|
if (request.status !== 'pending') {
|
|
262
274
|
log.info(
|
|
@@ -714,6 +714,7 @@ export class RuntimeHttpServer {
|
|
|
714
714
|
processMessage: this.processMessage,
|
|
715
715
|
persistAndProcessMessage: this.persistAndProcessMessage,
|
|
716
716
|
sendMessageDeps: this.sendMessageDeps,
|
|
717
|
+
approvalConversationGenerator: this.approvalConversationGenerator,
|
|
717
718
|
});
|
|
718
719
|
}
|
|
719
720
|
|
|
@@ -27,6 +27,7 @@ import { buildAssistantEvent } from '../assistant-event.js';
|
|
|
27
27
|
import { routeGuardianReply } from '../guardian-reply-router.js';
|
|
28
28
|
import { httpError } from '../http-errors.js';
|
|
29
29
|
import type {
|
|
30
|
+
ApprovalConversationGenerator,
|
|
30
31
|
MessageProcessor,
|
|
31
32
|
NonBlockingMessageProcessor,
|
|
32
33
|
RuntimeAttachmentMetadata,
|
|
@@ -87,6 +88,7 @@ async function tryConsumeInlineApprovalReply(params: {
|
|
|
87
88
|
}>;
|
|
88
89
|
session: import('../../daemon/session.js').Session;
|
|
89
90
|
onEvent: (msg: ServerMessage) => void;
|
|
91
|
+
approvalConversationGenerator?: ApprovalConversationGenerator;
|
|
90
92
|
}): Promise<{ consumed: boolean; messageId?: string }> {
|
|
91
93
|
const {
|
|
92
94
|
conversationId,
|
|
@@ -96,15 +98,16 @@ async function tryConsumeInlineApprovalReply(params: {
|
|
|
96
98
|
attachments,
|
|
97
99
|
session,
|
|
98
100
|
onEvent,
|
|
101
|
+
approvalConversationGenerator,
|
|
99
102
|
} = params;
|
|
100
103
|
const trimmedContent = content.trim();
|
|
101
104
|
|
|
102
|
-
//
|
|
103
|
-
//
|
|
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.
|
|
105
109
|
if (
|
|
106
110
|
!session.hasAnyPendingConfirmation()
|
|
107
|
-
|| session.getQueueDepth() > 0
|
|
108
111
|
|| trimmedContent.length === 0
|
|
109
112
|
) {
|
|
110
113
|
return { consumed: false };
|
|
@@ -125,6 +128,7 @@ async function tryConsumeInlineApprovalReply(params: {
|
|
|
125
128
|
},
|
|
126
129
|
conversationId,
|
|
127
130
|
pendingRequestIds,
|
|
131
|
+
approvalConversationGenerator,
|
|
128
132
|
});
|
|
129
133
|
|
|
130
134
|
if (!routerResult.consumed || routerResult.type === 'nl_keep_pending') {
|
|
@@ -176,6 +180,16 @@ async function tryConsumeInlineApprovalReply(params: {
|
|
|
176
180
|
return { consumed: true, messageId };
|
|
177
181
|
}
|
|
178
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
|
+
|
|
179
193
|
function getInterfaceFilesWithMtimes(interfacesDir: string | null): Array<{ path: string; mtimeMs: number }> {
|
|
180
194
|
if (!interfacesDir || !existsSync(interfacesDir)) return [];
|
|
181
195
|
const results: Array<{ path: string; mtimeMs: number }> = [];
|
|
@@ -319,12 +333,17 @@ function makeHubPublisher(
|
|
|
319
333
|
|
|
320
334
|
// Create a canonical guardian request so IPC/HTTP handlers can find it
|
|
321
335
|
// via applyCanonicalGuardianDecision.
|
|
336
|
+
const guardianContext = session.guardianContext;
|
|
337
|
+
const sourceChannel = guardianContext?.sourceChannel ?? 'vellum';
|
|
322
338
|
createCanonicalGuardianRequest({
|
|
323
339
|
id: msg.requestId,
|
|
324
340
|
kind: 'tool_approval',
|
|
325
|
-
sourceType:
|
|
326
|
-
sourceChannel
|
|
341
|
+
sourceType: resolveCanonicalRequestSourceType(sourceChannel),
|
|
342
|
+
sourceChannel,
|
|
327
343
|
conversationId,
|
|
344
|
+
requesterExternalUserId: guardianContext?.requesterExternalUserId,
|
|
345
|
+
requesterChatId: guardianContext?.requesterChatId,
|
|
346
|
+
guardianExternalUserId: guardianContext?.guardianExternalUserId,
|
|
328
347
|
toolName: msg.toolName,
|
|
329
348
|
status: 'pending',
|
|
330
349
|
requestCode: generateCanonicalRequestCode(),
|
|
@@ -362,6 +381,7 @@ export async function handleSendMessage(
|
|
|
362
381
|
processMessage?: MessageProcessor;
|
|
363
382
|
persistAndProcessMessage?: NonBlockingMessageProcessor;
|
|
364
383
|
sendMessageDeps?: SendMessageDeps;
|
|
384
|
+
approvalConversationGenerator?: ApprovalConversationGenerator;
|
|
365
385
|
},
|
|
366
386
|
): Promise<Response> {
|
|
367
387
|
const body = await req.json() as {
|
|
@@ -440,10 +460,11 @@ export async function handleSendMessage(
|
|
|
440
460
|
sourceChannel,
|
|
441
461
|
sourceInterface,
|
|
442
462
|
content: content ?? '',
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
463
|
+
attachments,
|
|
464
|
+
session,
|
|
465
|
+
onEvent,
|
|
466
|
+
approvalConversationGenerator: deps.approvalConversationGenerator,
|
|
467
|
+
});
|
|
447
468
|
if (inlineReplyResult.consumed) {
|
|
448
469
|
return Response.json(
|
|
449
470
|
{ accepted: true, ...(inlineReplyResult.messageId ? { messageId: inlineReplyResult.messageId } : {}) },
|
|
@@ -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) {
|