@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.
- package/CHANGELOG.md +5066 -0
- package/README.md +1195 -0
- package/dist/types/api-registry.d.ts +30 -0
- package/dist/types/auth/sqlite-credential-store.d.ts +117 -0
- package/dist/types/auth-broker/client.d.ts +93 -0
- package/dist/types/auth-broker/discover.d.ts +39 -0
- package/dist/types/auth-broker/index.d.ts +7 -0
- package/dist/types/auth-broker/refresher.d.ts +25 -0
- package/dist/types/auth-broker/remote-store.d.ts +136 -0
- package/dist/types/auth-broker/server.d.ts +48 -0
- package/dist/types/auth-broker/snapshot-cache.d.ts +17 -0
- package/dist/types/auth-broker/types.d.ts +152 -0
- package/dist/types/auth-broker/wire-schema-resource.d.ts +53 -0
- package/dist/types/auth-broker/wire-schemas.d.ts +5 -0
- package/dist/types/auth-gateway/http.d.ts +56 -0
- package/dist/types/auth-gateway/index.d.ts +3 -0
- package/dist/types/auth-gateway/server.d.ts +36 -0
- package/dist/types/auth-gateway/types.d.ts +127 -0
- package/dist/types/auth-retry.d.ts +150 -0
- package/dist/types/auth-storage.d.ts +1258 -0
- package/dist/types/dialect/anthropic.d.ts +15 -0
- package/dist/types/dialect/catalog.d.ts +3 -0
- package/dist/types/dialect/coercion.d.ts +23 -0
- package/dist/types/dialect/deepseek.d.ts +14 -0
- package/dist/types/dialect/demotion.d.ts +30 -0
- package/dist/types/dialect/examples.d.ts +18 -0
- package/dist/types/dialect/factory.d.ts +3 -0
- package/dist/types/dialect/fenced-thinking.d.ts +53 -0
- package/dist/types/dialect/gemini.d.ts +17 -0
- package/dist/types/dialect/gemma.d.ts +15 -0
- package/dist/types/dialect/glm.d.ts +9 -0
- package/dist/types/dialect/harmony.d.ts +8 -0
- package/dist/types/dialect/hermes.d.ts +9 -0
- package/dist/types/dialect/history.d.ts +3 -0
- package/dist/types/dialect/index.d.ts +11 -0
- package/dist/types/dialect/inventory.d.ts +9 -0
- package/dist/types/dialect/kimi.d.ts +14 -0
- package/dist/types/dialect/minimax.d.ts +3 -0
- package/dist/types/dialect/owned-stream.d.ts +4 -0
- package/dist/types/dialect/qwen3.d.ts +9 -0
- package/dist/types/dialect/rendering.d.ts +54 -0
- package/dist/types/dialect/thinking.d.ts +6 -0
- package/dist/types/dialect/types.d.ts +68 -0
- package/dist/types/dialect/xml.d.ts +9 -0
- package/dist/types/error/abort.d.ts +14 -0
- package/dist/types/error/auth-classify.d.ts +20 -0
- package/dist/types/error/auth.d.ts +27 -0
- package/dist/types/error/aws.d.ts +27 -0
- package/dist/types/error/classes.d.ts +106 -0
- package/dist/types/error/finalize.d.ts +39 -0
- package/dist/types/error/flags.d.ts +94 -0
- package/dist/types/error/format.d.ts +20 -0
- package/dist/types/error/gateway.d.ts +20 -0
- package/dist/types/error/index.d.ts +14 -0
- package/dist/types/error/oauth.d.ts +43 -0
- package/dist/types/error/provider.d.ts +42 -0
- package/dist/types/error/rate-limit.d.ts +80 -0
- package/dist/types/error/retryable.d.ts +27 -0
- package/dist/types/error/validation.d.ts +32 -0
- package/dist/types/index.d.ts +51 -0
- package/dist/types/provider-details.d.ts +24 -0
- package/dist/types/providers/amazon-bedrock.d.ts +39 -0
- package/dist/types/providers/anthropic-client.d.ts +106 -0
- package/dist/types/providers/anthropic-messages-server-schema.d.ts +937 -0
- package/dist/types/providers/anthropic-messages-server.d.ts +17 -0
- package/dist/types/providers/anthropic-wire.d.ts +345 -0
- package/dist/types/providers/anthropic.d.ts +261 -0
- package/dist/types/providers/aws-credentials.d.ts +48 -0
- package/dist/types/providers/aws-eventstream.d.ts +39 -0
- package/dist/types/providers/aws-sigv4.d.ts +55 -0
- package/dist/types/providers/azure-openai-responses.d.ts +16 -0
- package/dist/types/providers/bedrock-mantle.d.ts +13 -0
- package/dist/types/providers/claude-code-fingerprint.d.ts +19 -0
- package/dist/types/providers/cowork-fetch.d.ts +3 -0
- package/dist/types/providers/cursor/exec-modern.d.ts +98 -0
- package/dist/types/providers/cursor-pi-args.d.ts +105 -0
- package/dist/types/providers/cursor.d.ts +221 -0
- package/dist/types/providers/devin.d.ts +12 -0
- package/dist/types/providers/error-message.d.ts +25 -0
- package/dist/types/providers/github-copilot-headers.d.ts +40 -0
- package/dist/types/providers/gitlab-duo-workflow.d.ts +254 -0
- package/dist/types/providers/gitlab-duo.d.ts +27 -0
- package/dist/types/providers/google-auth.d.ts +24 -0
- package/dist/types/providers/google-gemini-cli.d.ts +120 -0
- package/dist/types/providers/google-shared.d.ts +203 -0
- package/dist/types/providers/google-types.d.ts +155 -0
- package/dist/types/providers/google-vertex.d.ts +7 -0
- package/dist/types/providers/google.d.ts +4 -0
- package/dist/types/providers/grammar.d.ts +1 -0
- package/dist/types/providers/kimi.d.ts +27 -0
- package/dist/types/providers/mock.d.ts +179 -0
- package/dist/types/providers/ollama.d.ts +8 -0
- package/dist/types/providers/openai-anthropic-shim.d.ts +35 -0
- package/dist/types/providers/openai-chat-server-schema.d.ts +1311 -0
- package/dist/types/providers/openai-chat-server.d.ts +16 -0
- package/dist/types/providers/openai-chat-wire.d.ts +669 -0
- package/dist/types/providers/openai-codex/request-transformer.d.ts +109 -0
- package/dist/types/providers/openai-codex/response-handler.d.ts +26 -0
- package/dist/types/providers/openai-codex-responses.d.ts +238 -0
- package/dist/types/providers/openai-completions.d.ts +48 -0
- package/dist/types/providers/openai-reasoning-fallback.d.ts +25 -0
- package/dist/types/providers/openai-responses-server-schema.d.ts +1314 -0
- package/dist/types/providers/openai-responses-server.d.ts +17 -0
- package/dist/types/providers/openai-responses-wire.d.ts +6099 -0
- package/dist/types/providers/openai-responses.d.ts +131 -0
- package/dist/types/providers/openai-shared.d.ts +624 -0
- package/dist/types/providers/pi-native-client.d.ts +13 -0
- package/dist/types/providers/pi-native-server.d.ts +69 -0
- package/dist/types/providers/register-builtins.d.ts +37 -0
- package/dist/types/providers/synthetic.d.ts +26 -0
- package/dist/types/providers/transform-messages.d.ts +32 -0
- package/dist/types/providers/vision-guard.d.ts +20 -0
- package/dist/types/registry/aiand.d.ts +7 -0
- package/dist/types/registry/aimlapi.d.ts +4 -0
- package/dist/types/registry/alibaba-coding-plan.d.ts +8 -0
- package/dist/types/registry/alibaba-token-plan.d.ts +18 -0
- package/dist/types/registry/amazon-bedrock.d.ts +5 -0
- package/dist/types/registry/anthropic.d.ts +10 -0
- package/dist/types/registry/api-key-login.d.ts +42 -0
- package/dist/types/registry/api-key-validation.d.ts +43 -0
- package/dist/types/registry/aws.d.ts +13 -0
- package/dist/types/registry/azure.d.ts +4 -0
- package/dist/types/registry/baseten.d.ts +7 -0
- package/dist/types/registry/bedrock-mantle.d.ts +22 -0
- package/dist/types/registry/cerebras.d.ts +7 -0
- package/dist/types/registry/cloudflare-ai-gateway.d.ts +13 -0
- package/dist/types/registry/coreweave.d.ts +7 -0
- package/dist/types/registry/cursor.d.ts +7 -0
- package/dist/types/registry/deepseek.d.ts +8 -0
- package/dist/types/registry/derived.d.ts +5 -0
- package/dist/types/registry/devin.d.ts +8 -0
- package/dist/types/registry/exa.d.ts +8 -0
- package/dist/types/registry/firepass.d.ts +16 -0
- package/dist/types/registry/fireworks.d.ts +7 -0
- package/dist/types/registry/github-copilot.d.ts +7 -0
- package/dist/types/registry/gitlab-duo-workflow.d.ts +10 -0
- package/dist/types/registry/gitlab-duo.d.ts +9 -0
- package/dist/types/registry/gmi-cloud.d.ts +7 -0
- package/dist/types/registry/google-antigravity.d.ts +9 -0
- package/dist/types/registry/google-gemini-cli.d.ts +9 -0
- package/dist/types/registry/google-vertex.d.ts +5 -0
- package/dist/types/registry/google.d.ts +4 -0
- package/dist/types/registry/groq.d.ts +4 -0
- package/dist/types/registry/huggingface.d.ts +7 -0
- package/dist/types/registry/index.d.ts +4 -0
- package/dist/types/registry/kagi.d.ts +14 -0
- package/dist/types/registry/kilo.d.ts +7 -0
- package/dist/types/registry/kimi-code.d.ts +7 -0
- package/dist/types/registry/litellm.d.ts +13 -0
- package/dist/types/registry/llama-cpp.d.ts +8 -0
- package/dist/types/registry/lm-studio.d.ts +8 -0
- package/dist/types/registry/meta.d.ts +7 -0
- package/dist/types/registry/minimax-code-cn.d.ts +6 -0
- package/dist/types/registry/minimax-code.d.ts +6 -0
- package/dist/types/registry/minimax.d.ts +4 -0
- package/dist/types/registry/mistral.d.ts +4 -0
- package/dist/types/registry/moonshot.d.ts +7 -0
- package/dist/types/registry/nanogpt.d.ts +7 -0
- package/dist/types/registry/novita.d.ts +6 -0
- package/dist/types/registry/nvidia.d.ts +7 -0
- package/dist/types/registry/oauth/anthropic-constants.d.ts +12 -0
- package/dist/types/registry/oauth/anthropic.d.ts +24 -0
- package/dist/types/registry/oauth/callback-server.d.ts +74 -0
- package/dist/types/registry/oauth/cursor.d.ts +16 -0
- package/dist/types/registry/oauth/device-code.d.ts +25 -0
- package/dist/types/registry/oauth/devin.d.ts +5 -0
- package/dist/types/registry/oauth/github-copilot.d.ts +30 -0
- package/dist/types/registry/oauth/gitlab-duo-workflow.d.ts +6 -0
- package/dist/types/registry/oauth/gitlab-duo.d.ts +3 -0
- package/dist/types/registry/oauth/google-antigravity.d.ts +11 -0
- package/dist/types/registry/oauth/google-gemini-cli.d.ts +22 -0
- package/dist/types/registry/oauth/google-oauth-shared.d.ts +56 -0
- package/dist/types/registry/oauth/index.d.ts +45 -0
- package/dist/types/registry/oauth/kimi.d.ts +21 -0
- package/dist/types/registry/oauth/minimax-code.d.ts +27 -0
- package/dist/types/registry/oauth/openai-codex.d.ts +33 -0
- package/dist/types/registry/oauth/opencode.d.ts +18 -0
- package/dist/types/registry/oauth/perplexity.d.ts +9 -0
- package/dist/types/registry/oauth/pkce.d.ts +8 -0
- package/dist/types/registry/oauth/types.d.ts +87 -0
- package/dist/types/registry/oauth/wafer.d.ts +1 -0
- package/dist/types/registry/oauth/xai-oauth.d.ts +46 -0
- package/dist/types/registry/oauth/xiaomi.d.ts +25 -0
- package/dist/types/registry/oauth/zai.d.ts +25 -0
- package/dist/types/registry/ollama-cloud.d.ts +7 -0
- package/dist/types/registry/ollama.d.ts +12 -0
- package/dist/types/registry/openai-codex-device.d.ts +8 -0
- package/dist/types/registry/openai-codex.d.ts +9 -0
- package/dist/types/registry/openai.d.ts +4 -0
- package/dist/types/registry/opencode-go.d.ts +6 -0
- package/dist/types/registry/opencode-zen.d.ts +6 -0
- package/dist/types/registry/openrouter.d.ts +13 -0
- package/dist/types/registry/parallel.d.ts +14 -0
- package/dist/types/registry/perplexity.d.ts +7 -0
- package/dist/types/registry/qianfan.d.ts +7 -0
- package/dist/types/registry/qwen-portal.d.ts +7 -0
- package/dist/types/registry/registry.d.ts +367 -0
- package/dist/types/registry/sakana.d.ts +7 -0
- package/dist/types/registry/siliconflow-cn.d.ts +7 -0
- package/dist/types/registry/siliconflow.d.ts +7 -0
- package/dist/types/registry/synthetic.d.ts +6 -0
- package/dist/types/registry/tavily.d.ts +14 -0
- package/dist/types/registry/together.d.ts +6 -0
- package/dist/types/registry/types.d.ts +75 -0
- package/dist/types/registry/umans.d.ts +7 -0
- package/dist/types/registry/venice.d.ts +13 -0
- package/dist/types/registry/vercel-ai-gateway.d.ts +7 -0
- package/dist/types/registry/vllm.d.ts +7 -0
- package/dist/types/registry/wafer-serverless.d.ts +6 -0
- package/dist/types/registry/xai-oauth.d.ts +7 -0
- package/dist/types/registry/xai.d.ts +7 -0
- package/dist/types/registry/xiaomi-token-plan-ams.d.ts +6 -0
- package/dist/types/registry/xiaomi-token-plan-cn.d.ts +6 -0
- package/dist/types/registry/xiaomi-token-plan-sgp.d.ts +6 -0
- package/dist/types/registry/xiaomi.d.ts +6 -0
- package/dist/types/registry/zai.d.ts +15 -0
- package/dist/types/registry/zenmux.d.ts +7 -0
- package/dist/types/registry/zhipu-coding-plan.d.ts +7 -0
- package/dist/types/stream.d.ts +46 -0
- package/dist/types/types.d.ts +1064 -0
- package/dist/types/usage/alibaba-token-plan.d.ts +3 -0
- package/dist/types/usage/claude.d.ts +4 -0
- package/dist/types/usage/cursor.d.ts +4 -0
- package/dist/types/usage/gemini.d.ts +2 -0
- package/dist/types/usage/github-copilot.d.ts +7 -0
- package/dist/types/usage/google-antigravity.d.ts +15 -0
- package/dist/types/usage/kimi.d.ts +2 -0
- package/dist/types/usage/minimax-code.d.ts +3 -0
- package/dist/types/usage/ollama.d.ts +5 -0
- package/dist/types/usage/openai-codex-base-url.d.ts +18 -0
- package/dist/types/usage/openai-codex-reset.d.ts +88 -0
- package/dist/types/usage/openai-codex.d.ts +10 -0
- package/dist/types/usage/opencode-go.d.ts +2 -0
- package/dist/types/usage/shared.d.ts +1 -0
- package/dist/types/usage/synthetic.d.ts +2 -0
- package/dist/types/usage/umans.d.ts +2 -0
- package/dist/types/usage/xai-oauth.d.ts +12 -0
- package/dist/types/usage/zai.d.ts +3 -0
- package/dist/types/usage.d.ts +527 -0
- package/dist/types/utils/abort.d.ts +25 -0
- package/dist/types/utils/anthropic-auth.d.ts +35 -0
- package/dist/types/utils/aws-profile.d.ts +17 -0
- package/dist/types/utils/block-symbols.d.ts +62 -0
- package/dist/types/utils/deterministic-id.d.ts +16 -0
- package/dist/types/utils/empty-completion-retry.d.ts +21 -0
- package/dist/types/utils/event-stream.d.ts +39 -0
- package/dist/types/utils/foundry.d.ts +1 -0
- package/dist/types/utils/google-validation.d.ts +2 -0
- package/dist/types/utils/harmony-leak.d.ts +135 -0
- package/dist/types/utils/http-inspector.d.ts +49 -0
- package/dist/types/utils/idle-iterator.d.ts +149 -0
- package/dist/types/utils/leaked-thinking-stream.d.ts +33 -0
- package/dist/types/utils/openai-http.d.ts +48 -0
- package/dist/types/utils/openrouter-headers.d.ts +1 -0
- package/dist/types/utils/parse-bind.d.ts +23 -0
- package/dist/types/utils/provider-response.d.ts +3 -0
- package/dist/types/utils/proxy.d.ts +39 -0
- package/dist/types/utils/request-debug.d.ts +29 -0
- package/dist/types/utils/retry-after.d.ts +4 -0
- package/dist/types/utils/retry.d.ts +14 -0
- package/dist/types/utils/schema/adapt.d.ts +24 -0
- package/dist/types/utils/schema/compatibility.d.ts +30 -0
- package/dist/types/utils/schema/dereference.d.ts +11 -0
- package/dist/types/utils/schema/draft.d.ts +10 -0
- package/dist/types/utils/schema/equality.d.ts +4 -0
- package/dist/types/utils/schema/fields.d.ts +54 -0
- package/dist/types/utils/schema/index.d.ts +14 -0
- package/dist/types/utils/schema/json-schema-validator.d.ts +20 -0
- package/dist/types/utils/schema/meta-validator.d.ts +2 -0
- package/dist/types/utils/schema/normalize.d.ts +153 -0
- package/dist/types/utils/schema/spill.d.ts +8 -0
- package/dist/types/utils/schema/stamps.d.ts +17 -0
- package/dist/types/utils/schema/strict-tool-validation.d.ts +16 -0
- package/dist/types/utils/schema/types.d.ts +4 -0
- package/dist/types/utils/schema/typescript.d.ts +24 -0
- package/dist/types/utils/schema/wire.d.ts +52 -0
- package/dist/types/utils/sdk-stream-timeout.d.ts +33 -0
- package/dist/types/utils/sse-debug.d.ts +5 -0
- package/dist/types/utils/stream-markup-healing.d.ts +87 -0
- package/dist/types/utils/thinking-loop.d.ts +102 -0
- package/dist/types/utils/tool-call-loop-guard.d.ts +26 -0
- package/dist/types/utils/tool-choice.d.ts +52 -0
- package/dist/types/utils/validation.d.ts +28 -0
- package/dist/types/utils.d.ts +57 -0
- package/package.json +138 -0
- package/src/api-registry.ts +109 -0
- package/src/auth/sqlite-credential-store.ts +2066 -0
- package/src/auth-broker/client.ts +471 -0
- package/src/auth-broker/discover.ts +310 -0
- package/src/auth-broker/index.ts +7 -0
- package/src/auth-broker/refresher.ts +117 -0
- package/src/auth-broker/remote-store.ts +1332 -0
- package/src/auth-broker/server.ts +898 -0
- package/src/auth-broker/snapshot-cache.ts +200 -0
- package/src/auth-broker/types.ts +193 -0
- package/src/auth-broker/wire-schema-resource.ts +487 -0
- package/src/auth-broker/wire-schemas.ts +43 -0
- package/src/auth-gateway/http.ts +227 -0
- package/src/auth-gateway/index.ts +3 -0
- package/src/auth-gateway/server.ts +836 -0
- package/src/auth-gateway/types.ts +153 -0
- package/src/auth-retry.ts +401 -0
- package/src/auth-storage.ts +6540 -0
- package/src/dialect/anthropic.md +31 -0
- package/src/dialect/anthropic.ts +608 -0
- package/src/dialect/catalog.ts +29 -0
- package/src/dialect/coercion.ts +136 -0
- package/src/dialect/deepseek.md +24 -0
- package/src/dialect/deepseek.ts +609 -0
- package/src/dialect/demotion.ts +40 -0
- package/src/dialect/examples.ts +71 -0
- package/src/dialect/factory.ts +34 -0
- package/src/dialect/fenced-thinking.ts +184 -0
- package/src/dialect/gemini.md +44 -0
- package/src/dialect/gemini.ts +583 -0
- package/src/dialect/gemma.md +33 -0
- package/src/dialect/gemma.ts +387 -0
- package/src/dialect/glm.md +32 -0
- package/src/dialect/glm.ts +579 -0
- package/src/dialect/harmony.md +31 -0
- package/src/dialect/harmony.ts +345 -0
- package/src/dialect/hermes.md +25 -0
- package/src/dialect/hermes.ts +206 -0
- package/src/dialect/history.ts +81 -0
- package/src/dialect/index.ts +15 -0
- package/src/dialect/inventory.ts +30 -0
- package/src/dialect/kimi.md +24 -0
- package/src/dialect/kimi.ts +340 -0
- package/src/dialect/minimax.md +31 -0
- package/src/dialect/minimax.ts +95 -0
- package/src/dialect/owned-stream.ts +481 -0
- package/src/dialect/prompt-template.md +12 -0
- package/src/dialect/qwen3.md +28 -0
- package/src/dialect/qwen3.ts +240 -0
- package/src/dialect/rendering.ts +304 -0
- package/src/dialect/thinking.ts +292 -0
- package/src/dialect/types.ts +56 -0
- package/src/dialect/xml.md +22 -0
- package/src/dialect/xml.ts +90 -0
- package/src/error/abort.ts +18 -0
- package/src/error/auth-classify.ts +47 -0
- package/src/error/auth.ts +48 -0
- package/src/error/aws.ts +35 -0
- package/src/error/classes.ts +281 -0
- package/src/error/finalize.ts +69 -0
- package/src/error/flags.ts +602 -0
- package/src/error/format.ts +45 -0
- package/src/error/gateway.ts +96 -0
- package/src/error/index.ts +14 -0
- package/src/error/oauth.ts +58 -0
- package/src/error/provider.ts +63 -0
- package/src/error/rate-limit.ts +303 -0
- package/src/error/retryable.ts +70 -0
- package/src/error/validation.ts +44 -0
- package/src/index.ts +51 -0
- package/src/provider-details.ts +90 -0
- package/src/providers/amazon-bedrock.ts +1064 -0
- package/src/providers/anthropic-client.ts +317 -0
- package/src/providers/anthropic-messages-server-schema.ts +252 -0
- package/src/providers/anthropic-messages-server.ts +818 -0
- package/src/providers/anthropic-wire.ts +359 -0
- package/src/providers/anthropic.ts +4539 -0
- package/src/providers/aws-credentials.ts +772 -0
- package/src/providers/aws-eventstream.ts +181 -0
- package/src/providers/aws-sigv4.ts +218 -0
- package/src/providers/azure-openai-responses.ts +438 -0
- package/src/providers/bedrock-mantle.ts +110 -0
- package/src/providers/claude-code-fingerprint.ts +20 -0
- package/src/providers/cowork-fetch.ts +201 -0
- package/src/providers/cursor/exec-modern.ts +496 -0
- package/src/providers/cursor/proto/agent.proto +4533 -0
- package/src/providers/cursor/proto/buf.gen.yaml +6 -0
- package/src/providers/cursor/proto/buf.yaml +17 -0
- package/src/providers/cursor-pi-args.ts +165 -0
- package/src/providers/cursor.ts +4689 -0
- package/src/providers/devin/proto/buf/validate/validate.proto +468 -0
- package/src/providers/devin/proto/buf.gen.yaml +33 -0
- package/src/providers/devin/proto/buf.yaml +17 -0
- package/src/providers/devin/proto/cel/expr/checked.proto +103 -0
- package/src/providers/devin/proto/cel/expr/eval.proto +38 -0
- package/src/providers/devin/proto/cel/expr/explain.proto +15 -0
- package/src/providers/devin/proto/cel/expr/syntax.proto +113 -0
- package/src/providers/devin/proto/cel/expr/value.proto +41 -0
- package/src/providers/devin/proto/connectext/grpc/status/v1/status.proto +11 -0
- package/src/providers/devin/proto/errorspb/errors.proto +56 -0
- package/src/providers/devin/proto/errorspb/hintdetail.proto +7 -0
- package/src/providers/devin/proto/errorspb/markers.proto +10 -0
- package/src/providers/devin/proto/errorspb/tags.proto +12 -0
- package/src/providers/devin/proto/errorspb/testing.proto +6 -0
- package/src/providers/devin/proto/exa/analytics_pb/analytics.proto +188 -0
- package/src/providers/devin/proto/exa/api_server_pb/api_server.proto +2461 -0
- package/src/providers/devin/proto/exa/auth_pb/auth.proto +19 -0
- package/src/providers/devin/proto/exa/auto_cascade_common_pb/auto_cascade_common.proto +79 -0
- package/src/providers/devin/proto/exa/browser_preview_pb/browser_preview.proto +32 -0
- package/src/providers/devin/proto/exa/bug_checker_pb/bug_checker.proto +22 -0
- package/src/providers/devin/proto/exa/cascade_plugins_pb/cascade_plugins.proto +262 -0
- package/src/providers/devin/proto/exa/chat_client_server_pb/chat_client_server.proto +57 -0
- package/src/providers/devin/proto/exa/chat_pb/chat.proto +449 -0
- package/src/providers/devin/proto/exa/code_edit/code_edit_pb/code_edit.proto +186 -0
- package/src/providers/devin/proto/exa/codeium_common_pb/codeium_common.proto +4157 -0
- package/src/providers/devin/proto/exa/context_module_pb/context_module.proto +175 -0
- package/src/providers/devin/proto/exa/cortex_pb/cortex.proto +3268 -0
- package/src/providers/devin/proto/exa/dev_pb/dev.proto +26 -0
- package/src/providers/devin/proto/exa/diff_action_pb/diff_action.proto +75 -0
- package/src/providers/devin/proto/exa/eval/pr_eval/datasets_pb/datasets.proto +103 -0
- package/src/providers/devin/proto/exa/eval_pb/eval.proto +1315 -0
- package/src/providers/devin/proto/exa/extension_server_pb/extension_server.proto +556 -0
- package/src/providers/devin/proto/exa/file_system_provider_pb/file_system_provider.proto +75 -0
- package/src/providers/devin/proto/exa/index_pb/index.proto +461 -0
- package/src/providers/devin/proto/exa/knowledge_base_pb/knowledge_base.proto +144 -0
- package/src/providers/devin/proto/exa/language_server_pb/language_server.proto +2385 -0
- package/src/providers/devin/proto/exa/model_management_pb/model_management.proto +186 -0
- package/src/providers/devin/proto/exa/opensearch_clients_pb/opensearch_clients.proto +503 -0
- package/src/providers/devin/proto/exa/product_analytics_pb/product_analytics.proto +37 -0
- package/src/providers/devin/proto/exa/prompt_pb/prompt.proto +92 -0
- package/src/providers/devin/proto/exa/reactive_component_pb/reactive_component.proto +96 -0
- package/src/providers/devin/proto/exa/seat_management_pb/seat_management.proto +2680 -0
- package/src/providers/devin/proto/exa/tokenizer_pb/tokenizer.proto +37 -0
- package/src/providers/devin/proto/exa/trainer_pb/config.proto +647 -0
- package/src/providers/devin/proto/exa/tree_sitter/language_data_pb/language_data.proto +14 -0
- package/src/providers/devin/proto/exa/trust_pb/trust.proto +157 -0
- package/src/providers/devin/proto/exa/user_analytics_pb/user_analytics.proto +519 -0
- package/src/providers/devin/proto/google.golang.org/appengine/internal/base/api_base.proto +28 -0
- package/src/providers/devin/proto/google.golang.org/appengine/internal/datastore/datastore_v3.proto +484 -0
- package/src/providers/devin/proto/google.golang.org/appengine/internal/log/log_service.proto +136 -0
- package/src/providers/devin/proto/google.golang.org/appengine/internal/remote_api/remote_api.proto +42 -0
- package/src/providers/devin/proto/google.golang.org/appengine/internal/urlfetch/urlfetch_service.proto +61 -0
- package/src/providers/devin/proto/grpc/binlog/v1/binarylog.proto +84 -0
- package/src/providers/devin/proto/io/prometheus/client/metrics.proto +98 -0
- package/src/providers/devin.ts +679 -0
- package/src/providers/error-message.ts +23 -0
- package/src/providers/github-copilot-headers.ts +141 -0
- package/src/providers/gitlab-duo-workflow-chatml-note.md +1 -0
- package/src/providers/gitlab-duo-workflow.ts +3135 -0
- package/src/providers/gitlab-duo.ts +399 -0
- package/src/providers/google-auth.ts +330 -0
- package/src/providers/google-gemini-cli.ts +1370 -0
- package/src/providers/google-shared.ts +1122 -0
- package/src/providers/google-types.ts +180 -0
- package/src/providers/google-vertex.ts +135 -0
- package/src/providers/google.ts +47 -0
- package/src/providers/grammar.ts +70 -0
- package/src/providers/kimi.ts +51 -0
- package/src/providers/mock.ts +514 -0
- package/src/providers/ollama.ts +776 -0
- package/src/providers/openai-anthropic-shim.ts +166 -0
- package/src/providers/openai-chat-server-schema.ts +243 -0
- package/src/providers/openai-chat-server.ts +752 -0
- package/src/providers/openai-chat-wire.ts +859 -0
- package/src/providers/openai-codex/request-transformer.ts +491 -0
- package/src/providers/openai-codex/response-handler.ts +102 -0
- package/src/providers/openai-codex-responses.ts +4716 -0
- package/src/providers/openai-completions.ts +2389 -0
- package/src/providers/openai-reasoning-fallback.ts +269 -0
- package/src/providers/openai-responses-server-schema.ts +397 -0
- package/src/providers/openai-responses-server.ts +1466 -0
- package/src/providers/openai-responses-wire.ts +6416 -0
- package/src/providers/openai-responses.ts +1393 -0
- package/src/providers/openai-shared.ts +3500 -0
- package/src/providers/pi-native-client.ts +275 -0
- package/src/providers/pi-native-server.ts +245 -0
- package/src/providers/register-builtins.ts +503 -0
- package/src/providers/synthetic.ts +50 -0
- package/src/providers/transform-messages.ts +1083 -0
- package/src/providers/vision-guard.ts +54 -0
- package/src/registry/aiand.ts +22 -0
- package/src/registry/aimlapi.ts +6 -0
- package/src/registry/alibaba-coding-plan.ts +104 -0
- package/src/registry/alibaba-token-plan.ts +125 -0
- package/src/registry/amazon-bedrock.ts +22 -0
- package/src/registry/anthropic.ts +26 -0
- package/src/registry/api-key-login.ts +115 -0
- package/src/registry/api-key-validation.ts +145 -0
- package/src/registry/aws.ts +57 -0
- package/src/registry/azure.ts +6 -0
- package/src/registry/baseten.ts +22 -0
- package/src/registry/bedrock-mantle.ts +34 -0
- package/src/registry/cerebras.ts +23 -0
- package/src/registry/cloudflare-ai-gateway.ts +45 -0
- package/src/registry/coreweave.ts +40 -0
- package/src/registry/cursor.ts +20 -0
- package/src/registry/deepseek.ts +46 -0
- package/src/registry/derived.ts +9 -0
- package/src/registry/devin.ts +15 -0
- package/src/registry/exa.ts +19 -0
- package/src/registry/firepass.ts +32 -0
- package/src/registry/fireworks.ts +28 -0
- package/src/registry/github-copilot.ts +22 -0
- package/src/registry/gitlab-duo-workflow.ts +20 -0
- package/src/registry/gitlab-duo.ts +19 -0
- package/src/registry/gmi-cloud.ts +22 -0
- package/src/registry/google-antigravity.ts +22 -0
- package/src/registry/google-gemini-cli.ts +22 -0
- package/src/registry/google-vertex.ts +38 -0
- package/src/registry/google.ts +6 -0
- package/src/registry/groq.ts +6 -0
- package/src/registry/huggingface.ts +29 -0
- package/src/registry/index.ts +4 -0
- package/src/registry/kagi.ts +46 -0
- package/src/registry/kilo.ts +114 -0
- package/src/registry/kimi-code.ts +17 -0
- package/src/registry/litellm.ts +45 -0
- package/src/registry/llama-cpp.ts +35 -0
- package/src/registry/lm-studio.ts +31 -0
- package/src/registry/meta.ts +22 -0
- package/src/registry/minimax-code-cn.ts +12 -0
- package/src/registry/minimax-code.ts +12 -0
- package/src/registry/minimax.ts +6 -0
- package/src/registry/mistral.ts +6 -0
- package/src/registry/moonshot.ts +28 -0
- package/src/registry/nanogpt.ts +22 -0
- package/src/registry/novita.ts +25 -0
- package/src/registry/nvidia.ts +61 -0
- package/src/registry/oauth/anthropic-constants.ts +12 -0
- package/src/registry/oauth/anthropic.ts +346 -0
- package/src/registry/oauth/callback-server.ts +438 -0
- package/src/registry/oauth/cursor.ts +187 -0
- package/src/registry/oauth/device-code.ts +92 -0
- package/src/registry/oauth/devin.ts +124 -0
- package/src/registry/oauth/github-copilot.ts +369 -0
- package/src/registry/oauth/gitlab-duo-workflow.ts +146 -0
- package/src/registry/oauth/gitlab-duo.ts +222 -0
- package/src/registry/oauth/google-antigravity.ts +225 -0
- package/src/registry/oauth/google-gemini-cli.ts +297 -0
- package/src/registry/oauth/google-oauth-shared.ts +211 -0
- package/src/registry/oauth/index.ts +187 -0
- package/src/registry/oauth/kimi.ts +297 -0
- package/src/registry/oauth/minimax-code.ts +53 -0
- package/src/registry/oauth/oauth.html +317 -0
- package/src/registry/oauth/openai-codex.ts +384 -0
- package/src/registry/oauth/opencode.ts +50 -0
- package/src/registry/oauth/perplexity.ts +228 -0
- package/src/registry/oauth/pkce.ts +18 -0
- package/src/registry/oauth/types.ts +96 -0
- package/src/registry/oauth/wafer.ts +24 -0
- package/src/registry/oauth/xai-oauth.ts +559 -0
- package/src/registry/oauth/xiaomi.ts +211 -0
- package/src/registry/oauth/zai.ts +285 -0
- package/src/registry/ollama-cloud.ts +36 -0
- package/src/registry/ollama.ts +43 -0
- package/src/registry/openai-codex-device.ts +18 -0
- package/src/registry/openai-codex.ts +19 -0
- package/src/registry/openai.ts +6 -0
- package/src/registry/opencode-go.ts +12 -0
- package/src/registry/opencode-zen.ts +12 -0
- package/src/registry/openrouter.ts +28 -0
- package/src/registry/parallel.ts +45 -0
- package/src/registry/perplexity.ts +13 -0
- package/src/registry/qianfan.ts +27 -0
- package/src/registry/qwen-portal.ts +50 -0
- package/src/registry/registry.ts +182 -0
- package/src/registry/sakana.ts +22 -0
- package/src/registry/siliconflow-cn.ts +22 -0
- package/src/registry/siliconflow.ts +22 -0
- package/src/registry/synthetic.ts +21 -0
- package/src/registry/tavily.ts +45 -0
- package/src/registry/together.ts +22 -0
- package/src/registry/types.ts +86 -0
- package/src/registry/umans.ts +23 -0
- package/src/registry/venice.ts +33 -0
- package/src/registry/vercel-ai-gateway.ts +38 -0
- package/src/registry/vllm.ts +34 -0
- package/src/registry/wafer-serverless.ts +12 -0
- package/src/registry/xai-oauth.ts +17 -0
- package/src/registry/xai.ts +22 -0
- package/src/registry/xiaomi-token-plan-ams.ts +12 -0
- package/src/registry/xiaomi-token-plan-cn.ts +12 -0
- package/src/registry/xiaomi-token-plan-sgp.ts +12 -0
- package/src/registry/xiaomi.ts +12 -0
- package/src/registry/zai.ts +41 -0
- package/src/registry/zenmux.ts +22 -0
- package/src/registry/zhipu-coding-plan.ts +27 -0
- package/src/stream.ts +1944 -0
- package/src/types.ts +1243 -0
- package/src/usage/alibaba-token-plan.ts +230 -0
- package/src/usage/claude.ts +830 -0
- package/src/usage/cursor.ts +335 -0
- package/src/usage/gemini.ts +258 -0
- package/src/usage/github-copilot.ts +424 -0
- package/src/usage/google-antigravity.ts +497 -0
- package/src/usage/kimi.ts +277 -0
- package/src/usage/minimax-code.ts +291 -0
- package/src/usage/ollama.ts +41 -0
- package/src/usage/openai-codex-base-url.ts +35 -0
- package/src/usage/openai-codex-reset.ts +205 -0
- package/src/usage/openai-codex.ts +627 -0
- package/src/usage/opencode-go.ts +89 -0
- package/src/usage/shared.ts +10 -0
- package/src/usage/synthetic.ts +180 -0
- package/src/usage/umans.ts +192 -0
- package/src/usage/xai-oauth.ts +414 -0
- package/src/usage/zai.ts +370 -0
- package/src/usage.ts +411 -0
- package/src/utils/abort.ts +67 -0
- package/src/utils/anthropic-auth.ts +93 -0
- package/src/utils/aws-profile.ts +88 -0
- package/src/utils/block-symbols.ts +78 -0
- package/src/utils/deterministic-id.ts +20 -0
- package/src/utils/empty-completion-retry.ts +161 -0
- package/src/utils/event-stream.ts +202 -0
- package/src/utils/foundry.ts +8 -0
- package/src/utils/google-validation.ts +25 -0
- package/src/utils/harmony-leak.ts +500 -0
- package/src/utils/http-inspector.ts +196 -0
- package/src/utils/idle-iterator.ts +531 -0
- package/src/utils/leaked-thinking-stream.ts +483 -0
- package/src/utils/openai-http.ts +119 -0
- package/src/utils/openrouter-headers.ts +12 -0
- package/src/utils/parse-bind.ts +56 -0
- package/src/utils/provider-response.ts +30 -0
- package/src/utils/proxy.ts +314 -0
- package/src/utils/request-debug.ts +351 -0
- package/src/utils/retry-after.ts +121 -0
- package/src/utils/retry.ts +77 -0
- package/src/utils/schema/CONSTRAINTS.md +168 -0
- package/src/utils/schema/adapt.ts +36 -0
- package/src/utils/schema/compatibility.ts +435 -0
- package/src/utils/schema/dereference.ts +98 -0
- package/src/utils/schema/draft.ts +341 -0
- package/src/utils/schema/equality.ts +97 -0
- package/src/utils/schema/fields.ts +210 -0
- package/src/utils/schema/index.ts +14 -0
- package/src/utils/schema/json-schema-validator.ts +595 -0
- package/src/utils/schema/meta-validator.ts +167 -0
- package/src/utils/schema/normalize.ts +2314 -0
- package/src/utils/schema/spill.ts +43 -0
- package/src/utils/schema/stamps.ts +109 -0
- package/src/utils/schema/strict-tool-validation.ts +117 -0
- package/src/utils/schema/types.ts +10 -0
- package/src/utils/schema/typescript.ts +212 -0
- package/src/utils/schema/wire.ts +662 -0
- package/src/utils/sdk-stream-timeout.ts +43 -0
- package/src/utils/sse-debug.ts +18 -0
- package/src/utils/stream-markup-healing.ts +247 -0
- package/src/utils/thinking-loop.ts +552 -0
- package/src/utils/tool-call-loop-guard.ts +107 -0
- package/src/utils/tool-choice.ts +101 -0
- package/src/utils/validation.ts +1932 -0
- 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
|
+
}
|