@vellumai/assistant 0.12.2 → 0.12.3-staging.1

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 (298) hide show
  1. package/.env.example +2 -0
  2. package/AGENTS.md +1 -1
  3. package/docs/architecture/integrations.md +7 -0
  4. package/docs/architecture/memory.md +17 -2
  5. package/docs/credential-execution-service.md +1 -1
  6. package/docs/desktop-browser-cli.md +7 -3
  7. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  9. package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
  10. package/node_modules/@vellumai/environments/src/shell.ts +24 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  12. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  13. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
  14. package/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +1 -0
  15. package/node_modules/@vellumai/service-contracts/src/secret-detection.ts +4 -1
  16. package/openapi.yaml +54 -5
  17. package/package.json +1 -1
  18. package/scripts/smoke-desktop-browser-cli.ts +1 -0
  19. package/scripts/sync-llm-catalog.ts +3 -0
  20. package/scripts/voice-ttft-spike.ts +2 -2
  21. package/src/__tests__/agent-loop.test.ts +259 -0
  22. package/src/__tests__/agent-wake-delegation-prompt.test.ts +64 -2
  23. package/src/__tests__/anthropic-provider.test.ts +55 -0
  24. package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
  25. package/src/__tests__/attachments-store.test.ts +22 -3
  26. package/src/__tests__/channel-approval.test.ts +9 -14
  27. package/src/__tests__/channel-reply-delivery.test.ts +56 -0
  28. package/src/__tests__/chat-credential-redaction.test.ts +23 -0
  29. package/src/__tests__/computer-use-screenshot-attachments.test.ts +411 -0
  30. package/src/__tests__/computer-use-screenshot-selection.test.ts +50 -0
  31. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +2 -0
  32. package/src/__tests__/conversation-agent-loop-overflow.test.ts +2 -0
  33. package/src/__tests__/conversation-agent-loop.test.ts +257 -6
  34. package/src/__tests__/conversation-attachments.test.ts +106 -0
  35. package/src/__tests__/conversation-fork-crud.test.ts +191 -0
  36. package/src/__tests__/conversation-rendered-delegation-state.test.ts +276 -0
  37. package/src/__tests__/credential-execution-client.test.ts +70 -53
  38. package/src/__tests__/db-conversation-tool-surface.test.ts +221 -0
  39. package/src/__tests__/events-tail-route.test.ts +33 -0
  40. package/src/__tests__/history-repair.test.ts +53 -0
  41. package/src/__tests__/inference-profile-session-handler.test.ts +26 -0
  42. package/src/__tests__/list-messages-tool-merge.test.ts +85 -1
  43. package/src/__tests__/llm-catalog-parity.test.ts +27 -4
  44. package/src/__tests__/oauth-apps-routes.test.ts +1 -0
  45. package/src/__tests__/oauth-commands-routes.test.ts +114 -101
  46. package/src/__tests__/oauth-connect-orchestrator.test.ts +2 -0
  47. package/src/__tests__/oauth-provider-serializer.test.ts +1 -0
  48. package/src/__tests__/oauth-providers-routes.test.ts +2 -0
  49. package/src/__tests__/persist-media-references.test.ts +50 -0
  50. package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
  51. package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
  52. package/src/__tests__/schedule-routes.test.ts +20 -0
  53. package/src/__tests__/scheduler-result-notification.test.ts +23 -4
  54. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  55. package/src/__tests__/secret-routes-platform-proxy.test.ts +86 -31
  56. package/src/__tests__/secret-routes-scrub.test.ts +22 -27
  57. package/src/__tests__/secret-scanner.test.ts +20 -0
  58. package/src/__tests__/secure-keys-managed-failover.test.ts +8 -0
  59. package/src/__tests__/secure-keys.test.ts +7 -3
  60. package/src/__tests__/server-tool-pairing.test.ts +107 -0
  61. package/src/__tests__/skills.test.ts +5 -4
  62. package/src/__tests__/subagent-tool-gate-mode.test.ts +226 -0
  63. package/src/__tests__/terminal-tools.test.ts +8 -0
  64. package/src/__tests__/tool-result-follow-up.test.ts +99 -0
  65. package/src/__tests__/tool-result-metadata-plumbing.test.ts +63 -0
  66. package/src/__tests__/unicode.test.ts +36 -0
  67. package/src/agent/loop.ts +37 -12
  68. package/src/agent/tool-result-follow-up.ts +70 -0
  69. package/src/api/attachment-provenance.test.ts +68 -0
  70. package/src/api/computer-use-tool.test.ts +55 -0
  71. package/src/api/computer-use-tool.ts +27 -0
  72. package/src/api/events/assistant-outbound-attachment.ts +3 -0
  73. package/src/api/events/desktop-activity-changed.ts +10 -0
  74. package/src/api/events/question-request.ts +1 -0
  75. package/src/api/index.ts +11 -0
  76. package/src/api/responses/conversation-message.ts +3 -0
  77. package/src/approvals/approval-primitive.ts +5 -2
  78. package/src/approvals/scoped-approval-grants.ts +6 -2
  79. package/src/browser/virtual-desktop-target.ts +1 -2
  80. package/src/cli/commands/__tests__/cli-test-harness.ts +21 -3
  81. package/src/cli/commands/__tests__/plugins.test.ts +20 -1
  82. package/src/cli/commands/__tests__/schedules.test.ts +14 -0
  83. package/src/cli/commands/bash.help.ts +4 -3
  84. package/src/cli/commands/browser.help.ts +5 -1
  85. package/src/cli/commands/credentials.help.ts +3 -3
  86. package/src/cli/commands/oauth/index.help.ts +10 -0
  87. package/src/cli/commands/oauth/providers.ts +12 -0
  88. package/src/cli/commands/oauth/request.test.ts +179 -2
  89. package/src/cli/commands/oauth/request.ts +41 -10
  90. package/src/cli/commands/plugins.ts +12 -5
  91. package/src/cli/commands/schedules.ts +2 -0
  92. package/src/cli/lib/__tests__/inspect-plugin.test.ts +54 -0
  93. package/src/cli/lib/__tests__/install-from-github.test.ts +41 -0
  94. package/src/cli/lib/__tests__/local-plugin-upgrade.test.ts +133 -51
  95. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +8 -2
  96. package/src/cli/lib/bundled-marketplace.json +855 -0
  97. package/src/cli/lib/bundled-plugin-packages.json +783 -1
  98. package/src/cli/lib/inspect-plugin.ts +11 -4
  99. package/src/cli/lib/upgrade-plugin.ts +19 -11
  100. package/src/cli/output.ts +18 -4
  101. package/src/config/__tests__/default-provider.test.ts +4 -0
  102. package/src/config/bundled-skills/schedule/SKILL.md +6 -6
  103. package/src/config/loader.ts +9 -17
  104. package/src/config/platform-identity.ts +8 -8
  105. package/src/config/profile-text-generation.test.ts +51 -0
  106. package/src/config/profile-text-generation.ts +51 -0
  107. package/src/config/schemas/__tests__/memory-v3.test.ts +12 -0
  108. package/src/config/schemas/llm.ts +7 -2
  109. package/src/config/schemas/mcp.ts +5 -1
  110. package/src/config/schemas/memory-v3.ts +12 -0
  111. package/src/credential-execution/ces-runtime.ts +2 -2
  112. package/src/credential-execution/executable-discovery.ts +36 -134
  113. package/src/credential-execution/process-manager.test.ts +20 -24
  114. package/src/credential-execution/process-manager.ts +24 -19
  115. package/src/daemon/__tests__/conversation-tool-setup.test.ts +43 -0
  116. package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +36 -0
  117. package/src/daemon/assistant-attachments.ts +20 -12
  118. package/src/daemon/chat-credential-redaction.ts +16 -1
  119. package/src/daemon/conversation-agent-loop-handlers.ts +74 -3
  120. package/src/daemon/conversation-agent-loop.ts +12 -0
  121. package/src/daemon/conversation-attachments.ts +93 -12
  122. package/src/daemon/conversation-tool-setup.ts +71 -3
  123. package/src/daemon/conversation-turn-finalize.ts +26 -13
  124. package/src/daemon/conversation.ts +72 -17
  125. package/src/daemon/daemon-control.ts +3 -7
  126. package/src/daemon/lifecycle.ts +4 -4
  127. package/src/daemon/mcp-reload-service.ts +1 -1
  128. package/src/daemon/message-types/shared.ts +1 -0
  129. package/src/daemon/orphan-reaper.ts +4 -3
  130. package/src/daemon/persist-media-references.ts +20 -3
  131. package/src/daemon/tool-setup-types.ts +6 -0
  132. package/src/daemon/wake-conversation-ops.ts +50 -15
  133. package/src/desktop/desktop-automation-lease.test.ts +230 -0
  134. package/src/desktop/desktop-automation-lease.ts +90 -6
  135. package/src/desktop/desktop-help.ts +15 -0
  136. package/src/desktop/desktop-stream-bridge.test.ts +65 -10
  137. package/src/desktop/desktop-stream-bridge.ts +3 -3
  138. package/src/desktop/desktop-wallpaper-renderer.ts +132 -0
  139. package/src/desktop/desktop-wallpaper-worker.ts +17 -0
  140. package/src/desktop/desktop-wallpaper.test.ts +48 -7
  141. package/src/desktop/desktop-wallpaper.ts +34 -102
  142. package/src/desktop/virtual-desktop-feature.ts +1 -1
  143. package/src/mcp/__tests__/manager-tool-caps.test.ts +111 -0
  144. package/src/mcp/__tests__/startup.test.ts +30 -7
  145. package/src/mcp/__tests__/tool-caps.test.ts +107 -0
  146. package/src/mcp/manager.ts +168 -101
  147. package/src/mcp/startup.ts +33 -13
  148. package/src/mcp/tool-caps.ts +176 -0
  149. package/src/messaging/provider-message-metadata.ts +3 -3
  150. package/src/notifications/__tests__/copy-composer.test.ts +70 -0
  151. package/src/notifications/copy-composer.ts +11 -3
  152. package/src/notifications/schedule-result-producer.ts +4 -6
  153. package/src/oauth/AGENTS.md +2 -0
  154. package/src/oauth/__tests__/identity-verifier.test.ts +36 -1
  155. package/src/oauth/identity-verifier.ts +24 -0
  156. package/src/oauth/oauth-store.ts +11 -1
  157. package/src/oauth/provider-serializer.ts +1 -0
  158. package/src/oauth/seed-providers.ts +12 -0
  159. package/src/permissions/confirmation-guardian-request.test.ts +16 -0
  160. package/src/permissions/confirmation-guardian-request.ts +2 -3
  161. package/src/permissions/question-prompter.test.ts +31 -0
  162. package/src/permissions/question-prompter.ts +2 -0
  163. package/src/persistence/attachments-store.ts +84 -44
  164. package/src/persistence/conversation-crud.ts +111 -20
  165. package/src/persistence/conversation-plugin-facade.ts +30 -0
  166. package/src/persistence/conversation-tool-surface.ts +135 -0
  167. package/src/persistence/conversation-types.test.ts +32 -0
  168. package/src/persistence/conversation-types.ts +26 -4
  169. package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +78 -0
  170. package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +29 -0
  171. package/src/persistence/migrations/379-oauth-providers-response-ok-field.test.ts +92 -0
  172. package/src/persistence/migrations/379-oauth-providers-response-ok-field.ts +32 -0
  173. package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.test.ts +90 -0
  174. package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.ts +34 -0
  175. package/src/persistence/schema/conversation-tool-surfaces.ts +32 -0
  176. package/src/persistence/schema/index.ts +1 -0
  177. package/src/persistence/schema/oauth.ts +1 -0
  178. package/src/persistence/steps.ts +24 -3
  179. package/src/plugin-api/conversation-turn.ts +31 -7
  180. package/src/plugin-api/index.ts +11 -1
  181. package/src/plugin-api/plugin-channel-turn-trust.test.ts +133 -0
  182. package/src/plugin-api/plugin-channel-turn-trust.ts +71 -0
  183. package/src/plugins/defaults/memory/AGENTS.md +39 -5
  184. package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +365 -0
  185. package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +43 -0
  186. package/src/plugins/defaults/memory/__tests__/conversation-memory-purge.test.ts +1 -0
  187. package/src/plugins/defaults/memory/__tests__/db-memory-attach.test.ts +2 -0
  188. package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +17 -0
  189. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +74 -0
  190. package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +161 -0
  191. package/src/plugins/defaults/memory/__tests__/relocated-memory-test-rows.ts +10 -0
  192. package/src/plugins/defaults/memory/buffer-file.ts +354 -0
  193. package/src/plugins/defaults/memory/buffer-format.ts +40 -0
  194. package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -2
  195. package/src/plugins/defaults/memory/context-search/format.ts +2 -1
  196. package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -1
  197. package/src/plugins/defaults/memory/context-search/sources/workspace.ts +2 -1
  198. package/src/plugins/defaults/memory/conversation-memory-purge.ts +4 -0
  199. package/src/plugins/defaults/memory/graph/capability-seed.ts +1 -2
  200. package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -42
  201. package/src/plugins/defaults/memory/host-utils.ts +0 -10
  202. package/src/plugins/defaults/memory/injectors.ts +4 -3
  203. package/src/plugins/defaults/memory/memory-retrospective-job.ts +72 -183
  204. package/src/plugins/defaults/memory/memory-run-evidence.ts +213 -0
  205. package/src/plugins/defaults/memory/src/memory-item-routes.test.ts +1 -1
  206. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +407 -99
  207. package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +10 -0
  208. package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +107 -7
  209. package/src/plugins/defaults/memory/substrate/consolidation-job.ts +307 -86
  210. package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +34 -0
  211. package/src/plugins/defaults/memory/substrate/page-index.ts +2 -1
  212. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +89 -49
  213. package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
  214. package/src/plugins/defaults/memory/tools.ts +1 -1
  215. package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
  216. package/src/plugins/defaults/memory/v1/graph/extraction.ts +2 -1
  217. package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
  218. package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +5 -0
  219. package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +5 -2
  220. package/src/plugins/defaults/memory/v2/reranker.ts +2 -1
  221. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +81 -1
  222. package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +87 -0
  223. package/src/plugins/defaults/memory/v3/__tests__/plugin-schema.test.ts +12 -0
  224. package/src/plugins/defaults/memory/v3/__tests__/pool-log-store.test.ts +183 -4
  225. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +103 -2
  226. package/src/plugins/defaults/memory/v3/card.ts +2 -1
  227. package/src/plugins/defaults/memory/v3/injector.ts +212 -178
  228. package/src/plugins/defaults/memory/v3/orchestrate.ts +112 -25
  229. package/src/plugins/defaults/memory/v3/plugin-schema.ts +54 -4
  230. package/src/plugins/defaults/memory/v3/pool-log-store.ts +253 -1
  231. package/src/plugins/defaults/memory/v3/pool-select.test.ts +35 -2
  232. package/src/plugins/defaults/memory/v3/pool-select.ts +31 -19
  233. package/src/plugins/defaults/memory/v3/sections.ts +2 -1
  234. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +41 -14
  235. package/src/plugins/defaults/tool-error/hooks/post-tool-use.ts +4 -1
  236. package/src/plugins/defaults/tool-result-truncate/terminal.ts +1 -46
  237. package/src/prompts/__tests__/parallel-tasks-section.test.ts +25 -0
  238. package/src/prompts/delegation-gate.ts +57 -0
  239. package/src/prompts/system-prompt.ts +14 -32
  240. package/src/providers/inference/adapter-factory.ts +6 -0
  241. package/src/providers/jev/client.test.ts +260 -0
  242. package/src/providers/jev/client.ts +518 -0
  243. package/src/providers/model-catalog.ts +58 -3
  244. package/src/providers/server-tool-pairing.ts +16 -7
  245. package/src/runtime/AGENTS.md +2 -2
  246. package/src/runtime/__tests__/agent-wake.test.ts +94 -1
  247. package/src/runtime/agent-wake.ts +31 -4
  248. package/src/runtime/guardian-action-service.ts +2 -17
  249. package/src/runtime/guardian-reply-router.ts +1 -8
  250. package/src/runtime/http-server.ts +2 -2
  251. package/src/runtime/migrations/__tests__/vbundle-import-policy.test.ts +89 -0
  252. package/src/runtime/migrations/vbundle-import-policy.ts +33 -11
  253. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +24 -2
  254. package/src/runtime/routes/channel-route-shared.ts +1 -9
  255. package/src/runtime/routes/conversation-routes.ts +29 -4
  256. package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
  257. package/src/runtime/routes/desktop-setup-routes.ts +8 -5
  258. package/src/runtime/routes/guardian-approval-interception.ts +24 -0
  259. package/src/runtime/routes/inbound-message-handler.ts +2 -3
  260. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
  261. package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -4
  262. package/src/runtime/routes/inference-profile-session-handler.ts +11 -1
  263. package/src/runtime/routes/inference-profiles-routes.ts +9 -0
  264. package/src/runtime/routes/oauth-commands-routes.ts +57 -54
  265. package/src/runtime/routes/oauth-providers.ts +4 -0
  266. package/src/runtime/routes/oauth-request-hints.test.ts +246 -0
  267. package/src/runtime/routes/oauth-request-hints.ts +149 -0
  268. package/src/runtime/routes/schedule-routes.ts +2 -0
  269. package/src/runtime/routes/secret-routes.ts +99 -119
  270. package/src/schedule/__tests__/worker-mcp-bootstrap.test.ts +107 -0
  271. package/src/schedule/__tests__/worker-mcp-readiness.test.ts +173 -0
  272. package/src/schedule/__tests__/worker-mcp-tools.test.ts +13 -47
  273. package/src/schedule/run-script.ts +2 -2
  274. package/src/schedule/scheduler.ts +47 -11
  275. package/src/schedule/tool-surface-readiness.ts +65 -0
  276. package/src/schedule/worker-mcp.ts +85 -0
  277. package/src/schedule/worker.ts +9 -31
  278. package/src/security/secure-keys.ts +60 -48
  279. package/src/tools/ask-question/ask-question-tool.test.ts +167 -5
  280. package/src/tools/ask-question/ask-question-tool.ts +119 -23
  281. package/src/tools/browser/browser-execution.ts +7 -1
  282. package/src/tools/host-terminal/host-shell.ts +12 -6
  283. package/src/tools/shared/filesystem/file-ops-service.ts +1 -31
  284. package/src/tools/shared/shell-output.test.ts +10 -0
  285. package/src/tools/shared/shell-output.ts +14 -2
  286. package/src/tools/skills/sandbox-runner.ts +13 -2
  287. package/src/tools/skills/scaffold-managed.ts +2 -1
  288. package/src/tools/terminal/__tests__/safe-env.test.ts +33 -4
  289. package/src/tools/terminal/__tests__/sanitized-bash.test.ts +0 -14
  290. package/src/tools/terminal/safe-env.ts +35 -24
  291. package/src/tools/terminal/sanitized-bash.ts +15 -2
  292. package/src/tools/terminal/shell-launch.test.ts +162 -0
  293. package/src/tools/terminal/shell.test.ts +29 -0
  294. package/src/tools/terminal/shell.ts +13 -7
  295. package/src/util/browser-human-verification.ts +14 -0
  296. package/src/util/host-process.test.ts +17 -1
  297. package/src/util/host-process.ts +24 -0
  298. package/src/util/unicode.ts +29 -0
@@ -66,6 +66,40 @@ mock.module("../daemon/conversation-skill-tools.js", () => ({
66
66
  })),
67
67
  }));
68
68
 
69
+ // Records every wire tool surface the resolver persists (conversation id +
70
+ // tool names + the hash it already knew), and lets a test make the write fail.
71
+ type RecordedSurface = {
72
+ tools: ToolDefinition[];
73
+ delegateIndependentTasks: boolean | null;
74
+ };
75
+ let recordedSurfaces: Array<{
76
+ conversationId: string;
77
+ toolNames: string[];
78
+ delegateIndependentTasks: boolean | null;
79
+ knownHash: string | undefined;
80
+ }> = [];
81
+ let recordSurfaceThrows: Error | null = null;
82
+ mock.module("../persistence/conversation-tool-surface.js", () => ({
83
+ recordConversationToolSurface: (
84
+ conversationId: string,
85
+ surface: RecordedSurface,
86
+ knownHash?: string,
87
+ ) => {
88
+ recordedSurfaces.push({
89
+ conversationId,
90
+ toolNames: surface.tools.map((t) => t.name),
91
+ delegateIndependentTasks: surface.delegateIndependentTasks,
92
+ knownHash,
93
+ });
94
+ if (recordSurfaceThrows) {
95
+ throw recordSurfaceThrows;
96
+ }
97
+ return `hash:${surface.tools.map((t) => t.name).join(",")}`;
98
+ },
99
+ hashConversationToolSurface: (surface: RecordedSurface) =>
100
+ `hash:${surface.tools.map((t) => t.name).join(",")}`,
101
+ }));
102
+
69
103
  // ---------------------------------------------------------------------------
70
104
  // Imports after mocks are in place
71
105
  // ---------------------------------------------------------------------------
@@ -75,6 +109,7 @@ import { createSurfaceMutex } from "../daemon/conversation-surfaces.js";
75
109
  import {
76
110
  createResolveToolsCallback,
77
111
  createToolExecutor,
112
+ createWireToolSurfaceRecorder,
78
113
  isRefusedInReadOnlyPass,
79
114
  type SubagentToolStats,
80
115
  } from "../daemon/conversation-tool-setup.js";
@@ -1117,3 +1152,194 @@ describe("createResolveToolsCallback — read-only hides dynamic MCP tools", ()
1117
1152
  expect(resolve(EMPTY_HISTORY).map((t) => t.name)).toContain("srv_send");
1118
1153
  });
1119
1154
  });
1155
+
1156
+ // ---------------------------------------------------------------------------
1157
+ // Resolver: wire tool surface record + replay (fork cache parity)
1158
+ // ---------------------------------------------------------------------------
1159
+
1160
+ describe("createResolveToolsCallback: wire tool surface record and replay", () => {
1161
+ beforeEach(() => {
1162
+ recordedSurfaces = [];
1163
+ recordSurfaceThrows = null;
1164
+ projectedSkillToolNames = [];
1165
+ });
1166
+
1167
+ test("the resolver itself never records: out-of-band callers read it too", () => {
1168
+ const toolDefs = [makeToolDef("remember"), makeToolDef("tool_b")];
1169
+ const ctx = makeProjectionCtx({ conversationId: "conv-live" });
1170
+ const resolve = createResolveToolsCallback(toolDefs, ctx)!;
1171
+
1172
+ // The `/compact` token count resolves outside any turn, where presence
1173
+ // reads clientless; recording here would overwrite the sent surface.
1174
+ resolve(EMPTY_HISTORY);
1175
+
1176
+ expect(recordedSurfaces).toEqual([]);
1177
+ expect(ctx.recordedToolSurfaceHash).toBeUndefined();
1178
+ });
1179
+
1180
+ test("the send-boundary recorder records the sent array, keyed by conversation, and remembers its hash", () => {
1181
+ const ctx = makeProjectionCtx({
1182
+ conversationId: "conv-live",
1183
+ renderedDelegateIndependentTasks: true,
1184
+ });
1185
+ const record = createWireToolSurfaceRecorder(ctx);
1186
+ const sent = [makeToolDef("remember"), makeToolDef("web_search")];
1187
+
1188
+ record(sent);
1189
+ record(sent);
1190
+
1191
+ // Every provider call records; the persistence layer dedupes on the hash
1192
+ // handed back from the previous call.
1193
+ expect(recordedSurfaces).toEqual([
1194
+ {
1195
+ conversationId: "conv-live",
1196
+ toolNames: ["remember", "web_search"],
1197
+ delegateIndependentTasks: true,
1198
+ knownHash: undefined,
1199
+ },
1200
+ {
1201
+ conversationId: "conv-live",
1202
+ toolNames: ["remember", "web_search"],
1203
+ delegateIndependentTasks: true,
1204
+ knownHash: "hash:remember,web_search",
1205
+ },
1206
+ ]);
1207
+ expect(ctx.recordedToolSurfaceHash).toBe("hash:remember,web_search");
1208
+ });
1209
+
1210
+ test("the recorder records the state the prompt build captured, never a re-derivation", () => {
1211
+ const sent = [makeToolDef("remember")];
1212
+
1213
+ // The loop sends the prompt built before the run, so the captured state
1214
+ // is recorded as is even where the live scope would now derive the
1215
+ // opposite: a spawn path excluded after the build (here, an allowlist
1216
+ // with none) still records the section on...
1217
+ createWireToolSurfaceRecorder(
1218
+ makeProjectionCtx({
1219
+ conversationId: "conv-captured-on",
1220
+ subagentAllowedTools: new Set(["remember"]),
1221
+ renderedDelegateIndependentTasks: true,
1222
+ }),
1223
+ )(sent);
1224
+ // ...and a path restored after the build still records it off.
1225
+ createWireToolSurfaceRecorder(
1226
+ makeProjectionCtx({
1227
+ conversationId: "conv-captured-off",
1228
+ renderedDelegateIndependentTasks: false,
1229
+ }),
1230
+ )(sent);
1231
+ // A verbatim system-prompt override captures unknown.
1232
+ createWireToolSurfaceRecorder(
1233
+ makeProjectionCtx({
1234
+ conversationId: "conv-override",
1235
+ renderedDelegateIndependentTasks: null,
1236
+ }),
1237
+ )(sent);
1238
+ // No prompt built yet: unknown as well.
1239
+ createWireToolSurfaceRecorder(
1240
+ makeProjectionCtx({ conversationId: "conv-unbuilt" }),
1241
+ )(sent);
1242
+
1243
+ expect(
1244
+ recordedSurfaces.map((r) => [
1245
+ r.conversationId,
1246
+ r.delegateIndependentTasks,
1247
+ ]),
1248
+ ).toEqual([
1249
+ ["conv-captured-on", true],
1250
+ ["conv-captured-off", false],
1251
+ ["conv-override", null],
1252
+ ["conv-unbuilt", null],
1253
+ ]);
1254
+ });
1255
+
1256
+ test("a failed record is swallowed and still counts as recorded", () => {
1257
+ recordSurfaceThrows = new Error("no such table");
1258
+ const record = createWireToolSurfaceRecorder(
1259
+ makeProjectionCtx({ conversationId: "conv-live" }),
1260
+ );
1261
+
1262
+ expect(() => record([makeToolDef("remember")])).not.toThrow();
1263
+ record([makeToolDef("remember")]);
1264
+
1265
+ // The second call carries the hash from the failed first one, so a
1266
+ // persistent failure is retried once per distinct surface, not per call.
1267
+ expect(recordedSurfaces.map((r) => r.knownHash)).toEqual([
1268
+ undefined,
1269
+ "hash:remember",
1270
+ ]);
1271
+ });
1272
+
1273
+ test("the recorder skips arrays that are not the conversation's own surface", () => {
1274
+ const tools = [makeToolDef("remember")];
1275
+
1276
+ // A replaying wake sends its source's array.
1277
+ createWireToolSurfaceRecorder(
1278
+ makeProjectionCtx({ conversationId: "conv-fork", wireToolReplay: tools }),
1279
+ )(tools);
1280
+ // A tools-disabled call sends nothing; a fork replaying [] could never
1281
+ // call remember.
1282
+ createWireToolSurfaceRecorder(
1283
+ makeProjectionCtx({ conversationId: "conv-live" }),
1284
+ )([]);
1285
+ // Disk-pressure cleanup narrows the wire to cleanup tools.
1286
+ createWireToolSurfaceRecorder(
1287
+ makeProjectionCtx({
1288
+ conversationId: "conv-live",
1289
+ diskPressureCleanupModeActive: true,
1290
+ }),
1291
+ )(tools);
1292
+
1293
+ expect(recordedSurfaces).toEqual([]);
1294
+ });
1295
+
1296
+ test("replays wireToolReplay verbatim on the wire while execution still derives from the fork's own context", () => {
1297
+ projectedSkillToolNames = ["scaffold_managed_skill"];
1298
+ const replay: ToolDefinition[] = [
1299
+ makeToolDef("bash"),
1300
+ makeToolDef("remember"),
1301
+ makeToolDef("bell_jingle"),
1302
+ {
1303
+ type: "web_search_20250305",
1304
+ name: "web_search",
1305
+ max_uses: 5,
1306
+ } as unknown as ToolDefinition,
1307
+ ];
1308
+ // The fork's own registry lacks bell_jingle (a user-plugin tool the
1309
+ // worker never loads) and web_search (a server tool the loop appends).
1310
+ const toolDefs = [makeToolDef("remember"), makeToolDef("bash")];
1311
+ const ctx = makeProjectionCtx({
1312
+ conversationId: "conv-fork",
1313
+ subagentAllowedTools: new Set(["remember"]),
1314
+ subagentToolGateMode: "execution",
1315
+ wireToolReplay: replay,
1316
+ });
1317
+ const resolve = createResolveToolsCallback(toolDefs, ctx)!;
1318
+
1319
+ const tools = resolve(EMPTY_HISTORY);
1320
+
1321
+ // Byte-identical to the source's recorded array, in the source's order,
1322
+ // including definitions this process could never resolve.
1323
+ expect(JSON.stringify(tools)).toBe(JSON.stringify(replay));
1324
+ // Execution-side inventory is still the fork's own derivation (plus the
1325
+ // preactivated skill tools), never widened by the replayed definitions.
1326
+ expect(ctx.allowedToolNames).toEqual(
1327
+ new Set(["remember", "bash", "scaffold_managed_skill"]),
1328
+ );
1329
+ });
1330
+
1331
+ test("replay returns a fresh array each call so the loop cannot mutate the recorded one", () => {
1332
+ const replay = [makeToolDef("remember")];
1333
+ const ctx = makeProjectionCtx({
1334
+ conversationId: "conv-fork",
1335
+ wireToolReplay: replay,
1336
+ });
1337
+ const resolve = createResolveToolsCallback([makeToolDef("remember")], ctx)!;
1338
+
1339
+ const first = resolve(EMPTY_HISTORY);
1340
+ first.push(makeToolDef("injected"));
1341
+
1342
+ expect(resolve(EMPTY_HISTORY).map((t) => t.name)).toEqual(["remember"]);
1343
+ expect(replay.map((t) => t.name)).toEqual(["remember"]);
1344
+ });
1345
+ });
@@ -44,6 +44,7 @@ import {
44
44
  formatShellOutput,
45
45
  MAX_OUTPUT_LENGTH,
46
46
  OUTPUT_TRUNCATED_TAG,
47
+ SHELL_DID_NOT_START_MESSAGE,
47
48
  } from "../tools/shared/shell-output.js";
48
49
  import {
49
50
  ALWAYS_INJECTED_ENV_VARS,
@@ -334,6 +335,13 @@ describe("formatShellOutput", () => {
334
335
  expect(result.isError).toBe(false);
335
336
  });
336
337
 
338
+ test("a close with exit 0 and empty pipes is an error when the process never started", () => {
339
+ const result = formatShellOutput("", "", 0, false, 120, { started: false });
340
+ expect(result.content).toBe(SHELL_DID_NOT_START_MESSAGE);
341
+ expect(result.isError).toBe(true);
342
+ expect(result.content).not.toBe("<command_completed />");
343
+ });
344
+
337
345
  test("failed command with no output shows exit code tag and descriptive message", () => {
338
346
  const result = formatShellOutput("", "", 1, false, 120);
339
347
  expect(result.content).toContain('<command_exit code="1" />');
@@ -0,0 +1,99 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { buildToolResultFollowUp } from "../agent/tool-result-follow-up.js";
4
+ import type { ContentBlock, Message, TextContent } from "../providers/types.js";
5
+
6
+ const opener: Message = {
7
+ role: "user",
8
+ content: [{ type: "text", text: "Check both" }],
9
+ };
10
+
11
+ /** Assistant turn calling two client tools and a native web search together. */
12
+ const mixedTurn: Message = {
13
+ role: "assistant",
14
+ content: [
15
+ { type: "tool_use", id: "tu_a", name: "web_fetch", input: {} },
16
+ { type: "tool_use", id: "tu_b", name: "file_read", input: {} },
17
+ {
18
+ type: "server_tool_use",
19
+ id: "srvtoolu_deferred",
20
+ name: "web_search",
21
+ input: { query: "news" },
22
+ },
23
+ ],
24
+ };
25
+
26
+ /** The same turn without the search: nothing is deferred. */
27
+ const clientOnlyTurn: Message = {
28
+ role: "assistant",
29
+ content: mixedTurn.content.filter((b) => b.type === "tool_use"),
30
+ };
31
+
32
+ const erroredResult: ContentBlock = {
33
+ type: "tool_result",
34
+ tool_use_id: "tu_a",
35
+ content: "Error: HTTP 404",
36
+ is_error: true,
37
+ };
38
+
39
+ const okResult: ContentBlock = {
40
+ type: "tool_result",
41
+ tool_use_id: "tu_b",
42
+ content: "file contents",
43
+ is_error: false,
44
+ };
45
+
46
+ const guidance: TextContent = {
47
+ type: "text",
48
+ text: "<system_notice>retry</system_notice>",
49
+ };
50
+
51
+ describe("buildToolResultFollowUp", () => {
52
+ test("returns the results untouched when there is no guidance", () => {
53
+ const results = [erroredResult, okResult];
54
+
55
+ expect(buildToolResultFollowUp([opener, mixedTurn], results, [])).toBe(
56
+ results,
57
+ );
58
+ });
59
+
60
+ test("appends guidance as separate blocks when no server tool is deferred", () => {
61
+ const followUp = buildToolResultFollowUp(
62
+ [opener, clientOnlyTurn],
63
+ [erroredResult, okResult],
64
+ [guidance],
65
+ );
66
+
67
+ expect(followUp).toEqual([erroredResult, okResult, guidance]);
68
+ });
69
+
70
+ test("folds guidance into the errored tool_result when a server tool is deferred", () => {
71
+ const followUp = buildToolResultFollowUp(
72
+ [opener, mixedTurn],
73
+ [erroredResult, okResult],
74
+ [guidance],
75
+ );
76
+
77
+ expect(followUp.map((b) => b.type)).toEqual(["tool_result", "tool_result"]);
78
+ expect(followUp[0]).toEqual({
79
+ ...erroredResult,
80
+ content: `Error: HTTP 404\n\n${guidance.text}`,
81
+ });
82
+ expect(followUp[1]).toBe(okResult);
83
+ });
84
+
85
+ test("falls back to the last tool_result when none errored", () => {
86
+ const followUp = buildToolResultFollowUp(
87
+ [opener, mixedTurn],
88
+ [okResult, { ...okResult, tool_use_id: "tu_a" }],
89
+ [guidance],
90
+ );
91
+
92
+ expect(followUp.map((b) => b.type)).toEqual(["tool_result", "tool_result"]);
93
+ expect(followUp[0]).toBe(okResult);
94
+ expect(followUp[1]).toMatchObject({
95
+ tool_use_id: "tu_a",
96
+ content: `file contents\n\n${guidance.text}`,
97
+ });
98
+ });
99
+ });
@@ -27,6 +27,7 @@ mock.module("../persistence/llm-request-log-store.js", () => ({
27
27
 
28
28
  // ── Imports (after mocks) ─────────────────────────────────────────────────────
29
29
  import type { AssistantEvent } from "../api/index.js";
30
+ import { toolImageFilename } from "../daemon/assistant-attachments.js";
30
31
  import type {
31
32
  EventHandlerDeps,
32
33
  EventHandlerState,
@@ -34,6 +35,7 @@ import type {
34
35
  import {
35
36
  createEventHandlerState,
36
37
  handleToolResult,
38
+ handleToolUse,
37
39
  } from "../daemon/conversation-agent-loop-handlers.js";
38
40
  import type { ToolActivityMetadata } from "../daemon/message-types/web-activity.js";
39
41
 
@@ -139,3 +141,64 @@ describe("tool_result activityMetadata plumbing", () => {
139
141
  expect(toolResultEvent?.activityMetadata).toBeUndefined();
140
142
  });
141
143
  });
144
+
145
+ describe("computer-use screenshot capture", () => {
146
+ test("tracks direct and wrapped invocations but excludes their images from ordinary promotion", () => {
147
+ const state = createEventHandlerState();
148
+ const { deps } = createCollectorDeps();
149
+
150
+ handleToolUse(state, deps, {
151
+ type: "tool_use",
152
+ id: "direct-call",
153
+ name: "computer_use_click",
154
+ input: { x: 10, y: 20 },
155
+ });
156
+ handleToolUse(state, deps, {
157
+ type: "tool_use",
158
+ id: "wrapped-call",
159
+ name: "skill_execute",
160
+ input: { tool: "computer_use_scroll" },
161
+ });
162
+ handleToolUse(state, deps, {
163
+ type: "tool_use",
164
+ id: "ordinary-call",
165
+ name: "browser_screenshot",
166
+ input: {},
167
+ });
168
+ handleToolResult(state, deps, {
169
+ type: "tool_result",
170
+ toolUseId: "wrapped-call",
171
+ content: "scrolled",
172
+ isError: false,
173
+ contentBlocks: [
174
+ {
175
+ type: "image",
176
+ source: {
177
+ type: "base64",
178
+ media_type: "image/png",
179
+ data: "c2NyZWVuc2hvdA==",
180
+ },
181
+ },
182
+ ],
183
+ });
184
+
185
+ expect(state.computerUseToolUseIds).toEqual([
186
+ "direct-call",
187
+ "wrapped-call",
188
+ ]);
189
+ expect(state.computerUseToolNames).toEqual(
190
+ new Map([
191
+ ["direct-call", "computer_use_click"],
192
+ ["wrapped-call", "computer_use_scroll"],
193
+ ]),
194
+ );
195
+ expect(
196
+ toolImageFilename(
197
+ "image/png",
198
+ state.computerUseToolNames.get("wrapped-call"),
199
+ ),
200
+ ).toBe("computer-use-scroll.png");
201
+ expect(state.computerUseScreenshotBlocks.has("wrapped-call")).toBe(true);
202
+ expect(state.accumulatedToolContentBlocks).toEqual([]);
203
+ });
204
+ });
@@ -4,6 +4,7 @@ import {
4
4
  safeStringSlice,
5
5
  stripOrphanedSurrogates,
6
6
  stripOrphanedSurrogatesDeep,
7
+ surrogateSafeWindow,
7
8
  } from "../util/unicode.js";
8
9
 
9
10
  // U+1F389 PARTY POPPER = "\uD83C\uDF89" (a surrogate pair).
@@ -302,3 +303,38 @@ describe("stripOrphanedSurrogatesDeep", () => {
302
303
  expect(() => JSON.parse(json)).not.toThrow();
303
304
  });
304
305
  });
306
+
307
+ describe("surrogateSafeWindow", () => {
308
+ const codeAt = (text: string) => (i: number) => text.charCodeAt(i);
309
+
310
+ test("a start that lands on a low surrogate backs up to include the pair", () => {
311
+ const text = `a${EMOJI}b`;
312
+ expect(surrogateSafeWindow(text.length, codeAt(text), 2, 10)).toEqual({
313
+ start: 1,
314
+ end: 4,
315
+ });
316
+ });
317
+
318
+ test("an end that would cut a pair backs off one unit", () => {
319
+ const text = `ab${EMOJI}cd`;
320
+ expect(surrogateSafeWindow(text.length, codeAt(text), 0, 3)).toEqual({
321
+ start: 0,
322
+ end: 2,
323
+ });
324
+ });
325
+
326
+ test("a one-unit window on a pair takes the whole pair so paging advances", () => {
327
+ const text = `${EMOJI}x`;
328
+ expect(surrogateSafeWindow(text.length, codeAt(text), 0, 1)).toEqual({
329
+ start: 0,
330
+ end: 2,
331
+ });
332
+ });
333
+
334
+ test("clamps to the text bounds", () => {
335
+ expect(surrogateSafeWindow(3, codeAt("abc"), 10, 5)).toEqual({
336
+ start: 3,
337
+ end: 3,
338
+ });
339
+ });
340
+ });
package/src/agent/loop.ts CHANGED
@@ -42,6 +42,7 @@ import type {
42
42
  Provider,
43
43
  ProviderResponse,
44
44
  SendMessageOptions,
45
+ TextContent,
45
46
  ToolDefinition,
46
47
  ToolResultContent,
47
48
  } from "../providers/types.js";
@@ -74,6 +75,7 @@ import {
74
75
  isRepairableOrderingError,
75
76
  isUserTerminalHistoryError,
76
77
  } from "./history-repair/history-repair.js";
78
+ import { buildToolResultFollowUp } from "./tool-result-follow-up.js";
77
79
 
78
80
  const log = getLogger("agent-loop");
79
81
 
@@ -1047,6 +1049,17 @@ export interface AgentLoopConstructorOptions {
1047
1049
  tools?: ToolDefinition[];
1048
1050
  toolExecutor?: LoopToolExecutor;
1049
1051
  resolveTools?: (history: Message[]) => ToolDefinition[];
1052
+ /**
1053
+ * Observer for the final tool array of each provider call, invoked
1054
+ * immediately before the request leaves with exactly what goes on the wire
1055
+ * (after any provider-native tool is appended, past the inter-call throttle
1056
+ * and the pre-model hooks, and not at all once the run is aborted). This is
1057
+ * the only point that sees the sent array: the dynamic `resolveTools`
1058
+ * callback is also consulted out of band (token counting, compaction
1059
+ * estimates), so a consumer that needs "what the last request sent"
1060
+ * subscribes here rather than wrapping the resolver. Must not throw.
1061
+ */
1062
+ onToolsSent?: (tools: ToolDefinition[]) => void;
1050
1063
  /**
1051
1064
  * Conversation this loop drives. Scopes the loop-held compaction circuit
1052
1065
  * breaker and is the source of truth the loop's pipeline contexts and
@@ -1087,6 +1100,7 @@ export class AgentLoop {
1087
1100
  private config: AgentLoopConfig;
1088
1101
  private tools: ToolDefinition[];
1089
1102
  private resolveTools: ((history: Message[]) => ToolDefinition[]) | null;
1103
+ private onToolsSent: ((tools: ToolDefinition[]) => void) | null;
1090
1104
  private toolExecutor: LoopToolExecutor | null;
1091
1105
 
1092
1106
  /**
@@ -1122,6 +1136,7 @@ export class AgentLoop {
1122
1136
  tools,
1123
1137
  toolExecutor,
1124
1138
  resolveTools,
1139
+ onToolsSent,
1125
1140
  conversationId,
1126
1141
  resolveConversationDir,
1127
1142
  transformCompactedHistory,
@@ -1131,6 +1146,7 @@ export class AgentLoop {
1131
1146
  this.config = { ...DEFAULT_CONFIG, ...config };
1132
1147
  this.tools = tools ?? [];
1133
1148
  this.resolveTools = resolveTools ?? null;
1149
+ this.onToolsSent = onToolsSent ?? null;
1134
1150
  this.toolExecutor = toolExecutor ?? null;
1135
1151
  this.conversationId = conversationId;
1136
1152
  this.resolveConversationDir = resolveConversationDir ?? null;
@@ -1544,7 +1560,7 @@ export class AgentLoop {
1544
1560
  turn: number,
1545
1561
  ): Promise<{
1546
1562
  resultBlocks: ContentBlock[];
1547
- additionalContextBlocks: ContentBlock[];
1563
+ additionalContextBlocks: TextContent[];
1548
1564
  }> => {
1549
1565
  if (conversationDir) {
1550
1566
  const toolCallByUseId = new Map(
@@ -1569,7 +1585,7 @@ export class AgentLoop {
1569
1585
  180_000;
1570
1586
 
1571
1587
  const resultBlocks: ContentBlock[] = [];
1572
- const additionalContextBlocks: ContentBlock[] = [];
1588
+ const additionalContextBlocks: TextContent[] = [];
1573
1589
  for (const block of rawBlocks) {
1574
1590
  if (block.type !== "tool_result") {
1575
1591
  resultBlocks.push(block);
@@ -2241,6 +2257,11 @@ export class AgentLoop {
2241
2257
  // Latency: the request is about to leave for the provider. The span
2242
2258
  // from here to the first streamed token is time-to-first-token.
2243
2259
  latencyTracker?.mark("request_sent");
2260
+ // Every await that could cancel the call is behind us; a run aborted
2261
+ // during the throttle or a hook never reports an array it did not send.
2262
+ if (!signal?.aborted) {
2263
+ this.onToolsSent?.(currentTools);
2264
+ }
2244
2265
  let response: ProviderResponse;
2245
2266
  try {
2246
2267
  response = await traceAsyncSection("agent-loop:provider-send", () =>
@@ -2945,16 +2966,20 @@ export class AgentLoop {
2945
2966
 
2946
2967
  toolUseTurns++;
2947
2968
 
2948
- // Append any guidance a post-tool-use hook surfaced via
2949
- // `additionalContext` (e.g. tool-error retry coaching) as separate
2950
- // blocks. They join the provider-bound history below but were not part
2951
- // of the tool_result events emitted above, so the model sees the
2952
- // guidance while the client-facing and persisted tool output stay the
2953
- // tool's actual result.
2954
- resultBlocks.push(...additionalContextBlocks);
2955
-
2956
- // Add tool results as a user message and continue the loop.
2957
- history.push({ role: "user", content: resultBlocks });
2969
+ // Add the tool results, plus any guidance a post-tool-use hook
2970
+ // surfaced via `additionalContext` (e.g. tool-error retry coaching),
2971
+ // as a user message and continue the loop. The guidance joins the
2972
+ // provider-bound history only: it was not part of the tool_result
2973
+ // events emitted above, so the client-facing and persisted tool
2974
+ // output stay the tool's actual result.
2975
+ history.push({
2976
+ role: "user",
2977
+ content: buildToolResultFollowUp(
2978
+ history,
2979
+ resultBlocks,
2980
+ additionalContextBlocks,
2981
+ ),
2982
+ });
2958
2983
 
2959
2984
  // Invoke checkpoint callback after tool results are in history.
2960
2985
  // Handoff takes precedence over the budget gate: a handoff decision
@@ -0,0 +1,70 @@
1
+ // Content of the user message that answers a tool-calling assistant turn:
2
+ // the tool_result blocks plus any guidance a post-tool-use hook surfaced via
3
+ // `additionalContext`.
4
+
5
+ import { analyzeServerToolPairing } from "../providers/server-tool-pairing.js";
6
+ import type {
7
+ ContentBlock,
8
+ Message,
9
+ TextContent,
10
+ ToolResultContent,
11
+ } from "../providers/types.js";
12
+
13
+ /**
14
+ * Hook guidance rides as separate blocks after the results, so the model sees
15
+ * it while each tool_result stays the tool's actual output. The exception is
16
+ * an assistant turn that left a server tool (native web search) deferred: the
17
+ * provider runs a deferred server tool only when the answering message holds
18
+ * tool_result blocks alone, and any block after the results closes the
19
+ * assistant turn, after which the provider rejects the unanswered server tool
20
+ * as unpaired. The guidance is then folded into the content of the last
21
+ * errored tool_result (the last tool_result when none errored), where the
22
+ * model still sees it and the search still runs.
23
+ */
24
+ export function buildToolResultFollowUp(
25
+ history: ReadonlyArray<Message>,
26
+ resultBlocks: ContentBlock[],
27
+ additionalContextBlocks: ReadonlyArray<TextContent>,
28
+ ): ContentBlock[] {
29
+ if (additionalContextBlocks.length === 0) {
30
+ return resultBlocks;
31
+ }
32
+ const targetIndex = foldTargetIndex(resultBlocks);
33
+ const pairing = analyzeServerToolPairing([
34
+ ...history,
35
+ { role: "user", content: resultBlocks },
36
+ ]);
37
+ if (pairing.deferredUseIds.size === 0 || targetIndex < 0) {
38
+ return [...resultBlocks, ...additionalContextBlocks];
39
+ }
40
+ const target = resultBlocks[targetIndex] as ToolResultContent;
41
+ const folded: ToolResultContent = {
42
+ ...target,
43
+ content: [target.content, ...additionalContextBlocks.map((b) => b.text)]
44
+ .filter((text) => text.length > 0)
45
+ .join("\n\n"),
46
+ };
47
+ return resultBlocks.map((block, index) =>
48
+ index === targetIndex ? folded : block,
49
+ );
50
+ }
51
+
52
+ /** Index of the last errored tool_result, else the last tool_result, else -1. */
53
+ function foldTargetIndex(blocks: ReadonlyArray<ContentBlock>): number {
54
+ let lastResult = -1;
55
+ for (let index = blocks.length - 1; index >= 0; index--) {
56
+ const block = blocks[index];
57
+ // guard:allow-tool-result-only: the answering user message carries client
58
+ // tool_result blocks only; server-side results live in assistant messages.
59
+ if (block.type !== "tool_result") {
60
+ continue;
61
+ }
62
+ if (block.is_error) {
63
+ return index;
64
+ }
65
+ if (lastResult < 0) {
66
+ lastResult = index;
67
+ }
68
+ }
69
+ return lastResult;
70
+ }