@vellumai/assistant 0.8.2 → 0.8.3

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 (231) hide show
  1. package/ARCHITECTURE.md +11 -12
  2. package/docker-entrypoint.sh +13 -1
  3. package/docker-init-apt-root.sh +79 -6
  4. package/openapi.yaml +336 -21
  5. package/package.json +1 -1
  6. package/src/__tests__/agent-loop-exit-reason.test.ts +272 -0
  7. package/src/__tests__/agent-loop-provider-error-recording.test.ts +195 -0
  8. package/src/__tests__/compactor-tail-resolution.test.ts +107 -1
  9. package/src/__tests__/config-get-vision-flag.test.ts +136 -0
  10. package/src/__tests__/config-loader-backfill.test.ts +115 -18
  11. package/src/__tests__/context-token-estimator.test.ts +30 -65
  12. package/src/__tests__/conversation-agent-loop.test.ts +57 -1
  13. package/src/__tests__/conversation-media-retry.test.ts +19 -8
  14. package/src/__tests__/conversation-runtime-assembly.test.ts +26 -4
  15. package/src/__tests__/date-context.test.ts +45 -0
  16. package/src/__tests__/external-plugin-loader.test.ts +91 -19
  17. package/src/__tests__/guardian-action-no-hardcoded-copy.test.ts +0 -1
  18. package/src/__tests__/guardian-dispatch.test.ts +1 -0
  19. package/src/__tests__/heartbeat-service.test.ts +24 -164
  20. package/src/__tests__/helpers/channel-test-adapter.ts +0 -2
  21. package/src/__tests__/host-app-control-proxy.test.ts +241 -0
  22. package/src/__tests__/host-proxy-preactivation.test.ts +200 -13
  23. package/src/__tests__/injector-background-turn.test.ts +153 -0
  24. package/src/__tests__/injector-chain.test.ts +5 -0
  25. package/src/__tests__/lifecycle-memory-v2-seed.test.ts +9 -2
  26. package/src/__tests__/llm-callsite-catalog.test.ts +25 -0
  27. package/src/__tests__/llm-catalog-parity.test.ts +3 -0
  28. package/src/__tests__/llm-request-log-agent-loop-exit-reason.test.ts +116 -0
  29. package/src/__tests__/llm-request-log-error-payload.test.ts +138 -0
  30. package/src/__tests__/llm-request-log-source-clickhouse.test.ts +2 -0
  31. package/src/__tests__/llm-resolver.test.ts +255 -2
  32. package/src/__tests__/managed-profile-guard.test.ts +10 -0
  33. package/src/__tests__/notification-decision-fallback.test.ts +0 -91
  34. package/src/__tests__/notification-decision-strategy.test.ts +14 -31
  35. package/src/__tests__/notification-deep-link.test.ts +15 -0
  36. package/src/__tests__/notification-guardian-path.test.ts +1 -2
  37. package/src/__tests__/notification-platform-adapter.test.ts +5 -4
  38. package/src/__tests__/notification-telegram-adapter.test.ts +1 -0
  39. package/src/__tests__/notification-vellum-adapter.test.ts +113 -0
  40. package/src/__tests__/openai-provider.test.ts +218 -3
  41. package/src/__tests__/openai-responses-cutover-guard.test.ts +3 -3
  42. package/src/__tests__/openrouter-provider-only.test.ts +51 -3
  43. package/src/__tests__/openrouter-token-estimation.test.ts +34 -25
  44. package/src/__tests__/platform-proxy-context.test.ts +6 -1
  45. package/src/__tests__/plugin-tool-contribution.test.ts +3 -3
  46. package/src/__tests__/plugin-types.test.ts +2 -2
  47. package/src/__tests__/provider-catalog-visibility.test.ts +16 -0
  48. package/src/__tests__/provider-platform-proxy-integration.test.ts +27 -25
  49. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -1
  50. package/src/__tests__/system-prompt.test.ts +6 -73
  51. package/src/__tests__/workspace-migration-087-memory-router-balanced-profile.test.ts +228 -0
  52. package/src/a2a/__tests__/agent-card.test.ts +98 -0
  53. package/src/a2a/__tests__/e2e-a2a-channel.test.ts +597 -0
  54. package/src/a2a/__tests__/protocol-helpers.test.ts +113 -0
  55. package/src/a2a/__tests__/task-store.test.ts +246 -0
  56. package/src/a2a/agent-card.ts +58 -0
  57. package/src/a2a/feature-gate.ts +8 -0
  58. package/src/a2a/protocol-constants.ts +21 -0
  59. package/src/a2a/protocol-errors.ts +50 -0
  60. package/src/a2a/protocol-types.ts +162 -0
  61. package/src/a2a/task-store.ts +168 -0
  62. package/src/agent/loop.ts +167 -18
  63. package/src/channels/config.ts +9 -0
  64. package/src/channels/types.ts +14 -0
  65. package/src/cli/{__tests__ → commands/__tests__}/notifications.test.ts +201 -28
  66. package/src/cli/commands/__tests__/schedules.test.ts +469 -0
  67. package/src/cli/commands/notifications.ts +65 -35
  68. package/src/cli/commands/plugins.ts +67 -0
  69. package/src/cli/commands/schedules.ts +297 -5
  70. package/src/cli/lib/__tests__/search-plugins.test.ts +261 -0
  71. package/src/cli/lib/install-from-github.ts +8 -9
  72. package/src/cli/lib/search-plugins.ts +163 -0
  73. package/src/cli/program.ts +14 -0
  74. package/src/config/assistant-feature-flags.ts +24 -54
  75. package/src/config/bundled-skills/app-builder/SKILL.md +117 -1
  76. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -1
  77. package/src/config/call-site-defaults.ts +105 -0
  78. package/src/config/feature-flag-registry.json +21 -29
  79. package/src/config/llm-resolver.ts +52 -1
  80. package/src/config/schema.ts +2 -0
  81. package/src/config/schemas/__tests__/memory-v2.test.ts +3 -3
  82. package/src/config/schemas/channels.ts +9 -0
  83. package/src/config/schemas/conversations.ts +10 -0
  84. package/src/config/schemas/heartbeat.ts +14 -0
  85. package/src/config/schemas/llm.ts +1 -3
  86. package/src/config/schemas/memory-retrospective.ts +1 -1
  87. package/src/config/schemas/memory-v2.ts +4 -4
  88. package/src/config/schemas/memory.ts +3 -1
  89. package/src/config/seed-inference-profiles.ts +99 -29
  90. package/src/context/compactor.ts +72 -12
  91. package/src/context/token-estimator.ts +32 -34
  92. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +3 -22
  93. package/src/daemon/conversation-agent-loop-handlers.ts +78 -0
  94. package/src/daemon/conversation-agent-loop.ts +29 -2
  95. package/src/daemon/conversation-runtime-assembly.ts +9 -0
  96. package/src/daemon/conversation.ts +0 -7
  97. package/src/daemon/date-context.ts +40 -0
  98. package/src/daemon/guardian-action-generators.ts +1 -125
  99. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +248 -0
  100. package/src/daemon/handlers/__tests__/config-a2a-invite.test.ts +154 -0
  101. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +133 -0
  102. package/src/daemon/handlers/__tests__/config-a2a.test.ts +95 -0
  103. package/src/daemon/handlers/config-a2a.ts +289 -0
  104. package/src/daemon/handlers/conversations.ts +1 -0
  105. package/src/daemon/host-app-control-proxy.ts +69 -18
  106. package/src/daemon/host-proxy-preactivation.ts +85 -18
  107. package/src/daemon/lifecycle.ts +49 -61
  108. package/src/daemon/memory-v2-startup.ts +49 -13
  109. package/src/daemon/message-types/notifications.ts +21 -0
  110. package/src/daemon/pkb-reminder-builder.test.ts +10 -53
  111. package/src/daemon/pkb-reminder-builder.ts +4 -19
  112. package/src/daemon/process-message.ts +3 -0
  113. package/src/daemon/skill-memory-refresh.ts +5 -1
  114. package/src/daemon/wake-target-adapter.ts +2 -0
  115. package/src/export/__tests__/transcript-formatter.test.ts +121 -0
  116. package/src/export/transcript-formatter.ts +54 -20
  117. package/src/heartbeat/__tests__/heartbeat-service.test.ts +44 -0
  118. package/src/heartbeat/heartbeat-service.ts +34 -191
  119. package/src/home/__tests__/feed-types.test.ts +40 -0
  120. package/src/home/feed-types.ts +14 -2
  121. package/src/ipc/cli-client.ts +147 -45
  122. package/src/memory/__tests__/conversation-queries.test.ts +220 -0
  123. package/src/memory/__tests__/memory-retrospective-enqueue.test.ts +2 -50
  124. package/src/memory/__tests__/memory-retrospective-job.test.ts +87 -4
  125. package/src/memory/conversation-queries.ts +87 -1
  126. package/src/memory/conversation-title-service.ts +26 -4
  127. package/src/memory/db-init.ts +6 -0
  128. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +84 -3
  129. package/src/memory/graph/conversation-graph-memory.ts +18 -6
  130. package/src/memory/graph/tools.ts +6 -37
  131. package/src/memory/invite-store.ts +53 -0
  132. package/src/memory/llm-request-log-source-clickhouse.ts +7 -2
  133. package/src/memory/llm-request-log-store.ts +92 -1
  134. package/src/memory/memory-retrospective-enqueue.ts +1 -20
  135. package/src/memory/memory-retrospective-job.ts +33 -6
  136. package/src/memory/migrations/250-provider-connection-base-url-and-models.ts +28 -0
  137. package/src/memory/migrations/251-a2a-tasks.ts +49 -0
  138. package/src/memory/migrations/252-llm-request-log-agent-loop-exit-reason.ts +32 -0
  139. package/src/memory/migrations/index.ts +3 -0
  140. package/src/memory/migrations/registry.ts +8 -0
  141. package/src/memory/schema/a2a.ts +15 -0
  142. package/src/memory/schema/index.ts +1 -0
  143. package/src/memory/schema/inference.ts +2 -0
  144. package/src/memory/schema/infrastructure.ts +1 -0
  145. package/src/memory/v2/__tests__/activation-store.test.ts +25 -23
  146. package/src/memory/v2/__tests__/cli-command-store.test.ts +404 -0
  147. package/src/memory/v2/__tests__/frontmatter-sweep.test.ts +25 -4
  148. package/src/memory/v2/__tests__/injection.test.ts +190 -3
  149. package/src/memory/v2/__tests__/static-context.test.ts +12 -1
  150. package/src/memory/v2/activation-store.ts +14 -16
  151. package/src/memory/v2/cli-command-content.ts +19 -0
  152. package/src/memory/v2/cli-command-store.ts +304 -0
  153. package/src/memory/v2/frontmatter-sweep.ts +7 -1
  154. package/src/memory/v2/injection.ts +49 -20
  155. package/src/memory/v2/page-index.ts +38 -13
  156. package/src/memory/v2/static-context.ts +4 -4
  157. package/src/memory/v2/types.ts +23 -0
  158. package/src/messaging/providers/a2a/__tests__/deliver.test.ts +274 -0
  159. package/src/messaging/providers/a2a/deliver.ts +156 -0
  160. package/src/messaging/providers/gmail/client.ts +9 -2
  161. package/src/messaging/providers/index.ts +11 -2
  162. package/src/notifications/__tests__/broadcaster.test.ts +203 -0
  163. package/src/notifications/__tests__/decision-engine.test.ts +283 -0
  164. package/src/notifications/__tests__/deterministic-checks.test.ts +286 -0
  165. package/src/notifications/__tests__/emit-signal-home-feed.test.ts +1 -0
  166. package/src/notifications/__tests__/home-feed-side-effect.test.ts +430 -7
  167. package/src/notifications/adapters/macos.ts +12 -2
  168. package/src/notifications/broadcaster.ts +29 -4
  169. package/src/notifications/copy-composer.ts +17 -64
  170. package/src/notifications/decision-engine.ts +111 -44
  171. package/src/notifications/deterministic-checks.ts +96 -0
  172. package/src/notifications/emit-signal.ts +1 -0
  173. package/src/notifications/home-feed-side-effect.ts +85 -6
  174. package/src/notifications/signal.ts +0 -4
  175. package/src/notifications/types.ts +8 -0
  176. package/src/oauth/platform-connection.test.ts +43 -3
  177. package/src/oauth/platform-connection.ts +13 -4
  178. package/src/plugins/defaults/injectors.ts +38 -19
  179. package/src/plugins/external-plugin-loader.ts +82 -10
  180. package/src/plugins/types.ts +16 -7
  181. package/src/prompts/__tests__/system-prompt.test.ts +6 -51
  182. package/src/prompts/__tests__/task-progress-hint-section.test.ts +4 -8
  183. package/src/prompts/system-prompt.ts +0 -8
  184. package/src/prompts/templates/BOOTSTRAP.md +5 -5
  185. package/src/prompts/templates/system-sections.ts +0 -9
  186. package/src/providers/__tests__/inference.test.ts +2 -0
  187. package/src/providers/call-site-routing.ts +24 -6
  188. package/src/providers/connection-resolution.ts +63 -13
  189. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +74 -0
  190. package/src/providers/inference/__tests__/connections-openai-compatible.test.ts +175 -0
  191. package/src/providers/inference/__tests__/connections-status-label.test.ts +15 -0
  192. package/src/providers/inference/adapter-factory.ts +9 -20
  193. package/src/providers/inference/auth.ts +12 -0
  194. package/src/providers/inference/backfill.ts +14 -1
  195. package/src/providers/inference/connections.ts +85 -5
  196. package/src/providers/inference/resolve-auth.ts +2 -0
  197. package/src/providers/model-catalog.ts +199 -244
  198. package/src/providers/model-intents.ts +3 -3
  199. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +235 -0
  200. package/src/providers/openai/chat-completions-provider.ts +159 -6
  201. package/src/providers/openrouter/client.ts +42 -4
  202. package/src/providers/platform-proxy/constants.ts +3 -4
  203. package/src/providers/provider-catalog-visibility.ts +3 -1
  204. package/src/providers/provider-send-message.ts +27 -12
  205. package/src/providers/registry.ts +30 -1
  206. package/src/runtime/agent-wake.ts +61 -1
  207. package/src/runtime/auth/route-policy.ts +13 -0
  208. package/src/runtime/http-server.ts +7 -16
  209. package/src/runtime/http-types.ts +0 -47
  210. package/src/runtime/routes/__tests__/consolidation-routes.test.ts +258 -0
  211. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +66 -4
  212. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +275 -44
  213. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +12 -0
  214. package/src/runtime/routes/channel-availability-routes.ts +5 -0
  215. package/src/runtime/routes/consolidation-routes.ts +100 -0
  216. package/src/runtime/routes/conversation-query-routes.ts +70 -11
  217. package/src/runtime/routes/conversation-routes.ts +7 -0
  218. package/src/runtime/routes/index.ts +2 -0
  219. package/src/runtime/routes/inference-provider-connection-routes.ts +134 -1
  220. package/src/runtime/routes/integrations/a2a.ts +235 -0
  221. package/src/runtime/routes/llm-call-sites-routes.ts +11 -1
  222. package/src/runtime/routes/subagents-routes.ts +41 -0
  223. package/src/subagent/manager.ts +2 -0
  224. package/src/tools/memory/register.ts +1 -9
  225. package/src/tools/registry.ts +2 -2
  226. package/src/tools/types.ts +37 -2
  227. package/src/workspace/migrations/087-memory-router-balanced-profile.ts +91 -0
  228. package/src/workspace/migrations/registry.ts +2 -0
  229. package/src/__tests__/guardian-action-conversation-turn.test.ts +0 -441
  230. package/src/memory/graph/__tests__/remember-description.test.ts +0 -55
  231. package/src/runtime/guardian-action-conversation-turn.ts +0 -99
@@ -439,6 +439,44 @@ function resolveTailStartIndex(
439
439
  return null;
440
440
  }
441
441
 
442
+ /**
443
+ * Walk a model-chosen tail index backward until it lands on a user message
444
+ * that does not contain client-side `tool_result` blocks. Prevents the
445
+ * orphan-`tool_result` failure where the matching assistant `tool_use` sits
446
+ * in the discarded prefix and Anthropic rejects the next call with
447
+ * `unexpected tool_use_id found in tool_result blocks`.
448
+ *
449
+ * Walking back (rather than forward) preserves the recent context the model
450
+ * deliberately chose to keep; the tail just expands by the few messages
451
+ * needed to re-anchor the orphaned `tool_result` against its `tool_use`.
452
+ *
453
+ * Returns 0 when the walk falls off the front — the caller treats this as
454
+ * "nothing to compact" via the existing `tailIndex === 0` branch.
455
+ *
456
+ * Only `type === "tool_result"` blocks count. Server-side tools
457
+ * (`server_tool_use` / `web_search_tool_result`) are self-paired inside an
458
+ * assistant message and never trigger an adjustment.
459
+ */
460
+ export function adjustTailIndexForToolPairing(
461
+ messages: Message[],
462
+ tailIndex: number,
463
+ ): number {
464
+ let k = tailIndex;
465
+ while (k > 0) {
466
+ const m = messages[k];
467
+ if (
468
+ m.role === "user" &&
469
+ // guard:allow-tool-result-only — server-side web_search_tool_result is
470
+ // self-paired inside its assistant message and never spans user turns.
471
+ !m.content.some((block) => block.type === "tool_result")
472
+ ) {
473
+ return k;
474
+ }
475
+ k--;
476
+ }
477
+ return 0;
478
+ }
479
+
442
480
  // ---------------------------------------------------------------------------
443
481
  // Retained-image hydration
444
482
  // ---------------------------------------------------------------------------
@@ -655,8 +693,12 @@ export async function runAssistantDrivenCompaction(
655
693
  }
656
694
 
657
695
  const timestamps = buildTimestampIndex(args.messages);
658
- const tailIndex = resolveTailStartIndex(args.messages, timestamps, parsed);
659
- if (tailIndex == null) {
696
+ const resolvedTailIndex = resolveTailStartIndex(
697
+ args.messages,
698
+ timestamps,
699
+ parsed,
700
+ );
701
+ if (resolvedTailIndex == null) {
660
702
  log.warn(
661
703
  {
662
704
  timestamp: parsed.tailStartTimestamp,
@@ -680,6 +722,22 @@ export async function runAssistantDrivenCompaction(
680
722
  };
681
723
  }
682
724
 
725
+ const tailIndex = adjustTailIndexForToolPairing(
726
+ args.messages,
727
+ resolvedTailIndex,
728
+ );
729
+ if (tailIndex !== resolvedTailIndex) {
730
+ log.info(
731
+ {
732
+ conversationId: args.conversationId,
733
+ originalTailIndex: resolvedTailIndex,
734
+ tailIndex,
735
+ walkedBy: resolvedTailIndex - tailIndex,
736
+ },
737
+ "Adjusted compaction tail backward to preserve tool_use/tool_result pairing",
738
+ );
739
+ }
740
+
683
741
  if (tailIndex === 0) {
684
742
  return {
685
743
  ...emptyResult(
@@ -762,6 +820,9 @@ export async function runAssistantDrivenCompaction(
762
820
  compactedMessages: compactableMessages.length,
763
821
  compactedPersistedMessages,
764
822
  tailIndex,
823
+ ...(tailIndex !== resolvedTailIndex
824
+ ? { originalTailIndex: resolvedTailIndex }
825
+ : {}),
765
826
  retainedImages: resolved.length,
766
827
  summaryChars: summaryText.length,
767
828
  },
@@ -885,10 +946,12 @@ export async function runEmergencyCompaction(
885
946
 
886
947
  const splitIndex = findLastToolPairStart(args.messages);
887
948
  if (splitIndex == null || splitIndex === 0) {
888
- log.info(
889
- "Emergency compaction: no tool pair found — falling through",
949
+ log.info("Emergency compaction: no tool pair found — falling through");
950
+ return emptyResult(
951
+ args,
952
+ thresholdTokens,
953
+ "no tool pair for emergency split",
890
954
  );
891
- return emptyResult(args, thresholdTokens, "no tool pair for emergency split");
892
955
  }
893
956
 
894
957
  const keptTail = stripInjectionsForCompaction(
@@ -904,8 +967,7 @@ export async function runEmergencyCompaction(
904
967
  const prefixBudget = args.maxInputTokens - instructionBudget - outputBudget;
905
968
 
906
969
  let prefixEstimate = estimatePromptTokens(prefix, args.systemPrompt, {
907
- providerName:
908
- args.provider.tokenEstimationProvider ?? args.provider.name,
970
+ providerName: args.provider.tokenEstimationProvider ?? args.provider.name,
909
971
  });
910
972
 
911
973
  if (prefixEstimate > prefixBudget && prefix.length > 1) {
@@ -920,10 +982,7 @@ export async function runEmergencyCompaction(
920
982
  // Drop messages from the front until we fit. Keep at least the first
921
983
  // message (may be an existing summary) and try to preserve recent context.
922
984
  let dropCount = 0;
923
- while (
924
- prefixEstimate > prefixBudget &&
925
- dropCount < prefix.length - 1
926
- ) {
985
+ while (prefixEstimate > prefixBudget && dropCount < prefix.length - 1) {
927
986
  dropCount++;
928
987
  const truncated = prefix.slice(dropCount);
929
988
  prefixEstimate = estimatePromptTokens(truncated, args.systemPrompt, {
@@ -1015,7 +1074,8 @@ export async function runEmergencyCompaction(
1015
1074
  compactedMessages: compactedCount,
1016
1075
  keptTailMessages: keptTail.length,
1017
1076
  summaryChars: summaryText.length,
1018
- prefixTruncated: prefix[0]?.content?.[0]?.type === "text" &&
1077
+ prefixTruncated:
1078
+ prefix[0]?.content?.[0]?.type === "text" &&
1019
1079
  (prefix[0].content[0] as { text: string }).text.includes("truncated"),
1020
1080
  },
1021
1081
  "Applied emergency mid-turn compaction",
@@ -33,18 +33,24 @@ const OTHER_BLOCK_TOKENS = 16;
33
33
  const SYSTEM_PROMPT_OVERHEAD_TOKENS = 8;
34
34
  const GEMINI_INLINE_FILE_MIME_TYPES = new Set(["application/pdf"]);
35
35
 
36
- // Anthropic scales images to fit within 1568x1568 maintaining aspect ratio,
37
- // then charges ~(width * height) / 750 tokens.
38
- const ANTHROPIC_IMAGE_MAX_DIMENSION = 1568;
39
- // Anthropic caps images at ~1.2 megapixels in addition to the 1568px dimension limit.
40
- // Images exceeding this are further scaled down. The docs state images above ~1,600 tokens
41
- // are resized. 1,200,000 / 750 = 1,600 tokens, matching the documented threshold.
42
- // Reference table (max sizes that won't be resized):
36
+ // Dimension-based image token estimate, used as a universal default for every
37
+ // provider. The formula and constants below come from Anthropic's published
38
+ // vision spec — scale to a 1568x1568 bounding box, then charge
39
+ // ~(width * height) / 750 tokens, with a ~1.2-megapixel cap that lands at
40
+ // ~1,600 tokens per image. Reference table (max sizes that won't be resized):
43
41
  // 1:1 → 1092x1092 (~1,590 tokens) 1:2 → 784x1568 (~1,639 tokens)
44
42
  // See: https://platform.claude.com/docs/en/build-with-claude/vision#evaluate-image-size
45
- const ANTHROPIC_IMAGE_MAX_PIXELS = 1_200_000;
46
- const ANTHROPIC_IMAGE_TOKENS_PER_PIXEL = 1 / 750;
47
- const ANTHROPIC_IMAGE_MAX_TOKENS = 1_600;
43
+ //
44
+ // Other multimodal providers (OpenAI/GPT-4V tile pricing, Moonshot/Kimi,
45
+ // Gemini fixed-cost, OpenRouter pass-through) price differently in detail,
46
+ // but every published rate lands in the same hundreds-to-low-thousands range
47
+ // per image. Using this formula as the default gets compaction within ~2-3x
48
+ // of reality instead of the ~30-100x over-counting produced by treating the
49
+ // raw base64 payload as if it were text.
50
+ const IMAGE_MAX_DIMENSION = 1568;
51
+ const IMAGE_MAX_PIXELS = 1_200_000;
52
+ const IMAGE_TOKENS_PER_PIXEL = 1 / 750;
53
+ const IMAGE_MAX_TOKENS = 1_600;
48
54
 
49
55
  // Anthropic renders each PDF page as an image (~1,568 tokens at standard
50
56
  // resolution) plus any extracted text. Typical PDF pages are 50-150 KB.
@@ -103,45 +109,37 @@ function estimateFileDataTokens(
103
109
  return 0;
104
110
  }
105
111
 
106
- function estimateAnthropicImageTokens(width: number, height: number): number {
112
+ function estimateImageTokensByDimensions(
113
+ width: number,
114
+ height: number,
115
+ ): number {
107
116
  // Step 1: Scale to fit within 1568px bounding box
108
- const dimScale = Math.min(
109
- 1,
110
- ANTHROPIC_IMAGE_MAX_DIMENSION / Math.max(width, height),
111
- );
117
+ const dimScale = Math.min(1, IMAGE_MAX_DIMENSION / Math.max(width, height));
112
118
  let scaledWidth = Math.round(width * dimScale);
113
119
  let scaledHeight = Math.round(height * dimScale);
114
120
 
115
121
  // Step 2: Scale further if exceeds megapixel budget
116
122
  const pixels = scaledWidth * scaledHeight;
117
- if (pixels > ANTHROPIC_IMAGE_MAX_PIXELS) {
118
- const mpScale = Math.sqrt(ANTHROPIC_IMAGE_MAX_PIXELS / pixels);
123
+ if (pixels > IMAGE_MAX_PIXELS) {
124
+ const mpScale = Math.sqrt(IMAGE_MAX_PIXELS / pixels);
119
125
  scaledWidth = Math.round(scaledWidth * mpScale);
120
126
  scaledHeight = Math.round(scaledHeight * mpScale);
121
127
  }
122
128
 
123
- return Math.ceil(
124
- scaledWidth * scaledHeight * ANTHROPIC_IMAGE_TOKENS_PER_PIXEL,
125
- );
129
+ return Math.ceil(scaledWidth * scaledHeight * IMAGE_TOKENS_PER_PIXEL);
126
130
  }
127
131
 
128
132
  function estimateImageTokens(
129
133
  block: Extract<ContentBlock, { type: "image" }>,
130
- options?: TokenEstimatorOptions,
131
134
  ): number {
132
- if (options?.providerName === "anthropic") {
133
- const dims = parseImageDimensions(
134
- block.source.data,
135
- block.source.media_type,
136
- );
137
- if (dims) {
138
- return estimateAnthropicImageTokens(dims.width, dims.height);
139
- }
140
- // Fallback: if dimensions can't be parsed, use Anthropic's max
141
- return ANTHROPIC_IMAGE_MAX_TOKENS;
135
+ const dims = parseImageDimensions(block.source.data, block.source.media_type);
136
+ if (dims) {
137
+ return estimateImageTokensByDimensions(dims.width, dims.height);
142
138
  }
143
- // Non-Anthropic: keep existing base64-size heuristic
144
- return estimateTextTokens(block.source.data);
139
+ // Dimensions unparseable (corrupt header, exotic format): use the per-image
140
+ // cap rather than the raw base64 length, which over-counts by 30-100x for
141
+ // non-Anthropic providers and trips spurious compaction.
142
+ return IMAGE_MAX_TOKENS;
145
143
  }
146
144
 
147
145
  export function estimateContentBlockTokens(
@@ -188,7 +186,7 @@ export function estimateContentBlockTokens(
188
186
  return (
189
187
  IMAGE_BLOCK_OVERHEAD_TOKENS +
190
188
  estimateTextTokens(block.source.media_type) +
191
- estimateImageTokens(block, options)
189
+ estimateImageTokens(block)
192
190
  );
193
191
  case "file":
194
192
  return (
@@ -97,7 +97,6 @@ mock.module("../../memory/auto-analysis-enqueue.js", () => ({
97
97
  },
98
98
  }));
99
99
 
100
- let memoryRetroEnabled = false;
101
100
  const memoryRetroCalls: Array<{
102
101
  conversationId: string;
103
102
  trigger: string;
@@ -108,7 +107,6 @@ mock.module("../../memory/memory-retrospective-enqueue.js", () => ({
108
107
  conversationId: string;
109
108
  trigger: string;
110
109
  }) => {
111
- if (!memoryRetroEnabled) return;
112
110
  memoryRetroCalls.push(args);
113
111
  },
114
112
  // Also export sibling functions other modules import from this file, so
@@ -205,7 +203,6 @@ describe("disposeConversation — auto-analysis enqueue", () => {
205
203
  autoAnalyzeCalls.length = 0;
206
204
  memoryRetroCalls.length = 0;
207
205
  autoAnalyzeEnabled = true;
208
- memoryRetroEnabled = false;
209
206
  autoAnalysisConversations.clear();
210
207
  v2Enabled = false;
211
208
  });
@@ -390,13 +387,11 @@ describe("disposeConversation — memory-retrospective lifecycle safety net", ()
390
387
  autoAnalyzeCalls.length = 0;
391
388
  memoryRetroCalls.length = 0;
392
389
  autoAnalyzeEnabled = false;
393
- memoryRetroEnabled = false;
394
390
  autoAnalysisConversations.clear();
395
391
  v2Enabled = false;
396
392
  });
397
393
 
398
- test("guardian conversation + flag on — enqueues memory-retrospective with trigger 'lifecycle'", () => {
399
- memoryRetroEnabled = true;
394
+ test("guardian conversation — enqueues memory-retrospective with trigger 'lifecycle'", () => {
400
395
  const ctx = makeDisposeContext({
401
396
  conversationId: "conv-retro",
402
397
  trustClass: "guardian",
@@ -411,20 +406,7 @@ describe("disposeConversation — memory-retrospective lifecycle safety net", ()
411
406
  });
412
407
  });
413
408
 
414
- test("flag off — no memory-retrospective enqueue", () => {
415
- memoryRetroEnabled = false;
416
- const ctx = makeDisposeContext({
417
- conversationId: "conv-retro-off",
418
- trustClass: "guardian",
419
- });
420
-
421
- disposeConversation(ctx);
422
-
423
- expect(memoryRetroCalls).toHaveLength(0);
424
- });
425
-
426
- test("untrusted actor — no memory-retrospective enqueue even when flag is on", () => {
427
- memoryRetroEnabled = true;
409
+ test("untrusted actor — no memory-retrospective enqueue", () => {
428
410
  const ctx = makeDisposeContext({
429
411
  conversationId: "conv-retro-untrusted",
430
412
  trustClass: "unknown",
@@ -443,8 +425,7 @@ describe("disposeConversation — memory-retrospective lifecycle safety net", ()
443
425
  // outside the `!isAutoAnalysis` guard, so it fired even for auto-analysis
444
426
  // conversations. Mirrors the indexer-time gate in `indexer.ts` and
445
427
  // matches the existing graph_extract recursion-guard semantics.
446
- test("auto-analysis conversation — does NOT enqueue memory-retrospective even with flag on", () => {
447
- memoryRetroEnabled = true;
428
+ test("auto-analysis conversation — does NOT enqueue memory-retrospective", () => {
448
429
  autoAnalysisConversations.add("conv-auto-retro");
449
430
  const ctx = makeDisposeContext({
450
431
  conversationId: "conv-auto-retro",
@@ -23,7 +23,9 @@ import {
23
23
  } from "../memory/conversation-crud.js";
24
24
  import {
25
25
  backfillMessageIdOnLogs,
26
+ buildProviderErrorResponsePayload,
26
27
  recordRequestLog,
28
+ setAgentLoopExitReasonOnLatestLog,
27
29
  } from "../memory/llm-request-log-store.js";
28
30
  import { backfillMemoryRecallLogMessageId } from "../memory/memory-recall-log-store.js";
29
31
  import { backfillMemoryV2ActivationMessageId } from "../memory/memory-v2-activation-log-store.js";
@@ -1166,6 +1168,52 @@ function handleUsage(
1166
1168
  state.llmCallStartedEmitted = false;
1167
1169
  }
1168
1170
 
1171
+ /**
1172
+ * Persist a provider-rejected LLM call as an `llm_request_logs` row.
1173
+ *
1174
+ * Mirrors `handleUsage`'s recording side-effect for the failure path: the
1175
+ * loop only reaches the success branch (and emits `usage`) when the
1176
+ * provider returns a response, so without this handler a rejected call
1177
+ * leaves nothing in the inspector — only a pino line saying "The AI
1178
+ * provider rejected the request." The row's `messageId` is left null
1179
+ * here and linked via one of two backfill paths, depending on how the
1180
+ * turn unwinds:
1181
+ *
1182
+ * - Multi-call turn where a later call also produces a real assistant
1183
+ * response: `handleMessageComplete` -> `backfillMessageIdOnLogs`
1184
+ * sweeps this row with the rest, same as a successful-call row.
1185
+ * - Pure provider-failure turn (no real assistant response): the
1186
+ * synthetic error-message branch in `conversation-agent-loop.ts`
1187
+ * persists a stand-in assistant message and calls
1188
+ * `backfillMessageIdOnLogs` itself, since `message_complete` is
1189
+ * never emitted on that path. Closing the orphan window inside the
1190
+ * same synchronous turn prevents a later turn's sweep from wrong-
1191
+ * attaching this row to an unrelated assistant message.
1192
+ *
1193
+ * Failures inside the recording itself are logged and swallowed — this
1194
+ * mirrors `handleUsage`'s non-fatal stance so a DB hiccup never escalates
1195
+ * a provider rejection into a dispatcher-level throw.
1196
+ */
1197
+ function handleProviderError(
1198
+ deps: EventHandlerDeps,
1199
+ event: Extract<AgentEvent, { type: "provider_error" }>,
1200
+ ): void {
1201
+ try {
1202
+ recordRequestLog(
1203
+ deps.ctx.conversationId,
1204
+ JSON.stringify(event.rawRequest),
1205
+ JSON.stringify(buildProviderErrorResponsePayload(event.error)),
1206
+ undefined,
1207
+ event.actualProvider,
1208
+ );
1209
+ } catch (err) {
1210
+ deps.rlog.warn(
1211
+ { err },
1212
+ "Failed to persist provider-error LLM request log (non-fatal)",
1213
+ );
1214
+ }
1215
+ }
1216
+
1169
1217
  // ── Dispatcher ───────────────────────────────────────────────────────
1170
1218
 
1171
1219
  /** Routes an AgentEvent to the appropriate handler. */
@@ -1254,12 +1302,42 @@ export async function dispatchAgentEvent(
1254
1302
  case "error":
1255
1303
  handleError(state, deps, event);
1256
1304
  break;
1305
+ case "provider_error":
1306
+ handleProviderError(deps, event);
1307
+ break;
1257
1308
  case "message_complete":
1258
1309
  await handleMessageComplete(state, deps, event);
1259
1310
  break;
1260
1311
  case "usage":
1261
1312
  handleUsage(state, deps, event);
1262
1313
  break;
1314
+ case "agent_loop_exit":
1315
+ // Stamp the exit reason onto the most-recent llm_request_logs
1316
+ // row for this conversation. The final `usage` event of the run
1317
+ // lands its row immediately before this event arrives (in the
1318
+ // normal-dispatch path; the wake path handles ordering
1319
+ // explicitly via `pendingExitReason`).
1320
+ //
1321
+ // Wrapped in try/catch so a DB hiccup here can't tear down the
1322
+ // surrounding dispatch — the outer try/catch already swallows
1323
+ // errors, but logging here gives the diagnosis hook a clear
1324
+ // attribution to the exit handler specifically.
1325
+ try {
1326
+ setAgentLoopExitReasonOnLatestLog(
1327
+ deps.ctx.conversationId,
1328
+ event.reason,
1329
+ );
1330
+ } catch (err) {
1331
+ log.warn(
1332
+ {
1333
+ err,
1334
+ conversationId: deps.ctx.conversationId,
1335
+ reason: event.reason,
1336
+ },
1337
+ "Failed to persist agent_loop_exit_reason (non-fatal)",
1338
+ );
1339
+ }
1340
+ break;
1263
1341
  }
1264
1342
  } catch (err) {
1265
1343
  log.error(
@@ -71,7 +71,9 @@ import {
71
71
  isReplaceableTitle,
72
72
  queueRegenerateConversationTitle,
73
73
  } from "../memory/conversation-title-service.js";
74
+ import { isBackgroundConversationType } from "../memory/conversation-types.js";
74
75
  import type { ConversationGraphMemory } from "../memory/graph/conversation-graph-memory.js";
76
+ import { backfillMessageIdOnLogs } from "../memory/llm-request-log-store.js";
75
77
  import { recordMemoryRecallLog } from "../memory/memory-recall-log-store.js";
76
78
  import { enqueueMemoryRetrospectiveOnCompaction } from "../memory/memory-retrospective-enqueue.js";
77
79
  import { PKB_WORKSPACE_SCOPE } from "../memory/pkb/types.js";
@@ -109,6 +111,7 @@ import type {
109
111
  MemoryResult,
110
112
  OverflowReduceArgs,
111
113
  OverflowReduceResult,
114
+ PersistAddResult,
112
115
  PersistArgs,
113
116
  PersistResult,
114
117
  TurnContext as PluginTurnContext,
@@ -1616,6 +1619,9 @@ export async function runAgentLoopImpl(
1616
1619
  transportHints: ctx.transportHints ?? null,
1617
1620
  slackRuntimeContextNotice: ctx.slackRuntimeContextNotice ?? null,
1618
1621
  isNonInteractive: !isInteractiveResolved,
1622
+ isBackgroundConversation: isBackgroundConversationType(
1623
+ turnStartConversation?.conversationType,
1624
+ ),
1619
1625
  subagentStatusBlock,
1620
1626
  slackChronologicalMessages,
1621
1627
  slackActiveThreadFocusBlock,
@@ -2942,7 +2948,7 @@ export async function runAgentLoopImpl(
2942
2948
  const errorAssistantMessage = createAssistantMessage(
2943
2949
  state.providerErrorUserMessage,
2944
2950
  );
2945
- await runPipeline<PersistArgs, PersistResult>(
2951
+ const errorPersistResult = (await runPipeline<PersistArgs, PersistResult>(
2946
2952
  "persistence",
2947
2953
  getMiddlewaresFor("persistence"),
2948
2954
  defaultPersistenceTerminal,
@@ -2955,9 +2961,30 @@ export async function runAgentLoopImpl(
2955
2961
  },
2956
2962
  buildPluginTurnContext(ctx, reqId),
2957
2963
  DEFAULT_TIMEOUTS.persistence,
2958
- );
2964
+ )) as PersistAddResult;
2959
2965
  persistedErrorAssistantMessage = true;
2960
2966
  newMessages.push(errorAssistantMessage);
2967
+ // Pipe the just-assigned message id into any orphaned LLM request log
2968
+ // row(s) for this turn. The success path links rows via
2969
+ // `handleMessageComplete` -> `backfillMessageIdOnLogs`, but provider-
2970
+ // failure turns never fire `message_complete` (the synthetic assistant
2971
+ // message is persisted directly above), so without this call the rows
2972
+ // from `handleProviderError` stay with `message_id IS NULL` and a
2973
+ // later turn's backfill sweep would wrong-attach them to that turn's
2974
+ // assistant message. Scope is per-conversation, so concurrent runs on
2975
+ // other conversations cannot collide. Non-fatal — a DB hiccup must
2976
+ // not escalate a provider rejection into a turn-level throw.
2977
+ try {
2978
+ backfillMessageIdOnLogs(
2979
+ ctx.conversationId,
2980
+ errorPersistResult.message.id,
2981
+ );
2982
+ } catch (err) {
2983
+ rlog.warn(
2984
+ { err },
2985
+ "Failed to backfill message_id on provider-error LLM request logs (non-fatal)",
2986
+ );
2987
+ }
2961
2988
  // Do NOT send assistant_text_delta here — handleProviderError already
2962
2989
  // emitted a conversation_error event for this same error text, and the
2963
2990
  // client renders it as an InlineChatErrorAlert. Sending a text delta
@@ -1652,6 +1652,7 @@ const RUNTIME_INJECTION_PREFIXES = [
1652
1652
  "<interface_turn_context>", // backward-compat: strip legacy separate interface blocks
1653
1653
  // NOTE: <turn_context> is intentionally NOT stripped — unified turn context
1654
1654
  // blocks persist in history so the assistant retains temporal/actor grounding.
1655
+ "<background_turn>",
1655
1656
  "<memory_context __injected>",
1656
1657
  "<memory_context>", // backward-compat: strip legacy blocks from pre-__injected history
1657
1658
  // The static `memory-v2-static` block (opens `<memory>\n…`) IS stripped
@@ -1966,6 +1967,13 @@ export interface RuntimeInjectionOptions {
1966
1967
  nowScratchpad?: string | null;
1967
1968
  subagentStatusBlock?: string | null;
1968
1969
  isNonInteractive?: boolean;
1970
+ /**
1971
+ * True when the active conversation's type is "background" or "scheduled".
1972
+ * Forwarded to {@link TurnInjectionInputs.isBackgroundConversation} so the
1973
+ * `background-turn` injector can wrap the tail user message with the
1974
+ * configured reminder.
1975
+ */
1976
+ isBackgroundConversation?: boolean;
1969
1977
  transportHints?: string[] | null;
1970
1978
  slackRuntimeContextNotice?: string | null;
1971
1979
  /**
@@ -2056,6 +2064,7 @@ function buildTurnInjectionInputs(
2056
2064
  voiceCallControlPrompt: options.voiceCallControlPrompt,
2057
2065
  transportHints: options.transportHints,
2058
2066
  isNonInteractive: options.isNonInteractive,
2067
+ isBackgroundConversation: options.isBackgroundConversation,
2059
2068
  activeDocuments: options.activeDocuments,
2060
2069
  };
2061
2070
  }
@@ -54,7 +54,6 @@ import {
54
54
  getConversationOriginChannel,
55
55
  getConversationOverrideProfileFromRow,
56
56
  } from "../memory/conversation-crud.js";
57
- import { isBackgroundConversationType } from "../memory/conversation-types.js";
58
57
  import { ConversationGraphMemory } from "../memory/graph/conversation-graph-memory.js";
59
58
  import { shouldExposePersonalMemory } from "../memory/v2/static-context.js";
60
59
  import { PermissionPrompter } from "../permissions/prompter.js";
@@ -489,9 +488,6 @@ export class Conversation {
489
488
  channelPersona: persona.channelPersona,
490
489
  userSlug: persona.userSlug,
491
490
  onboardingContext: this.getOnboardingContext(),
492
- isBackgroundConversation: isBackgroundConversationType(
493
- getConversation(this.conversationId)?.conversationType,
494
- ),
495
491
  });
496
492
  })(),
497
493
  };
@@ -583,9 +579,6 @@ export class Conversation {
583
579
  channelPersona: persona.channelPersona,
584
580
  userSlug: persona.userSlug,
585
581
  onboardingContext: this.getOnboardingContext(),
586
- isBackgroundConversation: isBackgroundConversationType(
587
- getConversation(this.conversationId)?.conversationType,
588
- ),
589
582
  });
590
583
  })();
591
584
  const tools = buildToolDefinitions();
@@ -326,6 +326,46 @@ function formatLocalDate(date: Date, timeZone: string): string {
326
326
  ).padStart(2, "0")}`;
327
327
  }
328
328
 
329
+ const localTimestampFormatterCache = new Map<string, Intl.DateTimeFormat>();
330
+
331
+ function getLocalTimestampFormatter(timeZone: string): Intl.DateTimeFormat {
332
+ let fmt = localTimestampFormatterCache.get(timeZone);
333
+ if (!fmt) {
334
+ fmt = new Intl.DateTimeFormat("en-CA", {
335
+ timeZone,
336
+ year: "numeric",
337
+ month: "2-digit",
338
+ day: "2-digit",
339
+ hour: "2-digit",
340
+ minute: "2-digit",
341
+ second: "2-digit",
342
+ hourCycle: "h23",
343
+ });
344
+ localTimestampFormatterCache.set(timeZone, fmt);
345
+ }
346
+ return fmt;
347
+ }
348
+
349
+ /**
350
+ * Format an epoch-millis instant as `YYYY-MM-DD HH:MM:SS` in the given
351
+ * IANA timezone. When `timeZone` is omitted or `"UTC"`, falls back to a
352
+ * pure-UTC `toISOString` slice so callers can opt in incrementally.
353
+ *
354
+ * The internal `Intl.DateTimeFormat` is memoized by `timeZone` because
355
+ * constructing it is ~1ms in V8 — material when rendering long transcripts.
356
+ */
357
+ export function formatLocalTimestamp(ms: number, timeZone?: string): string {
358
+ if (!timeZone || timeZone === "UTC") {
359
+ return new Date(ms).toISOString().replace("T", " ").slice(0, 19);
360
+ }
361
+ const parts = getLocalTimestampFormatter(timeZone).formatToParts(
362
+ new Date(ms),
363
+ );
364
+ const v: Record<string, string> = {};
365
+ for (const p of parts) v[p.type] = p.value;
366
+ return `${v.year}-${v.month}-${v.day} ${v.hour}:${v.minute}:${v.second}`;
367
+ }
368
+
329
369
  export function resolveTurnTimezoneContext(
330
370
  options: TemporalContextOptions = {},
331
371
  ): TurnTimezoneContext {