@vellumai/assistant 0.12.3 → 0.12.4-dev.202609231620.a15f321

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.
Files changed (209) hide show
  1. package/Dockerfile +7 -0
  2. package/README.md +12 -12
  3. package/docker/mousepad/90-vellum.gschema.override +4 -0
  4. package/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +48 -4
  5. package/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +12 -8
  6. package/openapi.yaml +97 -4
  7. package/package.json +1 -1
  8. package/src/__tests__/approval-routes-http.test.ts +4 -2
  9. package/src/__tests__/call-routes-http.test.ts +4 -2
  10. package/src/__tests__/channel-readiness-discord.test.ts +8 -0
  11. package/src/__tests__/channel-readiness-slack-remote.test.ts +8 -0
  12. package/src/__tests__/config-loader-backfill.test.ts +1 -0
  13. package/src/__tests__/conversation-pairing.test.ts +10 -0
  14. package/src/__tests__/conversation-resurface-done.test.ts +289 -0
  15. package/src/__tests__/default-profile-catalog-fallback.test.ts +1 -0
  16. package/src/__tests__/gemini-provider.test.ts +31 -0
  17. package/src/__tests__/inference-profile-validation-jev.test.ts +17 -0
  18. package/src/__tests__/job-handler-registration-guard.test.ts +1 -0
  19. package/src/__tests__/llm-catalog-parity.test.ts +4 -0
  20. package/src/__tests__/llm-resolver-override-or-default.test.ts +43 -0
  21. package/src/__tests__/openai-responses-prompt-cache.test.ts +3 -1
  22. package/src/__tests__/platform-callback-registration.test.ts +46 -1
  23. package/src/__tests__/plugin-api-model-profiles.test.ts +7 -0
  24. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  25. package/src/__tests__/pricing.test.ts +33 -0
  26. package/src/__tests__/provider-error-scenarios.test.ts +46 -0
  27. package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
  28. package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
  29. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  30. package/src/__tests__/scaffold-managed-skill-tool.test.ts +116 -66
  31. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
  32. package/src/__tests__/send-endpoint-busy.test.ts +4 -2
  33. package/src/__tests__/skill-load-inline-command.test.ts +35 -0
  34. package/src/__tests__/skill-load-tool.test.ts +42 -11
  35. package/src/__tests__/skill-tool-factory.test.ts +50 -2
  36. package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
  37. package/src/__tests__/validate-input.test.ts +73 -0
  38. package/src/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
  39. package/src/api/ask-question-tool.ts +50 -0
  40. package/src/api/events/tool-result.ts +11 -0
  41. package/src/api/index.ts +7 -0
  42. package/src/api/requests/dictation.ts +2 -2
  43. package/src/api/responses/home.ts +27 -0
  44. package/src/avatar/notification-avatar.test.ts +42 -34
  45. package/src/avatar/notification-avatar.ts +1 -0
  46. package/src/calls/__tests__/voice-session-bridge.test.ts +63 -1
  47. package/src/calls/voice-escalation-judge.ts +6 -5
  48. package/src/calls/voice-screen-annotation.ts +44 -0
  49. package/src/calls/voice-session-bridge.ts +22 -0
  50. package/src/cli/commands/__tests__/plugins.test.ts +29 -1
  51. package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
  52. package/src/cli/commands/platform/index.help.ts +2 -1
  53. package/src/cli/commands/platform/index.ts +8 -2
  54. package/src/cli/commands/plugins.ts +16 -0
  55. package/src/cli/lib/__tests__/inspect-plugin.test.ts +29 -0
  56. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +92 -2
  57. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +15 -0
  58. package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +66 -2
  59. package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +21 -0
  60. package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +65 -0
  61. package/src/cli/lib/__tests__/plugin-details.test.ts +43 -0
  62. package/src/cli/lib/__tests__/plugins-install-offline.test.ts +26 -0
  63. package/src/cli/lib/bundled-marketplace.json +4 -4
  64. package/src/cli/lib/inspect-plugin.ts +5 -0
  65. package/src/cli/lib/plugin-catalog-cache.ts +41 -4
  66. package/src/cli/lib/plugin-catalog-local.ts +9 -0
  67. package/src/cli/lib/plugin-catalog-platform.ts +48 -9
  68. package/src/cli/lib/plugin-catalog-visibility.ts +41 -0
  69. package/src/cli/lib/plugin-details.ts +10 -7
  70. package/src/cli/lib/search-plugins.ts +2 -0
  71. package/src/config/__tests__/default-profile-catalog.test.ts +61 -1
  72. package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
  73. package/src/config/__tests__/sync-gated-profiles.test.ts +71 -0
  74. package/src/config/bundled-skills/screen-annotation/SKILL.md +6 -3
  75. package/src/config/call-site-defaults.ts +17 -11
  76. package/src/config/default-profile-catalog.ts +118 -19
  77. package/src/config/default-profile-names.ts +69 -0
  78. package/src/config/feature-flag-registry.json +25 -9
  79. package/src/config/inference-profile-validation.ts +7 -6
  80. package/src/config/llm-resolver.ts +16 -0
  81. package/src/config/profile-text-generation.test.ts +9 -0
  82. package/src/config/schemas/call-site-catalog.ts +8 -1
  83. package/src/config/schemas/llm.ts +70 -9
  84. package/src/config/sidebar-done-gate.ts +27 -0
  85. package/src/config/sync-gated-profiles.ts +96 -44
  86. package/src/daemon/__tests__/auto-profile-router.test.ts +228 -0
  87. package/src/daemon/auto-profile-router.ts +220 -0
  88. package/src/daemon/conversation-agent-loop.ts +77 -4
  89. package/src/daemon/conversation-slash.ts +2 -2
  90. package/src/daemon/conversation-turn-finalize.ts +12 -0
  91. package/src/daemon/dictation-text-processing.ts +4 -4
  92. package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
  93. package/src/daemon/handlers/config-channels.ts +3 -1
  94. package/src/daemon/handlers/conversation-history.ts +11 -1
  95. package/src/daemon/handlers/conversations.ts +118 -10
  96. package/src/daemon/pkb-reminder-builder.test.ts +10 -3
  97. package/src/daemon/pkb-reminder-builder.ts +1 -1
  98. package/src/desktop/desktop-browser-endpoint.ts +1 -0
  99. package/src/home/feed-types.ts +2 -0
  100. package/src/inbound/platform-callback-registration.ts +19 -6
  101. package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
  102. package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
  103. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +27 -0
  104. package/src/live-voice/__tests__/protocol.test.ts +22 -0
  105. package/src/live-voice/continuation-judge.ts +1 -1
  106. package/src/live-voice/live-voice-session.ts +7 -6
  107. package/src/live-voice/protocol.ts +16 -5
  108. package/src/messaging/providers/gmail/client.ts +4 -0
  109. package/src/notifications/README.md +11 -3
  110. package/src/notifications/__tests__/home-feed-side-effect.test.ts +87 -3
  111. package/src/notifications/conversation-pairing.ts +23 -8
  112. package/src/notifications/home-feed-mirror.ts +39 -0
  113. package/src/notifications/home-feed-side-effect.ts +46 -19
  114. package/src/oauth/AGENTS.md +7 -1
  115. package/src/permissions/question-prompter.ts +14 -4
  116. package/src/persistence/conversation-crud.ts +114 -3
  117. package/src/persistence/conversation-queries.ts +48 -3
  118. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +228 -32
  119. package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
  120. package/src/persistence/embeddings/embedding-backend.ts +23 -41
  121. package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
  122. package/src/persistence/embeddings/embedding-gemini.ts +3 -1
  123. package/src/persistence/embeddings/embedding-local.ts +72 -17
  124. package/src/persistence/embeddings/embedding-types.ts +0 -19
  125. package/src/persistence/jobs-store.ts +229 -0
  126. package/src/persistence/migrations/383-normalize-opencode-host-connections.ts +44 -0
  127. package/src/persistence/migrations/__tests__/383-normalize-opencode-host-connections.test.ts +102 -0
  128. package/src/persistence/steps.ts +12 -0
  129. package/src/platform/sync-avatar.test.ts +29 -0
  130. package/src/plugin-api/model-profiles.ts +5 -3
  131. package/src/plugins/defaults/memory/AGENTS.md +5 -3
  132. package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
  133. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-boundary-guard.test.ts +45 -0
  134. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-job.test.ts +580 -0
  135. package/src/plugins/defaults/memory/graph/tools.ts +9 -5
  136. package/src/plugins/defaults/memory/hooks/conversation-deleted.ts +4 -0
  137. package/src/plugins/defaults/memory/job-handlers.ts +8 -2
  138. package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
  139. package/src/plugins/defaults/memory/skill-update-receipt-job.ts +334 -0
  140. package/src/plugins/defaults/memory/substrate/skill-content.ts +1 -1
  141. package/src/plugins/defaults/memory/tools.test.ts +17 -0
  142. package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +3 -0
  143. package/src/providers/__tests__/connection-model-compat.test.ts +2 -0
  144. package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
  145. package/src/providers/anthropic/client.ts +2 -2
  146. package/src/providers/gemini/client.ts +17 -1
  147. package/src/providers/inference/auth.ts +25 -0
  148. package/src/providers/jev/ask.test.ts +66 -0
  149. package/src/{calls/typesafe-noul.ts → providers/jev/ask.ts} +75 -36
  150. package/src/providers/malformed-tool-call.ts +55 -0
  151. package/src/providers/model-catalog.ts +257 -4
  152. package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
  153. package/src/providers/openai/chat-completions-provider.ts +30 -0
  154. package/src/providers/openai/codex-models.ts +2 -0
  155. package/src/providers/platform-proxy/constants.ts +5 -0
  156. package/src/providers/retry.ts +68 -36
  157. package/src/providers/search-provider-catalog.ts +10 -0
  158. package/src/providers/vellum-model-routing.test.ts +1 -0
  159. package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
  160. package/src/runtime/__tests__/question-resolution.test.ts +32 -0
  161. package/src/runtime/agent-wake.ts +9 -2
  162. package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
  163. package/src/runtime/auth/middleware.ts +15 -11
  164. package/src/runtime/middleware/auth.ts +7 -1
  165. package/src/runtime/pending-interactions.ts +8 -8
  166. package/src/runtime/question-resolution.ts +54 -8
  167. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
  168. package/src/runtime/routes/__tests__/dictation-processing.test.ts +174 -0
  169. package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
  170. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +60 -0
  171. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -1
  172. package/src/runtime/routes/__tests__/plugins-routes.test.ts +8 -0
  173. package/src/runtime/routes/conversation-list-routes.ts +16 -14
  174. package/src/runtime/routes/conversation-query-routes.ts +4 -0
  175. package/src/runtime/routes/conversation-routes.ts +4 -2
  176. package/src/runtime/routes/diagnostics-routes.ts +66 -229
  177. package/src/runtime/routes/global-search-routes.ts +22 -7
  178. package/src/runtime/routes/inference-provider-connection-routes.ts +25 -8
  179. package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
  180. package/src/runtime/routes/platform-routes.ts +6 -1
  181. package/src/runtime/routes/plugins-routes.ts +5 -2
  182. package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
  183. package/src/runtime/sync/resource-sync-events.ts +11 -0
  184. package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
  185. package/src/runtime/sync/worker-daemon-notify.ts +44 -0
  186. package/src/runtime/verification-outbound-actions.ts +2 -2
  187. package/src/skills/validate-input.ts +75 -11
  188. package/src/telegram/__tests__/webhook-health.test.ts +8 -0
  189. package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
  190. package/src/tools/ask-question/ask-question-tool.ts +12 -1
  191. package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +28 -1
  192. package/src/tools/network/__tests__/web-fetch-metadata.test.ts +34 -0
  193. package/src/tools/network/__tests__/web-fetch-tinyfish.test.ts +35 -1
  194. package/src/tools/network/__tests__/web-search.test.ts +96 -0
  195. package/src/tools/network/web-fetch.ts +30 -9
  196. package/src/tools/network/web-search.ts +235 -4
  197. package/src/tools/skills/execute.ts +1 -1
  198. package/src/tools/skills/format-input-schema.ts +15 -0
  199. package/src/tools/skills/load.ts +15 -43
  200. package/src/tools/skills/scaffold-managed.ts +86 -71
  201. package/src/tools/skills/skill-tool-factory.ts +2 -1
  202. package/src/tools/workflows/manage-workflows.ts +4 -2
  203. package/src/util/worker-pipe.test.ts +146 -0
  204. package/src/util/worker-pipe.ts +49 -0
  205. package/src/workspace/migrations/158-repoint-opencode-host-provider-fragments.ts +163 -0
  206. package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
  207. package/src/workspace/migrations/__tests__/158-repoint-opencode-host-provider-fragments.test.ts +188 -0
  208. package/src/workspace/migrations/registry.ts +4 -0
  209. package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
@@ -27,9 +27,14 @@ import { LocalEmbeddingBackend } from "../embedding-local.js";
27
27
  type Internals = any;
28
28
 
29
29
  /**
30
- * A stand-in for a Bun subprocess whose stdin pipe is broken. `write` throws
31
- * EPIPE the way a real worker's pipe does once the child is gone.
30
+ * A backend of its own for one test. Production gets the per-model instance
31
+ * from `forModel`; constructing directly keeps tests from sharing state.
32
32
  */
33
+ function newBackend(): Internals {
34
+ return new (LocalEmbeddingBackend as Internals)("test-model");
35
+ }
36
+
37
+ /** A stand-in for a Bun subprocess whose stdin `write` fails synchronously. */
33
38
  function brokenPipeProc(pid = 4242) {
34
39
  return {
35
40
  pid,
@@ -49,6 +54,62 @@ function brokenPipeProc(pid = 4242) {
49
54
  };
50
55
  }
51
56
 
57
+ /**
58
+ * A stand-in for a Bun subprocess whose stdin write outruns the pipe buffer:
59
+ * `write` and `flush` hand back one pending promise, and killing the worker
60
+ * rejects it with EPIPE, the way Bun settles a pending write once the child
61
+ * is reaped.
62
+ */
63
+ function pendingWriteProc(pid = 4244) {
64
+ const pending = Promise.withResolvers<number>();
65
+ return {
66
+ pid,
67
+ killed: false,
68
+ exited: Promise.resolve(0),
69
+ kill() {
70
+ this.killed = true;
71
+ pending.reject(
72
+ Object.assign(new Error("EPIPE: broken pipe, write"), {
73
+ code: "EPIPE",
74
+ }),
75
+ );
76
+ },
77
+ stdin: {
78
+ write: () => pending.promise,
79
+ flush: () => pending.promise,
80
+ },
81
+ };
82
+ }
83
+
84
+ /**
85
+ * A subprocess whose stdout the test closes, to drive the reader loop that
86
+ * settles requests the pipe never reported a failure for.
87
+ */
88
+ function stdoutProc(pid = 4245) {
89
+ let closeStdout: () => void = () => {};
90
+ const stdout = new ReadableStream<Uint8Array>({
91
+ start(controller) {
92
+ closeStdout = () => controller.close();
93
+ },
94
+ });
95
+ let resolveExit: (code: number) => void = () => {};
96
+ const exited = new Promise<number>((r) => {
97
+ resolveExit = r;
98
+ });
99
+ return {
100
+ pid,
101
+ killed: false,
102
+ exited,
103
+ stdout,
104
+ closeStdout: () => closeStdout(),
105
+ kill() {
106
+ this.killed = true;
107
+ resolveExit(0);
108
+ },
109
+ stdin: { write: () => 0, flush: () => 0 },
110
+ };
111
+ }
112
+
52
113
  /** A subprocess that stays "alive" until killed, so exit can be observed. */
53
114
  function liveProc(pid = 4243) {
54
115
  let resolveExit: (code: number) => void = () => {};
@@ -196,7 +257,7 @@ describe("PID file ownership", () => {
196
257
  */
197
258
  test("releasePidFile leaves an entry that names someone else's worker", () => {
198
259
  writeFileSync(pidPath, "777");
199
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
260
+ const backend = newBackend();
200
261
 
201
262
  backend.releasePidFile(888);
202
263
 
@@ -206,7 +267,7 @@ describe("PID file ownership", () => {
206
267
 
207
268
  test("releasePidFile drops the entry when it names our worker", () => {
208
269
  writeFileSync(pidPath, "777");
209
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
270
+ const backend = newBackend();
210
271
 
211
272
  backend.releasePidFile(777);
212
273
 
@@ -219,7 +280,7 @@ describe("PID file ownership", () => {
219
280
  */
220
281
  test("disposing an instance with no worker keeps another's PID entry", () => {
221
282
  writeFileSync(pidPath, "777");
222
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
283
+ const backend = newBackend();
223
284
  backend.disposeRequested = true;
224
285
  backend.workerProc = null;
225
286
 
@@ -232,12 +293,12 @@ describe("PID file ownership", () => {
232
293
 
233
294
  describe("broken worker pipe", () => {
234
295
  /**
235
- * A write to a dead worker raises EPIPE. Escaping, it reached the daemon's
236
- * `unhandledRejection` handler and terminated the process; it must instead
237
- * come back as an ordinary failed embed the backend chain can fall back from.
296
+ * A broken pipe comes back as an ordinary failed embed the backend chain can
297
+ * fall back from. An escaping rejection reaches the daemon's
298
+ * `unhandledRejection` handler, which shuts the process down.
238
299
  */
239
300
  test("EPIPE on write resolves the request as an error", async () => {
240
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
301
+ const backend = newBackend();
241
302
  backend.workerProc = brokenPipeProc();
242
303
 
243
304
  const response = await backend.sendRequest(["hello"]);
@@ -247,7 +308,7 @@ describe("broken worker pipe", () => {
247
308
  });
248
309
 
249
310
  test("EPIPE surfaces to embed() as a rejection, not a process crash", async () => {
250
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
311
+ const backend = newBackend();
251
312
  backend.workerProc = brokenPipeProc();
252
313
  // Skip initialization: the worker is already (notionally) running.
253
314
  backend.ensureInitialized = async () => {};
@@ -257,8 +318,49 @@ describe("broken worker pipe", () => {
257
318
  );
258
319
  });
259
320
 
321
+ /**
322
+ * A batch larger than the pipe buffer, sent to a worker too busy to drain
323
+ * stdin, leaves the write pending, and the pending write rejects after
324
+ * `sendRequest` has returned, once the worker dies. A synchronous guard
325
+ * never sees that rejection.
326
+ */
327
+ test("a write still pending when the worker dies resolves the request as an error", async () => {
328
+ const backend = newBackend();
329
+ const proc = pendingWriteProc();
330
+ backend.workerProc = proc;
331
+
332
+ const request = backend.sendRequest(["hello"]);
333
+ proc.kill();
334
+ const response = await request;
335
+
336
+ expect(response.error).toContain("worker pipe write failed");
337
+ expect(backend.pendingRequests.size).toBe(0);
338
+ });
339
+
340
+ /**
341
+ * Bun does not always report a dead worker through the write: on Linux it
342
+ * occasionally resolves the pending write with a short count instead of
343
+ * rejecting it, and `writeWorkerLine` then stays silent. Nothing times a
344
+ * request out, so the stdout reader ending is the only thing that settles
345
+ * it.
346
+ */
347
+ test("stdout ending settles a request the pipe never reported a failure for", async () => {
348
+ const backend = newBackend();
349
+ const proc = stdoutProc();
350
+ backend.workerProc = proc;
351
+ backend.startStdoutReader();
352
+
353
+ const inFlight = backend.sendRequest(["hello"]);
354
+ proc.closeStdout();
355
+
356
+ await expect(inFlight).resolves.toMatchObject({
357
+ error: expect.stringContaining("exited unexpectedly"),
358
+ });
359
+ expect(backend.pendingRequests.size).toBe(0);
360
+ });
361
+
260
362
  test("a failed write does not leak the pending request", async () => {
261
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
363
+ const backend = newBackend();
262
364
  backend.workerProc = brokenPipeProc();
263
365
 
264
366
  await backend.sendRequest(["hello"]);
@@ -274,7 +376,7 @@ describe("releasing ownership of a worker", () => {
274
376
  * strands it: no handle, no PID entry, invisible to every later reclaim.
275
377
  */
276
378
  test("stdout ending kills the child before clearing workerProc", async () => {
277
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
379
+ const backend = newBackend();
278
380
  const proc = liveProc();
279
381
  backend.workerProc = proc;
280
382
 
@@ -285,7 +387,7 @@ describe("releasing ownership of a worker", () => {
285
387
  });
286
388
 
287
389
  test("shutdown reaps the worker and resolves only once it exits", async () => {
288
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
390
+ const backend = newBackend();
289
391
  const proc = liveProc(555);
290
392
  backend.workerProc = proc;
291
393
  writeFileSync(getEmbedWorkerPidPath(), "555");
@@ -298,7 +400,7 @@ describe("releasing ownership of a worker", () => {
298
400
  });
299
401
 
300
402
  test("shutdown settles in-flight requests instead of hanging them", async () => {
301
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
403
+ const backend = newBackend();
302
404
  backend.workerProc = liveProc(556);
303
405
 
304
406
  const inFlight = backend.sendRequest(["hello"]);
@@ -315,7 +417,7 @@ describe("releasing ownership of a worker", () => {
315
417
  * pending requests are only settled after the wait.
316
418
  */
317
419
  test("a worker that ignores SIGTERM is escalated to SIGKILL, not waited on forever", async () => {
318
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
420
+ const backend = newBackend();
319
421
  backend.terminateGraceMs = 50;
320
422
  const signals: (string | undefined)[] = [];
321
423
  const wedged = {
@@ -354,7 +456,7 @@ describe("termination that cannot be confirmed", () => {
354
456
  }
355
457
 
356
458
  test("keeps the PID publication instead of unpublishing a possibly-live child", async () => {
357
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
459
+ const backend = newBackend();
358
460
  backend.terminateGraceMs = 20;
359
461
  const proc = wedgedProc();
360
462
  backend.workerProc = proc;
@@ -370,7 +472,7 @@ describe("termination that cannot be confirmed", () => {
370
472
  });
371
473
 
372
474
  test("refuses to start a replacement while the child may still be alive", async () => {
373
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
475
+ const backend = newBackend();
374
476
  // No workerPath recorded, so liveness falls back to a PID probe. Our own
375
477
  // PID is certainly alive, standing in for a child that will not die.
376
478
  backend.unconfirmedWorker = process.pid;
@@ -381,7 +483,7 @@ describe("termination that cannot be confirmed", () => {
381
483
  });
382
484
 
383
485
  test("surfaces the incomplete teardown through embed() rather than failing silently", async () => {
384
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
486
+ const backend = newBackend();
385
487
  backend.unconfirmedWorker = process.pid;
386
488
 
387
489
  await expect(backend.embed(["hello"])).rejects.toThrow(
@@ -401,7 +503,7 @@ describe("termination that cannot be confirmed", () => {
401
503
  });
402
504
  await Bun.sleep(400);
403
505
 
404
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
506
+ const backend = newBackend();
405
507
  backend.workerPath = scriptPath;
406
508
  backend.unconfirmedWorker = proc.pid;
407
509
  writeFileSync(getEmbedWorkerPidPath(), String(proc.pid));
@@ -440,7 +542,7 @@ describe("reclaiming before spawning a replacement", () => {
440
542
  await Bun.sleep(400);
441
543
  writeFileSync(getEmbedWorkerPidPath(), String(proc.pid));
442
544
 
443
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
545
+ const backend = newBackend();
444
546
  await backend.reclaimOwnedWorkers(scriptPath);
445
547
 
446
548
  // Confirmed gone by the time reclaim returns, not merely signalled.
@@ -464,7 +566,7 @@ describe("reclaiming before spawning a replacement", () => {
464
566
  spawned.push(proc);
465
567
  await Bun.sleep(400);
466
568
 
467
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
569
+ const backend = newBackend();
468
570
  backend.terminateGraceMs = 300;
469
571
  await backend.reclaimOwnedWorkers(scriptPath);
470
572
 
@@ -497,7 +599,7 @@ setTimeout(() => {}, 60_000);\n`,
497
599
  expect(workers.length).toBe(1);
498
600
  expect(workers[0].ppid).toBe(parent.pid);
499
601
 
500
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
602
+ const backend = newBackend();
501
603
  backend.terminateGraceMs = 300;
502
604
  await backend.reclaimOwnedWorkers(scriptPath);
503
605
 
@@ -521,7 +623,7 @@ describe("shutdown versus in-flight initialization", () => {
521
623
  * lets the initializer spawn a worker afterwards, with nobody left to reap it.
522
624
  */
523
625
  test("shutdown waits for an in-flight init and reaps the worker it creates", async () => {
524
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
626
+ const backend = newBackend();
525
627
  const proc = liveProc(600);
526
628
 
527
629
  let finishInit: () => void = () => {};
@@ -564,7 +666,7 @@ describe("parent exit orderings", () => {
564
666
  * process's and left it alone. So it kills its own child on the way out.
565
667
  */
566
668
  test("terminateNow kills the worker and releases the entry without waiting", () => {
567
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
669
+ const backend = newBackend();
568
670
  const signals: string[] = [];
569
671
  const proc = {
570
672
  pid: 909,
@@ -590,7 +692,7 @@ describe("parent exit orderings", () => {
590
692
  * process while the child was still being adopted.
591
693
  */
592
694
  test("shutdown does not inherit the model-load wait when init never settles", async () => {
593
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
695
+ const backend = newBackend();
594
696
  backend.terminateGraceMs = 50;
595
697
  // An initialization that never resolves, as during a cold model load.
596
698
  backend.initInFlight = new Promise<void>(() => {});
@@ -602,7 +704,7 @@ describe("parent exit orderings", () => {
602
704
  });
603
705
 
604
706
  test("a backend already shutting down refuses to spawn a replacement", async () => {
605
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
707
+ const backend = newBackend();
606
708
  backend.disposeRequested = true;
607
709
 
608
710
  await expect(backend.embed(["hello"])).rejects.toThrow(/shutting down/);
@@ -621,7 +723,7 @@ describe("transient failures stay transient", () => {
621
723
  const PERMANENT_FAILURE_SENTINEL = "Local embedding backend unavailable";
622
724
 
623
725
  test("an unconfirmed termination does not claim permanent unavailability", async () => {
624
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
726
+ const backend = newBackend();
625
727
  backend.unconfirmedWorker = process.pid;
626
728
 
627
729
  const err = await backend.ensureInitialized().catch((e: Error) => e);
@@ -631,7 +733,7 @@ describe("transient failures stay transient", () => {
631
733
  });
632
734
 
633
735
  test("shutting down does not claim permanent unavailability", async () => {
634
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
736
+ const backend = newBackend();
635
737
  backend.disposeRequested = true;
636
738
 
637
739
  const err = await backend.embed(["hello"]).catch((e: Error) => e);
@@ -652,7 +754,7 @@ describe("audit regressions", () => {
652
754
  * embed queued behind it.
653
755
  */
654
756
  test("a startup failure against a wedged worker does not hang", async () => {
655
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
757
+ const backend = newBackend();
656
758
  backend.terminateGraceMs = 30;
657
759
  const signals: (string | undefined)[] = [];
658
760
  backend.workerProc = {
@@ -680,7 +782,7 @@ describe("audit regressions", () => {
680
782
  * startup for the full two-minute timeout.
681
783
  */
682
784
  test("terminateNow clears the stdout buffer so a retry can parse ready", () => {
683
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
785
+ const backend = newBackend();
684
786
  backend.stdoutBuffer = '{"id":5,"vect';
685
787
  backend.workerProc = null;
686
788
 
@@ -707,7 +809,7 @@ describe("audit regressions", () => {
707
809
  spawned.push(proc);
708
810
  await Bun.sleep(400);
709
811
 
710
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
812
+ const backend = newBackend();
711
813
  backend.workerPath = scriptPath;
712
814
  backend.workerProc = null; // spawned, never adopted
713
815
 
@@ -722,7 +824,7 @@ describe("audit regressions", () => {
722
824
  * contract, so it must leave the instance able to start a replacement.
723
825
  */
724
826
  test("terminateNow leaves the instance reusable", () => {
725
- const backend = new LocalEmbeddingBackend("test-model") as Internals;
827
+ const backend = newBackend();
726
828
  backend.workerProc = null;
727
829
  backend.stdoutReaderActive = true;
728
830
 
@@ -766,3 +868,97 @@ test("orphan reclaim outlasts the embedding reap it must not truncate", () => {
766
868
  EMBEDDING_SHUTDOWN_BUDGET_MS + 1_000,
767
869
  );
768
870
  });
871
+
872
+ describe("one backend per model", () => {
873
+ test("forModel hands out one instance per model", () => {
874
+ const first = LocalEmbeddingBackend.forModel("per-model-a");
875
+
876
+ expect(LocalEmbeddingBackend.forModel("per-model-a")).toBe(first);
877
+ expect(LocalEmbeddingBackend.forModel("per-model-b")).not.toBe(first);
878
+ });
879
+
880
+ /**
881
+ * A backend-cache reset disposes the backend it evicts, and that disposal
882
+ * waits for in-flight embeds. The next embed needs the model again inside
883
+ * that window, and the embeds in flight keep their worker.
884
+ */
885
+ test("needing the model again while a disposal is pending keeps the busy worker", () => {
886
+ const backend = LocalEmbeddingBackend.forModel(
887
+ "per-model-reopen",
888
+ ) as Internals;
889
+ const proc = liveProc(7101);
890
+ backend.workerProc = proc;
891
+ backend.activeEmbeds = 1;
892
+ backend.dispose();
893
+
894
+ const again = LocalEmbeddingBackend.forModel(
895
+ "per-model-reopen",
896
+ ) as Internals;
897
+ backend.activeEmbeds = 0;
898
+ backend.disposeIfIdle();
899
+
900
+ expect(again).toBe(backend);
901
+ expect(again.workerProc).toBe(proc);
902
+ expect(proc.killed).toBe(false);
903
+ });
904
+
905
+ test("a pending disposal nobody withdraws still releases the worker once idle", () => {
906
+ const backend = LocalEmbeddingBackend.forModel(
907
+ "per-model-dispose",
908
+ ) as Internals;
909
+ const proc = liveProc(7102);
910
+ backend.workerProc = proc;
911
+ backend.activeEmbeds = 1;
912
+ backend.dispose();
913
+
914
+ backend.activeEmbeds = 0;
915
+ backend.disposeIfIdle();
916
+
917
+ expect(backend.workerProc).toBeNull();
918
+ expect(proc.killed).toBe(true);
919
+ });
920
+
921
+ /** The process is exiting, so nothing may spawn a worker behind the reap. */
922
+ test("a shut-down backend stays shut down", async () => {
923
+ const backend = LocalEmbeddingBackend.forModel("per-model-shutdown");
924
+ await backend.shutdown();
925
+
926
+ const again = LocalEmbeddingBackend.forModel("per-model-shutdown");
927
+
928
+ expect(again).toBe(backend);
929
+ await expect(again.embed(["hello"])).rejects.toThrow(/shutting down/);
930
+ });
931
+
932
+ /**
933
+ * The backend cache forgets a backend whose disposal is pending, so process
934
+ * teardown reaches it through the class's own list.
935
+ */
936
+ test("shutdownAll reaps a busy backend no cache still holds", async () => {
937
+ const backend = LocalEmbeddingBackend.forModel(
938
+ "per-model-evicted",
939
+ ) as Internals;
940
+ const proc = liveProc(7103);
941
+ backend.workerProc = proc;
942
+ backend.activeEmbeds = 1;
943
+ backend.dispose();
944
+
945
+ await LocalEmbeddingBackend.shutdownAll();
946
+
947
+ expect(proc.killed).toBe(true);
948
+ expect(backend.workerProc).toBeNull();
949
+ });
950
+
951
+ test("terminateAllNow kills a busy backend no cache still holds", () => {
952
+ const backend = LocalEmbeddingBackend.forModel(
953
+ "per-model-evicted-now",
954
+ ) as Internals;
955
+ const proc = liveProc(7104);
956
+ backend.workerProc = proc;
957
+ backend.activeEmbeds = 1;
958
+ backend.dispose();
959
+
960
+ LocalEmbeddingBackend.terminateAllNow();
961
+
962
+ expect(proc.killed).toBe(true);
963
+ });
964
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Process teardown reaches every local embedding worker the process owns.
3
+ *
4
+ * Kept in its own file: `shutdownEmbeddingBackends` latches the module into its
5
+ * shut-down state for the rest of the process.
6
+ */
7
+ import { expect, test } from "bun:test";
8
+
9
+ import { AssistantConfigSchema } from "../../config/schema.js";
10
+ import {
11
+ clearEmbeddingBackendCache,
12
+ selectEmbeddingBackend,
13
+ shutdownEmbeddingBackends,
14
+ } from "./embedding-backend.js";
15
+
16
+ /** Reach past `private`, which is compile-time only, so tests drive real state. */
17
+ type Internals = any;
18
+
19
+ /**
20
+ * A backend-cache reset forgets a backend whose disposal is waiting on
21
+ * in-flight embeds, while that backend still holds a live worker.
22
+ */
23
+ test("shutdown reaps a busy local worker the cache has already forgotten", async () => {
24
+ const config = AssistantConfigSchema.parse({
25
+ memory: { embeddings: { provider: "local" } },
26
+ });
27
+ const { backend } = await selectEmbeddingBackend(config);
28
+ const delegate = await (backend as Internals).getDelegate();
29
+ let killed = false;
30
+ delegate.workerProc = {
31
+ pid: 7201,
32
+ exited: new Promise<number>(() => {}),
33
+ kill() {
34
+ killed = true;
35
+ },
36
+ stdin: { write: () => 0, flush: () => 0 },
37
+ };
38
+ delegate.terminateGraceMs = 10;
39
+ delegate.activeEmbeds = 1;
40
+
41
+ clearEmbeddingBackendCache();
42
+ expect(killed).toBe(false);
43
+
44
+ await shutdownEmbeddingBackends();
45
+
46
+ expect(killed).toBe(true);
47
+ });
@@ -16,6 +16,7 @@ import {
16
16
  recordBillingSuccess,
17
17
  } from "./embedding-billing-breaker.js";
18
18
  import { GeminiEmbeddingBackend } from "./embedding-gemini.js";
19
+ import type { LocalEmbeddingBackend } from "./embedding-local.js";
19
20
  import { OllamaEmbeddingBackend } from "./embedding-ollama.js";
20
21
  import {
21
22
  OpenAIEmbeddingBackend,
@@ -78,6 +79,13 @@ export const EMBEDDING_SHUTDOWN_BUDGET_MS = 8_000;
78
79
  * the failure is contained and other embedding backends can be used instead.
79
80
  */
80
81
 
82
+ /**
83
+ * The local backend class, once {@link LazyLocalEmbeddingBackend} has loaded it.
84
+ * It lists every local backend this process owns, which process teardown needs
85
+ * and {@link backendCache} cannot supply.
86
+ */
87
+ let localEmbeddingBackends: typeof LocalEmbeddingBackend | null = null;
88
+
81
89
  class LazyLocalEmbeddingBackend implements EmbeddingBackend {
82
90
  readonly provider = "local" as const;
83
91
  readonly model: string;
@@ -114,21 +122,9 @@ class LazyLocalEmbeddingBackend implements EmbeddingBackend {
114
122
  this.delegate?.dispose?.();
115
123
  }
116
124
 
117
- terminateNow(): void {
118
- this.delegate?.terminateNow?.();
119
- }
120
-
121
- async sweepOwnedWorkers(): Promise<void> {
122
- await this.delegate?.sweepOwnedWorkers?.();
123
- }
124
-
125
- async shutdown(): Promise<void> {
126
- // A delegate under construction still ends up owning a worker, so settle
127
- // the in-flight import before tearing down rather than skipping it.
128
- if (!this.delegate && this.initPromise) {
129
- await this.initPromise.catch(() => undefined);
130
- }
131
- await this.delegate?.shutdown?.();
125
+ /** Settles once a delegate under construction has been handed out or failed. */
126
+ async loaded(): Promise<void> {
127
+ await this.initPromise?.catch(() => undefined);
132
128
  }
133
129
 
134
130
  resetForRetry(): void {
@@ -146,7 +142,8 @@ class LazyLocalEmbeddingBackend implements EmbeddingBackend {
146
142
  try {
147
143
  const { LocalEmbeddingBackend } =
148
144
  await import("./embedding-local.js");
149
- this.delegate = new LocalEmbeddingBackend(this.model);
145
+ localEmbeddingBackends = LocalEmbeddingBackend;
146
+ this.delegate = LocalEmbeddingBackend.forModel(this.model);
150
147
  return this.delegate;
151
148
  } catch (err) {
152
149
  localBackendBroken = true;
@@ -270,7 +267,7 @@ export function clearEmbeddingBackendCache(): void {
270
267
  }
271
268
 
272
269
  /**
273
- * Tear down every cached backend's OS resources and empty the caches.
270
+ * Tear down every local embedding worker this process owns and empty the caches.
274
271
  *
275
272
  * Called on daemon shutdown so process-owned embedding workers exit with their
276
273
  * owner instead of being orphaned. `clearEmbeddingBackendCache()` is the
@@ -279,24 +276,18 @@ export function clearEmbeddingBackendCache(): void {
279
276
  */
280
277
  export async function shutdownEmbeddingBackends(): Promise<void> {
281
278
  embeddingBackendsShutDown = true;
282
- const backends = new Set(backendCache.values());
279
+ const loading = [...backendCache.values()].filter(
280
+ (backend) => backend instanceof LazyLocalEmbeddingBackend,
281
+ );
283
282
  backendCache.clear();
284
283
  vectorCache.clear();
285
284
  vectorCacheBytes = 0;
286
285
  backendDimCache.clear();
287
286
 
288
- const teardown = Promise.all(
289
- [...backends].map(async (backend) => {
290
- try {
291
- await backend.shutdown?.();
292
- await backend.sweepOwnedWorkers?.();
293
- } catch (err) {
294
- log.warn(
295
- { err, provider: backend.provider, model: backend.model },
296
- "Failed to shut down embedding backend",
297
- );
298
- }
299
- }),
287
+ // A delegate under construction still ends up owning a worker, so let it
288
+ // join the class's list before tearing that list down.
289
+ const teardown = Promise.all(loading.map((backend) => backend.loaded())).then(
290
+ () => localEmbeddingBackends?.shutdownAll(),
300
291
  );
301
292
 
302
293
  const timedOut = Symbol("timeout");
@@ -313,7 +304,7 @@ export async function shutdownEmbeddingBackends(): Promise<void> {
313
304
  }
314
305
 
315
306
  /**
316
- * SIGKILL every cached backend's worker synchronously.
307
+ * SIGKILL every local embedding worker this process owns, synchronously.
317
308
  *
318
309
  * For a process that must exit immediately and cannot run the graceful
319
310
  * teardown: the memory worker on PID-file eviction, where staying alive to reap
@@ -322,16 +313,7 @@ export async function shutdownEmbeddingBackends(): Promise<void> {
322
313
  * sweep has passed, leaving two workers alive (JARVIS-1125).
323
314
  */
324
315
  export function terminateEmbeddingWorkersNow(): void {
325
- for (const backend of new Set(backendCache.values())) {
326
- try {
327
- backend.terminateNow?.();
328
- } catch (err) {
329
- log.warn(
330
- { err, provider: backend.provider, model: backend.model },
331
- "Failed to terminate embedding worker",
332
- );
333
- }
334
- }
316
+ localEmbeddingBackends?.terminateAllNow();
335
317
  }
336
318
 
337
319
  /** Reset the sticky local-backend failure flag without evicting live backends. */
@@ -439,6 +439,23 @@ describe("GeminiEmbeddingBackend: batched text inputs", () => {
439
439
  expect(vectors.map((v) => v[0])).toEqual(texts(250).map(textIndex));
440
440
  });
441
441
 
442
+ test("managed text inputs use single calls from the first request and preserve vector order", async () => {
443
+ const fetchMock = routedFetch();
444
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
445
+ const backend = new GeminiEmbeddingBackend("test-key", "test-model", {
446
+ managedBaseUrl: "https://proxy.example.com/v1/runtime-proxy/gemini",
447
+ interCallDelayMs: 0,
448
+ });
449
+
450
+ expect(await backend.embed(texts(3))).toEqual([[0], [1], [2]]);
451
+ expect(await backend.embed(["t4", "t5"])).toEqual([[4], [5]]);
452
+ expect(calledUrls(fetchMock)).toEqual(
453
+ Array(5).fill(
454
+ "https://proxy.example.com/v1/runtime-proxy/gemini/v1beta/models/test-model:embedContent",
455
+ ),
456
+ );
457
+ });
458
+
442
459
  test("each batched request names the model with the models/ prefix and carries taskType and outputDimensionality", async () => {
443
460
  const fetchMock = routedFetch();
444
461
  globalThis.fetch = fetchMock as unknown as typeof fetch;
@@ -71,7 +71,8 @@ export class GeminiEmbeddingBackend implements EmbeddingBackend {
71
71
  }
72
72
 
73
73
  /**
74
- * Embed `inputs` in order. Runs of text inputs go through
74
+ * Embed `inputs` in order. Managed requests use single `embedContent`
75
+ * calls because Vertex does not expose the batch route. Direct text runs use
75
76
  * `batchEmbedContents`, {@link GEMINI_EMBED_BATCH_SIZE} texts per round
76
77
  * trip, so a corpus re-embed costs one request per hundred sections rather
77
78
  * than one per section; a lone text and every multimodal input take the
@@ -99,6 +100,7 @@ export class GeminiEmbeddingBackend implements EmbeddingBackend {
99
100
  // Gather the run of text inputs starting here, up to one batch.
100
101
  let end = i;
101
102
  while (
103
+ !this.managed &&
102
104
  !this.batchRouteUnavailable &&
103
105
  end < normalized.length &&
104
106
  end - i < GEMINI_EMBED_BATCH_SIZE &&