@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.
@@ -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: 'desktop',
128
- sourceChannel: 'vellum',
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).
@@ -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
- this.workerProc.stdin.flush();
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 for diagnosis
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
- const reader = this.workerProc.stdout.getReader();
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
- // Worker exited reject all pending requests and clean up
199
- for (const [, pending] of this.pendingRequests) {
200
- pending.resolve({ error: 'Embedding worker process exited unexpectedly' });
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: trust.guardianBindingMatch?.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
- // Only consume inline replies when there are no queued turns, matching
103
- // the IPC path guard. With queued messages, "approve"/"no" should be
104
- // processed in queue order rather than treated as a confirmation reply.
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: 'desktop',
326
- sourceChannel: 'vellum',
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
- attachments,
444
- session,
445
- onEvent,
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
- 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) {