@bendyline/gezel-inference 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +13 -2
- package/dist/cache/adapter.d.ts +115 -0
- package/dist/cache/adapter.js +23 -0
- package/dist/cache/budget.d.ts +15 -0
- package/dist/cache/budget.js +31 -0
- package/dist/cache/controller.d.ts +205 -0
- package/dist/cache/controller.js +377 -0
- package/dist/catalog/LICENSE +21 -0
- package/dist/catalog/chat-models/bt/btl4-35b-q4/manifest.json +146 -0
- package/dist/catalog/chat-models/bt/btl4-35b-q4/versions/1.0.0/manifest.json +15 -0
- package/dist/catalog/chat-models/bt/btl4-35b-q4/versions/1.0.1/manifest.json +15 -0
- package/dist/catalog/chat-models/bt/btl4-compact-iq2/manifest.json +146 -0
- package/dist/catalog/chat-models/bt/btl4-compact-iq2/versions/1.0.0/manifest.json +15 -0
- package/dist/catalog/chat-models/bt/btl4-compact-iq2/versions/1.0.1/manifest.json +15 -0
- package/dist/catalog/chat-models/bt/btl4-compact-iq2/versions/1.0.2/manifest.json +15 -0
- package/dist/catalog/chat-models/de/deepseek-r1-8b-q4/manifest.json +166 -0
- package/dist/catalog/chat-models/de/deepseek-r1-8b-q4/versions/1.1.0/manifest.json +62 -0
- package/dist/catalog/chat-models/de/deepseek-r1-8b-q4/versions/1.1.1/manifest.json +61 -0
- package/dist/catalog/chat-models/de/deepseek-v4-flash-284b-mxfp4/manifest.json +125 -0
- package/dist/catalog/chat-models/de/deepseek-v4-flash-284b-mxfp4/versions/1.0.0/manifest.json +27 -0
- package/dist/catalog/chat-models/de/deepseek-v4-flash-284b-q2/manifest.json +125 -0
- package/dist/catalog/chat-models/de/deepseek-v4-flash-284b-q2/versions/1.0.0/manifest.json +27 -0
- package/dist/catalog/chat-models/de/deepseek-v4-flash-284b-q2/versions/1.1.0/manifest.json +27 -0
- package/dist/catalog/chat-models/de/deepseek-v4-flash-284b-q2q4/manifest.json +126 -0
- package/dist/catalog/chat-models/de/deepseek-v4-flash-284b-q2q4/versions/1.0.0/manifest.json +27 -0
- package/dist/catalog/chat-models/de/deepseek-v4-flash-284b-q4/manifest.json +318 -0
- package/dist/catalog/chat-models/de/deepseek-v4-flash-284b-q4/versions/1.0.0/manifest.json +225 -0
- package/dist/catalog/chat-models/de/deepseek-v4-flash-284b-q4/versions/1.1.0/manifest.json +225 -0
- package/dist/catalog/chat-models/ge/gemma4-12b-q4/manifest.json +249 -0
- package/dist/catalog/chat-models/ge/gemma4-12b-q4/versions/1.0.0/manifest.json +70 -0
- package/dist/catalog/chat-models/ge/gemma4-12b-q4/versions/1.1.0/manifest.json +72 -0
- package/dist/catalog/chat-models/ge/gemma4-12b-q4/versions/1.1.2/manifest.json +78 -0
- package/dist/catalog/chat-models/ge/gemma4-12b-q4/versions/1.1.3/manifest.json +78 -0
- package/dist/catalog/chat-models/ge/gemma4-12b-q4/versions/1.2.0/manifest.json +98 -0
- package/dist/catalog/chat-models/ge/gemma4-12b-q8/manifest.json +228 -0
- package/dist/catalog/chat-models/ge/gemma4-12b-q8/versions/1.0.0/manifest.json +72 -0
- package/dist/catalog/chat-models/ge/gemma4-12b-q8/versions/1.0.2/manifest.json +78 -0
- package/dist/catalog/chat-models/ge/gemma4-12b-q8/versions/1.0.3/manifest.json +78 -0
- package/dist/catalog/chat-models/ge/gemma4-12b-q8/versions/1.1.0/manifest.json +78 -0
- package/dist/catalog/chat-models/ge/gemma4-26b-q4/manifest.json +220 -0
- package/dist/catalog/chat-models/ge/gemma4-26b-q4/versions/1.1.0/manifest.json +75 -0
- package/dist/catalog/chat-models/ge/gemma4-26b-q4/versions/1.2.0/manifest.json +75 -0
- package/dist/catalog/chat-models/ge/gemma4-26b-q4/versions/1.2.1/manifest.json +80 -0
- package/dist/catalog/chat-models/ge/gemma4-26b-q4/versions/1.2.2/manifest.json +80 -0
- package/dist/catalog/chat-models/ge/gemma4-26b-q4/versions/1.3.0/manifest.json +81 -0
- package/dist/catalog/chat-models/ge/gemma4-31b-q4/manifest.json +226 -0
- package/dist/catalog/chat-models/ge/gemma4-31b-q4/versions/1.1.0/manifest.json +80 -0
- package/dist/catalog/chat-models/ge/gemma4-31b-q4/versions/1.1.1/manifest.json +82 -0
- package/dist/catalog/chat-models/ge/gemma4-31b-q4/versions/1.2.0/manifest.json +82 -0
- package/dist/catalog/chat-models/ge/gemma4-31b-q4/versions/1.2.1/manifest.json +95 -0
- package/dist/catalog/chat-models/ge/gemma4-31b-q4/versions/1.2.2/manifest.json +95 -0
- package/dist/catalog/chat-models/ge/gemma4-31b-q4/versions/1.3.0/manifest.json +86 -0
- package/dist/catalog/chat-models/ge/gemma4-e2b-q4/manifest.json +213 -0
- package/dist/catalog/chat-models/ge/gemma4-e2b-q4/versions/1.1.0/manifest.json +67 -0
- package/dist/catalog/chat-models/ge/gemma4-e2b-q4/versions/1.1.2/manifest.json +75 -0
- package/dist/catalog/chat-models/ge/gemma4-e2b-q4/versions/1.2.0/manifest.json +71 -0
- package/dist/catalog/chat-models/ge/gemma4-e2b-q4/versions/1.2.1/manifest.json +71 -0
- package/dist/catalog/chat-models/ge/gemma4-e2b-q4/versions/1.3.0/manifest.json +71 -0
- package/dist/catalog/chat-models/ge/gemma4-e4b-q4/manifest.json +205 -0
- package/dist/catalog/chat-models/ge/gemma4-e4b-q4/versions/1.1.0/manifest.json +72 -0
- package/dist/catalog/chat-models/ge/gemma4-e4b-q4/versions/1.1.2/manifest.json +75 -0
- package/dist/catalog/chat-models/ge/gemma4-e4b-q4/versions/1.2.0/manifest.json +76 -0
- package/dist/catalog/chat-models/ge/gemma4-e4b-q4/versions/1.2.1/manifest.json +76 -0
- package/dist/catalog/chat-models/ge/gemma4-e4b-q4/versions/1.3.0/manifest.json +76 -0
- package/dist/catalog/chat-models/gl/glm-5.2-754b-q2/manifest.json +127 -0
- package/dist/catalog/chat-models/gl/glm-5.2-754b-q2/versions/1.0.0/manifest.json +20 -0
- package/dist/catalog/chat-models/gl/glm-5.3-flash-320b-q2/manifest.json +140 -0
- package/dist/catalog/chat-models/gl/glm-5.3-flash-320b-q2/versions/1.0.0/manifest.json +23 -0
- package/dist/catalog/chat-models/gl/glm-5.3-flash-320b-q2/versions/1.0.1/manifest.json +26 -0
- package/dist/catalog/chat-models/gp/gpt-oss-120b-q4/manifest.json +247 -0
- package/dist/catalog/chat-models/gp/gpt-oss-120b-q4/versions/1.0.0/manifest.json +137 -0
- package/dist/catalog/chat-models/gp/gpt-oss-120b-q4/versions/1.1.0/manifest.json +135 -0
- package/dist/catalog/chat-models/gp/gpt-oss-20b-q4/manifest.json +195 -0
- package/dist/catalog/chat-models/gp/gpt-oss-20b-q4/versions/1.1.0/manifest.json +72 -0
- package/dist/catalog/chat-models/gp/gpt-oss-20b-q4/versions/1.1.1/manifest.json +71 -0
- package/dist/catalog/chat-models/gr/granite4.2-30b-q4/manifest.json +200 -0
- package/dist/catalog/chat-models/gr/granite4.2-30b-q4/versions/1.0.0/manifest.json +72 -0
- package/dist/catalog/chat-models/gr/granite4.2-3b-q4/manifest.json +196 -0
- package/dist/catalog/chat-models/gr/granite4.2-3b-q4/versions/1.0.0/manifest.json +57 -0
- package/dist/catalog/chat-models/gr/granite4.2-8b-q4/manifest.json +189 -0
- package/dist/catalog/chat-models/gr/granite4.2-8b-q4/versions/1.0.0/manifest.json +57 -0
- package/dist/catalog/chat-models/index.json +1 -0
- package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q4/manifest.json +235 -0
- package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q4/versions/1.0.0/manifest.json +124 -0
- package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q4/versions/1.0.1/manifest.json +124 -0
- package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q4/versions/1.0.2/manifest.json +123 -0
- package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q6/manifest.json +257 -0
- package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q6/versions/1.0.0/manifest.json +145 -0
- package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q6/versions/1.0.1/manifest.json +145 -0
- package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q8/manifest.json +285 -0
- package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q8/versions/1.0.0/manifest.json +174 -0
- package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q8/versions/1.0.1/manifest.json +174 -0
- package/dist/catalog/chat-models/la/laguna-s-2.1-118b-q8/versions/1.0.2/manifest.json +173 -0
- package/dist/catalog/chat-models/lf/lfm2.5-2.6b-q4/manifest.json +162 -0
- package/dist/catalog/chat-models/lf/lfm2.5-2.6b-q4/versions/1.0.0/manifest.json +59 -0
- package/dist/catalog/chat-models/lf/lfm2.5-2.6b-q4/versions/1.0.1/manifest.json +58 -0
- package/dist/catalog/chat-models/ll/llama3.2-3b-q4/manifest.json +134 -0
- package/dist/catalog/chat-models/ll/llama3.2-3b-q4/versions/1.1.0/manifest.json +57 -0
- package/dist/catalog/chat-models/ll/llama3.2-3b-q4/versions/1.1.1/manifest.json +56 -0
- package/dist/catalog/chat-models/mi/minicpm5-1b-q4/manifest.json +156 -0
- package/dist/catalog/chat-models/mi/minicpm5-1b-q4/versions/1.0.0/manifest.json +57 -0
- package/dist/catalog/chat-models/mi/minicpm5-2b-q4/manifest.json +161 -0
- package/dist/catalog/chat-models/mi/minicpm5-2b-q4/versions/1.0.0/manifest.json +57 -0
- package/dist/catalog/chat-models/mi/mistral-7b-q4/manifest.json +159 -0
- package/dist/catalog/chat-models/mi/mistral-7b-q4/versions/1.1.0/manifest.json +62 -0
- package/dist/catalog/chat-models/mi/mistral-7b-q4/versions/1.1.1/manifest.json +61 -0
- package/dist/catalog/chat-models/mi/mistral-medium-3.5-128b-q4/manifest.json +275 -0
- package/dist/catalog/chat-models/mi/mistral-medium-3.5-128b-q4/versions/1.0.0/manifest.json +154 -0
- package/dist/catalog/chat-models/mi/mistral-medium-3.5-128b-q4/versions/1.0.1/manifest.json +153 -0
- package/dist/catalog/chat-models/mu/muse-glimmer-30b-q4/manifest.json +135 -0
- package/dist/catalog/chat-models/mu/muse-glimmer-30b-q4/versions/1.0.0/manifest.json +20 -0
- package/dist/catalog/chat-models/mu/muse-glimmer-30b-q4/versions/1.0.1/manifest.json +20 -0
- package/dist/catalog/chat-models/mu/muse-glimmer-30b-q4/versions/1.0.2/manifest.json +20 -0
- package/dist/catalog/chat-models/na/nanbeige4.2-3b-q4/manifest.json +138 -0
- package/dist/catalog/chat-models/na/nanbeige4.2-3b-q4/versions/1.0.0/manifest.json +14 -0
- package/dist/catalog/chat-models/ne/nemotron3-nano-30b-q4/manifest.json +212 -0
- package/dist/catalog/chat-models/ne/nemotron3-nano-30b-q4/versions/1.0.0/manifest.json +94 -0
- package/dist/catalog/chat-models/ne/nemotron3-nano-30b-q4/versions/1.0.1/manifest.json +94 -0
- package/dist/catalog/chat-models/ne/nemotron3-nano-30b-q4/versions/1.0.2/manifest.json +93 -0
- package/dist/catalog/chat-models/ne/nemotron3-super-120b-q4/manifest.json +135 -0
- package/dist/catalog/chat-models/ne/nemotron3-super-120b-q4/versions/1.0.0/manifest.json +30 -0
- package/dist/catalog/chat-models/ne/nemotron3-super-120b-q4/versions/1.0.1/manifest.json +30 -0
- package/dist/catalog/chat-models/ne/nemotron3.5-lightning-30b-q4/manifest.json +192 -0
- package/dist/catalog/chat-models/ne/nemotron3.5-lightning-30b-q4/versions/1.0.0/manifest.json +74 -0
- package/dist/catalog/chat-models/ne/nemotron3.5-lightning-30b-q4/versions/1.0.1/manifest.json +73 -0
- package/dist/catalog/chat-models/ne/nemotron3.5-lightning-30b-q8/manifest.json +207 -0
- package/dist/catalog/chat-models/ne/nemotron3.5-lightning-30b-q8/versions/1.0.0/manifest.json +89 -0
- package/dist/catalog/chat-models/ne/nemotron3.5-lightning-30b-q8/versions/1.0.1/manifest.json +88 -0
- package/dist/catalog/chat-models/or/ornith-35b-q4/manifest.json +224 -0
- package/dist/catalog/chat-models/or/ornith-35b-q4/versions/1.0.0/manifest.json +94 -0
- package/dist/catalog/chat-models/or/ornith-35b-q4/versions/1.0.1/manifest.json +93 -0
- package/dist/catalog/chat-models/or/ornith-35b-q8/manifest.json +244 -0
- package/dist/catalog/chat-models/or/ornith-35b-q8/versions/1.0.0/manifest.json +114 -0
- package/dist/catalog/chat-models/or/ornith-35b-q8/versions/1.0.1/manifest.json +113 -0
- package/dist/catalog/chat-models/or/ornith-9b-q4/manifest.json +215 -0
- package/dist/catalog/chat-models/or/ornith-9b-q4/versions/1.0.0/manifest.json +84 -0
- package/dist/catalog/chat-models/or/ornith-9b-q4/versions/1.0.1/manifest.json +83 -0
- package/dist/catalog/chat-models/or/ornith1.5-35b-a3b-q4/manifest.json +208 -0
- package/dist/catalog/chat-models/or/ornith1.5-35b-a3b-q4/versions/1.0.0/manifest.json +74 -0
- package/dist/catalog/chat-models/or/ornith1.5-35b-a3b-q8/manifest.json +223 -0
- package/dist/catalog/chat-models/or/ornith1.5-35b-a3b-q8/versions/1.0.0/manifest.json +89 -0
- package/dist/catalog/chat-models/or/ornith1.5-9b-q4/manifest.json +187 -0
- package/dist/catalog/chat-models/or/ornith1.5-9b-q4/versions/1.0.0/manifest.json +53 -0
- package/dist/catalog/chat-models/or/ornith1.5-9b-q4/versions/1.0.1/manifest.json +77 -0
- package/dist/catalog/chat-models/or/ornith1.5-9b-q8/manifest.json +192 -0
- package/dist/catalog/chat-models/or/ornith1.5-9b-q8/versions/1.0.0/manifest.json +58 -0
- package/dist/catalog/chat-models/or/ornith1.5-9b-q8/versions/1.0.1/manifest.json +82 -0
- package/dist/catalog/chat-models/qw/qwen3.5-0.8b-q4/manifest.json +192 -0
- package/dist/catalog/chat-models/qw/qwen3.5-0.8b-q4/versions/1.0.0/manifest.json +75 -0
- package/dist/catalog/chat-models/qw/qwen3.5-122b-a10b-q4/manifest.json +315 -0
- package/dist/catalog/chat-models/qw/qwen3.5-122b-a10b-q4/versions/1.0.0/manifest.json +164 -0
- package/dist/catalog/chat-models/qw/qwen3.5-122b-a10b-q4/versions/1.0.1/manifest.json +164 -0
- package/dist/catalog/chat-models/qw/qwen3.5-122b-a10b-q4/versions/1.0.2/manifest.json +164 -0
- package/dist/catalog/chat-models/qw/qwen3.5-122b-a10b-q4/versions/1.0.3/manifest.json +163 -0
- package/dist/catalog/chat-models/qw/qwen3.5-27b-q4/manifest.json +217 -0
- package/dist/catalog/chat-models/qw/qwen3.5-27b-q4/versions/1.0.0/manifest.json +90 -0
- package/dist/catalog/chat-models/qw/qwen3.5-2b-q4/manifest.json +194 -0
- package/dist/catalog/chat-models/qw/qwen3.5-2b-q4/versions/1.1.0/manifest.json +77 -0
- package/dist/catalog/chat-models/qw/qwen3.5-2b-q4/versions/1.1.2/manifest.json +76 -0
- package/dist/catalog/chat-models/qw/qwen3.5-2b-q4/versions/1.1.3/manifest.json +76 -0
- package/dist/catalog/chat-models/qw/qwen3.5-4b-q4/manifest.json +188 -0
- package/dist/catalog/chat-models/qw/qwen3.5-4b-q4/versions/1.1.0/manifest.json +77 -0
- package/dist/catalog/chat-models/qw/qwen3.5-4b-q4/versions/1.1.2/manifest.json +76 -0
- package/dist/catalog/chat-models/qw/qwen3.5-4b-q4/versions/1.1.3/manifest.json +76 -0
- package/dist/catalog/chat-models/qw/qwen3.5-9b-q4/manifest.json +205 -0
- package/dist/catalog/chat-models/qw/qwen3.5-9b-q4/versions/1.1.0/manifest.json +82 -0
- package/dist/catalog/chat-models/qw/qwen3.5-9b-q4/versions/1.1.2/manifest.json +81 -0
- package/dist/catalog/chat-models/qw/qwen3.5-9b-q4/versions/1.1.3/manifest.json +81 -0
- package/dist/catalog/chat-models/qw/qwen3.6-27b-q4/manifest.json +230 -0
- package/dist/catalog/chat-models/qw/qwen3.6-27b-q4/versions/1.1.0/manifest.json +92 -0
- package/dist/catalog/chat-models/qw/qwen3.6-27b-q4/versions/1.1.4/manifest.json +91 -0
- package/dist/catalog/chat-models/qw/qwen3.6-27b-q4/versions/1.1.5/manifest.json +91 -0
- package/dist/catalog/chat-models/qw/qwen3.6-27b-q4/versions/1.1.6/manifest.json +115 -0
- package/dist/catalog/chat-models/qw/qwen3.6-27b-q8/manifest.json +240 -0
- package/dist/catalog/chat-models/qw/qwen3.6-27b-q8/versions/1.0.0/manifest.json +102 -0
- package/dist/catalog/chat-models/qw/qwen3.6-27b-q8/versions/1.0.2/manifest.json +103 -0
- package/dist/catalog/chat-models/qw/qwen3.6-27b-q8/versions/1.0.3/manifest.json +103 -0
- package/dist/catalog/chat-models/qw/qwen3.6-27b-q8/versions/1.0.4/manifest.json +127 -0
- package/dist/catalog/chat-models/qw/qwen3.6-35b-a3b-q4/manifest.json +233 -0
- package/dist/catalog/chat-models/qw/qwen3.6-35b-a3b-q4/versions/1.0.0/manifest.json +92 -0
- package/dist/catalog/chat-models/qw/qwen3.6-35b-a3b-q4/versions/1.0.1/manifest.json +93 -0
- package/dist/catalog/chat-models/qw/qwen3.6-35b-a3b-q4/versions/1.0.2/manifest.json +93 -0
- package/dist/catalog/chat-models/qw/qwen3.6-35b-a3b-q4/versions/1.0.3/manifest.json +117 -0
- package/dist/catalog/chat-models/qw/qwen3.6-35b-a3b-q8/manifest.json +252 -0
- package/dist/catalog/chat-models/qw/qwen3.6-35b-a3b-q8/versions/1.0.0/manifest.json +112 -0
- package/dist/catalog/chat-models/qw/qwen3.6-35b-a3b-q8/versions/1.0.1/manifest.json +113 -0
- package/dist/catalog/chat-models/qw/qwen3.6-35b-a3b-q8/versions/1.0.2/manifest.json +113 -0
- package/dist/catalog/chat-models/qw/qwen3.6-35b-a3b-q8/versions/1.0.3/manifest.json +137 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-iq1-s/manifest.json +195 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-iq1-s/versions/1.0.0/manifest.json +19 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-q2/manifest.json +194 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-q2/versions/1.0.0/manifest.json +19 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-q3/manifest.json +194 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-q3/versions/1.0.0/manifest.json +19 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-q3/versions/1.0.1/manifest.json +19 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-q4/manifest.json +291 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-q4/versions/1.0.0/manifest.json +20 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-q4/versions/1.0.1/manifest.json +93 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-q4/versions/1.0.2/manifest.json +92 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-q4/versions/1.0.3/manifest.json +116 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-q4/versions/1.0.4/manifest.json +116 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-q4/versions/1.0.5/manifest.json +116 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-q6/manifest.json +278 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-q6/versions/1.0.0/manifest.json +20 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-q6/versions/1.0.1/manifest.json +104 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-q6/versions/1.0.2/manifest.json +103 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-q6/versions/1.0.3/manifest.json +127 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-q8/manifest.json +278 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-q8/versions/1.0.0/manifest.json +113 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-q8/versions/1.0.1/manifest.json +113 -0
- package/dist/catalog/chat-models/qw/qwen3.8-27b-q8/versions/1.0.2/manifest.json +137 -0
- package/dist/catalog/chat-models/qw/qwen3.8-flash-next-iq3/manifest.json +221 -0
- package/dist/catalog/chat-models/qw/qwen3.8-flash-next-iq3/versions/1.0.0/manifest.json +36 -0
- package/dist/catalog/chat-models/qw/qwen3.8-flash-next-iq4/manifest.json +429 -0
- package/dist/catalog/chat-models/qw/qwen3.8-flash-next-iq4/versions/1.0.0/manifest.json +36 -0
- package/dist/catalog/chat-models/qw/qwen3.8-flash-next-iq4/versions/1.1.0/manifest.json +205 -0
- package/dist/catalog/chat-models/qw/qwen3.8-flash-next-iq4/versions/1.1.1/manifest.json +244 -0
- package/dist/catalog/chat-models/qw/qwen3.8-flash-next-q2/manifest.json +203 -0
- package/dist/catalog/chat-models/qw/qwen3.8-flash-next-q2/versions/1.0.0/manifest.json +21 -0
- package/dist/catalog/chat-models/qw/qwen3.8-flash-next-q2/versions/1.0.1/manifest.json +31 -0
- package/dist/catalog/chat-models/qw/qwen3.8-flash-next-q2/versions/1.0.2/manifest.json +30 -0
- package/dist/catalog/chat-models/raw-index.json +1 -0
- package/dist/catalog/chat-models/ta/talkie-1930-13b-q4/manifest.json +79 -0
- package/dist/catalog/chat-models/ta/talkie-1930-13b-q4/versions/1.0.0/manifest.json +15 -0
- package/dist/catalog/chat-models/ta/talkie-1930-13b-q4/versions/1.0.1/manifest.json +15 -0
- package/dist/catalog/chat-models/te/ternary-bonsai-27b-q2/manifest.json +204 -0
- package/dist/catalog/chat-models/te/ternary-bonsai-27b-q2/versions/1.0.0/manifest.json +70 -0
- package/dist/catalog/chat-models/te/ternary-bonsai-27b-q2/versions/1.0.1/manifest.json +70 -0
- package/dist/catalog/chat-models/un/underdog-saluki-27b-iq2-mix/manifest.json +164 -0
- package/dist/catalog/chat-models/un/underdog-saluki-27b-iq2-mix/versions/1.0.0/manifest.json +14 -0
- package/dist/catalog/knowledge-catalogs/be/bendyline-how-to-books/manifest.json +26 -0
- package/dist/catalog/knowledge-catalogs/be/bendyline-how-to-books/versions/2026.10.8/manifest.json +8950 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-alaska-part-0001/manifest.json +24 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-alaska-part-0001/versions/2026.10.1/manifest.json +658 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-nevada-part-0001/manifest.json +24 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-nevada-part-0001/versions/2026.10.1/manifest.json +666 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-us-300-399-part-0001/manifest.json +24 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-us-300-399-part-0001/versions/2026.10.2/manifest.json +182 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-us-347-349/manifest.json +25 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-us-347-349/versions/2026.10.5/manifest.json +38 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-utah-part-0001/manifest.json +24 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-utah-part-0001/versions/2026.10.1/manifest.json +558 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-utah-part-0002/manifest.json +24 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-utah-part-0002/versions/2026.10.1/manifest.json +258 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-washington-part-0001/manifest.json +24 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-washington-part-0001/versions/2026.10.1/manifest.json +318 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-washington-part-0002/manifest.json +24 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-washington-part-0002/versions/2026.10.1/manifest.json +266 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-washington-part-0003/manifest.json +24 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-washington-part-0003/versions/2026.10.1/manifest.json +186 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-washington-part-0004/manifest.json +24 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-washington-part-0004/versions/2026.10.1/manifest.json +138 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-washington-part-0005/manifest.json +24 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-washington-part-0005/versions/2026.10.1/manifest.json +138 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-wyoming-part-0001/manifest.json +24 -0
- package/dist/catalog/knowledge-catalogs/ca/caselaw-wyoming-part-0001/versions/2026.10.3/manifest.json +626 -0
- package/dist/catalog/knowledge-catalogs/index.json +1 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-aspnet-core-en/manifest.json +22 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-aspnet-core-en/versions/2026.10.16/manifest.json +18270 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-azure-ai-platform-en/manifest.json +23 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-azure-ai-platform-en/versions/2026.10.16/manifest.json +1098 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-azure-apps-compute-en/manifest.json +23 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-azure-apps-compute-en/versions/2026.10.16/manifest.json +722 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-azure-data-aiot-en/manifest.json +23 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-azure-data-aiot-en/versions/2026.10.16/manifest.json +530 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-azure-management-hybrid-en/manifest.json +23 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-azure-management-hybrid-en/versions/2026.10.16/manifest.json +602 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-azure-network-security-en/manifest.json +23 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-azure-network-security-en/versions/2026.10.16/manifest.json +250 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-dotnet-en/manifest.json +22 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-dotnet-en/versions/2026.10.16/manifest.json +16794 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-entity-framework-en/manifest.json +22 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-entity-framework-en/versions/2026.10.16/manifest.json +1106 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-microsoft-365-en/manifest.json +22 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-microsoft-365-en/versions/2026.10.16/manifest.json +234 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-powershell-en/manifest.json +22 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-powershell-en/versions/2026.10.16/manifest.json +522 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-sql-server-en/manifest.json +22 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-sql-server-en/versions/2026.10.16/manifest.json +1850 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-windows-apps-en/manifest.json +22 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-windows-apps-en/versions/2026.10.16/manifest.json +1734 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-windows-server-en/manifest.json +22 -0
- package/dist/catalog/knowledge-catalogs/mi/microsoftdocs-windows-server-en/versions/2026.10.16/manifest.json +774 -0
- package/dist/catalog/knowledge-catalogs/raw-index.json +1 -0
- package/dist/catalog/knowledge-catalogs/re/regional-american-south/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-american-south/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-american-south/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-american-south/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-andes-amazon/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-andes-amazon/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-andes-amazon/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-andes-amazon/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-australia-nz/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-australia-nz/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-australia-nz/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-australia-nz/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-brazil/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-brazil/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-brazil/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-brazil/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-britain-ireland/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-britain-ireland/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-britain-ireland/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-britain-ireland/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-california-southwest/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-california-southwest/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-california-southwest/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-california-southwest/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-canada-alaska/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-canada-alaska/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-canada-alaska/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-canada-alaska/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-central-eastern-europe/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-central-eastern-europe/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-central-eastern-europe/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-central-eastern-europe/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-east-asia/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-east-asia/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-east-asia/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-east-asia/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-east-southern-africa/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-east-southern-africa/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-east-southern-africa/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-east-southern-africa/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-mediterranean/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-mediterranean/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-mediterranean/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-mediterranean/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-mexico-central-america/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-mexico-central-america/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-mexico-central-america/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-mexico-central-america/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-middle-east/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-middle-east/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-middle-east/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-middle-east/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-midwest/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-midwest/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-midwest/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-midwest/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-north-africa/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-north-africa/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-north-africa/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-north-africa/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-northeast/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-northeast/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-northeast/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-northeast/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-northern-eurasia/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-northern-eurasia/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-northern-eurasia/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-northern-eurasia/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-northern-europe/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-northern-europe/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-northern-europe/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-northern-europe/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-pacific-islands/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-pacific-islands/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-pacific-islands/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-pacific-islands/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-pacific-northwest/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-pacific-northwest/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-pacific-northwest/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-pacific-northwest/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-rockies-plains/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-rockies-plains/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-rockies-plains/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-rockies-plains/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-south-asia/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-south-asia/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-south-asia/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-south-asia/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-southeast-asia/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-southeast-asia/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-southeast-asia/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-southeast-asia/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-southern-cone/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-southern-cone/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-southern-cone/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-southern-cone/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-west-central-africa/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-west-central-africa/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-west-central-africa/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-west-central-africa/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/re/regional-western-europe/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/re/regional-western-europe/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/re/regional-western-europe/manifest.json +48 -0
- package/dist/catalog/knowledge-catalogs/re/regional-western-europe/versions/2026.10.3/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-arts/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-arts/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-arts/manifest.json +28 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-arts/versions/2026.4.2/manifest.json +53 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-arts/versions/2026.4.3/manifest.json +885 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-arts/versions/2026.4.5/manifest.json +157 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-astronomy/art.json +11 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-astronomy/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-astronomy/manifest.json +32 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-astronomy/versions/2026.4.2/manifest.json +69 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-astronomy/versions/2026.4.3/manifest.json +741 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-astronomy/versions/2026.4.5/manifest.json +173 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-biology/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-biology/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-biology/manifest.json +69 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-biology/versions/2026.4.4/manifest.json +217 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-biology/versions/2026.4.5/manifest.json +217 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-business/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-business/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-business/manifest.json +64 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-business/versions/2026.4.4/manifest.json +197 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-business/versions/2026.4.5/manifest.json +197 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-film-tv/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-film-tv/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-film-tv/manifest.json +64 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-film-tv/versions/2026.4.5/manifest.json +197 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-food-drink/art.json +11 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-food-drink/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-food-drink/manifest.json +29 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-food-drink/versions/2026.4.2/manifest.json +57 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-food-drink/versions/2026.4.3/manifest.json +925 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-food-drink/versions/2026.4.5/manifest.json +185 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-games/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-games/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-games/manifest.json +28 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-games/versions/2026.4.2/manifest.json +53 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-games/versions/2026.4.3/manifest.json +1665 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-games/versions/2026.4.5/manifest.json +133 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-language/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-language/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-language/manifest.json +29 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-language/versions/2026.4.2/manifest.json +57 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-language/versions/2026.4.3/manifest.json +1233 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-language/versions/2026.4.5/manifest.json +141 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-law/art.json +11 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-law/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-law/manifest.json +29 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-law/versions/2026.4.2/manifest.json +57 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-law/versions/2026.4.3/manifest.json +493 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-law/versions/2026.4.5/manifest.json +185 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-literature/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-literature/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-literature/manifest.json +63 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-literature/versions/2026.4.4/manifest.json +193 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-literature/versions/2026.4.5/manifest.json +193 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-mathematics/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-mathematics/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-mathematics/manifest.json +30 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-mathematics/versions/2026.4.2/manifest.json +61 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-mathematics/versions/2026.4.3/manifest.json +665 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-mathematics/versions/2026.4.5/manifest.json +177 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-medicine/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-medicine/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-medicine/manifest.json +29 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-medicine/versions/2026.4.2/manifest.json +57 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-medicine/versions/2026.4.3/manifest.json +745 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-medicine/versions/2026.4.5/manifest.json +185 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-military/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-military/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-military/manifest.json +62 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-military/versions/2026.4.4/manifest.json +189 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-military/versions/2026.4.5/manifest.json +189 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-music/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-music/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-music/manifest.json +64 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-music/versions/2026.4.5/manifest.json +197 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-politics/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-politics/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-politics/manifest.json +64 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-politics/versions/2026.4.4/manifest.json +197 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-politics/versions/2026.4.5/manifest.json +197 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-religion-philosophy/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-religion-philosophy/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-religion-philosophy/manifest.json +30 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-religion-philosophy/versions/2026.4.2/manifest.json +61 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-religion-philosophy/versions/2026.4.3/manifest.json +1253 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-religion-philosophy/versions/2026.4.5/manifest.json +189 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-science/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-science/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-science/manifest.json +29 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-science/versions/2026.4.2/manifest.json +57 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-science/versions/2026.4.3/manifest.json +1957 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-science/versions/2026.4.5/manifest.json +185 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-sports/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-sports/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-sports/manifest.json +63 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-sports/versions/2026.4.5/manifest.json +193 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-technology/art.json +11 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-technology/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-technology/manifest.json +45 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-technology/versions/2026.4.4/manifest.json +121 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-technology/versions/2026.4.5/manifest.json +173 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-transportation/art.json +12 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-transportation/logo.webp +0 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-transportation/manifest.json +62 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-transportation/versions/2026.4.4/manifest.json +189 -0
- package/dist/catalog/knowledge-catalogs/wi/wikipedia-transportation/versions/2026.4.5/manifest.json +189 -0
- package/dist/catalog/provenance.json +8 -0
- package/dist/chat/catalog-model-lookup.d.ts +21 -0
- package/dist/chat/catalog-model-lookup.js +48 -0
- package/dist/chat/local-model-tier.d.ts +1 -0
- package/dist/chat/local-model-tier.js +2 -0
- package/dist/chat/vision-capability.d.ts +1 -0
- package/dist/chat/vision-capability.js +3 -0
- package/dist/engine-components.d.ts +30 -0
- package/dist/engine-components.js +144 -0
- package/dist/engines/electron-native-reuse.d.ts +65 -0
- package/dist/engines/electron-native-reuse.js +305 -0
- package/dist/engines/native-file-manifest.json +1320 -0
- package/dist/engines/native-manifest.d.ts +70 -0
- package/dist/engines/native-manifest.js +98 -0
- package/dist/engines/registry.d.ts +88 -0
- package/dist/engines/registry.js +217 -0
- package/dist/engines/resolver.d.ts +121 -0
- package/dist/engines/resolver.js +894 -0
- package/dist/engines/signature.d.ts +85 -0
- package/dist/engines/signature.js +231 -0
- package/dist/fs/atomic.d.ts +53 -0
- package/dist/fs/atomic.js +202 -0
- package/dist/fs/config-store.d.ts +14 -0
- package/dist/fs/config-store.js +101 -0
- package/dist/fs/home-permissions.d.ts +18 -0
- package/dist/fs/home-permissions.js +27 -0
- package/dist/fs/safe-paths.d.ts +107 -0
- package/dist/fs/safe-paths.js +264 -0
- package/dist/fs/security-json.d.ts +13 -0
- package/dist/fs/security-json.js +67 -0
- package/dist/http/auth-context.d.ts +23 -0
- package/dist/http/auth-context.js +1 -0
- package/dist/http/engine-runtime-context.d.ts +30 -0
- package/dist/http/engine-runtime-context.js +1 -0
- package/dist/http/external-conversation-contract.d.ts +49 -0
- package/dist/http/external-conversation-contract.js +1 -0
- package/dist/http/external-conversation-headers.d.ts +11 -0
- package/dist/http/external-conversation-headers.js +11 -0
- package/dist/http/inference-context.d.ts +36 -0
- package/dist/http/inference-context.js +1 -0
- package/dist/http/model-inventory-cache.d.ts +8 -0
- package/dist/http/model-inventory-cache.js +8 -0
- package/dist/http/openai-compat/action-ledger.d.ts +15 -0
- package/dist/http/openai-compat/action-ledger.js +119 -0
- package/dist/http/openai-compat/apple-model.d.ts +3 -0
- package/dist/http/openai-compat/apple-model.js +44 -0
- package/dist/http/openai-compat/caller-owned-profile.d.ts +7 -0
- package/dist/http/openai-compat/caller-owned-profile.js +18 -0
- package/dist/http/openai-compat/chat-progress.d.ts +9 -0
- package/dist/http/openai-compat/chat-progress.js +69 -0
- package/dist/http/openai-compat/chat-target.d.ts +76 -0
- package/dist/http/openai-compat/chat-target.js +97 -0
- package/dist/http/openai-compat/embeddings.d.ts +37 -0
- package/dist/http/openai-compat/embeddings.js +86 -0
- package/dist/http/openai-compat/request-tuning.d.ts +35 -0
- package/dist/http/openai-compat/request-tuning.js +87 -0
- package/dist/http/openai-compat/responses-streaming.d.ts +351 -0
- package/dist/http/openai-compat/responses-streaming.js +717 -0
- package/dist/http/openai-compat/responses-translate.d.ts +215 -0
- package/dist/http/openai-compat/responses-translate.js +377 -0
- package/dist/http/openai-compat/streaming-telemetry.d.ts +30 -0
- package/dist/http/openai-compat/streaming-telemetry.js +88 -0
- package/dist/http/openai-compat/streaming.d.ts +141 -0
- package/dist/http/openai-compat/streaming.js +687 -0
- package/dist/http/openai-compat/tool-call-stream-filter.d.ts +33 -0
- package/dist/http/openai-compat/tool-call-stream-filter.js +110 -0
- package/dist/http/openai-compat/translate.d.ts +254 -0
- package/dist/http/openai-compat/translate.js +485 -0
- package/dist/http/routes/engines.d.ts +13 -0
- package/dist/http/routes/engines.js +366 -0
- package/dist/http/routes/machine-engine-proxy.d.ts +26 -0
- package/dist/http/routes/machine-engine-proxy.js +43 -0
- package/dist/http/routes/model-bundles.d.ts +4 -0
- package/dist/http/routes/model-bundles.js +190 -0
- package/dist/http/routes/v1-chat.d.ts +72 -0
- package/dist/http/routes/v1-chat.js +645 -0
- package/dist/http/routes/v1-embeddings.d.ts +3 -0
- package/dist/http/routes/v1-embeddings.js +82 -0
- package/dist/http/routes/v1-knowledge.d.ts +4 -0
- package/dist/http/routes/v1-knowledge.js +304 -0
- package/dist/http/routes/v1-models-ensure.d.ts +32 -0
- package/dist/http/routes/v1-models-ensure.js +122 -0
- package/dist/http/routes/v1-models.d.ts +8 -0
- package/dist/http/routes/v1-models.js +189 -0
- package/dist/http/routes/v1-responses.d.ts +20 -0
- package/dist/http/routes/v1-responses.js +327 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/inference-service.d.ts +28 -0
- package/dist/inference-service.js +275 -0
- package/dist/knowledge/admission.d.ts +3 -0
- package/dist/knowledge/admission.js +22 -0
- package/dist/knowledge/bundled-catalog.d.ts +2 -0
- package/dist/knowledge/bundled-catalog.js +2 -0
- package/dist/knowledge/catalog-host.d.ts +103 -0
- package/dist/knowledge/catalog-host.js +180 -0
- package/dist/knowledge/catalog-source.d.ts +22 -0
- package/dist/knowledge/catalog-source.js +35 -0
- package/dist/knowledge/install.d.ts +55 -0
- package/dist/knowledge/install.js +274 -0
- package/dist/knowledge/manager.d.ts +287 -0
- package/dist/knowledge/manager.js +1540 -0
- package/dist/knowledge/registry.d.ts +35 -0
- package/dist/knowledge/registry.js +123 -0
- package/dist/knowledge/search-worker.d.ts +17 -0
- package/dist/knowledge/search-worker.js +36 -0
- package/dist/knowledge/shared-install-contract.d.ts +19 -0
- package/dist/knowledge/shared-install-contract.js +1 -0
- package/dist/knowledge/spatial-query.d.ts +11 -0
- package/dist/knowledge/spatial-query.js +33 -0
- package/dist/knowledge/trust-anchors.d.ts +11 -0
- package/dist/knowledge/trust-anchors.js +51 -0
- package/dist/knowledge/vector-floors.d.ts +44 -0
- package/dist/knowledge/vector-floors.js +92 -0
- package/dist/knowledge/worker-host.d.ts +9 -0
- package/dist/knowledge/worker-host.js +179 -0
- package/dist/kokoro-lexicon/LICENSE +202 -0
- package/dist/kokoro-lexicon/lexicon-gb-en.txt.gz +0 -0
- package/dist/kokoro-lexicon/lexicon-us-en.txt.gz +0 -0
- package/dist/kokoro-lexicon/manifest.json +23 -0
- package/dist/machine-engine/inspect-runtime.d.ts +3 -0
- package/dist/machine-engine/inspect-runtime.js +48 -0
- package/dist/machine-engine/knowledge-assets.d.ts +79 -0
- package/dist/machine-engine/knowledge-assets.js +505 -0
- package/dist/memory/embed-core.d.ts +131 -0
- package/dist/memory/embed-core.js +398 -0
- package/dist/memory/embed-worker.d.ts +23 -0
- package/dist/memory/embed-worker.js +49 -0
- package/dist/memory/embeddings.d.ts +105 -0
- package/dist/memory/embeddings.js +467 -0
- package/dist/model-profile/behaviors/tools-mlx-template-fix.d.ts +1 -0
- package/dist/model-profile/behaviors/tools-mlx-template-fix.js +2 -0
- package/dist/model-profile/behaviors/turn-ramble-detection.d.ts +1 -0
- package/dist/model-profile/behaviors/turn-ramble-detection.js +2 -0
- package/dist/model-profile/defaults.d.ts +1 -0
- package/dist/model-profile/defaults.js +2 -0
- package/dist/model-profile/index.d.ts +9 -0
- package/dist/model-profile/index.js +7 -0
- package/dist/model-profile/registry.d.ts +15 -0
- package/dist/model-profile/registry.js +18 -0
- package/dist/model-profile/runtime.d.ts +48 -0
- package/dist/model-profile/runtime.js +131 -0
- package/dist/model-profile/tool-grammar.d.ts +1 -0
- package/dist/model-profile/tool-grammar.js +2 -0
- package/dist/model-profile/tuning.d.ts +19 -0
- package/dist/model-profile/tuning.js +21 -0
- package/dist/model-profile/types.d.ts +1 -0
- package/dist/model-profile/types.js +2 -0
- package/dist/models/bundle-storage.d.ts +28 -0
- package/dist/models/bundle-storage.js +80 -0
- package/dist/models/catalog-drift.d.ts +48 -0
- package/dist/models/catalog-drift.js +94 -0
- package/dist/models/catalog-payload-identity.d.ts +107 -0
- package/dist/models/catalog-payload-identity.js +254 -0
- package/dist/models/ensure.d.ts +125 -0
- package/dist/models/ensure.js +205 -0
- package/dist/models/gezmodel.d.ts +47 -0
- package/dist/models/gezmodel.js +742 -0
- package/dist/models/install-registry.d.ts +101 -0
- package/dist/models/install-registry.js +216 -0
- package/dist/models/model-id.d.ts +22 -0
- package/dist/models/model-id.js +40 -0
- package/dist/models/storage-roots.d.ts +274 -0
- package/dist/models/storage-roots.js +865 -0
- package/dist/native-release-entry.d.ts +16 -0
- package/dist/native-release-entry.js +16 -0
- package/dist/perf/redact-path.d.ts +2 -0
- package/dist/perf/redact-path.js +27 -0
- package/dist/perf/responsiveness.d.ts +70 -0
- package/dist/perf/responsiveness.js +560 -0
- package/dist/providers/active-install-message.d.ts +42 -0
- package/dist/providers/active-install-message.js +49 -0
- package/dist/providers/apple-foundation-models/binary.d.ts +4 -0
- package/dist/providers/apple-foundation-models/binary.js +18 -0
- package/dist/providers/apple-foundation-models/helper.d.ts +66 -0
- package/dist/providers/apple-foundation-models/helper.js +262 -0
- package/dist/providers/apple-foundation-models/provider.d.ts +82 -0
- package/dist/providers/apple-foundation-models/provider.js +389 -0
- package/dist/providers/apple-foundation-models/status.d.ts +9 -0
- package/dist/providers/apple-foundation-models/status.js +49 -0
- package/dist/providers/audio/kokoro-frontend.d.ts +50 -0
- package/dist/providers/audio/kokoro-frontend.js +113 -0
- package/dist/providers/audio/managed-speech-model.d.ts +9 -0
- package/dist/providers/audio/managed-speech-model.js +86 -0
- package/dist/providers/audio/types.d.ts +202 -0
- package/dist/providers/audio/types.js +17 -0
- package/dist/providers/audio/whisper-cpp.d.ts +99 -0
- package/dist/providers/audio/whisper-cpp.js +420 -0
- package/dist/providers/audio-persister-contract.d.ts +13 -0
- package/dist/providers/audio-persister-contract.js +1 -0
- package/dist/providers/catalog-model-config.d.ts +63 -0
- package/dist/providers/catalog-model-config.js +95 -0
- package/dist/providers/code-block-salvage.d.ts +1 -0
- package/dist/providers/code-block-salvage.js +2 -0
- package/dist/providers/codex-cli/reasoning.d.ts +8 -0
- package/dist/providers/codex-cli/reasoning.js +17 -0
- package/dist/providers/complete-tool-call.d.ts +16 -0
- package/dist/providers/complete-tool-call.js +25 -0
- package/dist/providers/constrained-turn.d.ts +1 -0
- package/dist/providers/constrained-turn.js +2 -0
- package/dist/providers/deliverable-read-pacing.d.ts +1 -0
- package/dist/providers/deliverable-read-pacing.js +2 -0
- package/dist/providers/direct-file-work-prompt.d.ts +1 -0
- package/dist/providers/direct-file-work-prompt.js +2 -0
- package/dist/providers/ds4/build-provider.d.ts +100 -0
- package/dist/providers/ds4/build-provider.js +539 -0
- package/dist/providers/ds4/cache-adapter.d.ts +42 -0
- package/dist/providers/ds4/cache-adapter.js +110 -0
- package/dist/providers/ds4/dspark.d.ts +83 -0
- package/dist/providers/ds4/dspark.js +107 -0
- package/dist/providers/ds4/mtp.d.ts +32 -0
- package/dist/providers/ds4/mtp.js +46 -0
- package/dist/providers/ds4/provider.d.ts +67 -0
- package/dist/providers/ds4/provider.js +104 -0
- package/dist/providers/ds4/residency.d.ts +143 -0
- package/dist/providers/ds4/residency.js +139 -0
- package/dist/providers/ds4/stdout-parser.d.ts +47 -0
- package/dist/providers/ds4/stdout-parser.js +134 -0
- package/dist/providers/duplicate-tool-calls.d.ts +1 -0
- package/dist/providers/duplicate-tool-calls.js +2 -0
- package/dist/providers/file-repair-policy.d.ts +1 -0
- package/dist/providers/file-repair-policy.js +2 -0
- package/dist/providers/gpu-arbiter.d.ts +159 -0
- package/dist/providers/gpu-arbiter.js +301 -0
- package/dist/providers/image-persister-contract.d.ts +15 -0
- package/dist/providers/image-persister-contract.js +1 -0
- package/dist/providers/llama-cpp/build-metadata.d.ts +13 -0
- package/dist/providers/llama-cpp/build-metadata.js +39 -0
- package/dist/providers/llama-cpp/build-provider.d.ts +66 -0
- package/dist/providers/llama-cpp/build-provider.js +1491 -0
- package/dist/providers/llama-cpp/cache-adapter.d.ts +242 -0
- package/dist/providers/llama-cpp/cache-adapter.js +689 -0
- package/dist/providers/llama-cpp/devices.d.ts +130 -0
- package/dist/providers/llama-cpp/devices.js +256 -0
- package/dist/providers/llama-cpp/disk-cache.d.ts +8 -0
- package/dist/providers/llama-cpp/disk-cache.js +112 -0
- package/dist/providers/llama-cpp/engine-flags.d.ts +181 -0
- package/dist/providers/llama-cpp/engine-flags.js +279 -0
- package/dist/providers/llama-cpp/gguf-metadata-async.d.ts +11 -0
- package/dist/providers/llama-cpp/gguf-metadata-async.js +180 -0
- package/dist/providers/llama-cpp/gguf-metadata-worker.d.ts +7 -0
- package/dist/providers/llama-cpp/gguf-metadata-worker.js +27 -0
- package/dist/providers/llama-cpp/gguf-metadata.d.ts +198 -0
- package/dist/providers/llama-cpp/gguf-metadata.js +615 -0
- package/dist/providers/llama-cpp/gguf-quantization-backfill.d.ts +32 -0
- package/dist/providers/llama-cpp/gguf-quantization-backfill.js +75 -0
- package/dist/providers/llama-cpp/index.d.ts +10 -0
- package/dist/providers/llama-cpp/index.js +13 -0
- package/dist/providers/llama-cpp/kv-cache-type.d.ts +118 -0
- package/dist/providers/llama-cpp/kv-cache-type.js +140 -0
- package/dist/providers/llama-cpp/log.d.ts +92 -0
- package/dist/providers/llama-cpp/log.js +280 -0
- package/dist/providers/llama-cpp/models.d.ts +389 -0
- package/dist/providers/llama-cpp/models.js +1011 -0
- package/dist/providers/llama-cpp/offload-planner.d.ts +339 -0
- package/dist/providers/llama-cpp/offload-planner.js +524 -0
- package/dist/providers/llama-cpp/provider.d.ts +493 -0
- package/dist/providers/llama-cpp/provider.js +862 -0
- package/dist/providers/llama-cpp/reasoning-launch.d.ts +7 -0
- package/dist/providers/llama-cpp/reasoning-launch.js +14 -0
- package/dist/providers/llama-cpp/spec-draft.d.ts +44 -0
- package/dist/providers/llama-cpp/spec-draft.js +44 -0
- package/dist/providers/llama-cpp/stdout-parser.d.ts +70 -0
- package/dist/providers/llama-cpp/stdout-parser.js +197 -0
- package/dist/providers/local-tool-call-salvage.d.ts +1 -0
- package/dist/providers/local-tool-call-salvage.js +2 -0
- package/dist/providers/local-turn-policy.d.ts +1 -0
- package/dist/providers/local-turn-policy.js +2 -0
- package/dist/providers/mlx/async-file-handoff.d.ts +11 -0
- package/dist/providers/mlx/async-file-handoff.js +25 -0
- package/dist/providers/mlx/build-provider.d.ts +65 -0
- package/dist/providers/mlx/build-provider.js +717 -0
- package/dist/providers/mlx/cache-adapter.d.ts +161 -0
- package/dist/providers/mlx/cache-adapter.js +445 -0
- package/dist/providers/mlx/chat-protocol.d.ts +48 -0
- package/dist/providers/mlx/chat-protocol.js +8 -0
- package/dist/providers/mlx/config-parser.d.ts +29 -0
- package/dist/providers/mlx/config-parser.js +81 -0
- package/dist/providers/mlx/engine-gate.d.ts +51 -0
- package/dist/providers/mlx/engine-gate.js +207 -0
- package/dist/providers/mlx/engine-hold.d.ts +41 -0
- package/dist/providers/mlx/engine-hold.js +59 -0
- package/dist/providers/mlx/engine-log-router.d.ts +84 -0
- package/dist/providers/mlx/engine-log-router.js +137 -0
- package/dist/providers/mlx/engine-stats-sampler.d.ts +36 -0
- package/dist/providers/mlx/engine-stats-sampler.js +59 -0
- package/dist/providers/mlx/index.d.ts +10 -0
- package/dist/providers/mlx/index.js +10 -0
- package/dist/providers/mlx/log.d.ts +55 -0
- package/dist/providers/mlx/log.js +157 -0
- package/dist/providers/mlx/model-geometry.d.ts +35 -0
- package/dist/providers/mlx/model-geometry.js +118 -0
- package/dist/providers/mlx/models.d.ts +289 -0
- package/dist/providers/mlx/models.js +1079 -0
- package/dist/providers/mlx/provider.d.ts +404 -0
- package/dist/providers/mlx/provider.js +3017 -0
- package/dist/providers/mlx/python/cache_persist.py +348 -0
- package/dist/providers/mlx/python/cache_seed.py +402 -0
- package/dist/providers/mlx/python/gezel_mlx_server.py +4491 -0
- package/dist/providers/mlx/python/lfm2_compat.py +55 -0
- package/dist/providers/mlx/python/qwen3_5_text_compat.py +60 -0
- package/dist/providers/mlx/python/qwen4_ple.py +159 -0
- package/dist/providers/mlx/python/spec_decode.py +775 -0
- package/dist/providers/mlx/python/template_stability.py +133 -0
- package/dist/providers/mlx/python/think_budget.py +113 -0
- package/dist/providers/mlx/python/tool_args_json.py +297 -0
- package/dist/providers/mlx/python/tool_call_stream.py +265 -0
- package/dist/providers/mlx/python/tool_grammar.py +794 -0
- package/dist/providers/mlx/python/vision_inputs.py +64 -0
- package/dist/providers/mlx/python/wave_policy.py +147 -0
- package/dist/providers/mlx/reasoning-stream.d.ts +85 -0
- package/dist/providers/mlx/reasoning-stream.js +196 -0
- package/dist/providers/mlx/request-shape.d.ts +34 -0
- package/dist/providers/mlx/request-shape.js +42 -0
- package/dist/providers/mlx/runtime-diagnostics.d.ts +29 -0
- package/dist/providers/mlx/runtime-diagnostics.js +162 -0
- package/dist/providers/mlx/spec-drafter.d.ts +25 -0
- package/dist/providers/mlx/spec-drafter.js +73 -0
- package/dist/providers/mlx/stdout-parser.d.ts +60 -0
- package/dist/providers/mlx/stdout-parser.js +254 -0
- package/dist/providers/mlx/textual-tool-call-salvage.d.ts +46 -0
- package/dist/providers/mlx/textual-tool-call-salvage.js +396 -0
- package/dist/providers/mlx/tool-call-protocol.d.ts +65 -0
- package/dist/providers/mlx/tool-call-protocol.js +140 -0
- package/dist/providers/mlx/tool-call-stripper.d.ts +68 -0
- package/dist/providers/mlx/tool-call-stripper.js +153 -0
- package/dist/providers/mlx/tool-image-retention.d.ts +23 -0
- package/dist/providers/mlx/tool-image-retention.js +43 -0
- package/dist/providers/mlx/turn-telemetry.d.ts +22 -0
- package/dist/providers/mlx/turn-telemetry.js +45 -0
- package/dist/providers/mlx/venv.d.ts +91 -0
- package/dist/providers/mlx/venv.js +111 -0
- package/dist/providers/mlx/vision-mode.d.ts +92 -0
- package/dist/providers/mlx/vision-mode.js +187 -0
- package/dist/providers/mlx/vision.d.ts +2 -0
- package/dist/providers/mlx/vision.js +12 -0
- package/dist/providers/mlx/wire-transcript.d.ts +14 -0
- package/dist/providers/mlx/wire-transcript.js +43 -0
- package/dist/providers/native/capacity-broker.d.ts +812 -0
- package/dist/providers/native/capacity-broker.js +1058 -0
- package/dist/providers/native/capacity-budget.d.ts +133 -0
- package/dist/providers/native/capacity-budget.js +265 -0
- package/dist/providers/native/device-capacity-ledger.d.ts +31 -0
- package/dist/providers/native/device-capacity-ledger.js +196 -0
- package/dist/providers/native/device-capacity.d.ts +84 -0
- package/dist/providers/native/device-capacity.js +431 -0
- package/dist/providers/native/engine-api-key.d.ts +57 -0
- package/dist/providers/native/engine-api-key.js +72 -0
- package/dist/providers/native/engine-key.d.ts +18 -0
- package/dist/providers/native/engine-key.js +41 -0
- package/dist/providers/native/engine-router.d.ts +145 -0
- package/dist/providers/native/engine-router.js +191 -0
- package/dist/providers/native/gpu-panic-guard.d.ts +54 -0
- package/dist/providers/native/gpu-panic-guard.js +59 -0
- package/dist/providers/native/inference.d.ts +33 -0
- package/dist/providers/native/inference.js +49 -0
- package/dist/providers/native/local-engine-runtime.d.ts +412 -0
- package/dist/providers/native/local-engine-runtime.js +1714 -0
- package/dist/providers/native/measured-budget.d.ts +32 -0
- package/dist/providers/native/measured-budget.js +54 -0
- package/dist/providers/native/port.d.ts +11 -0
- package/dist/providers/native/port.js +29 -0
- package/dist/providers/native/provider-pool.d.ts +474 -0
- package/dist/providers/native/provider-pool.js +1166 -0
- package/dist/providers/native/supervisor.d.ts +503 -0
- package/dist/providers/native/supervisor.js +1466 -0
- package/dist/providers/observation-context.d.ts +16 -0
- package/dist/providers/observation-context.js +1 -0
- package/dist/providers/openai-compatible/sse.d.ts +1 -0
- package/dist/providers/openai-compatible/sse.js +2 -0
- package/dist/providers/patient-fetch.d.ts +36 -0
- package/dist/providers/patient-fetch.js +44 -0
- package/dist/providers/project-macro-loop-bail.d.ts +7 -0
- package/dist/providers/project-macro-loop-bail.js +21 -0
- package/dist/providers/prose-document-salvage.d.ts +1 -0
- package/dist/providers/prose-document-salvage.js +2 -0
- package/dist/providers/provider-disposal.d.ts +1 -0
- package/dist/providers/provider-disposal.js +2 -0
- package/dist/providers/queue.d.ts +15 -0
- package/dist/providers/queue.js +23 -0
- package/dist/providers/ramble-abort-message.d.ts +1 -0
- package/dist/providers/ramble-abort-message.js +2 -0
- package/dist/providers/ramble-detector.d.ts +1 -0
- package/dist/providers/ramble-detector.js +2 -0
- package/dist/providers/reasoning-depth.d.ts +1 -0
- package/dist/providers/reasoning-depth.js +2 -0
- package/dist/providers/required-input-reads.d.ts +1 -0
- package/dist/providers/required-input-reads.js +2 -0
- package/dist/providers/session-defaults.d.ts +13 -0
- package/dist/providers/session-defaults.js +49 -0
- package/dist/providers/session-hooks.d.ts +26 -0
- package/dist/providers/session-hooks.js +1 -0
- package/dist/providers/streaming-session.d.ts +1 -0
- package/dist/providers/streaming-session.js +2 -0
- package/dist/providers/terminal-tool-policy.d.ts +1 -0
- package/dist/providers/terminal-tool-policy.js +2 -0
- package/dist/providers/tool-arg-schema-coercion.d.ts +1 -0
- package/dist/providers/tool-arg-schema-coercion.js +2 -0
- package/dist/providers/tool-executor.d.ts +12 -0
- package/dist/providers/tool-executor.js +20 -0
- package/dist/providers/tool-failure-tracker.d.ts +1 -0
- package/dist/providers/tool-failure-tracker.js +2 -0
- package/dist/providers/tool-repeat-tracker.d.ts +1 -0
- package/dist/providers/tool-repeat-tracker.js +2 -0
- package/dist/providers/turn-timeout.d.ts +1 -0
- package/dist/providers/turn-timeout.js +1 -0
- package/dist/providers/types.d.ts +834 -0
- package/dist/providers/types.js +42 -0
- package/dist/providers/usage-builder.d.ts +1 -0
- package/dist/providers/usage-builder.js +2 -0
- package/dist/providers/vision-capability.d.ts +109 -0
- package/dist/providers/vision-capability.js +137 -0
- package/dist/python/mlx-runtime-status-bus.d.ts +39 -0
- package/dist/python/mlx-runtime-status-bus.js +27 -0
- package/dist/python/uv-runtime.d.ts +176 -0
- package/dist/python/uv-runtime.js +492 -0
- package/dist/relevance/install.d.ts +28 -0
- package/dist/relevance/install.js +114 -0
- package/dist/relevance/manager.d.ts +79 -0
- package/dist/relevance/manager.js +211 -0
- package/dist/relevance/registry.d.ts +11 -0
- package/dist/relevance/registry.js +158 -0
- package/dist/relevance/relevance-core.d.ts +67 -0
- package/dist/relevance/relevance-core.js +189 -0
- package/dist/relevance/relevance-model.d.ts +42 -0
- package/dist/relevance/relevance-model.js +292 -0
- package/dist/relevance/relevance-worker.d.ts +20 -0
- package/dist/relevance/relevance-worker.js +57 -0
- package/dist/relevance/settings.d.ts +30 -0
- package/dist/relevance/settings.js +81 -0
- package/dist/remotes/identity.d.ts +121 -0
- package/dist/remotes/identity.js +348 -0
- package/dist/remotes/pinned-fetch.d.ts +45 -0
- package/dist/remotes/pinned-fetch.js +193 -0
- package/dist/remotes/types.d.ts +54 -0
- package/dist/remotes/types.js +1 -0
- package/dist/runtime-lock.d.ts +29 -0
- package/dist/runtime-lock.js +85 -0
- package/dist/search/relevance-stage.d.ts +81 -0
- package/dist/search/relevance-stage.js +177 -0
- package/dist/secrets/types.d.ts +63 -0
- package/dist/secrets/types.js +64 -0
- package/dist/speech.d.ts +1 -0
- package/dist/speech.js +1 -0
- package/dist/system/darwin-memory.d.ts +26 -0
- package/dist/system/darwin-memory.js +81 -0
- package/dist/system/gezel-process-memory.d.ts +55 -0
- package/dist/system/gezel-process-memory.js +156 -0
- package/dist/system/memory.d.ts +154 -0
- package/dist/system/memory.js +501 -0
- package/dist/transformers-cache.d.ts +112 -0
- package/dist/transformers-cache.js +392 -0
- package/dist/utils/disk-space.d.ts +55 -0
- package/dist/utils/disk-space.js +72 -0
- package/dist/utils/download-common.d.ts +138 -0
- package/dist/utils/download-common.js +196 -0
- package/dist/utils/download-with-retry.d.ts +85 -0
- package/dist/utils/download-with-retry.js +522 -0
- package/dist/utils/extract-zip.d.ts +22 -0
- package/dist/utils/extract-zip.js +103 -0
- package/dist/utils/retire-model-worker.d.ts +15 -0
- package/dist/utils/retire-model-worker.js +24 -0
- package/dist/utils/service-worker-entry.d.ts +59 -0
- package/dist/utils/service-worker-entry.js +69 -0
- package/dist/utils/xet-download.d.ts +40 -0
- package/dist/utils/xet-download.js +526 -0
- package/dist/utils/xet.d.ts +86 -0
- package/dist/utils/xet.js +201 -0
- package/package.json +103 -3
- package/scripts/embedding-packaging.cjs +264 -0
- package/scripts/embedding-packaging.d.cts +16 -0
|
@@ -0,0 +1,3017 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MlxProvider — talks to a local `mlx_lm.server` subprocess over HTTP.
|
|
3
|
+
* The server speaks OpenAI-compatible `/v1/chat/completions` with SSE
|
|
4
|
+
* streaming; tool calling rides the standard OpenAI function-tool
|
|
5
|
+
* shape, same as llama-cpp.
|
|
6
|
+
*
|
|
7
|
+
* Platform: **darwin-arm64 only**. Apple Silicon's unified memory +
|
|
8
|
+
* Metal Performance Shaders are what make MLX fast; there's no MLX
|
|
9
|
+
* backend for Intel Mac, Linux, or Windows. `ChatManager.ensureProvider`
|
|
10
|
+
* gates the branch on platform; this file assumes Apple Silicon.
|
|
11
|
+
*
|
|
12
|
+
* Session model is stateless (same as llama-cpp / Ollama): full
|
|
13
|
+
* transcript lives in memory and is re-sent per turn.
|
|
14
|
+
* `ProviderSessionState` is empty — the session record on disk is the
|
|
15
|
+
* source of truth.
|
|
16
|
+
*
|
|
17
|
+
* Lifecycle of the mlx_lm.server process is managed by a
|
|
18
|
+
* {@link NativeEngineSupervisor}: lazy-start on first turn, idle-stop,
|
|
19
|
+
* health-watch, restart budget. Startup is usually slower than
|
|
20
|
+
* llama-cpp (Python import + PyTorch/MLX init) so we give it a 180s
|
|
21
|
+
* startup budget.
|
|
22
|
+
*
|
|
23
|
+
* First-time install of mlx-lm itself is handled outside this file —
|
|
24
|
+
* `buildMlxProvider` in ChatManager calls `UvRuntime.ensureVenv('mlx',
|
|
25
|
+
* [mlxLmSpec])` before spawning the supervisor, ensuring the venv +
|
|
26
|
+
* packages exist.
|
|
27
|
+
*/
|
|
28
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
29
|
+
import { randomUUID } from 'node:crypto';
|
|
30
|
+
import { AwakeBudget, autoCompactedNotice, createAwakeTimeout, createLogger, leaksUntaggedReasoning, turnCancelledMessage, } from '@bendyline/gezel';
|
|
31
|
+
import { leadingSystemMessages, standaloneTurnMessages, toolSurfaceSize, } from '@bendyline/gezel/local-loop';
|
|
32
|
+
import { computeToolBudgetChars } from '@bendyline/gezel/local-loop';
|
|
33
|
+
import { extractReasoningWithProfile, profileBehaviorConfig, profileHasBehavior, } from '../../model-profile/runtime.js';
|
|
34
|
+
import { prepareSalvagedCodeBlocks } from '../code-block-salvage.js';
|
|
35
|
+
import { hasCompleteToolCallMarkup } from '../complete-tool-call.js';
|
|
36
|
+
import { DeliverableReadPaceTracker } from '../deliverable-read-pacing.js';
|
|
37
|
+
import { collapseDuplicateToolCalls } from '../duplicate-tool-calls.js';
|
|
38
|
+
import { appendCapTruncationHintToRejectedWrite, appendTruncationHintToToolResult, buildUnknownToolNudge, describeMalformation, findTruncatedProseToolCall, findUnrecognizedFunctionMarkup, findUnrecognizedToolEnvelope, foldPreToolPreamble, formatToolMenu, isWriteShapedToolName, parseGemmaNativeToolCall, parseGemmaToolCall, uniqueWantedToolNames, } from '../local-tool-call-salvage.js';
|
|
39
|
+
import { readSseEvents } from '../openai-compatible/sse.js';
|
|
40
|
+
import { PROJECT_MACRO_INTERCEPT_CAP, deriveProjectMacroClosing, } from '../project-macro-loop-bail.js';
|
|
41
|
+
import { prepareSalvagedProseDocument } from '../prose-document-salvage.js';
|
|
42
|
+
import { ProviderDisposedError, runOnLiveProvider } from '../provider-disposal.js';
|
|
43
|
+
import { ProviderQueue, backgroundLaneCap, defaultAmbientQuietMs } from '../queue.js';
|
|
44
|
+
import { buildRambleAbortMessage } from '../ramble-abort-message.js';
|
|
45
|
+
import { RambleDetector, inertRambleDetector, outputIsConstrained } from '../ramble-detector.js';
|
|
46
|
+
import { downgradeReasoningDepthKwargs } from '../reasoning-depth.js';
|
|
47
|
+
import { promptReadInputs, requiredInputsRead, unreadRequiredInputs, } from '../required-input-reads.js';
|
|
48
|
+
import { StreamingSessionBase, } from '../streaming-session.js';
|
|
49
|
+
import { DeliverableReadySteer, TERMINAL_ACTION_SKIPPED_OUTPUT, terminalToolClosingText, } from '../terminal-tool-policy.js';
|
|
50
|
+
import { coerceToolCallArgs } from '../tool-arg-schema-coercion.js';
|
|
51
|
+
import { createToolExecutor } from '../tool-executor.js';
|
|
52
|
+
import { ToolFailureTracker } from '../tool-failure-tracker.js';
|
|
53
|
+
import { ToolRepeatTracker } from '../tool-repeat-tracker.js';
|
|
54
|
+
import { asyncHandoffClosing, immediateFileWriteClosing, isSuccessfulAsyncHandoff, } from './async-file-handoff.js';
|
|
55
|
+
export { isSuccessfulAsyncHandoff };
|
|
56
|
+
import { setChatTemplateKwarg, } from './chat-protocol.js';
|
|
57
|
+
import { MlxEngineGate } from './engine-gate.js';
|
|
58
|
+
import { EngineHold, engineHoldLabelFor } from './engine-hold.js';
|
|
59
|
+
import { EngineLogRouter } from './engine-log-router.js';
|
|
60
|
+
import { MlxEngineStatsSampler } from './engine-stats-sampler.js';
|
|
61
|
+
import { StreamingReasoningSplit, requestOpensReasoning, } from './reasoning-stream.js';
|
|
62
|
+
import { applyMlxRequestShape } from './request-shape.js';
|
|
63
|
+
import { PRE_FIRST_BYTE_BASE_MS, buildMidStreamDropMessage, buildPreFirstByteAbortMessage, computePreFirstByteBudgetMs, estimatePromptTokens, formatTps, isMidStreamConnectionDrop, translateMlxHttpError, } from './runtime-diagnostics.js';
|
|
64
|
+
import { classifyMlxFatalErrorLine, classifyMlxStartupLine, } from './stdout-parser.js';
|
|
65
|
+
import { salvageTextualToolCalls } from './textual-tool-call-salvage.js';
|
|
66
|
+
import { APPEND_TO_FILE_CONTINUATION_TOOL, MlxToolCallAccumulator, applyCallableToolGrammar, callableRequestToolNames, chatCompletionToolName, externalToolsAsChatCompletions, hermesRequiredArgGrammarRequested, missingTopLevelRequiredToolArgs, toChatCompletionsTools, validatorReportedMissingRequiredArgs, } from './tool-call-protocol.js';
|
|
67
|
+
import { LeakyToolCallStripper } from './tool-call-stripper.js';
|
|
68
|
+
import { TOOL_IMAGES_MESSAGE, retireInspectedToolImages } from './tool-image-retention.js';
|
|
69
|
+
import { buildMlxTerminalTelemetry } from './turn-telemetry.js';
|
|
70
|
+
import { MlxVisionMode, unseenToolImagesNote } from './vision-mode.js';
|
|
71
|
+
import { mlxWireTranscript } from './wire-transcript.js';
|
|
72
|
+
export { buildMidStreamDropMessage, computePreFirstByteBudgetMs, estimatePromptTokens, isMidStreamConnectionDrop, };
|
|
73
|
+
import { FILE_REPAIR_MUTATION_TOOLS, FILE_REPAIR_READ_TOOLS, applyConstrainedTurnShape, disableTemplateThinking, fileRepairTargetPath, isSourceFileRepairPrompt, requiredToolOnly, writeFileOnlyTools, } from '../constrained-turn.js';
|
|
74
|
+
import { hasExplicitFullFileRewriteWording } from '../direct-file-work-prompt.js';
|
|
75
|
+
import { buildImmediateFileWriteNoMutationNudge, buildPrerequisiteRepairReadNudge, buildScenarioRepairNoMutationNudge, completeWorkspaceReadPaths, extractPrerequisiteRepairReadPaths, normalizeWorkspacePathForCompare, remainingPrerequisiteRepairReadPaths, } from '../file-repair-policy.js';
|
|
76
|
+
import { LOCAL_TURN_LIMITS, LocalTurnPolicy, compactLocalTurn, planFileTurn, repairTools, turnCheckpoint, } from '../local-turn-policy.js';
|
|
77
|
+
const MAX_TOOL_LOOP_TURNS = LOCAL_TURN_LIMITS.iterations;
|
|
78
|
+
// Hard per-turn ceiling when the caller doesn't pass `opts.timeoutMs`.
|
|
79
|
+
// 10 minutes matches what the manager passes for local providers in
|
|
80
|
+
// short-form sessions; the original 3-minute floor was too tight for
|
|
81
|
+
// 26B+ models doing heavy reasoning. Manager calls always override
|
|
82
|
+
// this with `OLLAMA_TURN_TIMEOUT_MS` (30 min); the constant here is
|
|
83
|
+
// only the fallback for one-shot / test paths.
|
|
84
|
+
const DEFAULT_TIMEOUT_MS = 600_000;
|
|
85
|
+
// Fallback for direct/external provider construction where no catalog model
|
|
86
|
+
// metadata is available. Supervised catalog models pass their native context
|
|
87
|
+
// window explicitly from buildMlxProvider.
|
|
88
|
+
const DEFAULT_NUM_CTX = 65_536;
|
|
89
|
+
// File-producing turns need enough room for the tool arguments themselves.
|
|
90
|
+
// The Developer template selects `thinking-coding`, whose normal 2,048-token
|
|
91
|
+
// budget truncated single-file HTML games mid-script. Treat 4,096 as a floor,
|
|
92
|
+
// matching llama.cpp's immediate-write path; user/model tuning may still grant
|
|
93
|
+
// more. This is intentionally not a cap.
|
|
94
|
+
const FILE_WRITE_MIN_TOKENS = 4_096;
|
|
95
|
+
// Steers issued per send after a cap-truncated payload mutation was
|
|
96
|
+
// refused. Two is enough to convert "re-emit the whole file" into
|
|
97
|
+
// "make targeted edits"; beyond that the model is not reading the
|
|
98
|
+
// steer and more attempts only burn engine time at full context.
|
|
99
|
+
const MAX_CAP_TRUNCATION_STEERS = LOCAL_TURN_LIMITS.capTruncation;
|
|
100
|
+
// Max automatic append_to_file continuations after an immediate-write
|
|
101
|
+
// truncation before we bail with the partial. Each continuation is one
|
|
102
|
+
// bounded turn that writes the next chunk — comfortably above any
|
|
103
|
+
// single-file scenario deliverable while preventing a runaway. Replaces
|
|
104
|
+
// the old "bail after one truncated write and hope the next turn appends"
|
|
105
|
+
// path: the weak model had no append_to_file in its surface and just rambled
|
|
106
|
+
// instead (wild-caught tankcombat: a 5.8 KB game truncated at the former
|
|
107
|
+
// short output budget and the model never continued it).
|
|
108
|
+
const MAX_IMMEDIATE_WRITE_CONTINUATIONS = LOCAL_TURN_LIMITS.writeContinuations;
|
|
109
|
+
const IMMEDIATE_FILE_WRITE_PROMPT_SUFFIX = '\n\n[Local-model rescue: make this a compact first pass. Your entire visible output should be one `write_file` tool call. Prioritize a complete, runnable file over decorative extras; include every requested behavior and any named asset path. Do not include planning prose.]';
|
|
110
|
+
const log = createLogger('mlx');
|
|
111
|
+
/**
|
|
112
|
+
* Cap on how many times we'll nudge the model to retry a malformed
|
|
113
|
+
* tool call within a single turn before giving up. Two attempts is
|
|
114
|
+
* enough that a model with a transient slip can correct itself, low
|
|
115
|
+
* enough that a model that just can't form structured calls (small
|
|
116
|
+
* quants, certain Gemma variants) doesn't burn the whole turn budget.
|
|
117
|
+
*/
|
|
118
|
+
const MAX_MALFORMED_RETRIES = LOCAL_TURN_LIMITS.malformed;
|
|
119
|
+
export class MlxProvider {
|
|
120
|
+
name = 'mlx';
|
|
121
|
+
visionMode;
|
|
122
|
+
queue;
|
|
123
|
+
supportsExternalTools = true;
|
|
124
|
+
supportsPriorMessages = true;
|
|
125
|
+
supervisor;
|
|
126
|
+
externalBaseUrl;
|
|
127
|
+
defaultModel;
|
|
128
|
+
numCtx;
|
|
129
|
+
plannedReservation;
|
|
130
|
+
fetchImpl;
|
|
131
|
+
/** Set in {@link shutdown}; a disposed provider must never respawn its engine. */
|
|
132
|
+
disposed = false;
|
|
133
|
+
/**
|
|
134
|
+
* Catalog manager — when set, `listModels()` enumerates every model
|
|
135
|
+
* the user has installed via Settings → This Mac so the model picker
|
|
136
|
+
* in Settings → AI can pick from the full list. Optional because
|
|
137
|
+
* external-baseUrl mode (`GEZEL_MLX_SERVER_URL`) skips the catalog
|
|
138
|
+
* entirely. The picker selection writes back to
|
|
139
|
+
* `config.defaultModel.mlx` as a catalog id; `buildMlxProvider`
|
|
140
|
+
* resolves the id to a `modelDir` on the next provider rebuild.
|
|
141
|
+
*/
|
|
142
|
+
modelManager;
|
|
143
|
+
/**
|
|
144
|
+
* Friendly model name for the engine pill — e.g. "Qwen 3.5 8B".
|
|
145
|
+
* Surfaced in `loading_model` phase events as "Loading model
|
|
146
|
+
* (<name>)" so the user knows *which* MLX model is loading. Empty
|
|
147
|
+
* when the catalog manager wasn't wired in (external base URL mode)
|
|
148
|
+
* or when the runtime path doesn't map to a catalog entry — the
|
|
149
|
+
* pill falls back to the bare "Loading model" label in those cases.
|
|
150
|
+
*/
|
|
151
|
+
modelDisplayName;
|
|
152
|
+
/**
|
|
153
|
+
* Catalog model id for the on-disk model the provider is serving —
|
|
154
|
+
* `qwen3.6-27b-mlx`, `gemma4-e4b-mlx`, etc. Distinct from
|
|
155
|
+
* `defaultModel`, which holds the absolute model directory path
|
|
156
|
+
* (mlx_vlm.server's PRELOAD_MODEL value). Surfaced via
|
|
157
|
+
* {@link getEffectiveModelId} so the chat manager can resolve a
|
|
158
|
+
* tier for sessions that don't have an explicit model selection
|
|
159
|
+
* (`record.model` undefined AND `config.defaultModel.mlx`
|
|
160
|
+
* undefined). Without this, those sessions classify as
|
|
161
|
+
* `tier:tiny` because the tier resolver gets a path it can't parse.
|
|
162
|
+
*
|
|
163
|
+
* Empty when the provider was constructed in external-baseUrl mode
|
|
164
|
+
* (we don't know what the user's mlx-server is serving) or before
|
|
165
|
+
* the catalog has resolved a default model.
|
|
166
|
+
*/
|
|
167
|
+
catalogModelId;
|
|
168
|
+
/** See the constructor option of the same name. */
|
|
169
|
+
templateOpensReasoning;
|
|
170
|
+
activeSessions = new Set();
|
|
171
|
+
/**
|
|
172
|
+
* Engine stdout → per-session phase events, plus the "is this line fatal?"
|
|
173
|
+
* state machine. See {@link EngineLogRouter}; the rules there are load-bearing.
|
|
174
|
+
*/
|
|
175
|
+
logRouter = new EngineLogRouter({
|
|
176
|
+
modelDisplayName: () => this.modelDisplayName,
|
|
177
|
+
// A tagged phase belongs to exactly one session; anything untagged is an
|
|
178
|
+
// engine-wide signal every session should see. Without this, a session
|
|
179
|
+
// streaming its reply picks up a neighbour's prefill bar — at the
|
|
180
|
+
// neighbour's token total — because one engine has one stdout.
|
|
181
|
+
deliver: (phase) => {
|
|
182
|
+
// Engine-wide record of who the engine was last seen working FOR.
|
|
183
|
+
// A session that never gets a first byte cannot tell "the engine is
|
|
184
|
+
// sick" from "the engine is busy with someone else" from inside its
|
|
185
|
+
// own request — this is the only place both are visible.
|
|
186
|
+
if (phase.cacheId && phase.phase === 'prefill' && !phase.engineQueue) {
|
|
187
|
+
this.lastTaggedPrefill = {
|
|
188
|
+
cacheId: phase.cacheId,
|
|
189
|
+
detail: phase.detail ?? '',
|
|
190
|
+
at: Date.now(),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
for (const s of this.activeSessions) {
|
|
194
|
+
if (phase.cacheId && !s.ownsCacheId(phase.cacheId))
|
|
195
|
+
continue;
|
|
196
|
+
s.publishEnginePhase(phase);
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
onFatal: (fatal) => {
|
|
200
|
+
// If a session is blocked inside `supervisor.ensureRunning()` waiting on
|
|
201
|
+
// `/health`, abortStartup rejects that wait with the real error now
|
|
202
|
+
// instead of letting it burn the full startup budget (up to 5 minutes);
|
|
203
|
+
// the session-level catch turns it into the red bubble in chat.
|
|
204
|
+
const hintSuffix = fatal.hint ? `\n\n${fatal.hint}` : '';
|
|
205
|
+
const err = new Error(`[mlx] engine failed to start: ${fatal.message}${hintSuffix}`);
|
|
206
|
+
this.supervisor?.abortStartup(err);
|
|
207
|
+
// Tear the child down so the next `ensureRunning()` respawns fresh.
|
|
208
|
+
void this.supervisor?.stop().catch(() => { });
|
|
209
|
+
},
|
|
210
|
+
onReady: () => {
|
|
211
|
+
if (this.supervisor)
|
|
212
|
+
this.engineStats.schedule();
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
engineStats = new MlxEngineStatsSampler({
|
|
216
|
+
pid: () => this.supervisor?.currentChildPid(),
|
|
217
|
+
publish: (stats) => {
|
|
218
|
+
for (const s of this.activeSessions)
|
|
219
|
+
s.publishEngineStats(stats);
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
/** Width-N gate over physical engine requests; see {@link MlxEngineGate}. */
|
|
223
|
+
engineGate;
|
|
224
|
+
batchMaxConcurrency;
|
|
225
|
+
turnProtection = new AsyncLocalStorage();
|
|
226
|
+
constructor(opts) {
|
|
227
|
+
if (!opts.supervisor && !opts.baseUrl) {
|
|
228
|
+
throw new Error('[mlx] need either a supervisor or baseUrl');
|
|
229
|
+
}
|
|
230
|
+
if (opts.supervisor && opts.baseUrl) {
|
|
231
|
+
throw new Error('[mlx] supervisor and baseUrl are mutually exclusive');
|
|
232
|
+
}
|
|
233
|
+
if (opts.supervisor)
|
|
234
|
+
this.supervisor = opts.supervisor;
|
|
235
|
+
if (opts.baseUrl)
|
|
236
|
+
this.externalBaseUrl = opts.baseUrl.replace(/\/+$/, '');
|
|
237
|
+
this.defaultModel = opts.defaultModel ?? 'mlx';
|
|
238
|
+
this.numCtx = opts.numCtx ?? DEFAULT_NUM_CTX;
|
|
239
|
+
this.plannedReservation = opts.plannedReservationBytes;
|
|
240
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
241
|
+
if (opts.modelManager)
|
|
242
|
+
this.modelManager = opts.modelManager;
|
|
243
|
+
if (opts.modelDisplayName)
|
|
244
|
+
this.modelDisplayName = opts.modelDisplayName;
|
|
245
|
+
if (opts.catalogModelId)
|
|
246
|
+
this.catalogModelId = opts.catalogModelId;
|
|
247
|
+
if (opts.templateOpensReasoning)
|
|
248
|
+
this.templateOpensReasoning = opts.templateOpensReasoning;
|
|
249
|
+
const batchMax = Math.max(1, opts.batchMaxConcurrency ?? 1);
|
|
250
|
+
this.batchMaxConcurrency = batchMax;
|
|
251
|
+
this.engineGate = new MlxEngineGate(batchMax);
|
|
252
|
+
this.visionMode = opts.vision ?? MlxVisionMode.fixed(opts.visionEnabled === true);
|
|
253
|
+
this.visionMode.bindEngine(this.supervisor, this.engineGate, () => this.disposed);
|
|
254
|
+
// Interactive turns are capped at the memory-safe engine width (`batchMax`);
|
|
255
|
+
// the engine gate below (`acquireExclusiveEngineRequest`) enforces the same
|
|
256
|
+
// bound on real generation. The queue itself, though, must run at least ONE
|
|
257
|
+
// slot ABOVE that cap, reserved for the background lane — otherwise a
|
|
258
|
+
// mid-turn one-shot pinned to THIS provider (in-flight compaction, memory
|
|
259
|
+
// extraction, summarization; all `lane: 'background'`) can't dispatch while
|
|
260
|
+
// the foreground turn awaits it. At width 1 without the reserve, the turn
|
|
261
|
+
// holds the only slot and its own compaction one-shot deadlocks behind it
|
|
262
|
+
// forever: the turn awaits a job that can't start, and the one-shot's
|
|
263
|
+
// timeout only arms AFTER it acquires a slot. That is exactly what wedged
|
|
264
|
+
// sessions "mid-turn" once the memory ceiling clamped a big model to width 1.
|
|
265
|
+
// The +1 admits the chore WITHOUT ever running a 2nd concurrent generation —
|
|
266
|
+
// the engine gate, not queue width, is the OOM guard.
|
|
267
|
+
const interactiveConcurrency = batchMax;
|
|
268
|
+
const queueConcurrency = Math.max(opts.concurrency ?? 1, interactiveConcurrency + 1);
|
|
269
|
+
this.queue = new ProviderQueue({
|
|
270
|
+
concurrency: queueConcurrency,
|
|
271
|
+
interactiveConcurrency,
|
|
272
|
+
// Keyed to the engine gate's width (`batchMax`), not to the queue's
|
|
273
|
+
// deadlock reserve above — see `backgroundLaneCap`.
|
|
274
|
+
backgroundConcurrency: backgroundLaneCap(batchMax),
|
|
275
|
+
...(opts.affinity !== undefined ? { affinity: opts.affinity } : {}),
|
|
276
|
+
// Single local GPU — same ambient admission control as llama-cpp:
|
|
277
|
+
// housekeeping turns wait for a quiet window so they never wedge
|
|
278
|
+
// the lane right before the user's next message.
|
|
279
|
+
ambientQuietMs: defaultAmbientQuietMs(),
|
|
280
|
+
// The sidecar parks background waves for a waiting person, so a
|
|
281
|
+
// person's turn may pass task steps that fill every slot — the
|
|
282
|
+
// engine gate has the matching overflow (engine-gate.ts).
|
|
283
|
+
engineOverflow: true,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Batch capability advertised by the wrapped MLX BatchGenerator.
|
|
288
|
+
* See {@link LLMProvider.batch}.
|
|
289
|
+
*/
|
|
290
|
+
get batch() {
|
|
291
|
+
return { maxConcurrency: this.batchMaxConcurrency };
|
|
292
|
+
}
|
|
293
|
+
/** Can see images now, or after one reload into the vision tower. */
|
|
294
|
+
get supportsImageInput() {
|
|
295
|
+
return this.visionMode.capable;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Cache adapter (Phase 1+). Set by ChatManager after construction.
|
|
299
|
+
* Phase 2 wires the real `MlxCacheAdapter` that talks to the
|
|
300
|
+
* wrapped `gezel_mlx_server.py`. Sessions call its
|
|
301
|
+
* `buildRequestExtras` on every send to derive `cache_id`.
|
|
302
|
+
*/
|
|
303
|
+
cacheAdapter = null;
|
|
304
|
+
setCacheAdapter(adapter) {
|
|
305
|
+
this.cacheAdapter = adapter;
|
|
306
|
+
}
|
|
307
|
+
getCacheAdapter() {
|
|
308
|
+
return this.cacheAdapter;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Current engine base URL — `null` when the supervised engine
|
|
312
|
+
* hasn't started yet. Cache adapter polls treat null as "not
|
|
313
|
+
* ready" and return empty stats.
|
|
314
|
+
*/
|
|
315
|
+
currentBaseUrl() {
|
|
316
|
+
if (this.externalBaseUrl)
|
|
317
|
+
return this.externalBaseUrl;
|
|
318
|
+
return this.supervisor?.currentBaseUrl() ?? null;
|
|
319
|
+
}
|
|
320
|
+
lastTaggedPrefill = null;
|
|
321
|
+
/**
|
|
322
|
+
* The most recent prefill this engine ran for a DIFFERENT session, if it
|
|
323
|
+
* happened after `sinceMs`. Proof that the engine was alive and working —
|
|
324
|
+
* just not on the caller's turn.
|
|
325
|
+
*/
|
|
326
|
+
prefillForOtherSessionSince(ownCacheId, sinceMs) {
|
|
327
|
+
const last = this.lastTaggedPrefill;
|
|
328
|
+
if (!last || last.at < sinceMs)
|
|
329
|
+
return null;
|
|
330
|
+
if (ownCacheId !== null && last.cacheId === ownCacheId)
|
|
331
|
+
return null;
|
|
332
|
+
return { detail: last.detail, at: last.at };
|
|
333
|
+
}
|
|
334
|
+
/** True only while this sidecar is serving or queuing a physical request. */
|
|
335
|
+
isEngineBusy() {
|
|
336
|
+
return this.engineGate.busy;
|
|
337
|
+
}
|
|
338
|
+
/** Hold startup demand through the first request, before the engine gate exists. */
|
|
339
|
+
async withEngineTurn(run, signal) {
|
|
340
|
+
if (!this.supervisor?.coordinatesCapacity || this.turnProtection.getStore())
|
|
341
|
+
return run();
|
|
342
|
+
return this.supervisor.withRequest(() => this.turnProtection.run(true, run), signal);
|
|
343
|
+
}
|
|
344
|
+
engineLifecycleSnapshot() {
|
|
345
|
+
return this.supervisor?.lifecycleSnapshot();
|
|
346
|
+
}
|
|
347
|
+
async acquireExclusiveEngineRequest(label, signal, onWait, capacityProtected = false, priority = 'interactive') {
|
|
348
|
+
if (signal?.aborted)
|
|
349
|
+
throw new DOMException(`MLX engine request ${label} aborted`, 'AbortError');
|
|
350
|
+
if (this.supervisor?.coordinatesCapacity && !capacityProtected)
|
|
351
|
+
await this.supervisor.yieldForWaitingCapacity(signal);
|
|
352
|
+
return this.engineGate.acquire(label, signal, onWait, priority);
|
|
353
|
+
}
|
|
354
|
+
/** Cache warms through the adapter: nobody is waiting on them, so they queue as background. */
|
|
355
|
+
async runExclusiveEngineRequest(label, work) {
|
|
356
|
+
const release = await this.acquireExclusiveEngineRequest(label, undefined, undefined, false, 'background');
|
|
357
|
+
try {
|
|
358
|
+
return await work();
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
release();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
async initialize() {
|
|
365
|
+
// No-op — supervisor starts the child lazily on first sendAndWait.
|
|
366
|
+
}
|
|
367
|
+
getContextWindow() {
|
|
368
|
+
return this.numCtx;
|
|
369
|
+
}
|
|
370
|
+
/** See `LLMProvider.plannedReservationBytes`. */
|
|
371
|
+
plannedReservationBytes() {
|
|
372
|
+
return this.plannedReservation;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* True once {@link shutdown} has been called. A session whose stream dies
|
|
376
|
+
* reads this to tell "Gezel stopped this engine" from "the engine died on
|
|
377
|
+
* its own" — the two need different messages, and the flag is set before
|
|
378
|
+
* the stop so the racing turn always sees it.
|
|
379
|
+
*/
|
|
380
|
+
get isDisposed() {
|
|
381
|
+
return this.disposed;
|
|
382
|
+
}
|
|
383
|
+
async shutdown() {
|
|
384
|
+
// Poison FIRST so a turn racing the shutdown can't lazily respawn
|
|
385
|
+
// the engine after the stop below. The engine pool evicts replicas
|
|
386
|
+
// by calling `shutdown()`; a session still holding this instance
|
|
387
|
+
// must error out instead of resurrecting a zombie process outside
|
|
388
|
+
// the pool's capacity accounting.
|
|
389
|
+
this.disposed = true;
|
|
390
|
+
await this.supervisor?.stop();
|
|
391
|
+
}
|
|
392
|
+
async createSession(opts) {
|
|
393
|
+
if (this.disposed) {
|
|
394
|
+
throw new ProviderDisposedError('mlx');
|
|
395
|
+
}
|
|
396
|
+
const bridges = await createToolExecutor(opts, '[mlx]');
|
|
397
|
+
return new MlxSession({
|
|
398
|
+
resolveBaseUrl: () => this.resolveBaseUrl(),
|
|
399
|
+
markUsed: () => this.supervisor?.markUsed(),
|
|
400
|
+
fetchImpl: this.fetchImpl,
|
|
401
|
+
model: opts.model ?? this.defaultModel,
|
|
402
|
+
numCtx: this.numCtx,
|
|
403
|
+
systemMessage: opts.systemMessage,
|
|
404
|
+
...(opts.systemPromptLayers ? { systemPromptLayers: opts.systemPromptLayers } : {}),
|
|
405
|
+
...(opts.systemSharedPrefix ? { systemSharedPrefix: opts.systemSharedPrefix } : {}),
|
|
406
|
+
...(opts.volatileContext ? { volatileContext: opts.volatileContext } : {}),
|
|
407
|
+
// Pass widened priorMessages through — the session translates
|
|
408
|
+
// tool-role entries into ChatMessage tool_calls / role:'tool'
|
|
409
|
+
// entries in its constructor.
|
|
410
|
+
priorMessages: opts.priorMessages ?? [],
|
|
411
|
+
bridges,
|
|
412
|
+
queue: this.queue,
|
|
413
|
+
provider: this,
|
|
414
|
+
...(opts.externalTools && opts.externalTools.length > 0
|
|
415
|
+
? { externalTools: opts.externalTools }
|
|
416
|
+
: {}),
|
|
417
|
+
...(opts.debug ? { debug: opts.debug } : {}),
|
|
418
|
+
...(opts.requestCompaction ? { requestCompaction: opts.requestCompaction } : {}),
|
|
419
|
+
...(opts.profile ? { profile: opts.profile } : {}),
|
|
420
|
+
...(this.templateOpensReasoning
|
|
421
|
+
? { templateOpensReasoning: this.templateOpensReasoning }
|
|
422
|
+
: {}),
|
|
423
|
+
...(opts.activeCraftbookStep ? { activeCraftbookStep: opts.activeCraftbookStep } : {}),
|
|
424
|
+
...(opts.tuning ? { tuning: opts.tuning } : {}),
|
|
425
|
+
...(opts.forceDirectFileWork ? { forceDirectFileWork: true } : {}),
|
|
426
|
+
...(opts.singleToolCallTurn ? { singleToolCallTurn: true } : {}),
|
|
427
|
+
...(opts.terminalToolPolicy ? { terminalToolPolicy: opts.terminalToolPolicy } : {}),
|
|
428
|
+
...(opts.onWireTranscript ? { onWireTranscript: opts.onWireTranscript } : {}),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
/** Classifier hook wired into the supervisor's `onRawLine`. */
|
|
432
|
+
onStdoutLine(line) {
|
|
433
|
+
this.logRouter.onLine(line);
|
|
434
|
+
}
|
|
435
|
+
/** See {@link EngineLogRouter.takeFatalError}. */
|
|
436
|
+
takeFatalError() {
|
|
437
|
+
return this.logRouter.takeFatalError();
|
|
438
|
+
}
|
|
439
|
+
/** See {@link EngineLogRouter.takeRuntimeError}. */
|
|
440
|
+
takeRuntimeError() {
|
|
441
|
+
return this.logRouter.takeRuntimeError();
|
|
442
|
+
}
|
|
443
|
+
_registerActiveSession(session) {
|
|
444
|
+
this.activeSessions.add(session);
|
|
445
|
+
// Catch a late joiner up on the engine-wide phase only. A tagged phase
|
|
446
|
+
// belongs to one session's prefill; replaying it here is how a fresh chat
|
|
447
|
+
// would inherit a neighbour's progress bar.
|
|
448
|
+
const pending = this.logRouter.lastPhase();
|
|
449
|
+
if (pending && !pending.cacheId)
|
|
450
|
+
session.publishEnginePhase(pending);
|
|
451
|
+
const stats = this.engineStats.latest;
|
|
452
|
+
if (stats)
|
|
453
|
+
session.publishEngineStats(stats);
|
|
454
|
+
}
|
|
455
|
+
_deregisterActiveSession(session) {
|
|
456
|
+
this.activeSessions.delete(session);
|
|
457
|
+
}
|
|
458
|
+
async listModels() {
|
|
459
|
+
if (this.modelManager) {
|
|
460
|
+
try {
|
|
461
|
+
const installed = await this.modelManager.listInstalled();
|
|
462
|
+
if (installed.length > 0) {
|
|
463
|
+
return installed.map((m) => {
|
|
464
|
+
const sizeGb = m.approxSizeBytes / (1024 * 1024 * 1024);
|
|
465
|
+
const sizeLabel = sizeGb >= 0.1 ? ` · ${sizeGb.toFixed(1)} GB` : '';
|
|
466
|
+
const ctxLabel = m.contextWindow ? ` · ${Math.round(m.contextWindow / 1024)}k ctx` : '';
|
|
467
|
+
return {
|
|
468
|
+
id: m.id,
|
|
469
|
+
name: `${m.name}${sizeLabel}${ctxLabel}`,
|
|
470
|
+
supportsTools: true,
|
|
471
|
+
};
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
// Fall through to the default-only entry below — model
|
|
477
|
+
// enumeration is a UI nicety, not load-bearing for chat.
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return [
|
|
481
|
+
{
|
|
482
|
+
id: this.defaultModel,
|
|
483
|
+
name: this.defaultModel,
|
|
484
|
+
supportsTools: true,
|
|
485
|
+
},
|
|
486
|
+
];
|
|
487
|
+
}
|
|
488
|
+
getEffectiveModelId() {
|
|
489
|
+
return this.catalogModelId;
|
|
490
|
+
}
|
|
491
|
+
async resolveBaseUrl() {
|
|
492
|
+
if (this.externalBaseUrl)
|
|
493
|
+
return this.externalBaseUrl;
|
|
494
|
+
if (this.disposed) {
|
|
495
|
+
throw new Error('[mlx] provider disposed (engine was evicted) — re-resolve it');
|
|
496
|
+
}
|
|
497
|
+
if (!this.supervisor)
|
|
498
|
+
throw new Error('[mlx] no base URL resolver configured');
|
|
499
|
+
const launch = await this.supervisor.ensureRunning();
|
|
500
|
+
return launch.baseUrl.replace(/\/+$/, '');
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
class MlxSession extends StreamingSessionBase {
|
|
504
|
+
deps;
|
|
505
|
+
get supportsImageInput() {
|
|
506
|
+
return this.deps.provider.supportsImageInput;
|
|
507
|
+
}
|
|
508
|
+
messages;
|
|
509
|
+
/**
|
|
510
|
+
* Direct provider callers and ephemeral one-shot work do not always have a
|
|
511
|
+
* persisted chat-session id to put in `opts.queue.sessionId`. The wrapped
|
|
512
|
+
* MLX server treats a missing `cache_id` as an instruction to clear the KV
|
|
513
|
+
* cache after every request, which makes every tool-loop continuation pay a
|
|
514
|
+
* full re-prefill. Keep a stable id for this MlxSession as the fallback;
|
|
515
|
+
* normal chats still override it with their durable Gezel session id.
|
|
516
|
+
*/
|
|
517
|
+
fallbackCacheId = `mlx-session-${randomUUID()}`;
|
|
518
|
+
fallbackCacheUsed = false;
|
|
519
|
+
// Monotonic counter across `sendAndWait` calls on this session. Used as a
|
|
520
|
+
// tag in diagnostic logs so the per-turn lifecycle (queue dispatch → fetch
|
|
521
|
+
// → SSE start → SSE end) can be reassembled even when overlapping with
|
|
522
|
+
// other sessions in the daemon log. Helped diagnose mlx_vlm.server SSE
|
|
523
|
+
// termination issues where turn N completed cleanly but turn N+1 hung.
|
|
524
|
+
sendSeq = 0;
|
|
525
|
+
/** See LlamaCppSession.currentTurnStartIdx — same role here. */
|
|
526
|
+
currentTurnStartIdx = 0;
|
|
527
|
+
turnPolicy = new LocalTurnPolicy();
|
|
528
|
+
/**
|
|
529
|
+
* Aggregated `<think>` / `<reasoning>` text captured this turn,
|
|
530
|
+
* across every iteration of the tool loop. Reset at the top of each
|
|
531
|
+
* `sendAndWaitInner`. ChatManager reads it via
|
|
532
|
+
* `getLastTurnReasoning()` after the turn resolves and stashes the
|
|
533
|
+
* trace on the assistant message so the chat bubble can render it
|
|
534
|
+
* behind a collapsed expander.
|
|
535
|
+
*/
|
|
536
|
+
lastTurnReasoning = '';
|
|
537
|
+
/**
|
|
538
|
+
* Tool-call bodies the salvage layer couldn't parse on the most
|
|
539
|
+
* recent turn after the retry budget exhausted. Captured at the
|
|
540
|
+
* budget-exhausted branch and read by ChatManager via
|
|
541
|
+
* `getLastTurnAttemptedToolCalls()` to stash on the assistant
|
|
542
|
+
* message for the debug bundle. Reset at the top of `sendAndWaitInner`.
|
|
543
|
+
*/
|
|
544
|
+
lastTurnAttemptedToolCalls = [];
|
|
545
|
+
/**
|
|
546
|
+
* Watchdog reset hook owned by the in-flight `sendAndWaitInner`. Set
|
|
547
|
+
* just before the fetch fires; cleared in the turn's cleanup. While
|
|
548
|
+
* non-null, any engine-phase event arriving via `publishEnginePhase`
|
|
549
|
+
* counts as "the model is making progress" and resets the idle timer.
|
|
550
|
+
* Without this, large-prompt prefill (e.g. 30K tokens at 230 tok/s ≈
|
|
551
|
+
* 2 minutes) trips the pre-first-byte watchdog even though the
|
|
552
|
+
* python child is happily chunking through prefill — the SSE stream
|
|
553
|
+
* is silent until the very first generated token.
|
|
554
|
+
*/
|
|
555
|
+
currentTurnIdleReset = null;
|
|
556
|
+
/**
|
|
557
|
+
* Handler owned by the in-flight request for the engine's wave-scheduling
|
|
558
|
+
* markers (`waiting` / `paused` / `admitted`). Set beside
|
|
559
|
+
* {@link currentTurnIdleReset} and cleared with it.
|
|
560
|
+
*/
|
|
561
|
+
currentTurnEngineQueue = null;
|
|
562
|
+
engineHold = new EngineHold();
|
|
563
|
+
/**
|
|
564
|
+
* Last prefill phase event seen during the current turn — captured
|
|
565
|
+
* for the abort message so the user sees "aborted at prefill 47% /
|
|
566
|
+
* 14336 of 29930 tokens" instead of just "no first byte." Cleared
|
|
567
|
+
* at the start of every send.
|
|
568
|
+
*/
|
|
569
|
+
lastPrefillEvent = null;
|
|
570
|
+
/**
|
|
571
|
+
* The `cache_id` this session sends to the engine. Set on every send, and
|
|
572
|
+
* the key the provider routes per-sub engine phases by — see
|
|
573
|
+
* {@link MlxSession.ownsCacheId}.
|
|
574
|
+
*/
|
|
575
|
+
currentCacheId = null;
|
|
576
|
+
externalToolNames;
|
|
577
|
+
capturedCalls = [];
|
|
578
|
+
toolSurface;
|
|
579
|
+
/**
|
|
580
|
+
* Session-scoped quarantine for the assisted MTP route. Once an emitted
|
|
581
|
+
* Hermes call proves that decode-time required-field enforcement diverged,
|
|
582
|
+
* every later grammar-armed request from this live session uses ordinary
|
|
583
|
+
* sequential BatchGenerator decoding. The latch preserves speculation for
|
|
584
|
+
* healthy sessions while ensuring one bad call cannot become a five-retry
|
|
585
|
+
* validator loop.
|
|
586
|
+
*/
|
|
587
|
+
forceSequentialToolGrammar = false;
|
|
588
|
+
constructor(deps) {
|
|
589
|
+
super();
|
|
590
|
+
this.deps = deps;
|
|
591
|
+
this.externalToolNames = new Set((deps.externalTools ?? []).map((t) => t.name));
|
|
592
|
+
this.messages = [{ role: 'system', content: deps.systemMessage }];
|
|
593
|
+
// Layered prefix caching (flag ON): the volatile band rides as a
|
|
594
|
+
// SEPARATE frozen system message after the stable system message, so
|
|
595
|
+
// the stable `[system][tools]` wire prefix stays reusable.
|
|
596
|
+
if (deps.volatileContext) {
|
|
597
|
+
this.messages.push({ role: 'system', content: deps.volatileContext });
|
|
598
|
+
}
|
|
599
|
+
for (const m of deps.priorMessages) {
|
|
600
|
+
if (m.role === 'tool') {
|
|
601
|
+
this.messages.push({
|
|
602
|
+
role: 'tool',
|
|
603
|
+
content: m.content,
|
|
604
|
+
tool_call_id: m.toolCallId,
|
|
605
|
+
});
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
if (m.role === 'assistant' && 'toolCalls' in m && m.toolCalls.length > 0) {
|
|
609
|
+
this.messages.push({
|
|
610
|
+
role: 'assistant',
|
|
611
|
+
content: m.content || null,
|
|
612
|
+
tool_calls: m.toolCalls.map((tc) => ({
|
|
613
|
+
id: tc.id,
|
|
614
|
+
type: 'function',
|
|
615
|
+
function: { name: tc.name, arguments: tc.arguments },
|
|
616
|
+
})),
|
|
617
|
+
});
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (m.role === 'user' && m.images?.length && !this.supportsImageInput) {
|
|
621
|
+
throw new Error('[Mac AI] This MLX engine cannot accept image history. Enable its vision tower.');
|
|
622
|
+
}
|
|
623
|
+
this.messages.push({
|
|
624
|
+
role: m.role,
|
|
625
|
+
content: m.content,
|
|
626
|
+
...(m.role === 'user' && m.images?.length ? { images: m.images } : {}),
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
capturedToolCalls() {
|
|
631
|
+
return [...this.capturedCalls];
|
|
632
|
+
}
|
|
633
|
+
getWireTranscript() {
|
|
634
|
+
return mlxWireTranscript(this.messages);
|
|
635
|
+
}
|
|
636
|
+
getToolSurface() {
|
|
637
|
+
return this.toolSurface;
|
|
638
|
+
}
|
|
639
|
+
get numCtx() {
|
|
640
|
+
return this.deps.numCtx;
|
|
641
|
+
}
|
|
642
|
+
estimatePromptChars() {
|
|
643
|
+
let total = 0;
|
|
644
|
+
for (const m of this.messages) {
|
|
645
|
+
if (typeof m.content === 'string')
|
|
646
|
+
total += m.content.length;
|
|
647
|
+
// The sidecar bounds images to 1024px. Reserve patch tokens without
|
|
648
|
+
// counting base64 bytes as language tokens or treating pixels as free.
|
|
649
|
+
total += (m.images?.length ?? 0) * 8192;
|
|
650
|
+
if (m.tool_calls) {
|
|
651
|
+
for (const tc of m.tool_calls)
|
|
652
|
+
total += tc.function.arguments.length + tc.function.name.length;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
if (!this.deps.bridges.isEmpty()) {
|
|
656
|
+
for (const t of this.deps.bridges.getOpenAITools()) {
|
|
657
|
+
total += JSON.stringify(t).length;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return total;
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Prefill the exact standing `[system][tools][transcript]` prefix without
|
|
664
|
+
* appending a user turn or mutating this session. Used by the machine broker
|
|
665
|
+
* after a user daemon prepares `/v1/remote/cache/warm`.
|
|
666
|
+
*/
|
|
667
|
+
async prefillOnly(opts) {
|
|
668
|
+
// Vision KV is not eligible for text-prefix warming or persistence.
|
|
669
|
+
if (this.messages.some((message) => message.images?.length))
|
|
670
|
+
return;
|
|
671
|
+
// Focusing a chat must not cold-start MLX. The real turn owns normal lazy
|
|
672
|
+
// startup; warming only accelerates an already-resident server.
|
|
673
|
+
const baseUrl = this.deps.provider.currentBaseUrl();
|
|
674
|
+
if (!baseUrl)
|
|
675
|
+
return;
|
|
676
|
+
// A warm whose roster differs from the real turn's is worse than no warm
|
|
677
|
+
// at all. Qwen-family templates render the tool block at the TOP of the
|
|
678
|
+
// system message, so a no-tools render shares 3 tokens with a 44-tool
|
|
679
|
+
// turn — the shared prefix it saves then forces a full re-prefill on
|
|
680
|
+
// every session that seeds from it, and the next warm overwrites the
|
|
681
|
+
// corrected prefix right back.
|
|
682
|
+
//
|
|
683
|
+
// Wild-caught (koray PR-review fanout): `prefix-0b60345fcefa9ffd`
|
|
684
|
+
// oscillated between a 15,563-token no-tools render and a 24,796-token
|
|
685
|
+
// real one, ~9.2k tokens apart, producing `lcp=3 lcp_frac=0.00` and
|
|
686
|
+
// `mode=fresh reused=0` on turn after turn — 40 full re-prefills and
|
|
687
|
+
// 1.58M tokens re-prefilled against 238k of new work.
|
|
688
|
+
//
|
|
689
|
+
// The bridges spawn lazily, so an early warm sees an empty roster. A
|
|
690
|
+
// cold prefix costs one slow turn; a WRONG one costs every turn. An
|
|
691
|
+
// genuinely tool-less session also reads as empty here and is skipped
|
|
692
|
+
// too — its prompt is small, so the warm it loses is worth little,
|
|
693
|
+
// and that is the safe direction to be wrong in.
|
|
694
|
+
if (this.deps.bridges.isEmpty() && (this.deps.externalTools ?? []).length === 0) {
|
|
695
|
+
log.debug('turn#warm skipped — no tool roster registered yet; warming now would save a no-tools prefix that cannot match a real turn');
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
const bridgeTools = this.deps.bridges.isEmpty()
|
|
699
|
+
? []
|
|
700
|
+
: toChatCompletionsTools(this.deps.bridges);
|
|
701
|
+
const externalAsChatCompletions = externalToolsAsChatCompletions(this.deps.externalTools);
|
|
702
|
+
const tools = [...bridgeTools, ...externalAsChatCompletions];
|
|
703
|
+
const body = {
|
|
704
|
+
model: this.deps.model,
|
|
705
|
+
messages: this.messages,
|
|
706
|
+
stream: false,
|
|
707
|
+
max_tokens: 1,
|
|
708
|
+
};
|
|
709
|
+
applyMlxRequestShape(body, this.deps, { hasTools: tools.length > 0 });
|
|
710
|
+
// A warm must remain a one-token prefill even when catalog tuning carries
|
|
711
|
+
// a wider output cap.
|
|
712
|
+
body.max_tokens = 1;
|
|
713
|
+
// Nobody reads a warm; it must never hold up a person's turn.
|
|
714
|
+
body.priority = 'background';
|
|
715
|
+
if (tools.length > 0)
|
|
716
|
+
body.tools = tools;
|
|
717
|
+
if (opts?.sessionId) {
|
|
718
|
+
const adapter = this.deps.provider.getCacheAdapter();
|
|
719
|
+
if (adapter) {
|
|
720
|
+
await adapter.prepareForSend(opts.sessionId, this.deps.systemMessage, this.deps.systemPromptLayers,
|
|
721
|
+
// Same roster the body above will actually send. Omitting it made
|
|
722
|
+
// this warm register a prefix identity that could not distinguish
|
|
723
|
+
// a no-tools render from the real turn's — the send path at the
|
|
724
|
+
// bottom of `send()` has always passed it; this one did not.
|
|
725
|
+
body.tools, this.deps.systemSharedPrefix);
|
|
726
|
+
Object.assign(body, adapter.buildRequestExtras(opts.sessionId));
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const requestSignal = AbortSignal.timeout(opts?.timeoutMs ?? 10 * 60_000);
|
|
730
|
+
const releaseEngineRequest = await this.deps.provider.acquireExclusiveEngineRequest(`cache-warm:${opts?.sessionId ?? 'anonymous'}`, requestSignal, undefined, false, 'background');
|
|
731
|
+
try {
|
|
732
|
+
const res = await this.deps.fetchImpl(`${baseUrl}/v1/chat/completions`, {
|
|
733
|
+
method: 'POST',
|
|
734
|
+
headers: { 'Content-Type': 'application/json' },
|
|
735
|
+
body: JSON.stringify(body),
|
|
736
|
+
signal: requestSignal,
|
|
737
|
+
});
|
|
738
|
+
if (!res.ok) {
|
|
739
|
+
const detail = await res.text().catch(() => '');
|
|
740
|
+
throw new Error(`prefillOnly: HTTP ${res.status} ${detail.slice(0, 200)}`);
|
|
741
|
+
}
|
|
742
|
+
await res.text().catch(() => '');
|
|
743
|
+
}
|
|
744
|
+
finally {
|
|
745
|
+
releaseEngineRequest();
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
getLastTurnReasoning() {
|
|
749
|
+
return this.lastTurnReasoning.length > 0 ? this.lastTurnReasoning : undefined;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Running length of the same trace {@link getLastTurnReasoning}
|
|
753
|
+
* returns — read when a tool call fires so the persisted call can
|
|
754
|
+
* carry the offset it fired at.
|
|
755
|
+
*/
|
|
756
|
+
getCurrentTurnReasoningLength() {
|
|
757
|
+
return this.lastTurnReasoning.length;
|
|
758
|
+
}
|
|
759
|
+
getLastTurnAttemptedToolCalls() {
|
|
760
|
+
return this.lastTurnAttemptedToolCalls.length > 0 ? this.lastTurnAttemptedToolCalls : undefined;
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Whether a phase tagged with this engine cache id belongs to this session.
|
|
764
|
+
* The id is the one sent as `cache_id` on every request, so it is exactly
|
|
765
|
+
* what the sidecar stamps onto its per-sub prefill markers.
|
|
766
|
+
*/
|
|
767
|
+
ownsCacheId(cacheId) {
|
|
768
|
+
return this.currentCacheId === cacheId;
|
|
769
|
+
}
|
|
770
|
+
publishEnginePhase(ev) {
|
|
771
|
+
if (ev.engineQueue) {
|
|
772
|
+
// Scheduling markers are the in-flight request's business alone: they
|
|
773
|
+
// re-arm its watchdog and relabel it. Outside a request there is
|
|
774
|
+
// nothing to wait on, so they are dropped.
|
|
775
|
+
this.currentTurnEngineQueue?.(ev.engineQueue);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
// Capture the latest prefill state so the pre-first-byte abort
|
|
779
|
+
// message (when it does fire) can attribute the stall to a real
|
|
780
|
+
// observed point in the prefill arc.
|
|
781
|
+
if (ev.phase === 'prefill' && typeof ev.progress === 'number') {
|
|
782
|
+
this.lastPrefillEvent = {
|
|
783
|
+
progress: ev.progress,
|
|
784
|
+
detail: ev.detail ?? '',
|
|
785
|
+
at: Date.now(),
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
// Reset the in-flight turn's idle watchdog. Any engine-phase event
|
|
789
|
+
// (prefill chunk, model-load tick, ready signal) is real evidence
|
|
790
|
+
// the python child is making progress, even though the SSE stream
|
|
791
|
+
// hasn't yielded its first token yet.
|
|
792
|
+
this.currentTurnIdleReset?.();
|
|
793
|
+
this.emitEnginePhase(ev);
|
|
794
|
+
}
|
|
795
|
+
publishEngineStats(ev) {
|
|
796
|
+
this.emitEngineStats(ev);
|
|
797
|
+
}
|
|
798
|
+
async maybeCompactMidLoop(opts) {
|
|
799
|
+
return compactLocalTurn(this.turnPolicy, {
|
|
800
|
+
messages: this.messages,
|
|
801
|
+
turnStart: this.currentTurnStartIdx,
|
|
802
|
+
estimatedTokens: Math.ceil(this.estimatePromptChars() / 4),
|
|
803
|
+
numCtx: this.deps.numCtx,
|
|
804
|
+
request: this.deps.requestCompaction,
|
|
805
|
+
replace: (start, count, content) => {
|
|
806
|
+
this.messages.splice(start, count, { role: 'assistant', content });
|
|
807
|
+
this.currentTurnStartIdx = start + 1;
|
|
808
|
+
},
|
|
809
|
+
warn: (error) => log.warn('Mid-loop compaction failed', { error: String(error) }),
|
|
810
|
+
completed: (removed) => this.emitWarning(autoCompactedNotice(removed, this.deps.numCtx)),
|
|
811
|
+
}, opts?.force);
|
|
812
|
+
}
|
|
813
|
+
get isDisposed() {
|
|
814
|
+
return this.deps.provider.isDisposed;
|
|
815
|
+
}
|
|
816
|
+
async sendAndWait(prompt, opts) {
|
|
817
|
+
this.lastTurnBail = null;
|
|
818
|
+
// Queue-bypass path: sync consultations spawned via ask_specialist /
|
|
819
|
+
// ask_gezel would otherwise deadlock behind the asker's held slot
|
|
820
|
+
// (the asker is parked in `bridges.callTool` waiting for the
|
|
821
|
+
// consultation's reply — chicken-and-egg). The wrapped Python
|
|
822
|
+
// server's BatchEngine and its memory-aware admission still enforce
|
|
823
|
+
// the configured generation width, so bypassing the TS-side FIFO only
|
|
824
|
+
// loses the queue's affinity scoring, not safety.
|
|
825
|
+
return runOnLiveProvider(this.deps.provider, opts, () => this.deps.provider.withEngineTurn(() => this.sendAndWaitInner(prompt, opts), opts?.queue?.signal));
|
|
826
|
+
}
|
|
827
|
+
async sendAndWaitInner(prompt, opts) {
|
|
828
|
+
// Fail fast if the supervisor already saw mlx_lm.server die at
|
|
829
|
+
// startup. The httpd process can keep returning 200 on /health
|
|
830
|
+
// after `_generate` raises, so without this check we'd spend the
|
|
831
|
+
// full turn timeout hanging on an unresponsive
|
|
832
|
+
// /v1/chat/completions endpoint with no user-facing signal.
|
|
833
|
+
const fatal = this.deps.provider.takeFatalError();
|
|
834
|
+
if (fatal) {
|
|
835
|
+
const hintSuffix = fatal.hint ? `\n\n${fatal.hint}` : '';
|
|
836
|
+
throw new Error(`[Mac AI] engine failed to start: ${fatal.message}${hintSuffix}`);
|
|
837
|
+
}
|
|
838
|
+
const totalTimeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
839
|
+
// Budget in awake time, not wall clock. A laptop that naps mid-turn used
|
|
840
|
+
// to burn the whole budget while frozen and die on the wake-up burst
|
|
841
|
+
// reporting a duration it never got — `afterMs=1002151` against a 180s
|
|
842
|
+
// budget, of which 822s were macOS dark-wake sleep. `AwakeBudget` holds
|
|
843
|
+
// still across a suspension and gives up on its own terms when one nap is
|
|
844
|
+
// long enough to mean the machine was put away for good.
|
|
845
|
+
const budget = new AwakeBudget(totalTimeoutMs);
|
|
846
|
+
const turnTimeoutError = () => new Error(`[Mac AI] timed out after ${Math.round(totalTimeoutMs / 1000)}s${budget.describeSuspension()}`);
|
|
847
|
+
const start = Date.now();
|
|
848
|
+
const seq = ++this.sendSeq;
|
|
849
|
+
const debugOn = this.deps.debug?.isEnabled() === true;
|
|
850
|
+
// Always-on lifecycle tag — low frequency, high signal when piecing
|
|
851
|
+
// together "first turn worked, second never returned". Includes the
|
|
852
|
+
// pre-existing message count so the sequence in the log unambiguously
|
|
853
|
+
// shows whether the prior assistant reply landed in `this.messages`
|
|
854
|
+
// before this turn started.
|
|
855
|
+
log.info(`turn#${seq} START prompt=${prompt.length}c msgs=${this.messages.length}`);
|
|
856
|
+
const continueFromToolResult = opts?.continueFromToolResult === true;
|
|
857
|
+
if (continueFromToolResult) {
|
|
858
|
+
if (prompt.length > 0 || (opts?.attachments?.length ?? 0) > 0) {
|
|
859
|
+
throw new Error('[Mac AI] a tool-result continuation cannot include a new prompt or attachments');
|
|
860
|
+
}
|
|
861
|
+
const last = this.messages.at(-1);
|
|
862
|
+
if (last?.role !== 'tool' && !(last?.role === 'user' && last.images?.length)) {
|
|
863
|
+
throw new Error('[Mac AI] a tool-result continuation requires a trailing tool result');
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
const userMsg = { role: 'user', content: prompt };
|
|
867
|
+
if (opts?.attachments && opts.attachments.length > 0) {
|
|
868
|
+
if (!this.supportsImageInput)
|
|
869
|
+
throw new Error('[Mac AI] This MLX runtime supports text only. Enable native vision for an installed vision checkpoint; no image was delivered to the model.');
|
|
870
|
+
userMsg.images = opts.attachments.map((a) => a.base64);
|
|
871
|
+
}
|
|
872
|
+
// Mark this turn's start so mid-loop compaction can split prior
|
|
873
|
+
// history (compactable) from in-flight tool loop (preserve verbatim).
|
|
874
|
+
// See LlamaCppSession.sendAndWaitInner for the rationale.
|
|
875
|
+
this.currentTurnStartIdx = this.messages.length;
|
|
876
|
+
this.turnPolicy = new LocalTurnPolicy(turnCheckpoint(opts?.queue?.signal, () => budget.expired(), turnTimeoutError));
|
|
877
|
+
this.turnPolicy.checkpoint();
|
|
878
|
+
// Reset captured reasoning at the top of each turn — manager
|
|
879
|
+
// reads `getLastTurnReasoning()` after this resolves.
|
|
880
|
+
this.lastTurnReasoning = '';
|
|
881
|
+
// Same lifecycle for the attempted-tool-calls capture: read once
|
|
882
|
+
// by manager after the turn resolves, populated only when the
|
|
883
|
+
// salvage retry budget exhausts below.
|
|
884
|
+
this.lastTurnAttemptedToolCalls = [];
|
|
885
|
+
if (!continueFromToolResult)
|
|
886
|
+
this.messages.push(userMsg);
|
|
887
|
+
// Reset captures — each sendAndWait surfaces only its own externals.
|
|
888
|
+
this.capturedCalls = [];
|
|
889
|
+
const bridgeTools = this.deps.bridges.isEmpty()
|
|
890
|
+
? []
|
|
891
|
+
: toChatCompletionsTools(this.deps.bridges);
|
|
892
|
+
const externalAsChatCompletions = externalToolsAsChatCompletions(this.deps.externalTools);
|
|
893
|
+
const tools = bridgeTools.length + externalAsChatCompletions.length > 0
|
|
894
|
+
? [...bridgeTools, ...externalAsChatCompletions]
|
|
895
|
+
: undefined;
|
|
896
|
+
this.toolSurface = toolSurfaceSize(tools ?? []);
|
|
897
|
+
// Names of tools the model actually sees this turn. This must be
|
|
898
|
+
// derived from the advertised request surface, not the raw bridge
|
|
899
|
+
// inventory: role filtering hides tools from builders, but the bridge
|
|
900
|
+
// can still call them internally. Salvage and execution must not
|
|
901
|
+
// promote hidden-tool prose into real side effects.
|
|
902
|
+
const advertisedBridgeToolNames = new Set(bridgeTools.map((t) => chatCompletionToolName(t)).filter((n) => !!n));
|
|
903
|
+
const knownToolNames = new Set((tools ?? []).map((t) => chatCompletionToolName(t)).filter((n) => !!n));
|
|
904
|
+
const requiresToolCall = this.deps.tuning?.toolChoice === 'required' && knownToolNames.size > 0;
|
|
905
|
+
// Tool-name → declared input schema, so salvaged calls can have
|
|
906
|
+
// structural arguments the markup formats flattened into strings
|
|
907
|
+
// reinterpreted before anything else reads them. The Hermes /
|
|
908
|
+
// Claude / GLM / XML shapes are all flat KEY→text maps and cannot
|
|
909
|
+
// carry a nested object or array; without this the model's correct
|
|
910
|
+
// JSON reaches the validator as `got string, expected object` and
|
|
911
|
+
// it loops re-emitting the same call. See tool-arg-schema-coercion.ts.
|
|
912
|
+
const toolArgSchemas = new Map();
|
|
913
|
+
// Param-name → owning-tool-names index, so an unknown-tool nudge can
|
|
914
|
+
// catch the model naming a PARAMETER as the function (e.g. `description`
|
|
915
|
+
// is an argument of `start_project`, not a tool). Built once per send.
|
|
916
|
+
const toolParamIndex = new Map();
|
|
917
|
+
for (const t of tools ?? []) {
|
|
918
|
+
const tname = chatCompletionToolName(t);
|
|
919
|
+
if (!tname)
|
|
920
|
+
continue;
|
|
921
|
+
const schema = t.function.parameters;
|
|
922
|
+
if (schema)
|
|
923
|
+
toolArgSchemas.set(tname, schema);
|
|
924
|
+
const props = schema?.properties;
|
|
925
|
+
if (!props)
|
|
926
|
+
continue;
|
|
927
|
+
for (const param of Object.keys(props)) {
|
|
928
|
+
const arr = toolParamIndex.get(param);
|
|
929
|
+
if (arr) {
|
|
930
|
+
if (!arr.includes(tname))
|
|
931
|
+
arr.push(tname);
|
|
932
|
+
}
|
|
933
|
+
else {
|
|
934
|
+
toolParamIndex.set(param, [tname]);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
// Surgical edit tools on the roster ⇒ the deliverable is a modify of
|
|
939
|
+
// an existing file, so a repeated source-write failure should steer
|
|
940
|
+
// toward a targeted patch rather than "re-emit the whole file."
|
|
941
|
+
const surgicalEditsAvailable = knownToolNames.has('replace_in_file') ||
|
|
942
|
+
knownToolNames.has('insert_at_marker') ||
|
|
943
|
+
knownToolNames.has('replace_lines') ||
|
|
944
|
+
knownToolNames.has('append_to_file');
|
|
945
|
+
// Delegation tools on the roster ⇒ repeated edit failures can hand the
|
|
946
|
+
// file to a more capable model instead of thrashing to a plain abort.
|
|
947
|
+
const delegationAvailable = [...knownToolNames].some((n) => n.startsWith('delegate_'));
|
|
948
|
+
const writerFlags = {
|
|
949
|
+
artifactWriterAvailable: knownToolNames.has('write_artifact'),
|
|
950
|
+
workspaceWriterAvailable: knownToolNames.has('write_file'),
|
|
951
|
+
};
|
|
952
|
+
// Counter for the model self-correction loop: each malformed tool
|
|
953
|
+
// call we nudge the model about counts against this budget. Not
|
|
954
|
+
// reset between iterations — a turn with two consecutive bad calls
|
|
955
|
+
// burns through both retries instead of looping forever.
|
|
956
|
+
let malformedRetries = 0;
|
|
957
|
+
// Signature of the last unparseable/unknown tool-call we nudged about.
|
|
958
|
+
// If the model emits the IDENTICAL bad call again after a nudge, the
|
|
959
|
+
// nudge isn't landing — stop spending retries on a repeat (the actionable
|
|
960
|
+
// nudge already fired once) and end the turn instead of apology-looping.
|
|
961
|
+
let lastBadCallSig = null;
|
|
962
|
+
// Auto-continuation state for truncated immediate-writes. When an
|
|
963
|
+
// immediate-write `write_file` is EOS-flushed (file larger than the
|
|
964
|
+
// per-turn token cap), we keep the turn alive — surfacing
|
|
965
|
+
// `append_to_file` and looping — until the model writes the tail
|
|
966
|
+
// without truncating, or we hit MAX_IMMEDIATE_WRITE_CONTINUATIONS.
|
|
967
|
+
let repairReadCalls = 0;
|
|
968
|
+
const repairReadPaths = [];
|
|
969
|
+
const requiredInputReads = [];
|
|
970
|
+
const requiredInputs = [
|
|
971
|
+
...(this.deps.activeCraftbookStep?.requiredInputs ?? []),
|
|
972
|
+
...promptReadInputs(prompt),
|
|
973
|
+
];
|
|
974
|
+
let requiredInputHoldLogged = false;
|
|
975
|
+
let repairFailedMutations = 0;
|
|
976
|
+
let repairMutationSucceeded = false;
|
|
977
|
+
const requiredReadPaths = opts?.fileTurnIntent?.kind === 'repair-file' && opts.fileTurnIntent.readPaths
|
|
978
|
+
? opts.fileTurnIntent.readPaths
|
|
979
|
+
: extractPrerequisiteRepairReadPaths(prompt);
|
|
980
|
+
let writeContinuationActive = false;
|
|
981
|
+
let writeContinuations = 0;
|
|
982
|
+
// Per-tool consecutive-failure tracker. Catches the Qwen 3.6 27B
|
|
983
|
+
// pattern of emitting JSON-stringified args, hitting the same Zod
|
|
984
|
+
// validation error every iteration, "diagnosing" the schema in
|
|
985
|
+
// chat, and re-trying the same wrong shape forever. See the
|
|
986
|
+
// tracker's docstring for the threshold rationale.
|
|
987
|
+
const failureTracker = new ToolFailureTracker({
|
|
988
|
+
surgicalEditsAvailable,
|
|
989
|
+
delegationAvailable,
|
|
990
|
+
...writerFlags,
|
|
991
|
+
});
|
|
992
|
+
// Per-turn same-(name, args) repeat tracker. Catches the
|
|
993
|
+
// "narrative spinning" loop where the model re-reads the same
|
|
994
|
+
// files (read_task_notes, read_file, etc.) iteration after
|
|
995
|
+
// iteration, narrating "let me plan" prose between each call,
|
|
996
|
+
// never reaching write_file. See ToolRepeatTracker docstring for
|
|
997
|
+
// the threshold rationale.
|
|
998
|
+
const repeatTracker = new ToolRepeatTracker();
|
|
999
|
+
const deliverableReadPaceTracker = DeliverableReadPaceTracker.fromUserText(prompt, opts?.fileTurnIntent);
|
|
1000
|
+
const deliverableReadySteer = DeliverableReadySteer.forStep(this.deps.activeCraftbookStep);
|
|
1001
|
+
// Per-turn `ask_user_question` guard. Once a question card lands
|
|
1002
|
+
// successfully, additional ask_user_question calls in the same
|
|
1003
|
+
// turn are intercepted with a synthetic "you already asked, end
|
|
1004
|
+
// your turn" response — without this, verbose-family models
|
|
1005
|
+
// double-post cards (the second one usually re-phrasing the
|
|
1006
|
+
// first; user gets a cluttered Home panel). The flag also
|
|
1007
|
+
// signals `foldPreToolPreamble` to fold trailing wrap-up prose
|
|
1008
|
+
// for the rest of the turn.
|
|
1009
|
+
let askedQuestionThisTurn = false;
|
|
1010
|
+
// Per-turn project-macro guard. `start_project` and `start_job`
|
|
1011
|
+
// are atomic high-stakes macros — each call creates a project +
|
|
1012
|
+
// voorman + kickoff task. Without this, a Gemma 4 cold-start
|
|
1013
|
+
// turn that emits multiple `<|tool_call|>start_project{...}<|/tool_call|>`
|
|
1014
|
+
// envelopes in one response (brainstorming variations of the
|
|
1015
|
+
// same idea) produces N duplicate projects. Wild-caught on a
|
|
1016
|
+
// clean install ("Can you create a space invaders
|
|
1017
|
+
// game?" → 5 projects with iterated names).
|
|
1018
|
+
let startedProjectOrJobThisTurn = null;
|
|
1019
|
+
// Count redundant macro intercepts within this turn. Once the
|
|
1020
|
+
// per-turn guard arms (after the first successful start_project /
|
|
1021
|
+
// start_job), every additional macro call gets intercepted with
|
|
1022
|
+
// a "STOP" message. Some local models (gemma4-26b on MLX, wild-caught)
|
|
1023
|
+
// misread that as "your call was
|
|
1024
|
+
// malformed, retry it" and keep emitting envelopes until
|
|
1025
|
+
// MAX_TOOL_LOOP_TURNS bails — minutes of blank bubble for the
|
|
1026
|
+
// user. After `PROJECT_MACRO_INTERCEPT_CAP` redundant attempts,
|
|
1027
|
+
// we force end-of-turn with a synthesized closing summary
|
|
1028
|
+
// derived from the first successful result.
|
|
1029
|
+
let projectMacroInterceptCount = 0;
|
|
1030
|
+
let forceProjectMacroBail = null;
|
|
1031
|
+
let fullText = '';
|
|
1032
|
+
let lastUsage = null;
|
|
1033
|
+
let firstTokenAt = null;
|
|
1034
|
+
let lastIterationStartedAt = start;
|
|
1035
|
+
let lastIterationFirstTokenAt = null;
|
|
1036
|
+
let lastIterationFinishedAt = null;
|
|
1037
|
+
let turnFailed = false;
|
|
1038
|
+
// Every successful terminal path must publish the same usage + turn
|
|
1039
|
+
// snapshot. Keeping this in the outer finally covers ordinary replies,
|
|
1040
|
+
// external tool capture, synthesized terminal closings, question cards,
|
|
1041
|
+
// and one-shot sessions without duplicating subtly different accounting
|
|
1042
|
+
// at each return site.
|
|
1043
|
+
const emitTerminalTelemetry = () => {
|
|
1044
|
+
const telemetry = buildMlxTerminalTelemetry({
|
|
1045
|
+
lastUsage,
|
|
1046
|
+
model: this.deps.model,
|
|
1047
|
+
start,
|
|
1048
|
+
lastIterationStartedAt,
|
|
1049
|
+
lastIterationFirstTokenAt,
|
|
1050
|
+
lastIterationFinishedAt,
|
|
1051
|
+
firstTokenAt,
|
|
1052
|
+
});
|
|
1053
|
+
if (!telemetry)
|
|
1054
|
+
return;
|
|
1055
|
+
this.emitUsage(telemetry.usage);
|
|
1056
|
+
this.emitTurnStats(telemetry.stats);
|
|
1057
|
+
};
|
|
1058
|
+
this.deps.provider._registerActiveSession(this);
|
|
1059
|
+
// Status heartbeat. The pre-first-token window — cache warm (prepareForSend)
|
|
1060
|
+
// plus prompt prefill — can run for minutes on a large prompt and emits no
|
|
1061
|
+
// stdout phases, so the engine pill would otherwise sit on a stale label
|
|
1062
|
+
// until "First token in …" finally lands. Re-emit a live, descriptive phase
|
|
1063
|
+
// every few seconds so the pill keeps a meaningful label (and its elapsed
|
|
1064
|
+
// clock) the whole time. Crucially this uses `emitEnginePhase` (UI bus only)
|
|
1065
|
+
// and NOT `publishEnginePhase`, so it does NOT reset the stall watchdog — a
|
|
1066
|
+
// genuinely hung engine still trips the idle abort.
|
|
1067
|
+
let prepLabel = 'Preparing…';
|
|
1068
|
+
this.lastPrefillEvent = null;
|
|
1069
|
+
this.engineHold.end();
|
|
1070
|
+
const emitPrep = () => {
|
|
1071
|
+
if (firstTokenAt !== null)
|
|
1072
|
+
return;
|
|
1073
|
+
// Held by the engine behind other work: say so, and show no bar —
|
|
1074
|
+
// nothing of THIS prompt is being processed.
|
|
1075
|
+
const holdLabel = this.engineHold.label;
|
|
1076
|
+
if (holdLabel) {
|
|
1077
|
+
this.emitEnginePhase({ provider: 'mlx', phase: 'prefill', detail: holdLabel });
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
// Carry the last observed prefill progress forward. A phase event
|
|
1081
|
+
// WITHOUT `progress` tells the UI the bar is over (the timeline's
|
|
1082
|
+
// engine_phase handler deletes `thinkingProgress` and its detail), so
|
|
1083
|
+
// a bare heartbeat landing between two `Prefill:` lines blinked the
|
|
1084
|
+
// bar and its token count out every 4s for the whole prefill — which
|
|
1085
|
+
// on a 61K-token prompt is minutes of flicker.
|
|
1086
|
+
const last = this.lastPrefillEvent;
|
|
1087
|
+
this.emitEnginePhase({
|
|
1088
|
+
provider: 'mlx',
|
|
1089
|
+
phase: 'prefill',
|
|
1090
|
+
detail: last?.detail || prepLabel,
|
|
1091
|
+
...(last ? { progress: last.progress } : {}),
|
|
1092
|
+
});
|
|
1093
|
+
};
|
|
1094
|
+
const statusHeartbeat = setInterval(emitPrep, 4_000);
|
|
1095
|
+
emitPrep();
|
|
1096
|
+
// The physical request lease must outlive all per-iteration setup, but it
|
|
1097
|
+
// must never outlive this send. In particular, a fetch-level AbortError
|
|
1098
|
+
// happens before the SSE-body `finally`; keeping the active release here
|
|
1099
|
+
// lets the outer `finally` recover the slot from every exit path.
|
|
1100
|
+
let releaseActiveEngineRequest = null;
|
|
1101
|
+
// Cap-truncation steers spent this send. Bounded because the steer is
|
|
1102
|
+
// only worth issuing while the model can still act on it: past the
|
|
1103
|
+
// budget the honest outcome is the warning banner and an ended turn,
|
|
1104
|
+
// not another 6k-token attempt at the same over-cap payload.
|
|
1105
|
+
let capTruncationSteers = 0;
|
|
1106
|
+
try {
|
|
1107
|
+
for (let turn = 0; turn < MAX_TOOL_LOOP_TURNS; turn++) {
|
|
1108
|
+
this.turnPolicy.checkpoint();
|
|
1109
|
+
// Shared pressure check preserves all system bands and active tool results.
|
|
1110
|
+
await this.maybeCompactMidLoop();
|
|
1111
|
+
lastIterationStartedAt = Date.now();
|
|
1112
|
+
lastIterationFirstTokenAt = null;
|
|
1113
|
+
lastIterationFinishedAt = null;
|
|
1114
|
+
if (budget.expired())
|
|
1115
|
+
throw turnTimeoutError();
|
|
1116
|
+
// Before the URL: loading the vision tower restarts the engine.
|
|
1117
|
+
// A standalone turn's seed carries the whole state: earlier turns
|
|
1118
|
+
// stay in the transcript but not in the request.
|
|
1119
|
+
const transcript = opts?.standalone
|
|
1120
|
+
? standaloneTurnMessages(this.messages, this.currentTurnStartIdx)
|
|
1121
|
+
: this.messages;
|
|
1122
|
+
const messages = await this.deps.provider.visionMode.prepareRequest(retireInspectedToolImages(transcript), opts?.standalone ? leadingSystemMessages(this.messages) : this.currentTurnStartIdx, opts?.queue?.signal);
|
|
1123
|
+
const baseUrl = await this.deps.resolveBaseUrl();
|
|
1124
|
+
const body = {
|
|
1125
|
+
model: this.deps.model,
|
|
1126
|
+
messages,
|
|
1127
|
+
stream: true,
|
|
1128
|
+
// Per-turn output cap. mlx-vlm's stream_generate defaults to
|
|
1129
|
+
// a small built-in cap (256 in some versions) — verbose
|
|
1130
|
+
// reasoning models burn through that on `<think>` blocks
|
|
1131
|
+
// alone and truncate mid-tool-call. 16 384 covers any
|
|
1132
|
+
// realistic chat turn; can be overridden per-model via
|
|
1133
|
+
// catalog `tuning.sampling.maxTokens`.
|
|
1134
|
+
max_tokens: 16_384,
|
|
1135
|
+
};
|
|
1136
|
+
applyMlxRequestShape(body, this.deps, { hasTools: Boolean(tools?.length) });
|
|
1137
|
+
// Engine-side scheduling: who is waiting on this turn. Omitted reads
|
|
1138
|
+
// as interactive at the sidecar, so only background is spelled out.
|
|
1139
|
+
const enginePriority = opts?.queue?.enginePriority ?? 'interactive';
|
|
1140
|
+
if (enginePriority === 'background')
|
|
1141
|
+
body.priority = 'background';
|
|
1142
|
+
// Continuation-iteration output cap — see SendAndWaitOpts.
|
|
1143
|
+
// Iteration 0 keeps the catalog cap so a tool call is never cut
|
|
1144
|
+
// off before it starts; wrap-up iterations get the tight cap.
|
|
1145
|
+
if (turn > 0 && opts?.continuationMaxTokens && opts.continuationMaxTokens > 0) {
|
|
1146
|
+
const current = typeof body.max_tokens === 'number' ? body.max_tokens : Number.POSITIVE_INFINITY;
|
|
1147
|
+
body.max_tokens = Math.min(current, opts.continuationMaxTokens);
|
|
1148
|
+
}
|
|
1149
|
+
if (this.forceSequentialToolGrammar && hermesRequiredArgGrammarRequested(body)) {
|
|
1150
|
+
body.disable_speculation = true;
|
|
1151
|
+
}
|
|
1152
|
+
// Tool surface actually sent this turn. Defaults to the full roster;
|
|
1153
|
+
// constrained turns narrow it below.
|
|
1154
|
+
let requestTools = tools;
|
|
1155
|
+
// A turn whose whole job is one call (a game reaction's move): the
|
|
1156
|
+
// first request offers that tool alone, without thinking, as on
|
|
1157
|
+
// llama-cpp. The sidecar has no forced tool choice; one tool on offer
|
|
1158
|
+
// leaves nothing else to reach for.
|
|
1159
|
+
if (turn === 0 && opts?.requiredTool) {
|
|
1160
|
+
const required = requiredToolOnly(tools, opts.requiredTool);
|
|
1161
|
+
if (required.length > 0) {
|
|
1162
|
+
requestTools = required;
|
|
1163
|
+
disableTemplateThinking(body);
|
|
1164
|
+
log.debug(`turn#${seq}.${turn} required-call turn: ${opts.requiredTool} only`);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
const fileTurnPlan = planFileTurn(prompt, tools, opts?.fileTurnIntent);
|
|
1168
|
+
const unreadInputs = unreadRequiredInputs(requiredInputs, requiredInputReads);
|
|
1169
|
+
const immediateFileWriteTurn = fileTurnPlan.kind === 'create-file' && unreadInputs.length === 0;
|
|
1170
|
+
if (fileTurnPlan.kind === 'create-file' &&
|
|
1171
|
+
unreadInputs.length > 0 &&
|
|
1172
|
+
!requiredInputHoldLogged) {
|
|
1173
|
+
requiredInputHoldLogged = true;
|
|
1174
|
+
log.info(`turn#${seq}.${turn} immediate-write held: input(s) unread (${unreadInputs.map((i) => i.path).join(', ')})`);
|
|
1175
|
+
}
|
|
1176
|
+
const fileRepairTurn = fileTurnPlan.kind === 'repair-file';
|
|
1177
|
+
const remainingReadPaths = fileRepairTurn
|
|
1178
|
+
? remainingPrerequisiteRepairReadPaths(requiredReadPaths, repairReadPaths)
|
|
1179
|
+
: [];
|
|
1180
|
+
if (fileRepairTurn) {
|
|
1181
|
+
if (remainingReadPaths.length &&
|
|
1182
|
+
repairReadCalls > requiredReadPaths.length + LOCAL_TURN_LIMITS.noProgress) {
|
|
1183
|
+
throw new Error('File repair exceeded its bounded prerequisite read allowance.');
|
|
1184
|
+
}
|
|
1185
|
+
const rewrite = opts?.fileTurnIntent?.kind === 'repair-file' && opts.fileTurnIntent.strategy
|
|
1186
|
+
? opts.fileTurnIntent.strategy === 'rewrite'
|
|
1187
|
+
: hasExplicitFullFileRewriteWording(prompt);
|
|
1188
|
+
requestTools = repairTools(tools, {
|
|
1189
|
+
readSucceeded: repairReadPaths.length > 0,
|
|
1190
|
+
source: isSourceFileRepairPrompt(prompt, opts?.fileTurnIntent),
|
|
1191
|
+
rewrite: rewrite || repairFailedMutations >= 2 || this.turnPolicy.count('noProgress') >= 2,
|
|
1192
|
+
prerequisiteReadsPending: remainingReadPaths.length > 0,
|
|
1193
|
+
});
|
|
1194
|
+
applyConstrainedTurnShape(body);
|
|
1195
|
+
}
|
|
1196
|
+
if (immediateFileWriteTurn) {
|
|
1197
|
+
if (typeof userMsg.content === 'string' &&
|
|
1198
|
+
!userMsg.content.includes('[Local-model rescue:')) {
|
|
1199
|
+
userMsg.content += IMMEDIATE_FILE_WRITE_PROMPT_SUFFIX;
|
|
1200
|
+
}
|
|
1201
|
+
const shaped = applyConstrainedTurnShape(body);
|
|
1202
|
+
// Narrow to write_file alone, matching llama-cpp. Without this the
|
|
1203
|
+
// model keeps the full roster on a turn whose only acceptable move
|
|
1204
|
+
// is the write, and wanders back into reads and delegation.
|
|
1205
|
+
const narrowed = writeFileOnlyTools(tools);
|
|
1206
|
+
if (narrowed.length > 0)
|
|
1207
|
+
requestTools = narrowed;
|
|
1208
|
+
// The switch alone is not enough. Qwen 3.8's HF template resolves
|
|
1209
|
+
// `reasoning_effort|default('xhigh')` inside its thinking branch, so
|
|
1210
|
+
// a turn that isn't fully suppressed reasons at the model's most
|
|
1211
|
+
// expensive setting and truncates the write_file body it was asked
|
|
1212
|
+
// for. llama-cpp has downgraded depth here for a while; MLX did not,
|
|
1213
|
+
// which is the whole of the measured engine divergence.
|
|
1214
|
+
log.debug(`turn#${seq}.${turn} immediate-write mode: write_file-only surface (${narrowed.length} tool), thinking disabled${shaped.reasoningDepthDowngraded.length > 0
|
|
1215
|
+
? `, reasoning depth -> low (${shaped.reasoningDepthDowngraded.join(', ')})`
|
|
1216
|
+
: ''}, max_tokens=${body.max_tokens}`);
|
|
1217
|
+
}
|
|
1218
|
+
if (this.deps.forceDirectFileWork &&
|
|
1219
|
+
(typeof body.max_tokens !== 'number' || body.max_tokens < FILE_WRITE_MIN_TOKENS)) {
|
|
1220
|
+
body.max_tokens = FILE_WRITE_MIN_TOKENS;
|
|
1221
|
+
}
|
|
1222
|
+
if (requestTools && requestTools.length > 0)
|
|
1223
|
+
body.tools = requestTools;
|
|
1224
|
+
// Tool-schema size breakdown (opt-in: GEZEL_PROMPT_BREAKDOWN=1). The
|
|
1225
|
+
// companion to buildInstructions' system-text breakdown — the tool JSON
|
|
1226
|
+
// schemas are prefilled alongside the system prompt, so this shows the
|
|
1227
|
+
// other half of the per-turn token cost. Once per send (turn 0 only).
|
|
1228
|
+
if (turn === 0 && process.env.GEZEL_PROMPT_BREAKDOWN === '1' && tools && tools.length > 0) {
|
|
1229
|
+
const serialized = JSON.stringify(tools);
|
|
1230
|
+
const top = [...tools]
|
|
1231
|
+
.map((t) => ({ name: chatCompletionToolName(t) ?? '?', ch: JSON.stringify(t).length }))
|
|
1232
|
+
.sort((a, b) => b.ch - a.ch)
|
|
1233
|
+
.slice(0, 12)
|
|
1234
|
+
.map((t) => ` ${String(Math.round(t.ch / 4)).padStart(5)} tok ${String(t.ch).padStart(6)} ch ${t.name}`)
|
|
1235
|
+
.join('\n');
|
|
1236
|
+
log.info(`turn#${seq} [prompt-breakdown] tools: ${tools.length} defs · ` +
|
|
1237
|
+
`~${Math.round(serialized.length / 4)} tok (${serialized.length} ch). Largest:\n${top}`);
|
|
1238
|
+
}
|
|
1239
|
+
// Write-continuation: add `append_to_file` to the surface so the
|
|
1240
|
+
// model can finish a truncated file by appending its tail. Only
|
|
1241
|
+
// active after an immediate-write truncation; the base surface
|
|
1242
|
+
// stays write_file-only so the FIRST write is still focused.
|
|
1243
|
+
if (writeContinuationActive && Array.isArray(body.tools)) {
|
|
1244
|
+
if (!body.tools.some((t) => chatCompletionToolName(t) === 'append_to_file')) {
|
|
1245
|
+
body.tools = [...body.tools, APPEND_TO_FILE_CONTINUATION_TOOL];
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
applyCallableToolGrammar(body, this.deps.bridges);
|
|
1249
|
+
// Cache reuse extras from the engine's adapter (Phase 2). Adds
|
|
1250
|
+
// `cache_id: <sessionId>` so our wrapped `gezel_mlx_server.py`
|
|
1251
|
+
// preserves and reuses the prompt cache across turns. Falls
|
|
1252
|
+
// back to no-extras (= upstream behavior, full re-prefill per
|
|
1253
|
+
// turn) when no adapter is wired (tests, no controller setup).
|
|
1254
|
+
// Phase 3 also hands the system prompt to the adapter so it can
|
|
1255
|
+
// register and warm a per-gezel prefix entry — first session
|
|
1256
|
+
// for a new gezel pays cold prefill, sibling sessions inherit
|
|
1257
|
+
// the warm prefix from disk on first turn.
|
|
1258
|
+
const adapter = this.deps.provider.getCacheAdapter();
|
|
1259
|
+
const durableCacheId = opts?.queue?.sessionId;
|
|
1260
|
+
const cacheId = durableCacheId ?? this.fallbackCacheId;
|
|
1261
|
+
// The sidecar stamps per-sub prefill markers with the request's
|
|
1262
|
+
// `cache_id`, so this is the key the provider routes them by. With no
|
|
1263
|
+
// adapter the id never reaches the engine and its markers arrive
|
|
1264
|
+
// untagged — which the fanout treats as engine-wide and broadcasts,
|
|
1265
|
+
// exactly as before.
|
|
1266
|
+
this.currentCacheId = cacheId;
|
|
1267
|
+
if (adapter) {
|
|
1268
|
+
if (!durableCacheId)
|
|
1269
|
+
this.fallbackCacheUsed = true;
|
|
1270
|
+
await adapter.prepareForSend(cacheId, this.deps.systemMessage, this.deps.systemPromptLayers,
|
|
1271
|
+
// The wire tools this turn will actually send. Warming without
|
|
1272
|
+
// them renders a prompt through a different template branch,
|
|
1273
|
+
// which on Qwen shares 3 tokens with the real turn.
|
|
1274
|
+
body.tools, this.deps.systemSharedPrefix);
|
|
1275
|
+
const extras = adapter.buildRequestExtras(cacheId);
|
|
1276
|
+
Object.assign(body, extras);
|
|
1277
|
+
// Diagnostic: one line per request so the next "Stream
|
|
1278
|
+
// finished, cleared cache" investigation is visible from
|
|
1279
|
+
// the TS side without grepping Python stdout. The Python
|
|
1280
|
+
// server prints `[cache] hit/miss cache_id=…` on its end;
|
|
1281
|
+
// cross-referencing both confirms the round-trip works.
|
|
1282
|
+
// Drop to `debug` once cache reuse is confirmed stable.
|
|
1283
|
+
if (turn === 0) {
|
|
1284
|
+
log.info(`turn#${seq} cache extras attached: cache_id=${String(extras.cache_id ?? '')} prefix_cache_id=${String(extras.prefix_cache_id ?? 'none')}`);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
else if (turn === 0) {
|
|
1288
|
+
// The OTHER side of the diagnostic: when cache extras are
|
|
1289
|
+
// skipped, log WHY so we can tell from one line that the adapter
|
|
1290
|
+
// is unwired. Missing queue.sessionId is safe: cacheId falls back
|
|
1291
|
+
// to a stable id owned by this MlxSession. An unwired adapter means
|
|
1292
|
+
// the Python server clears its cache on finish and the next turn
|
|
1293
|
+
// pays full re-prefill — exactly
|
|
1294
|
+
// the prefill-loop the user reported on gemma4-26b.
|
|
1295
|
+
log.warn(`turn#${seq} cache extras NOT attached (full re-prefill expected): ` +
|
|
1296
|
+
`adapter=null cacheId=${cacheId}`);
|
|
1297
|
+
}
|
|
1298
|
+
const externalSignal = opts?.queue?.signal;
|
|
1299
|
+
if (budget.expired())
|
|
1300
|
+
throw turnTimeoutError();
|
|
1301
|
+
// Sleep-aware: `AbortSignal.timeout` fires the instant the host
|
|
1302
|
+
// resumes, which would drop a queued turn that never had its slot.
|
|
1303
|
+
const engineDeadline = createAwakeTimeout(budget.remainingMs());
|
|
1304
|
+
const engineDeadlineSignal = engineDeadline.signal;
|
|
1305
|
+
const engineWaitSignal = externalSignal
|
|
1306
|
+
? AbortSignal.any([externalSignal, engineDeadlineSignal])
|
|
1307
|
+
: engineDeadlineSignal;
|
|
1308
|
+
let releaseEngineRequest;
|
|
1309
|
+
try {
|
|
1310
|
+
releaseEngineRequest = await this.deps.provider.acquireExclusiveEngineRequest(`turn#${seq}.${turn}`, engineWaitSignal,
|
|
1311
|
+
// To the user, waiting for the queue and waiting for the engine
|
|
1312
|
+
// gate are one state: this turn has not started yet.
|
|
1313
|
+
opts?.queue?.onQueueWait, true, enginePriority);
|
|
1314
|
+
}
|
|
1315
|
+
catch (err) {
|
|
1316
|
+
if (err.name === 'AbortError') {
|
|
1317
|
+
if (externalSignal?.aborted) {
|
|
1318
|
+
throw new Error(turnCancelledMessage());
|
|
1319
|
+
}
|
|
1320
|
+
if (engineDeadlineSignal.aborted)
|
|
1321
|
+
throw turnTimeoutError();
|
|
1322
|
+
}
|
|
1323
|
+
throw err;
|
|
1324
|
+
}
|
|
1325
|
+
finally {
|
|
1326
|
+
// The signal only guards the wait for a slot; the turn's own ticker
|
|
1327
|
+
// owns the deadline from here.
|
|
1328
|
+
engineDeadline.dispose();
|
|
1329
|
+
}
|
|
1330
|
+
let engineRequestReleased = false;
|
|
1331
|
+
const releaseEngineRequestOnce = () => {
|
|
1332
|
+
if (engineRequestReleased)
|
|
1333
|
+
return;
|
|
1334
|
+
engineRequestReleased = true;
|
|
1335
|
+
releaseActiveEngineRequest = null;
|
|
1336
|
+
releaseEngineRequest();
|
|
1337
|
+
};
|
|
1338
|
+
releaseActiveEngineRequest = releaseEngineRequestOnce;
|
|
1339
|
+
if (budget.expired()) {
|
|
1340
|
+
releaseEngineRequestOnce();
|
|
1341
|
+
throw turnTimeoutError();
|
|
1342
|
+
}
|
|
1343
|
+
const ctrl = new AbortController();
|
|
1344
|
+
// Tag which trigger fired the abort — distinguishes the
|
|
1345
|
+
// hard-timer vs caller-cancel paths in the log so a hang
|
|
1346
|
+
// diagnosed via "turn#N FETCH-ABORT timer" reads unambiguously
|
|
1347
|
+
// as a model timeout rather than a user-driven cancel.
|
|
1348
|
+
let abortReason = null;
|
|
1349
|
+
const onExternalAbort = () => {
|
|
1350
|
+
abortReason ??= 'caller';
|
|
1351
|
+
ctrl.abort();
|
|
1352
|
+
};
|
|
1353
|
+
if (externalSignal) {
|
|
1354
|
+
if (externalSignal.aborted)
|
|
1355
|
+
onExternalAbort();
|
|
1356
|
+
else
|
|
1357
|
+
externalSignal.addEventListener('abort', onExternalAbort, { once: true });
|
|
1358
|
+
}
|
|
1359
|
+
// Deadline poll rather than a single armed timer. A `setTimeout`
|
|
1360
|
+
// armed before a suspension fires the instant the host resumes, so
|
|
1361
|
+
// the turn dies on the wake-up burst; polling re-reads the awake
|
|
1362
|
+
// budget each tick, so a nap defers the abort instead of causing it.
|
|
1363
|
+
// Drift is bounded by the poll interval, never by the sleep duration.
|
|
1364
|
+
// `budget.expired()` also covers the give-up case — one nap long
|
|
1365
|
+
// enough that the engine's SSE stream is not worth waiting on.
|
|
1366
|
+
const deadlineTicker = setInterval(() => {
|
|
1367
|
+
if (!budget.expired())
|
|
1368
|
+
return;
|
|
1369
|
+
abortReason ??= 'timer';
|
|
1370
|
+
log.error(`turn#${seq}.${turn} ABORT-FIRED reason=timer afterMs=${Date.now() - start} ` +
|
|
1371
|
+
`awakeMs=${Math.round(totalTimeoutMs - budget.remainingMs())} suspendedMs=${budget.suspendedMs()}`);
|
|
1372
|
+
ctrl.abort();
|
|
1373
|
+
clearInterval(deadlineTicker);
|
|
1374
|
+
}, 2_000);
|
|
1375
|
+
// Stream-idle watchdog. mlx_vlm.server has a known SSE
|
|
1376
|
+
// termination quirk where it stops emitting tokens mid-
|
|
1377
|
+
// stream without sending `[DONE]` and without closing the
|
|
1378
|
+
// connection — the for-await loop just hangs forever.
|
|
1379
|
+
// Without this watchdog the UI shows "still working" until
|
|
1380
|
+
// the hard deadline (3 min default) fires. The idle timer
|
|
1381
|
+
// anchors on every SSE event arrival; if the stream is
|
|
1382
|
+
// silent for `streamingIdleMs`, abort and let the catch
|
|
1383
|
+
// path surface what we have so far + a clear message.
|
|
1384
|
+
//
|
|
1385
|
+
// Pre-first-byte uses a longer budget because cold-start
|
|
1386
|
+
// model load + prefill on a 27B Qwen can legitimately take
|
|
1387
|
+
// minutes before the first token. The 180s budget covers a
|
|
1388
|
+
// cold model load before chunked prefill begins emitting
|
|
1389
|
+
// progress events; once `Prefill: …` lines start flowing,
|
|
1390
|
+
// `currentTurnIdleReset` (wired from `publishEnginePhase`)
|
|
1391
|
+
// anchors the watchdog on every prefill chunk so the timer
|
|
1392
|
+
// tracks real progress instead of wall-clock seconds.
|
|
1393
|
+
// Once tokens are flowing, a much tighter budget detects
|
|
1394
|
+
// mid-stream stalls without false-positive aborts on healthy
|
|
1395
|
+
// generation gaps.
|
|
1396
|
+
// Idle-watchdog budgets. Aligned with ollama / llama-cpp's
|
|
1397
|
+
// 300s defaults — earlier MLX-specific tighter values
|
|
1398
|
+
// (60s streaming, 180s pre-first-byte) were killing legitimate
|
|
1399
|
+
// 26B+ Gemma / Qwen turns that go silent for 60–120s while
|
|
1400
|
+
// doing heavy reasoning between tokens or hitting a slow
|
|
1401
|
+
// generation patch. The 30-min hard turn deadline from the
|
|
1402
|
+
// manager still bounds total runtime.
|
|
1403
|
+
//
|
|
1404
|
+
// - PRE_FIRST_BYTE_IDLE_MS: cold-start budget before any
|
|
1405
|
+
// SSE / progress event arrives. Covers model load +
|
|
1406
|
+
// prompt prefill on a 26B model at heavy quant. Now SCALED
|
|
1407
|
+
// by prompt size (computePreFirstByteBudgetMs) — a 37K-token
|
|
1408
|
+
// turn on a 27B-q8 needs far longer to first token than a 2K
|
|
1409
|
+
// "hello", and the old flat 300s aborted the big ones
|
|
1410
|
+
// mid-prefill (the qwen3.6-27b voorman stall).
|
|
1411
|
+
// - PRE_FIRST_BYTE_PER_PROGRESS_MS: budget to re-arm after a
|
|
1412
|
+
// prefill event arrives pre-first-byte. Unified to the SAME
|
|
1413
|
+
// size-scaled value: the serial path emits tqdm chunks every
|
|
1414
|
+
// couple seconds (each just re-arms the generous bound — fine),
|
|
1415
|
+
// while the batched path emits only a single "prefilling N
|
|
1416
|
+
// tokens" start marker and then blocks the event loop through
|
|
1417
|
+
// the whole prefill, so a tight per-chunk timer would
|
|
1418
|
+
// false-abort it. One generous bound serves both.
|
|
1419
|
+
// - STREAMING_IDLE_MS: budget between two consecutive SSE
|
|
1420
|
+
// content events once tokens are flowing. Generous enough
|
|
1421
|
+
// to cover a heavy reasoning gap on a 26B model.
|
|
1422
|
+
const approxPromptTokens = estimatePromptTokens(this.messages, body.tools);
|
|
1423
|
+
const PRE_FIRST_BYTE_IDLE_MS = computePreFirstByteBudgetMs(approxPromptTokens);
|
|
1424
|
+
const PRE_FIRST_BYTE_PER_PROGRESS_MS = PRE_FIRST_BYTE_IDLE_MS;
|
|
1425
|
+
const STREAMING_IDLE_MS = 300_000;
|
|
1426
|
+
if (turn === 0 || PRE_FIRST_BYTE_IDLE_MS > PRE_FIRST_BYTE_BASE_MS) {
|
|
1427
|
+
log.info(`turn#${seq}.${turn} pre-first-byte budget ${Math.round(PRE_FIRST_BYTE_IDLE_MS / 1000)}s ` +
|
|
1428
|
+
`(~${approxPromptTokens.toLocaleString('en-US')} prompt tokens)`);
|
|
1429
|
+
}
|
|
1430
|
+
let idleAbortKind = null;
|
|
1431
|
+
let lastSseEventAt = null;
|
|
1432
|
+
let firstSseEventAt = null;
|
|
1433
|
+
let idleTimer = setTimeout(() => {
|
|
1434
|
+
idleAbortKind = 'pre-first-byte';
|
|
1435
|
+
abortReason ??= 'idle';
|
|
1436
|
+
log.error(`turn#${seq}.${turn} ABORT-FIRED reason=idle (pre-first-byte) afterMs=${Date.now() - start}`);
|
|
1437
|
+
ctrl.abort();
|
|
1438
|
+
}, PRE_FIRST_BYTE_IDLE_MS);
|
|
1439
|
+
const armStreamingIdle = () => {
|
|
1440
|
+
clearTimeout(idleTimer);
|
|
1441
|
+
idleTimer = setTimeout(() => {
|
|
1442
|
+
idleAbortKind = 'streaming';
|
|
1443
|
+
abortReason ??= 'idle';
|
|
1444
|
+
log.error(`turn#${seq}.${turn} ABORT-FIRED reason=idle (streaming) afterMs=${Date.now() - start}`);
|
|
1445
|
+
ctrl.abort();
|
|
1446
|
+
}, STREAMING_IDLE_MS);
|
|
1447
|
+
};
|
|
1448
|
+
const resetIdle = () => {
|
|
1449
|
+
if (firstSseEventAt === null)
|
|
1450
|
+
firstSseEventAt = Date.now();
|
|
1451
|
+
lastSseEventAt = Date.now();
|
|
1452
|
+
this.engineHold.end();
|
|
1453
|
+
armStreamingIdle();
|
|
1454
|
+
};
|
|
1455
|
+
// Pre-first-byte progress reset — fires on prefill events
|
|
1456
|
+
// (and any other engine phase) arriving via the supervisor's
|
|
1457
|
+
// stdout fanout. Uses a tighter per-progress budget than the
|
|
1458
|
+
// initial cold-start budget: once we've seen one prefill
|
|
1459
|
+
// chunk, we know the engine is alive; the next chunk is
|
|
1460
|
+
// expected within ~PER_PROGRESS_MS at typical 200–250 tok/s
|
|
1461
|
+
// for 2K-token batches.
|
|
1462
|
+
const resetIdleFromPhaseEvent = () => {
|
|
1463
|
+
// Only meaningful before the first SSE byte. Once tokens
|
|
1464
|
+
// flow, `resetIdle()` (called inside the SSE loop) takes
|
|
1465
|
+
// over with the tighter STREAMING_IDLE_MS budget.
|
|
1466
|
+
if (firstSseEventAt !== null)
|
|
1467
|
+
return;
|
|
1468
|
+
clearTimeout(idleTimer);
|
|
1469
|
+
idleTimer = setTimeout(() => {
|
|
1470
|
+
idleAbortKind = 'pre-first-byte';
|
|
1471
|
+
abortReason ??= 'idle';
|
|
1472
|
+
log.error(`turn#${seq}.${turn} ABORT-FIRED reason=idle (pre-first-byte, after progress) afterMs=${Date.now() - start}`);
|
|
1473
|
+
ctrl.abort();
|
|
1474
|
+
}, PRE_FIRST_BYTE_PER_PROGRESS_MS);
|
|
1475
|
+
};
|
|
1476
|
+
this.currentTurnIdleReset = resetIdleFromPhaseEvent;
|
|
1477
|
+
this.lastPrefillEvent = null;
|
|
1478
|
+
// Engine-side holds (see engine-hold.ts): every `waiting` / `paused`
|
|
1479
|
+
// marker proves the engine is alive and busy, so it re-arms whichever
|
|
1480
|
+
// idle bound is live. The turn's own deadline (`budget`) still bounds
|
|
1481
|
+
// the whole wait.
|
|
1482
|
+
this.engineHold.end();
|
|
1483
|
+
this.currentTurnEngineQueue = (queue) => {
|
|
1484
|
+
if (queue.state === 'admitted') {
|
|
1485
|
+
if (this.engineHold.label === null)
|
|
1486
|
+
return;
|
|
1487
|
+
this.engineHold.end();
|
|
1488
|
+
if (firstSseEventAt === null)
|
|
1489
|
+
emitPrep();
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
if (firstSseEventAt === null)
|
|
1493
|
+
resetIdleFromPhaseEvent();
|
|
1494
|
+
else
|
|
1495
|
+
armStreamingIdle();
|
|
1496
|
+
if (firstSseEventAt !== null) {
|
|
1497
|
+
// Paused mid-reply: the stream stops until the chat ahead is
|
|
1498
|
+
// served. The reply so far stays on screen; the pill says why.
|
|
1499
|
+
const detail = engineHoldLabelFor(queue);
|
|
1500
|
+
this.emitEnginePhase({ provider: 'mlx', phase: 'generating', detail });
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
const detail = this.engineHold.begin(queue, opts?.queue?.onQueueWait);
|
|
1504
|
+
this.emitEnginePhase({ provider: 'mlx', phase: 'prefill', detail });
|
|
1505
|
+
};
|
|
1506
|
+
// Anchored at request dispatch: only work the engine did while THIS
|
|
1507
|
+
// turn was already waiting can explain why it never got a first byte.
|
|
1508
|
+
const requestDispatchedAt = Date.now();
|
|
1509
|
+
const busyElsewhere = () => {
|
|
1510
|
+
const other = this.deps.provider.prefillForOtherSessionSince(this.currentCacheId ?? null, requestDispatchedAt);
|
|
1511
|
+
if (!other)
|
|
1512
|
+
return null;
|
|
1513
|
+
return {
|
|
1514
|
+
detail: other.detail,
|
|
1515
|
+
secondsAgo: Math.max(0, Math.round((Date.now() - other.at) / 1000)),
|
|
1516
|
+
};
|
|
1517
|
+
};
|
|
1518
|
+
const cleanupTurn = () => {
|
|
1519
|
+
clearInterval(deadlineTicker);
|
|
1520
|
+
clearTimeout(idleTimer);
|
|
1521
|
+
this.engineHold.end();
|
|
1522
|
+
this.currentTurnEngineQueue = null;
|
|
1523
|
+
this.currentTurnIdleReset = null;
|
|
1524
|
+
releaseEngineRequestOnce();
|
|
1525
|
+
externalSignal?.removeEventListener('abort', onExternalAbort);
|
|
1526
|
+
};
|
|
1527
|
+
// Checkpoint the exact transcript this request sends, so a restart
|
|
1528
|
+
// can reseed the session with the prompt the engine is about to cache
|
|
1529
|
+
// instead of a lossy rebuild that matches only its system head. Taken
|
|
1530
|
+
// here, after every in-iteration edit — the immediate-write suffix
|
|
1531
|
+
// lands on the user message late.
|
|
1532
|
+
if (this.deps.onWireTranscript) {
|
|
1533
|
+
const transcript = mlxWireTranscript(messages);
|
|
1534
|
+
if (transcript)
|
|
1535
|
+
this.deps.onWireTranscript(transcript);
|
|
1536
|
+
}
|
|
1537
|
+
if (debugOn) {
|
|
1538
|
+
log.debug(`turn#${seq}.${turn} FETCH baseUrl=${baseUrl} model=${this.deps.model} ` +
|
|
1539
|
+
`bodyMsgs=${this.messages.length} tools=${tools?.length ?? 0}`);
|
|
1540
|
+
}
|
|
1541
|
+
let res;
|
|
1542
|
+
try {
|
|
1543
|
+
res = await this.deps.fetchImpl(`${baseUrl}/v1/chat/completions`, {
|
|
1544
|
+
method: 'POST',
|
|
1545
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1546
|
+
body: JSON.stringify(body),
|
|
1547
|
+
signal: ctrl.signal,
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
catch (err) {
|
|
1551
|
+
cleanupTurn();
|
|
1552
|
+
this.deps.markUsed();
|
|
1553
|
+
log.error(`turn#${seq}.${turn} FETCH-ERR ${err.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1554
|
+
if (err.name === 'AbortError') {
|
|
1555
|
+
if (externalSignal?.aborted) {
|
|
1556
|
+
throw new Error(turnCancelledMessage());
|
|
1557
|
+
}
|
|
1558
|
+
if (abortReason === 'idle') {
|
|
1559
|
+
// Idle-fired during the fetch itself (no response body yet).
|
|
1560
|
+
// If we observed prefill chunks, attribute the stall to the
|
|
1561
|
+
// last seen progress point — much more useful than "no
|
|
1562
|
+
// first byte" when the model was clearly working.
|
|
1563
|
+
throw new Error(buildPreFirstByteAbortMessage(this.lastPrefillEvent, busyElsewhere()));
|
|
1564
|
+
}
|
|
1565
|
+
throw turnTimeoutError();
|
|
1566
|
+
}
|
|
1567
|
+
throw new Error(`[Mac AI] couldn't reach the engine at ${baseUrl}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1568
|
+
}
|
|
1569
|
+
if (!res.ok || !res.body) {
|
|
1570
|
+
cleanupTurn();
|
|
1571
|
+
this.deps.markUsed();
|
|
1572
|
+
const txt = await res.text().catch(() => '');
|
|
1573
|
+
log.error(`turn#${seq}.${turn} HTTP-ERR status=${res.status} ${res.statusText}`);
|
|
1574
|
+
throw new Error(translateMlxHttpError(res.status, res.statusText, txt));
|
|
1575
|
+
}
|
|
1576
|
+
if (debugOn) {
|
|
1577
|
+
log.debug(`turn#${seq}.${turn} HTTP-OK status=${res.status} ` +
|
|
1578
|
+
`headers={content-type:${res.headers.get('content-type') ?? '?'}}`);
|
|
1579
|
+
}
|
|
1580
|
+
if (firstTokenAt === null) {
|
|
1581
|
+
// The request is in; we're now prefilling the prompt. Switch the
|
|
1582
|
+
// heartbeat label and emit once immediately so the pill updates
|
|
1583
|
+
// without waiting for the next heartbeat tick.
|
|
1584
|
+
prepLabel = 'Processing prompt';
|
|
1585
|
+
emitPrep();
|
|
1586
|
+
}
|
|
1587
|
+
let turnContent = '';
|
|
1588
|
+
let finishReason = null;
|
|
1589
|
+
const toolCallAccumulator = new MlxToolCallAccumulator();
|
|
1590
|
+
// Code-block salvage (Phase: gemma-26b orchestration). When the
|
|
1591
|
+
// ramble detector aborts a turn with NO recognizable tool-call
|
|
1592
|
+
// markup but the buffered prose DOES contain a fenced code
|
|
1593
|
+
// block (the "Here's the file: ```html …```" pattern small
|
|
1594
|
+
// models drift into instead of calling write_file), we promote
|
|
1595
|
+
// each block to a synthesized `write_artifact` call. The
|
|
1596
|
+
// accumulator below lives outside the try/catch so the post-
|
|
1597
|
+
// exit merge can include it alongside the other salvage
|
|
1598
|
+
// sources.
|
|
1599
|
+
let codeBlockRepaired = [];
|
|
1600
|
+
// Wall-of-prose detector. Verbose-family models (Qwen 3.6
|
|
1601
|
+
// 27B at heavy quant in particular) emit a tool-call markup
|
|
1602
|
+
// block early in the turn, then continue narrating for
|
|
1603
|
+
// thousands of characters about what they "would" do next —
|
|
1604
|
+
// the failure mode the about.md "stuck planning" rule warns
|
|
1605
|
+
// about. The original boolean version of this detector
|
|
1606
|
+
// disarmed permanently after the first tool-call signal
|
|
1607
|
+
// and missed exactly that pattern; `RambleDetector` measures
|
|
1608
|
+
// prose-since-last-action so the cap re-arms after every
|
|
1609
|
+
// markup block.
|
|
1610
|
+
//
|
|
1611
|
+
// Threshold: 6000 chars (~1500 tokens) of prose past the
|
|
1612
|
+
// last action. Generous enough that legitimate medium-length
|
|
1613
|
+
// tail explanations pass through; tight enough to catch the
|
|
1614
|
+
// "wrote one tool then rambled" wall before the user has
|
|
1615
|
+
// waited 90+ seconds. Gated on profile opt-in via
|
|
1616
|
+
// `turn.ramble-detection`; per-model thresholds come from
|
|
1617
|
+
// the validated config. Absent → detector disabled.
|
|
1618
|
+
//
|
|
1619
|
+
// Read from the request, not only the template: a thinking-off
|
|
1620
|
+
// request renders the block already closed, and seeding from the
|
|
1621
|
+
// template streamed a whole `write_file` call into the reasoning pane.
|
|
1622
|
+
const opensInReasoning = requestOpensReasoning(this.deps.templateOpensReasoning, body);
|
|
1623
|
+
const rambleConfig = profileBehaviorConfig(this.deps.profile, 'turn.ramble-detection');
|
|
1624
|
+
const ramble = outputIsConstrained(this.deps.tuning)
|
|
1625
|
+
? inertRambleDetector()
|
|
1626
|
+
: rambleConfig
|
|
1627
|
+
? new RambleDetector({
|
|
1628
|
+
threshold: rambleConfig.coldThreshold,
|
|
1629
|
+
postActionThreshold: rambleConfig.postActionThreshold,
|
|
1630
|
+
enabled: true,
|
|
1631
|
+
// Models that leak untagged reasoning narrate their plan
|
|
1632
|
+
// in the open before acting; give the cold cap room to
|
|
1633
|
+
// reach the tool call. Prefer the profile opt-in, but also
|
|
1634
|
+
// honor the model-id family signal so a verbose family whose
|
|
1635
|
+
// profile is missing `turn.preamble-folding` (config drift)
|
|
1636
|
+
// still gets the leaky budget instead of the tight 6k cap.
|
|
1637
|
+
leakyReasoning: profileHasBehavior(this.deps.profile, 'turn.preamble-folding') ||
|
|
1638
|
+
leaksUntaggedReasoning(this.deps.model),
|
|
1639
|
+
// MLX inlines chain-of-thought in the content deltas, and
|
|
1640
|
+
// a template that opens `<think>` itself never shows the
|
|
1641
|
+
// detector an open marker — so without this the whole
|
|
1642
|
+
// reasoning block is scored as cold prose. Same flag the
|
|
1643
|
+
// StreamingReasoningSplit below is seeded with.
|
|
1644
|
+
opensInReasoning,
|
|
1645
|
+
})
|
|
1646
|
+
: // Repetition guard is safe on any local model (fires only on
|
|
1647
|
+
// degenerate low-novelty loops); arm it even without the
|
|
1648
|
+
// length-cap opt-in. See RambleDetector. Still needs the
|
|
1649
|
+
// reasoning-open seed: the guard exempts reasoning spans, and
|
|
1650
|
+
// a template-opened `<think>` is one.
|
|
1651
|
+
new RambleDetector({
|
|
1652
|
+
threshold: 6000,
|
|
1653
|
+
enabled: false,
|
|
1654
|
+
repetitionGuardEnabled: true,
|
|
1655
|
+
opensInReasoning,
|
|
1656
|
+
});
|
|
1657
|
+
let rambleAborted = false;
|
|
1658
|
+
// Single-call turn: stop the stream once the one usable call is
|
|
1659
|
+
// complete in the VISIBLE text. Reasoning is excluded so a call the
|
|
1660
|
+
// model drafts while thinking can't end the turn early.
|
|
1661
|
+
let singleCallComplete = false;
|
|
1662
|
+
let singleCallVisible = '';
|
|
1663
|
+
// Tool name for the live tool-args channel — only the first
|
|
1664
|
+
// fragment of a streamed tool call carries `function.name`.
|
|
1665
|
+
let liveToolArgsName = '';
|
|
1666
|
+
// Drop chat-template tool-call markers (Gemma 3+ leaks
|
|
1667
|
+
// `<|tool_call|>...<tool_call|>` token text into the content
|
|
1668
|
+
// stream even though it ALSO ships a structured `tool_calls`
|
|
1669
|
+
// delta — the structured one is what the UI renders, the text
|
|
1670
|
+
// version is just noise). Streaming-safe: holds back fragments
|
|
1671
|
+
// that could be a partial marker prefix until disambiguated.
|
|
1672
|
+
const stripper = new LeakyToolCallStripper();
|
|
1673
|
+
// MLX inlines chain-of-thought in the same content deltas as the
|
|
1674
|
+
// reply; only the end-of-turn `extractReasoningWithProfile` pass below
|
|
1675
|
+
// separates them. Everything reading live deltas — the chat bubble and
|
|
1676
|
+
// every connected app through the OpenAI-compatible facade — would
|
|
1677
|
+
// otherwise render thinking as the answer. This is the dedicated
|
|
1678
|
+
// reasoning channel llama.cpp and ds4 get from their engines.
|
|
1679
|
+
const reasoningSplit = new StreamingReasoningSplit({
|
|
1680
|
+
opensInReasoning,
|
|
1681
|
+
enabled: this.deps.profile?.style.reasoningFormat !== 'none',
|
|
1682
|
+
});
|
|
1683
|
+
// Throttle live phase emissions during generation so we don't
|
|
1684
|
+
// saturate the SSE fanout with one phase event per token —
|
|
1685
|
+
// ~3 Hz is fast enough that the tok/s readout feels live, slow
|
|
1686
|
+
// enough that the UI's React reconciler doesn't choke on a
|
|
1687
|
+
// 30-tok/s burst from a small model.
|
|
1688
|
+
let lastPhaseAt = 0;
|
|
1689
|
+
const PHASE_EMIT_MIN_INTERVAL_MS = 300;
|
|
1690
|
+
// Stream-active heartbeat to the daemon LOG (distinct from the UI-bus
|
|
1691
|
+
// phase emits above, which never reach stdout). The eval harness's
|
|
1692
|
+
// soft-progress watchdog digests daemon.log; with no periodic line
|
|
1693
|
+
// during a long MLX decode it reads active generation as a stall and
|
|
1694
|
+
// false-fails big models mid-first-turn. Mirror the llama-cpp
|
|
1695
|
+
// provider's `[<engine>] stream-active` pulse so the watchdog sees the
|
|
1696
|
+
// engine is alive. ~5s cadence keeps the log quiet.
|
|
1697
|
+
let lastStreamPulseAt = 0;
|
|
1698
|
+
const STREAM_PULSE_INTERVAL_MS = 5_000;
|
|
1699
|
+
// SSE-loop exit attribution. The loop can leave four ways:
|
|
1700
|
+
// explicit `[DONE]` marker (healthy), natural stream end with no
|
|
1701
|
+
// `[DONE]` (mlx_vlm.server bug — server closed before sentinel),
|
|
1702
|
+
// exception (caught below), or it never exits (caller sees the
|
|
1703
|
+
// hardTimer abort fire and the catch turns it into AbortError).
|
|
1704
|
+
// The terminator is logged unconditionally on the way out so the
|
|
1705
|
+
// smoking gun for "second turn hangs" shows up in regular logs.
|
|
1706
|
+
let sseChunks = 0;
|
|
1707
|
+
let sseExitReason = 'stream-end';
|
|
1708
|
+
try {
|
|
1709
|
+
for await (const event of readSseEvents(res.body)) {
|
|
1710
|
+
// Anchor the idle watchdog on every event — content
|
|
1711
|
+
// chunks AND framing chunks (usage updates, empty
|
|
1712
|
+
// deltas) all count as "stream is alive."
|
|
1713
|
+
resetIdle();
|
|
1714
|
+
sseChunks++;
|
|
1715
|
+
if (event === '[DONE]') {
|
|
1716
|
+
sseExitReason = 'done';
|
|
1717
|
+
break;
|
|
1718
|
+
}
|
|
1719
|
+
const chunk = event;
|
|
1720
|
+
const choice = chunk.choices?.[0];
|
|
1721
|
+
const delta = choice?.delta;
|
|
1722
|
+
const hasContent = Boolean(delta?.content);
|
|
1723
|
+
const hasToolCalls = Boolean(delta?.tool_calls && delta.tool_calls.length > 0);
|
|
1724
|
+
// mlx_vlm.server emits Responses-API field names; older
|
|
1725
|
+
// OpenAI-compatible servers use the Chat-Completions ones.
|
|
1726
|
+
// Read both so this code works against either shape.
|
|
1727
|
+
const usage = chunk.usage;
|
|
1728
|
+
const promptTokens = usage?.input_tokens ?? usage?.prompt_tokens;
|
|
1729
|
+
const completionTokens = usage?.output_tokens ?? usage?.completion_tokens;
|
|
1730
|
+
const promptTps = usage?.prompt_tps;
|
|
1731
|
+
const generationTps = usage?.generation_tps;
|
|
1732
|
+
const cachedTokens = usage?.cached_tokens;
|
|
1733
|
+
// A structured-tool response may never emit visible `content`.
|
|
1734
|
+
// Count its first argument fragment as model activity so TTFT is
|
|
1735
|
+
// still observable and the engine phase advances to generating.
|
|
1736
|
+
if ((hasContent || hasToolCalls) && lastIterationFirstTokenAt === null) {
|
|
1737
|
+
lastIterationFirstTokenAt = Date.now();
|
|
1738
|
+
}
|
|
1739
|
+
if ((hasContent || hasToolCalls) && firstTokenAt === null) {
|
|
1740
|
+
firstTokenAt = lastIterationFirstTokenAt ?? Date.now();
|
|
1741
|
+
const ttft = firstTokenAt - start;
|
|
1742
|
+
log.info(`TTFT ${ttft}ms (session model=${this.deps.model})`);
|
|
1743
|
+
// First-token detail folds in the prefill speed when
|
|
1744
|
+
// the engine surfaces it. Prefill happens entirely
|
|
1745
|
+
// inside `generate_step`, so this is the first chance
|
|
1746
|
+
// we get to put a number on it.
|
|
1747
|
+
const prefillSuffix = promptTps && promptTps > 0 ? ` · prefill ${formatTps(promptTps)} tok/s` : '';
|
|
1748
|
+
this.emitEnginePhase({
|
|
1749
|
+
provider: 'mlx',
|
|
1750
|
+
phase: 'generating',
|
|
1751
|
+
detail: `First token in ${(ttft / 1000).toFixed(1)}s${prefillSuffix}`,
|
|
1752
|
+
ttftMs: ttft,
|
|
1753
|
+
});
|
|
1754
|
+
lastPhaseAt = firstTokenAt;
|
|
1755
|
+
}
|
|
1756
|
+
if ((hasContent || hasToolCalls) && firstTokenAt !== null) {
|
|
1757
|
+
const now = Date.now();
|
|
1758
|
+
if (now - lastPhaseAt >= PHASE_EMIT_MIN_INTERVAL_MS) {
|
|
1759
|
+
this.emitEnginePhase({
|
|
1760
|
+
provider: 'mlx',
|
|
1761
|
+
phase: 'generating',
|
|
1762
|
+
// No `detail`: the counters are the payload, and the
|
|
1763
|
+
// phase's own copy belongs to the UI. Composing them
|
|
1764
|
+
// into prose here only forced the pill to scrape them
|
|
1765
|
+
// back out — and the scrape left the header reading
|
|
1766
|
+
// "Koray · · 61 tokens".
|
|
1767
|
+
...(completionTokens !== undefined && completionTokens > 0
|
|
1768
|
+
? { outputTokens: completionTokens }
|
|
1769
|
+
: {}),
|
|
1770
|
+
...(generationTps !== undefined && generationTps > 0
|
|
1771
|
+
? { tokensPerSec: generationTps }
|
|
1772
|
+
: {}),
|
|
1773
|
+
});
|
|
1774
|
+
lastPhaseAt = now;
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
if (hasContent) {
|
|
1778
|
+
// Run the raw chunk through the marker stripper before
|
|
1779
|
+
// it lands anywhere user-visible. The TTFT / generating
|
|
1780
|
+
// phase emissions still fire on raw-chunk presence —
|
|
1781
|
+
// they're a "model is producing tokens" signal, not a
|
|
1782
|
+
// "user sees content" signal — so leaked-marker chunks
|
|
1783
|
+
// still tick the engine pill correctly even when the
|
|
1784
|
+
// visible output is empty.
|
|
1785
|
+
const safeContent = stripper.push(delta.content);
|
|
1786
|
+
if (safeContent.length > 0)
|
|
1787
|
+
turnContent += safeContent;
|
|
1788
|
+
{
|
|
1789
|
+
// Watchdog heartbeat to the daemon log (see decl above).
|
|
1790
|
+
const now = Date.now();
|
|
1791
|
+
if (now - lastStreamPulseAt >= STREAM_PULSE_INTERVAL_MS) {
|
|
1792
|
+
const tps = generationTps && generationTps > 0
|
|
1793
|
+
? ` · ${formatTps(generationTps)} tok/s`
|
|
1794
|
+
: '';
|
|
1795
|
+
log.info(`[mlx] stream-active tokens=${completionTokens ?? 0}${tps}`);
|
|
1796
|
+
lastStreamPulseAt = now;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
if (safeContent.length > 0) {
|
|
1800
|
+
// `turnContent` keeps the raw text on purpose: ramble
|
|
1801
|
+
// detection reads the reasoning phase, and the end-of-turn
|
|
1802
|
+
// split still owns what gets committed. Only what goes out
|
|
1803
|
+
// live is separated here.
|
|
1804
|
+
const live = reasoningSplit.push(safeContent);
|
|
1805
|
+
if (live.visible.length > 0)
|
|
1806
|
+
this.emitDelta(live.visible);
|
|
1807
|
+
if (live.reasoning.length > 0)
|
|
1808
|
+
this.emitReasoningDelta(live.reasoning);
|
|
1809
|
+
if (this.deps.singleToolCallTurn &&
|
|
1810
|
+
!singleCallComplete &&
|
|
1811
|
+
live.visible.length > 0) {
|
|
1812
|
+
singleCallVisible += live.visible;
|
|
1813
|
+
if (hasCompleteToolCallMarkup(singleCallVisible)) {
|
|
1814
|
+
singleCallComplete = true;
|
|
1815
|
+
log.info(`turn#${seq}.${turn} ABORT-FIRED reason=single-call-complete ` +
|
|
1816
|
+
`afterMs=${Date.now() - start}`);
|
|
1817
|
+
ctrl.abort();
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
if (!singleCallComplete && !rambleAborted && ramble.observeContent(turnContent)) {
|
|
1822
|
+
rambleAborted = true;
|
|
1823
|
+
abortReason ??= 'idle';
|
|
1824
|
+
log.error(`turn#${seq}.${turn} ABORT-FIRED reason=ramble ` +
|
|
1825
|
+
`(${ramble.firedOnRepetition ? 'repetition-loop' : ramble.isInsideReasoning ? 'reasoning' : ramble.inPostActionMode ? 'post-action' : 'cold'}; ` +
|
|
1826
|
+
`${ramble.proseSinceLastAction} chars since last signal, cap ${ramble.activeThreshold}) ` +
|
|
1827
|
+
`afterMs=${Date.now() - start}`);
|
|
1828
|
+
ctrl.abort();
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
if (hasToolCalls) {
|
|
1832
|
+
// Tool-argument streaming emits no deltas — pulse so long
|
|
1833
|
+
// structured writes read as live activity, not a stall.
|
|
1834
|
+
this.emitWirePulse();
|
|
1835
|
+
ramble.recordStructuredAction(turnContent.length);
|
|
1836
|
+
for (const tc of delta.tool_calls) {
|
|
1837
|
+
toolCallAccumulator.ingest(tc);
|
|
1838
|
+
// Live tool-args channel — see the llama-cpp counterpart.
|
|
1839
|
+
if (tc.function?.name)
|
|
1840
|
+
liveToolArgsName = tc.function.name;
|
|
1841
|
+
const argChunk = tc.function?.arguments ?? '';
|
|
1842
|
+
if (tc.function?.name || argChunk.length > 0) {
|
|
1843
|
+
this.emitToolArgsDelta(liveToolArgsName, argChunk, {
|
|
1844
|
+
index: tc.index,
|
|
1845
|
+
...(tc.id ? { id: tc.id } : {}),
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
if (usage && promptTokens !== undefined && completionTokens !== undefined) {
|
|
1851
|
+
lastUsage = {
|
|
1852
|
+
prompt_tokens: promptTokens,
|
|
1853
|
+
completion_tokens: completionTokens,
|
|
1854
|
+
...(promptTps !== undefined ? { prompt_tps: promptTps } : {}),
|
|
1855
|
+
...(generationTps !== undefined ? { generation_tps: generationTps } : {}),
|
|
1856
|
+
...(cachedTokens !== undefined ? { cached_tokens: cachedTokens } : {}),
|
|
1857
|
+
};
|
|
1858
|
+
}
|
|
1859
|
+
if (choice?.finish_reason) {
|
|
1860
|
+
finishReason = choice.finish_reason;
|
|
1861
|
+
}
|
|
1862
|
+
if (!hasContent && !hasToolCalls && !chunk.usage) {
|
|
1863
|
+
this.emitWirePulse();
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
catch (err) {
|
|
1868
|
+
sseExitReason = 'error';
|
|
1869
|
+
cleanupTurn();
|
|
1870
|
+
this.deps.markUsed();
|
|
1871
|
+
const stoppedAfterSingleCall = singleCallComplete && err.name === 'AbortError' && !externalSignal?.aborted;
|
|
1872
|
+
if (stoppedAfterSingleCall) {
|
|
1873
|
+
log.info(`turn#${seq}.${turn} SSE-EXIT reason=single-call-complete chunks=${sseChunks} contentChars=${turnContent.length} afterMs=${Date.now() - start}`);
|
|
1874
|
+
}
|
|
1875
|
+
else {
|
|
1876
|
+
log.error(`turn#${seq}.${turn} SSE-EXIT reason=error chunks=${sseChunks} contentChars=${turnContent.length} afterMs=${Date.now() - start} ${err.name}: ${err instanceof Error ? err.message : String(err)}${abortReason ? ` (abort=${abortReason})` : ''}`);
|
|
1877
|
+
}
|
|
1878
|
+
// Recovery path: a `ramble` abort that has salvageable
|
|
1879
|
+
// tool-call markup falls through to the salvage block
|
|
1880
|
+
// below instead of throwing — keeps the model's queued
|
|
1881
|
+
// tools alive so the next iteration sees results. Set
|
|
1882
|
+
// by the ramble branch when we want the catch to NOT
|
|
1883
|
+
// re-throw `err` at the end. A single-call stop is the same
|
|
1884
|
+
// fall-through with nothing to warn about.
|
|
1885
|
+
let recoveredFromRamble = false;
|
|
1886
|
+
if (err.name === 'AbortError') {
|
|
1887
|
+
if (externalSignal?.aborted) {
|
|
1888
|
+
throw new Error(turnCancelledMessage());
|
|
1889
|
+
}
|
|
1890
|
+
if (stoppedAfterSingleCall) {
|
|
1891
|
+
finishReason ??= 'single-call-complete';
|
|
1892
|
+
recoveredFromRamble = true;
|
|
1893
|
+
}
|
|
1894
|
+
else if (rambleAborted) {
|
|
1895
|
+
const hasSalvageableMarkup = /<tool_call>|<function=/i.test(turnContent);
|
|
1896
|
+
if (hasSalvageableMarkup) {
|
|
1897
|
+
this.emitWarning(ramble.firedOnRepetition
|
|
1898
|
+
? 'Stopped a repeating output loop — firing the tool calls you queued.'
|
|
1899
|
+
: `Stopped the planning monologue (${turnContent.length} chars without follow-through) — firing the tool calls you queued. Take a smaller next step.`);
|
|
1900
|
+
finishReason ??= 'ramble-recovered';
|
|
1901
|
+
recoveredFromRamble = true;
|
|
1902
|
+
}
|
|
1903
|
+
else {
|
|
1904
|
+
// Last-ditch salvage: small models often inline code in
|
|
1905
|
+
// fenced blocks instead of calling the write tool.
|
|
1906
|
+
// Promote each fenced block to a synthesized write call.
|
|
1907
|
+
//
|
|
1908
|
+
// Prefer `write_file`: a salvaged fenced block is source
|
|
1909
|
+
// the user ships, which belongs in the workspace, not
|
|
1910
|
+
// the artifacts drawer — the abort copy itself says
|
|
1911
|
+
// "don't save source files with write_artifact." Fall
|
|
1912
|
+
// back to `write_artifact` only when the role has no
|
|
1913
|
+
// workspace-write surface (e.g. a research/notes role).
|
|
1914
|
+
// Either way, gate on the chosen tool being wired —
|
|
1915
|
+
// fabricating a call to a tool that doesn't exist would
|
|
1916
|
+
// just trip the validate-ids wrapper. Wild-caught
|
|
1917
|
+
// (qwen3.6 consultation): a write_file-only
|
|
1918
|
+
// Builder role rambled the HTML in chat, but this gate
|
|
1919
|
+
// checked ONLY `write_artifact` and skipped salvage,
|
|
1920
|
+
// so the whole turn (and the drafted file) was thrown
|
|
1921
|
+
// away. Both tools take the same `{path, content}`
|
|
1922
|
+
// shape, so the synthesized args are identical. Only
|
|
1923
|
+
// fires when there's no other salvageable signal (the
|
|
1924
|
+
// structured markup branch above handled the easy cases).
|
|
1925
|
+
const salvageToolName = knownToolNames.has('write_file')
|
|
1926
|
+
? 'write_file'
|
|
1927
|
+
: knownToolNames.has('write_artifact')
|
|
1928
|
+
? 'write_artifact'
|
|
1929
|
+
: null;
|
|
1930
|
+
if (salvageToolName) {
|
|
1931
|
+
const blocks = prepareSalvagedCodeBlocks(turnContent, this.deps.activeCraftbookStep?.deliverableFile);
|
|
1932
|
+
if (blocks.length > 0) {
|
|
1933
|
+
codeBlockRepaired = blocks.map((b, i) => ({
|
|
1934
|
+
id: `code-salvage-${seq}-${turn}-${i}`,
|
|
1935
|
+
type: 'function',
|
|
1936
|
+
function: {
|
|
1937
|
+
name: salvageToolName,
|
|
1938
|
+
arguments: JSON.stringify({
|
|
1939
|
+
path: b.filename,
|
|
1940
|
+
content: b.content,
|
|
1941
|
+
}),
|
|
1942
|
+
},
|
|
1943
|
+
}));
|
|
1944
|
+
log.info(`turn#${seq}.${turn} code-block-salvage: extracted ${blocks.length} ` +
|
|
1945
|
+
`fenced block(s) from ${turnContent.length}-char ramble buffer ` +
|
|
1946
|
+
`→ ${salvageToolName} (${blocks.map((b) => `${b.lang}:${b.filename}`).join(', ')})`);
|
|
1947
|
+
this.emitWarning(`The model wrote ${blocks.length === 1 ? 'a code block' : `${blocks.length} code blocks`} in chat instead of calling ${salvageToolName} — promoting them to actual file writes. Ask the model to use tools directly next time.`);
|
|
1948
|
+
finishReason ??= 'ramble-recovered';
|
|
1949
|
+
recoveredFromRamble = true;
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
if (!recoveredFromRamble) {
|
|
1953
|
+
// Log a content preview so future regressions are easier
|
|
1954
|
+
// to diagnose: the ramble buffer is otherwise lost when
|
|
1955
|
+
// the throw fires.
|
|
1956
|
+
const head = turnContent.slice(0, 400).replace(/\s+/g, ' ');
|
|
1957
|
+
const tail = turnContent.slice(-200).replace(/\s+/g, ' ');
|
|
1958
|
+
log.warn(`turn#${seq}.${turn} ramble-no-salvage preview head=${JSON.stringify(head)} tail=${JSON.stringify(tail)}`);
|
|
1959
|
+
throw new Error(buildRambleAbortMessage({
|
|
1960
|
+
providerLabel: '[Mac AI]',
|
|
1961
|
+
charCount: turnContent.length,
|
|
1962
|
+
knownToolNames,
|
|
1963
|
+
...(this.deps.activeCraftbookStep?.deliverableFile
|
|
1964
|
+
? { deliverableFile: this.deps.activeCraftbookStep.deliverableFile }
|
|
1965
|
+
: {}),
|
|
1966
|
+
...(this.deps.activeCraftbookStep?.deliverableIsArtifact
|
|
1967
|
+
? { deliverableIsArtifact: true }
|
|
1968
|
+
: {}),
|
|
1969
|
+
}));
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
else if (abortReason === 'idle') {
|
|
1974
|
+
// Build a stall-aware message. Pre-first-byte vs
|
|
1975
|
+
// streaming idle land on different sentences so the
|
|
1976
|
+
// user can tell whether the model never started or
|
|
1977
|
+
// started then froze. The chars-received counter is
|
|
1978
|
+
// informative when the bubble would otherwise look
|
|
1979
|
+
// empty.
|
|
1980
|
+
if (idleAbortKind === 'pre-first-byte') {
|
|
1981
|
+
throw new Error(buildPreFirstByteAbortMessage(this.lastPrefillEvent, busyElsewhere()));
|
|
1982
|
+
}
|
|
1983
|
+
const sinceFirst = firstSseEventAt !== null ? Math.round((Date.now() - firstSseEventAt) / 1000) : null;
|
|
1984
|
+
const sinceLast = lastSseEventAt !== null ? Math.round((Date.now() - lastSseEventAt) / 1000) : null;
|
|
1985
|
+
const stats = sinceFirst
|
|
1986
|
+
? `received ${turnContent.length} chars in ${sinceFirst}s before going silent for ${sinceLast}s`
|
|
1987
|
+
: `received ${turnContent.length} chars before stalling`;
|
|
1988
|
+
// Engine facts go to the log; the person gets what happened and
|
|
1989
|
+
// what to do. A stream parked for a waiting chat never lands
|
|
1990
|
+
// here — its `paused` markers re-arm this bound.
|
|
1991
|
+
log.warn(`turn#${seq}.${turn} streaming idle abort: ${stats}`);
|
|
1992
|
+
const quietSeconds = Math.round(STREAMING_IDLE_MS / 1000);
|
|
1993
|
+
throw new Error(turnContent.length === 0
|
|
1994
|
+
? `[Mac AI] The model went quiet for ${quietSeconds} seconds before writing anything, so this turn was stopped. Try again; if it keeps happening, restart the engine in Settings → On-device.`
|
|
1995
|
+
: `[Mac AI] The model went quiet for ${quietSeconds} seconds partway through its reply, so this turn was stopped. Try again; if it keeps happening, restart the engine in Settings → On-device.`);
|
|
1996
|
+
}
|
|
1997
|
+
else {
|
|
1998
|
+
throw turnTimeoutError();
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
// Not one of our aborts (those are AbortErrors, handled above).
|
|
2002
|
+
// A non-abort failure mid-body is almost always the engine
|
|
2003
|
+
// dropping the HTTP stream from under us — undici surfaces a
|
|
2004
|
+
// premature socket close as a bare `TypeError: terminated`,
|
|
2005
|
+
// which is what reached the user before this. Translate it into
|
|
2006
|
+
// something actionable instead of the cryptic one-word message;
|
|
2007
|
+
// the original text still rides along in the persisted warning.
|
|
2008
|
+
if (!recoveredFromRamble && isMidStreamConnectionDrop(err)) {
|
|
2009
|
+
throw new Error(buildMidStreamDropMessage(turnContent.length, this.deps.provider.isDisposed, this.deps.provider.engineLifecycleSnapshot()?.running));
|
|
2010
|
+
}
|
|
2011
|
+
if (!recoveredFromRamble)
|
|
2012
|
+
throw err;
|
|
2013
|
+
}
|
|
2014
|
+
finally {
|
|
2015
|
+
lastIterationFinishedAt = Date.now();
|
|
2016
|
+
cleanupTurn();
|
|
2017
|
+
this.deps.markUsed();
|
|
2018
|
+
}
|
|
2019
|
+
// Drain any safely-buffered tail from the marker stripper. If
|
|
2020
|
+
// the model never closed an open marker `flush()` returns ''
|
|
2021
|
+
// (treats it as truncated noise) — the alternative is leaking
|
|
2022
|
+
// a half-marker into the saved transcript.
|
|
2023
|
+
const tail = stripper.flush();
|
|
2024
|
+
if (tail.length > 0) {
|
|
2025
|
+
turnContent += tail;
|
|
2026
|
+
const live = reasoningSplit.push(tail);
|
|
2027
|
+
if (live.visible.length > 0)
|
|
2028
|
+
this.emitDelta(live.visible);
|
|
2029
|
+
if (live.reasoning.length > 0)
|
|
2030
|
+
this.emitReasoningDelta(live.reasoning);
|
|
2031
|
+
}
|
|
2032
|
+
{
|
|
2033
|
+
// Whatever the split was still holding: a reasoning block the model
|
|
2034
|
+
// never closed stays on the reasoning channel rather than becoming
|
|
2035
|
+
// the reply.
|
|
2036
|
+
const rest = reasoningSplit.flush();
|
|
2037
|
+
if (rest.visible.length > 0)
|
|
2038
|
+
this.emitDelta(rest.visible);
|
|
2039
|
+
if (rest.reasoning.length > 0)
|
|
2040
|
+
this.emitReasoningDelta(rest.reasoning);
|
|
2041
|
+
}
|
|
2042
|
+
// Always-on terminator log. `stream-end` here is the smoking gun
|
|
2043
|
+
// for "engine closed the connection without sending [DONE]" —
|
|
2044
|
+
// benign on a well-behaved server but worth flagging since it
|
|
2045
|
+
// means we'd hang on any keep-alive variant.
|
|
2046
|
+
log.debug(`turn#${seq}.${turn} SSE-EXIT reason=${sseExitReason} chunks=${sseChunks} ` +
|
|
2047
|
+
`contentChars=${turnContent.length} toolCalls=${toolCallAccumulator.size()} ` +
|
|
2048
|
+
`finish=${finishReason ?? 'null'} afterMs=${Date.now() - start}`);
|
|
2049
|
+
const structuredCalls = toolCallAccumulator.finalize();
|
|
2050
|
+
// Ids of synthesized tool calls whose underlying salvage span
|
|
2051
|
+
// was flagged `truncated` by the lenient Hermes parser. The
|
|
2052
|
+
// tool-execution loop reads this to append an auto-
|
|
2053
|
+
// continuation hint to the corresponding tool result, so the
|
|
2054
|
+
// model knows the partial write_file landed and can issue a
|
|
2055
|
+
// follow-up with the remaining bytes.
|
|
2056
|
+
const truncatedCallIds = new Set();
|
|
2057
|
+
// Payload-mutation calls the cap cut mid-content that we refused
|
|
2058
|
+
// to execute, because landing a prefix would destroy a whole-file
|
|
2059
|
+
// target with no append path (see WRITE_SHAPED_TOOL_NAMES). The
|
|
2060
|
+
// refusal is correct; ending the turn on it silently is not —
|
|
2061
|
+
// nothing tells the model WHY, so the next attempt re-emits the
|
|
2062
|
+
// same over-cap payload. Consumed below to steer toward
|
|
2063
|
+
// incremental edits instead of falling through to a blank reply.
|
|
2064
|
+
const droppedTruncatedPayloadCalls = [];
|
|
2065
|
+
// Try to repair any tool-call markers the stripper extracted
|
|
2066
|
+
// but mlx_vlm.server's parser missed. Repair only succeeds when
|
|
2067
|
+
// (1) the body parses cleanly and (2) the named function is in
|
|
2068
|
+
// the bridge's known-tools set — refusing fabricated names is
|
|
2069
|
+
// the safety rail. Failures land in `unrepairedBodies`, which
|
|
2070
|
+
// drives the warning + model self-correction below.
|
|
2071
|
+
const strippedBodies = stripper.getStrippedBodies();
|
|
2072
|
+
const eosFlushedIndices = stripper.getEosFlushedBodyIndices();
|
|
2073
|
+
this.turnPolicy.checkpoint();
|
|
2074
|
+
const repairedCalls = [];
|
|
2075
|
+
const unrepairedBodies = [];
|
|
2076
|
+
for (let bodyIdx = 0; bodyIdx < strippedBodies.length; bodyIdx++) {
|
|
2077
|
+
const body = strippedBodies[bodyIdx];
|
|
2078
|
+
// Two-stage repair: try the legacy JSON-rehab path first
|
|
2079
|
+
// (handles Gemma's loose `{key: 'value'}` flavors), then
|
|
2080
|
+
// fall back to the native Gemma 4 char-by-char parser
|
|
2081
|
+
// (handles the chat-template's true format `{key:<|"|>val<|"|>}`
|
|
2082
|
+
// where string content contains unescaped newlines/quotes
|
|
2083
|
+
// that defeat JSON.parse). The native parser is strictly
|
|
2084
|
+
// additive — it only succeeds on shapes the JSON path would
|
|
2085
|
+
// have rejected anyway, so wiring it in can't regress
|
|
2086
|
+
// previously-working salvage. See
|
|
2087
|
+
// local-tool-call-salvage-gemma-native.test.ts.
|
|
2088
|
+
const parsed = parseGemmaToolCall(body, knownToolNames) ??
|
|
2089
|
+
parseGemmaNativeToolCall(body, knownToolNames);
|
|
2090
|
+
if (parsed) {
|
|
2091
|
+
// An EOS-flushed artifact call is only a prefix of an atomic
|
|
2092
|
+
// whole-file replacement, and the artifact drawer has no append
|
|
2093
|
+
// primitive. Do not destroy the previous artifact with it.
|
|
2094
|
+
if (eosFlushedIndices.has(bodyIdx) &&
|
|
2095
|
+
parsed.name === 'write_artifact' &&
|
|
2096
|
+
typeof parsed.arguments.content === 'string' &&
|
|
2097
|
+
typeof parsed.arguments.path === 'string') {
|
|
2098
|
+
this.emitWarning('The model hit its output limit while replacing an artifact. The incomplete write was skipped and the previous artifact was preserved; split or deterministically assemble the deliverable before retrying.');
|
|
2099
|
+
continue;
|
|
2100
|
+
}
|
|
2101
|
+
const id = `repair-${seq}-${turn}-${repairedCalls.length}`;
|
|
2102
|
+
repairedCalls.push({
|
|
2103
|
+
id,
|
|
2104
|
+
type: 'function',
|
|
2105
|
+
function: {
|
|
2106
|
+
name: parsed.name,
|
|
2107
|
+
arguments: JSON.stringify(parsed.arguments),
|
|
2108
|
+
},
|
|
2109
|
+
});
|
|
2110
|
+
// Layer 3 — if this Gemma marker call came from an
|
|
2111
|
+
// EOS-flushed body (close marker never arrived) AND it's
|
|
2112
|
+
// a write-shaped call with a string `content` arg, tag
|
|
2113
|
+
// it as truncated. parseGemmaNativeToolCall is permissive
|
|
2114
|
+
// about unclosed `<|"|>` boundaries (returns the rest of
|
|
2115
|
+
// the buffer as the string value), so we get a "complete-
|
|
2116
|
+
// looking" call object whose `content` is actually the
|
|
2117
|
+
// bytes received before the truncation point. The
|
|
2118
|
+
// continuation hint tells the model to append the missing
|
|
2119
|
+
// tail rather than re-emit the whole file.
|
|
2120
|
+
if (eosFlushedIndices.has(bodyIdx)) {
|
|
2121
|
+
const args = parsed.arguments;
|
|
2122
|
+
const isWriteShaped = isWriteShapedToolName(parsed.name);
|
|
2123
|
+
if (isWriteShaped &&
|
|
2124
|
+
typeof args.content === 'string' &&
|
|
2125
|
+
typeof args.path === 'string') {
|
|
2126
|
+
truncatedCallIds.add(id);
|
|
2127
|
+
log.info(`turn#${seq}.${turn} Gemma marker call ${parsed.name} (path=${args.path}, partial=${args.content.length} bytes) was EOS-flushed — continuation hint will fire on the tool result`);
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
else {
|
|
2132
|
+
unrepairedBodies.push(body);
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
const salvaged = salvageTextualToolCalls({
|
|
2136
|
+
turnContent,
|
|
2137
|
+
knownToolNames,
|
|
2138
|
+
seq,
|
|
2139
|
+
turn,
|
|
2140
|
+
structuredCalls,
|
|
2141
|
+
repairedCalls,
|
|
2142
|
+
codeBlockRepaired,
|
|
2143
|
+
truncatedCallIds,
|
|
2144
|
+
droppedTruncatedPayloadCalls,
|
|
2145
|
+
emitWarning: (message) => this.emitWarning(message),
|
|
2146
|
+
});
|
|
2147
|
+
turnContent = salvaged.turnContent;
|
|
2148
|
+
const { proseRepaired, xmlTagRepaired, claudeInvokeRepaired, glmRepaired, hermesRepaired, shellRepaired, envelopeRepaired, envelopeTruncated, unknownCall, truncatedSalvage, bareInvokeRepaired, } = salvaged;
|
|
2149
|
+
const rawTurnContentBeforeReasoning = turnContent;
|
|
2150
|
+
// Reasoning-tag extraction applies regardless of which (if any)
|
|
2151
|
+
// salvage path fired. Qwen 3.5/3.6 leak `<think>…</think>`
|
|
2152
|
+
// chain-of-thought blocks that read as the assistant's actual
|
|
2153
|
+
// message; smaller DeepSeek-R1 distillates and Gemma 3/4 with
|
|
2154
|
+
// the verbose-family hint do the same. The captured text gets
|
|
2155
|
+
// accumulated into `lastTurnReasoning` so the chat bubble can
|
|
2156
|
+
// render it behind a collapsed expander.
|
|
2157
|
+
{
|
|
2158
|
+
const split = extractReasoningWithProfile(turnContent, this.deps.profile);
|
|
2159
|
+
turnContent = split.visible;
|
|
2160
|
+
if (split.reasoning) {
|
|
2161
|
+
this.lastTurnReasoning =
|
|
2162
|
+
this.lastTurnReasoning.length > 0
|
|
2163
|
+
? `${this.lastTurnReasoning}\n\n${split.reasoning}`
|
|
2164
|
+
: split.reasoning;
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
const mergedCalls = [
|
|
2168
|
+
...structuredCalls,
|
|
2169
|
+
...repairedCalls,
|
|
2170
|
+
...proseRepaired,
|
|
2171
|
+
...xmlTagRepaired,
|
|
2172
|
+
...claudeInvokeRepaired,
|
|
2173
|
+
...glmRepaired,
|
|
2174
|
+
...hermesRepaired,
|
|
2175
|
+
...shellRepaired,
|
|
2176
|
+
...envelopeRepaired,
|
|
2177
|
+
...truncatedSalvage,
|
|
2178
|
+
...bareInvokeRepaired,
|
|
2179
|
+
...codeBlockRepaired,
|
|
2180
|
+
];
|
|
2181
|
+
// Every markup salvage format above is a flat KEY→text map, so a
|
|
2182
|
+
// parameter declared `object`/`array` arrives as a string. Repair
|
|
2183
|
+
// against the declared schema here, at the one place all the
|
|
2184
|
+
// salvage passes converge, so the structural shape is what gets
|
|
2185
|
+
// logged, recorded, and dispatched. Schema-gated: a genuine
|
|
2186
|
+
// string argument (a JSON file's `content`) is never touched.
|
|
2187
|
+
const coerced = coerceToolCallArgs(mergedCalls, (n) => toolArgSchemas.get(n));
|
|
2188
|
+
for (const r of coerced.repaired) {
|
|
2189
|
+
log.info(`turn#${seq}.${turn} repaired flattened arg(s) on ${r.name}: ${r.paths.join(', ')}`);
|
|
2190
|
+
}
|
|
2191
|
+
// After coercion, so two spellings of the same arguments compare equal.
|
|
2192
|
+
const collapsed = collapseDuplicateToolCalls(coerced.calls);
|
|
2193
|
+
for (const d of collapsed.dropped) {
|
|
2194
|
+
log.info(`turn#${seq}.${turn} collapsed ${d.count} duplicate ${d.name} call(s) emitted in this generation`);
|
|
2195
|
+
}
|
|
2196
|
+
let toolCalls = collapsed.calls;
|
|
2197
|
+
if (rambleAborted && toolCalls.length === 0) {
|
|
2198
|
+
// If ctrl.abort() races with mlx-vlm closing the SSE stream,
|
|
2199
|
+
// the for-await loop can exit cleanly instead of throwing an
|
|
2200
|
+
// AbortError. Without this guard the no-tool ramble falls
|
|
2201
|
+
// through as an empty assistant message after reasoning
|
|
2202
|
+
// extraction, so the gezel never receives the corrective.
|
|
2203
|
+
// Wild-caught (qwen3.6 35B MLX tankcombat):
|
|
2204
|
+
// Meester generated 3.5K tokens, the Python server logged
|
|
2205
|
+
// "client disconnected mid-generation", and the session
|
|
2206
|
+
// persisted a blank assistant turn with no project handoff.
|
|
2207
|
+
const head = rawTurnContentBeforeReasoning.slice(0, 400).replace(/\s+/g, ' ');
|
|
2208
|
+
const tail = rawTurnContentBeforeReasoning.slice(-200).replace(/\s+/g, ' ');
|
|
2209
|
+
log.warn(`turn#${seq}.${turn} ramble-clean-exit-no-salvage preview head=${JSON.stringify(head)} tail=${JSON.stringify(tail)}`);
|
|
2210
|
+
throw new Error(buildRambleAbortMessage({
|
|
2211
|
+
providerLabel: '[Mac AI]',
|
|
2212
|
+
charCount: rawTurnContentBeforeReasoning.length,
|
|
2213
|
+
knownToolNames,
|
|
2214
|
+
...(this.deps.activeCraftbookStep?.deliverableFile
|
|
2215
|
+
? { deliverableFile: this.deps.activeCraftbookStep.deliverableFile }
|
|
2216
|
+
: {}),
|
|
2217
|
+
...(this.deps.activeCraftbookStep?.deliverableIsArtifact
|
|
2218
|
+
? { deliverableIsArtifact: true }
|
|
2219
|
+
: {}),
|
|
2220
|
+
}));
|
|
2221
|
+
}
|
|
2222
|
+
// Prose-document salvage. Past the guard above, zero tool calls
|
|
2223
|
+
// means the model ENDED ITS OWN TURN — the one condition under
|
|
2224
|
+
// which the buffer is a finished document rather than a cut-off
|
|
2225
|
+
// one. A craftbook step whose deliverable is markdown gets it
|
|
2226
|
+
// promoted to the write tool instead of leaving the text in chat
|
|
2227
|
+
// where the gate will never see it. `turnContent` is already
|
|
2228
|
+
// reasoning-stripped here; `finishReason === 'length'` means the
|
|
2229
|
+
// token cap cut it, which is a truncation like any other.
|
|
2230
|
+
if (toolCalls.length === 0 && finishReason !== 'length') {
|
|
2231
|
+
const deliverable = this.deps.activeCraftbookStep?.deliverableFile;
|
|
2232
|
+
const salvageToolName = this.deps.activeCraftbookStep?.deliverableIsArtifact
|
|
2233
|
+
? knownToolNames.has('write_artifact')
|
|
2234
|
+
? 'write_artifact'
|
|
2235
|
+
: null
|
|
2236
|
+
: knownToolNames.has('write_file')
|
|
2237
|
+
? 'write_file'
|
|
2238
|
+
: null;
|
|
2239
|
+
const document = salvageToolName
|
|
2240
|
+
? prepareSalvagedProseDocument({
|
|
2241
|
+
text: turnContent,
|
|
2242
|
+
deliverableFile: deliverable,
|
|
2243
|
+
streamComplete: true,
|
|
2244
|
+
})
|
|
2245
|
+
: null;
|
|
2246
|
+
if (document && salvageToolName && deliverable) {
|
|
2247
|
+
toolCalls = [
|
|
2248
|
+
{
|
|
2249
|
+
id: `prose-doc-salvage-${seq}-${turn}`,
|
|
2250
|
+
type: 'function',
|
|
2251
|
+
function: {
|
|
2252
|
+
name: salvageToolName,
|
|
2253
|
+
arguments: JSON.stringify({ path: deliverable, content: document }),
|
|
2254
|
+
},
|
|
2255
|
+
},
|
|
2256
|
+
];
|
|
2257
|
+
log.info(`turn#${seq}.${turn} prose-document-salvage: promoted ${document.length}-char ` +
|
|
2258
|
+
`markdown buffer to ${salvageToolName} path=${deliverable}`);
|
|
2259
|
+
this.emitWarning(`The model wrote ${deliverable} in chat instead of calling ${salvageToolName} — promoting it to a real write. Ask the model to use the tool directly next time.`);
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
// Cap-truncated payload mutation with nothing left to run. The
|
|
2263
|
+
// skip above is right — a partial `write_artifact` would replace
|
|
2264
|
+
// a complete artifact with a prefix, and there is no
|
|
2265
|
+
// `append_artifact` to finish it. But falling through from here
|
|
2266
|
+
// ends the turn with zero tool calls and (after the rumination
|
|
2267
|
+
// fold) zero visible text: the user gets a blank bubble and the
|
|
2268
|
+
// MODEL gets nothing at all, so the next attempt re-emits the
|
|
2269
|
+
// same over-cap payload. llama-cpp already steers this case via
|
|
2270
|
+
// {@link appendCapTruncationHintToRejectedWrite} because its
|
|
2271
|
+
// rejected write comes back as a tool result; on MLX the call
|
|
2272
|
+
// never executes, so synthesize the same steer as a corrective
|
|
2273
|
+
// and spend another loop iteration on it.
|
|
2274
|
+
//
|
|
2275
|
+
// Wild-caught on gezel/49: a `write_artifact` carrying a
|
|
2276
|
+
// 580-path coverage ledger was cut at max_tokens=6144, dropped
|
|
2277
|
+
// here, and the turn ended after 62 minutes with replyChars=0.
|
|
2278
|
+
if (toolCalls.length === 0 &&
|
|
2279
|
+
droppedTruncatedPayloadCalls.length > 0 &&
|
|
2280
|
+
this.turnPolicy.retry('capTruncation')) {
|
|
2281
|
+
capTruncationSteers++;
|
|
2282
|
+
const dropped = droppedTruncatedPayloadCalls[0];
|
|
2283
|
+
const liveToolNames = new Set((Array.isArray(body.tools) ? body.tools : [])
|
|
2284
|
+
.map((tool) => chatCompletionToolName(tool))
|
|
2285
|
+
.filter((name) => typeof name === 'string'));
|
|
2286
|
+
const requestMaxTokens = typeof body.max_tokens === 'number' ? body.max_tokens : null;
|
|
2287
|
+
const steer = appendCapTruncationHintToRejectedWrite(`ERROR: \`${dropped.name}\` was not executed because the call was cut off at the per-turn output token cap before its payload finished.`, dropped.name, dropped.args, requestMaxTokens, {
|
|
2288
|
+
availableToolNames: liveToolNames,
|
|
2289
|
+
taskStep: this.deps.activeCraftbookStep !== undefined,
|
|
2290
|
+
});
|
|
2291
|
+
const noRemedy = steer.includes('No incremental edit tool is wired this turn');
|
|
2292
|
+
log.info(`turn#${seq}.${turn} cap-truncated ${dropped.name} dropped with no other call — ` +
|
|
2293
|
+
`${noRemedy ? 'no incremental tool wired; escalation steer' : 'incremental-edit steer'} ` +
|
|
2294
|
+
`injected (attempt ${capTruncationSteers}/${MAX_CAP_TRUNCATION_STEERS}, max_tokens=${requestMaxTokens ?? 'unset'})`);
|
|
2295
|
+
this.messages.push({ role: 'user', content: `[system] ${steer}` });
|
|
2296
|
+
continue;
|
|
2297
|
+
}
|
|
2298
|
+
// Auto-fold pre-tool preamble for verbose-family models. When
|
|
2299
|
+
// a tool call fired this turn, untagged visible text is
|
|
2300
|
+
// almost always reasoning the model leaked instead of
|
|
2301
|
+
// wrapping in `<think>` ("The user wants…", "Let me…").
|
|
2302
|
+
// Substantive answer comes after the tool runs in the next
|
|
2303
|
+
// tool-loop iteration. See {@link foldPreToolPreamble}.
|
|
2304
|
+
turnContent = foldPreToolPreamble({
|
|
2305
|
+
text: turnContent,
|
|
2306
|
+
toolCallsFired: toolCalls.length > 0,
|
|
2307
|
+
modelLeaksReasoning: profileHasBehavior(this.deps.profile, 'turn.preamble-folding'),
|
|
2308
|
+
askedQuestionThisTurn,
|
|
2309
|
+
});
|
|
2310
|
+
// Append to fullText AFTER every salvage path has had its turn
|
|
2311
|
+
// at stripping — earlier ordering meant the un-stripped prose /
|
|
2312
|
+
// envelope / reasoning text still landed in the persisted
|
|
2313
|
+
// assistant message even though the salvage worked.
|
|
2314
|
+
fullText += turnContent;
|
|
2315
|
+
// External tool capture: when the model invoked any caller-
|
|
2316
|
+
// supplied external tool, halt the loop and surface ALL
|
|
2317
|
+
// pending calls (external + bridge) to the caller. Bridge
|
|
2318
|
+
// calls in the same turn go unexecuted — the `/v1/chat/completions`
|
|
2319
|
+
// route owns the next turn.
|
|
2320
|
+
if (toolCalls.length > 0 &&
|
|
2321
|
+
toolCalls.some((tc) => this.externalToolNames.has(tc.function.name))) {
|
|
2322
|
+
this.capturedCalls = toolCalls.map((tc) => ({
|
|
2323
|
+
id: tc.id,
|
|
2324
|
+
name: tc.function.name,
|
|
2325
|
+
arguments: tc.function.arguments,
|
|
2326
|
+
}));
|
|
2327
|
+
this.messages.push({
|
|
2328
|
+
role: 'assistant',
|
|
2329
|
+
content: turnContent || null,
|
|
2330
|
+
tool_calls: toolCalls,
|
|
2331
|
+
});
|
|
2332
|
+
return fullText;
|
|
2333
|
+
}
|
|
2334
|
+
// Successful salvage paths (`repairedCalls`, `proseRepaired`,
|
|
2335
|
+
// `envelopeRepaired`) used to surface a yellow user-facing
|
|
2336
|
+
// warning — but salvage IS the success case, the tool fires
|
|
2337
|
+
// correctly and the call appears as a normal tool widget.
|
|
2338
|
+
// Annotating every Qwen/etc. turn with a warning trained users
|
|
2339
|
+
// to ignore them. Keep the console log for ops; drop the toast.
|
|
2340
|
+
if (repairedCalls.length > 0) {
|
|
2341
|
+
log.info(`turn#${seq}.${turn} repaired ${repairedCalls.length} ` +
|
|
2342
|
+
`malformed tool call(s): ${repairedCalls.map((c) => c.function.name).join(', ')}`);
|
|
2343
|
+
}
|
|
2344
|
+
if (proseRepaired.length > 0) {
|
|
2345
|
+
log.info(`turn#${seq}.${turn} salvaged ${proseRepaired.length} prose-shaped tool call(s): ${proseRepaired.map((c) => c.function.name).join(', ')}`);
|
|
2346
|
+
}
|
|
2347
|
+
if (xmlTagRepaired.length > 0) {
|
|
2348
|
+
log.info(`turn#${seq}.${turn} salvaged ${xmlTagRepaired.length} XML-tag tool call(s): ${xmlTagRepaired.map((c) => c.function.name).join(', ')}`);
|
|
2349
|
+
}
|
|
2350
|
+
if (claudeInvokeRepaired.length > 0) {
|
|
2351
|
+
log.info(`turn#${seq}.${turn} salvaged ${claudeInvokeRepaired.length} Claude-invoke tool call(s): ${claudeInvokeRepaired.map((c) => c.function.name).join(', ')}`);
|
|
2352
|
+
}
|
|
2353
|
+
if (glmRepaired.length > 0) {
|
|
2354
|
+
log.info(`turn#${seq}.${turn} salvaged ${glmRepaired.length} GLM-style tool call(s): ${glmRepaired.map((c) => c.function.name).join(', ')}`);
|
|
2355
|
+
}
|
|
2356
|
+
if (hermesRepaired.length > 0) {
|
|
2357
|
+
log.info(`turn#${seq}.${turn} salvaged ${hermesRepaired.length} Hermes-style tool call(s): ${hermesRepaired.map((c) => c.function.name).join(', ')}`);
|
|
2358
|
+
}
|
|
2359
|
+
if (shellRepaired.length > 0) {
|
|
2360
|
+
log.info(`turn#${seq}.${turn} salvaged ${shellRepaired.length} shell-style tool call(s): ${shellRepaired.map((c) => c.function.name).join(', ')}`);
|
|
2361
|
+
}
|
|
2362
|
+
if (envelopeRepaired.length > 0) {
|
|
2363
|
+
log.info(`turn#${seq}.${turn} salvaged ${envelopeRepaired.length} JSON-envelope tool call(s): ${envelopeRepaired.map((c) => c.function.name).join(', ')}`);
|
|
2364
|
+
}
|
|
2365
|
+
if (bareInvokeRepaired.length > 0) {
|
|
2366
|
+
log.info(`turn#${seq}.${turn} salvaged ${bareInvokeRepaired.length} bare-invoke tool call(s): ${bareInvokeRepaired.map((c) => c.function.name).join(', ')}`);
|
|
2367
|
+
}
|
|
2368
|
+
if (envelopeTruncated) {
|
|
2369
|
+
log.info(`turn#${seq}.${turn} truncated JSON-envelope tool call: ` +
|
|
2370
|
+
`${envelopeTruncated.wanted ?? '<unknown>'}`);
|
|
2371
|
+
this.emitWarning(`The model's last tool call (\`${envelopeTruncated.wanted ?? 'unknown'}\`) was cut off mid-stream and was skipped. Try again — or raise the output token cap if this happens often.`);
|
|
2372
|
+
}
|
|
2373
|
+
else if (toolCalls.length === 0 && turnContent.length > 0) {
|
|
2374
|
+
// Prose-shaped truncation — `name(args` with no closing paren.
|
|
2375
|
+
// Only check when no other salvage fired and no envelope-truncation
|
|
2376
|
+
// has already been flagged for the same tail.
|
|
2377
|
+
const proseTruncated = findTruncatedProseToolCall(turnContent, knownToolNames);
|
|
2378
|
+
if (proseTruncated) {
|
|
2379
|
+
log.info(`turn#${seq}.${turn} truncated prose tool call: ${proseTruncated.wanted}`);
|
|
2380
|
+
this.emitWarning(`The model started calling \`${proseTruncated.wanted}\` but was cut off mid-args. It likely spent its output budget on reasoning before reaching the tool call. Try again — or raise the output token cap.`);
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
// "Did you mean…?" nudge: model emitted a `{tool, args}`
|
|
2384
|
+
// envelope with a tool name we don't recognize (e.g.
|
|
2385
|
+
// `listtasks` instead of `list_tasks`). The salvage above
|
|
2386
|
+
// bailed because the name failed the known-set check; we
|
|
2387
|
+
// can't safely fabricate a call against the wrong name, but
|
|
2388
|
+
// we CAN tell the model what they mistyped and keep the
|
|
2389
|
+
// turn going. Shares the malformedRetries budget so a model
|
|
2390
|
+
// that keeps emitting bad names doesn't loop forever.
|
|
2391
|
+
if (toolCalls.length === 0) {
|
|
2392
|
+
// Cover BOTH unknown-tool shapes: the JSON `{tool,args}`
|
|
2393
|
+
// envelope AND the `<function=NAME>` / `<invoke name=…>`
|
|
2394
|
+
// markup. The latter is how a delegator role (no `write_file`)
|
|
2395
|
+
// "calls" write_file — before this it got no feedback and
|
|
2396
|
+
// believed the write succeeded. `buildUnknownToolNudge` routes
|
|
2397
|
+
// a no-write-access role to delegation instead of "did you
|
|
2398
|
+
// mean…?". Wild-caught (Space Shooter Arcade).
|
|
2399
|
+
const miss = unknownCall ??
|
|
2400
|
+
findUnrecognizedToolEnvelope(turnContent, knownToolNames) ??
|
|
2401
|
+
findUnrecognizedFunctionMarkup(turnContent, knownToolNames);
|
|
2402
|
+
if (miss) {
|
|
2403
|
+
const missSig = `u:${miss.wanted.toLowerCase()}`;
|
|
2404
|
+
if (this.turnPolicy.retry('malformed', missSig)) {
|
|
2405
|
+
malformedRetries++;
|
|
2406
|
+
lastBadCallSig = missSig;
|
|
2407
|
+
log.info(`turn#${seq}.${turn} model called unknown tool "${miss.wanted}"${miss.suggestion ? ` — suggesting "${miss.suggestion}"` : ''} (retry ${malformedRetries}/${MAX_MALFORMED_RETRIES})`);
|
|
2408
|
+
// No user-facing warning here — the unknown-name retry is
|
|
2409
|
+
// an internal recovery, the next loop iteration produces
|
|
2410
|
+
// the real tool call, the user sees a clean tool widget. The
|
|
2411
|
+
// nudge now names valid tools + flags param-as-function slips.
|
|
2412
|
+
this.messages.push({ role: 'assistant', content: turnContent });
|
|
2413
|
+
this.messages.push({
|
|
2414
|
+
role: 'user',
|
|
2415
|
+
content: buildUnknownToolNudge(miss.wanted, miss.suggestion, knownToolNames, toolParamIndex),
|
|
2416
|
+
});
|
|
2417
|
+
continue;
|
|
2418
|
+
}
|
|
2419
|
+
// Repeat of the same unknown tool after a nudge (or budget spent):
|
|
2420
|
+
// the nudge isn't landing, so stop retrying and let the turn end
|
|
2421
|
+
// rather than apology-loop. The actionable nudge already fired.
|
|
2422
|
+
if (missSig === lastBadCallSig) {
|
|
2423
|
+
log.info(`turn#${seq}.${turn} model repeated unknown tool "${miss.wanted}" after a nudge — stopping retries`);
|
|
2424
|
+
}
|
|
2425
|
+
this.emitWarning('The model could not call an available tool after bounded recovery. No tool ran.');
|
|
2426
|
+
return fullText;
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
// Self-correction nudge: model emitted tool-call markers but
|
|
2430
|
+
// none parsed AND no structured calls came through. Push the
|
|
2431
|
+
// partial assistant text + a corrective user message describing
|
|
2432
|
+
// what was wrong, then continue the loop so the model gets a
|
|
2433
|
+
// chance to retry with proper structure. Capped via
|
|
2434
|
+
// `malformedRetries` so we don't loop forever on a model that
|
|
2435
|
+
// just can't form structured calls.
|
|
2436
|
+
if (toolCalls.length === 0 && unrepairedBodies.length > 0) {
|
|
2437
|
+
const malSig = `m:${unrepairedBodies[0].replace(/\s+/g, '').toLowerCase().slice(0, 200)}`;
|
|
2438
|
+
if (this.turnPolicy.retry('malformed', malSig)) {
|
|
2439
|
+
malformedRetries++;
|
|
2440
|
+
lastBadCallSig = malSig;
|
|
2441
|
+
const detail = describeMalformation(unrepairedBodies[0]);
|
|
2442
|
+
log.info(`turn#${seq}.${turn} nudging model after malformed tool call ` +
|
|
2443
|
+
`(retry ${malformedRetries}/${MAX_MALFORMED_RETRIES})`);
|
|
2444
|
+
// Suppress the per-retry user-visible warning when the
|
|
2445
|
+
// model's profile knows the family's tool-call quirks
|
|
2446
|
+
// (`parse.gemma-special-token` etc.). For those models a
|
|
2447
|
+
// malformed marker is a recoverable family tic rather than
|
|
2448
|
+
// a user-actionable problem; the internal retry-nudge
|
|
2449
|
+
// still fires on the next loop iteration so behavior
|
|
2450
|
+
// doesn't change, just the noise floor in the chat. Only
|
|
2451
|
+
// the final budget-exhausted warning below stays universal
|
|
2452
|
+
// — that's load-bearing because the turn ends without a
|
|
2453
|
+
// real tool call landing.
|
|
2454
|
+
const suppressPerRetryWarning = profileHasBehavior(this.deps.profile, 'parse.gemma-special-token');
|
|
2455
|
+
if (!suppressPerRetryWarning) {
|
|
2456
|
+
this.emitWarning(`Model attempted a tool call with malformed syntax — asking it to retry (${malformedRetries}/${MAX_MALFORMED_RETRIES}).`);
|
|
2457
|
+
}
|
|
2458
|
+
this.messages.push({ role: 'assistant', content: turnContent });
|
|
2459
|
+
this.messages.push({
|
|
2460
|
+
role: 'user',
|
|
2461
|
+
content: `[system] Your previous response contained a malformed tool-call marker — the syntax didn't match the structured tool-call format the runtime can parse. ${detail} Please retry the tool invocation using the standard structured tool-call format (do not write the literal <|tool_call|> tokens as text — emit a real function call via the tools mechanism). Your available tools are: ${formatToolMenu(knownToolNames)}.`,
|
|
2462
|
+
});
|
|
2463
|
+
continue;
|
|
2464
|
+
}
|
|
2465
|
+
// Either the retry budget is spent OR the model just re-emitted the
|
|
2466
|
+
// identical malformed call — in both cases keep nudging is futile;
|
|
2467
|
+
// fall through to the terminal correction below instead of looping.
|
|
2468
|
+
if (malSig === lastBadCallSig) {
|
|
2469
|
+
log.info(`turn#${seq}.${turn} model repeated the same malformed tool call after a nudge — stopping retries`);
|
|
2470
|
+
}
|
|
2471
|
+
// Retry budget exhausted — fall through to the normal end-of-
|
|
2472
|
+
// turn block but emit a final user-visible warning so the user
|
|
2473
|
+
// knows the chat ended without a real tool call firing.
|
|
2474
|
+
this.emitWarning(`Model attempted ${unrepairedBodies.length} tool call${unrepairedBodies.length === 1 ? '' : 's'} but couldn't form ${unrepairedBodies.length === 1 ? 'it' : 'them'} correctly after ${MAX_MALFORMED_RETRIES} retries. Try rephrasing or use a cloud model for tool-heavy work in this thread.`);
|
|
2475
|
+
// Capture the failed bodies so ChatManager can stash them on
|
|
2476
|
+
// the persisted message for the debug bundle. Truncated per-
|
|
2477
|
+
// body so a runaway fabrication doesn't blow up the session
|
|
2478
|
+
// file. Without this, the bodies live only in provider logs
|
|
2479
|
+
// and the bundle reader sees "tool call attempted but couldn't
|
|
2480
|
+
// form" with no record of what shape was actually emitted.
|
|
2481
|
+
this.lastTurnAttemptedToolCalls = unrepairedBodies.map((body) => ({
|
|
2482
|
+
body: body.length > 600 ? `${body.slice(0, 600)}…` : body,
|
|
2483
|
+
reason: describeMalformation(body),
|
|
2484
|
+
}));
|
|
2485
|
+
// Inject a corrective system note into history so the NEXT
|
|
2486
|
+
// turn doesn't carry forward the model's "I just created the
|
|
2487
|
+
// project" assumption. Without this, the model's own prose
|
|
2488
|
+
// sits in history with no contradicting signal — turn N+1
|
|
2489
|
+
// tries to read the kickoff task of a project that doesn't
|
|
2490
|
+
// exist, hits the strict-IDs guard, and the cascade is on.
|
|
2491
|
+
// Using `role: 'user'` with a `[system]` prefix matches the
|
|
2492
|
+
// pattern the within-turn retries already use; the model
|
|
2493
|
+
// treats it as authoritative correction.
|
|
2494
|
+
const wantedNames = uniqueWantedToolNames(unrepairedBodies, knownToolNames);
|
|
2495
|
+
const wantedSummary = wantedNames.length > 0 ? `\`${wantedNames.join('`, `')}\`` : 'a tool';
|
|
2496
|
+
this.messages.push({
|
|
2497
|
+
role: 'user',
|
|
2498
|
+
content: `[system] Your previous turn attempted to call ${wantedSummary} but the tool-call syntax was malformed; the runtime could not parse it after ${MAX_MALFORMED_RETRIES} retries. NO TOOL RAN this turn. Any state your reasoning described (projects created, gezels recruited, files written, tasks assigned) does NOT exist on disk. Before referring to that state in subsequent turns, call \`list_projects\` / \`list_gezels\` to verify the actual state, OR retry the tool call using the structured function-calling mechanism with valid JSON arguments.`,
|
|
2499
|
+
});
|
|
2500
|
+
}
|
|
2501
|
+
if (toolCalls.length === 0 &&
|
|
2502
|
+
fileTurnPlan.kind !== 'ordinary' &&
|
|
2503
|
+
!repairMutationSucceeded &&
|
|
2504
|
+
!askedQuestionThisTurn) {
|
|
2505
|
+
const noMutationNudges = this.turnPolicy.requireProgressRetry();
|
|
2506
|
+
const nudge = remainingReadPaths.length
|
|
2507
|
+
? buildPrerequisiteRepairReadNudge({
|
|
2508
|
+
remainingPaths: remainingReadPaths,
|
|
2509
|
+
noProgressNudges: noMutationNudges,
|
|
2510
|
+
})
|
|
2511
|
+
: immediateFileWriteTurn
|
|
2512
|
+
? buildImmediateFileWriteNoMutationNudge({
|
|
2513
|
+
targetPath: opts?.fileTurnIntent?.path ?? fileRepairTargetPath(prompt),
|
|
2514
|
+
noMutationNudges,
|
|
2515
|
+
})
|
|
2516
|
+
: buildScenarioRepairNoMutationNudge(knownToolNames, {
|
|
2517
|
+
readOnlyCalls: repairReadCalls,
|
|
2518
|
+
readFilePaths: repairReadPaths,
|
|
2519
|
+
failedMutationCalls: repairFailedMutations,
|
|
2520
|
+
noMutationNudges,
|
|
2521
|
+
});
|
|
2522
|
+
this.messages.push({ role: 'user', content: nudge });
|
|
2523
|
+
continue;
|
|
2524
|
+
}
|
|
2525
|
+
if (toolCalls.length === 0 && requiresToolCall) {
|
|
2526
|
+
if (this.turnPolicy.retry('noProgress')) {
|
|
2527
|
+
const attempt = this.turnPolicy.count('noProgress');
|
|
2528
|
+
log.info(`turn#${seq}.${turn} required tool call missing — retrying ` +
|
|
2529
|
+
`(${attempt}/${LOCAL_TURN_LIMITS.noProgress})`);
|
|
2530
|
+
// The first decode is not a valid answer to a required-tool
|
|
2531
|
+
// request. Keep it in provider history as the assistant output
|
|
2532
|
+
// being corrected, but do not leak it into the eventual visible
|
|
2533
|
+
// reply when the retry succeeds.
|
|
2534
|
+
if (turnContent.length > 0) {
|
|
2535
|
+
this.messages.push({ role: 'assistant', content: turnContent });
|
|
2536
|
+
fullText = fullText.slice(0, -turnContent.length);
|
|
2537
|
+
}
|
|
2538
|
+
this.messages.push({
|
|
2539
|
+
role: 'user',
|
|
2540
|
+
content: `[system] This turn requires an actual structured tool call, but no tool ran. Emit exactly one real function call now using one of: ${formatToolMenu(knownToolNames)}. Do not explain, narrate, or print tool-call markup as text.`,
|
|
2541
|
+
});
|
|
2542
|
+
continue;
|
|
2543
|
+
}
|
|
2544
|
+
throw new Error(`The model did not emit a required tool call after ${LOCAL_TURN_LIMITS.noProgress} corrective retries. No tool ran.`);
|
|
2545
|
+
}
|
|
2546
|
+
if (toolCalls.length === 0) {
|
|
2547
|
+
this.messages.push({ role: 'assistant', content: turnContent });
|
|
2548
|
+
if (finishReason === 'length') {
|
|
2549
|
+
this.emitWarning('Model output was cut off at the length limit. The last turn may be incomplete.');
|
|
2550
|
+
}
|
|
2551
|
+
log.info(`turn#${seq} END ok afterMs=${Date.now() - start} ` +
|
|
2552
|
+
`replyChars=${fullText.length} loopTurns=${turn + 1}`);
|
|
2553
|
+
return fullText;
|
|
2554
|
+
}
|
|
2555
|
+
// Mixed-success path: at least one tool call ran (structured or
|
|
2556
|
+
// repaired) but some marker bodies still couldn't be parsed.
|
|
2557
|
+
// Mention the skipped attempts so the user understands why a
|
|
2558
|
+
// tool they expected to fire didn't, without blocking the work
|
|
2559
|
+
// that did succeed.
|
|
2560
|
+
//
|
|
2561
|
+
// Suppressed for profiles that opt into `parse.gemma-special-token` —
|
|
2562
|
+
// for those families a malformed marker alongside successful
|
|
2563
|
+
// calls is a known family tic, not a user-actionable problem.
|
|
2564
|
+
// The successful call is what the user sees in the UI; the
|
|
2565
|
+
// skipped one would just confuse them ("did the tool fire or
|
|
2566
|
+
// not?"). Logged as `info` instead so the diagnostic trail
|
|
2567
|
+
// remains.
|
|
2568
|
+
if (unrepairedBodies.length > 0) {
|
|
2569
|
+
if (profileHasBehavior(this.deps.profile, 'parse.gemma-special-token')) {
|
|
2570
|
+
log.info(`turn#${seq}.${turn} ${unrepairedBodies.length} unrepaired marker body${unrepairedBodies.length === 1 ? '' : 's'} alongside ${toolCalls.length} successful call${toolCalls.length === 1 ? '' : 's'} (suppressed user warning — known family tic)`);
|
|
2571
|
+
}
|
|
2572
|
+
else {
|
|
2573
|
+
this.emitWarning(`${unrepairedBodies.length} additional tool-call attempt` +
|
|
2574
|
+
`${unrepairedBodies.length === 1 ? ' was' : 's were'} malformed and skipped.`);
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
this.messages.push({
|
|
2578
|
+
role: 'assistant',
|
|
2579
|
+
content: turnContent || null,
|
|
2580
|
+
tool_calls: toolCalls,
|
|
2581
|
+
});
|
|
2582
|
+
let abortDueToFailureLoop = null;
|
|
2583
|
+
let asyncHandoffCount = 0;
|
|
2584
|
+
let terminalActionClosing = null;
|
|
2585
|
+
const toolImages = [];
|
|
2586
|
+
const immediateFileWritePaths = [];
|
|
2587
|
+
// Set when an immediate-write / continuation write this turn was
|
|
2588
|
+
// EOS-flushed (truncated mid-content) — drives the bail-vs-loop
|
|
2589
|
+
// decision below.
|
|
2590
|
+
let immediateWriteTruncated = false;
|
|
2591
|
+
for (const call of toolCalls) {
|
|
2592
|
+
this.turnPolicy.checkpoint();
|
|
2593
|
+
if (terminalActionClosing) {
|
|
2594
|
+
this.messages.push({
|
|
2595
|
+
role: 'tool',
|
|
2596
|
+
content: TERMINAL_ACTION_SKIPPED_OUTPUT,
|
|
2597
|
+
tool_call_id: call.id,
|
|
2598
|
+
});
|
|
2599
|
+
continue;
|
|
2600
|
+
}
|
|
2601
|
+
let args = {};
|
|
2602
|
+
let malformedArgs = false;
|
|
2603
|
+
try {
|
|
2604
|
+
args = call.function.arguments.length > 0 ? JSON.parse(call.function.arguments) : {};
|
|
2605
|
+
}
|
|
2606
|
+
catch {
|
|
2607
|
+
malformedArgs = true;
|
|
2608
|
+
}
|
|
2609
|
+
malformedArgs ||= !args || typeof args !== 'object' || Array.isArray(args);
|
|
2610
|
+
let output;
|
|
2611
|
+
// Intercept duplicate ask_user_question calls within the
|
|
2612
|
+
// same turn — synthesize a response that tells the model
|
|
2613
|
+
// its first question is already pending and to end the
|
|
2614
|
+
// turn. The second card is suppressed entirely (no API
|
|
2615
|
+
// call, no UI clutter).
|
|
2616
|
+
if (malformedArgs) {
|
|
2617
|
+
if (!this.turnPolicy.retry('malformed', `args:${call.function.name}:${call.function.arguments}`)) {
|
|
2618
|
+
throw new Error('Malformed tool-call recovery budget exhausted; no tool was executed.');
|
|
2619
|
+
}
|
|
2620
|
+
args = {};
|
|
2621
|
+
output = `ERROR: ${call.function.name} was not executed because its arguments are not a valid JSON object. Retry with the declared fields.`;
|
|
2622
|
+
}
|
|
2623
|
+
else if (!this.supportsImageInput &&
|
|
2624
|
+
call.function.name.replaceAll('-', '_') === 'read_image_as_base64') {
|
|
2625
|
+
// Do not execute the bridge: that would emit a successful image-read
|
|
2626
|
+
// receipt although this engine can never load a vision tower and its
|
|
2627
|
+
// pixels would be dropped. A craftbook must not approve fabricated inspection.
|
|
2628
|
+
output =
|
|
2629
|
+
'ERROR: Image inspection is unavailable in this text-only MLX runtime. No image was delivered. Use a provider with image input support; do not claim visual observations or approve a visual review.';
|
|
2630
|
+
}
|
|
2631
|
+
else if (opts?.fileTurnIntent &&
|
|
2632
|
+
FILE_REPAIR_MUTATION_TOOLS.has(call.function.name) &&
|
|
2633
|
+
(opts.fileTurnIntent.kind === 'create-file' ||
|
|
2634
|
+
opts.fileTurnIntent.mutationPath ||
|
|
2635
|
+
opts.fileTurnIntent.strategy === 'rewrite') &&
|
|
2636
|
+
(typeof args.path !== 'string' ||
|
|
2637
|
+
normalizeWorkspacePathForCompare(args.path) !==
|
|
2638
|
+
normalizeWorkspacePathForCompare(opts.fileTurnIntent.kind === 'repair-file'
|
|
2639
|
+
? (opts.fileTurnIntent.mutationPath ?? opts.fileTurnIntent.path)
|
|
2640
|
+
: opts.fileTurnIntent.path))) {
|
|
2641
|
+
output = 'ERROR: This mutation must use the requested output path.';
|
|
2642
|
+
}
|
|
2643
|
+
else if (fileRepairTurn &&
|
|
2644
|
+
remainingReadPaths.length &&
|
|
2645
|
+
(call.function.name !== 'read_file' ||
|
|
2646
|
+
typeof args.path !== 'string' ||
|
|
2647
|
+
remainingPrerequisiteRepairReadPaths(remainingReadPaths, [args.path]).length ===
|
|
2648
|
+
remainingReadPaths.length)) {
|
|
2649
|
+
output = 'ERROR: Read the remaining required source paths before editing.';
|
|
2650
|
+
}
|
|
2651
|
+
else if (call.function.name === 'ask_user_question' && askedQuestionThisTurn) {
|
|
2652
|
+
output =
|
|
2653
|
+
"You already posted a question card to the user earlier in this turn. The second `ask_user_question` call was suppressed so the user only sees one card. END YOUR TURN NOW — the user's answer to the first question will arrive as the next user message; this turn produces no further visible content.";
|
|
2654
|
+
}
|
|
2655
|
+
else if ((call.function.name === 'start_project' || call.function.name === 'start_job') &&
|
|
2656
|
+
startedProjectOrJobThisTurn) {
|
|
2657
|
+
// Success-led phrasing — wild-caught on gemma4-26b/MLX,
|
|
2658
|
+
// the previous "DO NOT call..." wording got misread by
|
|
2659
|
+
// the model as "your call was malformed, retry it" and
|
|
2660
|
+
// looped for 10+ iterations before MAX_TOOL_LOOP_TURNS
|
|
2661
|
+
// bailed. Leading with "✓ created" + the original
|
|
2662
|
+
// tool result makes the success unambiguous; the STOP
|
|
2663
|
+
// instruction is the closing emphasis, not the framing.
|
|
2664
|
+
projectMacroInterceptCount++;
|
|
2665
|
+
output = `✓ The project was successfully created on your earlier \`${startedProjectOrJobThisTurn.tool}\` call this turn — kickoff is complete and the lead is on it.\n\nOriginal result:\n${startedProjectOrJobThisTurn.firstResult}\n\nSTOP — do NOT emit any more tool calls this turn. Each project-start call creates a real project, lead, and kickoff task; repeating the macro would create duplicates. END YOUR TURN NOW with a one-sentence summary to the user (e.g. "Project is on it — the voorman is leading"). The lead handles the work from here.`;
|
|
2666
|
+
if (projectMacroInterceptCount >= PROJECT_MACRO_INTERCEPT_CAP) {
|
|
2667
|
+
forceProjectMacroBail = {
|
|
2668
|
+
closingText: deriveProjectMacroClosing(startedProjectOrJobThisTurn.firstResult),
|
|
2669
|
+
};
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
else if (advertisedBridgeToolNames.has(call.function.name) &&
|
|
2673
|
+
this.deps.bridges.isRestrictedFromCalling(call.function.name)) {
|
|
2674
|
+
// The grammar keeps a native call inside the callable set; this
|
|
2675
|
+
// catches calls salvaged from text, which the grammar never sees.
|
|
2676
|
+
output = `ERROR: \`${call.function.name}\` is not available for this request. Call one of: ${formatToolMenu(new Set(callableRequestToolNames(tools, this.deps.bridges)))}.`;
|
|
2677
|
+
}
|
|
2678
|
+
else if (advertisedBridgeToolNames.has(call.function.name) &&
|
|
2679
|
+
this.deps.bridges.hasTool(call.function.name)) {
|
|
2680
|
+
const missingRequired = missingTopLevelRequiredToolArgs(toolArgSchemas.get(call.function.name), args);
|
|
2681
|
+
const grammarContractViolation = missingRequired.length > 0 &&
|
|
2682
|
+
hermesRequiredArgGrammarRequested(body) &&
|
|
2683
|
+
!this.forceSequentialToolGrammar;
|
|
2684
|
+
if (grammarContractViolation) {
|
|
2685
|
+
this.forceSequentialToolGrammar = true;
|
|
2686
|
+
log.warn(`turn#${seq}.${turn} required-argument grammar contract violation tool=${call.function.name} missing=${missingRequired.join(',')} — latching this session to sequential decode`);
|
|
2687
|
+
// Do not send a call that the advertised schema proves cannot
|
|
2688
|
+
// pass validation. Keeping it out of the bridge also avoids
|
|
2689
|
+
// poisoning the session-wide unresolved-failure ledger before
|
|
2690
|
+
// the sequential retry gets a chance to recover.
|
|
2691
|
+
output = `ERROR: ${call.function.name} was not executed because the constrained decoder omitted required fields: ${missingRequired.join(', ')}. The runtime switched this session to sequential decoding; retry the call once.`;
|
|
2692
|
+
}
|
|
2693
|
+
else {
|
|
2694
|
+
try {
|
|
2695
|
+
const budgetChars = computeToolBudgetChars(this.deps.numCtx, this.estimatePromptChars());
|
|
2696
|
+
let unseenImages = 0;
|
|
2697
|
+
output = await this.deps.bridges.callTool(call.function.name, args, {
|
|
2698
|
+
budgetChars,
|
|
2699
|
+
numCtxTokens: this.deps.numCtx,
|
|
2700
|
+
onApprovalPending: () => {
|
|
2701
|
+
askedQuestionThisTurn = true;
|
|
2702
|
+
},
|
|
2703
|
+
onImages: (images) => {
|
|
2704
|
+
if (this.supportsImageInput)
|
|
2705
|
+
toolImages.push(...images.map((i) => i.base64));
|
|
2706
|
+
else
|
|
2707
|
+
unseenImages += images.length;
|
|
2708
|
+
},
|
|
2709
|
+
});
|
|
2710
|
+
output += unseenToolImagesNote(unseenImages);
|
|
2711
|
+
}
|
|
2712
|
+
catch (err) {
|
|
2713
|
+
output = `ERROR: ${err instanceof Error ? err.message : String(err)}`;
|
|
2714
|
+
}
|
|
2715
|
+
// Defense in depth for a schema skew between the model-facing
|
|
2716
|
+
// roster and the authoritative MCP validator. The local presence
|
|
2717
|
+
// check above catches the normal case before execution; this arm
|
|
2718
|
+
// catches a validator that knows about a required key absent from
|
|
2719
|
+
// the advertised schema. Sequential decode may still recover on
|
|
2720
|
+
// the next iteration, and the log now makes the skew explicit.
|
|
2721
|
+
if (validatorReportedMissingRequiredArgs(output) &&
|
|
2722
|
+
hermesRequiredArgGrammarRequested(body) &&
|
|
2723
|
+
!this.forceSequentialToolGrammar) {
|
|
2724
|
+
this.forceSequentialToolGrammar = true;
|
|
2725
|
+
log.warn(`turn#${seq}.${turn} MCP validator reported a required-argument violation not visible in the emitted call for tool=${call.function.name} — latching this session to sequential decode`);
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
// Successful ask_user_question (output not an ERROR
|
|
2729
|
+
// and not the suppression sentinel above) → arm the
|
|
2730
|
+
// dedup guard and the post-ask fold for the rest of
|
|
2731
|
+
// this turn.
|
|
2732
|
+
if (call.function.name === 'ask_user_question' && !output.startsWith('ERROR:')) {
|
|
2733
|
+
askedQuestionThisTurn = true;
|
|
2734
|
+
}
|
|
2735
|
+
if (isSuccessfulAsyncHandoff(call.function.name, output)) {
|
|
2736
|
+
asyncHandoffCount++;
|
|
2737
|
+
}
|
|
2738
|
+
if ((immediateFileWriteTurn || writeContinuationActive) &&
|
|
2739
|
+
(call.function.name === 'write_file' || call.function.name === 'append_to_file') &&
|
|
2740
|
+
!output.startsWith('ERROR:')) {
|
|
2741
|
+
if (typeof args.path === 'string')
|
|
2742
|
+
immediateFileWritePaths.push(args.path);
|
|
2743
|
+
// If this write was itself truncated, the file still isn't
|
|
2744
|
+
// complete — flag it so the bail block loops a continuation
|
|
2745
|
+
// instead of declaring the file done.
|
|
2746
|
+
if (truncatedCallIds.has(call.id))
|
|
2747
|
+
immediateWriteTruncated = true;
|
|
2748
|
+
}
|
|
2749
|
+
// Successful start_project / start_job → arm the
|
|
2750
|
+
// per-turn macro guard so subsequent variants get
|
|
2751
|
+
// intercepted above before reaching the bridge.
|
|
2752
|
+
if ((call.function.name === 'start_project' || call.function.name === 'start_job') &&
|
|
2753
|
+
!output.startsWith('ERROR:')) {
|
|
2754
|
+
startedProjectOrJobThisTurn = { tool: call.function.name, firstResult: output };
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
else {
|
|
2758
|
+
output = `ERROR: tool ${call.function.name} is not available`;
|
|
2759
|
+
}
|
|
2760
|
+
// Auto-continuation hint for truncated write-shaped tool
|
|
2761
|
+
// calls. Wild-caught on Qwen 3.6 27B write_file calls whose
|
|
2762
|
+
// `<parameter=content>…` stream-truncated before the closer
|
|
2763
|
+
// arrived (Hermes shape), and on Gemma 4 26B prose-shape /
|
|
2764
|
+
// JSON-envelope `write_file({...})` calls cut mid-content.
|
|
2765
|
+
// The bridge executed the call with the partial body (an
|
|
2766
|
+
// incomplete file on disk); without this hint the model
|
|
2767
|
+
// just narrates "I wrote the file" on the next turn and
|
|
2768
|
+
// the trial times out. Shared with Ollama + llama-cpp
|
|
2769
|
+
// providers via {@link appendTruncationHintToToolResult}.
|
|
2770
|
+
if (truncatedCallIds.has(call.id)) {
|
|
2771
|
+
const before = output.length;
|
|
2772
|
+
output = appendTruncationHintToToolResult(output, call.function.name, args);
|
|
2773
|
+
if (output.length !== before) {
|
|
2774
|
+
const path = typeof args.path === 'string' ? args.path : '(unknown)';
|
|
2775
|
+
const bytes = typeof args.content === 'string' ? args.content.length : 0;
|
|
2776
|
+
log.info(`turn#${seq} auto-continuation hint appended to tool=${call.function.name} id=${call.id} path=${path} bytes=${bytes}`);
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
this.turnPolicy.checkpoint();
|
|
2780
|
+
requiredInputReads.push(...requiredInputsRead(call.function.name, args, output));
|
|
2781
|
+
if (fileRepairTurn) {
|
|
2782
|
+
if (FILE_REPAIR_READ_TOOLS.has(call.function.name))
|
|
2783
|
+
repairReadCalls++;
|
|
2784
|
+
repairReadPaths.push(...completeWorkspaceReadPaths(call.function.name, args, output));
|
|
2785
|
+
if (FILE_REPAIR_MUTATION_TOOLS.has(call.function.name)) {
|
|
2786
|
+
if (output.startsWith('ERROR:'))
|
|
2787
|
+
repairFailedMutations++;
|
|
2788
|
+
else
|
|
2789
|
+
repairMutationSucceeded = true;
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
const tracked = failureTracker.recordResult(call.function.name, output);
|
|
2793
|
+
terminalActionClosing ??= terminalToolClosingText(this.deps.terminalToolPolicy, call.function.name, args, output);
|
|
2794
|
+
// Layer the same-args repeat tracker on top of the failure
|
|
2795
|
+
// tracker. The failure tracker catches "tool keeps erroring";
|
|
2796
|
+
// the repeat tracker catches "tool keeps succeeding with the
|
|
2797
|
+
// same args while the model spins on planning prose" — the
|
|
2798
|
+
// narrative-spinning loop the user reported on Ada.
|
|
2799
|
+
const repeated = repeatTracker.recordCall(call.function.name, args, tracked.output);
|
|
2800
|
+
const paced = deliverableReadPaceTracker?.recordCall(call.function.name, repeated.output);
|
|
2801
|
+
const readyFooter = await deliverableReadySteer?.footerFor(call.function.name, args, output, () => knownToolNames);
|
|
2802
|
+
if (readyFooter) {
|
|
2803
|
+
log.info(`turn#${seq} deliverable-ready footer appended tool=${call.function.name} path=${deliverableReadySteer?.deliverableFile} fired=${deliverableReadySteer?.firedCount}`);
|
|
2804
|
+
}
|
|
2805
|
+
// Always push the (possibly-augmented) tool message so the
|
|
2806
|
+
// transcript reflects what the model would see — even on
|
|
2807
|
+
// hard abort, debugging the runaway is easier with the
|
|
2808
|
+
// last context preserved.
|
|
2809
|
+
this.messages.push({
|
|
2810
|
+
role: 'tool',
|
|
2811
|
+
content: readyFooter
|
|
2812
|
+
? `${paced?.output ?? repeated.output}\n\n${readyFooter}`
|
|
2813
|
+
: (paced?.output ?? repeated.output),
|
|
2814
|
+
tool_call_id: call.id,
|
|
2815
|
+
});
|
|
2816
|
+
if (tracked.shouldAbort) {
|
|
2817
|
+
abortDueToFailureLoop = {
|
|
2818
|
+
tool: call.function.name,
|
|
2819
|
+
count: tracked.count,
|
|
2820
|
+
...(tracked.sourceFailureKind
|
|
2821
|
+
? { sourceFailureKind: tracked.sourceFailureKind }
|
|
2822
|
+
: {}),
|
|
2823
|
+
...(tracked.transportFailure ? { transportFailure: true } : {}),
|
|
2824
|
+
...(tracked.missingPath ? { missingPath: true } : {}),
|
|
2825
|
+
};
|
|
2826
|
+
break;
|
|
2827
|
+
}
|
|
2828
|
+
if (paced?.shouldAbort && deliverableReadPaceTracker) {
|
|
2829
|
+
throw deliverableReadPaceTracker.buildAbort('Mac AI');
|
|
2830
|
+
}
|
|
2831
|
+
if (repeated.shouldAbort) {
|
|
2832
|
+
log.error(`turn#${seq} END abort-repeat-loop afterMs=${Date.now() - start} ` +
|
|
2833
|
+
`tool=${call.function.name} sameArgsCalls=${repeated.count}`);
|
|
2834
|
+
throw ToolRepeatTracker.buildAbort({
|
|
2835
|
+
providerLabel: 'Mac AI',
|
|
2836
|
+
toolName: call.function.name,
|
|
2837
|
+
args,
|
|
2838
|
+
count: repeated.count,
|
|
2839
|
+
registeredTools: knownToolNames,
|
|
2840
|
+
...(this.deps.activeCraftbookStep
|
|
2841
|
+
? { activeStep: this.deps.activeCraftbookStep }
|
|
2842
|
+
: {}),
|
|
2843
|
+
});
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
// Keep tool-call/result pairs adjacent, then deliver the actual pixels
|
|
2847
|
+
// as one vision message. Base64 must never be tokenized as tool text.
|
|
2848
|
+
if (toolImages.length)
|
|
2849
|
+
this.messages.push({
|
|
2850
|
+
role: 'user',
|
|
2851
|
+
content: TOOL_IMAGES_MESSAGE,
|
|
2852
|
+
images: toolImages,
|
|
2853
|
+
});
|
|
2854
|
+
if (abortDueToFailureLoop) {
|
|
2855
|
+
const { tool: failedTool, count: failCount } = abortDueToFailureLoop;
|
|
2856
|
+
log.error(`turn#${seq} END abort-failure-loop afterMs=${Date.now() - start} ` +
|
|
2857
|
+
`tool=${failedTool} consecutiveFails=${failCount}`);
|
|
2858
|
+
throw ToolFailureTracker.buildLoopAbort({
|
|
2859
|
+
providerLabel: 'Mac AI',
|
|
2860
|
+
loop: abortDueToFailureLoop,
|
|
2861
|
+
surgicalEditsAvailable,
|
|
2862
|
+
delegationAvailable,
|
|
2863
|
+
...writerFlags,
|
|
2864
|
+
});
|
|
2865
|
+
}
|
|
2866
|
+
this.turnPolicy.checkpoint();
|
|
2867
|
+
if (fileRepairTurn && repairMutationSucceeded) {
|
|
2868
|
+
const closing = 'Updated the requested file.';
|
|
2869
|
+
this.messages.push({ role: 'assistant', content: closing });
|
|
2870
|
+
return closing;
|
|
2871
|
+
}
|
|
2872
|
+
if (!terminalActionClosing && deliverableReadySteer) {
|
|
2873
|
+
const backstop = await deliverableReadySteer.backstopClosing();
|
|
2874
|
+
if (backstop) {
|
|
2875
|
+
log.info(`turn#${seq} deliverable-ready backstop: ${deliverableReadySteer.deliverableFile} stayed ready through the grace iterations; ending turn for the end-of-turn advance`);
|
|
2876
|
+
terminalActionClosing = backstop;
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
if (terminalActionClosing) {
|
|
2880
|
+
// The action itself is the terminal outcome. Do not spend another
|
|
2881
|
+
// generation asking the model to re-analyze a board that changed
|
|
2882
|
+
// under it; persist one short line and release the turn.
|
|
2883
|
+
this.messages.push({ role: 'assistant', content: terminalActionClosing });
|
|
2884
|
+
fullText = terminalActionClosing;
|
|
2885
|
+
log.info(`turn#${seq} END terminal-tool-bail afterMs=${Date.now() - start} loopTurns=${turn + 1}`);
|
|
2886
|
+
return fullText;
|
|
2887
|
+
}
|
|
2888
|
+
if (immediateFileWritePaths.length > 0) {
|
|
2889
|
+
// File was truncated mid-content and we still have continuation
|
|
2890
|
+
// budget — keep the turn alive instead of bailing on a partial.
|
|
2891
|
+
// The tool result already carries the "append the rest" hint;
|
|
2892
|
+
// surfacing `append_to_file` (above) lets the model act on it.
|
|
2893
|
+
if (immediateWriteTruncated && this.turnPolicy.retry('writeContinuations')) {
|
|
2894
|
+
writeContinuationActive = true;
|
|
2895
|
+
writeContinuations++;
|
|
2896
|
+
// Make append_to_file callable + salvageable for the next turn.
|
|
2897
|
+
advertisedBridgeToolNames.add('append_to_file');
|
|
2898
|
+
knownToolNames.add('append_to_file');
|
|
2899
|
+
log.info(`turn#${seq} immediate-write truncated — auto-continuation ${writeContinuations}/${MAX_IMMEDIATE_WRITE_CONTINUATIONS} (append_to_file surfaced) paths=${immediateFileWritePaths.join(',')}`);
|
|
2900
|
+
continue;
|
|
2901
|
+
}
|
|
2902
|
+
const closingText = immediateFileWriteClosing(immediateFileWritePaths);
|
|
2903
|
+
this.messages.push({ role: 'assistant', content: closingText });
|
|
2904
|
+
this.lastTurnBail = 'immediate-write';
|
|
2905
|
+
fullText = closingText;
|
|
2906
|
+
log.info(`turn#${seq} END immediate-write-bail afterMs=${Date.now() - start} ` +
|
|
2907
|
+
`paths=${immediateFileWritePaths.join(',') || '(unknown)'} loopTurns=${turn + 1}` +
|
|
2908
|
+
`${writeContinuations > 0 ? ` continuations=${writeContinuations}` : ''}`);
|
|
2909
|
+
return fullText;
|
|
2910
|
+
}
|
|
2911
|
+
if (asyncHandoffCount > 0) {
|
|
2912
|
+
const closingText = asyncHandoffClosing(asyncHandoffCount);
|
|
2913
|
+
this.messages.push({ role: 'assistant', content: closingText });
|
|
2914
|
+
fullText = closingText;
|
|
2915
|
+
log.info(`turn#${seq} END async-handoff-bail afterMs=${Date.now() - start} ` +
|
|
2916
|
+
`handoffs=${asyncHandoffCount} loopTurns=${turn + 1}`);
|
|
2917
|
+
return fullText;
|
|
2918
|
+
}
|
|
2919
|
+
if (forceProjectMacroBail) {
|
|
2920
|
+
// Model emitted PROJECT_MACRO_INTERCEPT_CAP+ redundant
|
|
2921
|
+
// start_project / start_job envelopes despite the per-turn
|
|
2922
|
+
// guard returning a clear "STOP" message each time. The
|
|
2923
|
+
// project IS real — we hold onto the first call's result
|
|
2924
|
+
// text — but the model can't be talked into wrapping the
|
|
2925
|
+
// turn, so we synthesize a one-line closing and exit. The
|
|
2926
|
+
// user sees a clean bubble instead of minutes of malformed
|
|
2927
|
+
// retry prose.
|
|
2928
|
+
log.warn(`turn#${seq} END force-macro-bail afterMs=${Date.now() - start} ` +
|
|
2929
|
+
`intercepts=${projectMacroInterceptCount} loopTurns=${turn + 1}`);
|
|
2930
|
+
this.messages.push({ role: 'assistant', content: forceProjectMacroBail.closingText });
|
|
2931
|
+
fullText = forceProjectMacroBail.closingText;
|
|
2932
|
+
this.emitWarning(`On-device model emitted ${projectMacroInterceptCount + 1} \`${startedProjectOrJobThisTurn?.tool ?? 'start_project'}\` attempts in one turn after the first one succeeded. The runtime ended the turn for you — the project is real and the lead is on it. Consider switching the Meester to a cloud model if this recurs.`);
|
|
2933
|
+
return fullText;
|
|
2934
|
+
}
|
|
2935
|
+
// Terminal: the model posted a question card to the user — end the
|
|
2936
|
+
// turn (its answer arrives as the next user message). Without this the
|
|
2937
|
+
// loop issues another generation request and can wedge in-flight; see
|
|
2938
|
+
// the llama-cpp provider for the full "stuck session" rationale.
|
|
2939
|
+
if (askedQuestionThisTurn)
|
|
2940
|
+
return fullText;
|
|
2941
|
+
}
|
|
2942
|
+
throw new Error(`[mlx] too many tool-call loops (>${MAX_TOOL_LOOP_TURNS}); aborting to prevent runaway`);
|
|
2943
|
+
}
|
|
2944
|
+
catch (err) {
|
|
2945
|
+
turnFailed = true;
|
|
2946
|
+
log.info(`turn#${seq} END throw afterMs=${Date.now() - start} ` +
|
|
2947
|
+
`${err instanceof Error ? err.message.slice(0, 200) : String(err)}`);
|
|
2948
|
+
throw err;
|
|
2949
|
+
}
|
|
2950
|
+
finally {
|
|
2951
|
+
if (!turnFailed) {
|
|
2952
|
+
try {
|
|
2953
|
+
emitTerminalTelemetry();
|
|
2954
|
+
}
|
|
2955
|
+
catch (err) {
|
|
2956
|
+
// Telemetry must never turn a completed model response into a
|
|
2957
|
+
// failed user turn. Preserve the reply and leave a diagnostic.
|
|
2958
|
+
log.warn(`turn#${seq} terminal telemetry failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
releaseActiveEngineRequest?.();
|
|
2962
|
+
clearInterval(statusHeartbeat);
|
|
2963
|
+
this.deps.provider._deregisterActiveSession(this);
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
providerState() {
|
|
2967
|
+
return {};
|
|
2968
|
+
}
|
|
2969
|
+
getRegisteredToolNames() {
|
|
2970
|
+
if (this.deps.bridges.isEmpty())
|
|
2971
|
+
return [];
|
|
2972
|
+
return this.deps.bridges.getOpenAITools().map((t) => t.name);
|
|
2973
|
+
}
|
|
2974
|
+
setSystemMessage(text) {
|
|
2975
|
+
// Replace the system message in-place. The constructor always
|
|
2976
|
+
// installs a `{role: 'system'}` entry at index 0, so this is
|
|
2977
|
+
// safe even after a turn or two have appended user/assistant
|
|
2978
|
+
// messages — those stay where they are.
|
|
2979
|
+
if (this.messages.length === 0 || this.messages[0]?.role !== 'system') {
|
|
2980
|
+
this.messages.unshift({ role: 'system', content: text });
|
|
2981
|
+
this.currentTurnStartIdx += 1;
|
|
2982
|
+
}
|
|
2983
|
+
else {
|
|
2984
|
+
this.messages[0] = { role: 'system', content: text };
|
|
2985
|
+
}
|
|
2986
|
+
// Keep the prefix-cache mapping in sync with the bytes actually sent
|
|
2987
|
+
// (the next prepareForSend hashes deps.systemMessage / the layered
|
|
2988
|
+
// `project` band). Without this a mid-session refresh would key the
|
|
2989
|
+
// cache on the original prompt. The refresh only changes the tools
|
|
2990
|
+
// block (late `project` layer), so update `project`, keep `gezel`.
|
|
2991
|
+
this.deps.systemMessage = text;
|
|
2992
|
+
if (this.deps.systemPromptLayers) {
|
|
2993
|
+
this.deps.systemPromptLayers = {
|
|
2994
|
+
gezel: this.deps.systemPromptLayers.gezel,
|
|
2995
|
+
project: text,
|
|
2996
|
+
};
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
async disconnect() {
|
|
3000
|
+
this.clearHandlers();
|
|
3001
|
+
const adapter = this.deps.provider.getCacheAdapter();
|
|
3002
|
+
if (adapter && this.fallbackCacheUsed) {
|
|
3003
|
+
// Ephemeral/one-shot sessions only need their fallback cache while the
|
|
3004
|
+
// MlxSession is alive (especially across tool-loop iterations). Do not
|
|
3005
|
+
// leave one never-resumable disk entry per background completion.
|
|
3006
|
+
await adapter.evict([this.fallbackCacheId]).catch((err) => {
|
|
3007
|
+
log.warn(`failed to evict ephemeral cache ${this.fallbackCacheId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
3008
|
+
});
|
|
3009
|
+
}
|
|
3010
|
+
try {
|
|
3011
|
+
await this.deps.bridges.stop();
|
|
3012
|
+
}
|
|
3013
|
+
catch {
|
|
3014
|
+
/* ignore */
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
}
|