@vellumai/assistant 0.12.5 → 0.12.6-dev.202609292116.41f7caa

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 (295) hide show
  1. package/.env.example +2 -0
  2. package/ARCHITECTURE.md +26 -3
  3. package/docs/automatic-done.md +32 -0
  4. package/docs/desktop-browser-cli.md +3 -2
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
  9. package/node_modules/@vellumai/environments/src/shell.test.ts +13 -1
  10. package/node_modules/@vellumai/environments/src/shell.ts +22 -1
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  12. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
  13. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  14. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
  15. package/node_modules/@vellumai/gateway-client/src/__tests__/status-contracts.test.ts +39 -0
  16. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +25 -0
  17. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  18. package/node_modules/@vellumai/service-contracts/src/__tests__/mcp-oauth.test.ts +53 -0
  19. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  20. package/node_modules/@vellumai/service-contracts/src/mcp-oauth.ts +60 -0
  21. package/openapi.yaml +270 -10
  22. package/package.json +1 -1
  23. package/src/__tests__/agent-loop.test.ts +76 -0
  24. package/src/__tests__/anthropic-provider.test.ts +2 -0
  25. package/src/__tests__/byok-default-profile-ensure.test.ts +7 -0
  26. package/src/__tests__/config-get-vision-flag.test.ts +1 -1
  27. package/src/__tests__/config-loader-validation-reset-notice.test.ts +2 -2
  28. package/src/__tests__/conversation-agent-loop-overflow.test.ts +160 -154
  29. package/src/__tests__/conversation-evictor.test.ts +54 -0
  30. package/src/__tests__/conversation-pairing.test.ts +112 -0
  31. package/src/__tests__/document-append-idempotency.test.ts +13 -0
  32. package/src/__tests__/document-create-dedupe.test.ts +13 -0
  33. package/src/__tests__/document-find-replace.test.ts +13 -0
  34. package/src/__tests__/document-revision-history.test.ts +390 -0
  35. package/src/__tests__/document-revision.test.ts +425 -0
  36. package/src/__tests__/document-tool-security.test.ts +13 -0
  37. package/src/__tests__/document-update-default-surface.test.ts +13 -0
  38. package/src/__tests__/emit-signal-routing-intent.test.ts +270 -0
  39. package/src/__tests__/gateway-flag-listener.test.ts +59 -6
  40. package/src/__tests__/headless-browser-navigate.test.ts +20 -30
  41. package/src/__tests__/host-cu-proxy.test.ts +79 -0
  42. package/src/__tests__/host-cu-routes-targeted.test.ts +14 -5
  43. package/src/__tests__/host-shell-tool.test.ts +6 -2
  44. package/src/__tests__/identity-routes.test.ts +4 -3
  45. package/src/__tests__/list-messages-client-message-id.test.ts +43 -0
  46. package/src/__tests__/llm-catalog-parity.test.ts +4 -4
  47. package/src/__tests__/llm-schema.test.ts +1 -1
  48. package/src/__tests__/notification-platform-adapter.test.ts +83 -1
  49. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  50. package/src/__tests__/openai-provider.test.ts +38 -11
  51. package/src/__tests__/pricing.test.ts +11 -0
  52. package/src/__tests__/public-ingress-urls.test.ts +26 -0
  53. package/src/__tests__/retry-thinking-tool-choice.test.ts +47 -0
  54. package/src/__tests__/skill-execute-input.test.ts +11 -0
  55. package/src/__tests__/tool-approval-handler.test.ts +4 -0
  56. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -0
  57. package/src/__tests__/usage-attribution.test.ts +31 -0
  58. package/src/__tests__/workspace-migration-133-collapse-provider-connections.test.ts +3 -3
  59. package/src/__tests__/workspace-migration-160-repair-retired-fireworks-model-ids.test.ts +262 -0
  60. package/src/__tests__/workspace-migration-161-repair-retired-fireworks-glm-5p2-model-id.test.ts +233 -0
  61. package/src/acp/agent-process.ts +6 -1
  62. package/src/acp/client-handler.ts +6 -1
  63. package/src/agent/loop-unparseable-tool-retry.test.ts +333 -0
  64. package/src/agent/loop.ts +161 -17
  65. package/src/api/events/document-editor-show.ts +5 -0
  66. package/src/api/events/document-editor-update.ts +10 -0
  67. package/src/api/events/tool-result.ts +1 -0
  68. package/src/calls/__tests__/call-controller.test.ts +273 -1
  69. package/src/calls/__tests__/media-stream-server-integration.test.ts +40 -0
  70. package/src/calls/__tests__/media-stream-stt-session.test.ts +33 -0
  71. package/src/calls/__tests__/voice-session-bridge.test.ts +54 -0
  72. package/src/calls/call-constants.ts +10 -0
  73. package/src/calls/call-controller.ts +359 -25
  74. package/src/calls/channel-admission-reader.ts +15 -9
  75. package/src/calls/media-stream-server.ts +18 -0
  76. package/src/calls/media-stream-stt-session.ts +30 -9
  77. package/src/calls/twilio-provider.ts +14 -31
  78. package/src/calls/voice-session-bridge.ts +18 -1
  79. package/src/cli/commands/browser.ts +1 -2
  80. package/src/cli/lib/__tests__/plugin-artifact.test.ts +23 -0
  81. package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +2 -0
  82. package/src/cli/lib/__tests__/plugin-details.test.ts +16 -0
  83. package/src/cli/lib/bundled-marketplace.json +54 -2
  84. package/src/cli/lib/inspect-plugin.ts +15 -15
  85. package/src/cli/lib/list-installed-plugins.ts +29 -28
  86. package/src/cli/lib/plugin-artifact.ts +74 -63
  87. package/src/cli/lib/plugin-catalog-visibility.ts +1 -0
  88. package/src/cli/lib/plugin-details.ts +27 -42
  89. package/src/cli/lib/plugin-marketplace.ts +6 -8
  90. package/src/cli/lib/plugin-surfaces.ts +8 -7
  91. package/src/cli.ts +1 -3
  92. package/src/config/__tests__/chat-settings.test.ts +121 -0
  93. package/src/config/assistant-feature-flags.ts +28 -23
  94. package/src/config/bundled-skills/computer-use/SKILL.md +20 -0
  95. package/src/config/bundled-skills/computer-use/TOOLS.json +11 -11
  96. package/src/config/chat-settings.ts +100 -0
  97. package/src/config/default-profile-catalog.ts +1 -1
  98. package/src/config/feature-flag-cache.ts +7 -25
  99. package/src/config/feature-flag-registry.json +9 -9
  100. package/src/config/loader.ts +5 -1
  101. package/src/config/profile-text-generation.ts +25 -21
  102. package/src/config/schemas/conversations.ts +14 -0
  103. package/src/config/schemas/llm.ts +1 -0
  104. package/src/config/schemas/notifications.ts +8 -1
  105. package/src/contacts/gateway-channel-read.ts +1 -1
  106. package/src/conversations/__tests__/auto-archive-activity.test.ts +136 -0
  107. package/src/conversations/__tests__/auto-archive.test.ts +304 -0
  108. package/src/conversations/auto-archive-activity.ts +61 -0
  109. package/src/conversations/auto-archive.ts +201 -0
  110. package/src/daemon/computer-use-observation.test.ts +81 -0
  111. package/src/daemon/computer-use-observation.ts +23 -0
  112. package/src/daemon/conversation-agent-loop-handlers.ts +18 -2
  113. package/src/daemon/conversation-agent-loop.ts +14 -6
  114. package/src/daemon/conversation-evictor.ts +50 -16
  115. package/src/daemon/conversation-messaging.ts +4 -0
  116. package/src/daemon/conversation-process.ts +15 -0
  117. package/src/daemon/conversation-queue-manager.ts +2 -0
  118. package/src/daemon/conversation.ts +2 -0
  119. package/src/daemon/doordash-steps.ts +1 -3
  120. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +43 -1
  121. package/src/daemon/handlers/__tests__/config-channels.test.ts +1 -1
  122. package/src/daemon/handlers/config-a2a.ts +75 -2
  123. package/src/daemon/handlers/shared.ts +13 -2
  124. package/src/daemon/host-cu-proxy.ts +34 -3
  125. package/src/daemon/lifecycle.ts +15 -2
  126. package/src/daemon/process-message.ts +5 -0
  127. package/src/daemon/shutdown-handlers.ts +3 -0
  128. package/src/daemon/turn-finalization.ts +4 -0
  129. package/src/desktop/desktop-browser-endpoint.ts +11 -2
  130. package/src/desktop/desktop-chrome-policy.test.ts +1 -0
  131. package/src/desktop/desktop-chrome-policy.ts +7 -1
  132. package/src/desktop/desktop-computer-use.test.ts +14 -4
  133. package/src/desktop/desktop-session-manager.ts +5 -3
  134. package/src/documents/document-revisions-store.ts +153 -0
  135. package/src/documents/document-store.ts +471 -133
  136. package/src/followups/followup-store.ts +0 -4
  137. package/src/i18n/__tests__/i18n.test.ts +25 -0
  138. package/src/i18n/assistant-text.ts +18 -0
  139. package/src/i18n/index.ts +1 -0
  140. package/src/i18n/messages.ts +11 -0
  141. package/src/inbound/__tests__/oauth-callback-url.test.ts +25 -2
  142. package/src/inbound/oauth-callback-url.ts +30 -1
  143. package/src/inbound/public-ingress-urls.ts +12 -2
  144. package/src/ipc/gateway-client.test.ts +40 -0
  145. package/src/ipc/gateway-client.ts +12 -29
  146. package/src/ipc/gateway-flag-listener.ts +7 -10
  147. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +71 -0
  148. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +83 -0
  149. package/src/live-voice/__tests__/protocol.test.ts +77 -0
  150. package/src/live-voice/__tests__/share-targets.test.ts +163 -0
  151. package/src/live-voice/live-voice-session.ts +23 -6
  152. package/src/live-voice/protocol.ts +22 -0
  153. package/src/live-voice/share-targets.ts +225 -0
  154. package/src/mcp/__tests__/mcp-oauth-client-registration.test.ts +144 -3
  155. package/src/mcp/__tests__/mcp-oauth-provider.test.ts +2 -0
  156. package/src/mcp/client.ts +1 -19
  157. package/src/mcp/mcp-oauth-provider.ts +66 -49
  158. package/src/media/openrouter-image-service.ts +39 -22
  159. package/src/monitoring/__tests__/oom-inheritance-sweep.test.ts +86 -0
  160. package/src/monitoring/__tests__/oom-kill-reporter.test.ts +374 -0
  161. package/src/monitoring/control.ts +2 -1
  162. package/src/monitoring/oom-inheritance-sweep.ts +96 -0
  163. package/src/monitoring/oom-kill-reporter.ts +469 -0
  164. package/src/monitoring/resource-sampler.ts +30 -0
  165. package/src/notifications/README.md +53 -4
  166. package/src/notifications/__tests__/assistant-initiated-thread.test.ts +254 -0
  167. package/src/notifications/__tests__/broadcaster.test.ts +558 -6
  168. package/src/notifications/__tests__/completion-policy.test.ts +37 -0
  169. package/src/notifications/__tests__/from-me-alert-integration.test.ts +192 -0
  170. package/src/notifications/adapters/platform.ts +14 -0
  171. package/src/notifications/assistant-initiated-thread.ts +101 -0
  172. package/src/notifications/broadcaster.ts +94 -16
  173. package/src/notifications/chat-reply-policy.ts +11 -0
  174. package/src/notifications/completion-policy.ts +12 -2
  175. package/src/notifications/conversation-pairing.ts +15 -70
  176. package/src/notifications/emit-signal.ts +34 -3
  177. package/src/notifications/types.ts +2 -0
  178. package/src/oauth/connect-orchestrator.ts +1 -2
  179. package/src/persistence/__tests__/conversation-auto-archive.test.ts +330 -0
  180. package/src/persistence/conversation-auto-archive.ts +170 -0
  181. package/src/persistence/conversation-crud.ts +2 -1
  182. package/src/persistence/conversation-queries.ts +15 -8
  183. package/src/persistence/embeddings/embed.test.ts +96 -0
  184. package/src/persistence/embeddings/embed.ts +20 -2
  185. package/src/persistence/embeddings/embedding-gemini.test.ts +39 -3
  186. package/src/persistence/embeddings/embedding-gemini.ts +39 -1
  187. package/src/persistence/embeddings/embedding-local.ts +6 -0
  188. package/src/persistence/embeddings/qdrant-manager.ts +5 -0
  189. package/src/persistence/job-utils.ts +10 -0
  190. package/src/persistence/migrations/384-add-document-revision.test.ts +83 -0
  191. package/src/persistence/migrations/384-add-document-revision.ts +21 -0
  192. package/src/persistence/migrations/385-create-document-revisions.test.ts +67 -0
  193. package/src/persistence/migrations/385-create-document-revisions.ts +28 -0
  194. package/src/persistence/migrations/386-conversations-last-reopened-at.ts +10 -0
  195. package/src/persistence/migrations/__tests__/386-conversations-last-reopened-at.test.ts +32 -0
  196. package/src/persistence/schema/conversations.ts +1 -0
  197. package/src/persistence/schema/documents.ts +20 -0
  198. package/src/persistence/steps.ts +6 -0
  199. package/src/platform/managed-speech.ts +42 -46
  200. package/src/plugin-api/vision-support.test.ts +21 -21
  201. package/src/plugins/defaults/empty-response/hooks/post-model-call.ts +11 -6
  202. package/src/providers/__tests__/dispatch-connection-routing.test.ts +1 -1
  203. package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
  204. package/src/providers/__tests__/unparseable-tool-args.test.ts +25 -0
  205. package/src/providers/anthropic/client.ts +1 -1
  206. package/src/providers/fireworks/client.test.ts +168 -0
  207. package/src/providers/fireworks/client.ts +24 -0
  208. package/src/providers/inference/adapter-factory.ts +6 -0
  209. package/src/providers/ionet/client.test.ts +143 -0
  210. package/src/providers/ionet/client.ts +37 -0
  211. package/src/providers/model-catalog.ts +141 -84
  212. package/src/providers/model-intents.ts +4 -4
  213. package/src/providers/openai/chat-completions-provider.ts +33 -2
  214. package/src/providers/retry.ts +56 -4
  215. package/src/providers/search-provider-catalog.ts +10 -0
  216. package/src/providers/types.ts +10 -2
  217. package/src/providers/unparseable-tool-args.ts +29 -5
  218. package/src/providers/usage-tracking.ts +5 -3
  219. package/src/runtime/assistant-event-hub.ts +23 -0
  220. package/src/runtime/auth/__tests__/guard-tests.test.ts +0 -7
  221. package/src/runtime/auth/__tests__/route-policy.test.ts +1 -1
  222. package/src/runtime/channel-reply-delivery.ts +2 -0
  223. package/src/runtime/channel-retry-sweep.ts +1 -2
  224. package/src/runtime/http-types.ts +3 -0
  225. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +35 -10
  226. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +48 -0
  227. package/src/runtime/migrations/vbundle-builder.ts +19 -3
  228. package/src/runtime/migrations/vbundle-streaming-importer.ts +14 -19
  229. package/src/runtime/migrations/vbundle-streaming-validator.ts +14 -7
  230. package/src/runtime/migrations/vbundle-validator.ts +54 -1
  231. package/src/runtime/pending-interactions.ts +20 -2
  232. package/src/runtime/routes/__tests__/contact-routes.test.ts +5 -3
  233. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +36 -0
  234. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +375 -2
  235. package/src/runtime/routes/__tests__/inference-send-routes.test.ts +1 -1
  236. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +12 -0
  237. package/src/runtime/routes/contact-routes.ts +2 -2
  238. package/src/runtime/routes/conversation-list-routes.ts +10 -2
  239. package/src/runtime/routes/conversation-query-routes.ts +48 -10
  240. package/src/runtime/routes/conversation-routes.ts +7 -0
  241. package/src/runtime/routes/documents-routes.ts +185 -47
  242. package/src/runtime/routes/events-routes.ts +1 -3
  243. package/src/runtime/routes/host-app-control-routes.ts +1 -0
  244. package/src/runtime/routes/host-bash-routes.ts +1 -0
  245. package/src/runtime/routes/host-browser-routes.ts +15 -0
  246. package/src/runtime/routes/host-cu-routes.ts +4 -1
  247. package/src/runtime/routes/host-file-routes.ts +1 -0
  248. package/src/runtime/routes/host-proxy-result-binding.test.ts +83 -0
  249. package/src/runtime/routes/host-proxy-result-binding.ts +25 -0
  250. package/src/runtime/routes/host-transfer-routes.ts +1 -0
  251. package/src/runtime/routes/identity-routes.ts +3 -3
  252. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -0
  253. package/src/runtime/routes/integrations/a2a.ts +4 -1
  254. package/src/runtime/routes/migration-routes.ts +3 -1
  255. package/src/runtime/routes/notification-routes.ts +1 -2
  256. package/src/runtime/routes/ui-snapshot-routes.test.ts +1 -0
  257. package/src/runtime/routes/user-route-dispatcher.ts +5 -1
  258. package/src/runtime/verification-templates.ts +1 -2
  259. package/src/sequence/analytics.ts +0 -4
  260. package/src/sequence/engine.ts +1 -11
  261. package/src/sequence/guardrails.ts +1 -6
  262. package/src/sequence/importer.ts +0 -8
  263. package/src/sequence/store.ts +0 -10
  264. package/src/signals/user-message.ts +0 -6
  265. package/src/skills/catalog-install.ts +52 -23
  266. package/src/tools/browser/browser-execution.ts +20 -23
  267. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +1 -3
  268. package/src/tools/document/document-tool.ts +13 -11
  269. package/src/tools/executor.ts +1 -2
  270. package/src/tools/host-terminal/host-shell.ts +4 -2
  271. package/src/tools/network/__tests__/web-search.test.ts +105 -0
  272. package/src/tools/network/web-fetch.ts +2 -4
  273. package/src/tools/network/web-search.ts +239 -12
  274. package/src/tools/shared/filesystem/format-diff.ts +1 -2
  275. package/src/tools/shared/shell-output.test.ts +42 -0
  276. package/src/tools/shared/shell-output.ts +27 -4
  277. package/src/tools/skills/execute.ts +10 -7
  278. package/src/tools/terminal/shell.ts +3 -3
  279. package/src/tools/tool-approval-handler.ts +9 -1
  280. package/src/tools/ui-surface/visual-validation.ts +1 -2
  281. package/src/usage/attribution.ts +18 -1
  282. package/src/util/__tests__/oom-priority.test.ts +55 -0
  283. package/src/util/__tests__/worker-memory.test.ts +16 -0
  284. package/src/util/debounce.ts +0 -4
  285. package/src/util/oom-priority.ts +79 -0
  286. package/src/util/platform.ts +0 -2
  287. package/src/util/plugin-manifest.ts +5 -7
  288. package/src/util/pricing.ts +25 -0
  289. package/src/util/worker-memory.ts +9 -3
  290. package/src/util/worker-process.ts +8 -0
  291. package/src/workspace/byok-default-profile-ensure.ts +11 -0
  292. package/src/workspace/hatched-date.ts +11 -4
  293. package/src/workspace/migrations/160-repair-retired-fireworks-model-ids.ts +209 -0
  294. package/src/workspace/migrations/161-repair-retired-fireworks-glm-5p2-model-id.ts +197 -0
  295. package/src/workspace/migrations/registry.ts +4 -0
@@ -107,6 +107,7 @@ import { faviconUrlForDomain } from "../util/favicon.js";
107
107
  import { redactLogString } from "../util/log-redact.js";
108
108
  import { getLogger } from "../util/logger.js";
109
109
  import { withSqliteRetry } from "../util/sqlite-retry.js";
110
+ import { safeStringSlice } from "../util/unicode.js";
110
111
  import type { DirectiveRequest } from "./assistant-attachments.js";
111
112
  import {
112
113
  cleanAssistantContent,
@@ -640,6 +641,8 @@ export interface EventHandlerState {
640
641
  * rather than on every delta.
641
642
  */
642
643
  readonly surfacePendingScannedToolUseIds: Set<string>;
644
+ /** App previews whose cumulative JSON reached the client payload cap. */
645
+ readonly cappedAppPreviewToolUseIds: Set<string>;
643
646
  /**
644
647
  * In-flight priming of {@link liveRevealGuardEntries}. The dispatcher
645
648
  * awaits this before processing a `text_delta` (and before the
@@ -777,6 +780,7 @@ export function createEventHandlerState(): EventHandlerState {
777
780
  forChatMintWatermark: currentForChatMintWatermark(),
778
781
  stagedRevealIdentities: new Set(),
779
782
  surfacePendingScannedToolUseIds: new Set(),
783
+ cappedAppPreviewToolUseIds: new Set(),
780
784
  liveRevealGuardPriming: undefined,
781
785
  };
782
786
  }
@@ -1411,6 +1415,7 @@ function schedulePartialFlush(
1411
1415
  // tools the client discards it (extractCodePreview only handles app tools),
1412
1416
  // so we skip forwarding entirely to avoid transport/decode overhead.
1413
1417
  const APP_TOOL_NAMES = new Set(["app_create"]);
1418
+ const APP_INPUT_PREVIEW_CHAR_LIMIT = 256_000;
1414
1419
 
1415
1420
  // ── Surface Placeholder Detection ────────────────────────────────────
1416
1421
  // A `visual` ui_show streams the longest tool input the model produces, and
@@ -2111,13 +2116,24 @@ export function handleInputJsonDelta(
2111
2116
  // Only forward input deltas for app tools — the client only uses this
2112
2117
  // stream for app_create code previews. Non-app tools would send large
2113
2118
  // cumulative JSON on every delta with no benefit.
2114
- if (!APP_TOOL_NAMES.has(event.toolName)) {
2119
+ if (
2120
+ !APP_TOOL_NAMES.has(event.toolName) ||
2121
+ state.cappedAppPreviewToolUseIds.has(event.toolUseId)
2122
+ ) {
2115
2123
  return;
2116
2124
  }
2125
+ const content = safeStringSlice(
2126
+ event.accumulatedJson,
2127
+ 0,
2128
+ APP_INPUT_PREVIEW_CHAR_LIMIT,
2129
+ );
2130
+ if (content.length < event.accumulatedJson.length) {
2131
+ state.cappedAppPreviewToolUseIds.add(event.toolUseId);
2132
+ }
2117
2133
  deps.onEvent({
2118
2134
  type: "tool_input_delta",
2119
2135
  toolName: event.toolName,
2120
- content: event.accumulatedJson,
2136
+ content,
2121
2137
  conversationId: deps.ctx.conversationId,
2122
2138
  toolUseId: event.toolUseId,
2123
2139
  messageId: state.lastAssistantMessageId,
@@ -44,6 +44,7 @@ import { isSendUserMessageActiveForTurn } from "../config/send-user-message-gate
44
44
  import { desktopAutomationLease } from "../desktop/desktop-automation-lease.js";
45
45
  import { writeRelationshipState } from "../home/relationship-state-writer.js";
46
46
  import type { UserPromptSubmitInputContext } from "../hooks/types.js";
47
+ import type { SupportedLocale } from "../i18n/index.js";
47
48
  import {
48
49
  addMessage,
49
50
  deleteMessageById,
@@ -500,6 +501,7 @@ export async function runAgentLoopImpl(
500
501
  * loop defaults to `'mainAgent'` for user-initiated turns.
501
502
  */
502
503
  callSite?: LLMCallSite;
504
+ locale?: SupportedLocale;
503
505
  /** Skip fresh retrieval while keeping resident memory and static context. */
504
506
  skipMemoryRetrieval?: boolean;
505
507
  /**
@@ -1719,10 +1721,12 @@ export async function runAgentLoopImpl(
1719
1721
  onCheckpoint,
1720
1722
  callSite: turnCallSite,
1721
1723
  inferenceCallSite,
1724
+ locale: options?.locale,
1722
1725
  suppressAssistantText: sendUserMessageActive,
1723
1726
  supportsDynamicUi: conversationSupportsDynamicUi(ctx),
1724
1727
  trust: loopTrust,
1725
1728
  overrideProfile: turnOverrideProfile,
1729
+ ...(autoRoute ? { overrideProfileOrigin: "auto" as const } : {}),
1726
1730
  ...(forceOverrideProfile ? { forceOverrideProfile: true } : {}),
1727
1731
  resolveOverrideProfile: refreshCurrentProfileState,
1728
1732
  ...(onModelCallPrepared !== undefined ? { onModelCallPrepared } : {}),
@@ -2147,6 +2151,9 @@ export async function runAgentLoopImpl(
2147
2151
  // path to the terminal SSE that re-enables the composer.
2148
2152
  ctx.messages = restoredHistory;
2149
2153
 
2154
+ // The row's override is whichever profile served the last call.
2155
+ const mainRowOverrideProfile =
2156
+ state.exchangeInferenceProfile ?? refreshCurrentProfileState() ?? null;
2150
2157
  emitUsage(
2151
2158
  ctx,
2152
2159
  state.exchangeInputTokens,
@@ -2171,16 +2178,17 @@ export async function runAgentLoopImpl(
2171
2178
  // backup (via `state.exchangeProviderName` / `state.model`), so
2172
2179
  // attributing the profile from the primary would write a row that
2173
2180
  // contradicts itself. `forceOverrideProfile` floats it above the
2174
- // call-site profile exactly as the fallback dispatch did.
2181
+ // call-site profile exactly as the fallback dispatch did. The Auto
2182
+ // origin applies only while the row's override is still the router's
2183
+ // pick: a profile switched in mid-turn is the user's.
2175
2184
  {
2176
2185
  callSite: inferenceCallSite,
2177
- overrideProfile:
2178
- state.exchangeInferenceProfile ??
2179
- refreshCurrentProfileState() ??
2180
- null,
2186
+ overrideProfile: mainRowOverrideProfile,
2181
2187
  ...(state.exchangeInferenceProfile !== undefined
2182
2188
  ? { forceOverrideProfile: true }
2183
- : {}),
2189
+ : autoRoute && mainRowOverrideProfile === autoRoute.profile
2190
+ ? { overrideProfileOrigin: "auto" as const }
2191
+ : {}),
2184
2192
  },
2185
2193
  turnCronRunId,
2186
2194
  );
@@ -1,4 +1,5 @@
1
1
  import { getSubagentManager } from "../subagent/index.js";
2
+ import { getContainerMemoryLimitBytes } from "../util/cgroup-memory.js";
2
3
  import { getLogger } from "../util/logger.js";
3
4
  import { getConversationMap } from "./conversation-registry.js";
4
5
 
@@ -15,7 +16,7 @@ export interface EvictorOptions {
15
16
  ttlMs?: number;
16
17
  /** Max number of in-memory conversations before LRU eviction kicks in. Default: 100. */
17
18
  maxConversations?: number;
18
- /** RSS threshold (bytes) above which idle conversations are aggressively evicted. Default: 3 GB. */
19
+ /** RSS threshold (bytes) above which idle conversations are aggressively evicted. Default: {@link defaultMemoryThresholdBytes}. */
19
20
  memoryThresholdBytes?: number;
20
21
  /** Interval between periodic sweeps (ms). Default: 60 s. */
21
22
  sweepIntervalMs?: number;
@@ -34,13 +35,34 @@ export interface EvictionResult {
34
35
 
35
36
  const DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 minutes
36
37
  const DEFAULT_MAX_CONVERSATIONS = 100;
37
- const DEFAULT_MEMORY_THRESHOLD_BYTES = 3072 * 1024 * 1024; // 3 GB
38
+ /** Threshold when no container limit is known (local hosts). */
39
+ const FALLBACK_MEMORY_THRESHOLD_BYTES = 3072 * 1024 * 1024; // 3 GiB
40
+ /**
41
+ * Share of the container memory limit the daemon's own RSS may reach before
42
+ * idle conversations are evicted. The rest of the limit is Qdrant, the
43
+ * workers, and tool children, so the daemon cannot have it all.
44
+ */
45
+ const MEMORY_THRESHOLD_LIMIT_FRACTION = 0.5;
38
46
  const DEFAULT_SWEEP_INTERVAL_MS = 60 * 1000; // 60 seconds
47
+ /**
48
+ * Idle conversations evicted per sweep under memory pressure. RSS cannot fall
49
+ * until the disposed heap is collected after the sweep returns, so the sweep
50
+ * evicts a bounded batch and the next sweep re-measures.
51
+ */
52
+ const MEMORY_EVICTION_BATCH = 10;
53
+
54
+ /** Default memory-pressure threshold: half the container limit, or 3 GiB when there is none. */
55
+ export function defaultMemoryThresholdBytes(limitBytes: number | null): number {
56
+ return limitBytes != null
57
+ ? Math.floor(limitBytes * MEMORY_THRESHOLD_LIMIT_FRACTION)
58
+ : FALLBACK_MEMORY_THRESHOLD_BYTES;
59
+ }
39
60
 
40
61
  export class ConversationEvictor {
41
62
  private readonly ttlMs: number;
42
63
  private readonly maxConversations: number;
43
- private readonly memoryThresholdBytes: number;
64
+ /** Explicit threshold, or null to derive it from the container limit on first use. */
65
+ private memoryThresholdBytes: number | null;
44
66
  private readonly sweepIntervalMs: number;
45
67
 
46
68
  /** Tracks last access time per conversation ID. */
@@ -57,8 +79,7 @@ export class ConversationEvictor {
57
79
  this.ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS;
58
80
  this.maxConversations =
59
81
  options?.maxConversations ?? DEFAULT_MAX_CONVERSATIONS;
60
- this.memoryThresholdBytes =
61
- options?.memoryThresholdBytes ?? DEFAULT_MEMORY_THRESHOLD_BYTES;
82
+ this.memoryThresholdBytes = options?.memoryThresholdBytes ?? null;
62
83
  this.sweepIntervalMs =
63
84
  options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
64
85
  }
@@ -155,25 +176,26 @@ export class ConversationEvictor {
155
176
  }
156
177
  }
157
178
 
158
- // Phase 3: Memory pressure — if RSS exceeds threshold, evict idle conversations
159
- // starting from least-recently-used until we're under the threshold or
160
- // no more idle conversations remain.
179
+ // Phase 3: Memory pressure — if RSS exceeds threshold, evict a batch of
180
+ // idle conversations starting from least-recently-used.
161
181
  const rss = process.memoryUsage.rss();
162
- if (rss > this.memoryThresholdBytes) {
163
- const sorted = this.idleConversationsByLru();
164
- if (sorted.length > 0) {
182
+ const thresholdBytes = this.resolveMemoryThresholdBytes();
183
+ if (rss > thresholdBytes) {
184
+ const batch = this.idleConversationsByLru().slice(
185
+ 0,
186
+ MEMORY_EVICTION_BATCH,
187
+ );
188
+ if (batch.length > 0) {
165
189
  log.warn(
166
190
  {
167
191
  rssBytes: rss,
168
- thresholdBytes: this.memoryThresholdBytes,
192
+ thresholdBytes,
169
193
  conversationCount: this.conversations.size,
194
+ evicting: batch.length,
170
195
  },
171
196
  "Memory pressure detected, evicting idle conversations",
172
197
  );
173
- for (const [id, conversation] of sorted) {
174
- if (process.memoryUsage.rss() <= this.memoryThresholdBytes) {
175
- break;
176
- }
198
+ for (const [id, conversation] of batch) {
177
199
  this.evict(id, conversation);
178
200
  result.memoryEvicted++;
179
201
  }
@@ -198,6 +220,18 @@ export class ConversationEvictor {
198
220
 
199
221
  // ── Internals ──────────────────────────────────────────────────────
200
222
 
223
+ /**
224
+ * Resolved on first sweep rather than at construction: the module-level
225
+ * singleton is built at import, before the daemon has loaded its dotenv
226
+ * file, and VELLUM_MEMORY_LIMIT may come from there.
227
+ */
228
+ private resolveMemoryThresholdBytes(): number {
229
+ this.memoryThresholdBytes ??= defaultMemoryThresholdBytes(
230
+ getContainerMemoryLimitBytes(),
231
+ );
232
+ return this.memoryThresholdBytes;
233
+ }
234
+
201
235
  private evict(id: string, conversation: EvictableConversation): void {
202
236
  conversation.dispose();
203
237
  this.conversations.delete(id);
@@ -26,6 +26,7 @@ import {
26
26
  parseInterfaceId,
27
27
  } from "../channels/types.js";
28
28
  import { parseImageDimensions } from "../context/image-dimensions.js";
29
+ import type { SupportedLocale } from "../i18n/index.js";
29
30
  import {
30
31
  type ProviderMessageMetadata,
31
32
  providerMessageMetadataSchema,
@@ -824,6 +825,7 @@ export interface EnqueueMessageOptions {
824
825
  currentPage?: string;
825
826
  metadata?: Record<string, unknown>;
826
827
  isInteractive?: boolean;
828
+ locale?: SupportedLocale;
827
829
  displayContent?: string;
828
830
  transport?: ConversationTransportMetadata;
829
831
  clientMessageId?: string;
@@ -873,6 +875,7 @@ export function enqueueMessage(
873
875
  currentPage,
874
876
  metadata,
875
877
  isInteractive,
878
+ locale,
876
879
  displayContent,
877
880
  transport,
878
881
  clientMessageId,
@@ -912,6 +915,7 @@ export function enqueueMessage(
912
915
  turnChannelContext,
913
916
  turnInterfaceContext,
914
917
  isInteractive,
918
+ locale,
915
919
  sourceActorPrincipalId,
916
920
  authContext: queuedAuthContext,
917
921
  trustContext: queuedTrustContext,
@@ -20,6 +20,7 @@ import {
20
20
  type TurnInterfaceContext,
21
21
  } from "../channels/types.js";
22
22
  import type { LLMCallSite } from "../config/schemas/llm.js";
23
+ import type { SupportedLocale } from "../i18n/index.js";
23
24
  import { extractPreferences } from "../notifications/preference-extractor.js";
24
25
  import { createPreference } from "../notifications/preferences-store.js";
25
26
  import {
@@ -1278,6 +1279,7 @@ async function drainSingleMessage(
1278
1279
  titleText?: string;
1279
1280
  isHiddenPrompt?: boolean;
1280
1281
  turnTrustContext?: TrustContext;
1282
+ locale?: SupportedLocale;
1281
1283
  cronRunId?: string | null;
1282
1284
  } = {
1283
1285
  isUserMessage: true,
@@ -1289,6 +1291,9 @@ async function drainSingleMessage(
1289
1291
  if (next.isInteractive !== undefined) {
1290
1292
  drainLoopOptions.isInteractive = next.isInteractive;
1291
1293
  }
1294
+ if (next.locale !== undefined) {
1295
+ drainLoopOptions.locale = next.locale;
1296
+ }
1292
1297
  if (agentLoopContent !== resolvedContent) {
1293
1298
  drainLoopOptions.titleText = resolvedContent;
1294
1299
  }
@@ -1797,6 +1802,7 @@ async function drainBatch(
1797
1802
  isHiddenPrompt?: boolean;
1798
1803
  notifyUserMessageId?: string;
1799
1804
  turnTrustContext?: TrustContext;
1805
+ locale?: SupportedLocale;
1800
1806
  cronRunId?: string | null;
1801
1807
  } = {
1802
1808
  isUserMessage: true,
@@ -1816,6 +1822,9 @@ async function drainBatch(
1816
1822
  if (lastSuccessfulBatchEntry?.isInteractive !== undefined) {
1817
1823
  drainLoopOptions.isInteractive = lastSuccessfulBatchEntry.isInteractive;
1818
1824
  }
1825
+ if (lastSuccessfulBatchEntry?.locale !== undefined) {
1826
+ drainLoopOptions.locale = lastSuccessfulBatchEntry.locale;
1827
+ }
1819
1828
  // A batch counts as a hidden turn only when every message in it is a
1820
1829
  // hidden machine signal — one genuine user prompt justifies the
1821
1830
  // prompt-as-user-speech consumers (title generation).
@@ -1871,6 +1880,7 @@ export interface ProcessMessageOptions {
1871
1880
  currentPage?: string;
1872
1881
  isInteractive?: boolean;
1873
1882
  callSite?: LLMCallSite;
1883
+ locale?: SupportedLocale;
1874
1884
  /**
1875
1885
  * Optional ad-hoc inference-profile override applied to every LLM call
1876
1886
  * this turn issues (e.g. a schedule's pinned profile). Forwarded to
@@ -1928,6 +1938,7 @@ export async function processMessage(
1928
1938
  currentPage,
1929
1939
  isInteractive,
1930
1940
  callSite,
1941
+ locale,
1931
1942
  overrideProfile,
1932
1943
  displayContent,
1933
1944
  sourceActorPrincipalId,
@@ -2451,6 +2462,7 @@ export async function processMessage(
2451
2462
  isUserMessage?: boolean;
2452
2463
  titleText?: string;
2453
2464
  callSite?: LLMCallSite;
2465
+ locale?: SupportedLocale;
2454
2466
  overrideProfile?: string;
2455
2467
  turnTrustContext?: TrustContext;
2456
2468
  } = {
@@ -2471,6 +2483,9 @@ export async function processMessage(
2471
2483
  if (callSite !== undefined) {
2472
2484
  loopOptions.callSite = callSite;
2473
2485
  }
2486
+ if (locale !== undefined) {
2487
+ loopOptions.locale = locale;
2488
+ }
2474
2489
  if (overrideProfile !== undefined) {
2475
2490
  loopOptions.overrideProfile = overrideProfile;
2476
2491
  }
@@ -10,6 +10,7 @@ import type {
10
10
  TurnChannelContext,
11
11
  TurnInterfaceContext,
12
12
  } from "../channels/types.js";
13
+ import type { SupportedLocale } from "../i18n/index.js";
13
14
  import type { AuthContext } from "../runtime/auth/types.js";
14
15
  import { getLogger } from "../util/logger.js";
15
16
  import type { UserMessageAttachment } from "./message-protocol.js";
@@ -30,6 +31,7 @@ export interface QueuedMessage {
30
31
  turnInterfaceContext?: TurnInterfaceContext;
31
32
  /** When false, the turn has no interactive user and should skip clarification prompts. */
32
33
  isInteractive?: boolean;
34
+ locale?: SupportedLocale;
33
35
  /** Requester identity captured from the verified auth context at enqueue time. */
34
36
  sourceActorPrincipalId?: string;
35
37
  /** Full auth snapshot captured at enqueue time for turn-scoped authorization decisions. */
@@ -44,6 +44,7 @@ import {
44
44
  derefToolResultReReads,
45
45
  postTurnTruncateToolResults,
46
46
  } from "../context/post-turn-tool-result-truncation.js";
47
+ import type { SupportedLocale } from "../i18n/index.js";
47
48
  import { readProviderMetadata } from "../messaging/read-provider-metadata.js";
48
49
  import { isGuardianCardRow } from "../notifications/approval-card-data.js";
49
50
  import { PermissionPrompter } from "../permissions/prompter.js";
@@ -3528,6 +3529,7 @@ export class Conversation {
3528
3529
  */
3529
3530
  replyDeliveredInAppOnly?: boolean;
3530
3531
  callSite?: LLMCallSite;
3532
+ locale?: SupportedLocale;
3531
3533
  /** Skip fresh retrieval while keeping resident memory and static context. */
3532
3534
  skipMemoryRetrieval?: boolean;
3533
3535
  /** Provider configuration source when distinct from turn semantics. */
@@ -78,7 +78,7 @@ function updateDoordashSteps(
78
78
  return undefined;
79
79
  }
80
80
 
81
- const updated = steps.map((s, i) => {
81
+ return steps.map((s, i) => {
82
82
  if (i < stepIndex) {
83
83
  // Steps before current should be completed
84
84
  return s.status === "completed" ? s : { ...s, status: "completed" };
@@ -96,8 +96,6 @@ function updateDoordashSteps(
96
96
  }
97
97
  return s;
98
98
  });
99
-
100
- return updated;
101
99
  }
102
100
 
103
101
  // ── Helpers for reading/writing the task_progress surface ─────────────
@@ -21,7 +21,11 @@ import {
21
21
  } from "../../../contacts/contact-store.js";
22
22
  import { getSqlite } from "../../../persistence/db-connection.js";
23
23
  import { initializeDb } from "../../../persistence/db-init.js";
24
- import { acceptA2AInvite, createA2AInvite } from "../config-a2a.js";
24
+ import {
25
+ acceptA2AInvite,
26
+ assertSafeSenderGatewayUrl,
27
+ createA2AInvite,
28
+ } from "../config-a2a.js";
25
29
 
26
30
  await initializeDb();
27
31
 
@@ -487,4 +491,42 @@ describe("acceptA2AInvite", () => {
487
491
  const contacts = searchContacts({ channelType: "a2a" });
488
492
  expect(contacts).toHaveLength(0);
489
493
  });
494
+
495
+ test("rejects http, credentials, and loopback or metadata hosts", async () => {
496
+ const originalFetch = globalThis.fetch;
497
+ let fetchCalls = 0;
498
+ globalThis.fetch = (async () => {
499
+ fetchCalls += 1;
500
+ throw new Error("fetch should not run");
501
+ }) as unknown as typeof fetch;
502
+
503
+ try {
504
+ for (const url of [
505
+ "http://sender.example.com",
506
+ "https://user:pass@sender.example.com",
507
+ "https://127.0.0.1",
508
+ "https://localhost",
509
+ "https://169.254.169.254",
510
+ "https://metadata.google.internal",
511
+ ]) {
512
+ const result = await acceptA2AInvite({
513
+ senderGatewayUrl: url,
514
+ senderAssistantId: SENDER_ASSISTANT_ID,
515
+ token: "any-token",
516
+ });
517
+ expect(result.success).toBe(false);
518
+ expect(result.errorCode).toBe("invalid_sender_url");
519
+ }
520
+ expect(fetchCalls).toBe(0);
521
+ } finally {
522
+ globalThis.fetch = originalFetch;
523
+ }
524
+ });
525
+
526
+ test("allows https LAN peers used by self-hosted accept", () => {
527
+ expect(assertSafeSenderGatewayUrl("https://192.168.1.20:8787").ok).toBe(
528
+ true,
529
+ );
530
+ expect(assertSafeSenderGatewayUrl("https://peer.ts.net").ok).toBe(true);
531
+ });
490
532
  });
@@ -68,7 +68,7 @@ mock.module("../../../ipc/gateway-client.js", () => ({
68
68
  }
69
69
  if (method === "contacts_get_rich") {
70
70
  if (mockGwContactChannels == null) {
71
- return { ok: true, contact: null };
71
+ return null;
72
72
  }
73
73
  return {
74
74
  ok: true,
@@ -303,6 +303,54 @@ export function redeemA2AInvite(params: {
303
303
  // ── Self-hosted broker ──────────────────────────────────────────────
304
304
 
305
305
  const ACCEPT_TIMEOUT_MS = 15_000;
306
+ const ACCEPT_MAX_BODY_BYTES = 64_000;
307
+
308
+ const BLOCKED_SENDER_HOSTS = new Set([
309
+ "localhost",
310
+ "localhost.localdomain",
311
+ "metadata.google.internal",
312
+ ]);
313
+
314
+ /**
315
+ * Self-hosted accept brokers an outbound POST to the peer's invite/complete
316
+ * URL. Require https, reject credentials, and refuse loopback/metadata
317
+ * literals so a settings.write caller cannot steer the daemon at link-local
318
+ * or cloud-metadata endpoints. RFC1918 and Tailscale hostnames stay allowed
319
+ * because that is how self-hosted peers reach each other on a LAN.
320
+ */
321
+ export function assertSafeSenderGatewayUrl(
322
+ raw: string,
323
+ ): { ok: true; origin: string } | { ok: false; error: string } {
324
+ let parsed: URL;
325
+ try {
326
+ parsed = new URL(raw);
327
+ } catch {
328
+ return { ok: false, error: "senderGatewayUrl must be a valid URL" };
329
+ }
330
+ if (parsed.protocol !== "https:") {
331
+ return { ok: false, error: "senderGatewayUrl must use https" };
332
+ }
333
+ if (parsed.username || parsed.password) {
334
+ return {
335
+ ok: false,
336
+ error: "senderGatewayUrl must not include credentials",
337
+ };
338
+ }
339
+ const host = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase();
340
+ if (!host) {
341
+ return { ok: false, error: "senderGatewayUrl must include a host" };
342
+ }
343
+ if (BLOCKED_SENDER_HOSTS.has(host) || host.endsWith(".localhost")) {
344
+ return { ok: false, error: "senderGatewayUrl host is not allowed" };
345
+ }
346
+ if (host === "127.0.0.1" || host === "::1" || host === "0.0.0.0") {
347
+ return { ok: false, error: "senderGatewayUrl host is not allowed" };
348
+ }
349
+ if (host.startsWith("169.254.") || host === "169.254.169.254") {
350
+ return { ok: false, error: "senderGatewayUrl host is not allowed" };
351
+ }
352
+ return { ok: true, origin: parsed.origin };
353
+ }
306
354
 
307
355
  /**
308
356
  * Extract a human-readable error message from a daemon HTTP error
@@ -339,7 +387,15 @@ export async function acceptA2AInvite(params: {
339
387
  senderAssistantId: string;
340
388
  token: string;
341
389
  }): Promise<AcceptA2AInviteResult> {
342
- const senderGatewayUrl = params.senderGatewayUrl.replace(/\/+$/, "");
390
+ const safety = assertSafeSenderGatewayUrl(params.senderGatewayUrl);
391
+ if (!safety.ok) {
392
+ return {
393
+ success: false,
394
+ error: safety.error,
395
+ errorCode: "invalid_sender_url",
396
+ };
397
+ }
398
+ const senderGatewayUrl = safety.origin;
343
399
 
344
400
  // 1. Validate local config
345
401
  const displayName = getAssistantName() ?? "Vellum Assistant";
@@ -380,10 +436,27 @@ export async function acceptA2AInvite(params: {
380
436
  method: "POST",
381
437
  headers: { "Content-Type": "application/json" },
382
438
  body: JSON.stringify(completeBody),
439
+ redirect: "error",
383
440
  signal: AbortSignal.timeout(ACCEPT_TIMEOUT_MS),
384
441
  });
385
442
 
386
- completeData = (await response.json()) as Record<string, unknown>;
443
+ const rawBody = await response.text();
444
+ if (new TextEncoder().encode(rawBody).byteLength > ACCEPT_MAX_BODY_BYTES) {
445
+ return {
446
+ success: false,
447
+ error: "Sender invite/complete response exceeded size limit",
448
+ errorCode: "complete_failed",
449
+ };
450
+ }
451
+ try {
452
+ completeData = JSON.parse(rawBody) as Record<string, unknown>;
453
+ } catch {
454
+ return {
455
+ success: false,
456
+ error: "Sender invite/complete returned invalid JSON",
457
+ errorCode: "complete_failed",
458
+ };
459
+ }
387
460
 
388
461
  if (!response.ok) {
389
462
  const error =
@@ -19,6 +19,10 @@ import type {
19
19
  import { ConfirmationDecisionSchema } from "../../api/responses/conversation-message.js";
20
20
  import { getConfig } from "../../config/loader.js";
21
21
  import type { LLMCallSite, Speed } from "../../config/schemas/llm.js";
22
+ import {
23
+ resolveStoredMessageText,
24
+ type SupportedLocale,
25
+ } from "../../i18n/index.js";
22
26
  import { ipcCall as gatewayIpcCall } from "../../ipc/gateway-client.js";
23
27
  import type { ProviderMessageMetadata } from "../../messaging/provider-message-metadata.js";
24
28
  import type { SecretPromptResult } from "../../permissions/secret-prompt-types.js";
@@ -409,6 +413,7 @@ export function renderHistoryContent(
409
413
  >,
410
414
  messageId?: string,
411
415
  metadata?: unknown,
416
+ locale?: SupportedLocale,
412
417
  ): RenderedHistoryContent {
413
418
  // A row whose turn routed its reply through `send_user_message` carries
414
419
  // private working notes, so every consumer of this render (web history,
@@ -575,8 +580,14 @@ export function renderHistoryContent(
575
580
  const rawText =
576
581
  typeof block._redactionVersion === "number" &&
577
582
  block._redactionVersion >= SENTINEL_REDACTION_VERSION
578
- ? block.text
579
- : neutralizeRedactedSentinels(block.text);
583
+ ? locale
584
+ ? resolveStoredMessageText(block.text, locale)
585
+ : block.text
586
+ : neutralizeRedactedSentinels(
587
+ locale
588
+ ? resolveStoredMessageText(block.text, locale)
589
+ : block.text,
590
+ );
580
591
  const displayText = unwrapExternalContentForDisplay(rawText);
581
592
  // Skip empty/whitespace-only text blocks. During streaming the client
582
593
  // discards empty text deltas (guard !text.isEmpty), so including them
@@ -28,6 +28,7 @@ import { POINT_AT_PROXY_TOOL } from "../tools/computer-use/skill-proxy-bridge.js
28
28
  import type { ToolExecutionResult } from "../tools/types.js";
29
29
  import { AssistantError, ErrorCode } from "../util/errors.js";
30
30
  import { getLogger } from "../util/logger.js";
31
+ import { prepareComputerUseObservation } from "./computer-use-observation.js";
31
32
  import { resolveHostCuTarget } from "./host-cu-target.js";
32
33
  import { bestEffortModeSessionTracking } from "./mode-session-tracking.js";
33
34
 
@@ -615,7 +616,7 @@ export class HostCuProxy {
615
616
  this.recordAction(toolName, input, reasoning);
616
617
  this._previousAXTree = undefined;
617
618
  this._consecutiveUnchangedSteps = 0;
618
- const observation = await execute();
619
+ const observation = await prepareComputerUseObservation(await execute());
619
620
  return this.formatObservation(observation, undefined, false, {
620
621
  toolName,
621
622
  input,
@@ -791,7 +792,7 @@ export class HostCuProxy {
791
792
  );
792
793
  }
793
794
 
794
- const screenshotMeta = this.formatScreenshotMetadata(obs);
795
+ const screenshotMeta = this.formatScreenshotMetadata(obs, action);
795
796
  if (screenshotMeta.length > 0) {
796
797
  parts.push("");
797
798
  parts.push(...screenshotMeta);
@@ -898,7 +899,10 @@ export class HostCuProxy {
898
899
  }
899
900
  }
900
901
 
901
- private formatScreenshotMetadata(obs: CuObservationResult): string[] {
902
+ private formatScreenshotMetadata(
903
+ obs: CuObservationResult,
904
+ action: ActionIdentity | undefined,
905
+ ): string[] {
902
906
  if (!obs.screenshot) {
903
907
  return [];
904
908
  }
@@ -914,6 +918,33 @@ export class HostCuProxy {
914
918
  `Screen metadata: ${obs.screenWidthPt}x${obs.screenHeightPt} pt`,
915
919
  );
916
920
  }
921
+ if (action && hasCaptureTarget(action.input)) {
922
+ lines.push(
923
+ "This screenshot is capture-relative. Full-screen dimensions do not map its pixels to action coordinates. Use accessibility element IDs, or request an unscoped desktop observation before using coordinates.",
924
+ );
925
+ } else if (
926
+ [
927
+ obs.screenshotWidthPx,
928
+ obs.screenshotHeightPx,
929
+ obs.screenWidthPt,
930
+ obs.screenHeightPt,
931
+ ].every(
932
+ (dimension) =>
933
+ typeof dimension === "number" &&
934
+ Number.isFinite(dimension) &&
935
+ dimension > 0,
936
+ )
937
+ ) {
938
+ const scaleX = obs.screenWidthPt! / obs.screenshotWidthPx!;
939
+ const scaleY = obs.screenHeightPt! / obs.screenshotHeightPx!;
940
+ lines.push(
941
+ `Action coordinates are screen points, not screenshot pixels. Convert this image's pixels: x = round(image_x * ${scaleX.toFixed(6)}), y = round(image_y * ${scaleY.toFixed(6)}). Prefer accessibility element IDs when available.`,
942
+ );
943
+ } else {
944
+ lines.push(
945
+ "Screenshot-to-screen coordinate mapping is unavailable. Use accessibility element IDs or obtain an observation with both screenshot and screen dimensions before using coordinates.",
946
+ );
947
+ }
917
948
  return lines;
918
949
  }
919
950
  }