@vellumai/assistant 0.8.8 → 0.8.9-dev.202606091853.fbaa2ae

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 (475) hide show
  1. package/ARCHITECTURE.md +6 -6
  2. package/bun.lock +2 -2
  3. package/docs/activation-funnel-telemetry.md +310 -0
  4. package/examples/plugins/echo/README.md +61 -60
  5. package/examples/plugins/echo/hooks/post-tool-use.ts +18 -0
  6. package/examples/plugins/echo/hooks/stop.ts +16 -0
  7. package/examples/plugins/echo/hooks/user-prompt-submit.ts +18 -0
  8. package/examples/plugins/echo/package.json +1 -2
  9. package/examples/plugins/echo/src/emit.ts +19 -0
  10. package/node_modules/@vellumai/skill-host-contracts/src/skill-host.ts +7 -6
  11. package/openapi.yaml +263 -121
  12. package/package.json +2 -2
  13. package/src/__tests__/activation-early-marking.test.ts +120 -0
  14. package/src/__tests__/agent-loop-callsite-precedence.test.ts +69 -14
  15. package/src/__tests__/agent-loop-exit-reason.test.ts +204 -144
  16. package/src/__tests__/agent-loop-mutable-latest-user-message.test.ts +50 -35
  17. package/src/__tests__/agent-loop-output-hooks.test.ts +357 -0
  18. package/src/__tests__/agent-loop-override-profile.test.ts +25 -6
  19. package/src/__tests__/agent-loop-provider-error-recording.test.ts +41 -21
  20. package/src/__tests__/agent-loop-thinking.test.ts +36 -20
  21. package/src/__tests__/agent-loop.test.ts +441 -96
  22. package/src/__tests__/agent-wake-disk-pressure-callsite.test.ts +14 -14
  23. package/src/__tests__/agent-wake-override-profile.test.ts +17 -21
  24. package/src/__tests__/anthropic-provider.test.ts +23 -8
  25. package/src/__tests__/app-builder-tool-scripts.test.ts +21 -0
  26. package/src/__tests__/app-control-flow.test.ts +1 -1
  27. package/src/__tests__/app-dir-path-guard.test.ts +1 -0
  28. package/src/__tests__/app-executors.test.ts +132 -0
  29. package/src/__tests__/approval-cascade.test.ts +6 -5
  30. package/src/__tests__/approval-routes-http.test.ts +4 -1
  31. package/src/__tests__/background-workers-disk-pressure.test.ts +1 -1
  32. package/src/__tests__/channel-approval-routes.test.ts +1 -1
  33. package/src/__tests__/channel-approvals.test.ts +1 -1
  34. package/src/__tests__/compaction-circuit.test.ts +258 -0
  35. package/src/__tests__/compaction-direct.test.ts +146 -0
  36. package/src/__tests__/compaction-events.test.ts +7 -7
  37. package/src/__tests__/compaction.benchmark.test.ts +1 -1
  38. package/src/__tests__/compactor-web-search-strip.test.ts +213 -0
  39. package/src/__tests__/context-overflow-reducer.test.ts +265 -125
  40. package/src/__tests__/context-window-manager-compact-retry.test.ts +121 -15
  41. package/src/__tests__/conversation-abort-tool-results.test.ts +7 -5
  42. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +7 -10
  43. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +78 -119
  44. package/src/__tests__/conversation-agent-loop-overflow.test.ts +158 -234
  45. package/src/__tests__/conversation-agent-loop.test.ts +342 -623
  46. package/src/__tests__/conversation-clean-command.test.ts +5 -2
  47. package/src/__tests__/conversation-confirmation-signals.test.ts +6 -5
  48. package/src/__tests__/conversation-crud-inference-profile.test.ts +7 -9
  49. package/src/__tests__/conversation-error.test.ts +15 -1
  50. package/src/__tests__/conversation-history-web-search.test.ts +6 -1
  51. package/src/__tests__/conversation-media-retry.test.ts +1 -1
  52. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +44 -4
  53. package/src/__tests__/conversation-process-callsite.test.ts +15 -15
  54. package/src/__tests__/conversation-provider-retry-repair.test.ts +66 -65
  55. package/src/__tests__/conversation-queue.test.ts +10 -10
  56. package/src/__tests__/conversation-runtime-assembly.test.ts +1059 -231
  57. package/src/__tests__/conversation-runtime-workspace.test.ts +115 -20
  58. package/src/__tests__/conversation-slash-queue.test.ts +7 -5
  59. package/src/__tests__/conversation-slash-unknown.test.ts +7 -5
  60. package/src/__tests__/conversation-speed-override.test.ts +11 -10
  61. package/src/__tests__/conversation-starter-routes.test.ts +14 -6
  62. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
  63. package/src/__tests__/conversation-surfaces-app-control.test.ts +48 -1
  64. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +73 -4
  65. package/src/__tests__/conversation-undo.test.ts +2 -2
  66. package/src/__tests__/conversation-workspace-cache-state.test.ts +24 -21
  67. package/src/__tests__/conversation-workspace-injection.test.ts +69 -7
  68. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +15 -12
  69. package/src/__tests__/conversations-import-system-filter.test.ts +101 -0
  70. package/src/__tests__/corrected-target.test.ts +93 -0
  71. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  72. package/src/__tests__/cu-unified-flow.test.ts +36 -0
  73. package/src/__tests__/db-acp-history.test.ts +101 -0
  74. package/src/__tests__/dynamic-page-surface.test.ts +31 -0
  75. package/src/__tests__/empty-response-hook.test.ts +1 -1
  76. package/src/__tests__/file-write-tool.test.ts +63 -0
  77. package/src/__tests__/gateway-only-guard.test.ts +17 -2
  78. package/src/__tests__/guardian-grant-minting.test.ts +1 -1
  79. package/src/__tests__/guardian-routing-invariants.test.ts +93 -4
  80. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +1 -1
  81. package/src/__tests__/heartbeat-disk-pressure.test.ts +1 -0
  82. package/src/__tests__/heartbeat-service.test.ts +1 -0
  83. package/src/__tests__/history-repair-hook.test.ts +5 -1
  84. package/src/__tests__/host-app-control-proxy.test.ts +45 -0
  85. package/src/__tests__/host-app-control-routes.test.ts +1 -1
  86. package/src/__tests__/host-cu-routes-targeted.test.ts +3 -3
  87. package/src/__tests__/inference-profile-reaper.test.ts +62 -0
  88. package/src/__tests__/inference-profile-session-handler.test.ts +86 -0
  89. package/src/__tests__/injector-background-turn.test.ts +13 -23
  90. package/src/__tests__/injector-chain.test.ts +268 -44
  91. package/src/__tests__/injector-disk-pressure.test.ts +210 -52
  92. package/src/__tests__/injector-document-comments.test.ts +97 -114
  93. package/src/__tests__/injector-pkb-v2-silenced.test.ts +2 -2
  94. package/src/__tests__/injector-v3-suppression.test.ts +4 -4
  95. package/src/__tests__/list-messages-client-message-id.test.ts +91 -0
  96. package/src/__tests__/list-messages-hidden-metadata.test.ts +38 -0
  97. package/src/__tests__/llm-resolver.test.ts +73 -0
  98. package/src/__tests__/memory-retrieval-hook.test.ts +156 -38
  99. package/src/__tests__/memory-v2-static-injector.test.ts +86 -8
  100. package/src/__tests__/notification-decision-strategy.test.ts +3 -3
  101. package/src/__tests__/parallel-tool.benchmark.test.ts +35 -8
  102. package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
  103. package/src/__tests__/persist-unsendable-image.test.ts +97 -1
  104. package/src/__tests__/plugin-api-shim.test.ts +6 -9
  105. package/src/__tests__/plugin-bootstrap.test.ts +91 -81
  106. package/src/__tests__/plugin-registry.test.ts +3 -49
  107. package/src/__tests__/plugin-skill-contribution.test.ts +15 -14
  108. package/src/__tests__/plugin-tool-contribution.test.ts +7 -4
  109. package/src/__tests__/plugin-types.test.ts +0 -70
  110. package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
  111. package/src/__tests__/pre-model-call-sanitize.test.ts +109 -0
  112. package/src/__tests__/published-app-updater.test.ts +138 -0
  113. package/src/__tests__/reaction-persistence.test.ts +1 -1
  114. package/src/__tests__/send-endpoint-busy.test.ts +4 -1
  115. package/src/__tests__/skill-feature-flags-integration.test.ts +31 -0
  116. package/src/__tests__/steer-tool-repair.test.ts +1 -1
  117. package/src/__tests__/subagent-call-site-routing.test.ts +1 -1
  118. package/src/__tests__/subagent-detail.test.ts +25 -7
  119. package/src/__tests__/subagent-fork-notifications.test.ts +1 -3
  120. package/src/__tests__/subagent-fork-spawn.test.ts +1 -1
  121. package/src/__tests__/subagent-manager-notify.test.ts +1 -3
  122. package/src/__tests__/subagent-notify-parent.test.ts +1 -3
  123. package/src/__tests__/subagent-spawn-tool-fork.test.ts +1 -1
  124. package/src/__tests__/title-generate-hook.test.ts +5 -1
  125. package/src/__tests__/tool-error-hook.test.ts +1 -1
  126. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -1
  127. package/src/__tests__/user-plugin-loader.test.ts +54 -286
  128. package/src/__tests__/web-fetch.test.ts +45 -0
  129. package/src/acp/__tests__/agent-process.test.ts +161 -0
  130. package/src/acp/__tests__/client-handler.test.ts +40 -0
  131. package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
  132. package/src/acp/__tests__/helpers/acp-history-db.ts +82 -0
  133. package/src/acp/__tests__/helpers/exec-file-stub.ts +106 -0
  134. package/src/acp/__tests__/prepare-agent-env.test.ts +97 -0
  135. package/src/acp/__tests__/session-manager-persistence.test.ts +95 -28
  136. package/src/acp/__tests__/session-manager-resume.test.ts +888 -0
  137. package/src/acp/agent-process.ts +61 -1
  138. package/src/acp/auto-install.test.ts +280 -0
  139. package/src/acp/auto-install.ts +232 -0
  140. package/src/acp/client-handler.ts +31 -0
  141. package/src/acp/prepare-agent-env.ts +80 -27
  142. package/src/acp/resolve-agent.test.ts +188 -28
  143. package/src/acp/resolve-agent.ts +119 -42
  144. package/src/acp/resume-hint.ts +23 -0
  145. package/src/acp/session-manager.ts +507 -73
  146. package/src/agent/compaction-circuit.ts +60 -102
  147. package/src/agent/loop.ts +403 -251
  148. package/src/api/responses/conversation-message.ts +22 -2
  149. package/src/api/responses/memory-v3-selection-log.ts +19 -10
  150. package/src/approvals/guardian-request-resolvers.ts +27 -1
  151. package/src/background-wake/next-wake.ts +1 -0
  152. package/src/cli/commands/__tests__/memory-v3.test.ts +191 -210
  153. package/src/cli/commands/channel-verification-sessions.ts +6 -6
  154. package/src/cli/commands/db/__tests__/repair.test.ts +3 -1
  155. package/src/cli/commands/memory-v3.ts +57 -199
  156. package/src/cli/commands/plugins.ts +43 -37
  157. package/src/cli/lib/__tests__/install-from-github.test.ts +790 -111
  158. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +196 -0
  159. package/src/cli/lib/__tests__/plugin-details.test.ts +381 -0
  160. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +270 -0
  161. package/src/cli/lib/__tests__/search-plugins.test.ts +233 -32
  162. package/src/cli/lib/install-from-github.ts +797 -60
  163. package/src/cli/lib/plugin-catalog-cache.ts +84 -0
  164. package/src/cli/lib/plugin-details.ts +416 -0
  165. package/src/cli/lib/plugin-marketplace.ts +215 -0
  166. package/src/cli/lib/search-plugins.ts +201 -29
  167. package/src/config/__tests__/feature-flag-registry-guard.test.ts +2 -2
  168. package/src/config/acp-defaults.test.ts +10 -0
  169. package/src/config/acp-defaults.ts +9 -3
  170. package/src/config/acp-schema.ts +1 -7
  171. package/src/config/bundled-skills/acp/SKILL.md +80 -41
  172. package/src/config/bundled-skills/acp/TOOLS.json +4 -4
  173. package/src/config/bundled-skills/app-builder/SKILL.md +224 -381
  174. package/src/config/bundled-skills/app-builder/TOOLS.json +72 -2
  175. package/src/config/bundled-skills/app-builder/references/DESIGN_SYSTEM.md +48 -0
  176. package/src/config/bundled-skills/app-builder/references/RESPONSIVE.md +57 -0
  177. package/src/config/bundled-skills/app-builder/references/SLIDES.md +38 -0
  178. package/src/config/bundled-skills/app-builder/tools/app-list.ts +62 -0
  179. package/src/config/bundled-skills/app-builder/tools/app-update.ts +18 -0
  180. package/src/config/bundled-skills/document-editor/SKILL.md +28 -23
  181. package/src/config/bundled-skills/document-editor/TOOLS.json +1 -1
  182. package/src/config/bundled-tool-registry.ts +4 -0
  183. package/src/config/call-site-defaults.ts +0 -3
  184. package/src/config/feature-flag-registry.json +10 -16
  185. package/src/config/llm-resolver.ts +39 -7
  186. package/src/config/schemas/__tests__/memory-v3.test.ts +25 -9
  187. package/src/config/schemas/call-site-catalog.ts +0 -21
  188. package/src/config/schemas/heartbeat.ts +9 -0
  189. package/src/config/schemas/llm.ts +17 -3
  190. package/src/config/schemas/memory-v3.ts +58 -8
  191. package/src/config/seed-inference-profiles.ts +18 -0
  192. package/src/context/compactor.ts +22 -4
  193. package/src/context/post-turn-tool-result-truncation.ts +39 -1
  194. package/src/context/strip-injections.ts +8 -2
  195. package/src/daemon/conversation-agent-loop-handlers.ts +18 -36
  196. package/src/daemon/conversation-agent-loop.ts +308 -1297
  197. package/src/daemon/conversation-error.ts +31 -4
  198. package/src/daemon/conversation-history.ts +1 -1
  199. package/src/daemon/conversation-lifecycle.ts +11 -255
  200. package/src/daemon/conversation-media-retry.ts +19 -6
  201. package/src/daemon/conversation-messaging.ts +17 -0
  202. package/src/daemon/conversation-process.ts +22 -141
  203. package/src/daemon/conversation-queue-manager.ts +8 -0
  204. package/src/daemon/conversation-registry.ts +159 -0
  205. package/src/daemon/conversation-runtime-assembly.ts +361 -393
  206. package/src/daemon/conversation-store.ts +9 -90
  207. package/src/daemon/conversation-surfaces.ts +165 -11
  208. package/src/daemon/conversation-workspace.ts +17 -0
  209. package/src/daemon/conversation.ts +444 -62
  210. package/src/daemon/disk-pressure-policy.ts +0 -1
  211. package/src/daemon/external-plugins-bootstrap.ts +36 -46
  212. package/src/daemon/handlers/config-channels.ts +11 -2
  213. package/src/daemon/handlers/conversations.ts +3 -1
  214. package/src/daemon/handlers/skills.ts +4 -1
  215. package/src/daemon/host-app-control-proxy.ts +72 -57
  216. package/src/daemon/host-proxy-preactivation.ts +1 -3
  217. package/src/daemon/lifecycle.ts +21 -7
  218. package/src/daemon/persist-unsendable-image.ts +62 -25
  219. package/src/daemon/process-message.ts +1 -1
  220. package/src/daemon/server.ts +2 -0
  221. package/src/daemon/tool-side-effects.ts +15 -0
  222. package/src/daemon/wake-conversation-ops.ts +269 -0
  223. package/src/embedded/plugin-api.ts +2 -2
  224. package/src/export/__tests__/transcript-formatter.test.ts +5 -0
  225. package/src/heartbeat/__tests__/heartbeat-service.test.ts +3 -0
  226. package/src/heartbeat/heartbeat-run-store.ts +23 -1
  227. package/src/heartbeat/heartbeat-service.ts +26 -0
  228. package/src/ipc/__tests__/browser-ipc.test.ts +1 -1
  229. package/src/ipc/__tests__/ui-request-route.test.ts +3 -3
  230. package/src/ipc/skill-routes/__tests__/memory.test.ts +15 -0
  231. package/src/ipc/skill-routes/memory.ts +4 -2
  232. package/src/memory/__tests__/activation-session-store.test.ts +41 -0
  233. package/src/memory/__tests__/jobs-worker-v2-schedule.test.ts +87 -0
  234. package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
  235. package/src/memory/activation-session-store.ts +43 -0
  236. package/src/memory/conversation-crud.ts +29 -19
  237. package/src/memory/conversation-starter-checkpoints.ts +1 -0
  238. package/src/memory/db-init.ts +6 -0
  239. package/src/memory/job-handlers/conversation-starters.ts +13 -2
  240. package/src/memory/jobs/__tests__/embed-concept-page.test.ts +5 -4
  241. package/src/memory/jobs-worker.ts +25 -1
  242. package/src/memory/migrations/272-acp-session-history-cwd.ts +36 -0
  243. package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
  244. package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
  245. package/src/memory/migrations/index.ts +3 -0
  246. package/src/memory/onboarding-events-store.ts +66 -18
  247. package/src/memory/schema/acp.ts +4 -0
  248. package/src/memory/schema/infrastructure.ts +13 -0
  249. package/src/memory/v2/__tests__/consolidation-job.test.ts +4 -97
  250. package/src/memory/v2/__tests__/page-store.test.ts +22 -0
  251. package/src/memory/v2/consolidation-job.ts +2 -63
  252. package/src/memory/v2/types.ts +5 -0
  253. package/src/messaging/providers/telegram-bot/api.ts +14 -5
  254. package/src/notifications/__tests__/copy-composer.test.ts +244 -0
  255. package/src/notifications/access-request-copy.ts +298 -0
  256. package/src/notifications/adapters/slack.ts +3 -3
  257. package/src/notifications/adapters/telegram.ts +9 -2
  258. package/src/notifications/copy-composer.ts +49 -267
  259. package/src/notifications/decision-engine.ts +16 -35
  260. package/src/notifications/home-feed-side-effect.ts +1 -6
  261. package/src/plugin-api/constants.ts +4 -0
  262. package/src/plugin-api/index.ts +40 -11
  263. package/src/plugin-api/types.ts +108 -0
  264. package/src/plugins/defaults/compaction/compact.ts +106 -0
  265. package/src/{daemon → plugins/defaults/compaction}/context-overflow-reducer.ts +250 -42
  266. package/src/plugins/defaults/compaction/corrected-target.ts +53 -0
  267. package/src/plugins/defaults/compaction/manager-store.ts +57 -0
  268. package/src/plugins/defaults/compaction/package.json +1 -2
  269. package/src/{context → plugins/defaults/compaction}/window-manager.ts +95 -25
  270. package/src/plugins/defaults/empty-response/package.json +0 -1
  271. package/src/plugins/defaults/history-repair/package.json +0 -1
  272. package/src/plugins/defaults/index.ts +156 -26
  273. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +100 -52
  274. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit.ts +285 -0
  275. package/src/plugins/defaults/memory-retrieval/injector-chain.ts +2 -2
  276. package/src/plugins/defaults/{injectors/register.ts → memory-retrieval/injectors.ts} +148 -73
  277. package/src/plugins/defaults/memory-retrieval/package.json +14 -0
  278. package/src/plugins/defaults/memory-retrieval/unified-turn-context.ts +223 -0
  279. package/src/plugins/defaults/memory-v3-shadow/__tests__/capabilities.test.ts +71 -0
  280. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +181 -0
  281. package/src/plugins/defaults/memory-v3-shadow/__tests__/edge.test.ts +247 -0
  282. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/live-integration.test.ts +147 -134
  283. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +456 -0
  284. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +662 -0
  285. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +306 -0
  286. package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +91 -0
  287. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +402 -0
  288. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-needle.test.ts +135 -0
  289. package/src/plugins/defaults/memory-v3-shadow/__tests__/sections.test.ts +125 -0
  290. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/selection-log-store.test.ts +74 -19
  291. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +446 -0
  292. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +592 -0
  293. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/types.test.ts +2 -24
  294. package/src/plugins/defaults/memory-v3-shadow/capabilities.ts +91 -0
  295. package/src/plugins/defaults/memory-v3-shadow/dense.ts +97 -0
  296. package/src/plugins/defaults/memory-v3-shadow/edge.ts +252 -0
  297. package/src/plugins/defaults/memory-v3-shadow/hooks/post-compact.ts +14 -0
  298. package/src/plugins/defaults/memory-v3-shadow/hooks/user-prompt-submit.ts +19 -0
  299. package/src/plugins/defaults/memory-v3-shadow/injector.ts +76 -0
  300. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +555 -0
  301. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +266 -0
  302. package/src/plugins/defaults/memory-v3-shadow/package.json +14 -0
  303. package/src/plugins/defaults/memory-v3-shadow/page-content.ts +71 -0
  304. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +204 -0
  305. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/render-injection.ts +16 -8
  306. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +236 -0
  307. package/src/plugins/defaults/memory-v3-shadow/section-needle.ts +200 -0
  308. package/src/plugins/defaults/memory-v3-shadow/sections.ts +115 -0
  309. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +156 -0
  310. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +382 -0
  311. package/src/plugins/defaults/memory-v3-shadow/types.ts +92 -0
  312. package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/working-set.ts +4 -1
  313. package/src/plugins/defaults/title-generate/package.json +0 -1
  314. package/src/plugins/defaults/tool-error/package.json +0 -1
  315. package/src/plugins/defaults/tool-result-truncate/package.json +0 -1
  316. package/src/plugins/external-api.ts +12 -2
  317. package/src/plugins/pipeline.ts +6 -293
  318. package/src/plugins/registry.ts +9 -37
  319. package/src/plugins/types.ts +76 -381
  320. package/src/plugins/user-loader.ts +30 -127
  321. package/src/prompts/__tests__/system-prompt.test.ts +6 -0
  322. package/src/prompts/system-prompt.ts +61 -10
  323. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +71 -4
  324. package/src/providers/anthropic/client.ts +9 -10
  325. package/src/providers/inference/kimi-cjk-token-ids.ts +493 -0
  326. package/src/providers/inference/logit-bias.ts +55 -0
  327. package/src/providers/model-catalog.ts +34 -0
  328. package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
  329. package/src/providers/openai/chat-completions-provider.ts +44 -1
  330. package/src/providers/retry.ts +22 -0
  331. package/src/providers/types.ts +6 -0
  332. package/src/runtime/__tests__/agent-wake.test.ts +555 -691
  333. package/src/runtime/__tests__/interactive-ui.test.ts +1 -1
  334. package/src/runtime/agent-wake.ts +108 -209
  335. package/src/runtime/assistant-event-hub.ts +1 -1
  336. package/src/runtime/channel-approvals.ts +1 -1
  337. package/src/runtime/interactive-ui.ts +1 -1
  338. package/src/runtime/routes/__tests__/acp-routes.test.ts +466 -55
  339. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +1 -1
  340. package/src/runtime/routes/__tests__/plugins-routes.test.ts +423 -73
  341. package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
  342. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +5 -4
  343. package/src/runtime/routes/__tests__/surface-content-routes.test.ts +4 -1
  344. package/src/runtime/routes/acp-routes.test.ts +185 -32
  345. package/src/runtime/routes/acp-routes.ts +328 -30
  346. package/src/runtime/routes/app-management-routes.ts +3 -0
  347. package/src/runtime/routes/approval-routes.ts +1 -1
  348. package/src/runtime/routes/browser-routes.ts +1 -1
  349. package/src/runtime/routes/browser-tabs-routes.ts +6 -10
  350. package/src/runtime/routes/channel-verification-routes.ts +14 -5
  351. package/src/runtime/routes/conversation-cli-routes.ts +1 -1
  352. package/src/runtime/routes/conversation-list-routes.ts +1 -1
  353. package/src/runtime/routes/conversation-query-routes.ts +1 -1
  354. package/src/runtime/routes/conversation-routes.ts +30 -2
  355. package/src/runtime/routes/conversation-starter-routes.ts +13 -7
  356. package/src/runtime/routes/conversations-import-routes.ts +24 -7
  357. package/src/runtime/routes/host-app-control-routes.ts +1 -1
  358. package/src/runtime/routes/host-cu-routes.ts +1 -1
  359. package/src/runtime/routes/identity-routes.ts +18 -3
  360. package/src/runtime/routes/inbound-message-handler.ts +1 -1
  361. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +33 -34
  362. package/src/runtime/routes/inference-profile-session-handler.ts +11 -0
  363. package/src/runtime/routes/inference-profile-session-reaper.ts +6 -0
  364. package/src/runtime/routes/memory-v3-routes.ts +65 -305
  365. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
  366. package/src/runtime/routes/playground/helpers.ts +1 -1
  367. package/src/runtime/routes/plugins-routes.ts +337 -35
  368. package/src/runtime/routes/stt-routes.ts +45 -12
  369. package/src/runtime/routes/surface-conversation-resolver.ts +4 -3
  370. package/src/runtime/routes/work-items-routes.ts +2 -4
  371. package/src/runtime/routes/workspace-routes.ts +50 -15
  372. package/src/runtime/services/conversation-serializer.ts +1 -1
  373. package/src/runtime/verification-outbound-actions.ts +147 -2
  374. package/src/runtime/verification-templates.ts +29 -3
  375. package/src/services/published-app-updater.ts +30 -8
  376. package/src/signals/cancel.ts +2 -4
  377. package/src/subagent/manager.ts +21 -5
  378. package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
  379. package/src/telemetry/activation-funnel.ts +167 -0
  380. package/src/telemetry/types.ts +13 -0
  381. package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
  382. package/src/telemetry/usage-telemetry-reporter.ts +26 -1
  383. package/src/tools/acp/context.ts +20 -0
  384. package/src/tools/acp/list-agents.test.ts +9 -19
  385. package/src/tools/acp/list-agents.ts +3 -15
  386. package/src/tools/acp/spawn.test.ts +173 -202
  387. package/src/tools/acp/spawn.ts +37 -172
  388. package/src/tools/acp/steer.test.ts +105 -8
  389. package/src/tools/acp/steer.ts +48 -17
  390. package/src/tools/apps/executors.ts +166 -50
  391. package/src/tools/browser/browser-execution.ts +12 -2
  392. package/src/tools/filesystem/write.ts +34 -0
  393. package/src/tools/network/web-fetch.ts +65 -24
  394. package/src/tools/skills/load.ts +1 -1
  395. package/src/tools/subagent/spawn.ts +2 -4
  396. package/src/tools/ui-surface/definitions.ts +32 -5
  397. package/src/workspace/migrations/051-seed-conversation-summarization-callsite.ts +4 -5
  398. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +69 -45
  399. package/docs/plugins.md +0 -832
  400. package/examples/plugins/echo/register.ts +0 -143
  401. package/src/__tests__/circuit-breaker-pipeline.test.ts +0 -405
  402. package/src/__tests__/compaction-pipeline.test.ts +0 -210
  403. package/src/__tests__/compaction-timeout-recovery.test.ts +0 -251
  404. package/src/__tests__/overflow-reduce-pipeline.test.ts +0 -667
  405. package/src/__tests__/pipeline-runner.test.ts +0 -554
  406. package/src/daemon/wake-target-adapter.ts +0 -253
  407. package/src/memory/v3/__tests__/assign.test.ts +0 -242
  408. package/src/memory/v3/__tests__/capabilities.test.ts +0 -118
  409. package/src/memory/v3/__tests__/core.test.ts +0 -39
  410. package/src/memory/v3/__tests__/health.test.ts +0 -219
  411. package/src/memory/v3/__tests__/maintain-job.test.ts +0 -288
  412. package/src/memory/v3/__tests__/needle.test.ts +0 -107
  413. package/src/memory/v3/__tests__/orchestrate.test.ts +0 -436
  414. package/src/memory/v3/__tests__/provider-blocks.test.ts +0 -13
  415. package/src/memory/v3/__tests__/reconcile.test.ts +0 -274
  416. package/src/memory/v3/__tests__/render-injection.test.ts +0 -61
  417. package/src/memory/v3/__tests__/router.test.ts +0 -332
  418. package/src/memory/v3/__tests__/selector.test.ts +0 -470
  419. package/src/memory/v3/__tests__/shadow-plugin.test.ts +0 -432
  420. package/src/memory/v3/__tests__/snapshot.test.ts +0 -168
  421. package/src/memory/v3/__tests__/tree.test.ts +0 -192
  422. package/src/memory/v3/assign.ts +0 -268
  423. package/src/memory/v3/capabilities.ts +0 -124
  424. package/src/memory/v3/core.ts +0 -26
  425. package/src/memory/v3/health.ts +0 -0
  426. package/src/memory/v3/maintain-job.ts +0 -314
  427. package/src/memory/v3/needle.ts +0 -115
  428. package/src/memory/v3/orchestrate.ts +0 -126
  429. package/src/memory/v3/page-content.ts +0 -34
  430. package/src/memory/v3/provider-blocks.ts +0 -26
  431. package/src/memory/v3/reconcile.ts +0 -523
  432. package/src/memory/v3/router.ts +0 -190
  433. package/src/memory/v3/selection-log-store.ts +0 -84
  434. package/src/memory/v3/selector.ts +0 -226
  435. package/src/memory/v3/shadow-plugin.ts +0 -411
  436. package/src/memory/v3/snapshot.ts +0 -209
  437. package/src/memory/v3/tree.ts +0 -174
  438. package/src/memory/v3/types.ts +0 -59
  439. package/src/notifications/__tests__/emit-signal-home-feed.test.ts +0 -187
  440. package/src/plugins/defaults/circuit-breaker/middlewares/circuitBreaker.ts +0 -93
  441. package/src/plugins/defaults/circuit-breaker/package.json +0 -15
  442. package/src/plugins/defaults/circuit-breaker/register.ts +0 -39
  443. package/src/plugins/defaults/compaction/middlewares/compaction.ts +0 -25
  444. package/src/plugins/defaults/compaction/register.ts +0 -35
  445. package/src/plugins/defaults/compaction/terminal.ts +0 -73
  446. package/src/plugins/defaults/empty-response/register.ts +0 -23
  447. package/src/plugins/defaults/history-repair/register.ts +0 -24
  448. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +0 -216
  449. package/src/plugins/defaults/overflow-reduce/middlewares/overflowReduce.ts +0 -126
  450. package/src/plugins/defaults/overflow-reduce/package.json +0 -15
  451. package/src/plugins/defaults/overflow-reduce/register.ts +0 -42
  452. package/src/plugins/defaults/title-generate/register.ts +0 -35
  453. package/src/plugins/defaults/tool-error/register.ts +0 -23
  454. package/src/plugins/defaults/tool-result-truncate/register.ts +0 -24
  455. package/src/proactive-artifact/aux-message-injector.ts +0 -97
  456. package/src/proactive-artifact/decision.test.ts +0 -226
  457. package/src/proactive-artifact/decision.ts +0 -165
  458. package/src/proactive-artifact/index.ts +0 -7
  459. package/src/proactive-artifact/job.test.ts +0 -962
  460. package/src/proactive-artifact/job.ts +0 -372
  461. package/src/proactive-artifact/message-copy.ts +0 -58
  462. package/src/proactive-artifact/trigger-state.test.ts +0 -286
  463. package/src/proactive-artifact/trigger-state.ts +0 -123
  464. package/src/util/map-limit.ts +0 -27
  465. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/fixtures/eval-turns.json +0 -0
  466. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/fixtures/live-turns.json +0 -0
  467. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/working-set-eviction.test.ts +0 -0
  468. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/__tests__/working-set-skeleton.test.ts +0 -0
  469. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/README.md +0 -0
  470. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/assignments.json +0 -0
  471. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/core.json +0 -0
  472. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-a/topic-x.md +0 -0
  473. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-a/topic-y.md +0 -0
  474. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/data/leaves/domain-b/topic-z.md +0 -0
  475. /package/src/{memory/v3 → plugins/defaults/memory-v3-shadow}/llm-retry.ts +0 -0
@@ -4,18 +4,15 @@
4
4
  * This module contains the core agent loop orchestration: pre-flight
5
5
  * setup, event handling, retry logic, history reconstruction, and
6
6
  * completion event emission. The Conversation class delegates its
7
- * runAgentLoop method here via the AgentLoopConversationContext interface.
7
+ * runAgentLoop method here, passing itself as the loop context.
8
8
  */
9
9
 
10
10
  import { v4 as uuid } from "uuid";
11
11
 
12
- import { optimizeImageForTransport } from "../agent/image-optimize.js";
13
12
  import type {
14
13
  AgentEvent,
15
- AgentLoop,
16
14
  AgentLoopExitReason,
17
15
  CheckpointDecision,
18
- MidLoopCompaction,
19
16
  } from "../agent/loop.js";
20
17
  import { createAssistantMessage } from "../agent/message-types.js";
21
18
  import type {
@@ -24,7 +21,6 @@ import type {
24
21
  TurnChannelContext,
25
22
  TurnInterfaceContext,
26
23
  } from "../channels/types.js";
27
- import { isAssistantFeatureFlagEnabled } from "../config/assistant-feature-flags.js";
28
24
  import {
29
25
  contextWindowConfigFromEffective,
30
26
  type EffectiveContextWindow,
@@ -37,18 +33,14 @@ import {
37
33
  import { getConfig } from "../config/loader.js";
38
34
  import type { LLMCallSite } from "../config/schemas/llm.js";
39
35
  import type { ContextWindowConfig } from "../config/types.js";
40
- import { runEmergencyCompaction } from "../context/compactor.js";
41
36
  import {
42
37
  derefToolResultReReads,
43
38
  postTurnTruncateToolResults,
44
39
  } from "../context/post-turn-tool-result-truncation.js";
45
40
  import {
46
41
  estimatePromptTokens,
47
- estimatePromptTokensWithTools,
48
42
  getCalibrationProviderKey,
49
43
  } from "../context/token-estimator.js";
50
- import type { ContextWindowManager } from "../context/window-manager.js";
51
- import type { ToolProfiler } from "../events/tool-profiling-listener.js";
52
44
  import { writeRelationshipState } from "../home/relationship-state-writer.js";
53
45
  import {
54
46
  clearSentryConversationContext,
@@ -62,66 +54,48 @@ import {
62
54
  getConversation,
63
55
  getConversationOriginChannel,
64
56
  getConversationOriginInterface,
65
- getConversationOverrideProfileFromRow,
66
57
  getLastUserTimestampBefore,
67
58
  getMessageById,
68
59
  provenanceFromTrustContext,
60
+ resolveOverrideProfile,
69
61
  updateConversationContextWindow,
70
62
  updateConversationSlackContextWatermark,
71
- updateMessageMetadata,
72
63
  } from "../memory/conversation-crud.js";
73
64
  import { getResolvedConversationDirPath } from "../memory/conversation-directories.js";
74
65
  import { syncMessageToDisk } from "../memory/conversation-disk-view.js";
75
66
  import { isReplaceableTitle } from "../memory/conversation-title-service.js";
76
- import { isBackgroundConversationType } from "../memory/conversation-types.js";
77
67
  import type { ConversationGraphMemory } from "../memory/graph/conversation-graph-memory.js";
78
68
  import {
79
69
  backfillMessageIdOnLogs,
80
70
  recordSyntheticAgentErrorMessageLog,
81
71
  } from "../memory/llm-request-log-store.js";
82
72
  import { enqueueMemoryRetrospectiveOnCompaction } from "../memory/memory-retrospective-enqueue.js";
83
- import type { PermissionPrompter } from "../permissions/prompter.js";
84
73
  import { HOOKS } from "../plugin-api/constants.js";
85
74
  import type { UserPromptSubmitContext } from "../plugin-api/types.js";
86
- import { defaultCompactionTerminal } from "../plugins/defaults/compaction/terminal.js";
75
+ import {
76
+ createInitialReducerState,
77
+ reduceContextOverflow,
78
+ type ReducerState,
79
+ } from "../plugins/defaults/compaction/context-overflow-reducer.js";
80
+ import { computeCorrectedOverflowTarget } from "../plugins/defaults/compaction/corrected-target.js";
87
81
  import { deepRepairHistory } from "../plugins/defaults/history-repair/terminal.js";
88
- import postCompactReinject from "../plugins/defaults/memory-retrieval/hooks/post-compact.js";
89
- import userPromptSubmitMemoryRetrieval, {
90
- type MemoryRetrievalHookContext,
91
- } from "../plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.js";
92
- import { DEFAULT_TIMEOUTS, runHook, runPipeline } from "../plugins/pipeline.js";
93
- import { getMiddlewaresFor } from "../plugins/registry.js";
94
- import type {
95
- CompactionArgs,
96
- CompactionResult,
97
- OverflowReduceArgs,
98
- OverflowReduceResult,
99
- TurnContext as PluginTurnContext,
100
- } from "../plugins/types.js";
101
- import { PluginExecutionError, PluginTimeoutError } from "../plugins/types.js";
82
+ import { runHook } from "../plugins/pipeline.js";
102
83
  import type { ContentBlock, Message } from "../providers/types.js";
103
84
  import type { Provider } from "../providers/types.js";
104
- import { resolveActorTrust } from "../runtime/actor-trust-resolver.js";
85
+ import { isUntrustedTrustClass } from "../runtime/actor-trust-resolver.js";
105
86
  import { broadcastMessage } from "../runtime/assistant-event-hub.js";
106
87
  import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
107
88
  import { publishConversationMessagesChanged } from "../runtime/sync/resource-sync-events.js";
108
- import { getSubagentManager } from "../subagent/index.js";
89
+ import type { ActivationMomentParam } from "../telemetry/activation-funnel.js";
109
90
  import type { UsageActor } from "../usage/actors.js";
110
91
  import { getLogger } from "../util/logger.js";
111
92
  import { timeAgo } from "../util/time.js";
112
93
  import { truncate } from "../util/truncate.js";
113
94
  import { getWorkspaceGitService } from "../workspace/git-service.js";
114
95
  import { commitTurnChanges } from "../workspace/turn-commit.js";
115
- import {
116
- type AssistantAttachmentDraft,
117
- cleanAssistantContent,
118
- } from "./assistant-attachments.js";
96
+ import { cleanAssistantContent } from "./assistant-attachments.js";
119
97
  import { resolveOverflowAction } from "./context-overflow-policy.js";
120
- import {
121
- createInitialReducerState,
122
- reduceContextOverflow,
123
- type ReducerState,
124
- } from "./context-overflow-reducer.js";
98
+ import type { Conversation } from "./conversation.js";
125
99
  import {
126
100
  createEventHandlerState,
127
101
  dispatchAgentEvent,
@@ -140,35 +114,19 @@ import {
140
114
  isUserCancellation,
141
115
  } from "./conversation-error.js";
142
116
  import { raceWithTimeout } from "./conversation-media-retry.js";
143
- import type { MessageQueue } from "./conversation-queue-manager.js";
144
- import type { QueueDrainReason } from "./conversation-queue-manager.js";
145
- import type {
146
- ChannelCapabilities,
147
- InboundActorContext,
148
- InjectionMode,
149
- } from "./conversation-runtime-assembly.js";
117
+ import type { InjectionMode } from "./conversation-runtime-assembly.js";
150
118
  import {
151
119
  applyRuntimeInjections,
152
- buildActiveDocuments,
153
- buildActiveSurfaceContext,
154
- buildSubagentStatusBlock,
155
- buildUnifiedTurnContextBlock,
156
- buildWorkspaceTopLevelContext,
157
120
  getSlackCompactionWatermarkForPrefix,
158
- inboundActorContextFromTrust,
159
- inboundActorContextFromTrustContext,
160
- loadSlackActiveThreadFocusBlock,
161
121
  loadSlackChronologicalContext,
122
+ resolveTurnInboundActorContext,
123
+ resolveTurnModelProfileLabel,
162
124
  type SlackChronologicalContext,
163
125
  stripInjectionsForCompaction,
164
126
  } from "./conversation-runtime-assembly.js";
165
- import type { SkillProjectionCache } from "./conversation-skill-tools.js";
166
127
  import { markSurfaceCompleted } from "./conversation-surfaces.js";
167
128
  import { recordUsage } from "./conversation-usage.js";
168
- import {
169
- formatTurnTimestamp,
170
- resolveTurnTimezoneContext,
171
- } from "./date-context.js";
129
+ import { resolveTurnTimezoneContext } from "./date-context.js";
172
130
  import { getDiskPressureStatus } from "./disk-pressure-guard.js";
173
131
  import { classifyDiskPressureTurnPolicy } from "./disk-pressure-policy.js";
174
132
  import type {
@@ -177,15 +135,12 @@ import type {
177
135
  SurfaceType,
178
136
  UsageStats,
179
137
  } from "./message-protocol.js";
180
- import type { ConfirmationStateChanged } from "./message-types/messages.js";
181
138
  import { parseActualTokensFromError } from "./parse-actual-tokens-from-error.js";
182
139
  import {
140
+ oversizedImageReplacement,
183
141
  persistUnsendableImageDowngrades,
184
- UNSENDABLE_IMAGE_NOTE,
185
142
  } from "./persist-unsendable-image.js";
186
- import type { TraceEmitter } from "./trace-emitter.js";
187
143
  import { resolveTrustClass, type TrustContext } from "./trust-context.js";
188
- import { stripHistoricalWebSearchResults } from "./web-search-history.js";
189
144
 
190
145
  const log = getLogger("conversation-agent-loop");
191
146
 
@@ -206,23 +161,42 @@ const TOOL_FRIENDLY_LABEL: Record<string, string> = {
206
161
  skill_execute: "Run Skill Tool",
207
162
  };
208
163
 
209
- type GitServiceInitializer = {
210
- ensureInitialized(): Promise<void>;
211
- };
212
-
213
164
  function formatDiskPressureBlockedMessage(): string {
214
165
  return "Storage is critically low, so background processes are paused and remote messages are ignored until the guardian frees enough space. Remote senders should try again later.";
215
166
  }
216
167
 
168
+ // ── Image-recovery helpers ───────────────────────────────────────────
169
+
170
+ /**
171
+ * True when a message's content holds an image the provider may have rejected
172
+ * for being oversized — either a top-level image block (user upload) or one
173
+ * nested inside a tool_result's contentBlocks (e.g. a browser screenshot).
174
+ */
175
+ function messageHasImageBlock(content: ContentBlock[]): boolean {
176
+ return content.some(
177
+ (b) =>
178
+ b.type === "image" ||
179
+ (b.type === "tool_result" &&
180
+ (b.contentBlocks?.some((cb) => cb.type === "image") ?? false)),
181
+ );
182
+ }
183
+
184
+ /**
185
+ * Replace an oversized image with its downscaled form or an unsendable note,
186
+ * leaving still-sendable images untouched. Delegates to the shared
187
+ * {@link oversizedImageReplacement} so the in-memory recovery and the durable
188
+ * persist pass apply the identical provider-cap gate.
189
+ */
190
+ function recoverImageBlock(
191
+ block: Extract<ContentBlock, { type: "image" }>,
192
+ ): ContentBlock {
193
+ return oversizedImageReplacement(block) ?? block;
194
+ }
195
+
217
196
  // ── Plugin pipeline helpers ──────────────────────────────────────────
218
- //
219
- // Canonical {@link PluginTurnContext} builder threaded into every
220
- // `runPipeline` call inside `runAgentLoopImpl`. The orchestrator composes
221
- // the context on demand at each call site from ambient state rather than
222
- // carrying a persistent `TurnContext` instance across the turn.
223
197
 
224
198
  /**
225
- * Synthetic fallback trust context used when the orchestrator fires a pipeline
199
+ * Synthetic fallback trust context used when the orchestrator fires a hook
226
200
  * before the per-turn trust snapshot has been captured (e.g. invocations that
227
201
  * bypass `processMessage` / `drainQueue`). We bias to `unknown` rather than
228
202
  * `guardian` so a missing snapshot cannot accidentally grant elevated trust
@@ -233,65 +207,23 @@ const FALLBACK_TURN_TRUST: TrustContext = {
233
207
  trustClass: "unknown",
234
208
  };
235
209
 
236
- /**
237
- * Build the {@link TurnContext} passed to {@link runPipeline}.
238
- *
239
- * Canonical source of truth for every pipeline call site inside the agent
240
- * loop. Every `runPipeline` invocation in `runAgentLoopImpl` (and in the
241
- * handlers that share its ambient state) must route through this helper
242
- * rather than constructing a `TurnContext` literal inline — this keeps
243
- * `turnIndex`, trust resolution, and the `contextWindowManager` attachment
244
- * consistent across pipeline slots, which in turn keeps structured logs
245
- * filtered by `conversationId`/`turnIndex` coherent across slots.
246
- *
247
- * Behavior:
248
- * - `turnIndex` is always `ctx.turnCount` — the orchestrator-owned
249
- * 0-based turn counter. Reading from a single source avoids the
250
- * earlier inconsistency (`ctx.turnCount`, `ctx.messages.length - 1`,
251
- * `ctx.messages.length`, and `0` were all used for the same turn).
252
- * - Trust pulls from the per-turn snapshot first, then the conversation-
253
- * level context, then {@link FALLBACK_TURN_TRUST}. The cascade matches
254
- * the one inside the orchestrator's inline injection assembly so
255
- * middleware reads the same trust class the runtime sees.
256
- * - `contextWindowManager` is attached unconditionally. Pipelines that
257
- * don't need it can ignore it; the default compaction plugin reads it
258
- * via the typed optional field on `TurnContext`.
259
- */
260
- function buildPluginTurnContext(
261
- ctx: AgentLoopConversationContext,
262
- requestId: string,
263
- ): PluginTurnContext {
264
- const trust =
265
- ctx.currentTurnTrustContext ?? ctx.trustContext ?? FALLBACK_TURN_TRUST;
266
- return {
267
- requestId,
268
- conversationId: ctx.conversationId,
269
- turnIndex: ctx.turnCount,
270
- trust,
271
- contextWindowManager: ctx.contextWindowManager,
272
- callSite: ctx.currentCallSite,
273
- };
274
- }
275
-
276
210
  /**
277
211
  * Trust class of the actor whose turn is in progress, for the compactor's
278
212
  * image manifest filter. Prefers the turn-start snapshot
279
- * ({@link AgentLoopConversationContext.currentTurnTrustContext}) over the live
213
+ * ({@link Conversation.currentTurnTrustContext}) over the live
280
214
  * trust context so compaction running in a later tool iteration can't pick up
281
215
  * a concurrent request's actor.
282
216
  */
283
217
  function resolveTurnActorTrustClass(
284
- ctx: AgentLoopConversationContext,
218
+ ctx: Conversation,
285
219
  ): TrustContext["trustClass"] | undefined {
286
220
  return (ctx.currentTurnTrustContext ?? ctx.trustContext)?.trustClass;
287
221
  }
288
222
 
289
- // ── Context Interface ────────────────────────────────────────────────
290
-
291
223
  /**
292
224
  * Per-surface entry tracked on the current turn. Inline shape kept stable so
293
225
  * routes and persistence helpers can consume it via a named import instead of
294
- * `infer`-extracting from {@link AgentLoopConversationContext}.
226
+ * `infer`-extracting from {@link Conversation}.
295
227
  */
296
228
  export interface AssistantSurface {
297
229
  surfaceId: string;
@@ -308,182 +240,20 @@ export interface AssistantSurface {
308
240
  persistent?: boolean;
309
241
  /** Id of the tool call that produced this surface (the `ui_show` proxy tool). Persisted so app previews can gate on the tool result's arrival rather than whole-turn streaming state. */
310
242
  toolCallId?: string;
311
- }
312
-
313
- export interface AgentLoopConversationContext {
314
- readonly conversationId: string;
315
- messages: Message[];
316
- isProcessing(): boolean;
317
- setProcessing(value: boolean): void;
318
- abortController: AbortController | null;
319
- currentRequestId?: string;
320
- /**
321
- * The {@link LLMCallSite} of the in-flight turn, set at turn start from
322
- * `options?.callSite ?? "mainAgent"`. Read by {@link buildPluginTurnContext}
323
- * so pipeline/injector plugins can tell the main reply apart from
324
- * background agent-loop work (compaction, subagents, …) on this same
325
- * conversation. Per-turn mutable, mirroring {@link currentRequestId}.
326
- */
327
- currentCallSite?: LLMCallSite;
328
-
329
- readonly agentLoop: AgentLoop;
330
- readonly provider: Provider;
331
- readonly systemPrompt: string;
332
-
333
- readonly contextWindowManager: ContextWindowManager;
334
- contextCompactedMessageCount: number;
335
- contextCompactedAt: number | null;
336
- /**
337
- * Set by `applyCompactionResult` when compaction strips runtime injections
338
- * from the preserved tail. The next agent loop turn promotes this into a
339
- * `compactedThisTurn` signal so NOW.md, PKB, and the v2 static block are
340
- * re-injected on the first turn following `/compact` (which runs outside
341
- * the agent loop and so has no other way to surface that compaction
342
- * happened just before this turn).
343
- */
344
- pendingPostCompactReinject: boolean;
345
-
346
- readonly graphMemory: ConversationGraphMemory;
347
-
348
- currentActiveSurfaceId?: string;
349
- currentPage?: string;
350
- readonly surfaceState: Map<
351
- string,
352
- {
353
- surfaceType: SurfaceType;
354
- data: SurfaceData;
355
- title?: string;
356
- actions?: Array<{
357
- id: string;
358
- label: string;
359
- style?: string;
360
- data?: Record<string, unknown>;
361
- }>;
362
- }
363
- >;
364
- pendingSurfaceActions: Map<string, { surfaceType: SurfaceType }>;
365
- surfaceActionRequestIds: Set<string>;
366
- approvedViaPromptThisTurn?: boolean;
367
- currentTurnSurfaces: AssistantSurface[];
368
-
369
- workingDir: string;
370
- workspaceTopLevelContext: string | null;
371
- workspaceTopLevelDirty: boolean;
372
- channelCapabilities?: ChannelCapabilities;
373
- /** Per-turn snapshot of trustContext, frozen at message-processing start. */
374
- currentTurnTrustContext?: TrustContext;
375
- /** Per-turn snapshot of channelCapabilities, frozen at message-processing start. */
376
- currentTurnChannelCapabilities?: ChannelCapabilities;
377
- /**
378
- * Current inference-profile override for this turn. Read by
379
- * `createToolExecutor` so `ToolContext.overrideProfile` carries the same
380
- * profile the agent loop is sending to the provider. Refreshed between
381
- * model calls so an explicitly confirmed profile session opened mid-turn
382
- * is inherited by later tool executions and nested subagents.
383
- */
384
- currentTurnOverrideProfile?: string;
385
243
  /**
386
- * Set by the `switch_inference_profile` tool when the model self-selects a
387
- * different profile mid-turn. Read by `readCurrentOverrideProfile` in the
388
- * agent loop so the next LLM call uses the switched profile. Reset at
389
- * turn start.
244
+ * Commit-timing activation-rail tag (daemon-only). Persisted into the
245
+ * server-side `ui_surface` history block — NOT the client `ui_surface_show`
246
+ * message — so `restoreSurfaceStateFromHistory` can rehydrate the tag and a
247
+ * post-reload commit still records its funnel milestone. Show-timing moments
248
+ * record at render and are never stored here.
390
249
  */
391
- toolRoutedProfile?: string;
392
- commandIntent?: { type: string; payload?: string; languageCode?: string };
393
- trustContext?: TrustContext;
394
- /** Task-run scope for the current turn. Cleared at turn end so queued/drained turns don't inherit it. */
395
- taskRunId?: string;
396
- assistantId?: string;
397
- voiceCallControlPrompt?: string;
398
- transportHints?: string[];
399
- clientTimezone?: string;
400
-
401
- readonly coreToolNames: Set<string>;
402
- allowedToolNames?: Set<string>;
403
- diskPressureCleanupModeActive?: boolean;
404
- toolsDisabledDepth: number;
405
- preactivatedSkillIds?: string[];
406
- readonly skillProjectionState: Map<string, string>;
407
- readonly skillProjectionCache: SkillProjectionCache;
408
-
409
- readonly traceEmitter: TraceEmitter;
410
- readonly profiler: ToolProfiler;
411
- usageStats: UsageStats;
412
- turnCount: number;
413
-
414
- lastAssistantAttachments: AssistantAttachmentDraft[];
415
- lastAttachmentWarnings: string[];
416
-
417
- hasNoClient: boolean;
418
- /** True when this conversation is itself a subagent (suppresses subagent status injection). */
419
- isSubagent?: boolean;
420
- headlessLock?: boolean;
421
- readonly streamThinking: boolean;
422
- readonly prompter: PermissionPrompter;
423
- readonly queue: MessageQueue;
424
-
425
- emitActivityState(
426
- phase:
427
- | "idle"
428
- | "thinking"
429
- | "streaming"
430
- | "tool_running"
431
- | "awaiting_confirmation",
432
- reason:
433
- | "message_dequeued"
434
- | "thinking_delta"
435
- | "first_text_delta"
436
- | "tool_use_start"
437
- | "preview_start"
438
- | "tool_result_received"
439
- | "confirmation_requested"
440
- | "confirmation_resolved"
441
- | "context_compacting"
442
- | "message_complete"
443
- | "generation_cancelled"
444
- | "error_terminal",
445
- options?: {
446
- anchor?: "assistant_turn" | "user_turn" | "global";
447
- requestId?: string;
448
- statusText?: string;
449
- },
450
- ): void;
451
- emitConfirmationStateChanged(
452
- params: ConfirmationStateChanged extends {
453
- type: infer _;
454
- }
455
- ? Omit<ConfirmationStateChanged, "type">
456
- : never,
457
- ): void;
458
-
459
- /**
460
- * Optional callback invoked by the Conversation when a confirmation state changes.
461
- * The agent loop registers this to track requestId → toolUseId mappings
462
- * and record confirmation outcomes for persistence.
463
- */
464
- onConfirmationOutcome?: (
465
- requestId: string,
466
- state: string,
467
- toolUseId?: string,
468
- ) => void;
469
-
470
- getWorkspaceGitService?: (workspaceDir: string) => GitServiceInitializer;
471
- commitTurnChanges?: typeof commitTurnChanges;
472
-
473
- refreshWorkspaceTopLevelContextIfNeeded(): void;
474
- markWorkspaceTopLevelDirty(): void;
475
- getQueueDepth(): number;
476
- hasQueuedMessages(): boolean;
477
- canHandoffAtCheckpoint(): boolean;
478
- drainQueue(reason: QueueDrainReason): Promise<void>;
479
- getTurnChannelContext(): TurnChannelContext | null;
480
- getTurnInterfaceContext(): TurnInterfaceContext | null;
250
+ activationMoment?: ActivationMomentParam;
481
251
  }
482
252
 
483
253
  // ── runAgentLoop ─────────────────────────────────────────────────────
484
254
 
485
255
  export async function runAgentLoopImpl(
486
- ctx: AgentLoopConversationContext,
256
+ ctx: Conversation,
487
257
  content: string,
488
258
  userMessageId: string,
489
259
  onEvent: (msg: ServerMessage) => void,
@@ -556,26 +326,22 @@ export async function runAgentLoopImpl(
556
326
  // `resolveCallSiteConfig`, picking up any user overrides under
557
327
  // `llm.callSites.mainAgent` (falling back to `llm.default` when absent).
558
328
  const turnCallSite: LLMCallSite = options?.callSite ?? "mainAgent";
559
- // Expose the turn's call site to plugin pipeline/injector contexts (read by
560
- // buildPluginTurnContext) so plugins can scope behaviour to the main reply.
329
+ // Expose the turn's call site on the live conversation so the runtime
330
+ // injection assembly self-resolves it for the turn's plugin contexts.
561
331
  ctx.currentCallSite = turnCallSite;
562
332
 
563
- // Read the conversation row once for both the override-profile derivation
564
- // below and the title-replaceability check at turn start. Later reads in
565
- // this function (post-turn truncation, disk sync, home-feed emission)
566
- // intentionally re-read because state can change during the turn.
567
- const turnStartConversation = getConversation(ctx.conversationId);
568
-
569
333
  // Optional per-turn inference-profile override. Plumbed through to every
570
334
  // LLM call the loop emits and inherited by any subagents spawned during
571
335
  // this turn. Caller-supplied `options.overrideProfile` (e.g.
572
336
  // SubagentManager forwarding the parent's pinned profile into the
573
- // spawned subagent's background conversation) wins over the row read
574
- // so the agent loop's own background-skip rule doesn't zero out an
575
- // explicitly inherited override.
337
+ // spawned subagent's background conversation) wins over the conversation's
338
+ // own override so the agent loop's background-skip rule doesn't zero out an
339
+ // explicitly inherited override. The override state is mirrored onto the
340
+ // live conversation (hydrated on load, kept current by the HTTP setters and
341
+ // the expiry reaper), so the derivation reads `ctx` rather than re-fetching
342
+ // the row.
576
343
  const userExplicitOverride =
577
- options?.overrideProfile ??
578
- getConversationOverrideProfileFromRow(turnStartConversation);
344
+ options?.overrideProfile ?? resolveOverrideProfile(ctx);
579
345
 
580
346
  const config = getConfig();
581
347
 
@@ -588,9 +354,7 @@ export async function runAgentLoopImpl(
588
354
 
589
355
  const readCurrentOverrideProfile = (): string | undefined =>
590
356
  options?.overrideProfile ??
591
- getConversationOverrideProfileFromRow(
592
- getConversation(ctx.conversationId),
593
- ) ??
357
+ resolveOverrideProfile(ctx) ??
594
358
  ctx.toolRoutedProfile;
595
359
 
596
360
  const effectiveContextWindow = resolveEffectiveContextWindow({
@@ -608,11 +372,7 @@ export async function runAgentLoopImpl(
608
372
  }).contextWindow,
609
373
  currentEffectiveContextWindow,
610
374
  );
611
- const contextWindowManager =
612
- ctx.contextWindowManager as ContextWindowManager & {
613
- updateConfig?: (config: ContextWindowConfig) => void;
614
- };
615
- contextWindowManager.updateConfig?.(currentContextWindowConfig);
375
+ ctx.contextWindowManager.updateConfig(currentContextWindowConfig);
616
376
 
617
377
  let appliedOverrideProfile = turnOverrideProfile;
618
378
  let emittedToolRoutedProfile: string | undefined;
@@ -632,7 +392,7 @@ export async function runAgentLoopImpl(
632
392
  }).contextWindow,
633
393
  currentEffectiveContextWindow,
634
394
  );
635
- contextWindowManager.updateConfig?.(currentContextWindowConfig);
395
+ ctx.contextWindowManager.updateConfig(currentContextWindowConfig);
636
396
  appliedOverrideProfile = currentOverrideProfile;
637
397
  rlog.info(
638
398
  { overrideProfile: currentOverrideProfile ?? null },
@@ -751,11 +511,17 @@ export async function runAgentLoopImpl(
751
511
 
752
512
  const isInteractiveResolved =
753
513
  options?.isInteractive ?? (!ctx.hasNoClient && !ctx.headlessLock);
514
+ // Whether the in-flight turn has no human present to answer clarification
515
+ // questions. Derived from the loop's `isInteractive` option (which can fall
516
+ // back to mutable client/headless state that flips mid-turn), so it is
517
+ // resolved once here and threaded into every re-injection — including the
518
+ // post-compaction hook — rather than re-read per assembly call.
519
+ const isNonInteractive = !isInteractiveResolved;
754
520
  const diskPressureDecision = classifyDiskPressureTurnPolicy(
755
521
  getDiskPressureStatus(),
756
522
  {
757
- conversationType: turnStartConversation?.conversationType ?? null,
758
- conversationSource: turnStartConversation?.source ?? null,
523
+ conversationType: ctx.conversationType ?? null,
524
+ conversationSource: ctx.source ?? null,
759
525
  callSite: turnCallSite,
760
526
  isInteractive: isInteractiveResolved,
761
527
  sourceChannel:
@@ -772,10 +538,6 @@ export async function runAgentLoopImpl(
772
538
  : null,
773
539
  },
774
540
  );
775
- const diskPressureContext =
776
- diskPressureDecision.action === "allow-cleanup-mode"
777
- ? { cleanupModeActive: true }
778
- : null;
779
541
  ctx.diskPressureCleanupModeActive =
780
542
  diskPressureDecision.action === "allow-cleanup-mode";
781
543
 
@@ -879,22 +641,7 @@ export async function runAgentLoopImpl(
879
641
  }
880
642
 
881
643
  const isFirstMessage = ctx.messages.length === 1;
882
- // Promote a pending post-compaction re-inject signal (e.g. from `/compact`)
883
- // into `compactedThisTurn` so NOW.md / PKB / v2 static blocks land on this
884
- // turn even when no mid-turn compaction fires. Clear the flag immediately
885
- // so this fires exactly once per `/compact` event.
886
- const consumedPostCompactReinject = ctx.pendingPostCompactReinject;
887
- ctx.pendingPostCompactReinject = false;
888
- state.shouldInjectWorkspace = isFirstMessage || consumedPostCompactReinject;
889
- let compactedThisTurn = consumedPostCompactReinject;
890
- let slackCompactedThisTurn = false;
891
644
  const isSlackConversation = ctx.channelCapabilities?.channel === "slack";
892
- let currentSlackContextSummary =
893
- turnStartConversation?.contextSummary ?? null;
894
- let currentSlackContextCompactedMessageCount =
895
- turnStartConversation?.contextCompactedMessageCount ?? 0;
896
- let currentSlackContextCompactionWatermarkTs =
897
- turnStartConversation?.slackContextCompactionWatermarkTs ?? null;
898
645
  const loadCurrentSlackChronologicalContext =
899
646
  (): SlackChronologicalContext | null => {
900
647
  if (!isSlackConversation) return null;
@@ -903,18 +650,15 @@ export async function runAgentLoopImpl(
903
650
  ctx.channelCapabilities!,
904
651
  {
905
652
  trustClass: ctx.trustContext?.trustClass,
906
- contextSummary: currentSlackContextSummary,
907
- contextCompactedMessageCount:
908
- currentSlackContextCompactedMessageCount,
653
+ contextSummary: ctx.contextSummary,
654
+ contextCompactedMessageCount: ctx.contextCompactedMessageCount,
909
655
  slackContextCompactionWatermarkTs:
910
- currentSlackContextCompactionWatermarkTs,
656
+ ctx.slackContextCompactionWatermarkTs,
911
657
  },
912
658
  );
913
659
  };
914
660
  let slackChronologicalContext: SlackChronologicalContext | null =
915
661
  loadCurrentSlackChronologicalContext();
916
- const messagesForStartOfTurnCompaction =
917
- slackChronologicalContext?.messages ?? ctx.messages;
918
662
  const getSlackProvenanceContextForCompactionBasis = (
919
663
  messages: Message[],
920
664
  compactedMessages: number,
@@ -1001,15 +745,6 @@ export async function runAgentLoopImpl(
1001
745
  await applyCompactionResult(ctx, result, onEvent, reqId, {
1002
746
  slackContextCompactionWatermarkTs: slackWatermarkTs,
1003
747
  });
1004
- currentSlackContextSummary = result.summaryText;
1005
- currentSlackContextCompactedMessageCount =
1006
- ctx.contextCompactedMessageCount;
1007
- if (slackWatermarkTs) {
1008
- currentSlackContextCompactionWatermarkTs = slackWatermarkTs;
1009
- }
1010
- if (isSlackConversation) {
1011
- slackCompactedThisTurn = true;
1012
- }
1013
748
  slackChronologicalContext = projectSlackProvenanceAfterCompaction(
1014
749
  provenanceContext,
1015
750
  compactedBasis,
@@ -1017,88 +752,6 @@ export async function runAgentLoopImpl(
1017
752
  );
1018
753
  };
1019
754
 
1020
- const compactCheck = ctx.contextWindowManager.shouldCompact(
1021
- messagesForStartOfTurnCompaction,
1022
- );
1023
- // Skip auto-compaction while the circuit breaker is open. Force paths
1024
- // and user-initiated /compact bypass this check.
1025
- const autoCompactAllowed =
1026
- !(await ctx.agentLoop.compactionCircuit.isOpen(ctx));
1027
- if (compactCheck.needed && autoCompactAllowed) {
1028
- ctx.emitActivityState("thinking", "context_compacting", {
1029
- requestId: reqId,
1030
- });
1031
- }
1032
- const compactionOptions = {
1033
- precomputedEstimate: compactCheck.estimatedTokens,
1034
- overrideProfile: resolveCurrentOverrideProfile() ?? null,
1035
- actorTrustClass: resolveTurnActorTrustClass(ctx),
1036
- };
1037
- let compacted: Awaited<
1038
- ReturnType<typeof ctx.contextWindowManager.maybeCompact>
1039
- > | null = null;
1040
- if (autoCompactAllowed) {
1041
- try {
1042
- compacted = (await runPipeline<CompactionArgs, CompactionResult>(
1043
- "compaction",
1044
- getMiddlewaresFor("compaction"),
1045
- (args) =>
1046
- defaultCompactionTerminal(args, buildPluginTurnContext(ctx, reqId)),
1047
- {
1048
- messages: messagesForStartOfTurnCompaction,
1049
- signal: abortController.signal,
1050
- options: compactionOptions,
1051
- },
1052
- buildPluginTurnContext(ctx, reqId),
1053
- DEFAULT_TIMEOUTS.compaction,
1054
- )) as Awaited<ReturnType<typeof ctx.contextWindowManager.maybeCompact>>;
1055
- } catch (err) {
1056
- if (err instanceof PluginTimeoutError) {
1057
- // Pipeline exceeded its budget. Record the failure so the circuit
1058
- // breaker tracks consecutive timeouts (it trips after three),
1059
- // then degrade gracefully by skipping compaction this turn —
1060
- // the turn proceeds with the un-compacted history rather than
1061
- // hard-failing. The inner summary call has been aborted by the
1062
- // runner's signal-linking, so updateSummary's local fallback
1063
- // also ran before this catch block is reached.
1064
- rlog.warn(
1065
- { err, phase: "start-of-turn-compaction" },
1066
- "Compaction pipeline timed out — skipping compaction this turn",
1067
- );
1068
- await ctx.agentLoop.compactionCircuit.recordOutcome(
1069
- ctx,
1070
- true,
1071
- onEvent,
1072
- );
1073
- compacted = null;
1074
- } else {
1075
- throw err;
1076
- }
1077
- }
1078
- }
1079
- // Only track circuit-breaker state when a summary LLM call actually ran.
1080
- // `summaryFailed` is `undefined` on early returns (compaction disabled,
1081
- // below threshold, no eligible messages, truncation-only
1082
- // path) — treating those as "successful" compactions would silently reset
1083
- // the 3-strike counter and break the invariant.
1084
- if (compacted && compacted.summaryFailed !== undefined) {
1085
- await ctx.agentLoop.compactionCircuit.recordOutcome(
1086
- ctx,
1087
- compacted.summaryFailed,
1088
- onEvent,
1089
- );
1090
- }
1091
- if (compacted?.compacted) {
1092
- await applySuccessfulCompaction(
1093
- compacted,
1094
- messagesForStartOfTurnCompaction,
1095
- );
1096
- state.shouldInjectWorkspace = true;
1097
- if (compacted.compactedPersistedMessages > 0) {
1098
- compactedThisTurn = true;
1099
- }
1100
- }
1101
-
1102
755
  // Register confirmation outcome tracker so the agent loop can link
1103
756
  // confirmation decisions to tool_use_ids for persistence.
1104
757
  ctx.onConfirmationOutcome = (requestId, confirmationState, toolUseId) => {
@@ -1143,39 +796,16 @@ export async function runAgentLoopImpl(
1143
796
  hostTimeZone,
1144
797
  });
1145
798
 
1146
- // Resolve the inbound actor context for the unified <turn_context> block.
1147
- // When the conversation carries enough identity info, use the unified
1148
- // actor trust resolver so member status/policy and guardian binding details
1149
- // are fresh for this turn. The conversation runtime context remains the source
1150
- // for policy gating; this block is model-facing grounding metadata.
1151
- let resolvedInboundActorContext: InboundActorContext | null = null;
1152
- if (ctx.trustContext) {
1153
- const gc = ctx.trustContext;
1154
- if (gc.requesterExternalUserId && gc.requesterChatId) {
1155
- const actorTrust = resolveActorTrust({
1156
- assistantId: ctx.assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID,
1157
- sourceChannel: gc.sourceChannel,
1158
- conversationExternalId: gc.requesterChatId,
1159
- actorExternalId: gc.requesterExternalUserId,
1160
- actorDisplayName: gc.requesterSenderDisplayName,
1161
- });
1162
- resolvedInboundActorContext = inboundActorContextFromTrust(actorTrust);
1163
- } else {
1164
- resolvedInboundActorContext = inboundActorContextFromTrustContext(gc);
1165
- }
1166
- }
1167
-
1168
- // Resolve the channel/interface labels and the guardian flag for this
1169
- // turn. These derive only from the captured turn context and the resolved
1170
- // actor trust class — never from retrieval — so they settle before context
1171
- // assembly.
1172
- const interfaceName =
1173
- capturedTurnInterfaceContext.userMessageInterface ?? undefined;
1174
- const channelName =
1175
- capturedTurnChannelContext?.userMessageChannel ?? undefined;
1176
- const isGuardian =
1177
- resolvedInboundActorContext?.trustClass === "guardian" ||
1178
- !resolvedInboundActorContext;
799
+ // Unified `<turn_context>` actor input for this turn (model-facing grounding
800
+ // metadata; the conversation runtime context remains the source for policy
801
+ // gating). Resolved once at turn start and threaded per call site (like
802
+ // `modelProfile`) so post-compaction re-injection receives it as an explicit
803
+ // hook input rather than re-deriving it from live state that can flip
804
+ // mid-turn.
805
+ const actorContext = resolveTurnInboundActorContext(
806
+ ctx.trustContext,
807
+ ctx.assistantId,
808
+ );
1179
809
 
1180
810
  // Surface long gaps between user messages so the model can acknowledge
1181
811
  // the absence naturally. Gated at >12h to avoid noisy injection during
@@ -1196,6 +826,19 @@ export async function runAgentLoopImpl(
1196
826
  }
1197
827
  }
1198
828
 
829
+ // Freeze the turn-start client timezone and long-absence gap on the
830
+ // conversation so `applyRuntimeInjections` sources them from live state —
831
+ // like the channel/voice/transport hints. Frozen here (rather than read
832
+ // live in assembly) because the live `ctx.clientTimezone` is overwritten
833
+ // when a newer message for the same conversation arrives mid-turn, which
834
+ // would otherwise leak a queued message's timezone into the in-flight turn.
835
+ // The `current_time` value is computed fresh at each injection point, so
836
+ // it is not part of this snapshot.
837
+ ctx.currentTurnTemporalSnapshot = {
838
+ clientTimezone: timezoneContext.clientTimezone,
839
+ timeSinceLastMessage,
840
+ };
841
+
1199
842
  // Resolve the effective profile key for this turn and detect changes.
1200
843
  // Only inject model_profile into the turn context when the profile
1201
844
  // changed since the last turn (or on the first turn of a conversation)
@@ -1204,519 +847,73 @@ export async function runAgentLoopImpl(
1204
847
  turnOverrideProfile ??
1205
848
  config.llm.activeProfile ??
1206
849
  resolveDefaultProfileKey("mainAgent", config.llm);
1207
- const lastNotified = turnStartConversation?.lastNotifiedInferenceProfile;
1208
- let modelProfileStr: string | null = null;
1209
- if (effectiveProfileKey != null && effectiveProfileKey !== lastNotified) {
1210
- const profileEntry = config.llm.profiles?.[effectiveProfileKey];
1211
- const resolved = resolveCallSiteConfig(turnCallSite, config.llm, {
1212
- overrideProfile: turnOverrideProfile ?? undefined,
1213
- });
1214
- const label = profileEntry?.label ?? effectiveProfileKey;
1215
- modelProfileStr = resolved.model ? `${label} (${resolved.model})` : label;
850
+ const lastNotified = ctx.lastNotifiedInferenceProfile;
851
+ const modelProfileKey =
852
+ effectiveProfileKey != null && effectiveProfileKey !== lastNotified
853
+ ? effectiveProfileKey
854
+ : null;
855
+ // The key is threaded to the memory hook as plain turn data; the rendered
856
+ // `Label (model)` line for the mid-loop re-injection sites is resolved from
857
+ // it here so both paths share one derivation.
858
+ const modelProfileStr = resolveTurnModelProfileLabel(
859
+ modelProfileKey,
860
+ turnCallSite,
861
+ config.llm,
862
+ );
863
+ if (modelProfileKey != null) {
1216
864
  // Record the notification for persistence on delivery rather than here:
1217
865
  // the model only "learns" the profile once it receives this turn
1218
866
  // context, signalled by the first `message_complete`. Persisting inline
1219
867
  // would mark the profile notified even if the turn is cancelled or fails
1220
868
  // before the model ever sees the notice.
1221
- state.pendingNotifiedInferenceProfile = effectiveProfileKey;
869
+ state.pendingNotifiedInferenceProfile = modelProfileKey;
1222
870
  }
1223
871
 
1224
- // Memory retrieval — fetches PKB, NOW.md, and memory-graph outputs and
1225
- // persists the retrieval's own side effects (injected-block metadata,
1226
- // recall log, `memory_recalled` event). Runs at the early "prompt
1227
- // submitted, before context assembly" moment because its outputs feed the
1228
- // injection and overflow-reduction transforms below. It is shaped as the
1229
- // `user-prompt-submit-temp` hook handler but invoked directly for now: it
1230
- // must run early, while the canonical late `user-prompt-submit` hook
1231
- // (history repair, title) runs after those transforms, so the two cannot
1232
- // share a fire site until compaction is cleared from the gap between them.
872
+ // Trust class and injection mode, resolved once at turn start for the
873
+ // post-compaction re-injection sites further down, which re-apply the
874
+ // runtime injections (gated on trust) onto the compacted history.
1233
875
  const isTrustedActor = resolveTrustClass(ctx.trustContext) === "guardian";
1234
- const memoryCtx: MemoryRetrievalHookContext = {
1235
- graphMemory: ctx.graphMemory,
1236
- config: getConfig(),
1237
- onEvent,
1238
- isTrustedActor,
1239
- conversationId: ctx.conversationId,
1240
- userMessageId,
1241
- logger: rlog,
1242
- // An external cancel aborts `prepareMemory` instead of letting it run
1243
- // to completion after the turn has already been torn down.
1244
- signal: abortController.signal,
1245
- latestMessages: ctx.messages,
1246
- };
1247
- await userPromptSubmitMemoryRetrieval(memoryCtx);
1248
-
1249
- // The retriever owns its side effects (injected-block metadata, recall
1250
- // log, `memory_recalled` event) and records the dense/sparse PKB query
1251
- // pair on the graph handle for the PKB-reminder injector to read back; the
1252
- // loop only reuses the injected message list downstream.
1253
- let runMessages = memoryCtx.latestMessages;
1254
-
1255
- // Capture wall-clock "now" at its point of use, after the blocking memory
1256
- // retrieval, so the injected `<turn_context>` timestamp reflects current
1257
- // time rather than the moment the turn began.
1258
- const timestamp = formatTurnTimestamp({
1259
- timeZone: timezoneContext.effectiveTimezone,
1260
- });
1261
-
1262
- // Build unified turn context block that replaces the separate temporal,
1263
- // channel, interface, and actor context blocks.
1264
- const baseTurnContext = {
1265
- timestamp,
1266
- interfaceName,
1267
- channelName,
1268
- configuredUserTimezone: timezoneContext.configuredUserTimezone,
1269
- clientTimezone: timezoneContext.clientTimezone,
1270
- detectedTimezone: timezoneContext.detectedTimezone,
1271
- timeSinceLastMessage,
1272
- modelProfile: modelProfileStr,
1273
- };
1274
- const unifiedTurnContextStr = buildUnifiedTurnContextBlock(
1275
- isGuardian
1276
- ? baseTurnContext
1277
- : {
1278
- ...baseTurnContext,
1279
- actorContext: resolvedInboundActorContext,
1280
- },
1281
- );
1282
-
1283
- // The `remember` tool handles scratchpad-style memory writes directly to the graph.
1284
-
1285
- // Subagent status injection — gives the parent LLM visibility into active/completed children.
1286
- // Skipped when this conversation IS a subagent (no nesting) or has no children.
1287
- const subagentStatusBlock = ctx.isSubagent
1288
- ? null
1289
- : buildSubagentStatusBlock(
1290
- getSubagentManager().getChildrenOf(ctx.conversationId),
1291
- );
1292
-
1293
- // For any Slack conversation (channels and DMs alike), build a
1294
- // chronological transcript from the persisted message rows so the
1295
- // model sees one channel-wide view instead of the gateway's per-turn
1296
- // hints. DMs render as a flat sequence (no thread tags), channels
1297
- // include sibling threads.
1298
- const slackConversationForInjection = isSlackConversation
1299
- ? (getConversation(ctx.conversationId) ?? turnStartConversation)
1300
- : turnStartConversation;
1301
- if (isSlackConversation && !slackCompactedThisTurn) {
1302
- slackChronologicalContext ??= loadSlackChronologicalContext(
1303
- ctx.conversationId,
1304
- ctx.channelCapabilities!,
1305
- {
1306
- trustClass: ctx.trustContext?.trustClass,
1307
- contextSummary: slackConversationForInjection?.contextSummary,
1308
- contextCompactedMessageCount:
1309
- slackConversationForInjection?.contextCompactedMessageCount,
1310
- slackContextCompactionWatermarkTs:
1311
- slackConversationForInjection?.slackContextCompactionWatermarkTs,
1312
- },
1313
- );
1314
- }
1315
- const slackChronologicalMessages =
1316
- slackChronologicalContext?.messages ?? null;
1317
-
1318
- // Active-thread focus block: when the inbound user message belongs to
1319
- // a Slack thread, append a non-persisted `<active_thread>` tail block
1320
- // to the final user turn listing the thread's parent + replies. Helps
1321
- // the model orient when the channel transcript is long and
1322
- // interleaved. Replays strip the block via RUNTIME_INJECTION_PREFIXES.
1323
- // DMs short-circuit to null inside `loadSlackActiveThreadFocusBlock`
1324
- // since DMs do not have threads.
1325
- const slackActiveThreadFocusBlock = isSlackConversation
1326
- ? loadSlackActiveThreadFocusBlock(
1327
- ctx.conversationId,
1328
- ctx.channelCapabilities!,
1329
- {
1330
- trustClass: ctx.trustContext?.trustClass,
1331
- contextCompactedMessageCount:
1332
- slackConversationForInjection?.contextCompactedMessageCount,
1333
- slackContextCompactionWatermarkTs:
1334
- slackConversationForInjection?.slackContextCompactionWatermarkTs,
1335
- },
1336
- )
1337
- : null;
1338
-
1339
- state.reducerCompacted = compactedThisTurn;
1340
-
1341
- // memory-v3-live: when on, the provider anchors its long-TTL cache
1342
- // breakpoint on the most recent STABLE user message, since the latest user
1343
- // message now carries the volatile per-turn `<memory>` block the v3
1344
- // injector emits. The matching v2-suppression strip is owned by
1345
- // `applyRuntimeInjections`, which reads the same flag itself. Flag off →
1346
- // bit-for-bit identical to today's v2 path.
1347
- const memoryV3Live = isAssistantFeatureFlagEnabled(
1348
- "memory-v3-live",
1349
- getConfig(),
1350
- );
1351
-
1352
- // Shared injection options — reused whenever we need to re-inject after reduction.
1353
- const injectionOpts = {
1354
- diskPressureContext,
1355
- // Resolved from the conversation's surface state here, where the
1356
- // runtime injector is the only consumer of the active-surface block.
1357
- activeSurface: buildActiveSurfaceContext({
1358
- currentActiveSurfaceId: ctx.currentActiveSurfaceId,
1359
- currentPage: ctx.currentPage,
1360
- surfaceState: ctx.surfaceState,
1361
- }),
1362
- // Resolved here, where the runtime injector is the only consumer of the
1363
- // active-documents block.
1364
- activeDocuments: buildActiveDocuments(ctx.conversationId),
1365
- workspaceTopLevelContext: buildWorkspaceTopLevelContext(
1366
- ctx,
1367
- state.shouldInjectWorkspace,
1368
- ),
1369
- channelCapabilities: ctx.channelCapabilities ?? null,
1370
- channelCommandContext: ctx.commandIntent ?? null,
1371
- unifiedTurnContext: unifiedTurnContextStr,
1372
- voiceCallControlPrompt: ctx.voiceCallControlPrompt ?? null,
1373
- transportHints: ctx.transportHints ?? null,
1374
- isNonInteractive: !isInteractiveResolved,
1375
- isBackgroundConversation: isBackgroundConversationType(
1376
- turnStartConversation?.conversationType,
1377
- ),
1378
- subagentStatusBlock,
1379
- slackChronologicalMessages,
1380
- slackActiveThreadFocusBlock,
1381
- } as const;
1382
-
1383
876
  let currentInjectionMode: InjectionMode = "full";
1384
877
 
1385
- // Canonical per-turn TurnContext forwarded to the injector chain. The
1386
- // per-turn injection inputs are built inside `applyRuntimeInjections`
1387
- // from the `injectionOpts` bag; we only need to hand in identity +
1388
- // trust here so third-party injectors see the real turn metadata.
1389
- const injectionTurnCtx = buildPluginTurnContext(ctx, reqId);
1390
-
1391
- const injection = await applyRuntimeInjections(runMessages, {
1392
- ...injectionOpts,
1393
- slackChronologicalMessages: state.reducerCompacted
1394
- ? null
1395
- : injectionOpts.slackChronologicalMessages,
1396
- mode: currentInjectionMode,
1397
- turnContext: injectionTurnCtx,
1398
- });
1399
- runMessages = injection.messages;
1400
-
1401
- // Persist injected blocks in message metadata so they survive conversation
1402
- // reloads (eviction, restart, fork). loadFromDb re-injects from metadata.
1403
- // Only the first call site persists — the overflow-recovery re-entry sites
1404
- // send identical bytes and the tail row may not correspond to
1405
- // `userMessageId`. All blocks are written in a single call to avoid
1406
- // doubling SQLite SELECT+UPDATE work on every turn.
1407
- if (
1408
- injection.blocks.unifiedTurnContext ||
1409
- injection.blocks.pkbSystemReminder ||
1410
- injection.blocks.workspaceBlock ||
1411
- injection.blocks.nowScratchpadBlock ||
1412
- injection.blocks.pkbContextBlock ||
1413
- injection.blocks.memoryV2StaticBlock
1414
- ) {
1415
- try {
1416
- const metadataUpdates: Record<string, unknown> = {};
1417
- if (injection.blocks.unifiedTurnContext) {
1418
- metadataUpdates.turnContextBlock =
1419
- injection.blocks.unifiedTurnContext;
1420
- }
1421
- if (injection.blocks.pkbSystemReminder) {
1422
- metadataUpdates.pkbSystemReminderBlock =
1423
- injection.blocks.pkbSystemReminder;
1424
- }
1425
- if (injection.blocks.workspaceBlock) {
1426
- metadataUpdates.workspaceBlock = injection.blocks.workspaceBlock;
1427
- }
1428
- if (injection.blocks.nowScratchpadBlock) {
1429
- metadataUpdates.nowScratchpadBlock =
1430
- injection.blocks.nowScratchpadBlock;
1431
- }
1432
- if (injection.blocks.pkbContextBlock) {
1433
- metadataUpdates.pkbContextBlock = injection.blocks.pkbContextBlock;
1434
- }
1435
- if (injection.blocks.memoryV2StaticBlock) {
1436
- metadataUpdates.memoryV2StaticBlock =
1437
- injection.blocks.memoryV2StaticBlock;
1438
- }
1439
- updateMessageMetadata(userMessageId, metadataUpdates);
1440
- } catch (err) {
1441
- rlog.warn({ err }, "Failed to persist injection metadata (non-fatal)");
1442
- }
1443
- }
1444
-
1445
- // ── Preflight budget evaluation ──────────────────────────────
1446
- // After runtime injections are applied, estimate the prompt token count
1447
- // and proactively invoke the reducer if already above budget. This avoids
1448
- // a wasted provider round-trip that would just fail with context_too_large.
1449
- const initialContextBudget = resolveCurrentContextBudget();
1450
- const overflowRecovery = initialContextBudget.overflowRecovery;
1451
- const preflightBudget = initialContextBudget.preflightBudget;
1452
- let reducerState: ReducerState | undefined;
1453
-
1454
- const toolTokenBudget = ctx.agentLoop.getToolTokenBudget(runMessages);
1455
- // Canonical calibration key — used by the preflight estimate, the
1456
- // overflow reducer config, and the convergence-path `estimatePromptTokens`
1457
- // call. Matches the key recorded by `handleUsage` for wrapper providers
1458
- // (OpenRouter routing to Anthropic → key is `"anthropic"`).
1459
- const estimationProviderName = getCalibrationProviderKey(ctx.provider);
1460
-
1461
- const preflightTokens = estimatePromptTokensWithTools(
1462
- runMessages,
1463
- ctx.systemPrompt,
1464
- ctx.agentLoop.getResolvedTools(runMessages),
1465
- estimationProviderName,
1466
- );
1467
-
1468
- if (overflowRecovery.enabled && preflightTokens > preflightBudget) {
1469
- rlog.warn(
1470
- {
1471
- phase: "preflight",
1472
- estimatedTokens: preflightTokens,
1473
- budget: preflightBudget,
1474
- },
1475
- "Preflight budget exceeded — running overflow reducer before provider call",
1476
- );
1477
-
1478
- // Overflow reduction runs through the plugin pipeline. The default
1479
- // middleware (`default-overflow-reduce`, registered at bootstrap)
1480
- // contains the historical tier loop — forced compaction → tool-result
1481
- // truncation → media stubbing → injection downgrade — plus the
1482
- // re-inject/re-estimate convergence check. The callbacks below are
1483
- // the orchestrator-specific side effects that the plugin coordinates
1484
- // per iteration (activity emission, compaction application, runtime
1485
- // injection reassembly, token re-estimation). Registered plugins that
1486
- // wrap the `overflowReduce` slot see each iteration through their own
1487
- // middleware `next` callback.
1488
- const messagesForPreflightOverflowReduction =
1489
- slackChronologicalContext?.messages ?? ctx.messages;
1490
- const overflowArgs: OverflowReduceArgs = {
1491
- messages: messagesForPreflightOverflowReduction,
1492
- runMessages,
1493
- systemPrompt: ctx.systemPrompt,
1494
- providerName: estimationProviderName,
1495
- contextWindow: resolveCurrentContextWindowConfig(),
1496
- preflightBudget,
1497
- toolTokenBudget,
1498
- maxAttempts: resolveCurrentContextBudget().overflowRecovery.maxAttempts,
1499
- abortSignal: abortController.signal,
1500
- compactFn: async (msgs, signal, opts) => {
1501
- // Route the reducer's forced-compaction tier through the
1502
- // `compaction` pipeline so registered plugins observe these
1503
- // invocations. Without this, custom compaction middleware only
1504
- // sees the three orchestrator-owned call sites and misses the
1505
- // reducer-initiated forced compactions entirely.
1506
- //
1507
- // Pipeline timeouts must be caught locally — a `PluginTimeoutError`
1508
- // bubbling out of here would abort the overflow-reducer tier loop
1509
- // entirely, skipping fallback tiers (tool-result truncation, media
1510
- // stubbing, injection downgrade) and bypassing circuit-breaker
1511
- // bookkeeping. On timeout, record the failure and return a
1512
- // `compacted: false` result so the reducer falls through to the
1513
- // next tier.
1514
- try {
1515
- return (await runPipeline<CompactionArgs, CompactionResult>(
1516
- "compaction",
1517
- getMiddlewaresFor("compaction"),
1518
- (args) =>
1519
- defaultCompactionTerminal(
1520
- args,
1521
- buildPluginTurnContext(ctx, reqId),
1522
- ),
1523
- {
1524
- messages: msgs,
1525
- signal,
1526
- options: {
1527
- ...(opts ?? {}),
1528
- overrideProfile: resolveCurrentOverrideProfile() ?? null,
1529
- actorTrustClass: resolveTurnActorTrustClass(ctx),
1530
- },
1531
- },
1532
- buildPluginTurnContext(ctx, reqId),
1533
- DEFAULT_TIMEOUTS.compaction,
1534
- )) as Awaited<
1535
- ReturnType<typeof ctx.contextWindowManager.maybeCompact>
1536
- >;
1537
- } catch (err) {
1538
- if (err instanceof PluginTimeoutError) {
1539
- rlog.warn(
1540
- { err, phase: "overflow-reducer-forced-compaction" },
1541
- "Compaction pipeline timed out — falling through to next reducer tier",
1542
- );
1543
- await ctx.agentLoop.compactionCircuit.recordOutcome(
1544
- ctx,
1545
- true,
1546
- onEvent,
1547
- );
1548
- return {
1549
- messages: msgs,
1550
- compacted: false,
1551
- previousEstimatedInputTokens: 0,
1552
- estimatedInputTokens: 0,
1553
- maxInputTokens: 0,
1554
- thresholdTokens: 0,
1555
- compactedMessages: 0,
1556
- compactedPersistedMessages: 0,
1557
- summaryCalls: 0,
1558
- summaryInputTokens: 0,
1559
- summaryOutputTokens: 0,
1560
- summaryModel: "",
1561
- summaryText: "",
1562
- reason: "compaction pipeline timed out",
1563
- };
1564
- }
1565
- throw err;
1566
- }
1567
- },
1568
- emitActivityState: () => {
1569
- ctx.emitActivityState("thinking", "context_compacting", {
1570
- requestId: reqId,
1571
- });
1572
- },
1573
- onCompactionResult: async (result, compactedBasis) => {
1574
- // Track circuit-breaker state whenever the reducer invoked
1575
- // compaction. The reducer's forced_compaction tier uses
1576
- // force:true, so it bypasses the open-circuit check, but we
1577
- // still want failure tracking to detect a run of broken
1578
- // summaries and clear the counter on success. Only track when
1579
- // the summary LLM actually ran — `summaryFailed === undefined`
1580
- // indicates an early return (no eligible messages,
1581
- // truncation-only path, etc.) that shouldn't influence the
1582
- // breaker.
1583
- if (result.summaryFailed !== undefined) {
1584
- await ctx.agentLoop.compactionCircuit.recordOutcome(
1585
- ctx,
1586
- result.summaryFailed,
1587
- onEvent,
1588
- );
1589
- }
1590
- if (result.compacted) {
1591
- await applySuccessfulCompaction(result, compactedBasis);
1592
- state.shouldInjectWorkspace = true;
1593
- }
1594
- },
1595
- reinjectForMode: async (
1596
- reducedMessages,
1597
- mode,
1598
- stepCompacted,
1599
- accumulatedCompacted,
1600
- ) => {
1601
- // Mirror the pre-PR-23 behavior: `ctx.messages` must track the
1602
- // reducer's latest output before re-injection runs, because other
1603
- // sites consulted through `injectionOpts` (`workspaceTopLevelContext`,
1604
- // slack history, etc.) depend on it and `applyCompactionResult`
1605
- // only updates `ctx.messages` on a compaction tier. Assigning here
1606
- // keeps non-compaction tiers (tool-result truncation, media
1607
- // stubbing, injection downgrade) observable to downstream
1608
- // injection assembly on the same turn.
1609
- ctx.messages = reducedMessages;
1610
-
1611
- // When THIS iteration compacted, it stripped the existing
1612
- // memory-static block — so we re-inject current content. A later
1613
- // iteration that only truncates or downgrades must NOT re-force it,
1614
- // or each round would grow the token count.
1615
- // Gate: only the iteration that actually compacted re-injects.
1616
- // (The `<knowledge_base>`, NOW.md, and v2 static `<info>` blocks
1617
- // self-gate inside their injectors on whether they are already
1618
- // present in `reducedMessages`.)
1619
- const injection = await applyRuntimeInjections(reducedMessages, {
1620
- ...injectionOpts,
1621
- workspaceTopLevelContext: buildWorkspaceTopLevelContext(
1622
- ctx,
1623
- state.shouldInjectWorkspace,
1624
- ),
1625
- // Once ANY iteration has compacted `ctx.messages`, the captured
1626
- // `slackChronologicalMessages` snapshot (built from the full
1627
- // persisted transcript) would overwrite the compacted history
1628
- // and undo compaction. Suppress the override from here on —
1629
- // sticky across subsequent non-compacting iterations.
1630
- slackChronologicalMessages: accumulatedCompacted
1631
- ? null
1632
- : injectionOpts.slackChronologicalMessages,
1633
- mode,
1634
- turnContext: buildPluginTurnContext(ctx, reqId),
1635
- });
1636
- let next = injection.messages;
1637
- if (isTrustedActor && mode !== "minimal") {
1638
- const memResult = ctx.graphMemory.reinjectCachedMemory(next);
1639
- next = memResult.runMessages;
1640
- }
1641
- return next;
1642
- },
1643
- estimatePostInjection: (runMsgs) =>
1644
- estimatePromptTokens(runMsgs, ctx.systemPrompt, {
1645
- providerName: estimationProviderName,
1646
- toolTokenBudget,
1647
- }),
1648
- };
1649
-
1650
- const overflowResult = await runPipeline<
1651
- OverflowReduceArgs,
1652
- OverflowReduceResult
1653
- >(
1654
- "overflowReduce",
1655
- getMiddlewaresFor("overflowReduce"),
1656
- // Terminal — only reached when every registered middleware calls
1657
- // `next` and delegates past the innermost layer. The default plugin
1658
- // is a terminal itself (it doesn't call `next`), so in practice
1659
- // this fallback fires only when the default has been explicitly
1660
- // deregistered (tests) and no user plugin replaces it. Strict-fail
1661
- // semantics: throw so the missing terminal surfaces as a visible
1662
- // error instead of silently returning the history untouched.
1663
- async () => {
1664
- throw new PluginExecutionError(
1665
- "overflowReduce pipeline has no terminal handler — every reducer middleware called next() without providing a replacement",
1666
- "overflowReduce",
1667
- );
1668
- },
1669
- overflowArgs,
1670
- buildPluginTurnContext(ctx, reqId),
1671
- DEFAULT_TIMEOUTS.overflowReduce,
1672
- );
1673
-
1674
- ctx.messages = overflowResult.messages;
1675
- runMessages = overflowResult.runMessages;
1676
- currentInjectionMode = overflowResult.injectionMode;
1677
- reducerState = overflowResult.reducerState;
1678
- if (overflowResult.reducerCompacted) {
1679
- state.reducerCompacted = true;
1680
- }
1681
- }
1682
-
1683
- // Replace historical web_search_tool_result blocks with text summaries.
1684
- // The opaque `encrypted_content` tokens Anthropic attaches to each result
1685
- // expire / are route-scoped; replaying a stale token is rejected with
1686
- // `Invalid encrypted_content in search_result block`. Titles + URLs
1687
- // preserve enough context for the model on follow-up turns.
1688
- const webSearchStrip = stripHistoricalWebSearchResults(runMessages);
1689
- if (webSearchStrip.stats.blocksStripped > 0) {
1690
- rlog.info(
1691
- { phase: "pre_run", ...webSearchStrip.stats },
1692
- "Converted historical web_search_tool_result blocks to text summaries",
1693
- );
1694
- runMessages = webSearchStrip.messages;
1695
- }
1696
-
1697
- // user-prompt-submit hook: plugins may transform `runMessages` right
1698
- // before the agent loop receives them. Fires once per user turn at the
1699
- // primary `agentLoop.run` only — the re-entry / retry calls further down
1700
- // in this function do not refire it (they're not new user submissions).
1701
- // Plugins may mutate `ctx.latestMessages` in place OR return a new
1702
- // context with a fresh array; `runHook` forwards whichever the chain
1703
- // settles on. Order is plugin registration order.
1704
- //
1705
- // Fires BEFORE the agent loop runs so the hook-emitted messages are part
1706
- // of the loop's input; the loop then reports its own appended output via
1707
- // `AgentLoopRunResult.newMessages`, which is what persistence consumes.
878
+ // user-prompt-submit hook chain. Fires once per user turn at the primary
879
+ // `agentLoop.run` (the re-entry / retry calls further down do not refire it
880
+ // — they're not new user submissions), before the loop runs so the
881
+ // hook-assembled messages are part of its input. Memory retrieval runs
882
+ // first — fetching PKB / NOW.md / memory-graph outputs, persisting its own
883
+ // side effects (injected-block metadata, recall log, `memory_recalled`
884
+ // event), and assembling the turn's runtime-injection blocks onto the
885
+ // history — followed by history repair and title generation, which see the
886
+ // fully injected history. Plugins may mutate `ctx.latestMessages` in place
887
+ // OR return a new context with a fresh array; `runHook` forwards whichever
888
+ // the chain settles on, in plugin registration order. The loop then reports
889
+ // its own appended output via `AgentLoopRunResult.newMessages`, which
890
+ // persistence consumes.
1708
891
  const userPromptCtx: UserPromptSubmitContext = {
1709
892
  conversationId: ctx.conversationId,
893
+ userMessageId,
894
+ requestId: reqId,
1710
895
  prompt: options?.titleText ?? content,
1711
896
  originalMessages: ctx.messages,
1712
- latestMessages: runMessages,
897
+ latestMessages: ctx.messages,
1713
898
  logger: rlog,
899
+ modelProfileKey,
900
+ isNonInteractive,
1714
901
  };
1715
902
  const finalUserPromptCtx = await runHook(
1716
903
  HOOKS.USER_PROMPT_SUBMIT,
1717
904
  userPromptCtx,
1718
905
  );
1719
- runMessages = finalUserPromptCtx.latestMessages;
906
+ let runMessages = finalUserPromptCtx.latestMessages;
907
+
908
+ // Reducer state, tool-token budget, and calibration provider key consumed
909
+ // by the post-rejection convergence loop further down. The tool-token
910
+ // budget is resolved once per turn (the resolved tool set is stable across
911
+ // the turn); the calibration key matches the key recorded by `handleUsage`
912
+ // for wrapper providers (OpenRouter routing to Anthropic → key is
913
+ // `"anthropic"`).
914
+ let reducerState: ReducerState | undefined;
915
+ const toolTokenBudget = ctx.agentLoop.getToolTokenBudget(runMessages);
916
+ const estimationProviderName = getCalibrationProviderKey(ctx.provider);
1720
917
 
1721
918
  const shouldGenerateTitle = isReplaceableTitle(
1722
919
  getConversation(ctx.conversationId)?.title ?? null,
@@ -1750,75 +947,45 @@ export async function runAgentLoopImpl(
1750
947
 
1751
948
  rlog.info({ callSite: turnCallSite }, "Starting agent loop run");
1752
949
 
1753
- // Thread the orchestrator's canonical per-turn context into the agent
1754
- // loop so its internal pipeline invocations (e.g. compaction) see the
1755
- // real conversation identity / trust / contextWindowManager instead of
1756
- // the synthesized `"agent-loop"` placeholder. The loop clones this value
1757
- // and overwrites `turnIndex` with its own tool-use iteration counter.
1758
- const loopTurnCtx = buildPluginTurnContext(ctx, reqId);
1759
-
1760
- // Hook for the loop-owned mid-loop compaction. The agent loop owns the
1761
- // trigger (its budget gate), the `compaction` pipeline call, the result
1762
- // interpretation (circuit-breaker bookkeeping + the exhaustion decision),
1763
- // and the inline continue; this callback bridges the injection state the
1764
- // loop is intentionally blind to. Durable persistence is signalled via
1765
- // events; re-injection stays orchestrator-supplied for now.
1766
- const midLoopCompaction: MidLoopCompaction = {
1767
- postCompactionHook: async ({ history, turnContext }) => {
1768
- // stripInjectionsForCompaction() unconditionally removed the existing
1769
- // memory-static block, so re-inject the current content regardless of
1770
- // whether compaction actually ran. The `<knowledge_base>`, NOW.md, and
1771
- // v2 static `<info>` blocks self-gate inside their injectors on block
1772
- // presence.
1773
- const injection = await postCompactReinject({
1774
- ...injectionOpts,
1775
- workspaceTopLevelContext: buildWorkspaceTopLevelContext(
1776
- ctx,
1777
- state.shouldInjectWorkspace,
1778
- ),
1779
- // Suppress the chronological-transcript snapshot once the reducer
1780
- // has collapsed `ctx.messages`; the captured snapshot reflects the
1781
- // full persisted transcript and would overwrite compaction.
1782
- slackChronologicalMessages: state.reducerCompacted
1783
- ? null
1784
- : injectionOpts.slackChronologicalMessages,
1785
- mode: currentInjectionMode,
1786
- turnContext,
1787
- history,
1788
- isTrustedActor,
1789
- logger: rlog,
1790
- });
1791
- return injection.messages;
1792
- },
1793
- };
950
+ // Trust snapshot the loop forwards to its mid-loop in-place compaction
951
+ // (scoping the compactor's image manifest) and the post-compaction
952
+ // re-injection. Prefers the per-turn snapshot, then the conversation-level
953
+ // context, then the fallback — matching the trust the runtime injection
954
+ // assembly resolves for the same turn. The loop's other turn-identity
955
+ // fields self-resolve from its own conversation id.
956
+ const loopTrust =
957
+ ctx.currentTurnTrustContext ?? ctx.trustContext ?? FALLBACK_TURN_TRUST;
1794
958
 
1795
959
  /**
1796
960
  * Shared closure: runs the agent loop with the orchestrator's turn
1797
961
  * context and maps the loop's returned checkpoint pause-reason into the
1798
962
  * orchestrator's yield bookkeeping. Returns the updated history so call
1799
- * sites consume it exactly as before. Pass `compaction` only for the
1800
- * primary run, where the loop compacts in place when its budget gate
1801
- * trips; reruns omit it and keep yielding for budget.
963
+ * sites consume it exactly as before. Pass `compactInPlace` only for the
964
+ * primary run: the loop then runs its budget gate before the first call
965
+ * (subsuming the proactive turn-start compaction) and compacts in place
966
+ * whenever the gate trips. Reruns omit it, skip the first-call gate, and
967
+ * keep yielding for budget.
1802
968
  */
1803
969
  const runAgentLoop = async (
1804
970
  msgs: Message[],
1805
- compaction?: MidLoopCompaction,
971
+ compactInPlace = false,
1806
972
  ): Promise<Message[]> => {
1807
973
  const { history, exitReason, appendedNewMessages, newMessages } =
1808
- await ctx.agentLoop.run(msgs, eventHandler, {
974
+ await ctx.agentLoop.run({
975
+ messages: msgs,
976
+ onEvent: eventHandler,
1809
977
  signal: abortController.signal,
1810
978
  requestId: reqId,
1811
979
  onCheckpoint,
1812
980
  callSite: turnCallSite,
1813
- turnContext: loopTurnCtx,
981
+ trust: loopTrust,
1814
982
  overrideProfile: turnOverrideProfile,
1815
983
  resolveOverrideProfile: resolveCurrentOverrideProfile,
1816
984
  resolveContextWindow,
1817
- compaction,
1818
- // memory-v3-live: the latest user message carries the volatile v3
1819
- // `<memory>` block, so anchor the provider's long-TTL cache breakpoint
1820
- // on the most recent stable message instead.
1821
- mutableLatestUserMessage: memoryV3Live,
985
+ compactInPlace,
986
+ isNonInteractive,
987
+ modelProfile: modelProfileStr,
988
+ actorContext,
1822
989
  });
1823
990
  lastRunAppendedNewMessages = appendedNewMessages;
1824
991
  lastRunNewMessages = newMessages;
@@ -1832,7 +999,7 @@ export async function runAgentLoopImpl(
1832
999
  return history;
1833
1000
  };
1834
1001
 
1835
- let updatedHistory = await runAgentLoop(runMessages, midLoopCompaction);
1002
+ let updatedHistory = await runAgentLoop(runMessages, true);
1836
1003
 
1837
1004
  rlog.info(
1838
1005
  { resultMessageCount: updatedHistory.length },
@@ -1875,8 +1042,6 @@ export async function runAgentLoopImpl(
1875
1042
  // intentionally deferred until there's a concrete plugin-level use case.
1876
1043
  const retryRepair = deepRepairHistory(updatedHistory);
1877
1044
  runMessages = retryRepair.messages;
1878
- const retryStrip = stripHistoricalWebSearchResults(runMessages);
1879
- runMessages = retryStrip.messages;
1880
1045
  state.orderingErrorDetected = false;
1881
1046
  state.deferredOrderingError = null;
1882
1047
 
@@ -1892,44 +1057,41 @@ export async function runAgentLoopImpl(
1892
1057
 
1893
1058
  // ── Image-dimension overflow recovery ──────────────────────────
1894
1059
  // When the provider rejects because an image block exceeds its pixel
1895
- // cap, strip every image block from ctx.messages and retry once.
1896
- // optimizeImageForTransport already ran at upload time; if sips was
1897
- // unavailable (non-macOS) it returns the same bytes unchanged. In
1898
- // that case we swap the block for a text note so the model can tell
1899
- // the user what happened instead of hard-failing with a red banner.
1060
+ // or payload cap, recover every oversized image in ctx.messages and
1061
+ // retry once. recoverImageBlock downscales an oversized image, or swaps
1062
+ // it for a text note when resize is a no-op (e.g. sips unavailable
1063
+ // off macOS), while leaving still-sendable images untouched. This covers
1064
+ // both top-level image blocks (user uploads) and images nested inside a
1065
+ // tool_result's contentBlocks (e.g. a browser screenshot), which is where
1066
+ // the rejected block usually lives.
1900
1067
  if (state.imageTooLargeDetected) {
1901
1068
  state.imageTooLargeDetected = false;
1902
1069
  rlog.warn(
1903
1070
  { phase: "image-recovery" },
1904
- "Image too large — stripping oversized image blocks and retrying",
1071
+ "Image too large — recovering oversized image blocks and retrying",
1905
1072
  );
1906
1073
  ctx.messages = ctx.messages.map((msg) => {
1907
1074
  if (!Array.isArray(msg.content)) return msg;
1908
- if (!msg.content.some((b) => b.type === "image")) return msg;
1075
+ if (!messageHasImageBlock(msg.content)) return msg;
1909
1076
  return {
1910
1077
  ...msg,
1911
1078
  content: msg.content.flatMap((b): ContentBlock[] => {
1912
- if (b.type !== "image") return [b];
1913
- const resized = optimizeImageForTransport(
1914
- b.source.data,
1915
- b.source.media_type,
1916
- );
1917
- if (resized.data !== b.source.data) {
1918
- // sips managed to downscale — use the smaller version
1079
+ if (b.type === "image") return [recoverImageBlock(b)];
1080
+ // Images returned by a tool (e.g. browser_screenshot) live in
1081
+ // the tool_result's contentBlocks, not as top-level blocks.
1082
+ // Recover them in place so the tool_use/tool_result pairing
1083
+ // stays intact rather than dropping the whole tool_result.
1084
+ if (b.type === "tool_result" && b.contentBlocks?.length) {
1919
1085
  return [
1920
1086
  {
1921
1087
  ...b,
1922
- source: {
1923
- type: "base64" as const,
1924
- media_type: resized.mediaType,
1925
- data: resized.data,
1926
- },
1088
+ contentBlocks: b.contentBlocks.map((cb) =>
1089
+ cb.type === "image" ? recoverImageBlock(cb) : cb,
1090
+ ),
1927
1091
  },
1928
1092
  ];
1929
1093
  }
1930
- // Can't resize — replace with a text annotation so the model
1931
- // can explain the situation rather than silently dropping context
1932
- return [{ type: "text" as const, text: UNSENDABLE_IMAGE_NOTE }];
1094
+ return [b];
1933
1095
  }),
1934
1096
  };
1935
1097
  });
@@ -2007,101 +1169,76 @@ export async function runAgentLoopImpl(
2007
1169
  },
2008
1170
  );
2009
1171
  const convergenceBudget = resolveCurrentContextBudget();
2010
- let correctedTarget = convergenceBudget.preflightBudget;
2011
- if (actualTokens && estimatedTokensAtOverflow > 0) {
2012
- const estimationErrorRatio = actualTokens / estimatedTokensAtOverflow;
2013
- if (estimationErrorRatio > 1.0) {
2014
- correctedTarget = Math.floor(
2015
- convergenceBudget.preflightBudget / estimationErrorRatio,
2016
- );
2017
- rlog.warn(
2018
- {
2019
- phase: "convergence",
2020
- actualTokens,
2021
- estimatedTokens: estimatedTokensAtOverflow,
2022
- estimationErrorRatio: estimationErrorRatio.toFixed(2),
2023
- preflightBudget: convergenceBudget.preflightBudget,
2024
- correctedTarget,
2025
- },
2026
- "Adjusting compaction target based on observed estimation error",
2027
- );
2028
- }
1172
+ const { targetTokens: correctedTarget, estimationErrorRatio } =
1173
+ computeCorrectedOverflowTarget({
1174
+ preflightBudget: convergenceBudget.preflightBudget,
1175
+ actualTokens,
1176
+ estimatedTokens: estimatedTokensAtOverflow,
1177
+ });
1178
+ if (estimationErrorRatio != null) {
1179
+ rlog.warn(
1180
+ {
1181
+ phase: "convergence",
1182
+ actualTokens,
1183
+ estimatedTokens: estimatedTokensAtOverflow,
1184
+ estimationErrorRatio: estimationErrorRatio.toFixed(2),
1185
+ preflightBudget: convergenceBudget.preflightBudget,
1186
+ correctedTarget,
1187
+ },
1188
+ "Adjusting compaction target based on observed estimation error",
1189
+ );
2029
1190
  }
2030
1191
 
2031
- // ── Emergency mid-turn compaction ────────────────────────────
2032
- // Before entering the reducer tier loop, attempt a targeted
2033
- // emergency compaction: summarize everything before the last
2034
- // tool_use + tool_result pair and let the agent continue with
2035
- // [summary, last_tool_call, last_tool_result]. This preserves
2036
- // the agent's most recent action context while aggressively
2037
- // compressing history. Falls through to reducer tiers on failure.
2038
- {
2039
- try {
2040
- const emergencyConfig = getConfig().compaction;
2041
- const emergencyResult = await runEmergencyCompaction({
2042
- conversationId: ctx.conversationId,
2043
- messages: ctx.messages,
2044
- provider: ctx.provider,
2045
- systemPrompt: ctx.systemPrompt,
2046
- tools: undefined,
2047
- compaction: emergencyConfig,
2048
- maxInputTokens: resolveCurrentMaxInputTokens(),
2049
- previousEstimatedInputTokens: estimatedTokensAtOverflow,
2050
- force: true,
2051
- signal: abortController.signal,
2052
- overrideProfile: resolveCurrentOverrideProfile() ?? null,
2053
- nonPersistedPrefixCount:
2054
- ctx.contextWindowManager.nonPersistedPrefixCount,
2055
- });
2056
- if (emergencyResult.compacted) {
2057
- rlog.info(
2058
- {
2059
- phase: "convergence",
2060
- compactedMessages: emergencyResult.compactedMessages,
2061
- summaryChars: emergencyResult.summaryText.length,
2062
- },
2063
- "Emergency mid-turn compaction succeeded — bypassing reducer tiers",
2064
- );
2065
- if (emergencyResult.summaryFailed !== undefined) {
2066
- await ctx.agentLoop.compactionCircuit.recordOutcome(
2067
- ctx,
2068
- emergencyResult.summaryFailed,
2069
- onEvent,
2070
- );
2071
- }
2072
- if (emergencyResult.compacted) {
2073
- await applySuccessfulCompaction(emergencyResult, ctx.messages);
2074
- state.shouldInjectWorkspace = true;
2075
- }
2076
- // Clear the overflow flag and re-run the agent loop with
2077
- // the compacted context.
2078
- state.contextTooLargeDetected = false;
2079
- }
2080
- } catch (err) {
2081
- rlog.warn(
2082
- { phase: "convergence", err },
2083
- "Emergency mid-turn compaction failed; continuing to reducer tiers",
2084
- );
2085
- }
2086
- // If emergency compaction failed, fall through to reducer tiers.
2087
- }
1192
+ // ── Bounded context-overflow convergence driver ─────────────────
1193
+ // The compaction plugin's reduction ladder owns every rung: the
1194
+ // emergency summarize-around-last-tool-pair, the middle tiers (forced
1195
+ // compaction, tool-result truncation, media stubbing, injection
1196
+ // downgrade), and the terminal auto-compress of the latest turn. This
1197
+ // driver applies one rung per iteration, re-injects, and re-runs the
1198
+ // agent loop until the context fits or the ladder is exhausted. The
1199
+ // overflow policy decides whether the terminal auto-compress rung is
1200
+ // permitted; the ladder never makes that policy call itself.
1201
+ const overflowAction = resolveOverflowAction({
1202
+ overflowRecovery: convergenceBudget.overflowRecovery,
1203
+ isInteractive: isInteractiveResolved,
1204
+ });
1205
+ const reducerConfig = {
1206
+ providerName: estimationProviderName,
1207
+ systemPrompt: ctx.systemPrompt,
1208
+ contextWindow: resolveCurrentContextWindowConfig(),
1209
+ targetTokens: correctedTarget,
1210
+ toolTokenBudget,
1211
+ conversationId: ctx.conversationId,
1212
+ overrideProfile: resolveCurrentOverrideProfile() ?? null,
1213
+ actorTrustClass: resolveTurnActorTrustClass(ctx),
1214
+ previousEstimatedInputTokens: estimatedTokensAtOverflow,
1215
+ maxMiddleTierAttempts: convergenceBudget.overflowRecovery.maxAttempts,
1216
+ allowAutoCompressLatestTurn:
1217
+ overflowAction === "auto_compress_latest_turn",
1218
+ };
2088
1219
 
2089
- let convergenceAttempts = 0;
2090
- const maxAttempts = convergenceBudget.overflowRecovery.maxAttempts;
1220
+ // Safety backstop against a reduction ladder that never reports
1221
+ // exhaustion: the ladder can apply at most the emergency rung, the
1222
+ // middle-tier attempt budget, and the terminal auto-compress rung, so a
1223
+ // well-behaved reducer always sets `exhausted` within this many steps.
1224
+ // The cap only guards against a misbehaving reducer; it never truncates a
1225
+ // legitimate escalation.
1226
+ const maxConvergenceRungs = reducerConfig.maxMiddleTierAttempts + 2;
1227
+ let convergenceRungs = 0;
2091
1228
 
2092
1229
  while (
2093
1230
  state.contextTooLargeDetected &&
2094
- convergenceAttempts < maxAttempts &&
2095
- !reducerState.exhausted
1231
+ !reducerState.exhausted &&
1232
+ convergenceRungs < maxConvergenceRungs
2096
1233
  ) {
2097
- convergenceAttempts++;
1234
+ convergenceRungs++;
2098
1235
  rlog.warn(
2099
1236
  {
2100
1237
  phase: "convergence",
2101
- attempt: convergenceAttempts,
1238
+ rung: convergenceRungs,
2102
1239
  appliedTiers: reducerState.appliedTiers,
2103
1240
  },
2104
- "Context too large — applying next reducer tier",
1241
+ "Context too large — applying next reduction rung",
2105
1242
  );
2106
1243
 
2107
1244
  ctx.emitActivityState("thinking", "context_compacting", {
@@ -2110,20 +1247,8 @@ export async function runAgentLoopImpl(
2110
1247
  const convergenceCompactionBasis = ctx.messages;
2111
1248
  const step = await reduceContextOverflow(
2112
1249
  convergenceCompactionBasis,
2113
- {
2114
- providerName: estimationProviderName,
2115
- systemPrompt: ctx.systemPrompt,
2116
- contextWindow: resolveCurrentContextWindowConfig(),
2117
- targetTokens: correctedTarget,
2118
- toolTokenBudget,
2119
- },
1250
+ reducerConfig,
2120
1251
  reducerState,
2121
- (msgs, signal, opts) =>
2122
- ctx.contextWindowManager.maybeCompact(msgs, signal!, {
2123
- ...(opts ?? {}),
2124
- overrideProfile: resolveCurrentOverrideProfile() ?? null,
2125
- actorTrustClass: resolveTurnActorTrustClass(ctx),
2126
- }),
2127
1252
  abortController.signal,
2128
1253
  );
2129
1254
 
@@ -2131,16 +1256,14 @@ export async function runAgentLoopImpl(
2131
1256
  ctx.messages = step.messages;
2132
1257
  currentInjectionMode = step.state.injectionMode;
2133
1258
 
2134
- // See the preflight reducer call above for rationale. Only track when
2135
- // the summary LLM actually ran — `summaryFailed === undefined`
2136
- // indicates the reducer's forced compaction took an early-return path
2137
- // without calling the summary LLM.
1259
+ // Only track when the summary LLM actually ran — `summaryFailed
1260
+ // === undefined` indicates a forced-compaction early-return path that
1261
+ // never called the summary LLM.
2138
1262
  if (
2139
1263
  step.compactionResult &&
2140
1264
  step.compactionResult.summaryFailed !== undefined
2141
1265
  ) {
2142
1266
  await ctx.agentLoop.compactionCircuit.recordOutcome(
2143
- ctx,
2144
1267
  step.compactionResult.summaryFailed,
2145
1268
  onEvent,
2146
1269
  );
@@ -2151,8 +1274,6 @@ export async function runAgentLoopImpl(
2151
1274
  step.compactionResult,
2152
1275
  convergenceCompactionBasis,
2153
1276
  );
2154
- state.shouldInjectWorkspace = true;
2155
- state.reducerCompacted = true;
2156
1277
  }
2157
1278
 
2158
1279
  // Only re-inject the memory-static block when ctx.messages was
@@ -2161,174 +1282,55 @@ export async function runAgentLoopImpl(
2161
1282
  // blocks self-gate inside their injectors on whether they are already
2162
1283
  // present in `ctx.messages`.)
2163
1284
  const injection = await applyRuntimeInjections(ctx.messages, {
2164
- ...injectionOpts,
2165
- workspaceTopLevelContext: buildWorkspaceTopLevelContext(
2166
- ctx,
2167
- state.shouldInjectWorkspace,
2168
- ),
2169
- slackChronologicalMessages: state.reducerCompacted
2170
- ? null
2171
- : injectionOpts.slackChronologicalMessages,
1285
+ isNonInteractive,
1286
+ modelProfile: modelProfileStr,
1287
+ actorContext,
2172
1288
  mode: currentInjectionMode,
2173
- turnContext: buildPluginTurnContext(ctx, reqId),
1289
+ requestId: reqId,
1290
+ conversationId: ctx.conversationId,
2174
1291
  });
2175
1292
  runMessages = injection.messages;
2176
1293
  if (isTrustedActor && currentInjectionMode !== "minimal") {
2177
1294
  ctx.graphMemory.retrackCachedNodes();
2178
1295
  }
2179
- const convergenceStrip = stripHistoricalWebSearchResults(runMessages);
2180
- if (convergenceStrip.stats.blocksStripped > 0) {
2181
- rlog.info(
2182
- { phase: "convergence", ...convergenceStrip.stats },
2183
- "Converted historical web_search_tool_result blocks to text summaries",
2184
- );
2185
- runMessages = convergenceStrip.messages;
2186
- }
2187
1296
  state.contextTooLargeDetected = false;
2188
1297
  yieldedForBudget = false;
2189
1298
 
2190
1299
  updatedHistory = await runAgentLoop(runMessages);
2191
1300
 
2192
- // If the rerun still yields at checkpoint, the turn is still
2193
- // incomplete — continue reducing through the remaining tiers
2194
- // instead of silently dropping the incomplete state.
2195
1301
  if (yieldedForBudget && !abortController.signal.aborted) {
2196
- rlog.warn(
2197
- {
2198
- phase: "convergence",
2199
- attempt: convergenceAttempts,
2200
- appliedTiers: reducerState.appliedTiers,
2201
- },
2202
- "Post-convergence rerun still yielded at checkpoint — continuing reduction",
2203
- );
2204
- state.contextTooLargeDetected = true;
2205
-
2206
- // Fold rerun progress into ctx.messages so the next reducer
2207
- // tier operates on up-to-date history instead of stale
2208
- // pre-rerun messages.
2209
- if (lastRunAppendedNewMessages) {
2210
- ctx.messages = stripInjectionsForCompaction(updatedHistory);
2211
- markHistoryStrippedBestEffort(ctx.conversationId);
2212
- }
2213
- }
2214
- }
2215
-
2216
- // All reducer tiers exhausted but provider still rejects —
2217
- // consult the overflow policy for latest-turn compression.
2218
- // The policy either auto-compresses the latest turn or falls
2219
- // through to the final graceful-error fallback below.
2220
- if (state.contextTooLargeDetected) {
2221
- const action = resolveOverflowAction({
2222
- overflowRecovery,
2223
- isInteractive: isInteractiveResolved,
2224
- });
2225
-
2226
- if (action === "auto_compress_latest_turn") {
2227
- // Auto-compress without asking — users opt out via the "drop" policy.
2228
- ctx.emitActivityState("thinking", "context_compacting", {
2229
- requestId: reqId,
2230
- });
2231
- let emergencyCompact: Awaited<
2232
- ReturnType<typeof ctx.contextWindowManager.maybeCompact>
2233
- > | null = null;
2234
- try {
2235
- emergencyCompact = (await runPipeline<
2236
- CompactionArgs,
2237
- CompactionResult
2238
- >(
2239
- "compaction",
2240
- getMiddlewaresFor("compaction"),
2241
- (args) =>
2242
- defaultCompactionTerminal(
2243
- args,
2244
- buildPluginTurnContext(ctx, reqId),
2245
- ),
1302
+ if (!reducerState.exhausted) {
1303
+ // The rerun still yields and the ladder has a rung left — the turn
1304
+ // is incomplete, so continue reducing instead of silently dropping
1305
+ // the incomplete state.
1306
+ rlog.warn(
2246
1307
  {
2247
- messages: ctx.messages,
2248
- signal: abortController.signal,
2249
- options: {
2250
- force: true,
2251
- minKeepRecentUserTurns: 0,
2252
- overrideProfile: resolveCurrentOverrideProfile() ?? null,
2253
- },
1308
+ phase: "convergence",
1309
+ appliedTiers: reducerState.appliedTiers,
2254
1310
  },
2255
- buildPluginTurnContext(ctx, reqId),
2256
- DEFAULT_TIMEOUTS.compaction,
2257
- )) as Awaited<
2258
- ReturnType<typeof ctx.contextWindowManager.maybeCompact>
2259
- >;
2260
- } catch (err) {
2261
- if (err instanceof PluginTimeoutError) {
2262
- // Emergency compaction timed out. Record the circuit-breaker
2263
- // failure and fall through to the graceful-error path below
2264
- // (the unsuccessful-compaction fallback) rather than hard-
2265
- // failing the turn.
2266
- rlog.warn(
2267
- { err, phase: "emergency-compaction" },
2268
- "Emergency compaction pipeline timed out — continuing with overflow fallback",
2269
- );
2270
- await ctx.agentLoop.compactionCircuit.recordOutcome(
2271
- ctx,
2272
- true,
2273
- onEvent,
2274
- );
2275
- emergencyCompact = null;
2276
- } else {
2277
- throw err;
2278
- }
2279
- }
2280
- // Only track when the summary LLM actually ran; `force: true`
2281
- // bypasses the auto-threshold gate but not the early-return paths.
2282
- if (
2283
- emergencyCompact &&
2284
- emergencyCompact.summaryFailed !== undefined
2285
- ) {
2286
- await ctx.agentLoop.compactionCircuit.recordOutcome(
2287
- ctx,
2288
- emergencyCompact.summaryFailed,
2289
- onEvent,
1311
+ "Post-convergence rerun still yielded at checkpoint — continuing reduction",
2290
1312
  );
2291
- }
2292
- if (emergencyCompact?.compacted) {
2293
- await applySuccessfulCompaction(emergencyCompact, ctx.messages);
2294
- state.reducerCompacted = true;
2295
- state.shouldInjectWorkspace = true;
2296
- }
1313
+ state.contextTooLargeDetected = true;
2297
1314
 
2298
- // Only re-inject the memory-static block when ctx.messages was
2299
- // actually stripped; otherwise the existing block is still present.
2300
- // (The `<knowledge_base>`, NOW.md, and v2 static `<info>` blocks
2301
- // self-gate inside their injectors on whether they are already
2302
- // present in `ctx.messages`.)
2303
- const injection = await applyRuntimeInjections(ctx.messages, {
2304
- ...injectionOpts,
2305
- workspaceTopLevelContext: buildWorkspaceTopLevelContext(
2306
- ctx,
2307
- state.shouldInjectWorkspace,
2308
- ),
2309
- slackChronologicalMessages: state.reducerCompacted
2310
- ? null
2311
- : injectionOpts.slackChronologicalMessages,
2312
- mode: currentInjectionMode,
2313
- turnContext: buildPluginTurnContext(ctx, reqId),
2314
- });
2315
- runMessages = injection.messages;
2316
- if (isTrustedActor && currentInjectionMode !== "minimal") {
2317
- ctx.graphMemory.retrackCachedNodes();
2318
- }
2319
- const fallbackStrip = stripHistoricalWebSearchResults(runMessages);
2320
- if (fallbackStrip.stats.blocksStripped > 0) {
2321
- rlog.info(
2322
- { phase: "fail_gracefully_compact", ...fallbackStrip.stats },
2323
- "Converted historical web_search_tool_result blocks to text summaries",
2324
- );
2325
- runMessages = fallbackStrip.messages;
1315
+ // Fold rerun progress into ctx.messages so the next rung operates
1316
+ // on up-to-date history instead of stale pre-rerun messages.
1317
+ if (lastRunAppendedNewMessages) {
1318
+ ctx.messages = stripInjectionsForCompaction(updatedHistory);
1319
+ markHistoryStrippedBestEffort(ctx.conversationId);
1320
+ }
1321
+ } else if (
1322
+ !reducerState.appliedTiers.includes("auto_compress_latest_turn")
1323
+ ) {
1324
+ // The ladder is exhausted without the terminal auto-compress rung
1325
+ // (the overflow policy disallowed it, or the middle tiers ran out)
1326
+ // and the turn is still over budget. This is a genuine
1327
+ // unrecoverable overflow — surface `context_too_large` via the
1328
+ // fallback below. The terminal auto-compress rung instead leaves
1329
+ // this false so its post-rerun yield reads as
1330
+ // `budget_yield_unrecovered`.
1331
+ state.contextTooLargeDetected = true;
2326
1332
  }
2327
- state.contextTooLargeDetected = false;
2328
-
2329
- updatedHistory = await runAgentLoop(runMessages);
2330
1333
  }
2331
- // action === "fail_gracefully" falls through to the final error below
2332
1334
  }
2333
1335
 
2334
1336
  // Final fallback: all recovery paths exhausted
@@ -2906,10 +1908,7 @@ export async function runAgentLoopImpl(
2906
1908
  // ── Helper ───────────────────────────────────────────────────────────
2907
1909
 
2908
1910
  function emitUsage(
2909
- ctx: Pick<
2910
- AgentLoopConversationContext,
2911
- "conversationId" | "provider" | "usageStats"
2912
- >,
1911
+ ctx: Pick<Conversation, "conversationId" | "provider" | "usageStats">,
2913
1912
  inputTokens: number,
2914
1913
  outputTokens: number,
2915
1914
  model: string,
@@ -2949,17 +1948,18 @@ function emitUsage(
2949
1948
  }
2950
1949
 
2951
1950
  /**
2952
- * Minimal context shape consumed by `applyCompactionResult`. Both
2953
- * `AgentLoopConversationContext` and `Conversation` satisfy this via structural
2954
- * typing, so the helper can back both the 5 agent-loop auto-compaction sites
2955
- * and the single `forceCompact` user-initiated site.
1951
+ * Minimal context shape consumed by `applyCompactionResult`, satisfied by
1952
+ * `Conversation` via structural typing, so the helper can back both the 5
1953
+ * agent-loop auto-compaction sites and the single `forceCompact`
1954
+ * user-initiated site.
2956
1955
  */
2957
1956
  export interface CompactionApplyContext {
2958
1957
  readonly conversationId: string;
2959
1958
  messages: Message[];
2960
1959
  contextCompactedMessageCount: number;
2961
1960
  contextCompactedAt: number | null;
2962
- pendingPostCompactReinject: boolean;
1961
+ contextSummary: string | null;
1962
+ slackContextCompactionWatermarkTs: string | null;
2963
1963
  readonly graphMemory: ConversationGraphMemory;
2964
1964
  readonly provider: Provider;
2965
1965
  usageStats: UsageStats;
@@ -3006,13 +2006,22 @@ export async function applyCompactionResult(
3006
2006
  } = {},
3007
2007
  ): Promise<void> {
3008
2008
  ctx.messages = result.messages;
3009
- ctx.contextCompactedMessageCount += result.compactedPersistedMessages;
2009
+ // Compaction operates on the in-context history. Untrusted actor views
2010
+ // render that history unsliced (boundary 0); trusted views start past the
2011
+ // already-compacted prefix (the mirrored DB count). Advance from that
2012
+ // in-context boundary rather than the raw mirror so the persisted count
2013
+ // stays consistent with what the new summary represents and never
2014
+ // double-counts an unsliced untrusted view.
2015
+ const inContextCompactedCount = isUntrustedTrustClass(
2016
+ ctx.trustContext?.trustClass,
2017
+ )
2018
+ ? 0
2019
+ : ctx.contextCompactedMessageCount;
2020
+ ctx.contextCompactedMessageCount =
2021
+ inContextCompactedCount + result.compactedPersistedMessages;
2022
+ ctx.contextSummary = result.summaryText;
3010
2023
  const compactedAt = Date.now();
3011
2024
  ctx.contextCompactedAt = compactedAt;
3012
- // Signal to the next agent loop turn that NOW.md / PKB / v2 static blocks
3013
- // were stripped from the tail and need fresh re-injection. Consumed and
3014
- // cleared at the top of the next `runAgentLoopImpl` run.
3015
- ctx.pendingPostCompactReinject = true;
3016
2025
  await ctx.graphMemory.onCompacted(result.compactedPersistedMessages);
3017
2026
  updateConversationContextWindow(
3018
2027
  ctx.conversationId,
@@ -3026,6 +2035,8 @@ export async function applyCompactionResult(
3026
2035
  options.slackContextCompactionWatermarkTs,
3027
2036
  compactedAt,
3028
2037
  );
2038
+ ctx.slackContextCompactionWatermarkTs =
2039
+ options.slackContextCompactionWatermarkTs;
3029
2040
  }
3030
2041
  enqueueAutoAnalysisOnCompaction(
3031
2042
  ctx.conversationId,