@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
@@ -12,6 +12,10 @@ import { partialTagSuffix as sharedPartialTagSuffix } from "../../util/think-tag
12
12
  import { clampProviderString } from "../content-block-size.js";
13
13
  import { fileBlockToProviderText } from "../file-block-text.js";
14
14
  import { requestSupportsInlineAudio } from "../inline-audio-support.js";
15
+ import {
16
+ isMalformedToolCallFinishReason,
17
+ malformedToolCallError,
18
+ } from "../malformed-tool-call.js";
15
19
  import {
16
20
  base64Source,
17
21
  mediaSourceByteLength,
@@ -1150,6 +1154,7 @@ export class OpenAIChatCompletionsProvider implements Provider {
1150
1154
  >();
1151
1155
  const toolProgress = createToolProgressEmitter(onEvent);
1152
1156
  let finishReason = "unknown";
1157
+ let upstreamFinishReason: string | undefined;
1153
1158
  let responseModel = modelOverride ?? this.model;
1154
1159
  let promptTokens = 0;
1155
1160
  let completionTokens = 0;
@@ -1318,6 +1323,14 @@ export class OpenAIChatCompletionsProvider implements Provider {
1318
1323
  if (choice.finish_reason) {
1319
1324
  finishReason = choice.finish_reason;
1320
1325
  }
1326
+ // OpenRouter normalizes upstream finish reasons and carries the
1327
+ // raw value in `native_finish_reason`.
1328
+ const nativeFinishReason = (
1329
+ choice as { native_finish_reason?: string | null }
1330
+ ).native_finish_reason;
1331
+ if (nativeFinishReason) {
1332
+ upstreamFinishReason = nativeFinishReason;
1333
+ }
1321
1334
  }
1322
1335
 
1323
1336
  if (chunk.usage) {
@@ -1353,6 +1366,23 @@ export class OpenAIChatCompletionsProvider implements Provider {
1353
1366
  cleanupTimeout();
1354
1367
  }
1355
1368
 
1369
+ const malformedFinishReason = [upstreamFinishReason, finishReason].find(
1370
+ isMalformedToolCallFinishReason,
1371
+ );
1372
+ if (malformedFinishReason) {
1373
+ log.warn(
1374
+ {
1375
+ provider: this.name,
1376
+ model: responseModel,
1377
+ finishReason,
1378
+ upstreamFinishReason,
1379
+ streamedTextLength: contentText.length,
1380
+ },
1381
+ "Response ended on a malformed tool call",
1382
+ );
1383
+ throw malformedToolCallError(this.name, malformedFinishReason);
1384
+ }
1385
+
1356
1386
  if (this.parseThinkTags && pendingContent) {
1357
1387
  flushPendingContent(true);
1358
1388
  }
@@ -10,6 +10,8 @@
10
10
  */
11
11
  export const CODEX_SUBSCRIPTION_MODEL_IDS: ReadonlySet<string> = new Set([
12
12
  "gpt-6-astra",
13
+ "gpt-6-sol",
14
+ "gpt-6-luna",
13
15
  "gpt-5.6-sol",
14
16
  "gpt-5.6-terra",
15
17
  "gpt-5.6-luna",
@@ -56,6 +56,11 @@ export const PLATFORM_PROVIDER_META: Record<string, ManagedProviderMeta> = {
56
56
  managed: true,
57
57
  proxyPath: "/v1/runtime-proxy/vellum",
58
58
  },
59
+ typesafe: {
60
+ name: "typesafe",
61
+ managed: true,
62
+ proxyPath: "/v1/runtime-proxy/typesafe",
63
+ },
59
64
  poolside: {
60
65
  name: "poolside",
61
66
  managed: false,
@@ -36,6 +36,7 @@ import {
36
36
  tryAcquireRecoveryProbe,
37
37
  } from "./fallback-breaker.js";
38
38
  import { resolveLogitBiasPreset } from "./inference/logit-bias.js";
39
+ import { MALFORMED_TOOL_CALL_MESSAGE } from "./malformed-tool-call.js";
39
40
  import {
40
41
  isAdaptiveThinkingOnlyModel,
41
42
  isAdaptiveThinkingUnsupportedModel,
@@ -165,18 +166,22 @@ const RETRYABLE_STREAM_PATTERNS = [
165
166
  // Anthropic client salvages most of these into a `_raw`-wrapped tool call
166
167
  // before they surface (see anthropic/stream-content-shadow.ts); ones that
167
168
  // still reach here retry with a corrective note
168
- // (`withUnparseableToolArgsHint`) because the malformation can be
169
- // conditioned on the request context — a byte-identical resend can
170
- // reproduce it indefinitely.
169
+ // (`correctiveRetryHint`) because the malformation can be conditioned on
170
+ // the request context: a byte-identical resend can reproduce it
171
+ // indefinitely.
171
172
  UNPARSEABLE_TOOL_ARGS_SDK_MESSAGE,
173
+ // The response ended on a tool call the model emitted but that did not
174
+ // parse (Gemini `MALFORMED_FUNCTION_CALL` / `UNEXPECTED_TOOL_CALL`). Its
175
+ // text is the broken call's tail, so it retries with a corrective note too.
176
+ MALFORMED_TOOL_CALL_MESSAGE,
172
177
  ];
173
178
 
174
179
  /**
175
- * One-shot note appended to the retried request after a tool-argument JSON
176
- * parse failure. Appended as a trailing text block on the latest user
177
- * message: the request tail sits after every prompt-cache anchor, so the
178
- * hint costs no cache reuse (a system-prompt edit would invalidate the whole
179
- * cached prefix).
180
+ * One-shot notes appended to a retried request after the model's output was
181
+ * malformed in a way a byte-identical resend can reproduce. Appended as a
182
+ * trailing text block on the latest user message: the request tail sits
183
+ * after every prompt-cache anchor, so the hint costs no cache reuse (a
184
+ * system-prompt edit would invalidate the whole cached prefix).
180
185
  */
181
186
  const UNPARSEABLE_TOOL_ARGS_RETRY_HINT =
182
187
  "[assistant runtime] The previous attempt at this response was discarded: " +
@@ -185,23 +190,51 @@ const UNPARSEABLE_TOOL_ARGS_RETRY_HINT =
185
190
  "every string value double-quoted, including values that begin with '[' " +
186
191
  "or '{'.";
187
192
 
188
- function isUnparseableToolArgsError(error: unknown): boolean {
193
+ const MALFORMED_TOOL_CALL_RETRY_HINT =
194
+ "[assistant runtime] The previous attempt at this response was discarded: " +
195
+ "it ended on a tool call that did not parse. Respond again. To call a " +
196
+ "tool, emit one complete, well-formed tool call through the tool-calling " +
197
+ "interface, never as text.";
198
+
199
+ const CORRECTIVE_RETRY_HINTS: ReadonlyArray<{
200
+ pattern: string;
201
+ hint: string;
202
+ }> = [
203
+ {
204
+ pattern: UNPARSEABLE_TOOL_ARGS_SDK_MESSAGE,
205
+ hint: UNPARSEABLE_TOOL_ARGS_RETRY_HINT,
206
+ },
207
+ {
208
+ pattern: MALFORMED_TOOL_CALL_MESSAGE,
209
+ hint: MALFORMED_TOOL_CALL_RETRY_HINT,
210
+ },
211
+ ];
212
+
213
+ /**
214
+ * The corrective note a retry of `error` carries, or `undefined` when the
215
+ * error is not a request-conditioned output malformation. Only status-less
216
+ * provider errors qualify: those come from content the upstream streamed
217
+ * after accepting the request.
218
+ */
219
+ function correctiveRetryHint(error: unknown): string | undefined {
189
220
  if (!(error instanceof ProviderError)) {
190
- return false;
221
+ return undefined;
191
222
  }
192
223
  if (error.statusCode !== undefined) {
193
- return false;
224
+ return undefined;
194
225
  }
195
- return error.message.includes(UNPARSEABLE_TOOL_ARGS_SDK_MESSAGE);
226
+ return CORRECTIVE_RETRY_HINTS.find(({ pattern }) =>
227
+ error.message.includes(pattern),
228
+ )?.hint;
196
229
  }
197
230
 
198
231
  /**
199
- * Copy of `messages` with the corrective note appended to the latest user
200
- * message. When the request doesn't end on a user message (assistant
201
- * prefill), returns `messages` unchanged — appending anything there would
202
- * change prefill semantics.
232
+ * Copy of `messages` with `hint` appended to the latest user message. When
233
+ * the request doesn't end on a user message (assistant prefill), returns
234
+ * `messages` unchanged, since appending anything there would change prefill
235
+ * semantics.
203
236
  */
204
- function withUnparseableToolArgsHint(messages: Message[]): Message[] {
237
+ function withRetryHint(messages: Message[], hint: string): Message[] {
205
238
  const last = messages[messages.length - 1];
206
239
  if (last === undefined || last.role !== "user") {
207
240
  return messages;
@@ -210,10 +243,7 @@ function withUnparseableToolArgsHint(messages: Message[]): Message[] {
210
243
  ...messages.slice(0, -1),
211
244
  {
212
245
  ...last,
213
- content: [
214
- ...last.content,
215
- { type: "text", text: UNPARSEABLE_TOOL_ARGS_RETRY_HINT },
216
- ],
246
+ content: [...last.content, { type: "text", text: hint }],
217
247
  },
218
248
  ];
219
249
  }
@@ -1393,12 +1423,10 @@ export class RetryProvider implements Provider {
1393
1423
  // outage the route had nothing to do with. Skipped when the hint
1394
1424
  // has nowhere to go (an assistant prefill tail), since an
1395
1425
  // unchanged resend would only cost another round trip.
1396
- if (
1397
- !correctiveResendAttempted &&
1398
- isUnparseableToolArgsError(error)
1399
- ) {
1426
+ const hint = correctiveRetryHint(error);
1427
+ if (!correctiveResendAttempted && hint !== undefined) {
1400
1428
  correctiveResendAttempted = true;
1401
- const repaired = withUnparseableToolArgsHint(messages);
1429
+ const repaired = withRetryHint(messages, hint);
1402
1430
  if (repaired !== messages) {
1403
1431
  messagesForAttempt = repaired;
1404
1432
  continue;
@@ -1522,12 +1550,13 @@ export class RetryProvider implements Provider {
1522
1550
  }
1523
1551
 
1524
1552
  if (retryAttempt < DEFAULT_MAX_RETRIES && isRetryableError(error)) {
1525
- // Malformed tool-argument JSON is conditioned on the request, so
1526
- // resend with the corrective note. Built from the original
1527
- // `messages` each time — the note appears exactly once no matter
1528
- // how many attempts fail this way.
1529
- if (isUnparseableToolArgsError(error)) {
1530
- messagesForAttempt = withUnparseableToolArgsHint(messages);
1553
+ // A malformed tool call is conditioned on the request, so resend
1554
+ // with the corrective note. Built from the original `messages`
1555
+ // each time, so the note appears exactly once no matter how many
1556
+ // attempts fail this way.
1557
+ const hint = correctiveRetryHint(error);
1558
+ if (hint !== undefined) {
1559
+ messagesForAttempt = withRetryHint(messages, hint);
1531
1560
  }
1532
1561
  // Prefer server-provided Retry-After; fall back to exponential backoff.
1533
1562
  const { delay, retryAfterHeader } = retryPlan(error, retryAttempt);
@@ -1587,9 +1616,11 @@ export class RetryProvider implements Provider {
1587
1616
  ) {
1588
1617
  fallbackAttempted = true;
1589
1618
  // What the failure indicts: the whole upstream for an outage, only
1590
- // this model for a retirement or rename.
1619
+ // this model for a retirement or rename. A malformed output that
1620
+ // takes a corrective hint indicts neither: it is conditioned on this
1621
+ // request, so the backup may serve it without diverting the route.
1591
1622
  const failedRoute =
1592
- breakerRoute === null
1623
+ breakerRoute === null || correctiveRetryHint(error) !== undefined
1593
1624
  ? null
1594
1625
  : failureBreakerRoute(breakerRoute, error);
1595
1626
  let failureObservation: BreakerObservation | undefined;
@@ -1730,8 +1761,9 @@ export class RetryProvider implements Provider {
1730
1761
  }
1731
1762
  // Built from the original `messages` each time, so the corrective note
1732
1763
  // appears exactly once however many attempts fail this way.
1733
- if (isUnparseableToolArgsError(error)) {
1734
- messagesForAttempt = withUnparseableToolArgsHint(messages);
1764
+ const hint = correctiveRetryHint(error);
1765
+ if (hint !== undefined) {
1766
+ messagesForAttempt = withRetryHint(messages, hint);
1735
1767
  }
1736
1768
  const { delay, retryAfterHeader } = retryPlan(error, attempt);
1737
1769
  log.warn(
@@ -187,6 +187,16 @@ export const SEARCH_PROVIDER_CATALOG: readonly SearchProviderCatalogEntry[] = [
187
187
  supportsApiBase: true,
188
188
  defaultApiBase: "https://api.search.tinyfish.ai",
189
189
  },
190
+ {
191
+ id: "exa",
192
+ displayName: "Exa",
193
+ kind: "byok",
194
+ apiKeyPrefix: "Exa API key...",
195
+ envVar: "EXA_API_KEY",
196
+ secretKey: "exa",
197
+ fallbackOrder: 9,
198
+ privacyPolicyUrl: "https://exa.ai/privacy-policy",
199
+ },
190
200
  ];
191
201
 
192
202
  /** Provider ids accepted by the web-search config schema. */
@@ -73,6 +73,7 @@ describe("vellum-model-routing", () => {
73
73
  "gemini",
74
74
  "openai",
75
75
  "together",
76
+ "typesafe",
76
77
  "vellum",
77
78
  ]);
78
79
  });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The `sidebar-done` gate on the wake's archived-conversation rejection.
3
+ *
4
+ * `defaultResolveTarget` is module-private, so it is exercised through
5
+ * `wakeAgentForOpportunity()` called without explicit deps. The hydrate step
6
+ * after the archived check is stubbed to throw, which the resolver catches
7
+ * and reports as `not_found` — a flag-on result of `not_found` therefore
8
+ * proves the wake got PAST the archived check and tried to hydrate, while a
9
+ * result of `archived` proves it did not.
10
+ */
11
+
12
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
13
+
14
+ let sidebarDoneEnabled = false;
15
+ mock.module("../../config/sidebar-done-gate.js", () => ({
16
+ SIDEBAR_DONE_FLAG_KEY: "sidebar-done",
17
+ isSidebarDoneEnabled: () => sidebarDoneEnabled,
18
+ }));
19
+
20
+ const ARCHIVED_CONVERSATION_ID = "conv-archived-wake";
21
+
22
+ const getConversationMock = mock((id: string) =>
23
+ id === ARCHIVED_CONVERSATION_ID
24
+ ? { id, archivedAt: 1_700_000_000_000 }
25
+ : undefined,
26
+ );
27
+ const actualCrud = await import("../../persistence/conversation-crud.js");
28
+ mock.module("../../persistence/conversation-crud.js", () => ({
29
+ ...actualCrud,
30
+ getConversation: getConversationMock,
31
+ }));
32
+
33
+ const getOrCreateConversationMock = mock(async () => {
34
+ throw new Error("hydrate stub");
35
+ });
36
+ const actualStore = await import("../../daemon/conversation-store.js");
37
+ mock.module("../../daemon/conversation-store.js", () => ({
38
+ ...actualStore,
39
+ getOrCreateConversation: getOrCreateConversationMock,
40
+ }));
41
+
42
+ import { wakeAgentForOpportunity } from "../agent-wake.js";
43
+
44
+ describe("agent-wake archived conversations under sidebar-done", () => {
45
+ beforeEach(() => {
46
+ getConversationMock.mockClear();
47
+ getOrCreateConversationMock.mockClear();
48
+ });
49
+
50
+ test("rejects the wake with reason archived when the flag is off", async () => {
51
+ sidebarDoneEnabled = false;
52
+
53
+ const result = await wakeAgentForOpportunity({
54
+ conversationId: ARCHIVED_CONVERSATION_ID,
55
+ hint: "scheduled run",
56
+ source: "schedule",
57
+ });
58
+
59
+ expect(result.invoked).toBe(false);
60
+ expect(result.reason).toBe("archived");
61
+ expect(getOrCreateConversationMock).not.toHaveBeenCalled();
62
+ });
63
+
64
+ test("proceeds past the archived check when the flag is on", async () => {
65
+ sidebarDoneEnabled = true;
66
+
67
+ const result = await wakeAgentForOpportunity({
68
+ conversationId: ARCHIVED_CONVERSATION_ID,
69
+ hint: "scheduled run",
70
+ source: "schedule",
71
+ });
72
+
73
+ expect(result.reason).not.toBe("archived");
74
+ expect(getOrCreateConversationMock).toHaveBeenCalledTimes(1);
75
+ });
76
+
77
+ test("still reports not_found for a conversation that does not exist", async () => {
78
+ sidebarDoneEnabled = true;
79
+
80
+ const result = await wakeAgentForOpportunity({
81
+ conversationId: "conv-missing",
82
+ hint: "scheduled run",
83
+ source: "schedule",
84
+ });
85
+
86
+ expect(result.invoked).toBe(false);
87
+ expect(result.reason).toBe("not_found");
88
+ expect(getOrCreateConversationMock).not.toHaveBeenCalled();
89
+ });
90
+ });
@@ -73,6 +73,38 @@ describe("resolvePendingQuestion", () => {
73
73
  });
74
74
  });
75
75
 
76
+ test("chat_reply: the typed text answers the first question, the rest stay unanswered", () => {
77
+ const resolved: QuestionPromptResult[] = [];
78
+ registerQuestion(
79
+ "req-1",
80
+ [
81
+ { id: "q1", options: ["a"] },
82
+ { id: "q2", options: ["b"] },
83
+ ],
84
+ (v) => resolved.push(v as QuestionPromptResult),
85
+ );
86
+
87
+ const outcome = resolvePendingQuestion("req-1", {
88
+ kind: "chat_reply",
89
+ text: "tuesday, and keep it short",
90
+ });
91
+
92
+ expect(outcome.status).toBe("resolved");
93
+ expect(resolved[0]).toEqual({
94
+ overall: "completed",
95
+ entries: [
96
+ {
97
+ questionId: "q1",
98
+ decision: "free_text",
99
+ text: "tuesday, and keep it short",
100
+ },
101
+ { questionId: "q2", decision: "skipped" },
102
+ ],
103
+ answeredInChat: true,
104
+ });
105
+ expect(pendingInteractions.get("req-1")).toBeUndefined();
106
+ });
107
+
76
108
  test("not_found: unknown or wrong-kind requestId leaves nothing resolved", () => {
77
109
  expect(resolvePendingQuestion("missing", { kind: "close" })).toEqual({
78
110
  status: "not_found",
@@ -84,6 +84,7 @@ import {
84
84
  } from "../config/llm-resolver.js";
85
85
  import { getConfig } from "../config/loader.js";
86
86
  import type { LLMCallSite } from "../config/schemas/llm.js";
87
+ import { isSidebarDoneEnabled } from "../config/sidebar-done-gate.js";
87
88
  import { conversationSupportsDynamicUi } from "../daemon/channel-ui-capability.js";
88
89
  import type { Conversation } from "../daemon/conversation.js";
89
90
  import type { QueueDrainReason } from "../daemon/conversation-queue-manager.js";
@@ -506,7 +507,8 @@ export interface WakeDeps {
506
507
  /**
507
508
  * Resolve the live {@link Conversation} for a wake invocation.
508
509
  * Returns `null` if the conversation doesn't exist, `"archived"` if it
509
- * exists but is archived, or the `Conversation` to proceed with the wake.
510
+ * exists but is archived and the `sidebar-done` gate is off, or the
511
+ * `Conversation` to proceed with the wake.
510
512
  *
511
513
  * Receives the full {@link WakeOptions} so the default resolver can
512
514
  * thread `trustContext` into `getOrCreateConversation`. Without that
@@ -546,7 +548,12 @@ async function defaultResolveTarget(
546
548
  if (!existing) {
547
549
  return null;
548
550
  }
549
- if (existing.archivedAt != null) {
551
+ // Under the `sidebar-done` gate an archived conversation is one the user
552
+ // marked Done, not one they half-deleted: its schedules, channel threads
553
+ // and background tools keep working, and the first message the wake
554
+ // produces for the user brings the conversation back to the list
555
+ // (`resurfaceArchivedConversation`).
556
+ if (existing.archivedAt != null && !isSidebarDoneEnabled()) {
550
557
  log.info(
551
558
  { conversationId },
552
559
  "agent-wake: conversation is archived; rejecting wake",
@@ -325,6 +325,144 @@ describe("authenticateRequest for /v1/host-browser-result", () => {
325
325
  });
326
326
  });
327
327
 
328
+ // ---------------------------------------------------------------------------
329
+ // Dev bypass covers only a request that supplied no Authorization header. A
330
+ // bearer sent to a platform pod (the gateway's exchange token, or a trusted
331
+ // contact's) is verified on its own terms.
332
+ // ---------------------------------------------------------------------------
333
+
334
+ describe("authenticateRequest with auth disabled and a bearer present", () => {
335
+ beforeEach(() => {
336
+ authDisabled = true;
337
+ });
338
+
339
+ test("uses the token's claims rather than the dev-bypass context", () => {
340
+ const token = mintValidToken({ sub: "actor:self:contact-principal" });
341
+
342
+ const req = new Request("http://localhost/v1/messages", {
343
+ method: "POST",
344
+ headers: { Authorization: `Bearer ${token}` },
345
+ });
346
+
347
+ const result = authenticateRequest(req);
348
+ expect(result.ok).toBe(true);
349
+ if (result.ok) {
350
+ expect(result.context.actorPrincipalId).toBe("contact-principal");
351
+ expect(result.context.policyEpoch).toBe(CURRENT_POLICY_EPOCH);
352
+ }
353
+ });
354
+
355
+ test("accepts the gateway's exchange token", () => {
356
+ const token = mintValidToken({
357
+ sub: "svc:gateway:self",
358
+ scope_profile: "gateway_service_v1",
359
+ });
360
+
361
+ const req = new Request("http://localhost/v1/messages", {
362
+ method: "POST",
363
+ headers: { Authorization: `Bearer ${token}` },
364
+ });
365
+
366
+ const result = authenticateRequest(req);
367
+ expect(result.ok).toBe(true);
368
+ if (result.ok) {
369
+ expect(result.context.principalType).toBe("svc_gateway");
370
+ }
371
+ });
372
+
373
+ test("accepts a gateway-audience token through the fallback", () => {
374
+ const token = mintValidToken({
375
+ aud: "vellum-gateway",
376
+ sub: "actor:self:local-client",
377
+ });
378
+
379
+ const req = new Request("http://localhost/v1/messages", {
380
+ method: "POST",
381
+ headers: { Authorization: `Bearer ${token}` },
382
+ });
383
+
384
+ const result = authenticateRequest(req);
385
+ expect(result.ok).toBe(true);
386
+ if (result.ok) {
387
+ expect(result.context.actorPrincipalId).toBe("local-client");
388
+ expect(result.context.assistantId).toBe(DAEMON_INTERNAL_ASSISTANT_ID);
389
+ }
390
+ });
391
+
392
+ test.each([
393
+ ["malformed", () => "not-a-token.xxxxxxxxxxxxx"],
394
+ [
395
+ "expired",
396
+ () => mintValidToken({ exp: Math.floor(Date.now() / 1000) - 100 }),
397
+ ],
398
+ [
399
+ "wrong-audience",
400
+ () => mintValidToken({ aud: "vellum-other" as TokenAudience }),
401
+ ],
402
+ ["unparseable-sub", () => mintValidToken({ sub: "garbage" })],
403
+ ])("refuses a %s token rather than falling back", (_label, build) => {
404
+ const req = new Request("http://localhost/v1/messages", {
405
+ method: "POST",
406
+ headers: { Authorization: `Bearer ${build()}` },
407
+ });
408
+
409
+ const result = authenticateRequest(req);
410
+ expect(result.ok).toBe(false);
411
+ if (!result.ok) {
412
+ expect(result.response.status).toBe(401);
413
+ }
414
+ });
415
+
416
+ test("still returns the dev-bypass context when no bearer is sent", () => {
417
+ const req = new Request("http://localhost/v1/messages", {
418
+ method: "POST",
419
+ });
420
+
421
+ const result = authenticateRequest(req);
422
+ expect(result.ok).toBe(true);
423
+ if (result.ok) {
424
+ expect(result.context.actorPrincipalId).toBe("dev-bypass");
425
+ }
426
+ });
427
+
428
+ test("verifies a lowercase bearer scheme rather than bypassing it", () => {
429
+ const token = mintValidToken({ sub: "actor:self:contact-principal" });
430
+
431
+ const req = new Request("http://localhost/v1/messages", {
432
+ method: "POST",
433
+ headers: { Authorization: `bearer ${token}` },
434
+ });
435
+
436
+ const result = authenticateRequest(req);
437
+ expect(result.ok).toBe(true);
438
+ if (result.ok) {
439
+ expect(result.context.actorPrincipalId).toBe("contact-principal");
440
+ }
441
+ });
442
+
443
+ test.each([
444
+ ["lowercase scheme with a bad token", "bearer not-a-jwt.xxxxxxxx"],
445
+ ["empty credential", "Bearer "],
446
+ ["non-bearer scheme", "Basic dXNlcjpwYXNz"],
447
+ ["an empty header value", ""],
448
+ ["a whitespace-only header value", " "],
449
+ ])(
450
+ "refuses %s instead of granting the dev-bypass context",
451
+ (_label, header) => {
452
+ const req = new Request("http://localhost/v1/messages", {
453
+ method: "POST",
454
+ headers: { Authorization: header },
455
+ });
456
+
457
+ const result = authenticateRequest(req);
458
+ expect(result.ok).toBe(false);
459
+ if (!result.ok) {
460
+ expect(result.response.status).toBe(401);
461
+ }
462
+ },
463
+ );
464
+ });
465
+
328
466
  // ---------------------------------------------------------------------------
329
467
  // oauth_proxy_v1 grants: the narrow bearer a third-party CLI presents to the
330
468
  // OAuth passthrough route. Accepted on either audience, and carrying nothing
@@ -17,9 +17,10 @@
17
17
  * Replaces both the legacy bearer shared-secret check and the
18
18
  * actor-token HMAC middleware with a single JWT verification path.
19
19
  *
20
- * When DISABLE_HTTP_AUTH is set (platform-managed deployments), JWT
21
- * verification is skipped and a synthetic AuthContext is constructed
22
- * so downstream code always has a typed context to consume.
20
+ * When DISABLE_HTTP_AUTH is set (platform-managed deployments), a request
21
+ * carrying no bearer gets a synthetic AuthContext so downstream code always
22
+ * has a typed context to consume. A request that carries one is verified
23
+ * normally, and a bad token is refused rather than falling back.
23
24
  */
24
25
 
25
26
  import { isHttpAuthDisabled } from "../../config/env.js";
@@ -73,17 +74,18 @@ function buildDevBypassContext(): AuthContext {
73
74
  * The caller should return the error Response directly to the client.
74
75
  */
75
76
  export function authenticateRequest(req: Request): AuthenticateResult {
76
- // Dev bypass: skip JWT verification entirely
77
- if (isHttpAuthDisabled()) {
78
- return { ok: true, context: buildDevBypassContext() };
79
- }
80
-
81
- const path = new URL(req.url).pathname;
82
-
83
77
  const rawToken = extractBearerToken(req);
84
78
  if (!rawToken) {
79
+ // Dev bypass covers only a request that supplied no Authorization header
80
+ // at all. A header that is present but unusable (empty or whitespace-only
81
+ // value, a non-bearer scheme, a malformed credential) is refused rather
82
+ // than handed the guardian context. `has` rather than `get`: an empty
83
+ // value reads back as "" and would otherwise pass as absent.
84
+ if (isHttpAuthDisabled() && !req.headers.has("authorization")) {
85
+ return { ok: true, context: buildDevBypassContext() };
86
+ }
85
87
  log.warn(
86
- { reason: "missing_token", path },
88
+ { reason: "missing_token", path: new URL(req.url).pathname },
87
89
  "Auth denied: missing Authorization header",
88
90
  );
89
91
  return {
@@ -100,6 +102,8 @@ export function authenticateRequest(req: Request): AuthenticateResult {
100
102
  };
101
103
  }
102
104
 
105
+ const path = new URL(req.url).pathname;
106
+
103
107
  // Verify the JWT — prefer vellum-daemon audience (gateway-proxied requests
104
108
  // and daemon-minted tokens), but also accept vellum-gateway audience for
105
109
  // direct local clients (macOS SettingsStore) that hold a guardian-issued JWT
@@ -156,8 +156,14 @@ export function isPrivateNetworkOrigin(req: Request): boolean {
156
156
  /**
157
157
  * Extract and validate a bearer token from the Authorization header.
158
158
  * Returns the token string if present, or null.
159
+ *
160
+ * The scheme is matched case-insensitively, per RFC 7235 and to match the
161
+ * gateway's own parsing. An empty credential returns null.
159
162
  */
160
163
  export function extractBearerToken(req: Request): string | null {
161
164
  const authHeader = req.headers.get("authorization");
162
- return authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
165
+ if (!authHeader?.toLowerCase().startsWith("bearer ")) {
166
+ return null;
167
+ }
168
+ return authHeader.slice(7) || null;
163
169
  }