@namzu/sdk 1.0.0 → 1.2.0

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 (330) hide show
  1. package/CHANGELOG.md +253 -0
  2. package/dist/agents/ReactiveAgent.d.ts.map +1 -1
  3. package/dist/agents/ReactiveAgent.js +5 -0
  4. package/dist/agents/ReactiveAgent.js.map +1 -1
  5. package/dist/agents/SupervisorAgent.d.ts +22 -1
  6. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  7. package/dist/agents/SupervisorAgent.js +47 -14
  8. package/dist/agents/SupervisorAgent.js.map +1 -1
  9. package/dist/agents/__tests__/supervisor-ledger.test.d.ts +2 -0
  10. package/dist/agents/__tests__/supervisor-ledger.test.d.ts.map +1 -0
  11. package/dist/agents/__tests__/supervisor-ledger.test.js +131 -0
  12. package/dist/agents/__tests__/supervisor-ledger.test.js.map +1 -0
  13. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  14. package/dist/bridge/a2a/mapper.js +2 -0
  15. package/dist/bridge/a2a/mapper.js.map +1 -1
  16. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  17. package/dist/bridge/sse/mapper.js +4 -0
  18. package/dist/bridge/sse/mapper.js.map +1 -1
  19. package/dist/config/runtime.d.ts +24 -0
  20. package/dist/config/runtime.d.ts.map +1 -1
  21. package/dist/config/runtime.js +9 -0
  22. package/dist/config/runtime.js.map +1 -1
  23. package/dist/connector/builtins/webhook.test.js +17 -0
  24. package/dist/connector/builtins/webhook.test.js.map +1 -1
  25. package/dist/connector/index.d.ts +1 -0
  26. package/dist/connector/index.d.ts.map +1 -1
  27. package/dist/connector/index.js +1 -0
  28. package/dist/connector/index.js.map +1 -1
  29. package/dist/connector/mcp/client.d.ts.map +1 -1
  30. package/dist/connector/mcp/client.js +4 -0
  31. package/dist/connector/mcp/client.js.map +1 -1
  32. package/dist/connector/mcp/index.d.ts +1 -0
  33. package/dist/connector/mcp/index.d.ts.map +1 -1
  34. package/dist/connector/mcp/index.js +1 -0
  35. package/dist/connector/mcp/index.js.map +1 -1
  36. package/dist/connector/mcp/streamable-http.d.ts +22 -0
  37. package/dist/connector/mcp/streamable-http.d.ts.map +1 -0
  38. package/dist/connector/mcp/streamable-http.js +128 -0
  39. package/dist/connector/mcp/streamable-http.js.map +1 -0
  40. package/dist/connector/mcp/streamable-http.test.d.ts +2 -0
  41. package/dist/connector/mcp/streamable-http.test.d.ts.map +1 -0
  42. package/dist/connector/mcp/streamable-http.test.js +132 -0
  43. package/dist/connector/mcp/streamable-http.test.js.map +1 -0
  44. package/dist/{runtime/query → constants}/continuation.d.ts +5 -0
  45. package/dist/constants/continuation.d.ts.map +1 -0
  46. package/dist/{runtime/query → constants}/continuation.js +5 -0
  47. package/dist/constants/continuation.js.map +1 -0
  48. package/dist/constants/index.d.ts +1 -0
  49. package/dist/constants/index.d.ts.map +1 -1
  50. package/dist/constants/index.js +1 -0
  51. package/dist/constants/index.js.map +1 -1
  52. package/dist/manager/run/persistence.d.ts +11 -0
  53. package/dist/manager/run/persistence.d.ts.map +1 -1
  54. package/dist/manager/run/persistence.js +30 -1
  55. package/dist/manager/run/persistence.js.map +1 -1
  56. package/dist/persona/assembler.d.ts.map +1 -1
  57. package/dist/persona/assembler.js +18 -9
  58. package/dist/persona/assembler.js.map +1 -1
  59. package/dist/provider/capabilities.d.ts +27 -0
  60. package/dist/provider/capabilities.d.ts.map +1 -0
  61. package/dist/provider/capabilities.js +29 -0
  62. package/dist/provider/capabilities.js.map +1 -0
  63. package/dist/provider/collect.d.ts.map +1 -1
  64. package/dist/provider/collect.js +4 -1
  65. package/dist/provider/collect.js.map +1 -1
  66. package/dist/provider/index.d.ts +2 -0
  67. package/dist/provider/index.d.ts.map +1 -1
  68. package/dist/provider/index.js +1 -0
  69. package/dist/provider/index.js.map +1 -1
  70. package/dist/public-runtime.d.ts +4 -3
  71. package/dist/public-runtime.d.ts.map +1 -1
  72. package/dist/public-runtime.js +3 -3
  73. package/dist/public-runtime.js.map +1 -1
  74. package/dist/registry/tool/execute.d.ts +27 -0
  75. package/dist/registry/tool/execute.d.ts.map +1 -1
  76. package/dist/registry/tool/execute.js +124 -21
  77. package/dist/registry/tool/execute.js.map +1 -1
  78. package/dist/registry/tool/execute.test.d.ts +9 -6
  79. package/dist/registry/tool/execute.test.d.ts.map +1 -1
  80. package/dist/registry/tool/execute.test.js +72 -10
  81. package/dist/registry/tool/execute.test.js.map +1 -1
  82. package/dist/registry/toolset/catalog.test.js +62 -0
  83. package/dist/registry/toolset/catalog.test.js.map +1 -1
  84. package/dist/run/reporter.d.ts.map +1 -1
  85. package/dist/run/reporter.js +8 -0
  86. package/dist/run/reporter.js.map +1 -1
  87. package/dist/runtime/query/__tests__/capability-negotiation.test.d.ts +2 -0
  88. package/dist/runtime/query/__tests__/capability-negotiation.test.d.ts.map +1 -0
  89. package/dist/runtime/query/__tests__/capability-negotiation.test.js +184 -0
  90. package/dist/runtime/query/__tests__/capability-negotiation.test.js.map +1 -0
  91. package/dist/runtime/query/__tests__/checkpoint-store.test.d.ts +2 -0
  92. package/dist/runtime/query/__tests__/checkpoint-store.test.d.ts.map +1 -0
  93. package/dist/runtime/query/__tests__/checkpoint-store.test.js +259 -0
  94. package/dist/runtime/query/__tests__/checkpoint-store.test.js.map +1 -0
  95. package/dist/runtime/query/__tests__/checkpoint.test.js +9 -3
  96. package/dist/runtime/query/__tests__/checkpoint.test.js.map +1 -1
  97. package/dist/runtime/query/__tests__/deferred-tools.test.js +43 -5
  98. package/dist/runtime/query/__tests__/deferred-tools.test.js.map +1 -1
  99. package/dist/runtime/query/__tests__/long-document-flow.test.d.ts +2 -0
  100. package/dist/runtime/query/__tests__/long-document-flow.test.d.ts.map +1 -0
  101. package/dist/runtime/query/__tests__/long-document-flow.test.js +154 -0
  102. package/dist/runtime/query/__tests__/long-document-flow.test.js.map +1 -0
  103. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.d.ts +2 -0
  104. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.d.ts.map +1 -0
  105. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.js +110 -0
  106. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.js.map +1 -0
  107. package/dist/runtime/query/__tests__/prompt.test.js +15 -11
  108. package/dist/runtime/query/__tests__/prompt.test.js.map +1 -1
  109. package/dist/runtime/query/checkpoint.d.ts +16 -2
  110. package/dist/runtime/query/checkpoint.d.ts.map +1 -1
  111. package/dist/runtime/query/checkpoint.js +21 -7
  112. package/dist/runtime/query/checkpoint.js.map +1 -1
  113. package/dist/runtime/query/context.d.ts +7 -0
  114. package/dist/runtime/query/context.d.ts.map +1 -1
  115. package/dist/runtime/query/context.js +1 -0
  116. package/dist/runtime/query/context.js.map +1 -1
  117. package/dist/runtime/query/index.d.ts +29 -0
  118. package/dist/runtime/query/index.d.ts.map +1 -1
  119. package/dist/runtime/query/index.js +98 -8
  120. package/dist/runtime/query/index.js.map +1 -1
  121. package/dist/runtime/query/iteration/index.d.ts +3 -55
  122. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  123. package/dist/runtime/query/iteration/index.js +260 -656
  124. package/dist/runtime/query/iteration/index.js.map +1 -1
  125. package/dist/runtime/query/iteration/phases/checkpoint.d.ts.map +1 -1
  126. package/dist/runtime/query/iteration/phases/checkpoint.js +24 -2
  127. package/dist/runtime/query/iteration/phases/checkpoint.js.map +1 -1
  128. package/dist/runtime/query/iteration/phases/compaction.d.ts +8 -0
  129. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  130. package/dist/runtime/query/iteration/phases/compaction.js +84 -3
  131. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  132. package/dist/runtime/query/iteration/phases/context.d.ts +13 -2
  133. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  134. package/dist/runtime/query/iteration/phases/context.js +53 -0
  135. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  136. package/dist/runtime/query/iteration/phases/hitl-abort-race.test.d.ts +2 -0
  137. package/dist/runtime/query/iteration/phases/hitl-abort-race.test.d.ts.map +1 -0
  138. package/dist/runtime/query/iteration/phases/hitl-abort-race.test.js +73 -0
  139. package/dist/runtime/query/iteration/phases/hitl-abort-race.test.js.map +1 -0
  140. package/dist/runtime/query/iteration/phases/hitl-answer-question.test.d.ts +2 -0
  141. package/dist/runtime/query/iteration/phases/hitl-answer-question.test.d.ts.map +1 -0
  142. package/dist/runtime/query/iteration/phases/hitl-answer-question.test.js +120 -0
  143. package/dist/runtime/query/iteration/phases/hitl-answer-question.test.js.map +1 -0
  144. package/dist/runtime/query/iteration/phases/tool-review.d.ts +1 -1
  145. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  146. package/dist/runtime/query/iteration/phases/tool-review.js +7 -2
  147. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  148. package/dist/runtime/query/iteration/phases/working-memory-compaction.test.d.ts +19 -0
  149. package/dist/runtime/query/iteration/phases/working-memory-compaction.test.d.ts.map +1 -0
  150. package/dist/runtime/query/iteration/phases/working-memory-compaction.test.js +177 -0
  151. package/dist/runtime/query/iteration/phases/working-memory-compaction.test.js.map +1 -0
  152. package/dist/runtime/query/iteration/phases/working-memory.d.ts +31 -0
  153. package/dist/runtime/query/iteration/phases/working-memory.d.ts.map +1 -0
  154. package/dist/runtime/query/iteration/phases/working-memory.js +81 -0
  155. package/dist/runtime/query/iteration/phases/working-memory.js.map +1 -0
  156. package/dist/runtime/query/iteration/stream-turn.d.ts +35 -0
  157. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -0
  158. package/dist/runtime/query/iteration/stream-turn.js +341 -0
  159. package/dist/runtime/query/iteration/stream-turn.js.map +1 -0
  160. package/dist/runtime/query/replay/list.d.ts +17 -1
  161. package/dist/runtime/query/replay/list.d.ts.map +1 -1
  162. package/dist/runtime/query/replay/list.js +21 -4
  163. package/dist/runtime/query/replay/list.js.map +1 -1
  164. package/dist/runtime/query/replay/prepare.d.ts +10 -0
  165. package/dist/runtime/query/replay/prepare.d.ts.map +1 -1
  166. package/dist/runtime/query/replay/prepare.js +20 -4
  167. package/dist/runtime/query/replay/prepare.js.map +1 -1
  168. package/dist/session/workspace/__tests__/shared-run.test.js +1 -1
  169. package/dist/session/workspace/__tests__/shared-run.test.js.map +1 -1
  170. package/dist/session/workspace/shared-run.js +1 -0
  171. package/dist/session/workspace/shared-run.js.map +1 -1
  172. package/dist/skills/loader.test.js +8 -8
  173. package/dist/skills/loader.test.js.map +1 -1
  174. package/dist/store/index.d.ts +1 -0
  175. package/dist/store/index.d.ts.map +1 -1
  176. package/dist/store/index.js +1 -0
  177. package/dist/store/index.js.map +1 -1
  178. package/dist/store/run/checkpoint-disk.d.ts +28 -0
  179. package/dist/store/run/checkpoint-disk.d.ts.map +1 -0
  180. package/dist/store/run/checkpoint-disk.js +55 -0
  181. package/dist/store/run/checkpoint-disk.js.map +1 -0
  182. package/dist/streaming/coalesce.test.js.map +1 -1
  183. package/dist/tools/builtins/__tests__/payload-budget.test.js +48 -0
  184. package/dist/tools/builtins/__tests__/payload-budget.test.js.map +1 -1
  185. package/dist/tools/builtins/__tests__/read-file.test.js +9 -0
  186. package/dist/tools/builtins/__tests__/read-file.test.js.map +1 -1
  187. package/dist/tools/builtins/read-file.d.ts.map +1 -1
  188. package/dist/tools/builtins/read-file.js +50 -2
  189. package/dist/tools/builtins/read-file.js.map +1 -1
  190. package/dist/tools/builtins/search-tools.d.ts.map +1 -1
  191. package/dist/tools/builtins/search-tools.js +32 -7
  192. package/dist/tools/builtins/search-tools.js.map +1 -1
  193. package/dist/tools/coordinator/__tests__/approve-plan.test.d.ts +2 -0
  194. package/dist/tools/coordinator/__tests__/approve-plan.test.d.ts.map +1 -0
  195. package/dist/tools/coordinator/__tests__/approve-plan.test.js +114 -0
  196. package/dist/tools/coordinator/__tests__/approve-plan.test.js.map +1 -0
  197. package/dist/tools/coordinator/__tests__/ask-user-question.test.d.ts +2 -0
  198. package/dist/tools/coordinator/__tests__/ask-user-question.test.d.ts.map +1 -0
  199. package/dist/tools/coordinator/__tests__/ask-user-question.test.js +309 -0
  200. package/dist/tools/coordinator/__tests__/ask-user-question.test.js.map +1 -0
  201. package/dist/tools/coordinator/index.d.ts +8 -0
  202. package/dist/tools/coordinator/index.d.ts.map +1 -1
  203. package/dist/tools/coordinator/index.js +195 -15
  204. package/dist/tools/coordinator/index.js.map +1 -1
  205. package/dist/types/agent/index.d.ts +1 -0
  206. package/dist/types/agent/index.d.ts.map +1 -1
  207. package/dist/types/agent/index.js +1 -0
  208. package/dist/types/agent/index.js.map +1 -1
  209. package/dist/types/agent/reactive.d.ts +15 -0
  210. package/dist/types/agent/reactive.d.ts.map +1 -1
  211. package/dist/types/agent/supervisor.d.ts +18 -0
  212. package/dist/types/agent/supervisor.d.ts.map +1 -1
  213. package/dist/types/agent/working-memory.d.ts +29 -0
  214. package/dist/types/agent/working-memory.d.ts.map +1 -0
  215. package/dist/types/agent/working-memory.js +2 -0
  216. package/dist/types/agent/working-memory.js.map +1 -0
  217. package/dist/types/common/index.d.ts +10 -0
  218. package/dist/types/common/index.d.ts.map +1 -1
  219. package/dist/types/common/index.js +18 -0
  220. package/dist/types/common/index.js.map +1 -1
  221. package/dist/types/common/usage-merge.test.d.ts +2 -0
  222. package/dist/types/common/usage-merge.test.d.ts.map +1 -0
  223. package/dist/types/common/usage-merge.test.js +48 -0
  224. package/dist/types/common/usage-merge.test.js.map +1 -0
  225. package/dist/types/connector/mcp.d.ts +9 -2
  226. package/dist/types/connector/mcp.d.ts.map +1 -1
  227. package/dist/types/hitl/index.d.ts +42 -0
  228. package/dist/types/hitl/index.d.ts.map +1 -1
  229. package/dist/types/hitl/index.js +11 -0
  230. package/dist/types/hitl/index.js.map +1 -1
  231. package/dist/types/provider/chat.d.ts +9 -0
  232. package/dist/types/provider/chat.d.ts.map +1 -1
  233. package/dist/types/provider/config.d.ts +18 -0
  234. package/dist/types/provider/config.d.ts.map +1 -1
  235. package/dist/types/provider/interface.d.ts +10 -0
  236. package/dist/types/provider/interface.d.ts.map +1 -1
  237. package/dist/types/run/checkpoint-store.d.ts +64 -0
  238. package/dist/types/run/checkpoint-store.d.ts.map +1 -0
  239. package/dist/types/run/checkpoint-store.js +12 -0
  240. package/dist/types/run/checkpoint-store.js.map +1 -0
  241. package/dist/types/run/config.d.ts +27 -0
  242. package/dist/types/run/config.d.ts.map +1 -1
  243. package/dist/types/run/events.d.ts +6 -0
  244. package/dist/types/run/events.d.ts.map +1 -1
  245. package/dist/types/run/events.js.map +1 -1
  246. package/dist/types/run/index.d.ts +1 -0
  247. package/dist/types/run/index.d.ts.map +1 -1
  248. package/dist/types/run/index.js +1 -0
  249. package/dist/types/run/index.js.map +1 -1
  250. package/dist/version.d.ts.map +1 -1
  251. package/dist/version.js +25 -4
  252. package/dist/version.js.map +1 -1
  253. package/package.json +1 -1
  254. package/src/agents/ReactiveAgent.ts +5 -0
  255. package/src/agents/SupervisorAgent.ts +56 -16
  256. package/src/agents/__tests__/supervisor-ledger.test.ts +185 -0
  257. package/src/bridge/a2a/mapper.ts +3 -0
  258. package/src/bridge/sse/mapper.ts +5 -0
  259. package/src/config/runtime.ts +9 -0
  260. package/src/connector/builtins/webhook.test.ts +21 -0
  261. package/src/connector/index.ts +1 -0
  262. package/src/connector/mcp/client.ts +4 -0
  263. package/src/connector/mcp/index.ts +1 -0
  264. package/src/connector/mcp/streamable-http.test.ts +169 -0
  265. package/src/connector/mcp/streamable-http.ts +152 -0
  266. package/src/{runtime/query → constants}/continuation.ts +5 -0
  267. package/src/constants/index.ts +1 -0
  268. package/src/manager/run/persistence.ts +34 -1
  269. package/src/persona/assembler.ts +18 -6
  270. package/src/provider/capabilities.ts +47 -0
  271. package/src/provider/collect.ts +4 -1
  272. package/src/provider/index.ts +5 -0
  273. package/src/public-runtime.ts +5 -0
  274. package/src/registry/tool/execute.test.ts +85 -10
  275. package/src/registry/tool/execute.ts +124 -21
  276. package/src/registry/toolset/catalog.test.ts +68 -0
  277. package/src/run/reporter.ts +9 -0
  278. package/src/runtime/query/__tests__/capability-negotiation.test.ts +248 -0
  279. package/src/runtime/query/__tests__/checkpoint-store.test.ts +322 -0
  280. package/src/runtime/query/__tests__/checkpoint.test.ts +15 -6
  281. package/src/runtime/query/__tests__/deferred-tools.test.ts +52 -5
  282. package/src/runtime/query/__tests__/long-document-flow.test.ts +180 -0
  283. package/src/runtime/query/__tests__/orphan-task-turn-end.test.ts +127 -0
  284. package/src/runtime/query/__tests__/prompt.test.ts +15 -11
  285. package/src/runtime/query/checkpoint.ts +23 -9
  286. package/src/runtime/query/context.ts +9 -0
  287. package/src/runtime/query/index.ts +148 -29
  288. package/src/runtime/query/iteration/index.ts +316 -831
  289. package/src/runtime/query/iteration/phases/checkpoint.ts +32 -2
  290. package/src/runtime/query/iteration/phases/compaction.ts +89 -3
  291. package/src/runtime/query/iteration/phases/context.ts +59 -3
  292. package/src/runtime/query/iteration/phases/hitl-abort-race.test.ts +86 -0
  293. package/src/runtime/query/iteration/phases/hitl-answer-question.test.ts +139 -0
  294. package/src/runtime/query/iteration/phases/tool-review.ts +7 -3
  295. package/src/runtime/query/iteration/phases/working-memory-compaction.test.ts +215 -0
  296. package/src/runtime/query/iteration/phases/working-memory.ts +86 -0
  297. package/src/runtime/query/iteration/stream-turn.ts +396 -0
  298. package/src/runtime/query/replay/list.ts +38 -4
  299. package/src/runtime/query/replay/prepare.ts +37 -4
  300. package/src/session/workspace/__tests__/shared-run.test.ts +1 -1
  301. package/src/session/workspace/shared-run.ts +1 -0
  302. package/src/skills/loader.test.ts +8 -8
  303. package/src/store/index.ts +1 -0
  304. package/src/store/run/checkpoint-disk.ts +67 -0
  305. package/src/streaming/coalesce.test.ts +3 -3
  306. package/src/tools/builtins/__tests__/payload-budget.test.ts +63 -0
  307. package/src/tools/builtins/__tests__/read-file.test.ts +12 -0
  308. package/src/tools/builtins/read-file.ts +50 -2
  309. package/src/tools/builtins/search-tools.ts +38 -7
  310. package/src/tools/coordinator/__tests__/approve-plan.test.ts +139 -0
  311. package/src/tools/coordinator/__tests__/ask-user-question.test.ts +370 -0
  312. package/src/tools/coordinator/index.ts +227 -17
  313. package/src/types/agent/index.ts +1 -0
  314. package/src/types/agent/reactive.ts +17 -0
  315. package/src/types/agent/supervisor.ts +20 -0
  316. package/src/types/agent/working-memory.ts +29 -0
  317. package/src/types/common/index.ts +19 -0
  318. package/src/types/common/usage-merge.test.ts +53 -0
  319. package/src/types/connector/mcp.ts +14 -2
  320. package/src/types/hitl/index.ts +52 -1
  321. package/src/types/provider/chat.ts +10 -0
  322. package/src/types/provider/config.ts +18 -0
  323. package/src/types/provider/interface.ts +11 -0
  324. package/src/types/run/checkpoint-store.ts +72 -0
  325. package/src/types/run/config.ts +30 -0
  326. package/src/types/run/events.ts +13 -0
  327. package/src/types/run/index.ts +1 -0
  328. package/src/version.ts +26 -4
  329. package/dist/runtime/query/continuation.d.ts.map +0 -1
  330. package/dist/runtime/query/continuation.js.map +0 -1
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Regression test for the ses_055 Layer-B SDK seam (#104):
3
+ *
4
+ * 1. **Compaction FIRES** under a small `contextWindowTokens` even with the
5
+ * run-level `tokenBudget = 0` (UNLIMITED) — proving the F1 fix repointed
6
+ * BOTH the `<= 0` guard AND the divisor, closing the silent-no-op trap.
7
+ * 2. **The pinned working-memory slot SURVIVES** the compaction pass (it is a
8
+ * leading system message → preserved by header identity).
9
+ * 3. **No premature stop** from `tokenBudget = 0` alone: with NO
10
+ * `contextWindowTokens`, the same overflowing run does NOT compact (the
11
+ * legacy fallback), so consumers that never set the new field are
12
+ * byte-identical.
13
+ *
14
+ * The test drives `refreshWorkingMemory` (to pin the slot) then
15
+ * `runCompactionCheck` directly against a mock `IterationContext`, which is the
16
+ * exact sequence the iteration loop runs each turn.
17
+ */
18
+
19
+ import { describe, expect, it, vi } from 'vitest'
20
+
21
+ import { findDanglingMessages } from '../../../../compaction/dangling.js'
22
+ import { WorkingStateManager } from '../../../../compaction/manager.js'
23
+ import { CompactionConfigSchema } from '../../../../config/runtime.js'
24
+ import type { RunId } from '../../../../types/ids/index.js'
25
+ import {
26
+ type Message,
27
+ createAssistantMessage,
28
+ createSystemMessage,
29
+ createToolMessage,
30
+ createUserMessage,
31
+ } from '../../../../types/message/index.js'
32
+ import type { Logger } from '../../../../utils/logger.js'
33
+ import { runCompactionCheck } from './compaction.js'
34
+ import type { IterationContext } from './context.js'
35
+ import { WORKING_MEMORY_HEADER, refreshWorkingMemory } from './working-memory.js'
36
+
37
+ function makeLogger(): Logger {
38
+ const self = {
39
+ info: vi.fn(),
40
+ warn: vi.fn(),
41
+ error: vi.fn(),
42
+ debug: vi.fn(),
43
+ child: vi.fn(),
44
+ } as unknown as Logger
45
+ ;(self as { child: (ctx: unknown) => Logger }).child = vi.fn(() => self)
46
+ return self
47
+ }
48
+
49
+ /** A long string so a handful of messages overflow a small context window. */
50
+ const FILLER = 'x'.repeat(200)
51
+
52
+ function buildOverflowingMessages(): Message[] {
53
+ const msgs: Message[] = [createSystemMessage(`STATIC SYSTEM PROMPT ${FILLER}`, 'cache')]
54
+ for (let i = 0; i < 8; i++) {
55
+ msgs.push(createUserMessage(`user turn ${i} ${FILLER}`))
56
+ msgs.push(createAssistantMessage(`assistant turn ${i} ${FILLER}`))
57
+ }
58
+ return msgs
59
+ }
60
+
61
+ function makeCtx(opts: {
62
+ messages: Message[]
63
+ contextWindowTokens?: number
64
+ workingMemoryProvider?: IterationContext['workingMemoryProvider']
65
+ }): IterationContext {
66
+ const config = CompactionConfigSchema.parse({
67
+ strategy: 'structured',
68
+ llmVerification: false,
69
+ ...(opts.contextWindowTokens !== undefined
70
+ ? { contextWindowTokens: opts.contextWindowTokens }
71
+ : {}),
72
+ })
73
+ const manager = new WorkingStateManager(config)
74
+ // Seed a couple of slots so serializeState produces non-trivial output.
75
+ manager.addDecision('built the report as .docx')
76
+
77
+ return {
78
+ runConfig: { tokenBudget: 0 }, // UNLIMITED cumulative cost cap
79
+ compactionConfig: config,
80
+ workingStateManager: manager,
81
+ workingMemoryProvider: opts.workingMemoryProvider,
82
+ log: makeLogger(),
83
+ runMgr: {
84
+ id: 'run_1' as RunId,
85
+ currentIteration: 3,
86
+ messages: opts.messages,
87
+ },
88
+ } as unknown as IterationContext
89
+ }
90
+
91
+ const WM_BLOCK =
92
+ 'ARTIFACTS\n • report.docx · Word document · 48.2 KB · imp:H\n path: /mnt/user-data/outputs/report.docx'
93
+
94
+ describe('Layer-B compaction seam (ses_055 #104)', () => {
95
+ it('FIRES on small contextWindowTokens with tokenBudget=0 AND preserves the pinned WM slot', async () => {
96
+ const messages = buildOverflowingMessages()
97
+ const ctx = makeCtx({
98
+ messages,
99
+ contextWindowTokens: 100,
100
+ workingMemoryProvider: () => WM_BLOCK,
101
+ })
102
+
103
+ await refreshWorkingMemory(ctx)
104
+ // The slot is pinned as the last leading system message (index 1).
105
+ expect(messages[1]?.role).toBe('system')
106
+ expect(messages[1]?.content?.startsWith(WORKING_MEMORY_HEADER)).toBe(true)
107
+
108
+ const before = messages.length
109
+ await runCompactionCheck(ctx)
110
+
111
+ // (1) compaction fired: a [COMPACTED CONTEXT] message appeared and the
112
+ // transcript shrank.
113
+ expect(messages.length).toBeLessThan(before)
114
+ expect(messages.some((m) => m.content?.includes('[COMPACTED CONTEXT]'))).toBe(true)
115
+
116
+ // (2) the pinned working-memory slot survived by header identity.
117
+ const wm = messages.filter(
118
+ (m) => m.role === 'system' && m.content?.startsWith(WORKING_MEMORY_HEADER),
119
+ )
120
+ expect(wm).toHaveLength(1)
121
+ expect(wm[0]?.content).toContain('report.docx')
122
+ })
123
+
124
+ it('does NOT compact when contextWindowTokens is absent and tokenBudget=0 (byte-identical fallback)', async () => {
125
+ const messages = buildOverflowingMessages()
126
+ const ctx = makeCtx({ messages })
127
+
128
+ const before = messages.length
129
+ await runCompactionCheck(ctx)
130
+
131
+ // budget = tokenBudget = 0 → guard returns; no compaction, no mutation.
132
+ expect(messages.length).toBe(before)
133
+ expect(messages.some((m) => m.content?.includes('[COMPACTED CONTEXT]'))).toBe(false)
134
+ })
135
+
136
+ it('removes the slot when the provider returns an empty block', async () => {
137
+ const messages = buildOverflowingMessages()
138
+ const ctx = makeCtx({
139
+ messages,
140
+ contextWindowTokens: 100,
141
+ workingMemoryProvider: () => WM_BLOCK,
142
+ })
143
+ await refreshWorkingMemory(ctx)
144
+ expect(messages.some((m) => m.content?.startsWith(WORKING_MEMORY_HEADER))).toBe(true)
145
+
146
+ // Same transcript, provider now returns blank ⇒ the slot is removed.
147
+ const blankCtx = makeCtx({
148
+ messages,
149
+ contextWindowTokens: 100,
150
+ workingMemoryProvider: () => ' ',
151
+ })
152
+ await refreshWorkingMemory(blankCtx)
153
+ expect(messages.some((m) => m.content?.startsWith(WORKING_MEMORY_HEADER))).toBe(false)
154
+ })
155
+
156
+ it('keeps the prior slot when the provider throws (failure-isolated)', async () => {
157
+ const messages = buildOverflowingMessages()
158
+ const ctx = makeCtx({
159
+ messages,
160
+ contextWindowTokens: 100,
161
+ workingMemoryProvider: () => WM_BLOCK,
162
+ })
163
+ await refreshWorkingMemory(ctx)
164
+
165
+ const throwingCtx = makeCtx({
166
+ messages,
167
+ contextWindowTokens: 100,
168
+ workingMemoryProvider: () => {
169
+ throw new Error('provider boom')
170
+ },
171
+ })
172
+ await refreshWorkingMemory(throwingCtx)
173
+ // Prior slot retained; run not broken.
174
+ expect(messages.some((m) => m.content?.startsWith(WORKING_MEMORY_HEADER))).toBe(true)
175
+ })
176
+
177
+ it('does NOT orphan a tool_result when the recent-window cut splits a tool pair (C1)', async () => {
178
+ // keepRecentMessages defaults to 4. Lay the transcript out so the NAIVE
179
+ // cut (length - 4 = index 5) lands right AFTER the assistant-with-toolCall
180
+ // (index 4) but BEFORE... no — exactly ON its tool result (index 5), which
181
+ // a naive slice would keep in the recent window while dropping the
182
+ // assistant into the summarized older set → an orphaned tool_result at the
183
+ // head of the recent window → Anthropic 400. The fix snaps keepStart
184
+ // forward past the complete pair.
185
+ const toolCall = {
186
+ id: 't1',
187
+ type: 'function' as const,
188
+ function: { name: 'read', arguments: '{}' },
189
+ }
190
+ const messages: Message[] = [
191
+ createSystemMessage(`STATIC SYSTEM PROMPT ${FILLER}`, 'cache'),
192
+ createUserMessage(`user 0 ${FILLER}`),
193
+ createAssistantMessage(`assistant 0 ${FILLER}`),
194
+ createUserMessage(`user 1 ${FILLER}`),
195
+ createAssistantMessage(`calling a tool ${FILLER}`, [toolCall]), // idx 4
196
+ createToolMessage(`tool result ${FILLER}`, 't1'), // idx 5 — naive recent[0]
197
+ createAssistantMessage(`assistant 2 ${FILLER}`),
198
+ createUserMessage(`user 3 ${FILLER}`),
199
+ createAssistantMessage(`assistant 3 ${FILLER}`),
200
+ ]
201
+ const ctx = makeCtx({ messages, contextWindowTokens: 100 })
202
+
203
+ await runCompactionCheck(ctx)
204
+
205
+ // Compaction fired.
206
+ expect(messages.some((m) => m.content?.includes('[COMPACTED CONTEXT]'))).toBe(true)
207
+ // The surviving transcript has NO dangling tool pair — the tool result
208
+ // whose assistant was summarized away was moved into the older set too,
209
+ // so no orphaned tool_result remains to 400 the next provider turn.
210
+ expect(findDanglingMessages(messages).isValid).toBe(true)
211
+ // And a `tool` message never leads the recent window.
212
+ const firstNonSystem = messages.find((m) => m.role !== 'system')
213
+ expect(firstNonSystem?.role).not.toBe('tool')
214
+ })
215
+ })
@@ -0,0 +1,86 @@
1
+ import { createSystemMessage } from '../../../../types/message/index.js'
2
+ import type { IterationContext } from './context.js'
3
+
4
+ /**
5
+ * Sentinel header identifying the single PINNED working-memory system message.
6
+ *
7
+ * The SDK finds + replaces the slot by this header every turn and uses the same
8
+ * identity to re-pin it if compaction ever drops it (the OPAQUE survival guard
9
+ * in `compaction.ts` — no host-format knowledge required). The host's rendered
10
+ * block is stored verbatim AFTER this header; the SDK never parses it.
11
+ */
12
+ export const WORKING_MEMORY_HEADER =
13
+ '[WORKING MEMORY] Authoritative state for this conversation — you produced these.'
14
+
15
+ /**
16
+ * True when `content` is the pinned working-memory slot (header identity only).
17
+ * Shared with `compaction.ts` so the survival guard re-pins by the SAME rule.
18
+ */
19
+ export function isWorkingMemoryMessage(content: string | null | undefined): boolean {
20
+ return typeof content === 'string' && content.startsWith(WORKING_MEMORY_HEADER)
21
+ }
22
+
23
+ /**
24
+ * Resolve the host's working-memory string and rewrite a single PINNED leading
25
+ * system message in place. Insert-or-replace keyed by {@link WORKING_MEMORY_HEADER};
26
+ * an empty/blank string removes the slot.
27
+ *
28
+ * The slot is an EPHEMERAL system message placed as the LAST leading system
29
+ * message (after the cached static + dynamic system messages), so it never
30
+ * busts the prompt-cache prefix yet still rides inside the compaction-preserved
31
+ * leading-system run (the primacy edge Lost-in-the-Middle / Context-Rot need).
32
+ *
33
+ * Failure-isolated (the `web-search` seam rule): a throwing/slow provider
34
+ * degrades to "no refresh this turn" (keeps the prior slot), never breaks the
35
+ * run. Early-returns when no provider is configured ⇒ byte-identical run path.
36
+ */
37
+ export async function refreshWorkingMemory(ctx: IterationContext): Promise<void> {
38
+ const provider = ctx.workingMemoryProvider
39
+ if (!provider) return
40
+
41
+ let block: string
42
+ try {
43
+ block =
44
+ (await provider({
45
+ runId: ctx.runMgr.id,
46
+ iteration: ctx.runMgr.currentIteration,
47
+ })) ?? ''
48
+ } catch (err) {
49
+ ctx.log.warn('workingMemoryProvider failed; keeping prior slot', {
50
+ runId: ctx.runMgr.id,
51
+ error: err instanceof Error ? err.message : String(err),
52
+ })
53
+ return
54
+ }
55
+
56
+ const msgs = ctx.runMgr.messages
57
+
58
+ // Bound the search to the LEADING system run (the compaction-preserved
59
+ // region). A working-memory header appearing later in the transcript (e.g.
60
+ // echoed by a tool result) must not be mistaken for the pinned slot.
61
+ let leadEnd = 0
62
+ while (leadEnd < msgs.length && msgs[leadEnd]?.role === 'system') leadEnd++
63
+
64
+ let idx = -1
65
+ for (let i = 0; i < leadEnd; i++) {
66
+ const m = msgs[i]
67
+ if (m && m.role === 'system' && isWorkingMemoryMessage(m.content)) {
68
+ idx = i
69
+ break
70
+ }
71
+ }
72
+
73
+ if (!block.trim()) {
74
+ // Empty block ⇒ remove the slot (byte-identical-when-empty).
75
+ if (idx >= 0) msgs.splice(idx, 1)
76
+ return
77
+ }
78
+
79
+ const wm = createSystemMessage(`${WORKING_MEMORY_HEADER}\n\n${block}`, 'ephemeral')
80
+ if (idx >= 0) {
81
+ msgs[idx] = wm
82
+ } else {
83
+ // Insert as the LAST leading system message (after static + dynamic).
84
+ msgs.splice(leadEnd, 0, wm)
85
+ }
86
+ }
@@ -0,0 +1,396 @@
1
+ import { mergeTokenUsage } from '../../../types/common/index.js'
2
+ import type { ToolUseId } from '../../../types/ids/index.js'
3
+ import type {
4
+ ChatCompletionResponse,
5
+ LLMProvider,
6
+ StreamChunk,
7
+ } from '../../../types/provider/index.js'
8
+ import type { RunEvent } from '../../../types/run/index.js'
9
+ import type { MessageStopReason } from '../../../types/run/stop-reason.js'
10
+ import { generateMessageId } from '../../../utils/id.js'
11
+ import type { Logger } from '../../../utils/logger.js'
12
+ import type { EmitEvent } from '../events.js'
13
+
14
+ /**
15
+ * Map a provider's coarse `finishReason` plus the orchestrator's
16
+ * `forceFinalize` flag onto the per-message {@link MessageStopReason}
17
+ * union the v3 `message_completed` event surfaces.
18
+ */
19
+ function synthesizeMessageStopReason(
20
+ finishReason: 'stop' | 'tool_calls' | 'length' | 'content_filter',
21
+ forceFinalize: boolean,
22
+ ): MessageStopReason {
23
+ if (forceFinalize) return 'forced_finalize'
24
+ switch (finishReason) {
25
+ case 'tool_calls':
26
+ return 'tool_use'
27
+ case 'length':
28
+ return 'max_tokens'
29
+ case 'content_filter':
30
+ return 'refusal'
31
+ default:
32
+ return 'end_turn'
33
+ }
34
+ }
35
+
36
+ export interface StreamingTurnResult {
37
+ response: ChatCompletionResponse
38
+ messageId: import('../../../types/ids/index.js').MessageId
39
+ }
40
+
41
+ /**
42
+ * Consume a provider's streaming response and emit the v3 RunEvent
43
+ * lifecycle natively (message_started → text_delta* + tool_input_*
44
+ * → message_completed). Returns the aggregated `ChatCompletionResponse`
45
+ * for downstream code that still expects the legacy shape (assistant
46
+ * message construction, working-state extraction, telemetry attribute
47
+ * stamping).
48
+ *
49
+ * Per-delta `emitEvent` calls are followed by a `drainPending()`
50
+ * yield so SSE consumers see live progress instead of a burst at
51
+ * end-of-message. The bus's ephemeral filter (D1) ensures these
52
+ * deltas never hit transcript.jsonl.
53
+ *
54
+ * Edge cases (codex A3, A4, A5):
55
+ * - Stream ends without `finishReason` (anthropic-sdk-typescript#842
56
+ * dropped message_stop): we still emit `message_completed` from a
57
+ * finally-style fall-through path with `stopReason: 'refusal'`.
58
+ * - `tool_input_delta` with no `toolUseId` registered yet: we drop
59
+ * the fragment and log a warning (proxies seen to misorder events).
60
+ * - `chunk.error`: when no tool input is recoverable, we surface as
61
+ * a thrown error after emitting the message_completed terminator so
62
+ * consumer cards still close. If a tool-use block was already open,
63
+ * we instead synthesize a tool call with runtime truncation metadata
64
+ * so the executor can return a model-readable retry hint.
65
+ */
66
+ export async function* streamProviderTurn(
67
+ provider: LLMProvider,
68
+ params: import('../../../types/provider/index.js').ChatCompletionParams,
69
+ emitEvent: EmitEvent,
70
+ drainPending: () => Generator<RunEvent>,
71
+ runId: import('../../../types/ids/index.js').RunId,
72
+ iteration: number,
73
+ forceFinalize: boolean,
74
+ log: Logger,
75
+ ): AsyncGenerator<RunEvent, StreamingTurnResult> {
76
+ const messageId = generateMessageId()
77
+ await emitEvent({ type: 'message_started', runId, iteration, messageId })
78
+ yield* drainPending()
79
+
80
+ let id = ''
81
+ const model = ''
82
+ let textBuf = ''
83
+ let finishReason: ChatCompletionResponse['finishReason'] = 'stop'
84
+ let usage: ChatCompletionResponse['usage'] = {
85
+ promptTokens: 0,
86
+ completionTokens: 0,
87
+ totalTokens: 0,
88
+ cachedTokens: 0,
89
+ cacheWriteTokens: 0,
90
+ }
91
+ const toolBuckets = new Map<
92
+ number,
93
+ {
94
+ id: string
95
+ name: string
96
+ argsBuf: string
97
+ started: boolean
98
+ completed: boolean
99
+ /**
100
+ * Parsed input. `null` while the bucket is still streaming.
101
+ * The synthesized
102
+ * `ChatCompletionResponse.toolCalls[].function.arguments` is
103
+ * derived from this — never from the raw buffer — so the
104
+ * downstream executor (`runtime/query/executor.ts`) never has
105
+ * to re-parse a truncated string. A truncated tool call is
106
+ * surfaced as `arguments: "{}"` plus `metadata.inputTruncated`
107
+ * so tool args remain clean while the executor can still
108
+ * return a specific retry hint.
109
+ */
110
+ parsed: unknown | null
111
+ inputTruncated: boolean
112
+ }
113
+ >()
114
+ let streamError: string | undefined
115
+
116
+ const stream = provider.chatStream({ ...params, stream: true }) as AsyncIterable<StreamChunk>
117
+
118
+ // Drive the stream manually so each `.next()` can be RACED against the run
119
+ // abort: a Stop tears the in-flight model request down (the provider got
120
+ // `params.signal`), and we ALSO stop pulling within a tick even if a
121
+ // transport buffers or ignores the signal. The abort rejection propagates
122
+ // out of this generator so the run loop settles the turn as cancelled.
123
+ // `{ once: true }` keeps a multi-iteration run from leaking a listener/turn.
124
+ const it = stream[Symbol.asyncIterator]()
125
+ const signal = params.signal
126
+ let onAbort: (() => void) | undefined
127
+ const aborted: Promise<never> | undefined = signal
128
+ ? new Promise<never>((_resolve, reject) => {
129
+ if (signal.aborted) {
130
+ reject(signal.reason)
131
+ return
132
+ }
133
+ onAbort = () => reject(signal.reason)
134
+ signal.addEventListener('abort', onAbort, { once: true })
135
+ })
136
+ : undefined
137
+
138
+ try {
139
+ for (;;) {
140
+ const next = it.next()
141
+ // Neutralize the dangling loser so an eventual rejection of the
142
+ // un-awaited `next` is never an unhandled rejection.
143
+ if (aborted) next.catch(() => {})
144
+ const res = await (aborted ? Promise.race([next, aborted]) : next)
145
+ if (res.done) break
146
+ const chunk = res.value
147
+ if (chunk.error) {
148
+ streamError = chunk.error
149
+ break
150
+ }
151
+ if (!id && chunk.id) id = chunk.id
152
+
153
+ if (chunk.delta.content) {
154
+ textBuf += chunk.delta.content
155
+ await emitEvent({
156
+ type: 'text_delta',
157
+ runId,
158
+ iteration,
159
+ messageId,
160
+ text: chunk.delta.content,
161
+ })
162
+ yield* drainPending()
163
+ }
164
+
165
+ for (const tc of chunk.delta.toolCalls ?? []) {
166
+ let bucket = toolBuckets.get(tc.index)
167
+ if (!bucket) {
168
+ bucket = {
169
+ id: tc.id ?? '',
170
+ name: tc.function?.name ?? '',
171
+ argsBuf: '',
172
+ started: false,
173
+ completed: false,
174
+ parsed: null,
175
+ inputTruncated: false,
176
+ }
177
+ toolBuckets.set(tc.index, bucket)
178
+ }
179
+ if (tc.id && !bucket.id) bucket.id = tc.id
180
+ if (tc.function?.name && !bucket.name) bucket.name = tc.function.name
181
+
182
+ if (!bucket.started && bucket.id && bucket.name) {
183
+ bucket.started = true
184
+ await emitEvent({
185
+ type: 'tool_input_started',
186
+ runId,
187
+ iteration,
188
+ messageId,
189
+ toolUseId: bucket.id as ToolUseId,
190
+ toolName: bucket.name,
191
+ })
192
+ yield* drainPending()
193
+ }
194
+
195
+ const fragment = tc.function?.arguments
196
+ if (fragment) {
197
+ if (!bucket.id) {
198
+ log.warn('tool_input_delta arrived before tool id was known; dropping fragment', {
199
+ runId,
200
+ index: tc.index,
201
+ length: fragment.length,
202
+ })
203
+ } else {
204
+ bucket.argsBuf += fragment
205
+ await emitEvent({
206
+ type: 'tool_input_delta',
207
+ runId,
208
+ toolUseId: bucket.id as ToolUseId,
209
+ partialJson: fragment,
210
+ })
211
+ yield* drainPending()
212
+ }
213
+ }
214
+ }
215
+
216
+ if (chunk.delta.toolCallEnd) {
217
+ const { index, id: endId } = chunk.delta.toolCallEnd
218
+ const bucket = toolBuckets.get(index)
219
+ if (bucket && !bucket.completed) {
220
+ bucket.completed = true
221
+ let parsed: unknown = {}
222
+ try {
223
+ parsed = bucket.argsBuf ? JSON.parse(bucket.argsBuf) : {}
224
+ } catch (err) {
225
+ bucket.inputTruncated = true
226
+ log.warn('tool input JSON parse failed at content_block_stop', {
227
+ runId,
228
+ toolUseId: endId,
229
+ error: err instanceof Error ? err.message : String(err),
230
+ })
231
+ }
232
+ bucket.parsed = parsed
233
+ await emitEvent({
234
+ type: 'tool_input_completed',
235
+ runId,
236
+ toolUseId: endId as ToolUseId,
237
+ input: parsed,
238
+ ...(bucket.inputTruncated ? { inputTruncated: true } : {}),
239
+ })
240
+ yield* drainPending()
241
+ }
242
+ }
243
+
244
+ if (chunk.finishReason) finishReason = chunk.finishReason
245
+ // Merge (per-field max), not last-write-wins: a late usage frame that
246
+ // omits input/cache tokens must not zero the counts seen earlier in the
247
+ // stream, which would under-report this turn's accumulated usage.
248
+ if (chunk.usage) usage = mergeTokenUsage(usage, chunk.usage)
249
+ }
250
+ } catch (err) {
251
+ // An abort tears the turn down: propagate it so the run loop settles the
252
+ // run as cancelled rather than recording a normal (errored) turn. Any
253
+ // other stream error is captured into the synthesized response as before.
254
+ if (signal?.aborted) throw err
255
+ streamError = err instanceof Error ? err.message : String(err)
256
+ } finally {
257
+ if (onAbort) signal?.removeEventListener('abort', onAbort)
258
+ // Release the underlying connection on every exit (natural end, error,
259
+ // or abort). `for await` did this implicitly on natural completion; the
260
+ // manual drive must do it explicitly. `.return()` on an already-finished
261
+ // provider generator is a no-op.
262
+ await it.return?.().catch(() => {})
263
+ }
264
+
265
+ // Flush any tool buckets the provider failed to close (no toolCallEnd
266
+ // arrived — defensive against providers that don't yet emit it, and
267
+ // the load-bearing path when the provider stream ends with
268
+ // `stop_reason: "max_tokens"` mid-`input_json_delta`. In that case
269
+ // Anthropic's SSE never sends `content_block_stop` for the open
270
+ // tool_use block: the upstream model ran out of completion tokens
271
+ // before it could close the JSON literal, so the buffered
272
+ // `argsBuf` ends with something like `"content":"…some prefix` —
273
+ // not parseable.
274
+ //
275
+ // Two cases coalesce here:
276
+ // 1. The buffer parses cleanly (the provider just forgot to emit
277
+ // `content_block_stop` but the args are intact) — keep parsed.
278
+ // 2. The buffer is truncated mid-literal — `parsed = {}` is the
279
+ // safe fallback so the executor's `JSON.parse(arguments)`
280
+ // succeeds and downstream consumers don't crash. The PRICE
281
+ // we used to pay was the model getting back a generic
282
+ // "<field> is required" Zod error and not realising its
283
+ // previous tool call was truncated server-side, so it would
284
+ // retry with the SAME long input and hit the same cutoff in
285
+ // a loop. Detect the truncation case and mark the tool call
286
+ // with runtime metadata; the executor surfaces a specific
287
+ // "your tool call was cut off by max_tokens — retry with
288
+ // shorter input or split into smaller calls" message that the
289
+ // model can act on.
290
+ for (const bucket of toolBuckets.values()) {
291
+ if (bucket.started && !bucket.completed) {
292
+ bucket.completed = true
293
+ let parsed: unknown = {}
294
+ let truncated = false
295
+ if (bucket.argsBuf) {
296
+ try {
297
+ parsed = JSON.parse(bucket.argsBuf)
298
+ } catch {
299
+ // argsBuf had content but didn't parse — almost
300
+ // certainly the max_tokens-mid-literal cutoff. Mark
301
+ // the bucket so the executor can return a model-
302
+ // readable hint instead of a generic Zod error.
303
+ truncated = true
304
+ parsed = {}
305
+ }
306
+ }
307
+ bucket.parsed = parsed
308
+ bucket.inputTruncated = truncated
309
+ if (truncated) {
310
+ log.warn('tool input truncated by upstream cutoff (no toolCallEnd, argsBuf unparsable)', {
311
+ runId,
312
+ toolUseId: bucket.id,
313
+ toolName: bucket.name,
314
+ bufferLength: bucket.argsBuf.length,
315
+ })
316
+ }
317
+ await emitEvent({
318
+ type: 'tool_input_completed',
319
+ runId,
320
+ toolUseId: bucket.id as ToolUseId,
321
+ input: parsed,
322
+ ...(truncated ? { inputTruncated: true } : {}),
323
+ })
324
+ yield* drainPending()
325
+ }
326
+ }
327
+
328
+ // `arguments` MUST be valid JSON for the executor's `JSON.parse`
329
+ // (`runtime/query/executor.ts:executeSingle`) to succeed. We
330
+ // always serialise from the bucket's `parsed` object (filled by
331
+ // either the `toolCallEnd` branch above or the post-stream flush
332
+ // loop) instead of re-emitting `argsBuf`. When the provider
333
+ // stream truncated mid-input, `metadata.inputTruncated` carries that
334
+ // state; the executor parses cleanly and returns a specific
335
+ // model-readable retry hint instead of the generic "Invalid JSON in
336
+ // tool arguments" intercept.
337
+ const toolCalls = [...toolBuckets.entries()]
338
+ .sort(([a], [b]) => a - b)
339
+ .map(([, b]) => ({
340
+ id: b.id,
341
+ type: 'function' as const,
342
+ function: {
343
+ name: b.name,
344
+ arguments: JSON.stringify(b.parsed ?? {}),
345
+ },
346
+ ...(b.inputTruncated ? { metadata: { inputTruncated: true } } : {}),
347
+ }))
348
+
349
+ const recoveredToolInputFromStreamError =
350
+ streamError !== undefined && toolCalls.some((tc) => tc.id && tc.function.name)
351
+ const effectiveFinishReason: ChatCompletionResponse['finishReason'] =
352
+ recoveredToolInputFromStreamError ? 'tool_calls' : finishReason
353
+
354
+ if (recoveredToolInputFromStreamError) {
355
+ log.warn('provider stream failed after tool input; surfacing tool call to executor', {
356
+ runId,
357
+ iteration,
358
+ error: streamError,
359
+ toolCallCount: toolCalls.length,
360
+ })
361
+ }
362
+
363
+ const stopReason: MessageStopReason = streamError
364
+ ? recoveredToolInputFromStreamError
365
+ ? 'tool_use'
366
+ : 'refusal'
367
+ : synthesizeMessageStopReason(effectiveFinishReason, forceFinalize)
368
+
369
+ await emitEvent({
370
+ type: 'message_completed',
371
+ runId,
372
+ iteration,
373
+ messageId,
374
+ stopReason,
375
+ usage,
376
+ content: textBuf || undefined,
377
+ })
378
+ yield* drainPending()
379
+
380
+ if (streamError && !recoveredToolInputFromStreamError) {
381
+ throw new Error(`Provider stream error: ${streamError}`)
382
+ }
383
+
384
+ const response: ChatCompletionResponse = {
385
+ id: id || messageId,
386
+ model: model || params.model,
387
+ message: {
388
+ role: 'assistant',
389
+ content: textBuf.length > 0 ? textBuf : null,
390
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
391
+ },
392
+ finishReason: effectiveFinishReason,
393
+ usage,
394
+ }
395
+ return { response, messageId }
396
+ }