@vellumai/assistant 0.8.7 → 0.8.8

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 (387) hide show
  1. package/Dockerfile +20 -4
  2. package/docker-entrypoint.sh +4 -2
  3. package/docker-init-apt-root.sh +3 -1
  4. package/docker-kata-apt-env.sh +3 -1
  5. package/docker-kata-runtime-family.sh +12 -0
  6. package/docs/architecture/memory.md +1 -1
  7. package/docs/plugins.md +75 -79
  8. package/examples/plugins/echo/README.md +6 -12
  9. package/examples/plugins/echo/register.ts +0 -41
  10. package/node_modules/@vellumai/skill-host-contracts/src/server-message.ts +3 -3
  11. package/openapi.yaml +3381 -348
  12. package/package.json +1 -1
  13. package/scripts/generate-openapi.ts +68 -41
  14. package/src/__tests__/agent-loop-exit-reason.test.ts +34 -39
  15. package/src/__tests__/agent-loop-provider-error-recording.test.ts +1 -1
  16. package/src/__tests__/agent-loop.test.ts +37 -87
  17. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +2 -0
  18. package/src/__tests__/annotate-activity-metadata.test.ts +262 -0
  19. package/src/__tests__/annotate-risk-options.test.ts +2 -3
  20. package/src/__tests__/anthropic-provider.test.ts +95 -2
  21. package/src/__tests__/assistant-event-hub.test.ts +25 -0
  22. package/src/__tests__/assistant-events-sse-shed.test.ts +8 -0
  23. package/src/__tests__/{conversation-stream-state.test.ts → assistant-stream-state.test.ts} +252 -91
  24. package/src/__tests__/auth-fallback-events-store.test.ts +116 -0
  25. package/src/__tests__/background-workers-disk-pressure.test.ts +6 -0
  26. package/src/__tests__/btw-routes.test.ts +62 -3
  27. package/src/__tests__/build-persisted-content.test.ts +184 -0
  28. package/src/__tests__/catalog-files.test.ts +1 -1
  29. package/src/__tests__/clawhub-files.test.ts +1 -1
  30. package/src/__tests__/compaction-pipeline.test.ts +1 -1
  31. package/src/__tests__/compaction.benchmark.test.ts +0 -30
  32. package/src/__tests__/config-watcher.test.ts +1 -1
  33. package/src/__tests__/conversation-abort-tool-results.test.ts +57 -19
  34. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +6 -2
  35. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +10 -4
  36. package/src/__tests__/conversation-agent-loop-overflow.test.ts +313 -1136
  37. package/src/__tests__/conversation-agent-loop.test.ts +596 -1616
  38. package/src/__tests__/conversation-analysis-routes.test.ts +6 -0
  39. package/src/__tests__/conversation-history-web-search.test.ts +11 -1
  40. package/src/__tests__/conversation-pairing.test.ts +4 -31
  41. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +6 -0
  42. package/src/__tests__/conversation-provider-retry-repair.test.ts +26 -5
  43. package/src/__tests__/conversation-queue.test.ts +2 -0
  44. package/src/__tests__/conversation-routes-disk-view.test.ts +3 -0
  45. package/src/__tests__/conversation-routes-slash-commands.test.ts +6 -5
  46. package/src/__tests__/conversation-runtime-assembly.test.ts +170 -229
  47. package/src/__tests__/conversation-runtime-workspace.test.ts +3 -24
  48. package/src/__tests__/conversation-slash-commands.test.ts +8 -42
  49. package/src/__tests__/conversation-slash-queue.test.ts +6 -1
  50. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +84 -0
  51. package/src/__tests__/conversation-sync-tags.test.ts +27 -15
  52. package/src/__tests__/conversation-title-service.test.ts +135 -2
  53. package/src/__tests__/conversation-workspace-injection.test.ts +6 -1
  54. package/src/__tests__/cross-provider-web-search.test.ts +214 -1
  55. package/src/__tests__/db-schedule-syntax-migration.test.ts +5 -0
  56. package/src/__tests__/dm-persistence.test.ts +5 -1
  57. package/src/__tests__/empty-response-hook.test.ts +304 -0
  58. package/src/__tests__/feature-flag-test-helpers.ts +2 -2
  59. package/src/__tests__/gemini-image-service.test.ts +13 -0
  60. package/src/__tests__/helpers/mock-provider.ts +110 -0
  61. package/src/__tests__/helpers/native-web-search-harness.ts +129 -0
  62. package/src/__tests__/history-repair-hook.test.ts +1 -0
  63. package/src/__tests__/identity-intro-cache.test.ts +12 -100
  64. package/src/__tests__/identity-routes.test.ts +248 -7
  65. package/src/__tests__/inbound-slack-persistence.test.ts +5 -1
  66. package/src/__tests__/injector-background-turn.test.ts +2 -8
  67. package/src/__tests__/injector-chain.test.ts +106 -270
  68. package/src/__tests__/injector-disk-pressure.test.ts +3 -12
  69. package/src/__tests__/injector-document-comments.test.ts +2 -2
  70. package/src/__tests__/injector-pkb-v2-silenced.test.ts +30 -22
  71. package/src/__tests__/injector-v3-suppression.test.ts +31 -37
  72. package/src/__tests__/internal-telemetry-routes.test.ts +109 -0
  73. package/src/__tests__/list-messages-page-latest.test.ts +60 -0
  74. package/src/__tests__/list-messages-tool-merge.test.ts +20 -0
  75. package/src/__tests__/llm-usage-store.test.ts +223 -1
  76. package/src/__tests__/memory-retrieval-hook.test.ts +297 -0
  77. package/src/__tests__/memory-v2-static-injector.test.ts +103 -35
  78. package/src/__tests__/native-web-search.test.ts +191 -0
  79. package/src/__tests__/onboarding-template-contract.test.ts +2 -0
  80. package/src/__tests__/openai-image-service.test.ts +17 -0
  81. package/src/__tests__/openai-provider.test.ts +31 -1
  82. package/src/__tests__/persist-unsendable-image.test.ts +215 -0
  83. package/src/__tests__/persistence-secret-redaction.test.ts +1 -0
  84. package/src/__tests__/pipeline-runner.test.ts +29 -39
  85. package/src/__tests__/pkb-autoinject.test.ts +2 -5
  86. package/src/__tests__/plugin-bootstrap.test.ts +13 -28
  87. package/src/__tests__/plugin-registry.test.ts +0 -27
  88. package/src/__tests__/plugin-types.test.ts +2 -125
  89. package/src/__tests__/process-message-display-content.test.ts +6 -2
  90. package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +5 -1
  91. package/src/__tests__/resolve-trust-class.test.ts +4 -4
  92. package/src/__tests__/runtime-events-sse-reconnect.test.ts +60 -23
  93. package/src/__tests__/schedule-routes.test.ts +603 -2
  94. package/src/__tests__/schedule-store.test.ts +41 -0
  95. package/src/__tests__/schedule-tools.test.ts +35 -0
  96. package/src/__tests__/server-history-render.test.ts +314 -1
  97. package/src/__tests__/skillssh-files.test.ts +1 -1
  98. package/src/__tests__/system-prompt.test.ts +20 -0
  99. package/src/__tests__/task-scheduler.test.ts +162 -1
  100. package/src/__tests__/terminal-tools.test.ts +6 -1
  101. package/src/__tests__/title-generate-hook.test.ts +319 -0
  102. package/src/__tests__/tool-error-hook.test.ts +278 -0
  103. package/src/__tests__/tool-preview-lifecycle.test.ts +468 -5
  104. package/src/__tests__/tool-result-metadata-plumbing.test.ts +1 -0
  105. package/src/__tests__/tool-result-truncate-hook.test.ts +127 -0
  106. package/src/__tests__/tool-result-truncation.test.ts +0 -2
  107. package/src/__tests__/ui-choice-copy-surfaces.test.ts +254 -0
  108. package/src/__tests__/ui-work-result-surface.test.ts +159 -0
  109. package/src/__tests__/usage-routes.test.ts +285 -1
  110. package/src/__tests__/user-plugin-loader.test.ts +2 -2
  111. package/src/__tests__/voice-session-bridge.test.ts +6 -3
  112. package/src/__tests__/web-search-backend-failure.test.ts +166 -0
  113. package/src/agent/loop.ts +346 -442
  114. package/src/api/events/assistant-thinking-delta.ts +33 -0
  115. package/src/api/events/tool-output-chunk.ts +45 -0
  116. package/src/api/events/tool-use-preview-start.ts +32 -0
  117. package/src/api/events/trace-event.ts +69 -0
  118. package/src/api/index.ts +48 -13
  119. package/src/api/responses/conversation-message.ts +368 -0
  120. package/src/avatar/__tests__/avatar-store.test.ts +34 -29
  121. package/src/cli/commands/__tests__/notifications.test.ts +58 -14
  122. package/src/cli/commands/notifications.ts +112 -60
  123. package/src/config/assistant-feature-flags.ts +22 -11
  124. package/src/config/bundled-skills/app-builder/SKILL.md +3 -20
  125. package/src/config/bundled-skills/app-builder/references/examples/README.md +17 -0
  126. package/src/config/bundled-skills/app-builder/references/examples/expense-tracker.md +515 -0
  127. package/src/config/bundled-skills/app-builder/references/examples/focus-timer.md +342 -0
  128. package/src/config/bundled-skills/app-builder/references/examples/habit-tracker.md +490 -0
  129. package/src/config/bundled-skills/document-editor/SKILL.md +1 -1
  130. package/src/config/bundled-skills/messaging/SKILL.md +0 -7
  131. package/src/config/feature-flag-cache.ts +3 -3
  132. package/src/config/feature-flag-registry.json +35 -3
  133. package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
  134. package/src/config/schemas/__tests__/memory-v3.test.ts +25 -0
  135. package/src/config/schemas/llm.ts +1 -0
  136. package/src/config/schemas/memory-v2.ts +8 -0
  137. package/src/config/schemas/memory-v3.ts +8 -0
  138. package/src/config/schemas/platform.ts +8 -0
  139. package/src/config/seed-inference-profiles.ts +2 -2
  140. package/src/config/skills.ts +13 -0
  141. package/src/context/compactor.ts +1 -1
  142. package/src/context/strip-injections.ts +122 -0
  143. package/src/context/token-estimator.ts +23 -0
  144. package/src/context/tool-result-truncation.ts +0 -23
  145. package/src/context/window-manager.ts +3 -6
  146. package/src/credential-execution/executable-discovery.ts +16 -0
  147. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +6 -0
  148. package/src/daemon/__tests__/inference-profile-notification.test.ts +153 -0
  149. package/src/daemon/__tests__/native-web-search-metadata.test.ts +10 -8
  150. package/src/daemon/assistant-attachments.ts +1 -1
  151. package/src/daemon/config-watcher.ts +2 -2
  152. package/src/daemon/context-overflow-reducer.ts +0 -1
  153. package/src/daemon/conversation-agent-loop-handlers.ts +605 -153
  154. package/src/daemon/conversation-agent-loop.ts +281 -760
  155. package/src/daemon/conversation-history.ts +5 -4
  156. package/src/daemon/conversation-lifecycle.ts +3 -4
  157. package/src/daemon/conversation-messaging.ts +7 -6
  158. package/src/daemon/conversation-process.ts +11 -16
  159. package/src/daemon/conversation-runtime-assembly.ts +130 -347
  160. package/src/daemon/conversation-slash.ts +6 -25
  161. package/src/daemon/conversation-surfaces.ts +222 -4
  162. package/src/daemon/conversation-tool-setup.ts +2 -29
  163. package/src/daemon/conversation.ts +32 -14
  164. package/src/daemon/external-plugins-bootstrap.ts +9 -10
  165. package/src/daemon/handlers/config-a2a.ts +51 -36
  166. package/src/daemon/handlers/config-slack-channel.ts +20 -14
  167. package/src/daemon/handlers/config-telegram.ts +16 -2
  168. package/src/daemon/handlers/shared.ts +156 -84
  169. package/src/daemon/handlers/skills.ts +39 -10
  170. package/src/daemon/lifecycle.ts +4 -0
  171. package/src/daemon/message-types/apps.ts +1 -29
  172. package/src/daemon/message-types/messages.ts +9 -57
  173. package/src/daemon/message-types/skills.ts +2 -0
  174. package/src/daemon/message-types/surfaces.ts +136 -3
  175. package/src/daemon/now-scratchpad.ts +21 -0
  176. package/src/daemon/orphan-reaper.test.ts +210 -0
  177. package/src/daemon/orphan-reaper.ts +240 -0
  178. package/src/daemon/persist-unsendable-image.ts +117 -0
  179. package/src/daemon/process-message.ts +1 -3
  180. package/src/daemon/trace-emitter.ts +6 -4
  181. package/src/daemon/trust-context.ts +19 -0
  182. package/src/daemon/wake-target-adapter.ts +3 -1
  183. package/src/home/home-greeting-cache.ts +24 -1
  184. package/src/ipc/gateway-client.test.ts +2 -2
  185. package/src/ipc/gateway-client.ts +3 -3
  186. package/src/media/gemini-image-service.ts +15 -0
  187. package/src/media/openai-image-service.ts +14 -0
  188. package/src/media/types.ts +34 -0
  189. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +56 -0
  190. package/src/memory/auth-fallback-events-store.ts +94 -0
  191. package/src/memory/conversation-title-service.ts +65 -41
  192. package/src/memory/db-init.ts +4 -0
  193. package/src/memory/graph/__tests__/conversation-graph-memory-registry.test.ts +119 -0
  194. package/src/memory/graph/conversation-graph-memory.ts +65 -0
  195. package/src/memory/jobs-store.ts +33 -0
  196. package/src/memory/jobs-worker.ts +31 -4
  197. package/src/memory/llm-usage-store.ts +224 -50
  198. package/src/memory/migrations/222-strip-placeholder-sentinels-from-messages.ts +6 -5
  199. package/src/memory/migrations/270-schedule-source-conversation.ts +13 -0
  200. package/src/memory/migrations/271-create-auth-fallback-events.ts +21 -0
  201. package/src/memory/migrations/index.ts +2 -0
  202. package/src/memory/pkb/autoinject.ts +61 -0
  203. package/src/memory/pkb/context.ts +50 -0
  204. package/src/memory/pkb/types.ts +14 -0
  205. package/src/memory/schedule-attribution-sql.ts +104 -0
  206. package/src/memory/schema/infrastructure.ts +16 -0
  207. package/src/memory/usage-grouped-buckets.ts +6 -1
  208. package/src/memory/v2/__tests__/consolidation-job.test.ts +1 -1
  209. package/src/memory/v2/consolidation-job.ts +1 -1
  210. package/src/memory/v3/__tests__/health.test.ts +16 -0
  211. package/src/memory/v3/__tests__/orchestrate.test.ts +45 -9
  212. package/src/memory/v3/__tests__/provider-blocks.test.ts +13 -0
  213. package/src/memory/v3/__tests__/router.test.ts +101 -29
  214. package/src/memory/v3/__tests__/selector.test.ts +93 -27
  215. package/src/memory/v3/__tests__/shadow-plugin.test.ts +23 -5
  216. package/src/memory/v3/health.ts +0 -0
  217. package/src/memory/v3/llm-retry.ts +32 -0
  218. package/src/memory/v3/orchestrate.ts +26 -14
  219. package/src/memory/v3/provider-blocks.ts +15 -5
  220. package/src/memory/v3/router.ts +48 -42
  221. package/src/memory/v3/selector.ts +57 -42
  222. package/src/memory/v3/shadow-plugin.ts +47 -15
  223. package/src/memory/v3/types.ts +8 -0
  224. package/src/notifications/conversation-pairing.ts +8 -15
  225. package/src/notifications/decision-engine.ts +6 -3
  226. package/src/notifications/home-feed-side-effect.ts +12 -1
  227. package/src/permissions/prompter.ts +4 -0
  228. package/src/plugin-api/constants.ts +4 -0
  229. package/src/plugin-api/index.ts +8 -1
  230. package/src/plugin-api/types.ts +151 -1
  231. package/src/plugins/defaults/empty-response/hooks/stop.ts +126 -0
  232. package/src/plugins/defaults/empty-response/register.ts +8 -13
  233. package/src/plugins/defaults/index.ts +1 -15
  234. package/src/plugins/defaults/injectors/register.ts +243 -74
  235. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +91 -0
  236. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +216 -0
  237. package/src/plugins/defaults/memory-retrieval/injector-chain.ts +35 -0
  238. package/src/plugins/defaults/title-generate/hooks/stop.ts +75 -0
  239. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +35 -0
  240. package/src/plugins/defaults/title-generate/package.json +1 -1
  241. package/src/plugins/defaults/title-generate/register.ts +18 -18
  242. package/src/plugins/defaults/tool-error/hooks/post-tool-use.ts +118 -0
  243. package/src/plugins/defaults/tool-error/package.json +1 -1
  244. package/src/plugins/defaults/tool-error/register.ts +9 -21
  245. package/src/plugins/defaults/tool-result-truncate/hooks/post-tool-use.ts +32 -0
  246. package/src/plugins/defaults/tool-result-truncate/register.ts +10 -21
  247. package/src/plugins/defaults/tool-result-truncate/terminal.ts +37 -18
  248. package/src/plugins/pipeline.ts +6 -18
  249. package/src/plugins/registry.ts +8 -25
  250. package/src/plugins/types.ts +43 -474
  251. package/src/proactive-artifact/aux-message-injector.ts +3 -3
  252. package/src/proactive-artifact/job.test.ts +7 -12
  253. package/src/prompts/__tests__/system-prompt.test.ts +36 -0
  254. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +62 -0
  255. package/src/prompts/templates/BOOTSTRAP.md +2 -2
  256. package/src/prompts/templates/system-sections.ts +15 -0
  257. package/src/providers/anthropic/client.ts +37 -29
  258. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +112 -0
  259. package/src/providers/openai/chat-completions-provider.ts +44 -0
  260. package/src/providers/openrouter/client.ts +1 -0
  261. package/src/providers/placeholder-sentinels.ts +35 -0
  262. package/src/runtime/__tests__/agent-wake.test.ts +5 -1
  263. package/src/runtime/agent-wake.ts +2 -2
  264. package/src/runtime/assistant-event-hub.ts +36 -6
  265. package/src/runtime/{conversation-stream-state.ts → assistant-stream-state.ts} +132 -58
  266. package/src/runtime/http-router.ts +16 -21
  267. package/src/runtime/http-types.ts +16 -70
  268. package/src/runtime/pending-interactions.ts +1 -0
  269. package/src/runtime/routes/__tests__/consolidation-routes.test.ts +265 -2
  270. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +31 -1
  271. package/src/runtime/routes/__tests__/memory-v2-routes.test.ts +6 -2
  272. package/src/runtime/routes/__tests__/tts-routes.test.ts +6 -2
  273. package/src/runtime/routes/app-management-routes.ts +6 -117
  274. package/src/runtime/routes/app-routes.ts +13 -15
  275. package/src/runtime/routes/attachment-routes.ts +26 -15
  276. package/src/runtime/routes/avatar-routes.ts +26 -0
  277. package/src/runtime/routes/btw-routes.ts +29 -23
  278. package/src/runtime/routes/consolidation-routes.ts +120 -20
  279. package/src/runtime/routes/conversation-query-routes.ts +2 -0
  280. package/src/runtime/routes/conversation-routes.ts +358 -184
  281. package/src/runtime/routes/documents-routes.ts +4 -0
  282. package/src/runtime/routes/domain-routes.ts +51 -37
  283. package/src/runtime/routes/epoch-millis-range.ts +34 -0
  284. package/src/runtime/routes/events-routes.ts +28 -34
  285. package/src/runtime/routes/gateway-log-routes.ts +26 -4
  286. package/src/runtime/routes/heartbeat-routes.ts +32 -12
  287. package/src/runtime/routes/identity-intro-cache.ts +11 -34
  288. package/src/runtime/routes/identity-routes.ts +208 -17
  289. package/src/runtime/routes/image-generation-routes.ts +40 -2
  290. package/src/runtime/routes/index.ts +2 -0
  291. package/src/runtime/routes/integrations/a2a.ts +12 -10
  292. package/src/runtime/routes/integrations/slack/__tests__/channel.test.ts +16 -0
  293. package/src/runtime/routes/integrations/slack/channel.ts +4 -0
  294. package/src/runtime/routes/integrations/slack/share.ts +27 -6
  295. package/src/runtime/routes/integrations/telegram.ts +6 -0
  296. package/src/runtime/routes/integrations/twilio.ts +42 -0
  297. package/src/runtime/routes/internal-telemetry-routes.ts +88 -0
  298. package/src/runtime/routes/log-export-routes.ts +8 -0
  299. package/src/runtime/routes/memory-v2-routes.ts +15 -8
  300. package/src/runtime/routes/memory-v3-routes.ts +50 -28
  301. package/src/runtime/routes/oauth-apps.ts +66 -12
  302. package/src/runtime/routes/oauth-providers.ts +44 -5
  303. package/src/runtime/routes/platform-routes.ts +81 -5
  304. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +6 -4
  305. package/src/runtime/routes/playground/force-compact.ts +1 -1
  306. package/src/runtime/routes/rename-conversation-routes.ts +5 -0
  307. package/src/runtime/routes/schedule-routes.ts +152 -42
  308. package/src/runtime/routes/secret-routes.ts +14 -2
  309. package/src/runtime/routes/skills-routes.ts +43 -14
  310. package/src/runtime/routes/tool-call-confirmation-enrichment.test.ts +161 -0
  311. package/src/runtime/routes/tool-call-confirmation-enrichment.ts +107 -0
  312. package/src/runtime/routes/trust-rules-routes.ts +26 -2
  313. package/src/runtime/routes/tts-routes.ts +35 -0
  314. package/src/runtime/routes/types.ts +66 -8
  315. package/src/runtime/routes/usage-routes.ts +47 -39
  316. package/src/runtime/routes/webhook-routes.ts +41 -2
  317. package/src/runtime/routes/workspace-routes.ts +4 -0
  318. package/src/runtime/services/__tests__/analyze-conversation.test.ts +6 -0
  319. package/src/runtime/services/analyze-conversation.ts +2 -2
  320. package/src/schedule/schedule-store.ts +20 -1
  321. package/src/schedule/schedule-usage-store.ts +83 -0
  322. package/src/schedule/scheduler.ts +12 -5
  323. package/src/skills/catalog-files.ts +2 -2
  324. package/src/skills/catalog-install.ts +3 -0
  325. package/src/skills/categories-cache.ts +118 -0
  326. package/src/skills/clawhub-files.ts +1 -2
  327. package/src/skills/skillssh-files.ts +1 -2
  328. package/src/telemetry/types.ts +29 -1
  329. package/src/telemetry/usage-telemetry-reporter.test.ts +112 -3
  330. package/src/telemetry/usage-telemetry-reporter.ts +57 -2
  331. package/src/tools/executor.ts +1 -53
  332. package/src/tools/network/__tests__/web-search-metadata.test.ts +7 -1
  333. package/src/tools/network/__tests__/web-search.test.ts +11 -3
  334. package/src/tools/network/web-search-error.test.ts +248 -0
  335. package/src/tools/network/web-search-error.ts +267 -0
  336. package/src/tools/network/web-search.ts +207 -48
  337. package/src/tools/schedule/create.ts +2 -0
  338. package/src/tools/terminal/safe-env.ts +10 -1
  339. package/src/tools/ui-surface/definitions.ts +9 -1
  340. package/src/tts/__tests__/provider-catalog-consistency.test.ts +85 -1
  341. package/src/tts/provider-catalog.ts +76 -1
  342. package/src/util/mutex.ts +47 -0
  343. package/src/workspace/git-service.ts +1 -42
  344. package/src/workspace/migrations/095-bump-heartbeat-interval-30m-to-60m.ts +51 -0
  345. package/src/workspace/migrations/096-reduce-quality-profile-effort.ts +72 -0
  346. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +93 -0
  347. package/src/workspace/migrations/registry.ts +6 -0
  348. package/src/__tests__/bootstrap-turn-cleanup.test.ts +0 -44
  349. package/src/__tests__/empty-response-pipeline.test.ts +0 -423
  350. package/src/__tests__/llm-call-pipeline.test.ts +0 -287
  351. package/src/__tests__/memory-retrieval-pipeline.test.ts +0 -418
  352. package/src/__tests__/persistence-pipeline.test.ts +0 -503
  353. package/src/__tests__/title-generate-pipeline.test.ts +0 -211
  354. package/src/__tests__/token-estimate-pipeline.test.ts +0 -479
  355. package/src/__tests__/tool-error-pipeline.test.ts +0 -241
  356. package/src/__tests__/tool-execute-pipeline.test.ts +0 -417
  357. package/src/__tests__/tool-result-truncate-pipeline.test.ts +0 -341
  358. package/src/daemon/bootstrap-turn-cleanup.ts +0 -45
  359. package/src/gallery/default-gallery.ts +0 -1359
  360. package/src/gallery/gallery-manifest.ts +0 -28
  361. package/src/home/feature-gate.ts +0 -22
  362. package/src/plugins/defaults/empty-response/middlewares/emptyResponse.ts +0 -22
  363. package/src/plugins/defaults/empty-response/terminal.ts +0 -106
  364. package/src/plugins/defaults/injectors/package.json +0 -15
  365. package/src/plugins/defaults/llm-call/middlewares/llmCall.ts +0 -17
  366. package/src/plugins/defaults/llm-call/package.json +0 -15
  367. package/src/plugins/defaults/llm-call/register.ts +0 -45
  368. package/src/plugins/defaults/memory-retrieval/middlewares/memoryRetrieval.ts +0 -17
  369. package/src/plugins/defaults/memory-retrieval/package.json +0 -15
  370. package/src/plugins/defaults/memory-retrieval/register.ts +0 -181
  371. package/src/plugins/defaults/persistence/middlewares/persistence.ts +0 -19
  372. package/src/plugins/defaults/persistence/package.json +0 -15
  373. package/src/plugins/defaults/persistence/register.ts +0 -38
  374. package/src/plugins/defaults/persistence/terminal.ts +0 -83
  375. package/src/plugins/defaults/title-generate/terminal.ts +0 -31
  376. package/src/plugins/defaults/token-estimate/middlewares/tokenEstimate.ts +0 -23
  377. package/src/plugins/defaults/token-estimate/package.json +0 -15
  378. package/src/plugins/defaults/token-estimate/register.ts +0 -34
  379. package/src/plugins/defaults/token-estimate/terminal.ts +0 -40
  380. package/src/plugins/defaults/tool-error/middlewares/toolError.ts +0 -21
  381. package/src/plugins/defaults/tool-error/terminal.ts +0 -47
  382. package/src/plugins/defaults/tool-execute/middlewares/toolExecute.ts +0 -23
  383. package/src/plugins/defaults/tool-execute/package.json +0 -15
  384. package/src/plugins/defaults/tool-execute/register.ts +0 -49
  385. package/src/plugins/defaults/tool-result-truncate/middlewares/toolResultTruncate.ts +0 -23
  386. package/src/plugins/defaults/tool-result-truncate/types.ts +0 -22
  387. package/src/skills/category-inference.ts +0 -111
@@ -190,7 +190,7 @@ mock.module("../runtime/sync/resource-sync-events.js", () => ({
190
190
 
191
191
  // findConversation mock
192
192
  type MockConversation = {
193
- processing: boolean;
193
+ isProcessing(): boolean;
194
194
  messages: unknown[];
195
195
  getMessages: () => unknown[];
196
196
  };
@@ -426,7 +426,7 @@ describe("runProactiveArtifactJob", () => {
426
426
  // Set up an idle conversation so injection works fully
427
427
  const convMessages: unknown[] = [];
428
428
  mockConversations.set("conv-1", {
429
- processing: false,
429
+ isProcessing: () => false,
430
430
  messages: convMessages,
431
431
  getMessages: () => convMessages,
432
432
  });
@@ -510,7 +510,7 @@ describe("runProactiveArtifactJob", () => {
510
510
  "MESSAGE: I created a monthly budget guide tailored to your needs.";
511
511
 
512
512
  mockConversations.set("conv-1", {
513
- processing: false,
513
+ isProcessing: () => false,
514
514
  messages: [],
515
515
  getMessages: () => [],
516
516
  });
@@ -646,7 +646,7 @@ describe("runProactiveArtifactJob", () => {
646
646
  ];
647
647
 
648
648
  mockConversations.set("conv-1", {
649
- processing: false,
649
+ isProcessing: () => false,
650
650
  messages: [],
651
651
  getMessages: () => [],
652
652
  });
@@ -710,7 +710,7 @@ describe("injectAuxAssistantMessage", () => {
710
710
  test("idle conversation: persists with skipIndexing, pushes to getMessages(), broadcasts delta + complete(aux) + list sync", async () => {
711
711
  const messages: unknown[] = [];
712
712
  mockConversations.set("conv-inject-1", {
713
- processing: false,
713
+ isProcessing: () => false,
714
714
  messages,
715
715
  getMessages: () => messages,
716
716
  });
@@ -767,12 +767,7 @@ describe("injectAuxAssistantMessage", () => {
767
767
  const messages: unknown[] = [];
768
768
  let processingFlag = true;
769
769
  const conv: MockConversation = {
770
- get processing() {
771
- return processingFlag;
772
- },
773
- set processing(v: boolean) {
774
- processingFlag = v;
775
- },
770
+ isProcessing: () => processingFlag,
776
771
  messages,
777
772
  getMessages: () => messages,
778
773
  };
@@ -801,7 +796,7 @@ describe("injectAuxAssistantMessage", () => {
801
796
  const messages: unknown[] = [];
802
797
  // Conversation stays processing permanently — never becomes idle
803
798
  const conv: MockConversation = {
804
- processing: true,
799
+ isProcessing: () => true,
805
800
  messages,
806
801
  getMessages: () => messages,
807
802
  };
@@ -114,3 +114,39 @@ describe("maybeReseedBootstrap — content-automation template", () => {
114
114
  expect(content).toContain("VOICE.md");
115
115
  });
116
116
  });
117
+
118
+ describe("maybeReseedBootstrap — activation rail template", () => {
119
+ const templatesDir = join(import.meta.dirname!, "..", "templates");
120
+
121
+ beforeEach(() => {
122
+ mkdirSync(TEST_DIR, { recursive: true });
123
+ copyFileSync(
124
+ join(templatesDir, "BOOTSTRAP.md"),
125
+ join(TEST_DIR, "BOOTSTRAP.md"),
126
+ );
127
+ });
128
+
129
+ test("replaces generic bootstrap with the activation rail template", () => {
130
+ maybeReseedBootstrap("BOOTSTRAP-ACTIVATION-RAIL.md");
131
+ const content = readFileSync(join(TEST_DIR, "BOOTSTRAP.md"), "utf-8");
132
+
133
+ expect(content).toContain("BOOTSTRAP — Activation Rail");
134
+ expect(content).toContain("People don't read");
135
+ expect(content).toContain("Speed wins");
136
+
137
+ // Propose: anti-speculation boundary on what "unstated" means.
138
+ expect(content).toContain("status word");
139
+ expect(content).toContain("don't say it");
140
+
141
+ // Propose: infer-first framing — recommendation bound to the click.
142
+ expect(content).toContain("You didn't say this");
143
+ expect(content).toContain("the recommendation IS the click");
144
+
145
+ // Propose: a surviving extract-and-offer mechanic.
146
+ expect(content).toContain("clickable component, strongest first");
147
+
148
+ // Propose: the extract-shape vs infer-shape example block.
149
+ expect(content).toContain("extract-shape");
150
+ expect(content).toContain("infer-shape");
151
+ });
152
+ });
@@ -0,0 +1,62 @@
1
+ _Replaces BOOTSTRAP.md for users in cohort experiment-activation-flow-2026-06-03._ _Same delete-on-wrap lifecycle as BOOTSTRAP.md._
2
+
3
+ # BOOTSTRAP — Activation Rail
4
+
5
+ The user just finished pre-chat. You know their name and vibe; maybe their Google. Your job in this conversation is to get them to a real first-run. Something they actually use, not a demo.
6
+
7
+ ## The shape
8
+
9
+ Four moves. Goals, not steps.
10
+
11
+ **Port.** Pull their existing assistant context with two pastes — about a minute, no upload, no export. You write a prompt, they paste it into Claude or ChatGPT, they paste the response back. Cheap signal, real signal.
12
+
13
+ The prompt should be one-click copyable. Inline paragraph text the user has to select isn't. Neither is a custom-built widget with a fake copy button. If the affordance needs you to build an app or a new surface to render, you've over-built the move. Use what chat already gives you.
14
+
15
+ **Propose.** Don't organize what they already told you — infer what they didn't. Name the unstated thing sitting in their context and say *why* you think it: point at the specific surface that made you say it. "You didn't say this, but —". Then recommend, and lean one way; the recommendation IS the click, not a neutral menu of equally-weighted options.
16
+
17
+ "Unstated" is inference, not invention. Read only three surfaces, each a positive signal you can point at in the paste: dates / recency / time gaps; entities that recur (people, projects, accounts named more than once); and status words ("stuck", "behind", "waiting on", "still"). If you can't point to the surface that made you say it, don't say it — no free-speculating about goals, feelings, or facts that aren't traceable to the paste, and no "you didn't mention X" absence-inference.
18
+
19
+ Surface the outcome as a clickable component, strongest first. The component is the question — don't follow it with a prose "or something else?" Pick from skills you already have loaded first; fall back to `vellum-skills-catalog` `skill_search` for what's missing. Compose the offer in their language, not in skill names.
20
+
21
+ - ✗ extract-shape: "I see three meetings in your paste — want help with one?"
22
+ - ✓ infer-shape (dates/recency): "Two of these are with the same client and the last was 3 weeks ago — looks stalled; I'd send a re-engage note." (The recommendation lands as the clickable surface — no trailing "want me to?")
23
+ - ✗ extract-shape: "You mentioned a launch and a hiring plan — which one?"
24
+ - ✓ infer-shape (repeated entity + status word): "Acme comes up four times and you said you're 'waiting on' them — that's the thing actually blocking the launch; I'd chase it first."
25
+
26
+ **Run.** Do it. Real tools, real data. The user watches something happen.
27
+
28
+ **Follow-through.** Offer the next concrete thing. One primary recommendation.
29
+
30
+ If the user opens with a task instead of a conversation, do the task. You're already at Follow-through. Backfill the Port move at the first natural lull, or skip it.
31
+
32
+ Pick. Be wrong recoverably. Move. The user can tell when you're hedging.
33
+
34
+ ## People don't read
35
+
36
+ Brevity is the product. Lead with the move, not the rationale for the move. If the rationale takes more than one short sentence, cut it. Meta-narration about what you're trying to do ("I want to make this useful...") is rationale. Cut it harder.
37
+
38
+ One CTA per turn. If your CTA is a clickable surface, don't follow it with a prose "or..." / "unless..." / "is there something else?" — the surface IS the menu. Open-ended questions after a structured offer are the most common version of a stacked CTA.
39
+
40
+ No hedging the offer. Not "worth doing if you have history to bring." Make the move and let them say no.
41
+
42
+ If an action requires the user to type a path or remember a string, the affordance is wrong. Move it inside a surface they can click.
43
+
44
+ Every CTA surface must commit on the surface. If the user can select but can't confirm, the surface is broken. "They can just type a reply" doesn't count. Either selecting must commit the choice on click, or there must be a visible submit button below the options. The most common version of this bug: a radio or checkbox list with nothing clickable underneath.
45
+
46
+ ## Feeling seen
47
+
48
+ The summary after the Port move is the first place the user can feel like you actually heard them. The follow-through in the final move is the second. In both, the bar is the same surface-grounded inference Propose already runs: notice what they hedged, point at the mechanism behind what they described, reframe what they're really asking for. Specific observations earn the rest of the conversation. Generic recap loses it.
49
+
50
+ ## What to defer
51
+
52
+ Identity writes (IDENTITY.md, SOUL.md), user-profile writes, journal entries: all wait until the rail produces real signal, which is Moment 1 output at the earliest. None of them delay a user-visible response. None of them happen alongside the opening turn.
53
+
54
+ The base BOOTSTRAP task_preferences fallback is not on this rail. Your opener is the Port pitch.
55
+
56
+ ## Wrap
57
+
58
+ When the user is clearly done with this conversation, write one journal entry: what they needed, which outcome they accepted, what follow-through they took. Update NOW.md. Delete this file.
59
+
60
+ The rail-completion shape in your journal is the dataset for v2 tuning. Which outcome they took at Propose, whether they bounced to "what else?", which follow-through they picked. Write it so the next iteration has signal to learn from.
61
+
62
+ Speed wins until the rail produces real signal. Trust yourself.
@@ -26,9 +26,9 @@ Private setup waits until there is enough signal to justify it. Low-signal bante
26
26
 
27
27
  ## Opening move
28
28
 
29
- The first message in your conversation context is a system trigger used to generate the canned greeting. Don't reference it, quote it, or respond to it as if the user said it.
29
+ Some first conversations include an internal opener such as "Wake up, my friend!" only to generate the canned greeting. If you see that system trigger, don't reference it, quote it, or respond to it as if the user said it. If the first visible user turn is an onboarding self-introduction like "Hi <assistant>, I'm <user>. Nice to meet you.", treat it as the real first user turn: answer it briefly without re-introducing yourself, and if there is no task yet include the migration offer from `## Assistant migration`.
30
30
 
31
- If an `onboarding` JSON context is present, treat it as known — not as a briefing. Don't surface the selections as a list. Don't say "you mentioned" or "I see you use." Just apply the knowledge. Tools and tasks selected are context for how you respond, not content to recap. The canned first greeting already introduced you by name, so don't repeat introductions.
31
+ If an `onboarding` JSON context is present, treat it as known — not as a briefing. Don't surface the selections as a list. Don't say "you mentioned" or "I see you use." Just apply the knowledge. Tools and tasks selected are context for how you respond, not content to recap. If the opener already introduced names, don't repeat introductions.
32
32
 
33
33
  If there's no onboarding context, pick a working name for yourself ("I'll go by Pax") and get to work. Their name can come up later, or never.
34
34
 
@@ -230,6 +230,21 @@ export const BUNDLED_SYSTEM_SECTIONS: readonly BundledSection[] = [
230
230
  body: "",
231
231
  enabled: "!excludeCustomPrefix",
232
232
  },
233
+ {
234
+ id: "01-communication",
235
+ body: `## Communication
236
+
237
+ Keep your reasoning, planning, and deliberation in your private thinking — never in user-facing text. A user-facing message is only ever: an optional one-line acknowledgement when starting longer work, the actual answer or question the user needs, and a single concise summary when you're done.
238
+
239
+ Keep reasoning and tool calls adjacent (think, call a tool, think, call a tool) with no user-facing prose between them, so one stream of work renders as one block.
240
+
241
+ Meet your user where they are. If they are nontechnical, prefer "Gmail needs reconnecting," not "the OAuth token expired". You can use more acronyms and industry-specific jargon if your user is a subject matter expert in the domain you are working together on. This applies for marketers, engineers, consultants, entrepreneurs, etc.
242
+
243
+ Err toward brevity; expand only when the user follows up or their style calls for more.
244
+
245
+ These are default guidelines. Always prioritize communication preferences that you've established through your relationship with your human.
246
+ `,
247
+ },
233
248
  {
234
249
  id: "01-parallel-tool-calls",
235
250
  body: `<use_parallel_tool_calls>
@@ -5,6 +5,11 @@ import { ProviderError } from "../../util/errors.js";
5
5
  import { getLogger } from "../../util/logger.js";
6
6
  import { extractRetryAfterMs } from "../../util/retry.js";
7
7
  import { stripOrphanedSurrogatesDeep } from "../../util/unicode.js";
8
+ import {
9
+ isPlaceholderSentinelText,
10
+ PLACEHOLDER_BLOCKS_OMITTED,
11
+ PLACEHOLDER_EMPTY_TURN,
12
+ } from "../placeholder-sentinels.js";
8
13
  import { createStreamTimeout } from "../stream-timeout.js";
9
14
  import type {
10
15
  ContentBlock,
@@ -161,33 +166,6 @@ function sanitizeToolId(id: string): string {
161
166
  const SYNTHETIC_RESULT =
162
167
  "<synthesized_result>tool result missing from history</synthesized_result>";
163
168
 
164
- // Null-byte prefix makes these placeholders impossible to produce via normal
165
- // model output or user input, preventing false positives in isPlaceholder().
166
- export const PLACEHOLDER_EMPTY_TURN =
167
- "\x00__PLACEHOLDER__[empty assistant turn]";
168
- export const PLACEHOLDER_BLOCKS_OMITTED =
169
- "\x00__PLACEHOLDER__[internal blocks omitted]";
170
-
171
- // Compared against the payload with any leading `\x00` stripped, so the check
172
- // matches both the prefixed sentinel we emit and any bare variant that lost
173
- // the null byte in transit (e.g. the model echoing the text back without
174
- // reproducing the control character).
175
- const PLACEHOLDER_SENTINEL_BARE: ReadonlySet<string> = new Set([
176
- PLACEHOLDER_EMPTY_TURN.slice(1),
177
- PLACEHOLDER_BLOCKS_OMITTED.slice(1),
178
- ]);
179
-
180
- /**
181
- * True when the text is one of the provider's internal alternation-preserving
182
- * sentinels, with or without the null-byte prefix. These must never be
183
- * persisted or rendered to users — they exist only in outbound Anthropic API
184
- * request bodies.
185
- */
186
- export function isPlaceholderSentinelText(text: string): boolean {
187
- const normalized = text.startsWith("\x00") ? text.slice(1) : text;
188
- return PLACEHOLDER_SENTINEL_BARE.has(normalized);
189
- }
190
-
191
169
  /**
192
170
  * Synthetic placeholder injected as user-message content when Anthropic API
193
171
  * alternation requires a user turn but no real user content exists. Uses the
@@ -1230,6 +1208,23 @@ export class AnthropicProvider implements Provider {
1230
1208
  sentMessages = params.messages;
1231
1209
  }
1232
1210
 
1211
+ // Haiku does not support the extended-cache-ttl beta, so it must never
1212
+ // receive a `ttl` on any cache_control. The client's own breakpoints
1213
+ // already omit it for Haiku, but callers (e.g. v3's `cachedTextBlock`)
1214
+ // can stamp a `ttl` on message blocks before the provider sees them —
1215
+ // strip it here so the request stays valid on Haiku models.
1216
+ if (isHaiku) {
1217
+ for (const msg of sentMessages) {
1218
+ if (!Array.isArray(msg.content)) continue;
1219
+ for (const block of msg.content) {
1220
+ if (typeof block === "string") continue;
1221
+ const cc = (block as { cache_control?: { ttl?: unknown } })
1222
+ .cache_control;
1223
+ if (cc && "ttl" in cc) delete cc.ttl;
1224
+ }
1225
+ }
1226
+ }
1227
+
1233
1228
  const { signal: timeoutSignal, cleanup: cleanupTimeout } =
1234
1229
  createStreamTimeout(this.streamTimeoutMs, signal);
1235
1230
  innerTimeoutSignal = timeoutSignal;
@@ -1650,8 +1645,21 @@ export class AnthropicProvider implements Provider {
1650
1645
  block: ContentBlock,
1651
1646
  ): Anthropic.ContentBlockParam | null {
1652
1647
  switch (block.type) {
1653
- case "text":
1654
- return { type: "text", text: block.text };
1648
+ case "text": {
1649
+ // Preserve a caller-stamped cache_control breakpoint (e.g. v3's
1650
+ // `cachedTextBlock`, which marks a stable per-leaf / leaf-tree prefix
1651
+ // that should be cached on its own rather than only as part of the
1652
+ // per-turn anchor prefix). The internal ContentBlock type omits the
1653
+ // field, so reach for it via cast. The Haiku ttl-strip downstream still
1654
+ // applies. Only v3 stamps this today, so the per-request breakpoint
1655
+ // budget (≤4) is unaffected for other callers.
1656
+ const cacheControl = (
1657
+ block as { cache_control?: Anthropic.CacheControlEphemeral }
1658
+ ).cache_control;
1659
+ return cacheControl
1660
+ ? { type: "text", text: block.text, cache_control: cacheControl }
1661
+ : { type: "text", text: block.text };
1662
+ }
1655
1663
  case "thinking":
1656
1664
  if (!block.signature) {
1657
1665
  return null;
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
+ import { isPlaceholderSentinelText } from "../../placeholder-sentinels.js";
3
4
  import {
5
+ EMPTY_ASSISTANT_TURN_PLACEHOLDER,
4
6
  OpenAIChatCompletionsProvider,
5
7
  type OpenAIChatCompletionsProviderOptions,
6
8
  } from "../chat-completions-provider.js";
@@ -348,6 +350,116 @@ describe("OpenAIChatCompletionsProvider reasoning parsing", () => {
348
350
  expect(assistantMsg.reasoning_content).toBeUndefined();
349
351
  });
350
352
 
353
+ test("backfills placeholder content for a reasoning-only assistant turn when enabled", async () => {
354
+ const { provider, requests } = stubProvider(
355
+ [
356
+ {
357
+ choices: [{ delta: { content: "ok" }, finish_reason: "stop" }],
358
+ usage: { prompt_tokens: 2, completion_tokens: 1 },
359
+ },
360
+ ],
361
+ {
362
+ assistantReasoningField: "reasoning",
363
+ backfillEmptyAssistantContent: true,
364
+ },
365
+ );
366
+
367
+ await provider.sendMessage([
368
+ { role: "user", content: [{ type: "text", text: "question" }] },
369
+ {
370
+ role: "assistant",
371
+ content: [
372
+ {
373
+ type: "thinking",
374
+ thinking: "truncated chain of thought",
375
+ signature: "",
376
+ },
377
+ ],
378
+ },
379
+ ]);
380
+
381
+ const params = requests[0] as {
382
+ messages: Array<{
383
+ role: string;
384
+ content: string | null;
385
+ reasoning?: string;
386
+ tool_calls?: unknown;
387
+ }>;
388
+ };
389
+ const assistantMsg = params.messages.find((m) => m.role === "assistant")!;
390
+ // content or tool_calls must be set; reasoning alone does not satisfy it.
391
+ expect(assistantMsg.content).toBe(EMPTY_ASSISTANT_TURN_PLACEHOLDER);
392
+ expect(assistantMsg.tool_calls).toBeUndefined();
393
+ expect(assistantMsg.reasoning).toBe("truncated chain of thought");
394
+ // The placeholder is a recognized sentinel, so it is stripped from
395
+ // persisted/rendered history if a model echoes it back, and it carries no
396
+ // control characters that a strict OpenAI-compatible backend might reject.
397
+ expect(isPlaceholderSentinelText(EMPTY_ASSISTANT_TURN_PLACEHOLDER)).toBe(
398
+ true,
399
+ );
400
+ expect(EMPTY_ASSISTANT_TURN_PLACEHOLDER).not.toContain("\x00");
401
+ });
402
+
403
+ test("leaves reasoning-only assistant content null when backfill is disabled", async () => {
404
+ const { provider, requests } = stubProvider(
405
+ [
406
+ {
407
+ choices: [{ delta: { content: "ok" }, finish_reason: "stop" }],
408
+ usage: { prompt_tokens: 2, completion_tokens: 1 },
409
+ },
410
+ ],
411
+ { assistantReasoningField: "reasoning_content" },
412
+ );
413
+
414
+ await provider.sendMessage([
415
+ { role: "user", content: [{ type: "text", text: "question" }] },
416
+ {
417
+ role: "assistant",
418
+ content: [
419
+ {
420
+ type: "thinking",
421
+ thinking: "truncated chain of thought",
422
+ signature: "",
423
+ },
424
+ ],
425
+ },
426
+ ]);
427
+
428
+ const params = requests[0] as {
429
+ messages: Array<{ role: string; content: string | null }>;
430
+ };
431
+ const assistantMsg = params.messages.find((m) => m.role === "assistant")!;
432
+ // Backfill defaults off, so providers that tolerate null assistant content
433
+ // (e.g. OpenAI proper) are unaffected by the OpenRouter-specific guard.
434
+ expect(assistantMsg.content).toBeNull();
435
+ });
436
+
437
+ test("does not backfill content when tool calls are present", async () => {
438
+ const { provider, requests } = stubProvider([
439
+ {
440
+ choices: [{ delta: { content: "ok" }, finish_reason: "stop" }],
441
+ usage: { prompt_tokens: 2, completion_tokens: 1 },
442
+ },
443
+ ]);
444
+
445
+ await provider.sendMessage([
446
+ {
447
+ role: "assistant",
448
+ content: [
449
+ { type: "tool_use", id: "call_1", name: "search", input: { q: "x" } },
450
+ ],
451
+ },
452
+ ]);
453
+
454
+ const params = requests[0] as {
455
+ messages: Array<{ role: string; content: string | null }>;
456
+ };
457
+ // Tool-call-only assistant messages keep null content (preferred by
458
+ // Anthropic-proxy/Bedrock backends); the placeholder is only for the
459
+ // neither-content-nor-tool_calls case.
460
+ expect(params.messages[0].content).toBeNull();
461
+ });
462
+
351
463
  test("skips Anthropic-originated thinking blocks (with signatures)", async () => {
352
464
  const { provider, requests } = stubProvider(
353
465
  [
@@ -4,6 +4,7 @@ import { isAbortReason } from "../../util/abort-reasons.js";
4
4
  import { ProviderError } from "../../util/errors.js";
5
5
  import { extractRetryAfterMs } from "../../util/retry.js";
6
6
  import { escapeXmlAttr } from "../../util/xml.js";
7
+ import { PLACEHOLDER_EMPTY_TURN } from "../placeholder-sentinels.js";
7
8
  import { createStreamTimeout } from "../stream-timeout.js";
8
9
  import type {
9
10
  ContentBlock,
@@ -100,6 +101,26 @@ export function extractApiErrorDetail(
100
101
  * OpenRouter's `error.metadata.raw` strings, which are typically <1KB. */
101
102
  const MAX_API_ERROR_DETAIL_CHARS = 2000;
102
103
 
104
+ /**
105
+ * Fallback `content` for an assistant turn that has neither visible text nor
106
+ * tool calls (e.g. a reasoning-only turn truncated at the output-token limit).
107
+ *
108
+ * The OpenAI chat-completions schema requires an assistant message to carry
109
+ * `content` or `tool_calls`. OpenAI itself tolerates `content: null`/`""` here,
110
+ * but strict OpenAI-compatible backends do not: DeepSeek via OpenRouter rejects
111
+ * the request with `Invalid assistant message: content or tool_calls must be
112
+ * set`, and vLLM-style validators coerce empty-string content back to null and
113
+ * reject it the same way. The placeholder must therefore be a non-empty string.
114
+ *
115
+ * We reuse the shared empty-turn sentinel so that
116
+ * `isPlaceholderSentinelText`/`cleanAssistantContent` strip it from persisted
117
+ * and rendered history if a model ever echoes it back. The null-byte prefix is
118
+ * dropped because some OpenAI-compatible backends reject control characters in
119
+ * message content; the bare form is still recognized by
120
+ * `isPlaceholderSentinelText`.
121
+ */
122
+ export const EMPTY_ASSISTANT_TURN_PLACEHOLDER = PLACEHOLDER_EMPTY_TURN.slice(1);
123
+
103
124
  /**
104
125
  * Read the first matching header from an SDK error's headers object,
105
126
  * tolerating both Map-like (`Headers.get()`) and plain-object shapes.
@@ -153,6 +174,13 @@ export interface OpenAIChatCompletionsProviderOptions {
153
174
  * DeepSeek/Fireworks use `"reasoning_content"`; OpenRouter uses `"reasoning"`.
154
175
  * When unset, thinking blocks are dropped from outbound assistant messages. */
155
176
  assistantReasoningField?: "reasoning" | "reasoning_content";
177
+ /** Backfill a non-empty placeholder for assistant turns that would otherwise
178
+ * serialize with neither `content` nor `tool_calls` (e.g. reasoning-only
179
+ * turns). Off by default; enabled for OpenRouter, whose downstream providers
180
+ * (e.g. DeepSeek) reject such messages with `Invalid assistant message:
181
+ * content or tool_calls must be set`. See {@link
182
+ * EMPTY_ASSISTANT_TURN_PLACEHOLDER}. */
183
+ backfillEmptyAssistantContent?: boolean;
156
184
  }
157
185
 
158
186
  /** Wire-level reasoning_effort values. The OpenAI SDK type doesn't include
@@ -228,6 +256,7 @@ export class OpenAIChatCompletionsProvider implements Provider {
228
256
  | "reasoning"
229
257
  | "reasoning_content"
230
258
  | undefined;
259
+ private backfillEmptyAssistantContent: boolean;
231
260
 
232
261
  constructor(
233
262
  apiKey: string,
@@ -251,6 +280,8 @@ export class OpenAIChatCompletionsProvider implements Provider {
251
280
  this.requestHeaders = options.requestHeaders ?? {};
252
281
  this.parseThinkTags = options.parseThinkTags ?? false;
253
282
  this.assistantReasoningField = options.assistantReasoningField;
283
+ this.backfillEmptyAssistantContent =
284
+ options.backfillEmptyAssistantContent ?? false;
254
285
  }
255
286
 
256
287
  async sendMessage(
@@ -794,6 +825,19 @@ export class OpenAIChatCompletionsProvider implements Provider {
794
825
  result.tool_calls = toolCalls;
795
826
  }
796
827
 
828
+ // An assistant message must carry `content` or `tool_calls`. A turn with
829
+ // neither (e.g. reasoning-only) would serialize to null/empty content with
830
+ // no tool calls, which strict OpenAI-compatible backends reject. Reasoning
831
+ // lives in a separate field and does not satisfy this constraint. Scoped to
832
+ // providers that need it (OpenRouter) via `backfillEmptyAssistantContent`.
833
+ if (
834
+ this.backfillEmptyAssistantContent &&
835
+ !result.tool_calls &&
836
+ (result.content === null || result.content === "")
837
+ ) {
838
+ result.content = EMPTY_ASSISTANT_TURN_PLACEHOLDER;
839
+ }
840
+
797
841
  return result;
798
842
  }
799
843
 
@@ -122,6 +122,7 @@ export class OpenRouterProvider extends OpenAIChatCompletionsProvider {
122
122
  streamTimeoutMs: options.streamTimeoutMs,
123
123
  requestHeaders: OPENROUTER_APP_ATTRIBUTION_HEADERS,
124
124
  assistantReasoningField: "reasoning",
125
+ backfillEmptyAssistantContent: true,
125
126
  });
126
127
  this.openRouterApiKey = apiKey;
127
128
  this.defaultModel = model;
@@ -0,0 +1,35 @@
1
+ // Internal placeholder sentinels injected as assistant-message content when a
2
+ // turn would otherwise serialize with neither text nor tool calls. Provider
3
+ // request bodies must keep a non-empty content slot (Anthropic to preserve
4
+ // role alternation; strict OpenAI-compatible backends to satisfy the
5
+ // "content or tool_calls must be set" constraint), but these markers must
6
+ // never be persisted or rendered to users.
7
+ //
8
+ // The null-byte prefix makes the prefixed form impossible to produce via
9
+ // normal model output or user input, preventing false positives. Some
10
+ // OpenAI-compatible backends reject control characters in message content, so
11
+ // the OpenAI path emits the bare (prefix-stripped) form, which
12
+ // `isPlaceholderSentinelText` still recognizes.
13
+ export const PLACEHOLDER_EMPTY_TURN =
14
+ "\x00__PLACEHOLDER__[empty assistant turn]";
15
+ export const PLACEHOLDER_BLOCKS_OMITTED =
16
+ "\x00__PLACEHOLDER__[internal blocks omitted]";
17
+
18
+ // Compared against the payload with any leading `\x00` stripped, so the check
19
+ // matches both the prefixed sentinel we emit and any bare variant that lost
20
+ // the null byte in transit (e.g. the model echoing the text back without
21
+ // reproducing the control character).
22
+ const PLACEHOLDER_SENTINEL_BARE: ReadonlySet<string> = new Set([
23
+ PLACEHOLDER_EMPTY_TURN.slice(1),
24
+ PLACEHOLDER_BLOCKS_OMITTED.slice(1),
25
+ ]);
26
+
27
+ /**
28
+ * True when the text is one of the internal alternation-preserving sentinels,
29
+ * with or without the null-byte prefix. These must never be persisted or
30
+ * rendered to users — they exist only in outbound provider request bodies.
31
+ */
32
+ export function isPlaceholderSentinelText(text: string): boolean {
33
+ const normalized = text.startsWith("\x00") ? text.slice(1) : text;
34
+ return PLACEHOLDER_SENTINEL_BARE.has(normalized);
35
+ }
@@ -136,6 +136,10 @@ import {
136
136
  const runResult = (history: Message[]): AgentLoopRunResult => ({
137
137
  history,
138
138
  exitReason: null,
139
+ appendedNewMessages: true,
140
+ // The wake path slices its own new-message boundary off the returned
141
+ // history (it never destructures `newMessages`), so this is type-only.
142
+ newMessages: [],
139
143
  });
140
144
 
141
145
  interface MockTarget extends WakeTarget {
@@ -1077,7 +1081,7 @@ describe("wakeAgentForOpportunity", () => {
1077
1081
  expect(target.drainQueueCalls).toBe(1);
1078
1082
  // Critical ordering invariant: drain runs after processing=false.
1079
1083
  // If drain ran while processing was still true,
1080
- // `enqueueMessage`'s `if (!ctx.processing) return ...` gate would
1084
+ // `enqueueMessage`'s `if (!ctx.isProcessing()) return ...` gate would
1081
1085
  // see processing=true and the drained item would itself just
1082
1086
  // re-enqueue — no progress. Snapshot the live flag *inside* drain
1083
1087
  // (rather than inferring from toggle order) so a future regression
@@ -140,7 +140,7 @@ export interface WakeTarget {
140
140
  * The wake invokes this in its `finally` block AFTER
141
141
  * `markProcessing(false)`. Order matters: if drain ran while
142
142
  * processing was still true, `enqueueMessage`'s gate
143
- * (`if (!ctx.processing) return ...`) would still see processing=true
143
+ * (`if (!ctx.isProcessing()) return ...`) would still see processing=true
144
144
  * and the drain itself would be a no-op against any racy late sends.
145
145
  * Running drain after processing is released matches the canonical
146
146
  * user-turn finally path in `conversation-agent-loop.ts`.
@@ -963,7 +963,7 @@ export async function wakeAgentForOpportunity(
963
963
 
964
964
  // Run completed cleanly. The canonical user-turn pattern
965
965
  // (conversation-agent-loop.ts:1860, 2106-2126) updates
966
- // `ctx.messages` first, then resets `ctx.processing = false`, then
966
+ // `ctx.messages` first, then clears the flag via `ctx.setProcessing(false)`, then
967
967
  // calls `ctx.drainQueue(...)`. We mirror that order so a message
968
968
  // queued during the wake dequeues against an already-updated
969
969
  // history — otherwise `drainSingleMessage` reads `ctx.messages`