@linxiraos/pi-ai 1.0.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 (639) hide show
  1. package/CHANGELOG.md +5066 -0
  2. package/README.md +1195 -0
  3. package/dist/types/api-registry.d.ts +30 -0
  4. package/dist/types/auth/sqlite-credential-store.d.ts +117 -0
  5. package/dist/types/auth-broker/client.d.ts +93 -0
  6. package/dist/types/auth-broker/discover.d.ts +39 -0
  7. package/dist/types/auth-broker/index.d.ts +7 -0
  8. package/dist/types/auth-broker/refresher.d.ts +25 -0
  9. package/dist/types/auth-broker/remote-store.d.ts +136 -0
  10. package/dist/types/auth-broker/server.d.ts +48 -0
  11. package/dist/types/auth-broker/snapshot-cache.d.ts +17 -0
  12. package/dist/types/auth-broker/types.d.ts +152 -0
  13. package/dist/types/auth-broker/wire-schema-resource.d.ts +53 -0
  14. package/dist/types/auth-broker/wire-schemas.d.ts +5 -0
  15. package/dist/types/auth-gateway/http.d.ts +56 -0
  16. package/dist/types/auth-gateway/index.d.ts +3 -0
  17. package/dist/types/auth-gateway/server.d.ts +36 -0
  18. package/dist/types/auth-gateway/types.d.ts +127 -0
  19. package/dist/types/auth-retry.d.ts +150 -0
  20. package/dist/types/auth-storage.d.ts +1258 -0
  21. package/dist/types/dialect/anthropic.d.ts +15 -0
  22. package/dist/types/dialect/catalog.d.ts +3 -0
  23. package/dist/types/dialect/coercion.d.ts +23 -0
  24. package/dist/types/dialect/deepseek.d.ts +14 -0
  25. package/dist/types/dialect/demotion.d.ts +30 -0
  26. package/dist/types/dialect/examples.d.ts +18 -0
  27. package/dist/types/dialect/factory.d.ts +3 -0
  28. package/dist/types/dialect/fenced-thinking.d.ts +53 -0
  29. package/dist/types/dialect/gemini.d.ts +17 -0
  30. package/dist/types/dialect/gemma.d.ts +15 -0
  31. package/dist/types/dialect/glm.d.ts +9 -0
  32. package/dist/types/dialect/harmony.d.ts +8 -0
  33. package/dist/types/dialect/hermes.d.ts +9 -0
  34. package/dist/types/dialect/history.d.ts +3 -0
  35. package/dist/types/dialect/index.d.ts +11 -0
  36. package/dist/types/dialect/inventory.d.ts +9 -0
  37. package/dist/types/dialect/kimi.d.ts +14 -0
  38. package/dist/types/dialect/minimax.d.ts +3 -0
  39. package/dist/types/dialect/owned-stream.d.ts +4 -0
  40. package/dist/types/dialect/qwen3.d.ts +9 -0
  41. package/dist/types/dialect/rendering.d.ts +54 -0
  42. package/dist/types/dialect/thinking.d.ts +6 -0
  43. package/dist/types/dialect/types.d.ts +68 -0
  44. package/dist/types/dialect/xml.d.ts +9 -0
  45. package/dist/types/error/abort.d.ts +14 -0
  46. package/dist/types/error/auth-classify.d.ts +20 -0
  47. package/dist/types/error/auth.d.ts +27 -0
  48. package/dist/types/error/aws.d.ts +27 -0
  49. package/dist/types/error/classes.d.ts +106 -0
  50. package/dist/types/error/finalize.d.ts +39 -0
  51. package/dist/types/error/flags.d.ts +94 -0
  52. package/dist/types/error/format.d.ts +20 -0
  53. package/dist/types/error/gateway.d.ts +20 -0
  54. package/dist/types/error/index.d.ts +14 -0
  55. package/dist/types/error/oauth.d.ts +43 -0
  56. package/dist/types/error/provider.d.ts +42 -0
  57. package/dist/types/error/rate-limit.d.ts +80 -0
  58. package/dist/types/error/retryable.d.ts +27 -0
  59. package/dist/types/error/validation.d.ts +32 -0
  60. package/dist/types/index.d.ts +51 -0
  61. package/dist/types/provider-details.d.ts +24 -0
  62. package/dist/types/providers/amazon-bedrock.d.ts +39 -0
  63. package/dist/types/providers/anthropic-client.d.ts +106 -0
  64. package/dist/types/providers/anthropic-messages-server-schema.d.ts +937 -0
  65. package/dist/types/providers/anthropic-messages-server.d.ts +17 -0
  66. package/dist/types/providers/anthropic-wire.d.ts +345 -0
  67. package/dist/types/providers/anthropic.d.ts +261 -0
  68. package/dist/types/providers/aws-credentials.d.ts +48 -0
  69. package/dist/types/providers/aws-eventstream.d.ts +39 -0
  70. package/dist/types/providers/aws-sigv4.d.ts +55 -0
  71. package/dist/types/providers/azure-openai-responses.d.ts +16 -0
  72. package/dist/types/providers/bedrock-mantle.d.ts +13 -0
  73. package/dist/types/providers/claude-code-fingerprint.d.ts +19 -0
  74. package/dist/types/providers/cowork-fetch.d.ts +3 -0
  75. package/dist/types/providers/cursor/exec-modern.d.ts +98 -0
  76. package/dist/types/providers/cursor-pi-args.d.ts +105 -0
  77. package/dist/types/providers/cursor.d.ts +221 -0
  78. package/dist/types/providers/devin.d.ts +12 -0
  79. package/dist/types/providers/error-message.d.ts +25 -0
  80. package/dist/types/providers/github-copilot-headers.d.ts +40 -0
  81. package/dist/types/providers/gitlab-duo-workflow.d.ts +254 -0
  82. package/dist/types/providers/gitlab-duo.d.ts +27 -0
  83. package/dist/types/providers/google-auth.d.ts +24 -0
  84. package/dist/types/providers/google-gemini-cli.d.ts +120 -0
  85. package/dist/types/providers/google-shared.d.ts +203 -0
  86. package/dist/types/providers/google-types.d.ts +155 -0
  87. package/dist/types/providers/google-vertex.d.ts +7 -0
  88. package/dist/types/providers/google.d.ts +4 -0
  89. package/dist/types/providers/grammar.d.ts +1 -0
  90. package/dist/types/providers/kimi.d.ts +27 -0
  91. package/dist/types/providers/mock.d.ts +179 -0
  92. package/dist/types/providers/ollama.d.ts +8 -0
  93. package/dist/types/providers/openai-anthropic-shim.d.ts +35 -0
  94. package/dist/types/providers/openai-chat-server-schema.d.ts +1311 -0
  95. package/dist/types/providers/openai-chat-server.d.ts +16 -0
  96. package/dist/types/providers/openai-chat-wire.d.ts +669 -0
  97. package/dist/types/providers/openai-codex/request-transformer.d.ts +109 -0
  98. package/dist/types/providers/openai-codex/response-handler.d.ts +26 -0
  99. package/dist/types/providers/openai-codex-responses.d.ts +238 -0
  100. package/dist/types/providers/openai-completions.d.ts +48 -0
  101. package/dist/types/providers/openai-reasoning-fallback.d.ts +25 -0
  102. package/dist/types/providers/openai-responses-server-schema.d.ts +1314 -0
  103. package/dist/types/providers/openai-responses-server.d.ts +17 -0
  104. package/dist/types/providers/openai-responses-wire.d.ts +6099 -0
  105. package/dist/types/providers/openai-responses.d.ts +131 -0
  106. package/dist/types/providers/openai-shared.d.ts +624 -0
  107. package/dist/types/providers/pi-native-client.d.ts +13 -0
  108. package/dist/types/providers/pi-native-server.d.ts +69 -0
  109. package/dist/types/providers/register-builtins.d.ts +37 -0
  110. package/dist/types/providers/synthetic.d.ts +26 -0
  111. package/dist/types/providers/transform-messages.d.ts +32 -0
  112. package/dist/types/providers/vision-guard.d.ts +20 -0
  113. package/dist/types/registry/aiand.d.ts +7 -0
  114. package/dist/types/registry/aimlapi.d.ts +4 -0
  115. package/dist/types/registry/alibaba-coding-plan.d.ts +8 -0
  116. package/dist/types/registry/alibaba-token-plan.d.ts +18 -0
  117. package/dist/types/registry/amazon-bedrock.d.ts +5 -0
  118. package/dist/types/registry/anthropic.d.ts +10 -0
  119. package/dist/types/registry/api-key-login.d.ts +42 -0
  120. package/dist/types/registry/api-key-validation.d.ts +43 -0
  121. package/dist/types/registry/aws.d.ts +13 -0
  122. package/dist/types/registry/azure.d.ts +4 -0
  123. package/dist/types/registry/baseten.d.ts +7 -0
  124. package/dist/types/registry/bedrock-mantle.d.ts +22 -0
  125. package/dist/types/registry/cerebras.d.ts +7 -0
  126. package/dist/types/registry/cloudflare-ai-gateway.d.ts +13 -0
  127. package/dist/types/registry/coreweave.d.ts +7 -0
  128. package/dist/types/registry/cursor.d.ts +7 -0
  129. package/dist/types/registry/deepseek.d.ts +8 -0
  130. package/dist/types/registry/derived.d.ts +5 -0
  131. package/dist/types/registry/devin.d.ts +8 -0
  132. package/dist/types/registry/exa.d.ts +8 -0
  133. package/dist/types/registry/firepass.d.ts +16 -0
  134. package/dist/types/registry/fireworks.d.ts +7 -0
  135. package/dist/types/registry/github-copilot.d.ts +7 -0
  136. package/dist/types/registry/gitlab-duo-workflow.d.ts +10 -0
  137. package/dist/types/registry/gitlab-duo.d.ts +9 -0
  138. package/dist/types/registry/gmi-cloud.d.ts +7 -0
  139. package/dist/types/registry/google-antigravity.d.ts +9 -0
  140. package/dist/types/registry/google-gemini-cli.d.ts +9 -0
  141. package/dist/types/registry/google-vertex.d.ts +5 -0
  142. package/dist/types/registry/google.d.ts +4 -0
  143. package/dist/types/registry/groq.d.ts +4 -0
  144. package/dist/types/registry/huggingface.d.ts +7 -0
  145. package/dist/types/registry/index.d.ts +4 -0
  146. package/dist/types/registry/kagi.d.ts +14 -0
  147. package/dist/types/registry/kilo.d.ts +7 -0
  148. package/dist/types/registry/kimi-code.d.ts +7 -0
  149. package/dist/types/registry/litellm.d.ts +13 -0
  150. package/dist/types/registry/llama-cpp.d.ts +8 -0
  151. package/dist/types/registry/lm-studio.d.ts +8 -0
  152. package/dist/types/registry/meta.d.ts +7 -0
  153. package/dist/types/registry/minimax-code-cn.d.ts +6 -0
  154. package/dist/types/registry/minimax-code.d.ts +6 -0
  155. package/dist/types/registry/minimax.d.ts +4 -0
  156. package/dist/types/registry/mistral.d.ts +4 -0
  157. package/dist/types/registry/moonshot.d.ts +7 -0
  158. package/dist/types/registry/nanogpt.d.ts +7 -0
  159. package/dist/types/registry/novita.d.ts +6 -0
  160. package/dist/types/registry/nvidia.d.ts +7 -0
  161. package/dist/types/registry/oauth/anthropic-constants.d.ts +12 -0
  162. package/dist/types/registry/oauth/anthropic.d.ts +24 -0
  163. package/dist/types/registry/oauth/callback-server.d.ts +74 -0
  164. package/dist/types/registry/oauth/cursor.d.ts +16 -0
  165. package/dist/types/registry/oauth/device-code.d.ts +25 -0
  166. package/dist/types/registry/oauth/devin.d.ts +5 -0
  167. package/dist/types/registry/oauth/github-copilot.d.ts +30 -0
  168. package/dist/types/registry/oauth/gitlab-duo-workflow.d.ts +6 -0
  169. package/dist/types/registry/oauth/gitlab-duo.d.ts +3 -0
  170. package/dist/types/registry/oauth/google-antigravity.d.ts +11 -0
  171. package/dist/types/registry/oauth/google-gemini-cli.d.ts +22 -0
  172. package/dist/types/registry/oauth/google-oauth-shared.d.ts +56 -0
  173. package/dist/types/registry/oauth/index.d.ts +45 -0
  174. package/dist/types/registry/oauth/kimi.d.ts +21 -0
  175. package/dist/types/registry/oauth/minimax-code.d.ts +27 -0
  176. package/dist/types/registry/oauth/openai-codex.d.ts +33 -0
  177. package/dist/types/registry/oauth/opencode.d.ts +18 -0
  178. package/dist/types/registry/oauth/perplexity.d.ts +9 -0
  179. package/dist/types/registry/oauth/pkce.d.ts +8 -0
  180. package/dist/types/registry/oauth/types.d.ts +87 -0
  181. package/dist/types/registry/oauth/wafer.d.ts +1 -0
  182. package/dist/types/registry/oauth/xai-oauth.d.ts +46 -0
  183. package/dist/types/registry/oauth/xiaomi.d.ts +25 -0
  184. package/dist/types/registry/oauth/zai.d.ts +25 -0
  185. package/dist/types/registry/ollama-cloud.d.ts +7 -0
  186. package/dist/types/registry/ollama.d.ts +12 -0
  187. package/dist/types/registry/openai-codex-device.d.ts +8 -0
  188. package/dist/types/registry/openai-codex.d.ts +9 -0
  189. package/dist/types/registry/openai.d.ts +4 -0
  190. package/dist/types/registry/opencode-go.d.ts +6 -0
  191. package/dist/types/registry/opencode-zen.d.ts +6 -0
  192. package/dist/types/registry/openrouter.d.ts +13 -0
  193. package/dist/types/registry/parallel.d.ts +14 -0
  194. package/dist/types/registry/perplexity.d.ts +7 -0
  195. package/dist/types/registry/qianfan.d.ts +7 -0
  196. package/dist/types/registry/qwen-portal.d.ts +7 -0
  197. package/dist/types/registry/registry.d.ts +367 -0
  198. package/dist/types/registry/sakana.d.ts +7 -0
  199. package/dist/types/registry/siliconflow-cn.d.ts +7 -0
  200. package/dist/types/registry/siliconflow.d.ts +7 -0
  201. package/dist/types/registry/synthetic.d.ts +6 -0
  202. package/dist/types/registry/tavily.d.ts +14 -0
  203. package/dist/types/registry/together.d.ts +6 -0
  204. package/dist/types/registry/types.d.ts +75 -0
  205. package/dist/types/registry/umans.d.ts +7 -0
  206. package/dist/types/registry/venice.d.ts +13 -0
  207. package/dist/types/registry/vercel-ai-gateway.d.ts +7 -0
  208. package/dist/types/registry/vllm.d.ts +7 -0
  209. package/dist/types/registry/wafer-serverless.d.ts +6 -0
  210. package/dist/types/registry/xai-oauth.d.ts +7 -0
  211. package/dist/types/registry/xai.d.ts +7 -0
  212. package/dist/types/registry/xiaomi-token-plan-ams.d.ts +6 -0
  213. package/dist/types/registry/xiaomi-token-plan-cn.d.ts +6 -0
  214. package/dist/types/registry/xiaomi-token-plan-sgp.d.ts +6 -0
  215. package/dist/types/registry/xiaomi.d.ts +6 -0
  216. package/dist/types/registry/zai.d.ts +15 -0
  217. package/dist/types/registry/zenmux.d.ts +7 -0
  218. package/dist/types/registry/zhipu-coding-plan.d.ts +7 -0
  219. package/dist/types/stream.d.ts +46 -0
  220. package/dist/types/types.d.ts +1064 -0
  221. package/dist/types/usage/alibaba-token-plan.d.ts +3 -0
  222. package/dist/types/usage/claude.d.ts +4 -0
  223. package/dist/types/usage/cursor.d.ts +4 -0
  224. package/dist/types/usage/gemini.d.ts +2 -0
  225. package/dist/types/usage/github-copilot.d.ts +7 -0
  226. package/dist/types/usage/google-antigravity.d.ts +15 -0
  227. package/dist/types/usage/kimi.d.ts +2 -0
  228. package/dist/types/usage/minimax-code.d.ts +3 -0
  229. package/dist/types/usage/ollama.d.ts +5 -0
  230. package/dist/types/usage/openai-codex-base-url.d.ts +18 -0
  231. package/dist/types/usage/openai-codex-reset.d.ts +88 -0
  232. package/dist/types/usage/openai-codex.d.ts +10 -0
  233. package/dist/types/usage/opencode-go.d.ts +2 -0
  234. package/dist/types/usage/shared.d.ts +1 -0
  235. package/dist/types/usage/synthetic.d.ts +2 -0
  236. package/dist/types/usage/umans.d.ts +2 -0
  237. package/dist/types/usage/xai-oauth.d.ts +12 -0
  238. package/dist/types/usage/zai.d.ts +3 -0
  239. package/dist/types/usage.d.ts +527 -0
  240. package/dist/types/utils/abort.d.ts +25 -0
  241. package/dist/types/utils/anthropic-auth.d.ts +35 -0
  242. package/dist/types/utils/aws-profile.d.ts +17 -0
  243. package/dist/types/utils/block-symbols.d.ts +62 -0
  244. package/dist/types/utils/deterministic-id.d.ts +16 -0
  245. package/dist/types/utils/empty-completion-retry.d.ts +21 -0
  246. package/dist/types/utils/event-stream.d.ts +39 -0
  247. package/dist/types/utils/foundry.d.ts +1 -0
  248. package/dist/types/utils/google-validation.d.ts +2 -0
  249. package/dist/types/utils/harmony-leak.d.ts +135 -0
  250. package/dist/types/utils/http-inspector.d.ts +49 -0
  251. package/dist/types/utils/idle-iterator.d.ts +149 -0
  252. package/dist/types/utils/leaked-thinking-stream.d.ts +33 -0
  253. package/dist/types/utils/openai-http.d.ts +48 -0
  254. package/dist/types/utils/openrouter-headers.d.ts +1 -0
  255. package/dist/types/utils/parse-bind.d.ts +23 -0
  256. package/dist/types/utils/provider-response.d.ts +3 -0
  257. package/dist/types/utils/proxy.d.ts +39 -0
  258. package/dist/types/utils/request-debug.d.ts +29 -0
  259. package/dist/types/utils/retry-after.d.ts +4 -0
  260. package/dist/types/utils/retry.d.ts +14 -0
  261. package/dist/types/utils/schema/adapt.d.ts +24 -0
  262. package/dist/types/utils/schema/compatibility.d.ts +30 -0
  263. package/dist/types/utils/schema/dereference.d.ts +11 -0
  264. package/dist/types/utils/schema/draft.d.ts +10 -0
  265. package/dist/types/utils/schema/equality.d.ts +4 -0
  266. package/dist/types/utils/schema/fields.d.ts +54 -0
  267. package/dist/types/utils/schema/index.d.ts +14 -0
  268. package/dist/types/utils/schema/json-schema-validator.d.ts +20 -0
  269. package/dist/types/utils/schema/meta-validator.d.ts +2 -0
  270. package/dist/types/utils/schema/normalize.d.ts +153 -0
  271. package/dist/types/utils/schema/spill.d.ts +8 -0
  272. package/dist/types/utils/schema/stamps.d.ts +17 -0
  273. package/dist/types/utils/schema/strict-tool-validation.d.ts +16 -0
  274. package/dist/types/utils/schema/types.d.ts +4 -0
  275. package/dist/types/utils/schema/typescript.d.ts +24 -0
  276. package/dist/types/utils/schema/wire.d.ts +52 -0
  277. package/dist/types/utils/sdk-stream-timeout.d.ts +33 -0
  278. package/dist/types/utils/sse-debug.d.ts +5 -0
  279. package/dist/types/utils/stream-markup-healing.d.ts +87 -0
  280. package/dist/types/utils/thinking-loop.d.ts +102 -0
  281. package/dist/types/utils/tool-call-loop-guard.d.ts +26 -0
  282. package/dist/types/utils/tool-choice.d.ts +52 -0
  283. package/dist/types/utils/validation.d.ts +28 -0
  284. package/dist/types/utils.d.ts +57 -0
  285. package/package.json +138 -0
  286. package/src/api-registry.ts +109 -0
  287. package/src/auth/sqlite-credential-store.ts +2066 -0
  288. package/src/auth-broker/client.ts +471 -0
  289. package/src/auth-broker/discover.ts +310 -0
  290. package/src/auth-broker/index.ts +7 -0
  291. package/src/auth-broker/refresher.ts +117 -0
  292. package/src/auth-broker/remote-store.ts +1332 -0
  293. package/src/auth-broker/server.ts +898 -0
  294. package/src/auth-broker/snapshot-cache.ts +200 -0
  295. package/src/auth-broker/types.ts +193 -0
  296. package/src/auth-broker/wire-schema-resource.ts +487 -0
  297. package/src/auth-broker/wire-schemas.ts +43 -0
  298. package/src/auth-gateway/http.ts +227 -0
  299. package/src/auth-gateway/index.ts +3 -0
  300. package/src/auth-gateway/server.ts +836 -0
  301. package/src/auth-gateway/types.ts +153 -0
  302. package/src/auth-retry.ts +401 -0
  303. package/src/auth-storage.ts +6540 -0
  304. package/src/dialect/anthropic.md +31 -0
  305. package/src/dialect/anthropic.ts +608 -0
  306. package/src/dialect/catalog.ts +29 -0
  307. package/src/dialect/coercion.ts +136 -0
  308. package/src/dialect/deepseek.md +24 -0
  309. package/src/dialect/deepseek.ts +609 -0
  310. package/src/dialect/demotion.ts +40 -0
  311. package/src/dialect/examples.ts +71 -0
  312. package/src/dialect/factory.ts +34 -0
  313. package/src/dialect/fenced-thinking.ts +184 -0
  314. package/src/dialect/gemini.md +44 -0
  315. package/src/dialect/gemini.ts +583 -0
  316. package/src/dialect/gemma.md +33 -0
  317. package/src/dialect/gemma.ts +387 -0
  318. package/src/dialect/glm.md +32 -0
  319. package/src/dialect/glm.ts +579 -0
  320. package/src/dialect/harmony.md +31 -0
  321. package/src/dialect/harmony.ts +345 -0
  322. package/src/dialect/hermes.md +25 -0
  323. package/src/dialect/hermes.ts +206 -0
  324. package/src/dialect/history.ts +81 -0
  325. package/src/dialect/index.ts +15 -0
  326. package/src/dialect/inventory.ts +30 -0
  327. package/src/dialect/kimi.md +24 -0
  328. package/src/dialect/kimi.ts +340 -0
  329. package/src/dialect/minimax.md +31 -0
  330. package/src/dialect/minimax.ts +95 -0
  331. package/src/dialect/owned-stream.ts +481 -0
  332. package/src/dialect/prompt-template.md +12 -0
  333. package/src/dialect/qwen3.md +28 -0
  334. package/src/dialect/qwen3.ts +240 -0
  335. package/src/dialect/rendering.ts +304 -0
  336. package/src/dialect/thinking.ts +292 -0
  337. package/src/dialect/types.ts +56 -0
  338. package/src/dialect/xml.md +22 -0
  339. package/src/dialect/xml.ts +90 -0
  340. package/src/error/abort.ts +18 -0
  341. package/src/error/auth-classify.ts +47 -0
  342. package/src/error/auth.ts +48 -0
  343. package/src/error/aws.ts +35 -0
  344. package/src/error/classes.ts +281 -0
  345. package/src/error/finalize.ts +69 -0
  346. package/src/error/flags.ts +602 -0
  347. package/src/error/format.ts +45 -0
  348. package/src/error/gateway.ts +96 -0
  349. package/src/error/index.ts +14 -0
  350. package/src/error/oauth.ts +58 -0
  351. package/src/error/provider.ts +63 -0
  352. package/src/error/rate-limit.ts +303 -0
  353. package/src/error/retryable.ts +70 -0
  354. package/src/error/validation.ts +44 -0
  355. package/src/index.ts +51 -0
  356. package/src/provider-details.ts +90 -0
  357. package/src/providers/amazon-bedrock.ts +1064 -0
  358. package/src/providers/anthropic-client.ts +317 -0
  359. package/src/providers/anthropic-messages-server-schema.ts +252 -0
  360. package/src/providers/anthropic-messages-server.ts +818 -0
  361. package/src/providers/anthropic-wire.ts +359 -0
  362. package/src/providers/anthropic.ts +4539 -0
  363. package/src/providers/aws-credentials.ts +772 -0
  364. package/src/providers/aws-eventstream.ts +181 -0
  365. package/src/providers/aws-sigv4.ts +218 -0
  366. package/src/providers/azure-openai-responses.ts +438 -0
  367. package/src/providers/bedrock-mantle.ts +110 -0
  368. package/src/providers/claude-code-fingerprint.ts +20 -0
  369. package/src/providers/cowork-fetch.ts +201 -0
  370. package/src/providers/cursor/exec-modern.ts +496 -0
  371. package/src/providers/cursor/proto/agent.proto +4533 -0
  372. package/src/providers/cursor/proto/buf.gen.yaml +6 -0
  373. package/src/providers/cursor/proto/buf.yaml +17 -0
  374. package/src/providers/cursor-pi-args.ts +165 -0
  375. package/src/providers/cursor.ts +4689 -0
  376. package/src/providers/devin/proto/buf/validate/validate.proto +468 -0
  377. package/src/providers/devin/proto/buf.gen.yaml +33 -0
  378. package/src/providers/devin/proto/buf.yaml +17 -0
  379. package/src/providers/devin/proto/cel/expr/checked.proto +103 -0
  380. package/src/providers/devin/proto/cel/expr/eval.proto +38 -0
  381. package/src/providers/devin/proto/cel/expr/explain.proto +15 -0
  382. package/src/providers/devin/proto/cel/expr/syntax.proto +113 -0
  383. package/src/providers/devin/proto/cel/expr/value.proto +41 -0
  384. package/src/providers/devin/proto/connectext/grpc/status/v1/status.proto +11 -0
  385. package/src/providers/devin/proto/errorspb/errors.proto +56 -0
  386. package/src/providers/devin/proto/errorspb/hintdetail.proto +7 -0
  387. package/src/providers/devin/proto/errorspb/markers.proto +10 -0
  388. package/src/providers/devin/proto/errorspb/tags.proto +12 -0
  389. package/src/providers/devin/proto/errorspb/testing.proto +6 -0
  390. package/src/providers/devin/proto/exa/analytics_pb/analytics.proto +188 -0
  391. package/src/providers/devin/proto/exa/api_server_pb/api_server.proto +2461 -0
  392. package/src/providers/devin/proto/exa/auth_pb/auth.proto +19 -0
  393. package/src/providers/devin/proto/exa/auto_cascade_common_pb/auto_cascade_common.proto +79 -0
  394. package/src/providers/devin/proto/exa/browser_preview_pb/browser_preview.proto +32 -0
  395. package/src/providers/devin/proto/exa/bug_checker_pb/bug_checker.proto +22 -0
  396. package/src/providers/devin/proto/exa/cascade_plugins_pb/cascade_plugins.proto +262 -0
  397. package/src/providers/devin/proto/exa/chat_client_server_pb/chat_client_server.proto +57 -0
  398. package/src/providers/devin/proto/exa/chat_pb/chat.proto +449 -0
  399. package/src/providers/devin/proto/exa/code_edit/code_edit_pb/code_edit.proto +186 -0
  400. package/src/providers/devin/proto/exa/codeium_common_pb/codeium_common.proto +4157 -0
  401. package/src/providers/devin/proto/exa/context_module_pb/context_module.proto +175 -0
  402. package/src/providers/devin/proto/exa/cortex_pb/cortex.proto +3268 -0
  403. package/src/providers/devin/proto/exa/dev_pb/dev.proto +26 -0
  404. package/src/providers/devin/proto/exa/diff_action_pb/diff_action.proto +75 -0
  405. package/src/providers/devin/proto/exa/eval/pr_eval/datasets_pb/datasets.proto +103 -0
  406. package/src/providers/devin/proto/exa/eval_pb/eval.proto +1315 -0
  407. package/src/providers/devin/proto/exa/extension_server_pb/extension_server.proto +556 -0
  408. package/src/providers/devin/proto/exa/file_system_provider_pb/file_system_provider.proto +75 -0
  409. package/src/providers/devin/proto/exa/index_pb/index.proto +461 -0
  410. package/src/providers/devin/proto/exa/knowledge_base_pb/knowledge_base.proto +144 -0
  411. package/src/providers/devin/proto/exa/language_server_pb/language_server.proto +2385 -0
  412. package/src/providers/devin/proto/exa/model_management_pb/model_management.proto +186 -0
  413. package/src/providers/devin/proto/exa/opensearch_clients_pb/opensearch_clients.proto +503 -0
  414. package/src/providers/devin/proto/exa/product_analytics_pb/product_analytics.proto +37 -0
  415. package/src/providers/devin/proto/exa/prompt_pb/prompt.proto +92 -0
  416. package/src/providers/devin/proto/exa/reactive_component_pb/reactive_component.proto +96 -0
  417. package/src/providers/devin/proto/exa/seat_management_pb/seat_management.proto +2680 -0
  418. package/src/providers/devin/proto/exa/tokenizer_pb/tokenizer.proto +37 -0
  419. package/src/providers/devin/proto/exa/trainer_pb/config.proto +647 -0
  420. package/src/providers/devin/proto/exa/tree_sitter/language_data_pb/language_data.proto +14 -0
  421. package/src/providers/devin/proto/exa/trust_pb/trust.proto +157 -0
  422. package/src/providers/devin/proto/exa/user_analytics_pb/user_analytics.proto +519 -0
  423. package/src/providers/devin/proto/google.golang.org/appengine/internal/base/api_base.proto +28 -0
  424. package/src/providers/devin/proto/google.golang.org/appengine/internal/datastore/datastore_v3.proto +484 -0
  425. package/src/providers/devin/proto/google.golang.org/appengine/internal/log/log_service.proto +136 -0
  426. package/src/providers/devin/proto/google.golang.org/appengine/internal/remote_api/remote_api.proto +42 -0
  427. package/src/providers/devin/proto/google.golang.org/appengine/internal/urlfetch/urlfetch_service.proto +61 -0
  428. package/src/providers/devin/proto/grpc/binlog/v1/binarylog.proto +84 -0
  429. package/src/providers/devin/proto/io/prometheus/client/metrics.proto +98 -0
  430. package/src/providers/devin.ts +679 -0
  431. package/src/providers/error-message.ts +23 -0
  432. package/src/providers/github-copilot-headers.ts +141 -0
  433. package/src/providers/gitlab-duo-workflow-chatml-note.md +1 -0
  434. package/src/providers/gitlab-duo-workflow.ts +3135 -0
  435. package/src/providers/gitlab-duo.ts +399 -0
  436. package/src/providers/google-auth.ts +330 -0
  437. package/src/providers/google-gemini-cli.ts +1370 -0
  438. package/src/providers/google-shared.ts +1122 -0
  439. package/src/providers/google-types.ts +180 -0
  440. package/src/providers/google-vertex.ts +135 -0
  441. package/src/providers/google.ts +47 -0
  442. package/src/providers/grammar.ts +70 -0
  443. package/src/providers/kimi.ts +51 -0
  444. package/src/providers/mock.ts +514 -0
  445. package/src/providers/ollama.ts +776 -0
  446. package/src/providers/openai-anthropic-shim.ts +166 -0
  447. package/src/providers/openai-chat-server-schema.ts +243 -0
  448. package/src/providers/openai-chat-server.ts +752 -0
  449. package/src/providers/openai-chat-wire.ts +859 -0
  450. package/src/providers/openai-codex/request-transformer.ts +491 -0
  451. package/src/providers/openai-codex/response-handler.ts +102 -0
  452. package/src/providers/openai-codex-responses.ts +4716 -0
  453. package/src/providers/openai-completions.ts +2389 -0
  454. package/src/providers/openai-reasoning-fallback.ts +269 -0
  455. package/src/providers/openai-responses-server-schema.ts +397 -0
  456. package/src/providers/openai-responses-server.ts +1466 -0
  457. package/src/providers/openai-responses-wire.ts +6416 -0
  458. package/src/providers/openai-responses.ts +1393 -0
  459. package/src/providers/openai-shared.ts +3500 -0
  460. package/src/providers/pi-native-client.ts +275 -0
  461. package/src/providers/pi-native-server.ts +245 -0
  462. package/src/providers/register-builtins.ts +503 -0
  463. package/src/providers/synthetic.ts +50 -0
  464. package/src/providers/transform-messages.ts +1083 -0
  465. package/src/providers/vision-guard.ts +54 -0
  466. package/src/registry/aiand.ts +22 -0
  467. package/src/registry/aimlapi.ts +6 -0
  468. package/src/registry/alibaba-coding-plan.ts +104 -0
  469. package/src/registry/alibaba-token-plan.ts +125 -0
  470. package/src/registry/amazon-bedrock.ts +22 -0
  471. package/src/registry/anthropic.ts +26 -0
  472. package/src/registry/api-key-login.ts +115 -0
  473. package/src/registry/api-key-validation.ts +145 -0
  474. package/src/registry/aws.ts +57 -0
  475. package/src/registry/azure.ts +6 -0
  476. package/src/registry/baseten.ts +22 -0
  477. package/src/registry/bedrock-mantle.ts +34 -0
  478. package/src/registry/cerebras.ts +23 -0
  479. package/src/registry/cloudflare-ai-gateway.ts +45 -0
  480. package/src/registry/coreweave.ts +40 -0
  481. package/src/registry/cursor.ts +20 -0
  482. package/src/registry/deepseek.ts +46 -0
  483. package/src/registry/derived.ts +9 -0
  484. package/src/registry/devin.ts +15 -0
  485. package/src/registry/exa.ts +19 -0
  486. package/src/registry/firepass.ts +32 -0
  487. package/src/registry/fireworks.ts +28 -0
  488. package/src/registry/github-copilot.ts +22 -0
  489. package/src/registry/gitlab-duo-workflow.ts +20 -0
  490. package/src/registry/gitlab-duo.ts +19 -0
  491. package/src/registry/gmi-cloud.ts +22 -0
  492. package/src/registry/google-antigravity.ts +22 -0
  493. package/src/registry/google-gemini-cli.ts +22 -0
  494. package/src/registry/google-vertex.ts +38 -0
  495. package/src/registry/google.ts +6 -0
  496. package/src/registry/groq.ts +6 -0
  497. package/src/registry/huggingface.ts +29 -0
  498. package/src/registry/index.ts +4 -0
  499. package/src/registry/kagi.ts +46 -0
  500. package/src/registry/kilo.ts +114 -0
  501. package/src/registry/kimi-code.ts +17 -0
  502. package/src/registry/litellm.ts +45 -0
  503. package/src/registry/llama-cpp.ts +35 -0
  504. package/src/registry/lm-studio.ts +31 -0
  505. package/src/registry/meta.ts +22 -0
  506. package/src/registry/minimax-code-cn.ts +12 -0
  507. package/src/registry/minimax-code.ts +12 -0
  508. package/src/registry/minimax.ts +6 -0
  509. package/src/registry/mistral.ts +6 -0
  510. package/src/registry/moonshot.ts +28 -0
  511. package/src/registry/nanogpt.ts +22 -0
  512. package/src/registry/novita.ts +25 -0
  513. package/src/registry/nvidia.ts +61 -0
  514. package/src/registry/oauth/anthropic-constants.ts +12 -0
  515. package/src/registry/oauth/anthropic.ts +346 -0
  516. package/src/registry/oauth/callback-server.ts +438 -0
  517. package/src/registry/oauth/cursor.ts +187 -0
  518. package/src/registry/oauth/device-code.ts +92 -0
  519. package/src/registry/oauth/devin.ts +124 -0
  520. package/src/registry/oauth/github-copilot.ts +369 -0
  521. package/src/registry/oauth/gitlab-duo-workflow.ts +146 -0
  522. package/src/registry/oauth/gitlab-duo.ts +222 -0
  523. package/src/registry/oauth/google-antigravity.ts +225 -0
  524. package/src/registry/oauth/google-gemini-cli.ts +297 -0
  525. package/src/registry/oauth/google-oauth-shared.ts +211 -0
  526. package/src/registry/oauth/index.ts +187 -0
  527. package/src/registry/oauth/kimi.ts +297 -0
  528. package/src/registry/oauth/minimax-code.ts +53 -0
  529. package/src/registry/oauth/oauth.html +317 -0
  530. package/src/registry/oauth/openai-codex.ts +384 -0
  531. package/src/registry/oauth/opencode.ts +50 -0
  532. package/src/registry/oauth/perplexity.ts +228 -0
  533. package/src/registry/oauth/pkce.ts +18 -0
  534. package/src/registry/oauth/types.ts +96 -0
  535. package/src/registry/oauth/wafer.ts +24 -0
  536. package/src/registry/oauth/xai-oauth.ts +559 -0
  537. package/src/registry/oauth/xiaomi.ts +211 -0
  538. package/src/registry/oauth/zai.ts +285 -0
  539. package/src/registry/ollama-cloud.ts +36 -0
  540. package/src/registry/ollama.ts +43 -0
  541. package/src/registry/openai-codex-device.ts +18 -0
  542. package/src/registry/openai-codex.ts +19 -0
  543. package/src/registry/openai.ts +6 -0
  544. package/src/registry/opencode-go.ts +12 -0
  545. package/src/registry/opencode-zen.ts +12 -0
  546. package/src/registry/openrouter.ts +28 -0
  547. package/src/registry/parallel.ts +45 -0
  548. package/src/registry/perplexity.ts +13 -0
  549. package/src/registry/qianfan.ts +27 -0
  550. package/src/registry/qwen-portal.ts +50 -0
  551. package/src/registry/registry.ts +182 -0
  552. package/src/registry/sakana.ts +22 -0
  553. package/src/registry/siliconflow-cn.ts +22 -0
  554. package/src/registry/siliconflow.ts +22 -0
  555. package/src/registry/synthetic.ts +21 -0
  556. package/src/registry/tavily.ts +45 -0
  557. package/src/registry/together.ts +22 -0
  558. package/src/registry/types.ts +86 -0
  559. package/src/registry/umans.ts +23 -0
  560. package/src/registry/venice.ts +33 -0
  561. package/src/registry/vercel-ai-gateway.ts +38 -0
  562. package/src/registry/vllm.ts +34 -0
  563. package/src/registry/wafer-serverless.ts +12 -0
  564. package/src/registry/xai-oauth.ts +17 -0
  565. package/src/registry/xai.ts +22 -0
  566. package/src/registry/xiaomi-token-plan-ams.ts +12 -0
  567. package/src/registry/xiaomi-token-plan-cn.ts +12 -0
  568. package/src/registry/xiaomi-token-plan-sgp.ts +12 -0
  569. package/src/registry/xiaomi.ts +12 -0
  570. package/src/registry/zai.ts +41 -0
  571. package/src/registry/zenmux.ts +22 -0
  572. package/src/registry/zhipu-coding-plan.ts +27 -0
  573. package/src/stream.ts +1944 -0
  574. package/src/types.ts +1243 -0
  575. package/src/usage/alibaba-token-plan.ts +230 -0
  576. package/src/usage/claude.ts +830 -0
  577. package/src/usage/cursor.ts +335 -0
  578. package/src/usage/gemini.ts +258 -0
  579. package/src/usage/github-copilot.ts +424 -0
  580. package/src/usage/google-antigravity.ts +497 -0
  581. package/src/usage/kimi.ts +277 -0
  582. package/src/usage/minimax-code.ts +291 -0
  583. package/src/usage/ollama.ts +41 -0
  584. package/src/usage/openai-codex-base-url.ts +35 -0
  585. package/src/usage/openai-codex-reset.ts +205 -0
  586. package/src/usage/openai-codex.ts +627 -0
  587. package/src/usage/opencode-go.ts +89 -0
  588. package/src/usage/shared.ts +10 -0
  589. package/src/usage/synthetic.ts +180 -0
  590. package/src/usage/umans.ts +192 -0
  591. package/src/usage/xai-oauth.ts +414 -0
  592. package/src/usage/zai.ts +370 -0
  593. package/src/usage.ts +411 -0
  594. package/src/utils/abort.ts +67 -0
  595. package/src/utils/anthropic-auth.ts +93 -0
  596. package/src/utils/aws-profile.ts +88 -0
  597. package/src/utils/block-symbols.ts +78 -0
  598. package/src/utils/deterministic-id.ts +20 -0
  599. package/src/utils/empty-completion-retry.ts +161 -0
  600. package/src/utils/event-stream.ts +202 -0
  601. package/src/utils/foundry.ts +8 -0
  602. package/src/utils/google-validation.ts +25 -0
  603. package/src/utils/harmony-leak.ts +500 -0
  604. package/src/utils/http-inspector.ts +196 -0
  605. package/src/utils/idle-iterator.ts +531 -0
  606. package/src/utils/leaked-thinking-stream.ts +483 -0
  607. package/src/utils/openai-http.ts +119 -0
  608. package/src/utils/openrouter-headers.ts +12 -0
  609. package/src/utils/parse-bind.ts +56 -0
  610. package/src/utils/provider-response.ts +30 -0
  611. package/src/utils/proxy.ts +314 -0
  612. package/src/utils/request-debug.ts +351 -0
  613. package/src/utils/retry-after.ts +121 -0
  614. package/src/utils/retry.ts +77 -0
  615. package/src/utils/schema/CONSTRAINTS.md +168 -0
  616. package/src/utils/schema/adapt.ts +36 -0
  617. package/src/utils/schema/compatibility.ts +435 -0
  618. package/src/utils/schema/dereference.ts +98 -0
  619. package/src/utils/schema/draft.ts +341 -0
  620. package/src/utils/schema/equality.ts +97 -0
  621. package/src/utils/schema/fields.ts +210 -0
  622. package/src/utils/schema/index.ts +14 -0
  623. package/src/utils/schema/json-schema-validator.ts +595 -0
  624. package/src/utils/schema/meta-validator.ts +167 -0
  625. package/src/utils/schema/normalize.ts +2314 -0
  626. package/src/utils/schema/spill.ts +43 -0
  627. package/src/utils/schema/stamps.ts +109 -0
  628. package/src/utils/schema/strict-tool-validation.ts +117 -0
  629. package/src/utils/schema/types.ts +10 -0
  630. package/src/utils/schema/typescript.ts +212 -0
  631. package/src/utils/schema/wire.ts +662 -0
  632. package/src/utils/sdk-stream-timeout.ts +43 -0
  633. package/src/utils/sse-debug.ts +18 -0
  634. package/src/utils/stream-markup-healing.ts +247 -0
  635. package/src/utils/thinking-loop.ts +552 -0
  636. package/src/utils/tool-call-loop-guard.ts +107 -0
  637. package/src/utils/tool-choice.ts +101 -0
  638. package/src/utils/validation.ts +1932 -0
  639. package/src/utils.ts +492 -0
@@ -0,0 +1,3135 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import {
4
+ discoverGitLabDuoWorkflowRuntimeNamespace,
5
+ type GitLabDuoWorkflowNamespaceSelection,
6
+ } from "@linxiraos/pi-catalog/discovery/gitlab-duo-workflow";
7
+ import * as AIError from "../error";
8
+ import type {
9
+ Api,
10
+ AssistantMessage,
11
+ Context,
12
+ FetchImpl,
13
+ Message,
14
+ Model,
15
+ ProviderSessionState,
16
+ StreamFunction,
17
+ StreamOptions,
18
+ Tool,
19
+ ToolCall,
20
+ ToolChoice,
21
+ ToolResultMessage,
22
+ } from "../types";
23
+ import { normalizeSystemPrompts } from "../utils";
24
+ import { AssistantMessageEventStream } from "../utils/event-stream";
25
+ import { toolWireSchema } from "../utils/schema/wire";
26
+ import chatmlHistoryNote from "./gitlab-duo-workflow-chatml-note.md" with { type: "text" };
27
+ import { redactSensitiveCredentials } from "./transform-messages";
28
+
29
+ export const GITLAB_DUO_WORKFLOW_PROVIDER_ID = "gitlab-duo-agent";
30
+ export const GITLAB_DUO_WORKFLOW_API = "gitlab-duo-agent";
31
+ export const GITLAB_DUO_WORKFLOW_DEFINITION = "ambient";
32
+ export type GitLabDuoWorkflowDefinition = "ambient" | (string & {});
33
+
34
+ const DEFAULT_GITLAB_BASE_URL = "https://gitlab.com";
35
+ const GITLAB_DUO_WORKFLOW_TRACE_ENV = "GITLAB_DUO_WORKFLOW_TRACE";
36
+ const GITLAB_DUO_WORKFLOW_TRACE_FILE_ENV = "GITLAB_DUO_WORKFLOW_TRACE_FILE";
37
+ const DEFAULT_GITLAB_DUO_WORKFLOW_TRACE_FILE = path.resolve(
38
+ import.meta.dir,
39
+ "../../../../.tmp/gitlab-duo-workflow-trace.log",
40
+ );
41
+ const GITLAB_DUO_WORKFLOW_CLIENT_TYPE = "node-websocket";
42
+ /**
43
+ * Idle deadline for the workflow WebSocket. The socket has no server-side
44
+ * keepalive contract OMP can rely on, so a connection silently going half-open
45
+ * (proxy/LB drops the TCP link without delivering FIN/RST) would otherwise leave
46
+ * `runGitLabDuoWorkflowSocket` waiting forever. If no frame arrives within this
47
+ * window — before open or between checkpoints — the socket is aborted and the
48
+ * run reconnects once on the same `workflowID` (server-side resume).
49
+ */
50
+ const GITLAB_DUO_WORKFLOW_IDLE_TIMEOUT_MS = 90_000;
51
+ /**
52
+ * Absolute deadline (ms) for each REST setup fetch (`ensureGitLabDuoWorkflowSettings`,
53
+ * `discoverGitLabDuoWorkflowProject`, `resolveGitLabDuoWorkflowNumericProjectId`,
54
+ * `requestGitLabDuoWorkflowDirectAccess`, `createGitLabDuoWorkflow`,
55
+ * `fetchGitLabDuoWorkflowAvailableModels`, `stopGitLabDuoWorkflow`).
56
+ *
57
+ * `streamGitLabDuoWorkflow` pushes its `start` event before these calls run and the
58
+ * `gitlab-duo-agent` bypass in `streamSimple` skips the `register-builtins`
59
+ * `iterateWithIdleTimeout` wrapper, so a stalled setup fetch would otherwise leave
60
+ * the stream with no terminal event. 30s covers healthy p99 for every REST endpoint
61
+ * the workflow touches while still surfacing a real stall as a provider error;
62
+ * matches the OAuth `TOKEN_REQUEST_TIMEOUT_MS` used by sibling GitLab flows.
63
+ */
64
+ const GITLAB_DUO_WORKFLOW_REST_TIMEOUT_MS = 30_000;
65
+ /**
66
+ * How many times a single stream may restart on a FRESH workflow after the server
67
+ * reports its per-workflow step (graph-recursion) limit. Long OMP tool-call loops
68
+ * legitimately overrun the cap; each restart resets the budget. Bounded so a task
69
+ * that perpetually overruns degrades to a graceful stop instead of looping on quota.
70
+ */
71
+ const GITLAB_DUO_WORKFLOW_MAX_STEP_LIMIT_RESTARTS = 4;
72
+ /**
73
+ * How many times a single stream may restart on a FRESH workflow after the server
74
+ * returns its de-identified catch-all FAILED (transient upstream fault wrapper).
75
+ * Kept low because, unlike the step limit, a generic failure that repeats is more
76
+ * likely deterministic; one bounded retry covers the common transient case without
77
+ * looping on quota.
78
+ */
79
+ const GITLAB_DUO_WORKFLOW_MAX_GENERIC_ERROR_RETRIES = 1;
80
+ /**
81
+ * How many times a single stream may restart on a FRESH workflow after detecting a
82
+ * stalled workflow: the server emitted a fresh checkpoint at a tool-call boundary
83
+ * but its `ui_chat_log` total did NOT advance past the previous tool-call boundary
84
+ * of the SAME workflow. A healthy run strictly grows the log each turn (agent
85
+ * reasoning + tool boundary entries); a flat total means the server-side turn did
86
+ * not progress — the model re-issues the same tool call against a history that
87
+ * never gained its prior call/result (captured live: total pinned at 2 while the
88
+ * model repeated `next_step({"n":1})`). Restarting on a fresh workflow resends the
89
+ * full goal transcript (rebuilt from the agent loop's intact `context.messages`,
90
+ * so no in-flight tool result is lost) and the new run progresses. Bounded so a
91
+ * persistently stalling endpoint degrades to a surfaced result instead of a quota
92
+ * sink.
93
+ */
94
+ const GITLAB_DUO_WORKFLOW_MAX_STALL_RESTARTS = 2;
95
+ /**
96
+ * Surfaced when a workflow stalled (its `ui_chat_log` total stopped advancing) and
97
+ * every bounded fresh-workflow restart also stalled. Phrased as a transient
98
+ * server-side failure so the agent loop treats it as a normal error rather than a
99
+ * client bug.
100
+ */
101
+ const GITLAB_DUO_WORKFLOW_STALL_ERROR_MESSAGE =
102
+ "GitLab Duo Agent stopped making progress (the workflow's visible history did not advance after multiple restarts).";
103
+ /**
104
+ * Two rendered-`goal` byte thresholds bounding three reliability zones. Empirically
105
+ * the DWS/Workhorse transport accepts no fixed token wall (it has tokenized
106
+ * 970k-token goals) but its failure probability rises with the rendered-goal BYTE
107
+ * size: ≤~1MB is the reliable floor we now treat as the auto-compaction trigger,
108
+ * ~1.4–1.7MB is a jitter band where a request fails more often than not but can still
109
+ * go through, ≥~2MB basically always fails, and 4MB is the DWS gRPC `MAX_MESSAGE_SIZE`
110
+ * hard cap. The soft threshold was lowered from 1.25MB to 1MB because the higher value
111
+ * almost never fired in practice — auto-compaction needs to engage earlier.
112
+ *
113
+ * - `[0, SOFT)` reliable zone: send normally; an error here is a genuine upstream
114
+ * fault and surfaces verbatim.
115
+ * - `[SOFT, HARD)` jitter zone: still attempt once (it can succeed); if the run then
116
+ * ERRORS, the size is the likely cause, so re-label it as a context-overflow to
117
+ * drive auto-compaction.
118
+ * - `[HARD, ∞)` necessary-fail zone: do NOT spend the request — proactively end the
119
+ * stream with the overflow error so the session compacts immediately.
120
+ *
121
+ * `SOFT` is the auto-compaction trigger floor; `HARD` is the necessary-fail floor.
122
+ * Re-labeling uses {@link buildGitLabDuoWorkflowGoalOverflowMessage}.
123
+ */
124
+ const GITLAB_DUO_WORKFLOW_GOAL_SOFT_OVERFLOW_BYTES = 1_048_576;
125
+ const GITLAB_DUO_WORKFLOW_GOAL_HARD_OVERFLOW_BYTES = 2_000_000;
126
+
127
+ // An overflow-pattern message for an oversized goal. The "prompt is too long" prefix
128
+ // is one of the shared overflow classifier patterns, so
129
+ // `isContextOverflow` recognizes it and the session triggers auto-compaction instead
130
+ // of surfacing a hard failure. Byte counts (not tokens) are reported because the
131
+ // budget is a byte budget.
132
+ function buildGitLabDuoWorkflowGoalOverflowMessage(goalBytes: number): string {
133
+ return `prompt is too long: ${goalBytes} bytes exceeds the GitLab Duo Agent goal byte budget (soft ${GITLAB_DUO_WORKFLOW_GOAL_SOFT_OVERFLOW_BYTES}, hard ${GITLAB_DUO_WORKFLOW_GOAL_HARD_OVERFLOW_BYTES})`;
134
+ }
135
+ const GITLAB_DUO_WORKFLOW_LANGUAGE_SERVER_VERSION = "8.104.0";
136
+ const GITLAB_DUO_WORKFLOW_AVAILABLE_MODELS_QUERY = `query omp_gitlabDuoWorkflowAvailableModels($rootNamespaceId: GroupID!) {
137
+ aiChatAvailableModels(rootNamespaceId: $rootNamespaceId) {
138
+ defaultModel { name ref }
139
+ selectableModels { name ref }
140
+ pinnedModel { name ref }
141
+ }
142
+ }`;
143
+
144
+ export const GITLAB_DUO_WORKFLOW_CLIENT_CAPABILITIES = [
145
+ "incremental_streaming",
146
+ "read_file_chunked",
147
+ "shell_command",
148
+ "command_timeout",
149
+ "tool_call_approval",
150
+ ] as const;
151
+
152
+ const GITLAB_DUO_WORKFLOW_INLINE_AGENT_NAME = "omp_agent";
153
+ const GITLAB_DUO_WORKFLOW_INLINE_PROMPT_ID = "omp_inline_prompt";
154
+ // `on_agent_reasoning` is what makes the server tag an agent's pre-tool-call
155
+ // commentary as `message_sub_type: "reasoning"` — the chain-of-thought the
156
+ // official Duo CLI surfaces. An inline flow must opt in explicitly.
157
+ const GITLAB_DUO_WORKFLOW_INLINE_UI_LOG_EVENTS = [
158
+ "on_agent_reasoning",
159
+ "on_agent_final_answer",
160
+ "on_tool_execution_success",
161
+ "on_tool_execution_failed",
162
+ ] as const;
163
+
164
+ const GITLAB_DUO_WORKFLOW_ACTION_NAMES = ["runMCPTool", "run_mcp_tool"] as const;
165
+
166
+ export interface GitLabMcpToolArgs {
167
+ name?: string;
168
+ tool_name?: string;
169
+ toolName?: string;
170
+ providerIdentifier?: string;
171
+ provider_identifier?: string;
172
+ toolCallId?: string;
173
+ tool_call_id?: string;
174
+ args?: Record<string, unknown> | string;
175
+ arguments?: Record<string, unknown> | string;
176
+ }
177
+
178
+ export interface GitLabPlainTextResponse {
179
+ response?: string;
180
+ error?: string;
181
+ }
182
+
183
+ export type PlainTextResponse = GitLabPlainTextResponse;
184
+ export interface GitLabDuoWorkflowOptions extends StreamOptions {
185
+ rootNamespaceId?: string;
186
+ namespaceId?: string;
187
+ projectId?: string;
188
+ projectPath?: string;
189
+ workflowDefinition?: GitLabDuoWorkflowDefinition;
190
+ workflowId?: string;
191
+ workflowToken?: string;
192
+ cwd?: string;
193
+ webSocketFactory?: GitLabDuoWorkflowWebSocketFactory;
194
+ /** Idle WebSocket deadline (ms) before aborting and resuming; defaults to {@link GITLAB_DUO_WORKFLOW_IDLE_TIMEOUT_MS}. */
195
+ idleTimeoutMs?: number;
196
+ /**
197
+ * Tool-choice override forwarded from the stream layer. Only `"none"` is
198
+ * acted on: a side-request (e.g. handoff) keeps tool definitions in the cache
199
+ * prefix but disables tool use, so the provider must not advertise them to Duo.
200
+ */
201
+ toolChoice?: ToolChoice;
202
+ }
203
+
204
+ export interface GitLabDuoWorkflowWebSocketLike {
205
+ readyState?: number;
206
+ binaryType?: string;
207
+ onopen: ((event: Event) => void) | null;
208
+ onmessage: ((event: MessageEvent) => void) | null;
209
+ onerror: ((event: Event) => void) | null;
210
+ onclose: ((event: CloseEvent) => void) | null;
211
+ send(data: string): void;
212
+ close(code?: number, reason?: string): void;
213
+ }
214
+
215
+ export interface GitLabDuoWorkflowWebSocketFactoryOptions {
216
+ headers: Record<string, string>;
217
+ protocols?: string[];
218
+ }
219
+
220
+ export type GitLabDuoWorkflowWebSocketFactory = (
221
+ url: string,
222
+ options: GitLabDuoWorkflowWebSocketFactoryOptions,
223
+ ) => GitLabDuoWorkflowWebSocketLike;
224
+
225
+ export interface GitLabDirectAccessResponse {
226
+ token?: string;
227
+ access_token?: string;
228
+ jwt?: string;
229
+ workflow_token?: string;
230
+ duo_workflow_access_token?: string;
231
+ duo_workflow_service?: { token?: string; base_url?: string; headers?: Record<string, string> };
232
+ gitlab_rails?: { token?: string };
233
+ [key: string]: unknown;
234
+ }
235
+
236
+ interface GitLabDuoWorkflowDirectAccessConnection {
237
+ token: string;
238
+ baseUrl?: string;
239
+ headers: Record<string, string>;
240
+ serviceEndpoint: boolean;
241
+ }
242
+
243
+ interface GitLabCreateWorkflowResponse {
244
+ id?: string | number;
245
+ workflow_id?: string | number;
246
+ workflowId?: string | number;
247
+ [key: string]: unknown;
248
+ }
249
+
250
+ interface GitLabDuoWorkflowCreateBodyOptions {
251
+ projectId?: string;
252
+ goal?: string;
253
+ workflowDefinition?: GitLabDuoWorkflowDefinition;
254
+ }
255
+
256
+ interface GitLabDuoWorkflowStartMetadataOptions {
257
+ projectId?: string;
258
+ projectPath?: string;
259
+ namespaceId?: string;
260
+ rootNamespaceId?: string;
261
+ workflowDefinition?: GitLabDuoWorkflowDefinition;
262
+ inlineFlow?: boolean;
263
+ }
264
+ export interface GitLabMcpToolDefinition {
265
+ name: string;
266
+ originalToolName: string;
267
+ serverName: string;
268
+ description: string;
269
+ inputSchema: string;
270
+ isApproved: boolean;
271
+ }
272
+
273
+ export interface GitLabDuoWorkflowAdditionalContextItem {
274
+ id: string;
275
+ category: "agent_user_environment" | "user_rule";
276
+ content: string;
277
+ metadata: {
278
+ title: string;
279
+ enabled: boolean;
280
+ subType: "snippet";
281
+ icon: string;
282
+ secondaryText: string;
283
+ subTypeLabel: string;
284
+ };
285
+ }
286
+
287
+ export interface GitLabDuoWorkflowStartRequest {
288
+ workflowID: string;
289
+ clientVersion: "1.0";
290
+ workflowDefinition: GitLabDuoWorkflowDefinition;
291
+ goal: string;
292
+ workflowMetadata: string;
293
+ additional_context: readonly GitLabDuoWorkflowAdditionalContextItem[];
294
+ approval?: {
295
+ approval?: Record<string, never>;
296
+ rejection?: { message?: string };
297
+ };
298
+ clientCapabilities: readonly (typeof GITLAB_DUO_WORKFLOW_CLIENT_CAPABILITIES)[number][];
299
+ mcpTools: GitLabMcpToolDefinition[];
300
+ preapproved_tools: string[];
301
+ flowConfigSchemaVersion?: "v1";
302
+ flowConfigId?: string;
303
+ flowVersion?: string;
304
+ flowConfig?: GitLabDuoWorkflowInlineFlowConfig;
305
+ }
306
+
307
+ export interface GitLabDuoWorkflowInlineFlowComponent {
308
+ name: string;
309
+ type: "AgentComponent";
310
+ prompt_id: string;
311
+ toolset: string[];
312
+ inputs: { from: string; as: string }[];
313
+ ui_log_events: string[];
314
+ }
315
+
316
+ export interface GitLabDuoWorkflowInlineFlowPrompt {
317
+ name: string;
318
+ prompt_id: string;
319
+ unit_primitives: string[];
320
+ prompt_template: { system: string; user: string; placeholder: string };
321
+ }
322
+
323
+ export interface GitLabDuoWorkflowInlineFlowConfig {
324
+ version: "v1";
325
+ environment: "ambient";
326
+ flow: { entry_point: string };
327
+ components: GitLabDuoWorkflowInlineFlowComponent[];
328
+ routers: { from: string; to: string }[];
329
+ prompts: GitLabDuoWorkflowInlineFlowPrompt[];
330
+ }
331
+
332
+ export interface GitLabDuoWorkflowActionResponse {
333
+ actionResponse: {
334
+ requestID: string;
335
+ plainTextResponse?: GitLabPlainTextResponse;
336
+ };
337
+ }
338
+
339
+ interface GitLabDuoWorkflowActionDescriptor {
340
+ requestID: string;
341
+ name: string;
342
+ args: unknown;
343
+ }
344
+
345
+ export interface GitLabDuoWorkflowActiveSession {
346
+ workflowId: string;
347
+ startPayload: GitLabDuoWorkflowStartRequest;
348
+ ws: GitLabDuoWorkflowWebSocketLike;
349
+ // Best-effort server-side stop for THIS workflow, captured with its own
350
+ // fetch/baseUrl/apiKey so `ProviderSessionState.close()` (session reset/dispose)
351
+ // can stop a workflow the server is still running, even though it holds none of
352
+ // that context itself. Fire-and-forget; never throws.
353
+ stop?: () => void;
354
+ pendingActions?: GitLabDuoWorkflowActionDescriptor[];
355
+ checkpointAgentContentByKey?: Record<string, string>;
356
+ checkpointAgentContentSignatures?: Record<string, true>;
357
+ paused?: boolean;
358
+ pauseBuffer?: unknown[];
359
+ // Byte length of the server's last checkpoint observed at this workflow's tool-call
360
+ // boundaries. The control experiment proved a healthy turn emits checkpoints whose
361
+ // byte size varies and progresses, while a stalled workflow re-emits a byte-identical
362
+ // checkpoint — so equal lengths across consecutive boundaries flag a stall (see
363
+ // GITLAB_DUO_WORKFLOW_MAX_STALL_RESTARTS). Persisted on the session so the comparison
364
+ // survives the resume that reuses this socket.
365
+ lastToolBoundaryContentLength?: number;
366
+ }
367
+
368
+ export interface GitLabDuoWorkflowProviderSessionState extends ProviderSessionState {
369
+ active?: GitLabDuoWorkflowActiveSession;
370
+ }
371
+
372
+ export interface GitLabDuoWorkflowStreamState {
373
+ stream: AssistantMessageEventStream;
374
+ output: AssistantMessage;
375
+ activeTextIndex?: number;
376
+ activeThinkingIndex?: number;
377
+ activeCheckpointMessageKey?: string;
378
+ started: boolean;
379
+ checkpointAgentContentByKey?: Record<string, string>;
380
+ checkpointAgentContentSignatures?: Record<string, true>;
381
+ pauseRequested?: boolean;
382
+ stepLimitRequested?: boolean;
383
+ retryableErrorRequested?: boolean;
384
+ // Byte length of the server's latest checkpoint seen this socket run; the action
385
+ // handler compares it against the previous tool-call boundary's length to detect a
386
+ // stall (a byte-identical checkpoint means the server-side turn did not advance).
387
+ lastCheckpointContentLength?: number;
388
+ // Set when a tool-call boundary's checkpoint byte length did not change from the
389
+ // previous boundary — the socket settles "stalled" so the run restarts fresh.
390
+ stalledRequested?: boolean;
391
+ providerSessionState?: GitLabDuoWorkflowProviderSessionState;
392
+ lastApprovalStatus?: string;
393
+ // When the rendered goal exceeds the byte budget, this carries an overflow-pattern
394
+ // message. A terminal/exhausted error then surfaces THIS instead of the raw server
395
+ // error so `isContextOverflow` recognizes it and the agent loop auto-compacts. Left
396
+ // undefined for a goal within budget, so ordinary errors surface verbatim.
397
+ goalOverflowMessage?: string;
398
+ }
399
+
400
+ type GitLabDuoWorkflowSocketResult =
401
+ | "closed"
402
+ | "terminal"
403
+ | "approval"
404
+ | "action"
405
+ | "pause"
406
+ | "timeout"
407
+ | "step_limit"
408
+ | "retryable_error"
409
+ | "stalled";
410
+
411
+ export interface GitLabAvailableModel {
412
+ name?: string | null;
413
+ ref?: string | null;
414
+ }
415
+
416
+ export interface GitLabAvailableModelsPayload {
417
+ pinnedModel?: GitLabAvailableModel | null;
418
+ selectedModel?: GitLabAvailableModel | null;
419
+ defaultModel?: GitLabAvailableModel | null;
420
+ selectableModels?: GitLabAvailableModel[] | null;
421
+ }
422
+
423
+ export const streamGitLabDuoWorkflow: StreamFunction<"gitlab-duo-agent"> = (
424
+ model: Model<"gitlab-duo-agent">,
425
+ context: Context,
426
+ options: GitLabDuoWorkflowOptions,
427
+ ): AssistantMessageEventStream => {
428
+ const stream = new AssistantMessageEventStream();
429
+ const output = createAssistantMessage(model);
430
+ stream.push({ type: "start", partial: output });
431
+ const state: GitLabDuoWorkflowStreamState = { stream, output, started: true };
432
+
433
+ void runGitLabDuoWorkflow(model, context, options, state).catch(error => {
434
+ const errorText = gitLabDuoWorkflowErrorText(error);
435
+ if (!stream.done) {
436
+ output.stopReason = "error";
437
+ // A throw (socket reject, abnormal 1006 close, …) on a goal already past the
438
+ // byte budget is almost certainly the oversized request — surface it as a
439
+ // context-overflow so the session auto-compacts rather than hard-failing.
440
+ output.errorMessage = state.goalOverflowMessage ?? errorText;
441
+ stream.push({ type: "error", reason: "error", error: output });
442
+ }
443
+ });
444
+
445
+ return stream;
446
+ };
447
+
448
+ export function buildGitLabDuoWorkflowDirectAccessBody(
449
+ rootNamespaceId: string,
450
+ projectId?: string,
451
+ workflowDefinition: GitLabDuoWorkflowDefinition = GITLAB_DUO_WORKFLOW_DEFINITION,
452
+ ): Record<string, string> {
453
+ return {
454
+ workflow_definition: workflowDefinition,
455
+ root_namespace_id: toGitLabGraphQLNamespaceId(rootNamespaceId),
456
+ ...(projectId ? { project_id: projectId } : undefined),
457
+ };
458
+ }
459
+
460
+ export function buildGitLabDuoWorkflowCreateBody(
461
+ namespaceId?: string,
462
+ options: GitLabDuoWorkflowCreateBodyOptions = {},
463
+ ): Record<string, string | boolean | string[] | number[]> {
464
+ return {
465
+ workflow_definition: options.workflowDefinition ?? GITLAB_DUO_WORKFLOW_DEFINITION,
466
+ environment: "ide",
467
+ allow_agent_to_request_user: false,
468
+ agent_privileges: [6],
469
+ pre_approved_agent_privileges: [6],
470
+ requires_duo_cli_enabled: false,
471
+ ...(namespaceId && !options.projectId ? { namespace_id: namespaceId } : undefined),
472
+ ...(options.projectId ? { project_id: options.projectId } : undefined),
473
+ ...(options.goal !== undefined ? { goal: options.goal } : { goal: "" }),
474
+ };
475
+ }
476
+
477
+ export function buildGitLabDuoWorkflowStopBody(): Record<string, string> {
478
+ return { status_event: "stop" };
479
+ }
480
+
481
+ export function buildGitLabDuoWorkflowWebSocketUrl(
482
+ baseUrl: string,
483
+ options: {
484
+ projectId?: string;
485
+ namespaceId?: string;
486
+ rootNamespaceId?: string;
487
+ selectedModelIdentifier?: string;
488
+ workflowDefinition?: GitLabDuoWorkflowDefinition;
489
+ serviceEndpoint?: boolean;
490
+ } = {},
491
+ ): string {
492
+ // serviceEndpoint connects to the DWS runway host (root path); otherwise route to the
493
+ // GitLab instance, preserving any relative install base path (e.g. `https://host/gitlab`).
494
+ const wsUrl = options.serviceEndpoint
495
+ ? new URL("/", normalizeGitLabBaseUrl(baseUrl))
496
+ : gitLabApiUrl(baseUrl, "/api/v4/ai/duo_workflows/ws");
497
+ wsUrl.protocol = wsUrl.protocol === "http:" ? "ws:" : "wss:";
498
+ if (options.projectId) wsUrl.searchParams.set("project_id", options.projectId);
499
+ if (options.namespaceId && !options.serviceEndpoint)
500
+ wsUrl.searchParams.set("namespace_id", toGitLabRestNamespaceId(options.namespaceId));
501
+ if (options.rootNamespaceId)
502
+ wsUrl.searchParams.set("root_namespace_id", toGitLabRestNamespaceId(options.rootNamespaceId));
503
+ if (options.selectedModelIdentifier)
504
+ wsUrl.searchParams.set("user_selected_model_identifier", options.selectedModelIdentifier);
505
+ if (options.workflowDefinition) wsUrl.searchParams.set("workflow_definition", options.workflowDefinition);
506
+ return wsUrl.toString();
507
+ }
508
+
509
+ export function buildGitLabDuoWorkflowWebSocketHeaders(options: {
510
+ token: string;
511
+ baseUrl?: string;
512
+ projectId?: string;
513
+ namespaceId?: string;
514
+ rootNamespaceId?: string;
515
+ extraHeaders?: Record<string, string>;
516
+ }): Record<string, string> {
517
+ const base = new URL(normalizeGitLabBaseUrl(options.baseUrl ?? DEFAULT_GITLAB_BASE_URL));
518
+ return {
519
+ ...options.extraHeaders,
520
+ authorization: `Bearer ${options.token}`,
521
+ "x-gitlab-client-type": GITLAB_DUO_WORKFLOW_CLIENT_TYPE,
522
+ "x-gitlab-language-server-version": GITLAB_DUO_WORKFLOW_LANGUAGE_SERVER_VERSION,
523
+ "user-agent": `unknown/unknown unknown/unknown gitlab-language-server/${GITLAB_DUO_WORKFLOW_LANGUAGE_SERVER_VERSION}`,
524
+ origin: base.origin,
525
+ ...(options.projectId ? { "x-gitlab-project-id": options.projectId } : {}),
526
+ ...(options.namespaceId ? { "x-gitlab-namespace-id": toGitLabRestNamespaceId(options.namespaceId) } : {}),
527
+ ...(options.rootNamespaceId
528
+ ? { "x-gitlab-root-namespace-id": toGitLabRestNamespaceId(options.rootNamespaceId) }
529
+ : {}),
530
+ };
531
+ }
532
+ export function buildGitLabDuoWorkflowStartRequest(
533
+ workflowId: string,
534
+ model: Model<"gitlab-duo-agent">,
535
+ context: Context,
536
+ tools: Tool[] | undefined = context.tools,
537
+ availableModels?: GitLabAvailableModelsPayload | null,
538
+ metadataOptions: GitLabDuoWorkflowStartMetadataOptions = {},
539
+ ): GitLabDuoWorkflowStartRequest {
540
+ const workflowMetadata = buildGitLabDuoWorkflowStartMetadata(model, availableModels, metadataOptions);
541
+ const mcpTools = buildGitLabDuoWorkflowMcpTools(tools);
542
+ return {
543
+ workflowID: workflowId,
544
+ clientVersion: "1.0",
545
+ workflowDefinition: metadataOptions.workflowDefinition ?? GITLAB_DUO_WORKFLOW_DEFINITION,
546
+ goal: buildGitLabDuoWorkflowGoal(context),
547
+ workflowMetadata: JSON.stringify(workflowMetadata),
548
+ additional_context: buildGitLabDuoWorkflowClientAdditionalContext(),
549
+ clientCapabilities: GITLAB_DUO_WORKFLOW_CLIENT_CAPABILITIES,
550
+ mcpTools,
551
+ preapproved_tools: mcpTools.map(tool => tool.name),
552
+ flowConfigSchemaVersion: "v1" as const,
553
+ flowConfig: buildGitLabDuoWorkflowInlineFlowConfig(buildGitLabDuoWorkflowSystemPrompt(context)),
554
+ };
555
+ }
556
+
557
+ // Build the inline ambient flow sent over the wire (Path B / `flowConfig`). The
558
+ // server constructs the whole flow from this struct: a single agent component
559
+ // whose system slot carries OMP's own authoritative system prompt (no GitLab jinja
560
+ // wrapper / project metadata) and `on_agent_reasoning` so pre-tool-call commentary
561
+ // streams back as reasoning. `toolset: []` because MCP tools auto-attach from
562
+ // `startRequest.mcpTools` when the workflow's `mcp_enabled` is true. The user slot
563
+ // is `{{goal}}`, which the provider fills with the flat conversation transcript.
564
+ export function buildGitLabDuoWorkflowInlineFlowConfig(systemPrompt: string): GitLabDuoWorkflowInlineFlowConfig {
565
+ return {
566
+ version: "v1",
567
+ environment: "ambient",
568
+ flow: { entry_point: GITLAB_DUO_WORKFLOW_INLINE_AGENT_NAME },
569
+ components: [
570
+ {
571
+ name: GITLAB_DUO_WORKFLOW_INLINE_AGENT_NAME,
572
+ type: "AgentComponent",
573
+ prompt_id: GITLAB_DUO_WORKFLOW_INLINE_PROMPT_ID,
574
+ toolset: [],
575
+ inputs: [{ from: "context:goal", as: "goal" }],
576
+ ui_log_events: [...GITLAB_DUO_WORKFLOW_INLINE_UI_LOG_EVENTS],
577
+ },
578
+ ],
579
+ routers: [{ from: GITLAB_DUO_WORKFLOW_INLINE_AGENT_NAME, to: "end" }],
580
+ prompts: [
581
+ {
582
+ name: GITLAB_DUO_WORKFLOW_INLINE_PROMPT_ID,
583
+ prompt_id: GITLAB_DUO_WORKFLOW_INLINE_PROMPT_ID,
584
+ unit_primitives: ["duo_agent_platform"],
585
+ prompt_template: { system: systemPrompt, user: "{{goal}}", placeholder: "history" },
586
+ },
587
+ ],
588
+ };
589
+ }
590
+
591
+ function buildGitLabDuoWorkflowStartMetadata(
592
+ model: Model<"gitlab-duo-agent">,
593
+ availableModels: GitLabAvailableModelsPayload | null | undefined,
594
+ metadataOptions: GitLabDuoWorkflowStartMetadataOptions,
595
+ ): Record<string, string> {
596
+ return {
597
+ environment: "ide",
598
+ client_type: GITLAB_DUO_WORKFLOW_CLIENT_TYPE,
599
+ ...(metadataOptions.projectId ? { projectId: metadataOptions.projectId } : undefined),
600
+ ...(metadataOptions.namespaceId
601
+ ? { namespaceId: toGitLabRestNamespaceId(metadataOptions.namespaceId) }
602
+ : undefined),
603
+ ...(metadataOptions.rootNamespaceId
604
+ ? { rootNamespaceId: toGitLabRestNamespaceId(metadataOptions.rootNamespaceId) }
605
+ : undefined),
606
+ selectedModelIdentifier: selectGitLabDuoWorkflowModelRef(model.id, availableModels),
607
+ };
608
+ }
609
+
610
+ export function buildGitLabDuoWorkflowClientAdditionalContext(): GitLabDuoWorkflowAdditionalContextItem[] {
611
+ return [];
612
+ }
613
+
614
+ export function buildGitLabDuoWorkflowMcpTools(tools: Tool[] | undefined): GitLabMcpToolDefinition[] {
615
+ return tools?.map(buildGitLabMcpToolDefinition) ?? [];
616
+ }
617
+
618
+ export function selectGitLabDuoWorkflowModelRef(
619
+ selectedModel: string,
620
+ availableModels?: GitLabAvailableModelsPayload | null,
621
+ ): string {
622
+ const pinned = availableModels?.pinnedModel?.ref;
623
+ if (pinned) return pinned;
624
+ return selectedModel;
625
+ }
626
+
627
+ export function buildGitLabPlainTextFromToolResult(toolResult: ToolResultMessage): GitLabPlainTextResponse {
628
+ const text = gitLabToolResultToText(toolResult);
629
+ return toolResult.isError ? { error: text } : { response: text };
630
+ }
631
+ function findGitLabDuoWorkflowToolResultById(
632
+ messages: readonly Message[],
633
+ requestID: string,
634
+ ): ToolResultMessage | undefined {
635
+ for (let index = messages.length - 1; index >= 0; index--) {
636
+ const message = messages[index];
637
+ if (message?.role === "toolResult" && message.toolCallId === requestID) return message;
638
+ }
639
+ return undefined;
640
+ }
641
+
642
+ // Resolve each pending action to its tool result. The serial inline flow yields a
643
+ // single pending action per turn, but the helper stays general; it returns the
644
+ // {requestID, result} pairs only when ALL are present, so a resume that fires
645
+ // before the agent loop appended the tool result is held back rather than sent.
646
+ function resolveGitLabDuoWorkflowActionBatch(
647
+ messages: readonly Message[],
648
+ actions: readonly GitLabDuoWorkflowActionDescriptor[],
649
+ ): { requestID: string; result: ToolResultMessage }[] | undefined {
650
+ const resolved: { requestID: string; result: ToolResultMessage }[] = [];
651
+ for (const action of actions) {
652
+ const result = findGitLabDuoWorkflowToolResultById(messages, action.requestID);
653
+ if (!result) return undefined;
654
+ resolved.push({ requestID: action.requestID, result });
655
+ }
656
+ return resolved;
657
+ }
658
+
659
+ // True when the user steered mid-tool-loop: a user/developer message sits AFTER the
660
+ // last tool result the pending batch resolves to. The DWS wire has no in-flight
661
+ // channel to inject a new user message into a running workflow (the only entry,
662
+ // human_input, is gated behind a LangGraph interrupt that ends the run and forces
663
+ // the broken same-id RESUME). So the steer would be dropped if we just returned the
664
+ // tool results on the live socket. Instead the caller abandons this workflow and
665
+ // re-seeds a fresh one, where the steer rides the goal transcript as the last turn —
666
+ // matching the official CLI, which on interrupt restarts with the new instruction.
667
+ function hasGitLabDuoWorkflowSteerAfterBatch(
668
+ messages: readonly Message[],
669
+ batch: readonly { requestID: string; result: ToolResultMessage }[],
670
+ ): boolean {
671
+ let lastBatchResultIndex = -1;
672
+ const requestIds = new Set(batch.map(entry => entry.requestID));
673
+ for (let index = messages.length - 1; index >= 0; index--) {
674
+ const message = messages[index];
675
+ if (message?.role === "toolResult" && requestIds.has(message.toolCallId)) {
676
+ lastBatchResultIndex = index;
677
+ break;
678
+ }
679
+ }
680
+ if (lastBatchResultIndex < 0) return false;
681
+ for (let index = lastBatchResultIndex + 1; index < messages.length; index++) {
682
+ const role = messages[index]?.role;
683
+ if (role === "user" || role === "developer") return true;
684
+ }
685
+ return false;
686
+ }
687
+
688
+ function buildGitLabDuoWorkflowResponseFromToolResult(toolResult: ToolResultMessage): GitLabPlainTextResponse {
689
+ return buildGitLabPlainTextFromToolResult(toolResult);
690
+ }
691
+
692
+ // Stream one tool_call into the assistant message and finalize the turn. The DWS
693
+ // inline ambient flow dispatches MCP tool calls serially: its ToolNode runs a
694
+ // `for tool_call ...: await tool.ainvoke(...)` loop, and each MCP `ainvoke`
695
+ // blocks in `put_action_and_wait_for_response` until this client returns the
696
+ // matching actionResponse. So only ONE `runMCPTool` action is ever in flight per
697
+ // model turn — the next is not dispatched until the previous is answered. There
698
+ // is no burst to batch; each action is its own assistant message (one `done`,
699
+ // one usage) and the single pending action is committed for the resume turn.
700
+ function emitGitLabDuoWorkflowActionToolCall(
701
+ state: GitLabDuoWorkflowStreamState,
702
+ action: GitLabDuoWorkflowActionDescriptor,
703
+ ): void {
704
+ endGitLabDuoWorkflowText(state);
705
+ endGitLabDuoWorkflowThinking(state);
706
+ const toolCall = buildGitLabDuoWorkflowActionToolCall(action);
707
+ state.output.content.push(toolCall);
708
+ const contentIndex = state.output.content.length - 1;
709
+ state.stream.push({ type: "toolcall_start", contentIndex, partial: state.output });
710
+ state.stream.push({
711
+ type: "toolcall_delta",
712
+ contentIndex,
713
+ delta: JSON.stringify(toolCall.arguments),
714
+ partial: state.output,
715
+ });
716
+ state.stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: state.output });
717
+ finishGitLabDuoWorkflowStream(state, "toolUse");
718
+ if (state.providerSessionState?.active) {
719
+ state.providerSessionState.active.pendingActions = [action];
720
+ }
721
+ }
722
+
723
+ // Decide whether THIS tool-call boundary signals a stalled workflow. The control
724
+ // experiment proved the checkpoint `ui_chat_log` length (messageCount) is an
725
+ // incremental-streaming slice window capped at ~2 even on a healthy FINISHED run,
726
+ // so it cannot discriminate a loop. The raw server checkpoint BYTE size does: a
727
+ // healthy turn emits checkpoints whose size varies and progresses, while a stalled
728
+ // workflow re-emits a byte-identical checkpoint (the server replays the same
729
+ // non-advancing state). So a fresh tool-call boundary whose checkpoint byte length
730
+ // exactly equals the previous boundary's length of the same workflow means the
731
+ // server-side turn did not progress. Persist the last length on the session so the
732
+ // comparison survives the resume that reuses this socket. Returns false until a
733
+ // comparable prior reading exists (first boundary of a workflow, or checkpoints that
734
+ // never carried a length) so a single boundary is never falsely flagged.
735
+ function detectGitLabDuoWorkflowStall(state: GitLabDuoWorkflowStreamState): boolean {
736
+ const active = state.providerSessionState?.active;
737
+ const length = state.lastCheckpointContentLength;
738
+ if (!active || length === undefined) return false;
739
+ const previousLength = active.lastToolBoundaryContentLength;
740
+ const stalled = previousLength !== undefined && length === previousLength;
741
+ active.lastToolBoundaryContentLength = length;
742
+ return stalled;
743
+ }
744
+
745
+ function buildGitLabDuoWorkflowActionToolCall(action: GitLabDuoWorkflowActionDescriptor): ToolCall {
746
+ const args =
747
+ action.args && typeof action.args === "object" && !Array.isArray(action.args)
748
+ ? (action.args as Record<string, unknown>)
749
+ : {};
750
+ const mapped = mapGitLabDuoWorkflowActionToOmpTool(action.name, args);
751
+ return {
752
+ type: "toolCall",
753
+ id: action.requestID,
754
+ name: mapped.name,
755
+ arguments: mapped.arguments,
756
+ };
757
+ }
758
+
759
+ function mapGitLabDuoWorkflowActionToOmpTool(
760
+ actionName: string,
761
+ args: Record<string, unknown>,
762
+ ): { name: string; arguments: Record<string, unknown> } {
763
+ switch (actionName) {
764
+ case "runMCPTool":
765
+ case "run_mcp_tool":
766
+ return mapGitLabDuoWorkflowMcpToolCall(args);
767
+ default:
768
+ return { name: actionName, arguments: { ...args } };
769
+ }
770
+ }
771
+
772
+ function mapGitLabDuoWorkflowMcpToolCall(args: Record<string, unknown>): {
773
+ name: string;
774
+ arguments: Record<string, unknown>;
775
+ } {
776
+ const rawName = stringField(args, "toolName") ?? stringField(args, "tool_name") ?? stringField(args, "name") ?? "";
777
+ const toolName = rawName.startsWith("mcp__omp__") ? rawName.slice("mcp__omp__".length) : rawName;
778
+ const parsedArgs = parseGitLabDuoWorkflowMcpArguments(args.args ?? args.arguments);
779
+ if (toolName === "edit" && typeof parsedArgs.input === "string") {
780
+ return { name: "edit", arguments: { input: parsedArgs.input } };
781
+ }
782
+ return { name: toolName, arguments: parsedArgs };
783
+ }
784
+
785
+ function parseGitLabDuoWorkflowMcpArguments(value: unknown): Record<string, unknown> {
786
+ if (value === undefined) return {};
787
+ if (typeof value === "string") {
788
+ try {
789
+ const parsed = JSON.parse(value) as unknown;
790
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
791
+ ? (parsed as Record<string, unknown>)
792
+ : {};
793
+ } catch {
794
+ return {};
795
+ }
796
+ }
797
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
798
+ }
799
+
800
+ function gitLabDuoWorkflowProviderSessionStateKey(
801
+ baseUrl: string,
802
+ modelId: string,
803
+ sessionId: string | undefined,
804
+ ): string {
805
+ return `gitlab-duo-agent:${baseUrl}\u0000${modelId}\u0000${sessionId ?? ""}`;
806
+ }
807
+
808
+ function createGitLabDuoWorkflowProviderSessionState(): GitLabDuoWorkflowProviderSessionState {
809
+ const state: GitLabDuoWorkflowProviderSessionState = {
810
+ close: () => {
811
+ // Stop the server-side workflow before tearing down the socket. The session
812
+ // is being reset/disposed, so no resume will return the result; without this
813
+ // PATCH a workflow the server is still running on OMP would be stranded.
814
+ try {
815
+ state.active?.stop?.();
816
+ } catch {
817
+ // Best-effort: never let a stop failure block disposal.
818
+ }
819
+ try {
820
+ state.active?.ws.close();
821
+ } catch {
822
+ // Ignore close failures from already-closed sockets.
823
+ }
824
+ state.active = undefined;
825
+ },
826
+ };
827
+ return state;
828
+ }
829
+
830
+ function getGitLabDuoWorkflowProviderSessionState(
831
+ providerSessionState: Map<string, ProviderSessionState> | undefined,
832
+ baseUrl: string,
833
+ modelId: string,
834
+ sessionId: string | undefined,
835
+ ): GitLabDuoWorkflowProviderSessionState | undefined {
836
+ if (!providerSessionState) return undefined;
837
+ const key = gitLabDuoWorkflowProviderSessionStateKey(baseUrl, modelId, sessionId);
838
+ const existing = providerSessionState.get(key) as GitLabDuoWorkflowProviderSessionState | undefined;
839
+ if (existing) return existing;
840
+ const created = createGitLabDuoWorkflowProviderSessionState();
841
+ providerSessionState.set(key, created);
842
+ return created;
843
+ }
844
+
845
+ interface GitLabDuoWorkflowAccountState {
846
+ namespaceSelection?: GitLabDuoWorkflowNamespaceSelection;
847
+ // Once the namespace's Duo settings (agent platform + MCP + experiment flags)
848
+ // have been ensured for this ACCOUNT, later turns and side-requests should not
849
+ // re-send the best-effort enablement PUT. This is account-scoped, not session-
850
+ // scoped: compaction/handoff are independent side-requests that must benefit from
851
+ // the same prepared account state without reusing the main workflow session.
852
+ settingsEnsured?: boolean;
853
+ }
854
+
855
+ // Per-(account, workspace) provider state. The discovered root namespace is a
856
+ // function of the GitLab credential AND the current cwd's git remote (a token with
857
+ // several top-level groups resolves a different namespace per repo), so caching it
858
+ // account-only would reuse the first workspace's namespace in a second repo and skip
859
+ // re-discovery (and skip per-namespace settings enablement). Key by credential +
860
+ // baseUrl + cwd; reuse across turns/sessions in the SAME workspace, re-discover only
861
+ // when a cached namespace later proves invalid. Explicit namespace/project config
862
+ // bypasses this cache entirely. Keyed by a non-reversible credential fingerprint
863
+ // (never the raw token).
864
+ const gitLabDuoWorkflowAccountState = new Map<string, GitLabDuoWorkflowAccountState>();
865
+
866
+ function gitLabDuoWorkflowAccountKey(apiKey: string, baseUrl: string, cwd: string | undefined): string {
867
+ return `${Bun.hash(apiKey).toString(36)}\u0000${baseUrl}\u0000${cwd ?? ""}`;
868
+ }
869
+
870
+ function getGitLabDuoWorkflowAccountState(
871
+ apiKey: string,
872
+ baseUrl: string,
873
+ cwd: string | undefined,
874
+ ): GitLabDuoWorkflowAccountState {
875
+ const key = gitLabDuoWorkflowAccountKey(apiKey, baseUrl, cwd);
876
+ const existing = gitLabDuoWorkflowAccountState.get(key);
877
+ if (existing) return existing;
878
+ const created: GitLabDuoWorkflowAccountState = {};
879
+ gitLabDuoWorkflowAccountState.set(key, created);
880
+ return created;
881
+ }
882
+
883
+ function getGitLabDuoWorkflowCachedNamespace(
884
+ apiKey: string,
885
+ baseUrl: string,
886
+ cwd: string | undefined,
887
+ ): GitLabDuoWorkflowNamespaceSelection | undefined {
888
+ return getGitLabDuoWorkflowAccountState(apiKey, baseUrl, cwd).namespaceSelection;
889
+ }
890
+
891
+ function setGitLabDuoWorkflowCachedNamespace(
892
+ apiKey: string,
893
+ baseUrl: string,
894
+ cwd: string | undefined,
895
+ selection: GitLabDuoWorkflowNamespaceSelection,
896
+ ): void {
897
+ getGitLabDuoWorkflowAccountState(apiKey, baseUrl, cwd).namespaceSelection = selection;
898
+ }
899
+
900
+ function clearGitLabDuoWorkflowCachedNamespace(apiKey: string, baseUrl: string, cwd: string | undefined): void {
901
+ getGitLabDuoWorkflowAccountState(apiKey, baseUrl, cwd).namespaceSelection = undefined;
902
+ }
903
+
904
+ function isGitLabDuoWorkflowSettingsEnsured(apiKey: string, baseUrl: string, cwd: string | undefined): boolean {
905
+ return getGitLabDuoWorkflowAccountState(apiKey, baseUrl, cwd).settingsEnsured === true;
906
+ }
907
+
908
+ function markGitLabDuoWorkflowSettingsEnsured(apiKey: string, baseUrl: string, cwd: string | undefined): void {
909
+ getGitLabDuoWorkflowAccountState(apiKey, baseUrl, cwd).settingsEnsured = true;
910
+ }
911
+
912
+ // True when the user pinned a namespace/project explicitly (option or env). Explicit
913
+ // configuration is authoritative and cheap to resolve, so it bypasses the account
914
+ // cache entirely (neither read nor written).
915
+ function hasGitLabDuoWorkflowExplicitNamespace(options: GitLabDuoWorkflowOptions): boolean {
916
+ return Boolean(
917
+ nonEmptyString(options.rootNamespaceId) ??
918
+ nonEmptyString(options.namespaceId) ??
919
+ nonEmptyString(Bun.env.GITLAB_DUO_NAMESPACE_ID) ??
920
+ nonEmptyString(options.projectId) ??
921
+ nonEmptyString(options.projectPath) ??
922
+ nonEmptyString(Bun.env.GITLAB_DUO_PROJECT_ID) ??
923
+ nonEmptyString(Bun.env.GITLAB_DUO_PROJECT_PATH),
924
+ );
925
+ }
926
+
927
+ export function gitLabDuoWorkflowErrorText(error: unknown): string {
928
+ return error instanceof Error ? error.message : String(error);
929
+ }
930
+
931
+ // Absolute-deadline signal for one REST setup fetch (`fetch`, `direct_access`, etc.).
932
+ // The caller's abort signal — when present — is folded in with `AbortSignal.any`, so
933
+ // either the request being cancelled OR the local timeout aborts the fetch. Called
934
+ // per-fetch so each REST call gets its OWN fresh budget; a shared timeout would race
935
+ // several fetches on the same clock and starve the later ones after the first spent
936
+ // the whole budget. The workflow's `start` event already streamed before any of
937
+ // these calls run, so an unbounded fetch would leave the assistant stream with no
938
+ // terminal event — see {@link GITLAB_DUO_WORKFLOW_REST_TIMEOUT_MS}.
939
+ function gitLabDuoWorkflowRestSignal(callerSignal?: AbortSignal): AbortSignal {
940
+ const timeoutSignal = AbortSignal.timeout(GITLAB_DUO_WORKFLOW_REST_TIMEOUT_MS);
941
+ return callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal;
942
+ }
943
+
944
+ async function readGitLabDuoWorkflowResponseErrorMessage(response: Response): Promise<string | undefined> {
945
+ try {
946
+ const payload: unknown = await response.json();
947
+ const message =
948
+ getGitLabDuoWorkflowErrorField(payload, "message") ?? getGitLabDuoWorkflowErrorField(payload, "error");
949
+ return message ? gitLabDuoWorkflowErrorText(message) : undefined;
950
+ } catch {
951
+ return undefined;
952
+ }
953
+ }
954
+
955
+ function getGitLabDuoWorkflowErrorField(payload: unknown, field: "message" | "error"): string | undefined {
956
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
957
+ const value = (payload as Record<string, unknown>)[field];
958
+ if (typeof value !== "string" || value.trim().length === 0) return undefined;
959
+ return value;
960
+ }
961
+
962
+ // Everything `setupForNamespace` resolves for a chosen namespace: the REST/root ids,
963
+ // the discovered project scoping, the prepared START payload, and the direct_access
964
+ // connection. Named (not `ReturnType<...>`) per repo convention so the contract stays
965
+ // explicit for the cached-namespace and re-discovery branches that consume it.
966
+ interface GitLabDuoWorkflowNamespaceSetup {
967
+ rootNamespaceId: string;
968
+ restNamespaceId: string;
969
+ createNamespaceId: string;
970
+ restProjectId: string | undefined;
971
+ startPayload: GitLabDuoWorkflowStartRequest;
972
+ webSocketProjectId: string | undefined;
973
+ workflowConnection: GitLabDuoWorkflowDirectAccessConnection;
974
+ workflowId: string;
975
+ selectedModelIdentifier: string;
976
+ }
977
+
978
+ async function runGitLabDuoWorkflow(
979
+ model: Model<"gitlab-duo-agent">,
980
+ context: Context,
981
+ options: GitLabDuoWorkflowOptions,
982
+ state: GitLabDuoWorkflowStreamState,
983
+ ): Promise<void> {
984
+ const apiKey = options.apiKey;
985
+ if (!apiKey) throw new AIError.MissingApiKeyError("gitlab-duo-agent");
986
+ const baseUrl = normalizeGitLabBaseUrl(model.baseUrl || DEFAULT_GITLAB_BASE_URL);
987
+ const fetchImpl = options.fetch ?? fetch;
988
+ const providerSessionState = getGitLabDuoWorkflowProviderSessionState(
989
+ options.providerSessionState,
990
+ baseUrl,
991
+ model.id,
992
+ options.sessionId,
993
+ );
994
+ state.providerSessionState = providerSessionState;
995
+ const pendingSession = providerSessionState?.active;
996
+ if (pendingSession) {
997
+ hydrateGitLabDuoWorkflowCheckpointState(state, pendingSession);
998
+ }
999
+ const pendingActions = pendingSession?.pendingActions;
1000
+ const resolvedBatch =
1001
+ pendingSession && pendingActions && pendingActions.length > 0
1002
+ ? resolveGitLabDuoWorkflowActionBatch(context.messages, pendingActions)
1003
+ : undefined;
1004
+ // Steer mid-tool-loop: the user added a new instruction after this batch's tool
1005
+ // results. Returning the results on the live socket would silently drop the steer
1006
+ // (no in-flight user-message channel). Abandon the workflow and re-seed a fresh one
1007
+ // below — the steer rides the goal transcript as the last turn.
1008
+ const steeredMidBatch = Boolean(
1009
+ resolvedBatch && hasGitLabDuoWorkflowSteerAfterBatch(context.messages, resolvedBatch),
1010
+ );
1011
+ if (pendingSession && resolvedBatch && !steeredMidBatch) {
1012
+ const responses = resolvedBatch.map(({ requestID, result }) =>
1013
+ buildGitLabDuoWorkflowActionResponse(requestID, buildGitLabDuoWorkflowResponseFromToolResult(result)),
1014
+ );
1015
+ pendingSession.pendingActions = undefined;
1016
+ const resumeResult = await resumeGitLabDuoWorkflowSocket(
1017
+ { fetchImpl, baseUrl, apiKey, workflowId: pendingSession.workflowId, state, providerSessionState },
1018
+ () => runGitLabDuoWorkflowSocket(pendingSession.ws, pendingSession.startPayload, state, options, responses),
1019
+ );
1020
+ // A stall on the resumed socket means the server-side turn stopped advancing even
1021
+ // after the tool result was returned. The helper already stopped that workflow and
1022
+ // dropped `active`; fall through to seed a FRESH workflow whose rebuilt goal
1023
+ // transcript includes the just-returned tool result, breaking the loop.
1024
+ if (resumeResult !== "stalled") return;
1025
+ }
1026
+ if (providerSessionState?.active?.paused) {
1027
+ const session = providerSessionState.active;
1028
+ const replay = session.pauseBuffer ?? [];
1029
+ session.paused = false;
1030
+ session.pauseBuffer = [];
1031
+ const sessionWorkflowId = session.workflowId;
1032
+ const resumeResult = await resumeGitLabDuoWorkflowSocket(
1033
+ { fetchImpl, baseUrl, apiKey, workflowId: sessionWorkflowId, state, providerSessionState },
1034
+ () => runGitLabDuoWorkflowSocket(session.ws, session.startPayload, state, options, undefined, replay),
1035
+ );
1036
+ // As with the action resume, a stall falls through to a fresh-workflow seed
1037
+ // (the helper already stopped the stalled workflow and dropped `active`).
1038
+ if (resumeResult !== "stalled") return;
1039
+ }
1040
+ // Two cases reach here with a live `pendingSession` that must be abandoned before
1041
+ // seeding a fresh workflow:
1042
+ // 1. A mid-batch steer (resolvedBatch present, user message after it).
1043
+ // 2. Pending actions that did NOT resolve to tool results (resolvedBatch
1044
+ // undefined): the requestID↔toolResult.toolCallId pairing broke, so the live
1045
+ // socket can never be answered. Silently creating a fresh workflow while
1046
+ // leaving the old one running strands it server-side — its LangGraph still
1047
+ // treats the tool call as pending, so the model never sees the result and
1048
+ // re-issues the same tool call (the observed "repeats the same tool, ignores
1049
+ // the result" loop). Both cases need the same cleanup: close the socket, stop
1050
+ // the workflow server-side, and drop the resumable session so the fresh
1051
+ // workflow below owns `active`. The accumulated history (including the
1052
+ // unanswered tool's result) replays through the new goal transcript.
1053
+ const abandonStaleSession = Boolean(
1054
+ pendingSession && (steeredMidBatch || (pendingActions && pendingActions.length > 0 && !resolvedBatch)),
1055
+ );
1056
+ if (abandonStaleSession && pendingSession) {
1057
+ traceGitLabDuoWorkflow(steeredMidBatch ? "workflow.steer_restart" : "workflow.stale_action_restart", {
1058
+ workflowId: pendingSession.workflowId,
1059
+ });
1060
+ pendingSession.pendingActions = undefined;
1061
+ try {
1062
+ pendingSession.ws.close();
1063
+ } catch {
1064
+ // Ignore close failures from already-closed sockets.
1065
+ }
1066
+ if (providerSessionState) providerSessionState.active = undefined;
1067
+ await stopGitLabDuoWorkflow(fetchImpl, baseUrl, apiKey, pendingSession.workflowId);
1068
+ }
1069
+ const workflowDefinition = resolveGitLabDuoWorkflowDefinition(options.workflowDefinition);
1070
+ const explicitNamespace = hasGitLabDuoWorkflowExplicitNamespace(options);
1071
+ const configuredProjectPath = nonEmptyString(options.projectPath) ?? nonEmptyString(Bun.env.GITLAB_DUO_PROJECT_PATH);
1072
+ const configuredProjectId = nonEmptyString(options.projectId) ?? nonEmptyString(Bun.env.GITLAB_DUO_PROJECT_ID);
1073
+ const goal = extractLatestUserPrompt(context.messages);
1074
+
1075
+ // Resolve the namespace and everything scoped to it (settings enable, project
1076
+ // auto-discovery, direct_access, workflow create). With auto-discovery the
1077
+ // namespace is cached per account and reused as the first choice; only if a
1078
+ // cached namespace turns out stale (the dependent calls fail) do we invalidate
1079
+ // it and re-discover once. Explicit namespace/project config bypasses the cache.
1080
+ const setupForNamespace = async (
1081
+ namespaceSelection: GitLabDuoWorkflowNamespaceSelection,
1082
+ ): Promise<GitLabDuoWorkflowNamespaceSetup> => {
1083
+ const rootNamespaceId = namespaceSelection.rootNamespaceId;
1084
+ const restNamespaceId = toGitLabRestNamespaceId(rootNamespaceId);
1085
+ const createNamespaceId = namespaceSelection.namespacePath ?? restNamespaceId;
1086
+ traceGitLabDuoWorkflow("run.start", {
1087
+ baseUrl,
1088
+ model: model.id,
1089
+ rootNamespaceId,
1090
+ restNamespaceId,
1091
+ namespaceSource: namespaceSelection.source,
1092
+ toolCount: context.tools?.length ?? 0,
1093
+ });
1094
+ // Once per session, make sure the namespace has the Duo agent-platform + MCP +
1095
+ // beta flags on. The inline ambient flow needs them; a fresh group ships with
1096
+ // them off. Best-effort (PUT needs maintainer) and idempotent, never blocks.
1097
+ if (
1098
+ !isGitLabDuoWorkflowSettingsEnsured(apiKey, baseUrl, options.cwd) &&
1099
+ isGitLabDuoWorkflowInlineFlow(workflowDefinition)
1100
+ ) {
1101
+ // Mark the workspace ensured only after a definitive attempt (HTTP response,
1102
+ // success or 4xx). A transient network error / 5xx returns false so a later
1103
+ // turn retries instead of permanently skipping the PUT on a namespace whose
1104
+ // flags are still off.
1105
+ if (await ensureGitLabDuoWorkflowSettings(fetchImpl, baseUrl, apiKey, restNamespaceId, options.signal)) {
1106
+ markGitLabDuoWorkflowSettingsEnsured(apiKey, baseUrl, options.cwd);
1107
+ }
1108
+ }
1109
+ // The inline `ambient` flow fails server-side without a project, and OMP has
1110
+ // no project of its own, so auto-discover one when nothing is configured. Prefer
1111
+ // the project the namespace was resolved from (the workspace git remote or an
1112
+ // explicit project), so a group with multiple projects scopes to the actual
1113
+ // repository instead of a generic group-listing pick. Fall back to the generic
1114
+ // membership lookup only when the namespace carries no project. `chat` runs
1115
+ // namespace-only.
1116
+ const discoveredProject =
1117
+ !configuredProjectPath && !configuredProjectId && isGitLabDuoWorkflowInlineFlow(workflowDefinition)
1118
+ ? namespaceSelection.projectPath
1119
+ ? { path: namespaceSelection.projectPath }
1120
+ : await discoverGitLabDuoWorkflowProject(fetchImpl, baseUrl, apiKey, restNamespaceId, options.signal)
1121
+ : undefined;
1122
+ if (discoveredProject) {
1123
+ traceGitLabDuoWorkflow("project.discover", {
1124
+ projectId: discoveredProject.id,
1125
+ hasPath: Boolean(discoveredProject.path),
1126
+ fromRemote: Boolean(namespaceSelection.projectPath),
1127
+ });
1128
+ }
1129
+ // A configured `projectId` that carries a slash is really a full `group/project`
1130
+ // path (namespace discovery accepts that form too): route it through the path flow
1131
+ // so `webSocketProjectId` is resolved to a numeric id instead of sending the raw
1132
+ // path string as `project_id` on the WebSocket, which fails project-scoped routing.
1133
+ const configuredProjectIdIsPath = Boolean(configuredProjectId?.includes("/"));
1134
+ const numericConfiguredProjectId = configuredProjectIdIsPath ? undefined : configuredProjectId;
1135
+ const pathConfiguredProjectId = configuredProjectIdIsPath ? configuredProjectId : undefined;
1136
+ const projectPath = configuredProjectPath ?? pathConfiguredProjectId ?? discoveredProject?.path;
1137
+ const projectId = numericConfiguredProjectId ?? discoveredProject?.id;
1138
+ const restProjectId = configuredProjectPath ?? configuredProjectId ?? discoveredProject?.path;
1139
+ const webSocketProjectId =
1140
+ projectId ??
1141
+ (projectPath
1142
+ ? await resolveGitLabDuoWorkflowNumericProjectId(fetchImpl, baseUrl, apiKey, projectPath, options.signal)
1143
+ : undefined);
1144
+ const workflowConnection: GitLabDuoWorkflowDirectAccessConnection = options.workflowToken
1145
+ ? { token: options.workflowToken, headers: {}, serviceEndpoint: false }
1146
+ : await requestGitLabDuoWorkflowDirectAccess(
1147
+ fetchImpl,
1148
+ baseUrl,
1149
+ apiKey,
1150
+ rootNamespaceId,
1151
+ restProjectId,
1152
+ workflowDefinition,
1153
+ options.signal,
1154
+ );
1155
+ const workflowId =
1156
+ options.workflowId ??
1157
+ (await createGitLabDuoWorkflow(
1158
+ fetchImpl,
1159
+ baseUrl,
1160
+ apiKey,
1161
+ createNamespaceId,
1162
+ goal,
1163
+ restProjectId,
1164
+ workflowDefinition,
1165
+ options.signal,
1166
+ ));
1167
+ const availableModels = await fetchGitLabDuoWorkflowAvailableModels(
1168
+ fetchImpl,
1169
+ baseUrl,
1170
+ apiKey,
1171
+ rootNamespaceId,
1172
+ options.signal,
1173
+ );
1174
+ const selectedModelIdentifier = selectGitLabDuoWorkflowModelRef(model.id, availableModels);
1175
+ // A `toolChoice: "none"` side-request (e.g. handoff keeps live tool definitions
1176
+ // in the cache prefix but disables tool use) must not advertise the tools to
1177
+ // Duo: if the model picked one, the provider would emit a `toolUse` message and
1178
+ // the text-only handoff consumer would yield an empty/partial document. Drop the
1179
+ // advertised tools in that case; named/`auto`/`any` choices keep them.
1180
+ const advertisedTools = options.toolChoice === "none" ? [] : context.tools;
1181
+ const startPayload = buildGitLabDuoWorkflowStartRequest(
1182
+ workflowId,
1183
+ model,
1184
+ context,
1185
+ advertisedTools,
1186
+ availableModels,
1187
+ {
1188
+ projectId: webSocketProjectId,
1189
+ projectPath,
1190
+ namespaceId: restNamespaceId,
1191
+ rootNamespaceId: restNamespaceId,
1192
+ workflowDefinition,
1193
+ inlineFlow: isGitLabDuoWorkflowInlineFlow(workflowDefinition),
1194
+ },
1195
+ );
1196
+ return {
1197
+ rootNamespaceId,
1198
+ restNamespaceId,
1199
+ createNamespaceId,
1200
+ restProjectId,
1201
+ startPayload,
1202
+ webSocketProjectId,
1203
+ workflowConnection,
1204
+ workflowId,
1205
+ selectedModelIdentifier,
1206
+ };
1207
+ };
1208
+
1209
+ const cachedNamespace = explicitNamespace
1210
+ ? undefined
1211
+ : getGitLabDuoWorkflowCachedNamespace(apiKey, baseUrl, options.cwd);
1212
+ let setup: GitLabDuoWorkflowNamespaceSetup;
1213
+ if (cachedNamespace) {
1214
+ try {
1215
+ setup = await setupForNamespace(cachedNamespace);
1216
+ } catch (cachedError) {
1217
+ // The cached account namespace no longer works (revoked access, deleted
1218
+ // group, membership change). Drop it and re-discover once from scratch.
1219
+ traceGitLabDuoWorkflow("namespace.cache_invalidate", {
1220
+ rootNamespaceId: cachedNamespace.rootNamespaceId,
1221
+ error: gitLabDuoWorkflowErrorText(cachedError),
1222
+ });
1223
+ clearGitLabDuoWorkflowCachedNamespace(apiKey, baseUrl, options.cwd);
1224
+ const rediscovered = await resolveGitLabDuoWorkflowNamespaceSelection(
1225
+ model,
1226
+ options,
1227
+ apiKey,
1228
+ baseUrl,
1229
+ fetchImpl,
1230
+ );
1231
+ setup = await setupForNamespace(rediscovered);
1232
+ setGitLabDuoWorkflowCachedNamespace(apiKey, baseUrl, options.cwd, rediscovered);
1233
+ }
1234
+ } else {
1235
+ const namespaceSelection = await resolveGitLabDuoWorkflowNamespaceSelection(
1236
+ model,
1237
+ options,
1238
+ apiKey,
1239
+ baseUrl,
1240
+ fetchImpl,
1241
+ );
1242
+ setup = await setupForNamespace(namespaceSelection);
1243
+ // Cache the freshly discovered namespace per account so the next session/turn
1244
+ // reuses it instead of re-discovering. Explicit config is never cached.
1245
+ if (!explicitNamespace) {
1246
+ setGitLabDuoWorkflowCachedNamespace(apiKey, baseUrl, options.cwd, namespaceSelection);
1247
+ }
1248
+ }
1249
+ const restNamespaceId = setup.restNamespaceId;
1250
+ const createNamespaceId = setup.createNamespaceId;
1251
+ const restProjectId = setup.restProjectId;
1252
+ const webSocketProjectId = setup.webSocketProjectId;
1253
+ const workflowConnection = setup.workflowConnection;
1254
+ const selectedModelIdentifier = setup.selectedModelIdentifier;
1255
+ let workflowId = setup.workflowId;
1256
+ let startPayload = setup.startPayload;
1257
+ // Three byte zones (see GITLAB_DUO_WORKFLOW_GOAL_*_OVERFLOW_BYTES):
1258
+ // - [HARD, ∞): necessary-fail. Do NOT spend the request — emit the overflow error
1259
+ // now so the session compacts immediately. The fresh-workflow already created in
1260
+ // setup is stopped by the `finally` below.
1261
+ // - [SOFT, HARD): jitter. Attempt once (it can succeed); stash the overflow label so
1262
+ // that IF the run errors it is re-labeled as a context-overflow rather than a
1263
+ // transient fault.
1264
+ // - [0, SOFT): reliable. Leave the label undefined; ordinary errors surface verbatim.
1265
+ const renderedGoalBytes = Buffer.byteLength(startPayload.goal, "utf8");
1266
+ if (renderedGoalBytes >= GITLAB_DUO_WORKFLOW_GOAL_HARD_OVERFLOW_BYTES) {
1267
+ traceGitLabDuoWorkflow("goal.over_budget", {
1268
+ renderedGoalBytes,
1269
+ zone: "hard",
1270
+ soft: GITLAB_DUO_WORKFLOW_GOAL_SOFT_OVERFLOW_BYTES,
1271
+ hard: GITLAB_DUO_WORKFLOW_GOAL_HARD_OVERFLOW_BYTES,
1272
+ });
1273
+ if (!state.stream.done) {
1274
+ state.output.stopReason = "error";
1275
+ state.output.errorMessage = buildGitLabDuoWorkflowGoalOverflowMessage(renderedGoalBytes);
1276
+ state.stream.push({ type: "error", reason: "error", error: state.output });
1277
+ }
1278
+ // Stop the freshly created server-side workflow so it is not stranded, then
1279
+ // return without opening the socket — the request is never spent.
1280
+ if (providerSessionState) providerSessionState.active = undefined;
1281
+ await stopGitLabDuoWorkflow(fetchImpl, baseUrl, apiKey, workflowId);
1282
+ return;
1283
+ }
1284
+ if (renderedGoalBytes >= GITLAB_DUO_WORKFLOW_GOAL_SOFT_OVERFLOW_BYTES) {
1285
+ state.goalOverflowMessage = buildGitLabDuoWorkflowGoalOverflowMessage(renderedGoalBytes);
1286
+ traceGitLabDuoWorkflow("goal.over_budget", {
1287
+ renderedGoalBytes,
1288
+ zone: "jitter",
1289
+ soft: GITLAB_DUO_WORKFLOW_GOAL_SOFT_OVERFLOW_BYTES,
1290
+ hard: GITLAB_DUO_WORKFLOW_GOAL_HARD_OVERFLOW_BYTES,
1291
+ });
1292
+ }
1293
+ let lastSocketResult: GitLabDuoWorkflowSocketResult = "closed";
1294
+ let timeoutReconnected = false;
1295
+ let stepLimitRestarts = 0;
1296
+ let genericErrorRetries = 0;
1297
+ let stallRestarts = 0;
1298
+ let settledNormally = false;
1299
+ try {
1300
+ for (let attempt = 0; attempt < 12; attempt++) {
1301
+ const ws = openGitLabDuoWorkflowSocket(workflowConnection.baseUrl ?? baseUrl, {
1302
+ token: workflowConnection.token,
1303
+ projectId: webSocketProjectId,
1304
+ // Pass the resolved namespace/root even when no numeric project id is
1305
+ // available (project path unresolved, or auto-discovery found none): the
1306
+ // REST direct_access/create calls may be namespace- or path-scoped, but the
1307
+ // socket must still route inside the selected namespace. Dropping them with
1308
+ // the project left the socket scope-less and could route/fail outside it.
1309
+ namespaceId: restNamespaceId,
1310
+ rootNamespaceId: restNamespaceId,
1311
+ selectedModelIdentifier,
1312
+ workflowDefinition,
1313
+ serviceEndpoint: workflowConnection.serviceEndpoint,
1314
+ extraHeaders: workflowConnection.headers,
1315
+ originBaseUrl: baseUrl,
1316
+ webSocketFactory: options.webSocketFactory,
1317
+ });
1318
+ if (providerSessionState) {
1319
+ // Capture the CURRENT workflow id (it is reassigned across timeout/step-limit/
1320
+ // retry restarts) so a later session-dispose stops the right workflow.
1321
+ const stopWorkflowId = workflowId;
1322
+ providerSessionState.active = {
1323
+ workflowId,
1324
+ startPayload,
1325
+ ws,
1326
+ stop: () => {
1327
+ void stopGitLabDuoWorkflow(fetchImpl, baseUrl, apiKey, stopWorkflowId);
1328
+ },
1329
+ };
1330
+ }
1331
+ lastSocketResult = await runGitLabDuoWorkflowSocket(ws, startPayload, state, options);
1332
+ if (lastSocketResult === "approval") {
1333
+ startPayload = buildGitLabDuoWorkflowApprovalStartRequest(startPayload);
1334
+ state.lastApprovalStatus = undefined;
1335
+ continue;
1336
+ }
1337
+ // A silent half-open socket (no frame within the idle window) leaves the
1338
+ // remote workflow stuck. Same-id reconnect is NOT recoverable on an inline
1339
+ // flow: a second connection re-compiles the flow from the live `flowConfig`
1340
+ // and the LangGraph checkpoint replay rejects the rebuilt graph topology
1341
+ // (server-side FAILED, agent never runs — verified live). So recover the
1342
+ // same way step_limit does: stop the dead workflow and create a FRESH one
1343
+ // (status CREATED → START branch, no checkpoint replay), then reopen the
1344
+ // socket. The accumulated conversation replays through the goal transcript.
1345
+ // Bounded to a single retry so a persistently dead endpoint can't loop on quota.
1346
+ if (lastSocketResult === "timeout" && !timeoutReconnected) {
1347
+ timeoutReconnected = true;
1348
+ traceGitLabDuoWorkflow("websocket.idle_restart", { workflowId });
1349
+ await stopGitLabDuoWorkflow(fetchImpl, baseUrl, apiKey, workflowId);
1350
+ workflowId = await createGitLabDuoWorkflow(
1351
+ fetchImpl,
1352
+ baseUrl,
1353
+ apiKey,
1354
+ createNamespaceId,
1355
+ goal,
1356
+ restProjectId,
1357
+ workflowDefinition,
1358
+ options.signal,
1359
+ );
1360
+ startPayload = { ...startPayload, workflowID: workflowId };
1361
+ continue;
1362
+ }
1363
+ // The server caps each workflow at a fixed step (graph-recursion) limit.
1364
+ // A long but healthy OMP tool-call loop legitimately overruns it; that is
1365
+ // not a real failure. Stop the exhausted run and create a FRESH workflow
1366
+ // (a new id resets the step budget — unlike the timeout case, resending on
1367
+ // the same id would not), then reopen the socket. The conversation so far
1368
+ // (assistant text + tool results accumulated in `context`) replays through
1369
+ // the goal envelope, so the new workflow continues where it left off; the
1370
+ // checkpoint dedupe drops any re-sent ui_chat_log entries. Bounded so a
1371
+ // task that perpetually overruns degrades to a graceful stop, not a quota
1372
+ // sink.
1373
+ if (lastSocketResult === "step_limit" && stepLimitRestarts < GITLAB_DUO_WORKFLOW_MAX_STEP_LIMIT_RESTARTS) {
1374
+ stepLimitRestarts++;
1375
+ state.stepLimitRequested = false;
1376
+ traceGitLabDuoWorkflow("websocket.step_limit_restart", { workflowId, restart: stepLimitRestarts });
1377
+ await stopGitLabDuoWorkflow(fetchImpl, baseUrl, apiKey, workflowId);
1378
+ workflowId = await createGitLabDuoWorkflow(
1379
+ fetchImpl,
1380
+ baseUrl,
1381
+ apiKey,
1382
+ createNamespaceId,
1383
+ goal,
1384
+ restProjectId,
1385
+ workflowDefinition,
1386
+ options.signal,
1387
+ );
1388
+ startPayload = { ...startPayload, workflowID: workflowId };
1389
+ continue;
1390
+ }
1391
+ // The server emitted a fresh tool-call boundary whose `ui_chat_log` total did
1392
+ // not advance past the previous boundary of this workflow — the server-side
1393
+ // turn stopped progressing (captured live: total pinned while the model
1394
+ // repeated one tool call). Recover exactly like step_limit: stop the stalled
1395
+ // workflow and create a FRESH one (a new id with no checkpoint replay), then
1396
+ // reopen the socket. The conversation replays through the goal transcript,
1397
+ // rebuilt from the agent loop's intact `context.messages`, so no in-flight
1398
+ // tool result is lost. Bounded so a persistently stalling endpoint degrades to
1399
+ // a surfaced result instead of looping on quota.
1400
+ if (lastSocketResult === "stalled" && stallRestarts < GITLAB_DUO_WORKFLOW_MAX_STALL_RESTARTS) {
1401
+ stallRestarts++;
1402
+ state.stalledRequested = false;
1403
+ traceGitLabDuoWorkflow("websocket.stall_restart", { workflowId, restart: stallRestarts });
1404
+ await stopGitLabDuoWorkflow(fetchImpl, baseUrl, apiKey, workflowId);
1405
+ workflowId = await createGitLabDuoWorkflow(
1406
+ fetchImpl,
1407
+ baseUrl,
1408
+ apiKey,
1409
+ createNamespaceId,
1410
+ goal,
1411
+ restProjectId,
1412
+ workflowDefinition,
1413
+ options.signal,
1414
+ );
1415
+ startPayload = { ...startPayload, workflowID: workflowId };
1416
+ continue;
1417
+ }
1418
+ // The server returned its de-identified catch-all FAILED — a wrapper over a
1419
+ // transient upstream fault (model 5xx, AgentStuckError, …). Retry on a FRESH
1420
+ // workflow exactly like step_limit (same-id reconnect is broken on inline
1421
+ // flows): the conversation replays through the goal transcript. Bounded low
1422
+ // so a deterministic failure surfaces instead of looping on quota.
1423
+ if (
1424
+ lastSocketResult === "retryable_error" &&
1425
+ genericErrorRetries < GITLAB_DUO_WORKFLOW_MAX_GENERIC_ERROR_RETRIES
1426
+ ) {
1427
+ genericErrorRetries++;
1428
+ state.retryableErrorRequested = false;
1429
+ // Clear the stashed message: it only surfaces if the retry also fails.
1430
+ state.output.errorMessage = undefined;
1431
+ traceGitLabDuoWorkflow("websocket.generic_error_retry", { workflowId, retry: genericErrorRetries });
1432
+ await stopGitLabDuoWorkflow(fetchImpl, baseUrl, apiKey, workflowId);
1433
+ workflowId = await createGitLabDuoWorkflow(
1434
+ fetchImpl,
1435
+ baseUrl,
1436
+ apiKey,
1437
+ createNamespaceId,
1438
+ goal,
1439
+ restProjectId,
1440
+ workflowDefinition,
1441
+ options.signal,
1442
+ );
1443
+ startPayload = { ...startPayload, workflowID: workflowId };
1444
+ continue;
1445
+ }
1446
+ // A retryable error that exhausted its retries must surface as a real error;
1447
+ // the FAILED branch suppressed the error event expecting a retry, so emit it
1448
+ // now before falling through to the terminal break.
1449
+ if (lastSocketResult === "retryable_error" && !state.stream.done) {
1450
+ state.output.stopReason = "error";
1451
+ // An oversized goal that exhausted its retry is almost certainly failing on
1452
+ // the byte size, not a transient fault — surface it as a context-overflow so
1453
+ // the session auto-compacts instead of hard-failing.
1454
+ if (state.goalOverflowMessage) state.output.errorMessage = state.goalOverflowMessage;
1455
+ state.stream.push({ type: "error", reason: "error", error: state.output });
1456
+ }
1457
+ // A stall that exhausted its fresh-workflow restarts is a persistent failure to
1458
+ // progress; surface it as a real error so the run does not stop silently.
1459
+ if (lastSocketResult === "stalled" && !state.stream.done) {
1460
+ state.output.stopReason = "error";
1461
+ state.output.errorMessage =
1462
+ state.goalOverflowMessage ?? state.output.errorMessage ?? GITLAB_DUO_WORKFLOW_STALL_ERROR_MESSAGE;
1463
+ state.stream.push({ type: "error", reason: "error", error: state.output });
1464
+ }
1465
+ break;
1466
+ }
1467
+ settledNormally = true;
1468
+ finalizeGitLabDuoWorkflowResumeResult(state, providerSessionState, lastSocketResult);
1469
+ } finally {
1470
+ // The socket loop can exit several ways that leave the remote workflow running
1471
+ // and `active` referencing a dead socket: a user abort; `runGitLabDuoWorkflowSocket`
1472
+ // rejecting (e.g. `ws.onerror`) so the settle block never ran (`settledNormally`
1473
+ // stays false); or the socket reached a half-open/stuck terminal state with no
1474
+ // real completion — `lastSocketResult === "closed"` (proxy/server drop),
1475
+ // `"timeout"` (idle deadline, retry already exhausted), or `"stalled"` (the
1476
+ // workflow's visible history stopped advancing and the bounded restarts were
1477
+ // exhausted). In all of these the local stream is finalized but the server
1478
+ // workflow has no explicit stop, so drop the resumable session and stop it with a
1479
+ // FRESH signal (the request's own signal may be aborted, which would cancel the
1480
+ // PATCH before it is sent). The happy path that intentionally keeps `active` for
1481
+ // an `action`/`pause` resume reaches a real terminal status, never
1482
+ // "closed"/"timeout"/"stalled", so it is not affected.
1483
+ const aborted = options.signal?.aborted ?? false;
1484
+ if (
1485
+ aborted ||
1486
+ !settledNormally ||
1487
+ lastSocketResult === "closed" ||
1488
+ lastSocketResult === "timeout" ||
1489
+ lastSocketResult === "stalled"
1490
+ ) {
1491
+ if (providerSessionState) {
1492
+ providerSessionState.active = undefined;
1493
+ }
1494
+ await stopGitLabDuoWorkflow(fetchImpl, baseUrl, apiKey, workflowId);
1495
+ }
1496
+ }
1497
+ }
1498
+
1499
+ async function fetchGitLabDuoWorkflowAvailableModels(
1500
+ fetchImpl: FetchImpl,
1501
+ baseUrl: string,
1502
+ apiKey: string,
1503
+ rootNamespaceId: string,
1504
+ signal?: AbortSignal,
1505
+ ): Promise<GitLabAvailableModelsPayload | undefined> {
1506
+ try {
1507
+ const response = await fetchImpl(gitLabApiUrl(baseUrl, "/api/graphql"), {
1508
+ method: "POST",
1509
+ headers: {
1510
+ Authorization: `Bearer ${apiKey}`,
1511
+ "content-type": "application/json",
1512
+ },
1513
+ body: JSON.stringify({
1514
+ query: GITLAB_DUO_WORKFLOW_AVAILABLE_MODELS_QUERY,
1515
+ variables: { rootNamespaceId: toGitLabGraphQLNamespaceId(rootNamespaceId) },
1516
+ }),
1517
+ signal: gitLabDuoWorkflowRestSignal(signal),
1518
+ });
1519
+ if (!response.ok) return undefined;
1520
+ const payload: unknown = await response.json();
1521
+ const models = getRecord(getRecord(payload, "data"), "aiChatAvailableModels");
1522
+ return parseGitLabAvailableModelsPayload(models);
1523
+ } catch {
1524
+ // Timeout (AbortSignal.timeout) surfaces as an AbortError here; matches the pre-fix
1525
+ // transient-network behavior (undefined -> caller falls back to defaults), so a
1526
+ // stalled models fetch degrades rather than hanging the whole stream.
1527
+ return undefined;
1528
+ }
1529
+ }
1530
+
1531
+ function parseGitLabAvailableModelsPayload(value: unknown): GitLabAvailableModelsPayload | undefined {
1532
+ if (!value || typeof value !== "object") return undefined;
1533
+ return {
1534
+ pinnedModel: parseGitLabAvailableModel(getRecord(value, "pinnedModel")),
1535
+ selectedModel: parseGitLabAvailableModel(getRecord(value, "selectedModel")),
1536
+ defaultModel: parseGitLabAvailableModel(getRecord(value, "defaultModel")),
1537
+ selectableModels: parseGitLabAvailableModelArray((value as Record<string, unknown>).selectableModels),
1538
+ };
1539
+ }
1540
+
1541
+ function parseGitLabAvailableModel(value: unknown): GitLabAvailableModel | null {
1542
+ if (!value || typeof value !== "object") return null;
1543
+ return { name: getRecordString(value, "name") ?? null, ref: getRecordString(value, "ref") ?? null };
1544
+ }
1545
+
1546
+ function parseGitLabAvailableModelArray(value: unknown): GitLabAvailableModel[] | undefined {
1547
+ if (!Array.isArray(value)) return undefined;
1548
+ return value.map(parseGitLabAvailableModel).filter((model): model is GitLabAvailableModel => Boolean(model));
1549
+ }
1550
+
1551
+ async function resolveGitLabDuoWorkflowNumericProjectId(
1552
+ fetchImpl: FetchImpl,
1553
+ baseUrl: string,
1554
+ apiKey: string,
1555
+ projectPath: string,
1556
+ signal?: AbortSignal,
1557
+ ): Promise<string | undefined> {
1558
+ try {
1559
+ const response = await fetchImpl(gitLabApiUrl(baseUrl, `/api/v4/projects/${encodeURIComponent(projectPath)}`), {
1560
+ method: "GET",
1561
+ headers: {
1562
+ Authorization: `Bearer ${apiKey}`,
1563
+ "content-type": "application/json",
1564
+ },
1565
+ signal: gitLabDuoWorkflowRestSignal(signal),
1566
+ });
1567
+ if (!response.ok) return undefined;
1568
+ const payload: unknown = await response.json();
1569
+ return getRecordString(payload, "id");
1570
+ } catch {
1571
+ // Timeout / abort behaves like a transient network fault: undefined leaves the
1572
+ // caller to fall back to the workflow's namespace-only routing rather than block
1573
+ // the stream on a hanging project lookup.
1574
+ return undefined;
1575
+ }
1576
+ }
1577
+
1578
+ interface GitLabDuoWorkflowDiscoveredProject {
1579
+ // Numeric id is known when discovered via the projects API; for a project carried
1580
+ // from the resolved namespace (git remote / explicit path) only the full path is
1581
+ // known and the numeric id is resolved later from the path for WebSocket routing.
1582
+ id?: string;
1583
+ path: string;
1584
+ }
1585
+
1586
+ // OMP has no GitLab project of its own, but the inline `ambient` flow fails
1587
+ // server-side without a project context. When the caller did not configure a
1588
+ // project, discover one the credential can access: prefer a project inside the
1589
+ // resolved namespace group, then fall back to any membership project. Returns
1590
+ // the numeric id (WebSocket routing) and full path (REST scoping) together so
1591
+ // no second lookup is needed.
1592
+ async function discoverGitLabDuoWorkflowProject(
1593
+ fetchImpl: FetchImpl,
1594
+ baseUrl: string,
1595
+ apiKey: string,
1596
+ restNamespaceId: string,
1597
+ signal?: AbortSignal,
1598
+ ): Promise<GitLabDuoWorkflowDiscoveredProject | undefined> {
1599
+ const query = "per_page=1&min_access_level=30&order_by=last_activity_at&sort=desc";
1600
+ const endpoints = [
1601
+ `/api/v4/groups/${encodeURIComponent(restNamespaceId)}/projects?include_subgroups=true&${query}`,
1602
+ `/api/v4/projects?membership=true&${query}`,
1603
+ ];
1604
+ for (const endpoint of endpoints) {
1605
+ try {
1606
+ const response = await fetchImpl(gitLabApiUrl(baseUrl, endpoint), {
1607
+ method: "GET",
1608
+ headers: {
1609
+ Authorization: `Bearer ${apiKey}`,
1610
+ "content-type": "application/json",
1611
+ },
1612
+ signal: gitLabDuoWorkflowRestSignal(signal),
1613
+ });
1614
+ if (!response.ok) continue;
1615
+ const payload: unknown = await response.json();
1616
+ const first = Array.isArray(payload) ? payload[0] : undefined;
1617
+ const id = getRecordString(first, "id");
1618
+ const path = getRecordString(first, "path_with_namespace");
1619
+ if (id && path) return { id, path };
1620
+ } catch {
1621
+ // Timeout/abort on one endpoint: fall through to the next fallback rather than
1622
+ // aborting discovery. Each endpoint gets its own fresh REST budget.
1623
+ }
1624
+ }
1625
+ return undefined;
1626
+ }
1627
+
1628
+ async function requestGitLabDuoWorkflowDirectAccess(
1629
+ fetchImpl: FetchImpl,
1630
+ baseUrl: string,
1631
+ apiKey: string,
1632
+ rootNamespaceId: string,
1633
+ projectId?: string,
1634
+ workflowDefinition: GitLabDuoWorkflowDefinition = GITLAB_DUO_WORKFLOW_DEFINITION,
1635
+ signal?: AbortSignal,
1636
+ ): Promise<GitLabDuoWorkflowDirectAccessConnection> {
1637
+ // A timeout here throws `AbortError`/`TimeoutError` (per `AbortSignal.timeout`),
1638
+ // which surfaces through the outer `streamGitLabDuoWorkflow` catch as a real
1639
+ // stream `error` event — matching the existing HTTP-error path rather than the
1640
+ // swallowed best-effort helpers (settings ensure / project discovery / models).
1641
+ const response = await fetchImpl(gitLabApiUrl(baseUrl, "/api/v4/ai/duo_workflows/direct_access"), {
1642
+ method: "POST",
1643
+ headers: {
1644
+ Authorization: `Bearer ${apiKey}`,
1645
+ "content-type": "application/json",
1646
+ },
1647
+ body: JSON.stringify(buildGitLabDuoWorkflowDirectAccessBody(rootNamespaceId, projectId, workflowDefinition)),
1648
+ signal: gitLabDuoWorkflowRestSignal(signal),
1649
+ });
1650
+ traceGitLabDuoWorkflow("direct_access.response", {
1651
+ status: response.status,
1652
+ ok: response.ok,
1653
+ rootNamespaceId,
1654
+ hasProjectId: Boolean(projectId),
1655
+ });
1656
+ if (!response.ok) {
1657
+ const message = await readGitLabDuoWorkflowResponseErrorMessage(response);
1658
+ // Always embed the HTTP status, even when the body carries a message: the
1659
+ // streaming auth-retry/rotation path (`extractStatusFromAssistantError` ->
1660
+ // `extractHttpStatusFromError`) refreshes/rotates broker credentials only
1661
+ // when the assistant error exposes `errorStatus` or the message embeds an
1662
+ // `HTTP <status>` token. A 401 `{"message":"Unauthorized"}` or a 429 quota
1663
+ // body would otherwise surface as a hard failure with no recoverable status.
1664
+ throw new AIError.GitLabDuoWorkflowApiError(
1665
+ message
1666
+ ? `GitLab Duo Workflow direct_access failed with HTTP ${response.status}: ${message}`
1667
+ : `GitLab Duo Workflow direct_access failed with HTTP ${response.status}`,
1668
+ response.status,
1669
+ );
1670
+ }
1671
+ const payload = (await response.json()) as GitLabDirectAccessResponse;
1672
+ const token = extractGitLabWorkflowToken(payload);
1673
+ if (!token) {
1674
+ throw new AIError.ProviderResponseError("GitLab Duo Workflow direct_access did not return credentials", {
1675
+ provider: "gitlab-duo-agent",
1676
+ kind: "empty-body",
1677
+ });
1678
+ }
1679
+ traceGitLabDuoWorkflow("direct_access.token", { hasToken: true });
1680
+ const serviceEndpoint = !payload.gitlab_rails?.token && Boolean(payload.duo_workflow_service?.base_url);
1681
+ return {
1682
+ token,
1683
+ ...(serviceEndpoint && payload.duo_workflow_service?.base_url
1684
+ ? { baseUrl: normalizeGitLabDuoWorkflowServiceBaseUrl(payload.duo_workflow_service.base_url) }
1685
+ : {}),
1686
+ headers: serviceEndpoint ? (payload.duo_workflow_service?.headers ?? {}) : {},
1687
+ serviceEndpoint,
1688
+ };
1689
+ }
1690
+
1691
+ async function createGitLabDuoWorkflow(
1692
+ fetchImpl: FetchImpl,
1693
+ baseUrl: string,
1694
+ apiKey: string,
1695
+ namespaceId: string,
1696
+ goal?: string,
1697
+ projectId?: string,
1698
+ workflowDefinition: GitLabDuoWorkflowDefinition = GITLAB_DUO_WORKFLOW_DEFINITION,
1699
+ signal?: AbortSignal,
1700
+ ): Promise<string> {
1701
+ const body = buildGitLabDuoWorkflowCreateBody(namespaceId, {
1702
+ goal: isGitLabDuoWorkflowInlineFlow(workflowDefinition) ? "" : goal,
1703
+ projectId,
1704
+ workflowDefinition,
1705
+ });
1706
+ const response = await fetchImpl(gitLabApiUrl(baseUrl, "/api/v4/ai/duo_workflows/workflows"), {
1707
+ method: "POST",
1708
+ headers: {
1709
+ Authorization: `Bearer ${apiKey}`,
1710
+ "content-type": "application/json",
1711
+ },
1712
+ body: JSON.stringify(body),
1713
+ signal: gitLabDuoWorkflowRestSignal(signal),
1714
+ });
1715
+ traceGitLabDuoWorkflow("workflow.create.response", {
1716
+ status: response.status,
1717
+ ok: response.ok,
1718
+ namespaceId,
1719
+ hasProjectId: Boolean(projectId),
1720
+ });
1721
+ if (!response.ok) {
1722
+ throw new AIError.GitLabDuoWorkflowApiError(
1723
+ `GitLab Duo Workflow create failed with HTTP ${response.status}`,
1724
+ response.status,
1725
+ );
1726
+ }
1727
+ const payload = (await response.json()) as GitLabCreateWorkflowResponse;
1728
+ const workflowId = payload.id ?? payload.workflow_id ?? payload.workflowId;
1729
+ if (workflowId === undefined) {
1730
+ throw new AIError.ProviderResponseError(
1731
+ `GitLab Duo Workflow create response missing workflow id (HTTP ${response.status})`,
1732
+ { provider: "gitlab-duo-agent", kind: "empty-body" },
1733
+ );
1734
+ }
1735
+ traceGitLabDuoWorkflow("workflow.create.id", { workflowId });
1736
+ return String(workflowId);
1737
+ }
1738
+
1739
+ async function stopGitLabDuoWorkflow(
1740
+ fetchImpl: FetchImpl,
1741
+ baseUrl: string,
1742
+ apiKey: string,
1743
+ workflowId: string,
1744
+ ): Promise<void> {
1745
+ // Stop rides a FRESH timeout signal, deliberately decoupled from `options.signal`
1746
+ // (see the `finally` block in `runGitLabDuoWorkflow`): a run cancelled by the
1747
+ // caller must still fire the server-side stop, but a stalled PATCH here would
1748
+ // otherwise leave the `runGitLabDuoWorkflow` promise unresolved forever — the
1749
+ // bounded budget keeps cleanup best-effort in both directions.
1750
+ try {
1751
+ await fetchImpl(gitLabApiUrl(baseUrl, `/api/v4/ai/duo_workflows/workflows/${encodeURIComponent(workflowId)}`), {
1752
+ method: "PATCH",
1753
+ headers: {
1754
+ Authorization: `Bearer ${apiKey}`,
1755
+ "content-type": "application/json",
1756
+ },
1757
+ body: JSON.stringify(buildGitLabDuoWorkflowStopBody()),
1758
+ signal: gitLabDuoWorkflowRestSignal(),
1759
+ });
1760
+ } catch (error) {
1761
+ // Server-side stop is best-effort: a timeout / network fault must not reject
1762
+ // the caller (the local stream already emitted its terminal event). Trace and
1763
+ // swallow so the enclosing `finally` never surfaces a spurious rejection.
1764
+ traceGitLabDuoWorkflow("workflow.stop_error", {
1765
+ workflowId,
1766
+ error: gitLabDuoWorkflowErrorText(error),
1767
+ });
1768
+ }
1769
+ }
1770
+
1771
+ // Body the group PUT carries to turn on exactly the three flags the inline MCP-only
1772
+ // ambient flow requires. Kept minimal on purpose: it never touches `duo_availability`,
1773
+ // foundational flows, tool-approval, usage-data, or any other setting the operator may
1774
+ // have configured. Idempotent — re-enabling an already-on flag is a server-side no-op.
1775
+ export function buildGitLabDuoWorkflowSettingsBody(): Record<string, unknown> {
1776
+ return {
1777
+ experiment_features_enabled: true,
1778
+ ai_settings_attributes: {
1779
+ duo_agent_platform_enabled: true,
1780
+ duo_workflow_mcp_enabled: true,
1781
+ },
1782
+ };
1783
+ }
1784
+
1785
+ // Best-effort enable of the namespace Duo settings the agent flow needs. Without
1786
+ // `duo_agent_platform_enabled` / `duo_workflow_mcp_enabled` / `experiment_features_enabled`
1787
+ // the inline ambient flow is rejected server-side, so a fresh group must have them on.
1788
+ // PUT requires owner/maintainer; a 4xx (insufficient rights, no namespace) is logged via
1789
+ // trace and swallowed — the run proceeds and surfaces the real error if the flow is still
1790
+ // disabled, rather than blocking login/turns on a permission the user may not hold.
1791
+ async function ensureGitLabDuoWorkflowSettings(
1792
+ fetchImpl: FetchImpl,
1793
+ baseUrl: string,
1794
+ apiKey: string,
1795
+ restNamespaceId: string,
1796
+ signal?: AbortSignal,
1797
+ ): Promise<boolean> {
1798
+ // Returns whether the attempt was DEFINITIVE (so the caller may stop retrying):
1799
+ // any HTTP response — 2xx (flags now on) or 4xx (insufficient rights / no such
1800
+ // namespace, which retrying never fixes) — is definitive. A thrown network error
1801
+ // or a 5xx is transient, so the caller should keep the guard retryable and try
1802
+ // again on a later turn rather than permanently skipping the PUT.
1803
+ try {
1804
+ const response = await fetchImpl(gitLabApiUrl(baseUrl, `/api/v4/groups/${encodeURIComponent(restNamespaceId)}`), {
1805
+ method: "PUT",
1806
+ headers: {
1807
+ Authorization: `Bearer ${apiKey}`,
1808
+ "content-type": "application/json",
1809
+ },
1810
+ body: JSON.stringify(buildGitLabDuoWorkflowSettingsBody()),
1811
+ signal: gitLabDuoWorkflowRestSignal(signal),
1812
+ });
1813
+ traceGitLabDuoWorkflow("settings.ensure", { status: response.status, ok: response.ok });
1814
+ return response.status < 500;
1815
+ } catch (error) {
1816
+ traceGitLabDuoWorkflow("settings.ensure_error", { error: gitLabDuoWorkflowErrorText(error) });
1817
+ return false;
1818
+ }
1819
+ }
1820
+
1821
+ function openGitLabDuoWorkflowSocket(
1822
+ baseUrl: string,
1823
+ options: {
1824
+ token: string;
1825
+ projectId?: string;
1826
+ namespaceId?: string;
1827
+ rootNamespaceId?: string;
1828
+ selectedModelIdentifier?: string;
1829
+ originBaseUrl?: string;
1830
+ workflowDefinition?: GitLabDuoWorkflowDefinition;
1831
+ serviceEndpoint?: boolean;
1832
+ extraHeaders?: Record<string, string>;
1833
+ webSocketFactory?: GitLabDuoWorkflowWebSocketFactory;
1834
+ },
1835
+ ): GitLabDuoWorkflowWebSocketLike {
1836
+ const url = buildGitLabDuoWorkflowWebSocketUrl(baseUrl, options);
1837
+ const headers = buildGitLabDuoWorkflowWebSocketHeaders({
1838
+ ...options,
1839
+ baseUrl: normalizeGitLabBaseUrl(options.originBaseUrl ?? baseUrl),
1840
+ });
1841
+ const factory = options.webSocketFactory ?? defaultGitLabDuoWorkflowWebSocketFactory;
1842
+ traceGitLabDuoWorkflow("websocket.create", { url });
1843
+ return factory(url, { headers });
1844
+ }
1845
+ function defaultGitLabDuoWorkflowWebSocketFactory(
1846
+ url: string,
1847
+ options: GitLabDuoWorkflowWebSocketFactoryOptions,
1848
+ ): GitLabDuoWorkflowWebSocketLike {
1849
+ return new (
1850
+ WebSocket as unknown as new (
1851
+ url: string,
1852
+ options: Bun.WebSocketOptions,
1853
+ ) => GitLabDuoWorkflowWebSocketLike
1854
+ )(url, { headers: options.headers });
1855
+ }
1856
+
1857
+ export function runGitLabDuoWorkflowSocket(
1858
+ ws: GitLabDuoWorkflowWebSocketLike,
1859
+ startPayload: GitLabDuoWorkflowStartRequest,
1860
+ state: GitLabDuoWorkflowStreamState,
1861
+ options: GitLabDuoWorkflowOptions,
1862
+ resumeResponse?: GitLabDuoWorkflowActionResponse | readonly GitLabDuoWorkflowActionResponse[],
1863
+ replayMessages?: readonly unknown[],
1864
+ ): Promise<GitLabDuoWorkflowSocketResult> {
1865
+ const { promise, resolve, reject } = Promise.withResolvers<GitLabDuoWorkflowSocketResult>();
1866
+ let settled = false;
1867
+ let idleTimer: NodeJS.Timeout | undefined;
1868
+ const clearIdleTimer = (): void => {
1869
+ if (idleTimer !== undefined) {
1870
+ clearTimeout(idleTimer);
1871
+ idleTimer = undefined;
1872
+ }
1873
+ };
1874
+ const settle = (result: GitLabDuoWorkflowSocketResult = "closed", error?: unknown): void => {
1875
+ if (settled) return;
1876
+ settled = true;
1877
+ clearIdleTimer();
1878
+ if (error) reject(error);
1879
+ else resolve(result);
1880
+ };
1881
+ const idleTimeoutMs =
1882
+ options.idleTimeoutMs !== undefined && options.idleTimeoutMs > 0
1883
+ ? options.idleTimeoutMs
1884
+ : GITLAB_DUO_WORKFLOW_IDLE_TIMEOUT_MS;
1885
+ const resetIdleTimer = (): void => {
1886
+ clearIdleTimer();
1887
+ if (settled) return;
1888
+ idleTimer = setTimeout(() => {
1889
+ traceGitLabDuoWorkflow("websocket.idle_timeout", { timeoutMs: idleTimeoutMs });
1890
+ close();
1891
+ settle("timeout");
1892
+ }, idleTimeoutMs);
1893
+ };
1894
+ const close = (): void => {
1895
+ try {
1896
+ ws.close();
1897
+ } catch {
1898
+ // Ignore close failures from test doubles or already closed sockets.
1899
+ }
1900
+ };
1901
+ const abort = (): void => {
1902
+ close();
1903
+ settle("closed", new AIError.AbortError("GitLab Duo Workflow request aborted"));
1904
+ };
1905
+ if (options.signal?.aborted) {
1906
+ abort();
1907
+ return promise;
1908
+ }
1909
+ options.signal?.addEventListener("abort", abort, { once: true });
1910
+
1911
+ const active = state.providerSessionState?.active;
1912
+ const handleSocketResult = (
1913
+ result: GitLabDuoWorkflowMessageResult,
1914
+ data: unknown,
1915
+ remaining: readonly unknown[],
1916
+ ): boolean => {
1917
+ if (result === "pause") {
1918
+ if (active) {
1919
+ active.paused = true;
1920
+ active.pauseBuffer = [data, ...remaining, ...(active.pauseBuffer ?? [])];
1921
+ }
1922
+ pauseGitLabDuoWorkflowStream(state);
1923
+ settle("pause");
1924
+ return false;
1925
+ }
1926
+ if (result === "action") {
1927
+ // One MCP tool_call per turn: DWS ToolNode awaits each action's response
1928
+ // before dispatching the next, so the turn is complete at this single
1929
+ // action. Settle now (the agent loop runs the tool, then resumes by
1930
+ // sending the actionResponse on this SAME socket — so do NOT close it).
1931
+ settle("action");
1932
+ return false;
1933
+ }
1934
+ if (result !== "continue") {
1935
+ close();
1936
+ settle(result);
1937
+ return false;
1938
+ }
1939
+ return true;
1940
+ };
1941
+ ws.onerror = event => {
1942
+ const detail = describeGitLabDuoWorkflowSocketEvent(event);
1943
+ traceGitLabDuoWorkflow("websocket.error", { event: detail });
1944
+ settle(
1945
+ "closed",
1946
+ new AIError.ProviderResponseError(`GitLab Duo Workflow WebSocket error: ${detail}`, {
1947
+ provider: "gitlab-duo-agent",
1948
+ kind: "runtime",
1949
+ }),
1950
+ );
1951
+ };
1952
+ ws.onclose = event => {
1953
+ traceGitLabDuoWorkflow("websocket.close", { code: event.code, reason: event.reason });
1954
+ settle(state.lastApprovalStatus ? "approval" : "closed");
1955
+ };
1956
+ ws.onmessage = event => {
1957
+ resetIdleTimer();
1958
+ if (active?.paused) {
1959
+ active.pauseBuffer ??= [];
1960
+ active.pauseBuffer.push(event.data);
1961
+ return;
1962
+ }
1963
+ void handleGitLabDuoWorkflowSocketMessage(event.data, state).then(
1964
+ result => {
1965
+ handleSocketResult(result, event.data, []);
1966
+ },
1967
+ error => settle("closed", error),
1968
+ );
1969
+ };
1970
+ if (replayMessages && replayMessages.length > 0) {
1971
+ ws.onopen = null;
1972
+ void (async () => {
1973
+ if (active) active.paused = true;
1974
+ const pending: unknown[] = [...replayMessages];
1975
+ while (!settled) {
1976
+ if (pending.length === 0) {
1977
+ if (active?.pauseBuffer && active.pauseBuffer.length > 0) {
1978
+ pending.push(...active.pauseBuffer);
1979
+ active.pauseBuffer = [];
1980
+ continue;
1981
+ }
1982
+ // Replay queue fully drained and no buffered frames remain.
1983
+ break;
1984
+ }
1985
+ const data = pending.shift();
1986
+ let result: GitLabDuoWorkflowMessageResult;
1987
+ try {
1988
+ result = await handleGitLabDuoWorkflowSocketMessage(data, state);
1989
+ } catch (error) {
1990
+ settle("closed", error);
1991
+ return;
1992
+ }
1993
+ if (!handleSocketResult(result, data, pending)) {
1994
+ // An `action` result stops the replay loop to hand the tool call back
1995
+ // to OMP. Clear the pause flag first: the live `onmessage` handler must
1996
+ // process the resume continuation directly instead of buffering it
1997
+ // (a buffered continuation would idle the turn until timeout).
1998
+ if (active) active.paused = false;
1999
+ return;
2000
+ }
2001
+ if (active?.pauseBuffer && active.pauseBuffer.length > 0) {
2002
+ pending.push(...active.pauseBuffer);
2003
+ active.pauseBuffer = [];
2004
+ }
2005
+ }
2006
+ if (!settled && active) active.paused = false;
2007
+ })();
2008
+ } else if (resumeResponse && (!Array.isArray(resumeResponse) || resumeResponse.length > 0)) {
2009
+ ws.onopen = null;
2010
+ // Resume the live socket by returning the tool result for the single pending
2011
+ // action of this turn. (Accepts an array for forward-compat, but the serial
2012
+ // inline flow only ever has one.) DWS matches it by requestID to the awaiting
2013
+ // outbox future and the workflow continues on the same connection.
2014
+ const responses = Array.isArray(resumeResponse) ? resumeResponse : [resumeResponse];
2015
+ for (const response of responses) {
2016
+ ws.send(JSON.stringify(response));
2017
+ }
2018
+ } else {
2019
+ ws.onopen = () => {
2020
+ traceGitLabDuoWorkflow("websocket.open", {
2021
+ workflowId: startPayload.workflowID,
2022
+ workflowDefinition: startPayload.workflowDefinition,
2023
+ flowConfigId: startPayload.flowConfigId,
2024
+ flowVersion: startPayload.flowVersion,
2025
+ flowConfigSchemaVersion: startPayload.flowConfigSchemaVersion,
2026
+ mcpTools: startPayload.mcpTools.length,
2027
+ preapprovedTools: startPayload.preapproved_tools.length,
2028
+ });
2029
+ ws.send(JSON.stringify({ startRequest: startPayload }));
2030
+ };
2031
+ }
2032
+ resetIdleTimer();
2033
+ return promise.finally(() => {
2034
+ clearIdleTimer();
2035
+ options.signal?.removeEventListener("abort", abort);
2036
+ });
2037
+ }
2038
+
2039
+ type GitLabDuoWorkflowMessageResult =
2040
+ | "continue"
2041
+ | "terminal"
2042
+ | "approval"
2043
+ | "action"
2044
+ | "pause"
2045
+ | "step_limit"
2046
+ | "retryable_error"
2047
+ | "stalled";
2048
+
2049
+ type GitLabDuoWorkflowCheckpointKind = "text" | "thinking";
2050
+
2051
+ interface GitLabDuoWorkflowCheckpointAgentEntry {
2052
+ kind: GitLabDuoWorkflowCheckpointKind;
2053
+ messageIndex: number;
2054
+ messageKey: string;
2055
+ content: string;
2056
+ }
2057
+
2058
+ interface GitLabDuoWorkflowCheckpointBoundaryEntry {
2059
+ kind: "boundary";
2060
+ messageIndex: number;
2061
+ }
2062
+
2063
+ type GitLabDuoWorkflowCheckpointEntry =
2064
+ | GitLabDuoWorkflowCheckpointAgentEntry
2065
+ | GitLabDuoWorkflowCheckpointBoundaryEntry;
2066
+
2067
+ interface GitLabDuoWorkflowContextUsage {
2068
+ used: number;
2069
+ window: number;
2070
+ }
2071
+
2072
+ interface GitLabDuoWorkflowCheckpointContent {
2073
+ entries: GitLabDuoWorkflowCheckpointEntry[];
2074
+ contentLength: number;
2075
+ latestMessageType?: string;
2076
+ contextUsage?: GitLabDuoWorkflowContextUsage;
2077
+ }
2078
+
2079
+ async function handleGitLabDuoWorkflowSocketMessage(
2080
+ data: unknown,
2081
+ state: GitLabDuoWorkflowStreamState,
2082
+ ): Promise<GitLabDuoWorkflowMessageResult> {
2083
+ const event = parseGitLabDuoWorkflowSocketData(data);
2084
+ if (!event) return "continue";
2085
+ const status =
2086
+ getRecordString(event, "status") ??
2087
+ getNestedRecordString(event, "workflowStatus", "status") ??
2088
+ getNestedRecordString(event, "newCheckpoint", "status");
2089
+ const checkpoint = extractGitLabDuoWorkflowCheckpoint(event);
2090
+ traceGitLabDuoWorkflow("websocket.message", {
2091
+ keys: Object.keys(event),
2092
+ status,
2093
+ hasCheckpoint: Boolean(getRecord(event, "newCheckpoint") ?? getRecord(event, "checkpoint")),
2094
+ checkpointLength: checkpoint?.contentLength ?? 0,
2095
+ });
2096
+ if (checkpoint) {
2097
+ emitGitLabDuoWorkflowCheckpoint(state, checkpoint);
2098
+ }
2099
+ if (state.pauseRequested) {
2100
+ state.pauseRequested = false;
2101
+ return "pause";
2102
+ }
2103
+ if (isGitLabWorkflowApprovalStatus(status)) {
2104
+ state.lastApprovalStatus = status;
2105
+ traceGitLabDuoWorkflow("websocket.approval", { status });
2106
+ return "approval";
2107
+ }
2108
+ if (isGitLabWorkflowCompletionStatus(status)) {
2109
+ traceGitLabDuoWorkflow("websocket.terminal", { status, checkpointLength: checkpoint?.contentLength ?? 0 });
2110
+ finishGitLabDuoWorkflowStream(state, "stop");
2111
+ return "terminal";
2112
+ }
2113
+ if (status === "FAILED" || status === "STOPPED") {
2114
+ const message = gitLabDuoWorkflowErrorText(
2115
+ getRecordString(event, "error") ?? getRecordString(event, "message") ?? status,
2116
+ );
2117
+ // The server caps each workflow at a fixed graph-recursion limit (DWS
2118
+ // RECURSION_LIMIT). A long but healthy OMP tool-call loop legitimately hits
2119
+ // it and surfaces as FAILED with this message. That is not a real failure —
2120
+ // resume by starting a fresh workflow that continues the same conversation
2121
+ // (the accumulated context/tool results replay via the goal envelope).
2122
+ if (status === "FAILED" && isGitLabDuoWorkflowStepLimitMessage(message)) {
2123
+ traceGitLabDuoWorkflow("websocket.step_limit", { status });
2124
+ state.stepLimitRequested = true;
2125
+ return "step_limit";
2126
+ }
2127
+ // The DWS catch-all FAILED ("...error processing your request in the Duo Agent
2128
+ // Platform...") is a de-identified wrapper over transient upstream faults
2129
+ // (model 5xx that exhausted retries, AgentStuckError, etc.). Retry ONCE on a
2130
+ // FRESH workflow (the broken same-id reconnect is never used): the accumulated
2131
+ // conversation replays through the goal transcript. Bounded so a deterministic
2132
+ // failure degrades to a surfaced error instead of a quota sink.
2133
+ if (status === "FAILED" && isGitLabDuoWorkflowGenericProcessingError(message)) {
2134
+ traceGitLabDuoWorkflow("websocket.generic_error", { status });
2135
+ state.retryableErrorRequested = true;
2136
+ // Stash the real message but do NOT push an error event yet: the loop retries
2137
+ // on a fresh workflow and only surfaces this if retries are exhausted.
2138
+ state.output.errorMessage = message;
2139
+ return "retryable_error";
2140
+ }
2141
+ traceGitLabDuoWorkflow("websocket.failed", { status });
2142
+ state.output.stopReason = "error";
2143
+ // An oversized goal that fails terminally is almost certainly failing on the byte
2144
+ // size — surface it as a context-overflow so the session auto-compacts.
2145
+ state.output.errorMessage = state.goalOverflowMessage ?? message;
2146
+ state.stream.push({ type: "error", reason: "error", error: state.output });
2147
+ return "terminal";
2148
+ }
2149
+ const action = extractGitLabDuoWorkflowAction(event);
2150
+ if (!action) return "continue";
2151
+ traceGitLabDuoWorkflow("websocket.action", {
2152
+ actionName: action.name,
2153
+ requestID: action.requestID,
2154
+ toolName:
2155
+ getRecordString(action.args as Record<string, unknown>, "name") ??
2156
+ getRecordString(action.args as Record<string, unknown>, "toolName") ??
2157
+ getRecordString(action.args as Record<string, unknown>, "tool_name"),
2158
+ argKeys: Object.keys(action.args as Record<string, unknown>).slice(0, 20),
2159
+ });
2160
+ // A fresh tool-call boundary whose `ui_chat_log` total did not advance past the
2161
+ // previous boundary of this workflow means the server-side turn did not progress:
2162
+ // emitting and answering this tool call would only feed the same non-advancing loop.
2163
+ // Settle "stalled" so the socket loop restarts on a fresh workflow (resending the
2164
+ // full goal transcript) instead of running the doomed tool call.
2165
+ if (detectGitLabDuoWorkflowStall(state)) {
2166
+ traceGitLabDuoWorkflow("websocket.stalled", {
2167
+ checkpointLength: state.lastCheckpointContentLength,
2168
+ actionName: action.name,
2169
+ });
2170
+ state.stalledRequested = true;
2171
+ return "stalled";
2172
+ }
2173
+ // Finalize this tool_call as its own assistant message and commit it as the
2174
+ // single pending action; the socket loop settles "action" so the agent loop
2175
+ // runs the tool and resumes.
2176
+ emitGitLabDuoWorkflowActionToolCall(state, action);
2177
+ return "action";
2178
+ }
2179
+ function isGitLabWorkflowApprovalStatus(status: string | undefined): boolean {
2180
+ return status === "PLAN_APPROVAL_REQUIRED" || status === "TOOL_CALL_APPROVAL_REQUIRED";
2181
+ }
2182
+
2183
+ function isGitLabWorkflowCompletionStatus(status: string | undefined): boolean {
2184
+ return status === "INPUT_REQUIRED" || status === "FINISHED";
2185
+ }
2186
+ // Matches the DWS GraphRecursionError surface ("The workflow reached its maximum
2187
+ // step limit and could not complete."). The leading clause is stable across
2188
+ // flows; match on it case-insensitively so a fresh workflow can continue the run.
2189
+ function isGitLabDuoWorkflowStepLimitMessage(message: string): boolean {
2190
+ return message.toLowerCase().includes("reached its maximum step limit");
2191
+ }
2192
+ // Matches the DWS de-identified catch-all FAILED ("There was an error processing
2193
+ // your request in the Duo Agent Platform, please contact support if the issue
2194
+ // persists.") — server-side wrapper over transient upstream faults. Match on the
2195
+ // stable middle clause case-insensitively (the surrounding text varies slightly
2196
+ // across server versions).
2197
+ function isGitLabDuoWorkflowGenericProcessingError(message: string): boolean {
2198
+ return message.toLowerCase().includes("error processing your request in the duo agent platform");
2199
+ }
2200
+ export function buildGitLabDuoWorkflowApprovalStartRequest(
2201
+ startPayload: GitLabDuoWorkflowStartRequest,
2202
+ ): GitLabDuoWorkflowStartRequest {
2203
+ return {
2204
+ ...startPayload,
2205
+ goal: "",
2206
+ additional_context: [],
2207
+ approval: { approval: {} },
2208
+ };
2209
+ }
2210
+
2211
+ function buildGitLabDuoWorkflowActionResponse(
2212
+ requestID: string,
2213
+ response: GitLabPlainTextResponse,
2214
+ ): GitLabDuoWorkflowActionResponse {
2215
+ return { actionResponse: { requestID, plainTextResponse: response } };
2216
+ }
2217
+
2218
+ function gitLabToolResultToText(toolResult: ToolResultMessage): string {
2219
+ return toolResult.content.map(item => (item.type === "text" ? item.text : `[${item.mimeType} image]`)).join("\n");
2220
+ }
2221
+
2222
+ function buildGitLabMcpToolDefinition(tool: Tool): GitLabMcpToolDefinition {
2223
+ const schema = toolWireSchema(tool);
2224
+ // Register the tool under its BARE name (no `mcp__omp__` prefix). The server does
2225
+ // not strip prefixes — it registers `_executable_tools` and binds the model schema
2226
+ // under exactly the wire `name` (sanitize_llm_name only replaces illegal chars), so
2227
+ // the name the model sees, the toolset key it is matched against, and OMP's own
2228
+ // tool docs must all be the same bare name. A prefixed wire name only forced the
2229
+ // model to learn `mcp__omp__read` while OMP docs say `read`, with no upside.
2230
+ // `originalToolName`/`serverName` stay as MCP metadata; they are not the match key.
2231
+ return {
2232
+ name: tool.name,
2233
+ originalToolName: tool.name,
2234
+ serverName: "omp",
2235
+ description: tool.description || "",
2236
+ inputSchema: JSON.stringify(
2237
+ schema && typeof schema === "object" ? schema : { type: "object", properties: {}, required: [] },
2238
+ ),
2239
+ isApproved: true,
2240
+ };
2241
+ }
2242
+
2243
+ function createAssistantMessage(model: Model<Api>): AssistantMessage {
2244
+ return {
2245
+ role: "assistant",
2246
+ content: [],
2247
+ api: model.api,
2248
+ provider: model.provider,
2249
+ model: model.id,
2250
+ usage: {
2251
+ input: 0,
2252
+ output: 0,
2253
+ cacheRead: 0,
2254
+ cacheWrite: 0,
2255
+ totalTokens: 0,
2256
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
2257
+ },
2258
+ stopReason: "stop",
2259
+ timestamp: Date.now(),
2260
+ };
2261
+ }
2262
+
2263
+ function hydrateGitLabDuoWorkflowCheckpointState(
2264
+ state: GitLabDuoWorkflowStreamState,
2265
+ session: GitLabDuoWorkflowActiveSession,
2266
+ ): void {
2267
+ state.checkpointAgentContentByKey = session.checkpointAgentContentByKey;
2268
+ state.checkpointAgentContentSignatures = session.checkpointAgentContentSignatures;
2269
+ }
2270
+
2271
+ function syncGitLabDuoWorkflowCheckpointState(state: GitLabDuoWorkflowStreamState): void {
2272
+ const active = state.providerSessionState?.active;
2273
+ if (!active) return;
2274
+ active.checkpointAgentContentByKey = state.checkpointAgentContentByKey;
2275
+ active.checkpointAgentContentSignatures = state.checkpointAgentContentSignatures;
2276
+ }
2277
+
2278
+ function emitGitLabDuoWorkflowCheckpoint(
2279
+ state: GitLabDuoWorkflowStreamState,
2280
+ checkpoint: GitLabDuoWorkflowCheckpointContent,
2281
+ ): void {
2282
+ if (checkpoint.contextUsage) {
2283
+ applyGitLabDuoWorkflowContextUsage(state, checkpoint.contextUsage);
2284
+ }
2285
+ // Track the server's latest checkpoint byte length so the action handler can detect
2286
+ // a workflow whose state stopped advancing (stall). The control experiment proved a
2287
+ // healthy turn emits checkpoints whose byte size varies and grows, while a stalled
2288
+ // workflow re-emits a byte-identical checkpoint.
2289
+ state.lastCheckpointContentLength = checkpoint.contentLength;
2290
+ // GitLab checkpoints are full ui_chat_log snapshots, so a later frame replays
2291
+ // earlier request/tool boundaries before the new agent delta. Pause only on a
2292
+ // boundary that follows a delta emitted in THIS checkpoint (`deltaThisCheckpoint`),
2293
+ // not any delta emitted earlier in the socket call — otherwise a stale replayed
2294
+ // boundary would fire one pause_turn per snapshot and hit the loop's continuation cap.
2295
+ let deltaThisCheckpoint = false;
2296
+ // Turn position within this full-snapshot replay: a request/tool boundary
2297
+ // starts a new turn. The content-signature fallback below is scoped to this
2298
+ // index so it suppresses only a replayed message reappearing at the SAME turn
2299
+ // position (e.g. GitLab renames a message_id across a shrunk snapshot, so the
2300
+ // per-key lookup misses but the text was already emitted for that turn). A
2301
+ // genuinely new later message with text equal to an earlier one lands at a
2302
+ // LATER turn (after an extra boundary), so its signature differs and it still
2303
+ // emits — repeated assistant output across turns is no longer swallowed.
2304
+ let turnIndex = 0;
2305
+ for (const entry of checkpoint.entries) {
2306
+ if (entry.kind === "boundary") {
2307
+ if (deltaThisCheckpoint && state.providerSessionState?.active) {
2308
+ state.pauseRequested = true;
2309
+ return;
2310
+ }
2311
+ endGitLabDuoWorkflowText(state);
2312
+ endGitLabDuoWorkflowThinking(state);
2313
+ turnIndex += 1;
2314
+ continue;
2315
+ }
2316
+
2317
+ const contentByKey = state.checkpointAgentContentByKey ?? {};
2318
+ const contentSignatures = state.checkpointAgentContentSignatures ?? {};
2319
+ const previousContent = contentByKey[entry.messageKey];
2320
+ const contentSignature = `${turnIndex}\u0000${entry.kind}\u0000${entry.content}`;
2321
+ const contentOnlySignature = `${turnIndex}\u0000content\u0000${entry.content}`;
2322
+ const duplicateContent =
2323
+ previousContent === undefined &&
2324
+ (contentSignatures[contentSignature] === true || contentSignatures[contentOnlySignature] === true);
2325
+ const rewroteExistingContent =
2326
+ previousContent !== undefined &&
2327
+ !entry.content.startsWith(previousContent) &&
2328
+ previousContent !== entry.content;
2329
+ const delta = duplicateContent
2330
+ ? ""
2331
+ : rewroteExistingContent
2332
+ ? ""
2333
+ : previousContent !== undefined
2334
+ ? entry.content.slice(previousContent.length)
2335
+ : entry.content;
2336
+
2337
+ contentByKey[entry.messageKey] = entry.content;
2338
+ contentSignatures[contentSignature] = true;
2339
+ contentSignatures[contentOnlySignature] = true;
2340
+ state.checkpointAgentContentByKey = contentByKey;
2341
+ state.checkpointAgentContentSignatures = contentSignatures;
2342
+ syncGitLabDuoWorkflowCheckpointState(state);
2343
+
2344
+ if (delta.length === 0) continue;
2345
+
2346
+ if (
2347
+ state.activeCheckpointMessageKey &&
2348
+ state.activeCheckpointMessageKey !== entry.messageKey &&
2349
+ previousContent === undefined
2350
+ ) {
2351
+ endGitLabDuoWorkflowText(state);
2352
+ endGitLabDuoWorkflowThinking(state);
2353
+ }
2354
+ emitGitLabDuoWorkflowCheckpointSegment(state, entry.kind, delta);
2355
+ state.activeCheckpointMessageKey = entry.messageKey;
2356
+ deltaThisCheckpoint = true;
2357
+ }
2358
+ }
2359
+
2360
+ // Map the server's per-agent context occupancy onto the assistant usage so the per-message
2361
+ // usage row reflects the real prompt/context size. total_tokens is GitLab's full-history
2362
+ // estimate (the input/prompt side); there is no separate billing usage on this transport.
2363
+ function applyGitLabDuoWorkflowContextUsage(
2364
+ state: GitLabDuoWorkflowStreamState,
2365
+ contextUsage: GitLabDuoWorkflowContextUsage,
2366
+ ): void {
2367
+ const usage = state.output.usage;
2368
+ usage.input = contextUsage.used;
2369
+ usage.totalTokens = usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
2370
+ }
2371
+
2372
+ function emitGitLabDuoWorkflowCheckpointSegment(
2373
+ state: GitLabDuoWorkflowStreamState,
2374
+ kind: GitLabDuoWorkflowCheckpointKind,
2375
+ delta: string,
2376
+ ): void {
2377
+ if (kind === "thinking") {
2378
+ emitGitLabDuoWorkflowThinking(state, delta);
2379
+ return;
2380
+ }
2381
+ emitGitLabDuoWorkflowText(state, delta);
2382
+ }
2383
+
2384
+ function emitGitLabDuoWorkflowText(state: GitLabDuoWorkflowStreamState, text: string): void {
2385
+ if (!text) return;
2386
+ endGitLabDuoWorkflowThinking(state);
2387
+ let activeTextIndex = state.activeTextIndex;
2388
+ if (activeTextIndex === undefined) {
2389
+ const block = { type: "text" as const, text: "" };
2390
+ state.output.content.push(block);
2391
+ activeTextIndex = state.output.content.length - 1;
2392
+ state.activeTextIndex = activeTextIndex;
2393
+ state.stream.push({ type: "text_start", contentIndex: activeTextIndex, partial: state.output });
2394
+ }
2395
+ const block = state.output.content[activeTextIndex];
2396
+ if (block?.type !== "text") return;
2397
+ block.text += text;
2398
+ state.stream.push({ type: "text_delta", contentIndex: activeTextIndex, delta: text, partial: state.output });
2399
+ }
2400
+
2401
+ function emitGitLabDuoWorkflowThinking(state: GitLabDuoWorkflowStreamState, thinking: string): void {
2402
+ if (!thinking) return;
2403
+ endGitLabDuoWorkflowText(state);
2404
+ let activeThinkingIndex = state.activeThinkingIndex;
2405
+ if (activeThinkingIndex === undefined) {
2406
+ const block = { type: "thinking" as const, thinking: "" };
2407
+ state.output.content.push(block);
2408
+ activeThinkingIndex = state.output.content.length - 1;
2409
+ state.activeThinkingIndex = activeThinkingIndex;
2410
+ state.stream.push({ type: "thinking_start", contentIndex: activeThinkingIndex, partial: state.output });
2411
+ }
2412
+ const block = state.output.content[activeThinkingIndex];
2413
+ if (block?.type !== "thinking") return;
2414
+ block.thinking += thinking;
2415
+ state.stream.push({
2416
+ type: "thinking_delta",
2417
+ contentIndex: activeThinkingIndex,
2418
+ delta: thinking,
2419
+ partial: state.output,
2420
+ });
2421
+ }
2422
+
2423
+ function endGitLabDuoWorkflowText(state: GitLabDuoWorkflowStreamState): void {
2424
+ if (state.activeTextIndex === undefined) return;
2425
+ const block = state.output.content[state.activeTextIndex];
2426
+ if (block?.type === "text") {
2427
+ state.stream.push({
2428
+ type: "text_end",
2429
+ contentIndex: state.activeTextIndex,
2430
+ content: block.text,
2431
+ partial: state.output,
2432
+ });
2433
+ }
2434
+ state.activeTextIndex = undefined;
2435
+ }
2436
+
2437
+ function endGitLabDuoWorkflowThinking(state: GitLabDuoWorkflowStreamState): void {
2438
+ if (state.activeThinkingIndex === undefined) return;
2439
+ const block = state.output.content[state.activeThinkingIndex];
2440
+ if (block?.type === "thinking") {
2441
+ state.stream.push({
2442
+ type: "thinking_end",
2443
+ contentIndex: state.activeThinkingIndex,
2444
+ content: block.thinking,
2445
+ partial: state.output,
2446
+ });
2447
+ }
2448
+ state.activeThinkingIndex = undefined;
2449
+ }
2450
+
2451
+ function finishGitLabDuoWorkflowStream(
2452
+ state: GitLabDuoWorkflowStreamState,
2453
+ reason: Extract<AssistantMessage["stopReason"], "stop" | "length" | "toolUse">,
2454
+ ): void {
2455
+ endGitLabDuoWorkflowText(state);
2456
+ endGitLabDuoWorkflowThinking(state);
2457
+ state.output.stopReason = reason;
2458
+ state.stream.push({ type: "done", reason, message: state.output });
2459
+ }
2460
+
2461
+ // Finalize a resumed-socket turn. `action`/`pause` keep the session alive for the
2462
+ // next resume; every other result (`terminal`/`closed`/`approval`/`timeout`) drops
2463
+ // the resumable session, and — because only `terminal` carries a server `done` —
2464
+ // emits a terminal `done` for the rest so the assistant stream never hangs open
2465
+ // after a tool result the way the fresh-workflow loop already finalizes.
2466
+ function finalizeGitLabDuoWorkflowResumeResult(
2467
+ state: GitLabDuoWorkflowStreamState,
2468
+ providerSessionState: GitLabDuoWorkflowProviderSessionState | undefined,
2469
+ result: GitLabDuoWorkflowSocketResult,
2470
+ ): void {
2471
+ if (result === "action" || result === "pause") return;
2472
+ if (providerSessionState) {
2473
+ providerSessionState.active = undefined;
2474
+ }
2475
+ if (result !== "terminal" && !state.stream.done) {
2476
+ finishGitLabDuoWorkflowStream(state, "stop");
2477
+ }
2478
+ }
2479
+
2480
+ // Run a resume on a preserved socket (action-result or pause replay) and finalize it
2481
+ // the same way the fresh-workflow loop does, returning the settled socket result so
2482
+ // the caller can react to a stall. If the resume rejects — the preserved WebSocket
2483
+ // errored, or `ws.send` threw because it closed while the local tool ran — the
2484
+ // preserved session would otherwise be left with `active` still set and the server
2485
+ // workflow still running. Drop `active` and fire a best-effort stop before rethrowing
2486
+ // so the next turn never resumes a dead socket or strands the workflow.
2487
+ async function resumeGitLabDuoWorkflowSocket(
2488
+ args: {
2489
+ fetchImpl: FetchImpl;
2490
+ baseUrl: string;
2491
+ apiKey: string;
2492
+ workflowId: string;
2493
+ state: GitLabDuoWorkflowStreamState;
2494
+ providerSessionState: GitLabDuoWorkflowProviderSessionState | undefined;
2495
+ },
2496
+ run: () => Promise<GitLabDuoWorkflowSocketResult>,
2497
+ ): Promise<GitLabDuoWorkflowSocketResult> {
2498
+ let socketResult: GitLabDuoWorkflowSocketResult;
2499
+ try {
2500
+ socketResult = await run();
2501
+ } catch (error) {
2502
+ if (args.providerSessionState) {
2503
+ args.providerSessionState.active = undefined;
2504
+ }
2505
+ await stopGitLabDuoWorkflow(args.fetchImpl, args.baseUrl, args.apiKey, args.workflowId);
2506
+ throw error;
2507
+ }
2508
+ // A stall on the resumed socket must NOT finalize the stream: the caller re-seeds a
2509
+ // fresh workflow (rebuilt goal includes the just-returned tool result) to break the
2510
+ // non-advancing loop. Stop the stalled workflow and drop `active` here so the caller
2511
+ // owns a clean slate, but leave the stream open for the fresh run.
2512
+ if (socketResult === "stalled") {
2513
+ if (args.providerSessionState) args.providerSessionState.active = undefined;
2514
+ await stopGitLabDuoWorkflow(args.fetchImpl, args.baseUrl, args.apiKey, args.workflowId);
2515
+ return socketResult;
2516
+ }
2517
+ finalizeGitLabDuoWorkflowResumeResult(args.state, args.providerSessionState, socketResult);
2518
+ // `action`/`pause` keep the session alive for the next resume; `terminal` is a real
2519
+ // server completion. But `closed`/`timeout` (and an exhausted `approval`) settle the
2520
+ // local stream while the remote workflow may still be running — mirror the fresh-
2521
+ // workflow `finally` and send the stop PATCH so a half-open/dropped socket after a
2522
+ // tool result never strands the server-side workflow with no local handle left.
2523
+ if (socketResult === "closed" || socketResult === "timeout") {
2524
+ await stopGitLabDuoWorkflow(args.fetchImpl, args.baseUrl, args.apiKey, args.workflowId);
2525
+ }
2526
+ return socketResult;
2527
+ }
2528
+
2529
+ function pauseGitLabDuoWorkflowStream(state: GitLabDuoWorkflowStreamState): void {
2530
+ endGitLabDuoWorkflowText(state);
2531
+ endGitLabDuoWorkflowThinking(state);
2532
+ state.output.stopReason = "stop";
2533
+ state.output.stopDetails = { type: "pause_turn" };
2534
+ state.stream.push({ type: "done", reason: "stop", message: state.output });
2535
+ }
2536
+ interface GitLabDuoWorkflowReplayToolCall {
2537
+ id: string;
2538
+ name: string;
2539
+ arguments: Record<string, unknown>;
2540
+ }
2541
+
2542
+ interface GitLabDuoWorkflowReplayMessage {
2543
+ role: "user" | "assistant" | "tool";
2544
+ content: string;
2545
+ toolCalls?: GitLabDuoWorkflowReplayToolCall[];
2546
+ toolCallId?: string;
2547
+ toolName?: string;
2548
+ isError?: boolean;
2549
+ }
2550
+
2551
+ // Trimmed once: the static note tells the model the goal transcript's ChatML/`<ran>`
2552
+ // markers are a historical record, not a syntax to emit.
2553
+ const GITLAB_DUO_WORKFLOW_CHATML_HISTORY_NOTE = chatmlHistoryNote.trim();
2554
+
2555
+ // The OMP system prompt that rides the inline flow's `prompt_template.system` slot.
2556
+ // DWS wraps it in its own gateway boilerplate, but the slot content is delivered to
2557
+ // the model verbatim, so OMP's authoritative rules go here directly — no redirect
2558
+ // preamble and no embedding inside the goal. When the goal is a multi-turn ChatML
2559
+ // transcript (not a lone bare-text prompt), append the history-note so the model does
2560
+ // not mimic the transcript's `<|im_start|>`/`<ran …>` markers as its own tool-call
2561
+ // output — markers it kept copying even after they were reframed to past tense.
2562
+ function buildGitLabDuoWorkflowSystemPrompt(context: Context): string {
2563
+ const base = normalizeSystemPrompts(context.systemPrompt).join("\n\n");
2564
+ if (!isGitLabDuoWorkflowChatMlGoal(context)) return base;
2565
+ return base ? `${base}\n\n${GITLAB_DUO_WORKFLOW_CHATML_HISTORY_NOTE}` : GITLAB_DUO_WORKFLOW_CHATML_HISTORY_NOTE;
2566
+ }
2567
+
2568
+ // A goal renders as a literal ChatML transcript only when more than one turn survives
2569
+ // the replay filter; a lone turn is sent as bare text (see buildGitLabDuoWorkflowGoal),
2570
+ // so the history-note would describe markers that are not present.
2571
+ function isGitLabDuoWorkflowChatMlGoal(context: Context): boolean {
2572
+ return buildGitLabDuoWorkflowConversationHistory(context.messages).length > 1;
2573
+ }
2574
+
2575
+ // The goal carries ONLY the conversation, rendered as a bare ChatML transcript. The
2576
+ // system prompt lives in the flow's system slot, so the goal needs no envelope, no
2577
+ // `<instructions>` section, and no preamble. A lone turn is sent verbatim; a real
2578
+ // multi-turn session becomes the flat ChatML transcript, every turn equal-weight,
2579
+ // ending naturally on the last turn. ChatML markers are literal text here (DWS does
2580
+ // not tokenize the goal as a chat template), chosen because `<|im_start|>`/`<|im_end|>`
2581
+ // effectively never collide with natural message content and are not Claude-reserved
2582
+ // conversation sequences the way `Human:`/`Assistant:` are.
2583
+ function buildGitLabDuoWorkflowGoal(context: Context): string {
2584
+ const conversation = buildGitLabDuoWorkflowConversationHistory(context.messages);
2585
+ // The goal transcript bypasses transformMessages, so apply the outbound
2586
+ // credential redaction here — the same scrub the flow-config system slot
2587
+ // already receives — before the payload leaves the process.
2588
+ if (conversation.length <= 1) {
2589
+ return redactSensitiveCredentials(extractLatestUserPrompt(context.messages));
2590
+ }
2591
+ return redactSensitiveCredentials(renderGitLabDuoWorkflowChatMl(conversation));
2592
+ }
2593
+
2594
+ const GITLAB_DUO_WORKFLOW_CHATML_START = "<|im_start|>";
2595
+ const GITLAB_DUO_WORKFLOW_CHATML_END = "<|im_end|>";
2596
+
2597
+ // Render the flat transcript as literal ChatML. Each turn is
2598
+ // `<|im_start|>role\n<body><|im_end|>`. An assistant turn that issued tool calls
2599
+ // renders them after its text as `<ran NAME>{args}</ran>` records — a PAST-tense log
2600
+ // of a call that already executed, deliberately NOT the `{name,arguments}` shape the
2601
+ // live structured tool-use channel uses, so the model reads history as a record and
2602
+ // does not mimic it as emittable call grammar. The paired result rides the next
2603
+ // `tool` turn, linked by adjacency (1 call/turn), so the chain stays intact.
2604
+ function renderGitLabDuoWorkflowChatMl(conversation: readonly GitLabDuoWorkflowReplayMessage[]): string {
2605
+ return conversation.map(renderGitLabDuoWorkflowChatMlTurn).join("\n");
2606
+ }
2607
+
2608
+ function renderGitLabDuoWorkflowChatMlTurn(message: GitLabDuoWorkflowReplayMessage): string {
2609
+ const body = gitLabDuoWorkflowChatMlBody(message);
2610
+ return `${GITLAB_DUO_WORKFLOW_CHATML_START}${message.role}\n${body}${GITLAB_DUO_WORKFLOW_CHATML_END}`;
2611
+ }
2612
+
2613
+ function gitLabDuoWorkflowChatMlBody(message: GitLabDuoWorkflowReplayMessage): string {
2614
+ const parts: string[] = [];
2615
+ if (message.content.length > 0) parts.push(message.content);
2616
+ if (message.role === "assistant" && message.toolCalls) {
2617
+ for (const toolCall of message.toolCalls) {
2618
+ parts.push(renderGitLabDuoWorkflowChatMlToolCall(toolCall));
2619
+ }
2620
+ }
2621
+ if (message.role === "tool") {
2622
+ const header = gitLabDuoWorkflowChatMlToolResultHeader(message);
2623
+ return header ? `${header}\n${message.content}\n` : `${message.content}\n`;
2624
+ }
2625
+ return `${parts.join("\n")}\n`;
2626
+ }
2627
+
2628
+ function gitLabDuoWorkflowChatMlToolResultHeader(message: GitLabDuoWorkflowReplayMessage): string | undefined {
2629
+ if (!message.toolName && !message.toolCallId) return undefined;
2630
+ const status = message.isError ? " status=error" : "";
2631
+ // The tool name is omitted: the result rides the turn immediately after its call
2632
+ // (1:1, adjacent), so the model pairs them by position; repeating the name is dead
2633
+ // weight and makes the result read like an independent construct. `<ran:result>` is
2634
+ // past-tense — the adjacent output of the prior historical run, not emittable grammar.
2635
+ return `<ran:result${status}>`;
2636
+ }
2637
+
2638
+ function renderGitLabDuoWorkflowChatMlToolCall(toolCall: GitLabDuoWorkflowReplayToolCall): string {
2639
+ // The goal is a plain text transcript fed to the model, not an HTML/script
2640
+ // context, so `<`/`>` need no escaping. Render as a past-tense `<ran NAME>` record:
2641
+ // the tag names the tool, the body is just the arguments JSON (the `{name,arguments}`
2642
+ // wrapper is dropped — it was the exact shape the model copied as a would-be live
2643
+ // call). The call id is OMP-internal wiring the model never reads (call→result pair
2644
+ // by adjacency), so it is omitted to save bytes. `arguments` carries the `i` (intent)
2645
+ // key only at live dispatch; on replay it is stripped (see gitLabDuoWorkflowAssistantToolCalls).
2646
+ const args = JSON.stringify(toolCall.arguments) ?? "null";
2647
+ return `<ran ${toolCall.name}>${args}</ran>`;
2648
+ }
2649
+
2650
+ // The whole session as a flat, equal-weight transcript. Every turn — including the
2651
+ // latest user message — is one entry; nothing is elevated to a privileged
2652
+ // `<current_request>`. DWS' goal blob has no native turn priority, so elevating the
2653
+ // last turn (the old template) caused mid-task reminders / IRC wakes to outrank the
2654
+ // actual task. A flat transcript ending naturally on the last turn removes that skew.
2655
+ function buildGitLabDuoWorkflowConversationHistory(messages: readonly Message[]): GitLabDuoWorkflowReplayMessage[] {
2656
+ const history: GitLabDuoWorkflowReplayMessage[] = [];
2657
+ for (let index = 0; index < messages.length; index++) {
2658
+ const replayMessage = buildGitLabDuoWorkflowReplayMessage(messages[index]);
2659
+ if (replayMessage) history.push(replayMessage);
2660
+ }
2661
+ return history;
2662
+ }
2663
+
2664
+ function buildGitLabDuoWorkflowReplayMessage(message: Message | undefined): GitLabDuoWorkflowReplayMessage | undefined {
2665
+ if (!message) return undefined;
2666
+ if (message.role === "toolResult") {
2667
+ const content = gitLabDuoWorkflowMessageContentToText(message);
2668
+ return {
2669
+ role: "tool",
2670
+ content,
2671
+ toolCallId: message.toolCallId,
2672
+ toolName: message.toolName,
2673
+ isError: message.isError,
2674
+ };
2675
+ }
2676
+ if (message.role === "assistant") {
2677
+ const content = gitLabDuoWorkflowMessageContentToText(message);
2678
+ const toolCalls = gitLabDuoWorkflowAssistantToolCalls(message);
2679
+ if (content.length === 0 && toolCalls.length === 0) return undefined;
2680
+ return toolCalls.length > 0 ? { role: "assistant", content, toolCalls } : { role: "assistant", content };
2681
+ }
2682
+ const content = gitLabDuoWorkflowMessageContentToText(message);
2683
+ if (content.length === 0) return undefined;
2684
+ return { role: "user", content };
2685
+ }
2686
+
2687
+ function gitLabDuoWorkflowAssistantToolCalls(message: AssistantMessage): GitLabDuoWorkflowReplayToolCall[] {
2688
+ const toolCalls: GitLabDuoWorkflowReplayToolCall[] = [];
2689
+ for (const item of message.content) {
2690
+ if (item.type === "toolCall") {
2691
+ toolCalls.push({
2692
+ id: item.id,
2693
+ name: item.name,
2694
+ arguments: stripGitLabDuoWorkflowReplayIntent(item.arguments),
2695
+ });
2696
+ }
2697
+ }
2698
+ return toolCalls;
2699
+ }
2700
+
2701
+ // The `i` key is OMP's per-call intent narration (e.g. "Reading kernel smoke body").
2702
+ // It is UI-time metadata describing the call as it is made; on replay the tool name
2703
+ // plus arguments already say what the call did, so the intent is dead transcript
2704
+ // weight. Drop it from the rendered history. (Live dispatch never reads the replayed
2705
+ // args, so this only affects the bytes the model sees, never tool execution.)
2706
+ function stripGitLabDuoWorkflowReplayIntent(args: Record<string, unknown>): Record<string, unknown> {
2707
+ if (!("i" in args)) return args;
2708
+ const { i: _intent, ...rest } = args;
2709
+ return rest;
2710
+ }
2711
+
2712
+ function extractLatestUserPrompt(messages: readonly Message[]): string {
2713
+ const index = findLatestGitLabDuoWorkflowUserMessageIndex(messages);
2714
+ if (index < 0) return "";
2715
+ return gitLabDuoWorkflowUserContentToText(messages[index] as Exclude<Message, AssistantMessage>);
2716
+ }
2717
+
2718
+ function findLatestGitLabDuoWorkflowUserMessageIndex(messages: readonly Message[]): number {
2719
+ for (let index = messages.length - 1; index >= 0; index--) {
2720
+ const message = messages[index];
2721
+ if (message?.role === "user" || message?.role === "developer") return index;
2722
+ }
2723
+ return -1;
2724
+ }
2725
+
2726
+ function gitLabDuoWorkflowMessageContentToText(message: Message): string {
2727
+ if (message.role === "assistant") {
2728
+ return message.content
2729
+ .map(item => {
2730
+ if (item.type === "text") return item.text;
2731
+ if (item.type === "thinking" || item.type === "redactedThinking") return "";
2732
+ return "";
2733
+ })
2734
+ .join("\n");
2735
+ }
2736
+ return gitLabDuoWorkflowUserContentToText(message);
2737
+ }
2738
+
2739
+ function gitLabDuoWorkflowUserContentToText(message: Exclude<Message, AssistantMessage>): string {
2740
+ if (typeof message.content === "string") return message.content;
2741
+ return message.content.map(item => (item.type === "text" ? item.text : `[${item.mimeType} image]`)).join("\n");
2742
+ }
2743
+
2744
+ export function describeGitLabDuoWorkflowSocketEvent(event: unknown): string {
2745
+ const fields: string[] = [];
2746
+ if (event && typeof event === "object") {
2747
+ const type = getRecordString(event, "type");
2748
+ const message = getRecordString(event, "message");
2749
+ const code = getRecordString(event, "code");
2750
+ const reason = getRecordString(event, "reason");
2751
+ const error = socketEventErrorText((event as Record<string, unknown>).error);
2752
+ if (type) fields.push(`type=${type}`);
2753
+ if (message) fields.push(`message=${message}`);
2754
+ if (error) fields.push(`error=${error}`);
2755
+ if (code) fields.push(`code=${code}`);
2756
+ if (reason) fields.push(`reason=${reason}`);
2757
+ }
2758
+ const fallback = fields.length > 0 ? fields.join(", ") : String(event);
2759
+ return gitLabDuoWorkflowErrorText(fallback);
2760
+ }
2761
+
2762
+ function socketEventErrorText(error: unknown): string | undefined {
2763
+ if (typeof error === "string" || typeof error === "number") return String(error);
2764
+ if (error instanceof Error) return error.message;
2765
+ if (error && typeof error === "object") {
2766
+ return getRecordString(error, "message") ?? getRecordString(error, "name");
2767
+ }
2768
+ return undefined;
2769
+ }
2770
+
2771
+ export function traceGitLabDuoWorkflow(event: string, data: Record<string, unknown> = {}): void {
2772
+ if (Bun.env[GITLAB_DUO_WORKFLOW_TRACE_ENV] !== "1") return;
2773
+ const traceFile = Bun.env[GITLAB_DUO_WORKFLOW_TRACE_FILE_ENV]?.trim() || DEFAULT_GITLAB_DUO_WORKFLOW_TRACE_FILE;
2774
+ const line = `${JSON.stringify({
2775
+ time: new Date().toISOString(),
2776
+ event,
2777
+ ...truncateGitLabTraceData(data),
2778
+ })}\n`;
2779
+ void fs
2780
+ .mkdir(path.dirname(traceFile), { recursive: true })
2781
+ .then(() => fs.appendFile(traceFile, line, "utf8"))
2782
+ .catch(() => {});
2783
+ }
2784
+
2785
+ function truncateGitLabTraceData(data: Record<string, unknown>): Record<string, unknown> {
2786
+ const truncated: Record<string, unknown> = {};
2787
+ for (const [key, value] of Object.entries(data)) {
2788
+ truncated[key] = truncateGitLabTraceValue(value);
2789
+ }
2790
+ return truncated;
2791
+ }
2792
+
2793
+ function truncateGitLabTraceValue(value: unknown): unknown {
2794
+ if (typeof value === "string") return value.slice(0, 500);
2795
+ if (typeof value === "number" || typeof value === "boolean" || value === null) return value;
2796
+ if (Array.isArray(value)) return value.slice(0, 20).map(item => truncateGitLabTraceValue(item));
2797
+ if (value && typeof value === "object") return truncateGitLabTraceData(value as Record<string, unknown>);
2798
+ return value;
2799
+ }
2800
+
2801
+ function normalizeGitLabBaseUrl(baseUrl: string): string {
2802
+ return baseUrl.replace(/\/+$/, "") || DEFAULT_GITLAB_BASE_URL;
2803
+ }
2804
+
2805
+ // Join a GitLab API path onto a base URL while preserving any relative install path
2806
+ // (e.g. self-managed `https://host/gitlab`). `new URL("/api/...", base)` discards the
2807
+ // base path; concatenating onto the trailing-slash-trimmed base keeps it.
2808
+ function gitLabApiUrl(baseUrl: string, path: string): URL {
2809
+ const normalized = normalizeGitLabBaseUrl(baseUrl);
2810
+ return new URL(`${normalized}${path.startsWith("/") ? path : `/${path}`}`);
2811
+ }
2812
+
2813
+ function normalizeGitLabDuoWorkflowServiceBaseUrl(baseUrl: string): string {
2814
+ const trimmed = baseUrl.trim();
2815
+ const absolute = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
2816
+ return normalizeGitLabBaseUrl(absolute);
2817
+ }
2818
+
2819
+ function toGitLabGraphQLNamespaceId(rootNamespaceId: string): string {
2820
+ if (/^\d+$/.test(rootNamespaceId)) return `gid://gitlab/Group/${rootNamespaceId}`;
2821
+ return rootNamespaceId;
2822
+ }
2823
+
2824
+ function toGitLabRestNamespaceId(rootNamespaceId: string): string {
2825
+ const match = rootNamespaceId.match(/^gid:\/\/gitlab\/(?:Group|Namespace)\/(\d+)$/);
2826
+ return match?.[1] ?? rootNamespaceId;
2827
+ }
2828
+
2829
+ export function extractGitLabWorkflowToken(payload: GitLabDirectAccessResponse): string | undefined {
2830
+ return (
2831
+ payload.gitlab_rails?.token ??
2832
+ payload.duo_workflow_service?.token ??
2833
+ payload.duo_workflow_access_token ??
2834
+ payload.workflow_token ??
2835
+ payload.token ??
2836
+ payload.access_token ??
2837
+ payload.jwt
2838
+ );
2839
+ }
2840
+
2841
+ export async function resolveGitLabDuoWorkflowNamespaceSelection(
2842
+ model: Model<"gitlab-duo-agent">,
2843
+ options: GitLabDuoWorkflowOptions,
2844
+ apiKey: string,
2845
+ baseUrl: string,
2846
+ fetchImpl: FetchImpl,
2847
+ ): Promise<GitLabDuoWorkflowNamespaceSelection> {
2848
+ // Re-discover the namespace from the current credentials/cwd each turn rather than
2849
+ // trusting model.gitlabDuoWorkflowRootNamespaceId, which can be stale (the account's
2850
+ // other top-level groups, or a cwd/env shift between model refresh and this turn).
2851
+ void model;
2852
+ const configured =
2853
+ nonEmptyString(options.rootNamespaceId) ??
2854
+ nonEmptyString(options.namespaceId) ??
2855
+ nonEmptyString(Bun.env.GITLAB_DUO_NAMESPACE_ID);
2856
+
2857
+ try {
2858
+ const projectId =
2859
+ nonEmptyString(options.projectId) ??
2860
+ nonEmptyString(options.projectPath) ??
2861
+ nonEmptyString(Bun.env.GITLAB_DUO_PROJECT_ID) ??
2862
+ nonEmptyString(Bun.env.GITLAB_DUO_PROJECT_PATH);
2863
+ return await discoverGitLabDuoWorkflowRuntimeNamespace({
2864
+ apiKey,
2865
+ baseUrl,
2866
+ fetch: fetchImpl,
2867
+ namespaceId: configured,
2868
+ projectId,
2869
+ cwd: options.cwd,
2870
+ });
2871
+ } catch (error) {
2872
+ throw new AIError.ProviderResponseError(
2873
+ `GitLab Duo Workflow runtime namespace resolution failed: ${gitLabDuoWorkflowErrorText(error)}`,
2874
+ { provider: "gitlab-duo-agent", kind: "runtime" },
2875
+ );
2876
+ }
2877
+ }
2878
+
2879
+ export async function resolveGitLabDuoWorkflowRootNamespaceId(
2880
+ model: Model<"gitlab-duo-agent">,
2881
+ options: GitLabDuoWorkflowOptions,
2882
+ apiKey: string,
2883
+ baseUrl: string,
2884
+ fetchImpl: FetchImpl,
2885
+ ): Promise<string> {
2886
+ const selection = await resolveGitLabDuoWorkflowNamespaceSelection(model, options, apiKey, baseUrl, fetchImpl);
2887
+ return selection.rootNamespaceId;
2888
+ }
2889
+
2890
+ function nonEmptyString(value: unknown): string | undefined {
2891
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
2892
+ }
2893
+
2894
+ function resolveGitLabDuoWorkflowDefinition(
2895
+ workflowDefinition: GitLabDuoWorkflowDefinition | undefined,
2896
+ ): GitLabDuoWorkflowDefinition {
2897
+ const configured =
2898
+ nonEmptyString(workflowDefinition) ??
2899
+ nonEmptyString(Bun.env.GITLAB_DUO_WORKFLOW_DEFINITION) ??
2900
+ GITLAB_DUO_WORKFLOW_DEFINITION;
2901
+ return configured;
2902
+ }
2903
+
2904
+ // Every workflow definition OMP ships is the inline ambient flow (Path B /
2905
+ // `flowConfig`); the predicate is kept as a seam for future server-side flows.
2906
+ function isGitLabDuoWorkflowInlineFlow(workflowDefinition: GitLabDuoWorkflowDefinition): boolean {
2907
+ void workflowDefinition;
2908
+ return true;
2909
+ }
2910
+
2911
+ function parseGitLabDuoWorkflowSocketData(data: unknown): Record<string, unknown> | null {
2912
+ if (typeof data === "string") return parseJsonRecord(data);
2913
+ if (data instanceof ArrayBuffer) return parseJsonRecord(new TextDecoder().decode(data));
2914
+ if (data instanceof Uint8Array) return parseJsonRecord(new TextDecoder().decode(data));
2915
+ if (data && typeof data === "object") return data as Record<string, unknown>;
2916
+ return null;
2917
+ }
2918
+
2919
+ function parseJsonRecord(text: string): Record<string, unknown> | null {
2920
+ try {
2921
+ const parsed = JSON.parse(text) as unknown;
2922
+ return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
2923
+ } catch {
2924
+ return null;
2925
+ }
2926
+ }
2927
+
2928
+ function numberField(record: Record<string, unknown>, key: string): number | undefined {
2929
+ const value = record[key];
2930
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
2931
+ }
2932
+
2933
+ function stringField(record: Record<string, unknown>, key: string): string | undefined {
2934
+ return nonEmptyString(record[key]);
2935
+ }
2936
+
2937
+ function extractGitLabDuoWorkflowCheckpoint(
2938
+ event: Record<string, unknown>,
2939
+ ): GitLabDuoWorkflowCheckpointContent | undefined {
2940
+ const action = getRecord(event, "action");
2941
+ const checkpoint =
2942
+ getRecord(action, "newCheckpoint") ?? getRecord(event, "newCheckpoint") ?? getRecord(event, "checkpoint");
2943
+ if (!checkpoint) return undefined;
2944
+ const directText =
2945
+ getRecordString(checkpoint, "message") ??
2946
+ getRecordString(checkpoint, "text") ??
2947
+ getRecordString(checkpoint, "content") ??
2948
+ getNestedRecordString(checkpoint, "checkpoint", "message") ??
2949
+ getNestedRecordString(checkpoint, "checkpoint", "text");
2950
+ const contextUsage = extractGitLabDuoWorkflowContextUsage(event, action, checkpoint);
2951
+ if (directText) {
2952
+ return {
2953
+ entries: [{ kind: "text", messageIndex: 0, messageKey: "direct:text", content: directText }],
2954
+ contentLength: directText.length,
2955
+ contextUsage,
2956
+ };
2957
+ }
2958
+ const checkpointJson = getRecordString(checkpoint, "checkpoint");
2959
+ const content = checkpointJson ? extractGitLabCheckpointEntries(checkpointJson) : undefined;
2960
+ if (content) {
2961
+ if (contextUsage) content.contextUsage = contextUsage;
2962
+ return content;
2963
+ }
2964
+ if (contextUsage) {
2965
+ return { entries: [], contentLength: 0, contextUsage };
2966
+ }
2967
+ return undefined;
2968
+ }
2969
+
2970
+ // GitLab Duo Workflow Service attaches per-agent context occupancy to every checkpoint
2971
+ // (`checkpointer/notifier.py`): agent_context_usage[<agent>] = { total_tokens, max_tokens }.
2972
+ // total_tokens is the server-side token estimate of that agent's full history; max_tokens
2973
+ // is the model context window (claude_opus_4_8 observed at 1_000_000). The field rides on
2974
+ // the event root in practice but can also appear under `action`/`newCheckpoint`.
2975
+ function extractGitLabDuoWorkflowContextUsage(
2976
+ ...sources: (Record<string, unknown> | undefined)[]
2977
+ ): GitLabDuoWorkflowContextUsage | undefined {
2978
+ for (const source of sources) {
2979
+ const usageMap = getRecord(source, "agent_context_usage");
2980
+ if (!usageMap) continue;
2981
+ const selected = selectGitLabDuoWorkflowContextUsageAgent(usageMap);
2982
+ if (selected) return selected;
2983
+ }
2984
+ return undefined;
2985
+ }
2986
+
2987
+ const GITLAB_DUO_WORKFLOW_CONTEXT_AGENT_PRIORITY = ["Chat Agent", "context_builder"];
2988
+
2989
+ function selectGitLabDuoWorkflowContextUsageAgent(
2990
+ usageMap: Record<string, unknown>,
2991
+ ): GitLabDuoWorkflowContextUsage | undefined {
2992
+ for (const preferred of GITLAB_DUO_WORKFLOW_CONTEXT_AGENT_PRIORITY) {
2993
+ const usage = readGitLabDuoWorkflowAgentUsage(usageMap[preferred]);
2994
+ if (usage) return usage;
2995
+ }
2996
+ for (const value of Object.values(usageMap)) {
2997
+ const usage = readGitLabDuoWorkflowAgentUsage(value);
2998
+ if (usage) return usage;
2999
+ }
3000
+ return undefined;
3001
+ }
3002
+
3003
+ function readGitLabDuoWorkflowAgentUsage(value: unknown): GitLabDuoWorkflowContextUsage | undefined {
3004
+ if (!value || typeof value !== "object") return undefined;
3005
+ const record = value as Record<string, unknown>;
3006
+ const used = numberField(record, "total_tokens");
3007
+ const window = numberField(record, "max_tokens");
3008
+ if (used === undefined || window === undefined || window <= 0) return undefined;
3009
+ return { used, window };
3010
+ }
3011
+
3012
+ function extractGitLabCheckpointEntries(checkpointJson: string): GitLabDuoWorkflowCheckpointContent | undefined {
3013
+ const checkpoint = parseJsonRecord(checkpointJson);
3014
+ const channelValues = getRecord(checkpoint, "channel_values");
3015
+ const chatLog = channelValues?.ui_chat_log;
3016
+ if (!Array.isArray(chatLog)) return undefined;
3017
+ const entries: GitLabDuoWorkflowCheckpointEntry[] = [];
3018
+ for (let index = 0; index < chatLog.length; index++) {
3019
+ const entry = chatLog[index];
3020
+ if (!entry || typeof entry !== "object") continue;
3021
+ const record = entry as Record<string, unknown>;
3022
+ const messageType = getRecordString(record, "message_type");
3023
+ if (messageType === "agent") {
3024
+ const content = getRecordString(record, "content");
3025
+ if (!content) continue;
3026
+ const messageId = getRecordString(record, "message_id");
3027
+ // `message_sub_type: "reasoning"` is the agent's pre-tool-call
3028
+ // commentary the inline flow opts into via `on_agent_reasoning`; map it
3029
+ // to a thinking block. Other agent text is the answer → text.
3030
+ const isReasoning = getRecordString(record, "message_sub_type") === "reasoning";
3031
+ const fallbackKey = isReasoning ? `reasoning:${index}` : `agent:${index}`;
3032
+ entries.push({
3033
+ kind: isReasoning ? "thinking" : "text",
3034
+ messageIndex: index,
3035
+ messageKey: messageId ? `agent:${messageId}` : fallbackKey,
3036
+ content,
3037
+ });
3038
+ continue;
3039
+ }
3040
+ if (messageType === "request" || messageType === "tool") {
3041
+ entries.push({ kind: "boundary", messageIndex: index });
3042
+ }
3043
+ }
3044
+ return {
3045
+ entries,
3046
+ contentLength: checkpointJson.length,
3047
+ latestMessageType: getGitLabDuoWorkflowLatestMessageType(chatLog),
3048
+ };
3049
+ }
3050
+
3051
+ function getGitLabDuoWorkflowLatestMessageType(chatLog: unknown[]): string | undefined {
3052
+ for (let index = chatLog.length - 1; index >= 0; index--) {
3053
+ const entry = chatLog[index];
3054
+ if (!entry || typeof entry !== "object") continue;
3055
+ const messageType = getRecordString(entry, "message_type");
3056
+ if (messageType) return messageType;
3057
+ }
3058
+ return undefined;
3059
+ }
3060
+
3061
+ function extractGitLabDuoWorkflowAction(event: Record<string, unknown>): GitLabDuoWorkflowActionDescriptor | undefined {
3062
+ const wrappedAction =
3063
+ getRecord(event, "action") ?? getRecord(event, "workflowAction") ?? getRecord(event, "toolCall");
3064
+ if (wrappedAction) {
3065
+ if (getRecord(wrappedAction, "newCheckpoint")) return undefined;
3066
+ const name =
3067
+ getRecordString(wrappedAction, "name") ??
3068
+ getRecordString(wrappedAction, "action") ??
3069
+ getRecordString(wrappedAction, "type") ??
3070
+ getRecordString(event, "actionName");
3071
+ if (!name) return undefined;
3072
+ const requestID =
3073
+ getRecordString(wrappedAction, "requestID") ??
3074
+ getRecordString(wrappedAction, "requestId") ??
3075
+ getRecordString(wrappedAction, "id") ??
3076
+ getRecordString(event, "requestID") ??
3077
+ getRecordString(event, "requestId");
3078
+ const resolvedRequestID = requireGitLabDuoWorkflowRequestID(requestID, name, wrappedAction);
3079
+ const args = getRecord(wrappedAction, "args") ?? getRecord(wrappedAction, "arguments") ?? wrappedAction;
3080
+ return { requestID: resolvedRequestID, name, args: withGitLabDuoWorkflowToolCallId(args, resolvedRequestID) };
3081
+ }
3082
+ for (const name of GITLAB_DUO_WORKFLOW_ACTION_NAMES) {
3083
+ const args = getRecord(event, name);
3084
+ if (args) {
3085
+ const requestID = getRecordString(event, "requestID") ?? getRecordString(event, "requestId");
3086
+ const resolvedRequestID = requireGitLabDuoWorkflowRequestID(requestID, name, event);
3087
+ return { requestID: resolvedRequestID, name, args: withGitLabDuoWorkflowToolCallId(args, resolvedRequestID) };
3088
+ }
3089
+ }
3090
+ return undefined;
3091
+ }
3092
+
3093
+ // DWS assigns every executor Action a non-empty `requestID` (contract.proto Action
3094
+ // field 1; emitted verbatim by Workhorse's proto->JSON relay). The client MUST echo
3095
+ // that exact id back in `actionResponse.requestID` or the server's outbox silently
3096
+ // discards the response (outbox.set_action_response: a non-empty id that misses the
3097
+ // awaiting-futures map hits the "doesn't expect responses, discarding" branch) and
3098
+ // the tool call's future never resolves — the model then re-issues the same tool
3099
+ // call, looping. A synthesized id is therefore never correct: it is either redundant
3100
+ // (the real id was present) or actively harmful (guaranteed-discarded). Fail fast so
3101
+ // the socket loop surfaces a protocol drift instead of stalling.
3102
+ function requireGitLabDuoWorkflowRequestID(
3103
+ requestID: string | undefined,
3104
+ actionName: string,
3105
+ source: Record<string, unknown>,
3106
+ ): string {
3107
+ if (requestID) return requestID;
3108
+ throw new AIError.ValidationError(
3109
+ `GitLab Duo Workflow action "${actionName}" missing requestID (keys: ${Object.keys(source).slice(0, 20).join(", ")})`,
3110
+ );
3111
+ }
3112
+
3113
+ function withGitLabDuoWorkflowToolCallId(args: unknown, requestID: string): unknown {
3114
+ const record = args && typeof args === "object" && !Array.isArray(args) ? (args as Record<string, unknown>) : {};
3115
+ if (typeof record.toolCallId === "string" || typeof record.tool_call_id === "string") {
3116
+ return record;
3117
+ }
3118
+ return { ...record, toolCallId: requestID, tool_call_id: requestID };
3119
+ }
3120
+
3121
+ function getRecord(value: unknown, key: string): Record<string, unknown> | undefined {
3122
+ if (!value || typeof value !== "object") return undefined;
3123
+ const nested = (value as Record<string, unknown>)[key];
3124
+ return nested && typeof nested === "object" ? (nested as Record<string, unknown>) : undefined;
3125
+ }
3126
+
3127
+ function getRecordString(value: unknown, key: string): string | undefined {
3128
+ if (!value || typeof value !== "object") return undefined;
3129
+ const nested = (value as Record<string, unknown>)[key];
3130
+ return typeof nested === "string" || typeof nested === "number" ? String(nested) : undefined;
3131
+ }
3132
+
3133
+ function getNestedRecordString(value: unknown, parentKey: string, key: string): string | undefined {
3134
+ return getRecordString(getRecord(value, parentKey), key);
3135
+ }