@bendyline/gezel-inference 1.0.6 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/dist/cache/controller.d.ts +2 -0
  2. package/dist/cache/controller.js +38 -5
  3. package/dist/catalog/chat-models/index.json +1 -1
  4. package/dist/catalog/chat-models/lf/lfm3-1.7b-q4/manifest.json +114 -0
  5. package/dist/catalog/chat-models/lf/lfm3-1.7b-q4/versions/1.0.0/manifest.json +15 -0
  6. package/dist/catalog/chat-models/raw-index.json +1 -1
  7. package/dist/catalog/image-loras/index.json +1 -0
  8. package/dist/catalog/image-loras/kr/krea-2-ink-wash/manifest.json +31 -0
  9. package/dist/catalog/image-loras/kr/krea-2-ink-wash/versions/1.0.0/manifest.json +15 -0
  10. package/dist/catalog/image-loras/kr/krea-2-isometric-3d/manifest.json +32 -0
  11. package/dist/catalog/image-loras/kr/krea-2-isometric-3d/versions/1.0.0/manifest.json +15 -0
  12. package/dist/catalog/image-loras/kr/krea-2-kids-drawing/manifest.json +31 -0
  13. package/dist/catalog/image-loras/kr/krea-2-kids-drawing/versions/1.0.0/manifest.json +15 -0
  14. package/dist/catalog/image-loras/kr/krea-2-pixel-art/manifest.json +31 -0
  15. package/dist/catalog/image-loras/kr/krea-2-pixel-art/versions/1.0.0/manifest.json +15 -0
  16. package/dist/catalog/image-loras/kr/krea-2-soft-watercolor/manifest.json +31 -0
  17. package/dist/catalog/image-loras/kr/krea-2-soft-watercolor/versions/1.0.0/manifest.json +15 -0
  18. package/dist/catalog/image-loras/kr/krea-2-turbo-4step/manifest.json +31 -0
  19. package/dist/catalog/image-loras/kr/krea-2-turbo-4step/versions/1.0.0/manifest.json +15 -0
  20. package/dist/catalog/image-loras/mo/moss-image-gouache/manifest.json +29 -0
  21. package/dist/catalog/image-loras/mo/moss-image-gouache/versions/1.0.0/manifest.json +15 -0
  22. package/dist/catalog/image-loras/mo/moss-image-sft-4step/manifest.json +30 -0
  23. package/dist/catalog/image-loras/mo/moss-image-sft-4step/versions/1.0.0/manifest.json +15 -0
  24. package/dist/catalog/image-loras/qw/qwen-image-edit-2511-lightning/manifest.json +31 -0
  25. package/dist/catalog/image-loras/qw/qwen-image-edit-2511-lightning/versions/1.0.0/manifest.json +15 -0
  26. package/dist/catalog/image-loras/raw-index.json +1 -0
  27. package/dist/catalog/image-loras/sd/sdxl-3d-render/manifest.json +29 -0
  28. package/dist/catalog/image-loras/sd/sdxl-3d-render/versions/1.0.0/manifest.json +15 -0
  29. package/dist/catalog/image-loras/sd/sdxl-coloring-book/manifest.json +30 -0
  30. package/dist/catalog/image-loras/sd/sdxl-coloring-book/versions/1.0.0/manifest.json +15 -0
  31. package/dist/catalog/image-loras/sd/sdxl-papercut/manifest.json +29 -0
  32. package/dist/catalog/image-loras/sd/sdxl-papercut/versions/1.0.0/manifest.json +15 -0
  33. package/dist/catalog/image-loras/sd/sdxl-pixel-art/manifest.json +29 -0
  34. package/dist/catalog/image-loras/sd/sdxl-pixel-art/versions/1.0.0/manifest.json +15 -0
  35. package/dist/catalog/image-models/fl/flux-2-klein-4b-q4/manifest.json +3 -0
  36. package/dist/catalog/image-models/index.json +1 -1
  37. package/dist/catalog/image-models/qw/qwen-image-edit-2511-q4/manifest.json +44 -0
  38. package/dist/catalog/image-models/qw/qwen-image-edit-2511-q4/versions/1.0.0/manifest.json +36 -0
  39. package/dist/catalog/image-models/raw-index.json +1 -1
  40. package/dist/catalog/knowledge-catalogs/index.json +1 -1
  41. package/dist/catalog/knowledge-catalogs/raw-index.json +1 -1
  42. package/dist/catalog/knowledge-catalogs/wi/wikipedia-arts/versions/2026.10.2/manifest.json +181 -0
  43. package/dist/catalog/knowledge-catalogs/wi/wikipedia-astronomy/versions/2026.10.2/manifest.json +181 -0
  44. package/dist/catalog/knowledge-catalogs/wi/wikipedia-biology/versions/2026.10.2/manifest.json +217 -0
  45. package/dist/catalog/knowledge-catalogs/wi/wikipedia-business/versions/2026.10.2/manifest.json +197 -0
  46. package/dist/catalog/knowledge-catalogs/wi/wikipedia-film-tv/versions/2026.10.2/manifest.json +197 -0
  47. package/dist/catalog/knowledge-catalogs/wi/wikipedia-food-drink/versions/2026.10.2/manifest.json +185 -0
  48. package/dist/catalog/knowledge-catalogs/wi/wikipedia-games/versions/2026.10.2/manifest.json +181 -0
  49. package/dist/catalog/knowledge-catalogs/wi/wikipedia-general/manifest.json +55 -0
  50. package/dist/catalog/knowledge-catalogs/wi/wikipedia-general/versions/2026.10.2/manifest.json +165 -0
  51. package/dist/catalog/knowledge-catalogs/wi/wikipedia-geography/versions/2026.10.2/manifest.json +217 -0
  52. package/dist/catalog/knowledge-catalogs/wi/wikipedia-history/versions/2026.10.2/manifest.json +217 -0
  53. package/dist/catalog/knowledge-catalogs/wi/wikipedia-language/versions/2026.10.2/manifest.json +181 -0
  54. package/dist/catalog/knowledge-catalogs/wi/wikipedia-law/versions/2026.10.2/manifest.json +185 -0
  55. package/dist/catalog/knowledge-catalogs/wi/wikipedia-literature/versions/2026.10.2/manifest.json +193 -0
  56. package/dist/catalog/knowledge-catalogs/wi/wikipedia-mathematics/versions/2026.10.2/manifest.json +189 -0
  57. package/dist/catalog/knowledge-catalogs/wi/wikipedia-medicine/versions/2026.10.2/manifest.json +185 -0
  58. package/dist/catalog/knowledge-catalogs/wi/wikipedia-military/versions/2026.10.2/manifest.json +189 -0
  59. package/dist/catalog/knowledge-catalogs/wi/wikipedia-music/versions/2026.10.2/manifest.json +197 -0
  60. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-arts/versions/2026.10.2/manifest.json +209 -0
  61. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-business/versions/2026.10.2/manifest.json +189 -0
  62. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-entertainment/versions/2026.10.2/manifest.json +201 -0
  63. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-military/versions/2026.10.2/manifest.json +185 -0
  64. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-other/versions/2026.10.2/manifest.json +165 -0
  65. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-politics/versions/2026.10.2/manifest.json +197 -0
  66. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-religion/versions/2026.10.2/manifest.json +201 -0
  67. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-science/versions/2026.10.2/manifest.json +205 -0
  68. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-sports/versions/2026.10.2/manifest.json +237 -0
  69. package/dist/catalog/knowledge-catalogs/wi/wikipedia-politics/versions/2026.10.2/manifest.json +197 -0
  70. package/dist/catalog/knowledge-catalogs/wi/wikipedia-religion-philosophy/versions/2026.10.2/manifest.json +189 -0
  71. package/dist/catalog/knowledge-catalogs/wi/wikipedia-science/versions/2026.10.2/manifest.json +185 -0
  72. package/dist/catalog/knowledge-catalogs/wi/wikipedia-sports/versions/2026.10.2/manifest.json +193 -0
  73. package/dist/catalog/knowledge-catalogs/wi/wikipedia-technology/versions/2026.10.2/manifest.json +185 -0
  74. package/dist/catalog/knowledge-catalogs/wi/wikipedia-transportation/versions/2026.10.2/manifest.json +189 -0
  75. package/dist/catalog/provenance.json +2 -1
  76. package/dist/engine-components.js +2 -2
  77. package/dist/engines/electron-native-reuse.d.ts +7 -1
  78. package/dist/engines/electron-native-reuse.js +12 -4
  79. package/dist/engines/native-file-manifest.json +546 -406
  80. package/dist/engines/native-manifest.d.ts +4 -4
  81. package/dist/engines/native-manifest.js +36 -36
  82. package/dist/engines/resolver.js +1 -0
  83. package/dist/http/auth-context.d.ts +12 -0
  84. package/dist/http/routes/image-ensure-routes.d.ts +4 -0
  85. package/dist/http/routes/image-ensure-routes.js +102 -0
  86. package/dist/http/routes/v1-image-styles.d.ts +5 -0
  87. package/dist/http/routes/v1-image-styles.js +91 -0
  88. package/dist/http/routes/v1-images.d.ts +3 -1
  89. package/dist/http/routes/v1-images.js +48 -99
  90. package/dist/inference-service.js +2 -1
  91. package/dist/knowledge/manager.d.ts +3 -27
  92. package/dist/knowledge/manager.js +8 -364
  93. package/dist/knowledge/unified-search.d.ts +61 -0
  94. package/dist/knowledge/unified-search.js +373 -0
  95. package/dist/knowledge/worker-host.js +2 -0
  96. package/dist/machine-engine/inspect-runtime.js +12 -3
  97. package/dist/memory/embed-core.js +3 -0
  98. package/dist/memory/embeddings.js +2 -0
  99. package/dist/models/image-fit.js +2 -2
  100. package/dist/models/weight-facts.d.ts +5 -0
  101. package/dist/models/weight-facts.js +5 -2
  102. package/dist/providers/ds4/build-provider.js +1 -1
  103. package/dist/providers/ds4/residency.js +3 -3
  104. package/dist/providers/image/img2img-support.d.ts +36 -1
  105. package/dist/providers/image/img2img-support.js +30 -1
  106. package/dist/providers/image/local-engine.d.ts +1 -0
  107. package/dist/providers/image/local-engine.js +8 -2
  108. package/dist/providers/image/local-factory.d.ts +10 -1
  109. package/dist/providers/image/local-factory.js +33 -6
  110. package/dist/providers/image/mock.d.ts +8 -1
  111. package/dist/providers/image/mock.js +46 -1
  112. package/dist/providers/image/pull-registry.d.ts +25 -3
  113. package/dist/providers/image/pull-registry.js +96 -34
  114. package/dist/providers/image/sd-cpp.d.ts +63 -4
  115. package/dist/providers/image/sd-cpp.js +368 -38
  116. package/dist/providers/image/types.d.ts +76 -4
  117. package/dist/providers/image/types.js +8 -0
  118. package/dist/providers/llama-cpp/build-provider.js +3 -2
  119. package/dist/providers/llama-cpp/cache-adapter.d.ts +3 -0
  120. package/dist/providers/llama-cpp/cache-adapter.js +18 -4
  121. package/dist/providers/llama-cpp/devices.js +2 -2
  122. package/dist/providers/llama-cpp/engine-flags.d.ts +5 -0
  123. package/dist/providers/llama-cpp/engine-flags.js +5 -2
  124. package/dist/providers/mlx/async-file-handoff.d.ts +9 -1
  125. package/dist/providers/mlx/async-file-handoff.js +32 -4
  126. package/dist/providers/mlx/provider.js +41 -20
  127. package/dist/providers/mlx/python/gezel_mlx_server.py +9 -2
  128. package/dist/providers/mlx/python/spec_decode.py +44 -5
  129. package/dist/providers/mlx/runtime-diagnostics.d.ts +6 -0
  130. package/dist/providers/mlx/runtime-diagnostics.js +18 -1
  131. package/dist/providers/mlx/tool-call-protocol.d.ts +4 -3
  132. package/dist/providers/mlx/tool-call-protocol.js +4 -3
  133. package/dist/providers/native/capacity-broker.d.ts +1 -1
  134. package/dist/providers/native/capacity-broker.js +8 -8
  135. package/dist/providers/native/capacity-budget.js +5 -4
  136. package/dist/providers/native/device-capacity-ledger.d.ts +5 -1
  137. package/dist/providers/native/device-capacity-ledger.js +60 -14
  138. package/dist/providers/native/device-capacity.js +4 -3
  139. package/dist/providers/native/local-engine-runtime.js +4 -4
  140. package/dist/providers/native/process-identity.d.ts +40 -0
  141. package/dist/providers/native/process-identity.js +156 -0
  142. package/dist/relevance/relevance-core.js +2 -0
  143. package/dist/relevance/relevance-model.js +2 -0
  144. package/dist/relevance/settings.js +2 -2
  145. package/dist/runtime-lock.d.ts +19 -5
  146. package/dist/runtime-lock.js +116 -26
  147. package/dist/system/host-memory.d.ts +46 -0
  148. package/dist/system/host-memory.js +89 -0
  149. package/dist/system/memory.js +5 -4
  150. package/dist/transformers-cache.js +2 -0
  151. package/dist/utils/download-common.d.ts +1 -1
  152. package/package.json +8 -8
@@ -1,4 +1,4 @@
1
- import type { ImageAspectRatio, ImageModelAuxiliaryRole, ImageQuality } from '@bendyline/gezel';
1
+ import type { ImageAspectRatio, ImageLoraPrompt, ImageLoraPurpose, ImageLoraRender, ImageModelAuxiliaryRole, ImageQuality } from '@bendyline/gezel';
2
2
  export type { ImageModelAuxiliaryRole } from '@bendyline/gezel';
3
3
  /**
4
4
  * ImageProvider — the service-side abstraction for local image generation.
@@ -69,12 +69,21 @@ export interface ImageGenerationInput {
69
69
  seed?: number;
70
70
  /**
71
71
  * Source images for image editing / reference-driven generation.
72
- * Cloud providers accept multiple; sd-cpp's img2img path uses the
73
- * first only (the others are dropped with a warning).
72
+ * Cloud providers accept multiple; a local model that reads references
73
+ * takes up to its limit, and sd-cpp's img2img path uses the first only
74
+ * (the others are dropped with a warning).
74
75
  */
75
76
  inputImages?: ImageInputBytes[];
76
- /** Img2img strength (0 = keep source, 1 = ignore source). sd-cpp only. */
77
+ /** Img2img strength (0 = keep source, 1 = ignore source). sd-cpp img2img only. */
77
78
  strength?: number;
79
+ /**
80
+ * LoRAs to apply, by catalog id: a style, a fast mode. The engine reads each
81
+ * one's file, strength, prompt words and render settings from its own
82
+ * install, so an engine that predates LoRAs renders correctly without them.
83
+ */
84
+ loras?: Array<{
85
+ id: string;
86
+ }>;
78
87
  /**
79
88
  * Optional callback fired as the engine advances through sampling
80
89
  * steps. Today only the local sd-cpp provider emits these; cloud
@@ -95,6 +104,11 @@ export declare class ImageGenerationCancelledError extends Error {
95
104
  readonly code = "image_generation_cancelled";
96
105
  constructor();
97
106
  }
107
+ /** A model made only for editing was asked for a picture with nothing to edit. */
108
+ export declare class ImageSourceRequiredError extends Error {
109
+ readonly code = "image_source_required";
110
+ constructor(modelName: string);
111
+ }
98
112
  /**
99
113
  * The engine could not fit the render into graphics memory. sd-server plans
100
114
  * placement at launch and can misjudge free memory held by another engine;
@@ -120,6 +134,11 @@ export interface ImageGenerationMeta {
120
134
  img2imgSkippedReason?: string;
121
135
  /** Provider limits or adjustments to the requested image settings. */
122
136
  settingsNotes?: string[];
137
+ /** The LoRAs the engine applied; absent when none. */
138
+ loras?: Array<{
139
+ id: string;
140
+ name: string;
141
+ }>;
123
142
  }
124
143
  export interface ImageGenerationOutput {
125
144
  png: Buffer;
@@ -134,6 +153,45 @@ export interface InstalledImageModelInfo {
134
153
  weightsKind?: 'checkpoint' | 'diffusion-model';
135
154
  /** Explicit catalog capability recorded at pull time; absent → resolve via the ladder. */
136
155
  supportsImg2Img?: boolean;
156
+ /**
157
+ * Resolved editing capability (see `resolveImageEditCapability`), so a
158
+ * caller across the machine broker or a paired server can choose a model
159
+ * without the engine's assessment map.
160
+ */
161
+ editMode?: 'reference' | 'img2img';
162
+ maxSourceImages?: number;
163
+ editOnly?: boolean;
164
+ /**
165
+ * Installed LoRAs that fit this model. Carried on the model listing because
166
+ * that listing already reaches the product daemon from a machine broker or
167
+ * a paired server; an engine that predates LoRAs lists none.
168
+ */
169
+ loras?: InstalledImageLoraSummary[];
170
+ }
171
+ /** An installed LoRA as the model listing names it. */
172
+ export interface InstalledImageLoraSummary {
173
+ id: string;
174
+ name: string;
175
+ purpose: ImageLoraPurpose;
176
+ /** The steps a speed LoRA renders in. */
177
+ steps?: number;
178
+ }
179
+ export interface InstalledImageLoraInfo extends InstalledImageLoraSummary {
180
+ baseModels: string[];
181
+ approxSizeBytes: number;
182
+ installedAt: string;
183
+ }
184
+ /** What a LoRA install needs from its catalog entry; persisted beside the file. */
185
+ export interface ImageLoraPullSpec {
186
+ downloadUrl: string;
187
+ sha256: string;
188
+ approxSizeBytes: number;
189
+ name: string;
190
+ baseModels: string[];
191
+ purpose: ImageLoraPurpose;
192
+ strength: number;
193
+ prompt?: ImageLoraPrompt;
194
+ render?: ImageLoraRender;
137
195
  }
138
196
  export type ImageModelPullEvent = {
139
197
  type: 'progress';
@@ -187,6 +245,12 @@ export interface ImageModelPullSpec {
187
245
  * weights-kind default.
188
246
  */
189
247
  supportsImg2Img?: boolean;
248
+ /** Catalog `referenceImages`, persisted the same way. */
249
+ referenceImages?: {
250
+ max: number;
251
+ };
252
+ /** Catalog `editOnly`, persisted the same way. */
253
+ editOnly?: boolean;
190
254
  /** Auxiliary files (VAE, text encoders) downloaded alongside the unet. */
191
255
  auxiliaryFiles: ImageModelAuxiliaryPullSpec[];
192
256
  }
@@ -225,6 +289,14 @@ export interface ImageProvider {
225
289
  */
226
290
  pullModel(id: string, spec: ImageModelPullSpec, signal?: AbortSignal): AsyncIterable<ImageModelPullEvent>;
227
291
  deleteModel(id: string): Promise<void>;
292
+ /**
293
+ * LoRA management for an engine that applies them (the local sd-cpp
294
+ * engine). Rendering never needs these: the model listing carries the
295
+ * installed LoRAs and `generate` takes their ids.
296
+ */
297
+ pullLora?(id: string, spec: ImageLoraPullSpec, signal?: AbortSignal): AsyncIterable<ImageModelPullEvent>;
298
+ listLoras?(): Promise<InstalledImageLoraInfo[]>;
299
+ deleteLora?(id: string): Promise<void>;
228
300
  /**
229
301
  * Probe the underlying engine for reachability without doing real
230
302
  * work. Implementations should be fast (sub-second) and never throw
@@ -6,6 +6,14 @@ export class ImageGenerationCancelledError extends Error {
6
6
  this.name = 'ImageGenerationCancelledError';
7
7
  }
8
8
  }
9
+ /** A model made only for editing was asked for a picture with nothing to edit. */
10
+ export class ImageSourceRequiredError extends Error {
11
+ code = 'image_source_required';
12
+ constructor(modelName) {
13
+ super(`${modelName} changes a picture you give it. Attach the picture to change, or pick a model that draws new ones.`);
14
+ this.name = 'ImageSourceRequiredError';
15
+ }
16
+ }
9
17
  /**
10
18
  * The engine could not fit the render into graphics memory. sd-server plans
11
19
  * placement at launch and can misjudge free memory held by another engine;
@@ -1,4 +1,3 @@
1
- import { availableParallelism } from 'node:os';
2
1
  import { delimiter, dirname, join } from 'node:path';
3
2
  import { DEFAULT_LOCAL_ENGINE_IDLE_TIMEOUT_MS, createLogger, estimateLlamaCppResidentBytes, llamaCppBackendExclusion, llamaCppBackendLabel, } from '@bendyline/gezel';
4
3
  import { LLAMA_QUARANTINE_STRIKES, clearLlamaStartupStrikes, recordLlamaStartupCrash, unrunnableBuildExit, } from '@bendyline/gezel/native';
@@ -6,6 +5,7 @@ import { gezelPaths } from '@bendyline/gezel/paths';
6
5
  import { mmprojBudgetBytes, nativeVisionEnabledFor } from '../../chat/vision-capability.js';
7
6
  import { effectiveEngineRelease, isEnginePinned } from '../../engines/native-manifest.js';
8
7
  import { engineInstallPhase } from '../../engines/registry.js';
8
+ import { availableCpuCount, containerCpuLimit } from '../../system/host-memory.js';
9
9
  import { localDeviceSettingsLabel, noModelYetMessage, notReadyError, } from '../active-install-message.js';
10
10
  import { resolveCatalogLlamaCppEngineConfig, resolveCatalogReasoningBudget, } from '../catalog-model-config.js';
11
11
  import { CapacityDeniedError, NOT_ENOUGH_MEMORY_MESSAGE, availableSystemRamBytes, formatContextCapacityDenial, minViableLocalContextTokens, planAdaptiveContextGrowth, pressureIdleGraceMs, resolveLlamaCppContextRequirement, } from '../native/capacity-broker.js';
@@ -1352,7 +1352,8 @@ export async function buildLlamaCppProvider(opts) {
1352
1352
  reasoningFormat: process.env.GEZEL_LLAMA_REASONING_FORMAT?.trim() || undefined,
1353
1353
  reasoningPreserve,
1354
1354
  backend: llamaBuildMetadata?.backend ?? process.env.GEZEL_LLAMA_SERVER_BACKEND,
1355
- hostCpuCount: availableParallelism(),
1355
+ hostCpuCount: availableCpuCount(),
1356
+ containerCpuLimit: containerCpuLimit() ?? undefined,
1356
1357
  });
1357
1358
  return {
1358
1359
  command: binary,
@@ -201,6 +201,7 @@ export declare class LlamaCppCacheAdapter implements EngineCacheAdapter {
201
201
  prepareForSend(sessionId: string, systemPrompt?: string, layers?: SystemPromptLayers): Promise<void>;
202
202
  buildRequestExtras(sessionId: string): Record<string, unknown>;
203
203
  evict(sessionIds: readonly string[]): Promise<void>;
204
+ private evictUnlocked;
204
205
  markEphemeral(sessionId: string): void;
205
206
  reportUsage(): Promise<readonly EngineCacheUsage[]>;
206
207
  warm(_sessionId: string, _messages: readonly CacheWarmMessage[]): Promise<void>;
@@ -210,6 +211,7 @@ export declare class LlamaCppCacheAdapter implements EngineCacheAdapter {
210
211
  * sessions for the next boot.
211
212
  */
212
213
  flushAll(): Promise<number>;
214
+ private flushAllUnlocked;
213
215
  /**
214
216
  * Async path: allocate a slot for `sessionId`, save the LRU victim
215
217
  * (if any) to disk, and try to restore the new slot from disk
@@ -217,6 +219,7 @@ export declare class LlamaCppCacheAdapter implements EngineCacheAdapter {
217
219
  * {@link prepareForSend}.
218
220
  */
219
221
  private allocateSlot;
222
+ private withAllocationLock;
220
223
  private assertEngineGeneration;
221
224
  private allocateSlotUnlocked;
222
225
  /**
@@ -258,7 +258,11 @@ export class LlamaCppCacheAdapter {
258
258
  };
259
259
  }
260
260
  async evict(sessionIds) {
261
- const generation = this.engineGeneration;
261
+ return this.withAllocationLock((generation) => this.evictUnlocked(sessionIds, generation));
262
+ }
263
+ async evictUnlocked(sessionIds, generation) {
264
+ if (generation !== this.engineGeneration)
265
+ return;
262
266
  // Save before forgetting so the session can resume on next access.
263
267
  // Persistence is best-effort; failures are logged and the local
264
268
  // mapping is still cleared (controller view stays consistent).
@@ -352,7 +356,11 @@ export class LlamaCppCacheAdapter {
352
356
  async flushAll() {
353
357
  if (!this.slotSavePath)
354
358
  return 0;
355
- const generation = this.engineGeneration;
359
+ return this.withAllocationLock((generation) => this.flushAllUnlocked(generation));
360
+ }
361
+ async flushAllUnlocked(generation) {
362
+ if (generation !== this.engineGeneration)
363
+ return 0;
356
364
  let saved = 0;
357
365
  for (const [sessionId, slot] of this.sessionToSlot) {
358
366
  if (await this.saveSlotForSession(slot, sessionId))
@@ -369,6 +377,13 @@ export class LlamaCppCacheAdapter {
369
377
  * {@link prepareForSend}.
370
378
  */
371
379
  async allocateSlot(sessionId) {
380
+ return this.withAllocationLock((generation) => {
381
+ this.assertEngineGeneration(generation);
382
+ return this.allocateSlotUnlocked(sessionId, generation);
383
+ });
384
+ }
385
+ // Eviction and idle flushing must not save a slot after a handoff has rebound it.
386
+ async withAllocationLock(work) {
372
387
  const generation = this.engineGeneration;
373
388
  const previous = this.allocationTail;
374
389
  let release;
@@ -377,8 +392,7 @@ export class LlamaCppCacheAdapter {
377
392
  });
378
393
  await previous;
379
394
  try {
380
- this.assertEngineGeneration(generation);
381
- return await this.allocateSlotUnlocked(sessionId, generation);
395
+ return await work(generation);
382
396
  }
383
397
  finally {
384
398
  release();
@@ -17,11 +17,11 @@
17
17
  */
18
18
  import { execFile } from 'node:child_process';
19
19
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
20
- import { totalmem } from 'node:os';
21
20
  import { delimiter, dirname, join } from 'node:path';
22
21
  import { promisify } from 'node:util';
23
22
  import { windowsHeadlessSpawnOptions } from '@bendyline/gezel/native';
24
23
  import { gezelHome } from '@bendyline/gezel/paths';
24
+ import { totalMemoryBytes } from '../../system/host-memory.js';
25
25
  const execFileAsync = promisify(execFile);
26
26
  const LINE_RE = /^\s*(\S+?):\s*(.+?)\s*\((\d+)\s*MiB,\s*(\d+)\s*MiB\s+free\)\s*$/;
27
27
  /**
@@ -158,7 +158,7 @@ export function llamaBinaryFingerprint(binaryPath) {
158
158
  export async function probeLlamaDevices(opts) {
159
159
  const { binaryPath, home, force } = opts;
160
160
  const now = opts.nowIso ?? (() => new Date().toISOString());
161
- const systemRamBytes = opts.systemRamBytes ?? totalmem();
161
+ const systemRamBytes = opts.systemRamBytes ?? totalMemoryBytes();
162
162
  const path = cachePath(home);
163
163
  const binary = llamaBinaryFingerprint(binaryPath);
164
164
  if (!force && existsSync(path)) {
@@ -133,6 +133,11 @@ export interface EngineFlagInput {
133
133
  backend?: string | undefined;
134
134
  /** Logical CPUs available to the daemon; caps the GPU-resident thread default. */
135
135
  hostCpuCount?: number | undefined;
136
+ /**
137
+ * CPUs a container leaves the daemon, when fewer than the host has (see
138
+ * `containerCpuLimit`). Without it llama.cpp sizes its pool from the host.
139
+ */
140
+ containerCpuLimit?: number | undefined;
136
141
  }
137
142
  /** Default `--cache-reuse` chunk when neither config nor manifest set it. */
138
143
  export declare const DEFAULT_CACHE_REUSE = 256;
@@ -100,7 +100,7 @@ function normalizeFlagKey(key) {
100
100
  * `args` array.
101
101
  */
102
102
  export function buildLlamaCppEngineArgs(input) {
103
- const { config, perModel, planner, kvCacheType, slots, ggufHasMtp, installedDraftModelPath, reasoningFormat, reasoningPreserve, backend, hostCpuCount, } = input;
103
+ const { config, perModel, planner, kvCacheType, slots, ggufHasMtp, installedDraftModelPath, reasoningFormat, reasoningPreserve, backend, hostCpuCount, containerCpuLimit, } = input;
104
104
  const args = [];
105
105
  // ── GPU-layer offload (`--n-gpu-layers`) ──────────────────────────
106
106
  const nGpuLayers = config.llamaCppNGpuLayers ?? perModel?.nGpuLayers ?? planner?.nGpuLayers;
@@ -203,9 +203,12 @@ export function buildLlamaCppEngineArgs(input) {
203
203
  !(typeof nCpuMoe === 'number' && nCpuMoe > 0) &&
204
204
  !(typeof nCpuFfn === 'number' && nCpuFfn > 0) &&
205
205
  !extraArgsOwnThreads(config.llamaCppExtraArgs);
206
+ // In a container that allows fewer CPUs than the host has, the all-cores
207
+ // default oversubscribes the quota and spends each period throttled.
206
208
  const threads = config.llamaCppThreads ??
207
209
  perModel?.threads ??
208
- (gpuResident ? gpuResidentThreads(hostCpuCount) : undefined);
210
+ (gpuResident ? gpuResidentThreads(hostCpuCount) : undefined) ??
211
+ (extraArgsOwnThreads(config.llamaCppExtraArgs) ? undefined : containerCpuLimit);
209
212
  if (typeof threads === 'number')
210
213
  args.push('--threads', String(threads));
211
214
  const batchSize = config.llamaCppBatchSize ?? perModel?.batchSize;
@@ -7,5 +7,13 @@
7
7
  * recipients.
8
8
  */
9
9
  export declare function isSuccessfulAsyncHandoff(toolName: string, output: string): boolean;
10
- export declare function asyncHandoffClosing(count: number): string;
10
+ /** The gezel a successful handoff result names, when it names one. */
11
+ export declare function asyncHandoffRecipient(output: string): string | undefined;
12
+ /**
13
+ * The reply the person sees when the runtime ends a delegating turn, which the
14
+ * model also reads back as its own last message. The tool result already told
15
+ * the model to end its turn, so this line only says what happened. Each entry
16
+ * is one handoff's recipient, or undefined when its result named nobody.
17
+ */
18
+ export declare function asyncHandoffClosing(recipients: readonly (string | undefined)[]): string;
11
19
  export declare function immediateFileWriteClosing(paths: string[]): string;
@@ -10,10 +10,38 @@ export function isSuccessfulAsyncHandoff(toolName, output) {
10
10
  return (!output.startsWith('ERROR:') &&
11
11
  (toolName === 'message_gezel' || toolName.startsWith('delegate_')));
12
12
  }
13
- export function asyncHandoffClosing(count) {
14
- return count === 1
15
- ? 'I sent the handoff and ended my turn so the recipient can use the provider queue. Their reply will arrive asynchronously.'
16
- : `I sent ${count} handoffs and ended my turn so the recipients can use the provider queue. Their replies will arrive asynchronously.`;
13
+ /**
14
+ * The handoff results (core's `messageGezelText`, the MCP `delegate_*` tools)
15
+ * name the recipient after a fixed lead-in and again as "<name>'s turn".
16
+ * Requiring both means a reworded result, or a name holding a period, gives
17
+ * no name rather than half of one.
18
+ */
19
+ const HANDOFF_RECIPIENT = /(?:Accepted the (?:message|handoff) for|Dispatched the (?:message|handoff) to|An identical file handoff is already pending with) (.+?)(?= \(|[.;])[\s\S]*?\1's turn/;
20
+ /** The gezel a successful handoff result names, when it names one. */
21
+ export function asyncHandoffRecipient(output) {
22
+ return HANDOFF_RECIPIENT.exec(output.slice(0, 2_000))?.[1]?.trim() || undefined;
23
+ }
24
+ function joinNames(names) {
25
+ if (names.length <= 2)
26
+ return names.join(' and ');
27
+ return `${names.slice(0, -1).join(', ')}, and ${names[names.length - 1]}`;
28
+ }
29
+ /**
30
+ * The reply the person sees when the runtime ends a delegating turn, which the
31
+ * model also reads back as its own last message. The tool result already told
32
+ * the model to end its turn, so this line only says what happened. Each entry
33
+ * is one handoff's recipient, or undefined when its result named nobody.
34
+ */
35
+ export function asyncHandoffClosing(recipients) {
36
+ const named = [...new Set(recipients.filter((name) => Boolean(name)))];
37
+ if (named.length > 0 && !recipients.includes(undefined)) {
38
+ return named.length === 1
39
+ ? `${named[0]} is on it. Their reply will appear here.`
40
+ : `${joinNames(named)} are on it. Their replies will appear here.`;
41
+ }
42
+ return recipients.length > 1
43
+ ? "I've passed this on. The replies will appear here."
44
+ : "I've passed this on. The reply will appear here.";
17
45
  }
18
46
  export function immediateFileWriteClosing(paths) {
19
47
  const unique = [...new Set(paths.filter((path) => path.trim().length > 0))];
@@ -51,7 +51,7 @@ import { coerceToolCallArgs } from '../tool-arg-schema-coercion.js';
51
51
  import { createToolExecutor } from '../tool-executor.js';
52
52
  import { ToolFailureTracker } from '../tool-failure-tracker.js';
53
53
  import { ToolRepeatTracker } from '../tool-repeat-tracker.js';
54
- import { asyncHandoffClosing, immediateFileWriteClosing, isSuccessfulAsyncHandoff, } from './async-file-handoff.js';
54
+ import { asyncHandoffClosing, asyncHandoffRecipient, immediateFileWriteClosing, isSuccessfulAsyncHandoff, } from './async-file-handoff.js';
55
55
  export { isSuccessfulAsyncHandoff };
56
56
  import { setChatTemplateKwarg, } from './chat-protocol.js';
57
57
  import { MlxEngineGate } from './engine-gate.js';
@@ -60,7 +60,7 @@ import { EngineLogRouter } from './engine-log-router.js';
60
60
  import { MlxEngineStatsSampler } from './engine-stats-sampler.js';
61
61
  import { REASONING_OPEN_HEADER, StreamingReasoningSplit, engineOpensReasoning, reopenTemplateReasoning, requestOpensReasoning, } from './reasoning-stream.js';
62
62
  import { applyMlxRequestShape } from './request-shape.js';
63
- import { PRE_FIRST_BYTE_BASE_MS, buildMidStreamDropMessage, buildPreFirstByteAbortMessage, computePreFirstByteBudgetMs, estimatePromptTokens, formatTps, isMidStreamConnectionDrop, mlxHttpError, } from './runtime-diagnostics.js';
63
+ import { MLX_IMAGE_RESERVED_CHARS, PRE_FIRST_BYTE_BASE_MS, buildMidStreamDropMessage, buildPreFirstByteAbortMessage, computePreFirstByteBudgetMs, estimatePromptTokens, formatTps, isMidStreamConnectionDrop, mlxHttpError, } from './runtime-diagnostics.js';
64
64
  import { classifyMlxFatalErrorLine, classifyMlxStartupLine, } from './stdout-parser.js';
65
65
  import { salvageTextualToolCalls } from './textual-tool-call-salvage.js';
66
66
  import { APPEND_TO_FILE_CONTINUATION_TOOL, MlxToolCallAccumulator, applyCallableToolGrammar, callableRequestToolNames, chatCompletionToolName, externalToolsAsChatCompletions, hermesRequiredArgGrammarRequested, missingTopLevelRequiredToolArgs, toChatCompletionsTools, validatorReportedMissingRequiredArgs, } from './tool-call-protocol.js';
@@ -73,7 +73,7 @@ export { buildMidStreamDropMessage, computePreFirstByteBudgetMs, estimatePromptT
73
73
  import { FILE_REPAIR_MUTATION_TOOLS, FILE_REPAIR_READ_TOOLS, applyConstrainedTurnShape, disableTemplateThinking, fileRepairTargetPath, isSourceFileRepairPrompt, requiredToolOnly, writeFileOnlyTools, } from '../constrained-turn.js';
74
74
  import { hasExplicitFullFileRewriteWording } from '../direct-file-work-prompt.js';
75
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';
76
+ import { ConstrainedWriteRecovery, LOCAL_TURN_LIMITS, LocalTurnPolicy, compactLocalTurn, planFileTurn, repairTools, turnCheckpoint, } from '../local-turn-policy.js';
77
77
  const MAX_TOOL_LOOP_TURNS = LOCAL_TURN_LIMITS.iterations;
78
78
  // Hard per-turn ceiling when the caller doesn't pass `opts.timeoutMs`.
79
79
  // 10 minutes matches what the manager passes for local providers in
@@ -646,7 +646,7 @@ class MlxSession extends StreamingSessionBase {
646
646
  total += m.content.length;
647
647
  // The sidecar bounds images to 1024px. Reserve patch tokens without
648
648
  // counting base64 bytes as language tokens or treating pixels as free.
649
- total += (m.images?.length ?? 0) * 8192;
649
+ total += (m.images?.length ?? 0) * MLX_IMAGE_RESERVED_CHARS;
650
650
  if (m.tool_calls) {
651
651
  for (const tc of m.tool_calls)
652
652
  total += tc.function.arguments.length + tc.function.name.length;
@@ -984,6 +984,7 @@ class MlxSession extends StreamingSessionBase {
984
984
  // validation error every iteration, "diagnosing" the schema in
985
985
  // chat, and re-trying the same wrong shape forever. See the
986
986
  // tracker's docstring for the threshold rationale.
987
+ const writeRecovery = new ConstrainedWriteRecovery();
987
988
  const failureTracker = new ToolFailureTracker({
988
989
  surgicalEditsAvailable,
989
990
  delegationAvailable,
@@ -1164,7 +1165,9 @@ class MlxSession extends StreamingSessionBase {
1164
1165
  log.debug(`turn#${seq}.${turn} required-call turn: ${opts.requiredTool} only`);
1165
1166
  }
1166
1167
  }
1167
- const fileTurnPlan = planFileTurn(prompt, tools, opts?.fileTurnIntent);
1168
+ const fileTurnPlan = writeRecovery.active
1169
+ ? { kind: 'ordinary' }
1170
+ : planFileTurn(prompt, tools, opts?.fileTurnIntent);
1168
1171
  const unreadInputs = unreadRequiredInputs(requiredInputs, requiredInputReads);
1169
1172
  const immediateFileWriteTurn = fileTurnPlan.kind === 'create-file' && unreadInputs.length === 0;
1170
1173
  if (fileTurnPlan.kind === 'create-file' &&
@@ -1219,6 +1222,11 @@ class MlxSession extends StreamingSessionBase {
1219
1222
  (typeof body.max_tokens !== 'number' || body.max_tokens < FILE_WRITE_MIN_TOKENS)) {
1220
1223
  body.max_tokens = FILE_WRITE_MIN_TOKENS;
1221
1224
  }
1225
+ if (writeRecovery.lookupPending) {
1226
+ requestTools = tools?.filter((tool) => chatCompletionToolName(tool) === writeRecovery.lookupPending);
1227
+ applyConstrainedTurnShape(body);
1228
+ log.debug(`turn#${seq}.${turn} grounding recovery: research-only surface=${writeRecovery.lookupPending}`);
1229
+ }
1222
1230
  if (requestTools && requestTools.length > 0)
1223
1231
  body.tools = requestTools;
1224
1232
  // Tool-schema size breakdown (opt-in: GEZEL_PROMPT_BREAKDOWN=1). The
@@ -1419,7 +1427,8 @@ class MlxSession extends StreamingSessionBase {
1419
1427
  // - STREAMING_IDLE_MS: budget between two consecutive SSE
1420
1428
  // content events once tokens are flowing. Generous enough
1421
1429
  // to cover a heavy reasoning gap on a 26B model.
1422
- const approxPromptTokens = estimatePromptTokens(this.messages, body.tools);
1430
+ // The request as sent: an image retired from it costs no prefill.
1431
+ const approxPromptTokens = estimatePromptTokens(body.messages, body.tools);
1423
1432
  const PRE_FIRST_BYTE_IDLE_MS = computePreFirstByteBudgetMs(approxPromptTokens);
1424
1433
  const PRE_FIRST_BYTE_PER_PROGRESS_MS = PRE_FIRST_BYTE_IDLE_MS;
1425
1434
  const STREAMING_IDLE_MS = 300_000;
@@ -2160,6 +2169,11 @@ class MlxSession extends StreamingSessionBase {
2160
2169
  });
2161
2170
  turnContent = salvaged.turnContent;
2162
2171
  const { proseRepaired, xmlTagRepaired, claudeInvokeRepaired, glmRepaired, hermesRepaired, shellRepaired, envelopeRepaired, envelopeTruncated, unknownCall, truncatedSalvage, bareInvokeRepaired, } = salvaged;
2172
+ // A prose-shaped call such as lookup_record({}) was ordinary TEXT
2173
+ // to the Hermes grammar, not a constrained tool envelope. Missing
2174
+ // arguments in that salvage cannot establish a decoding violation.
2175
+ // Keep provenance by id through coercion and duplicate collapse.
2176
+ const proseCallIds = new Set(proseRepaired.map((call) => call.id));
2163
2177
  const rawTurnContentBeforeReasoning = turnContent;
2164
2178
  // Reasoning-tag extraction applies regardless of which (if any)
2165
2179
  // salvage path fired. Qwen 3.5/3.6 leak `<think>…</think>`
@@ -2594,7 +2608,7 @@ class MlxSession extends StreamingSessionBase {
2594
2608
  tool_calls: toolCalls,
2595
2609
  });
2596
2610
  let abortDueToFailureLoop = null;
2597
- let asyncHandoffCount = 0;
2611
+ const asyncHandoffRecipients = [];
2598
2612
  let terminalActionClosing = null;
2599
2613
  const toolImages = [];
2600
2614
  const immediateFileWritePaths = [];
@@ -2693,16 +2707,21 @@ class MlxSession extends StreamingSessionBase {
2693
2707
  this.deps.bridges.hasTool(call.function.name)) {
2694
2708
  const missingRequired = missingTopLevelRequiredToolArgs(toolArgSchemas.get(call.function.name), args);
2695
2709
  const grammarContractViolation = missingRequired.length > 0 &&
2710
+ !proseCallIds.has(call.id) &&
2696
2711
  hermesRequiredArgGrammarRequested(body) &&
2697
2712
  !this.forceSequentialToolGrammar;
2698
- if (grammarContractViolation) {
2699
- this.forceSequentialToolGrammar = true;
2700
- log.warn(`turn#${seq}.${turn} required-argument grammar contract violation tool=${call.function.name} missing=${missingRequired.join(',')} — latching this session to sequential decode`);
2713
+ if (missingRequired.length > 0) {
2714
+ if (grammarContractViolation) {
2715
+ this.forceSequentialToolGrammar = true;
2716
+ log.warn(`turn#${seq}.${turn} required-argument grammar contract violation tool=${call.function.name} missing=${missingRequired.join(',')} — latching this session to sequential decode`);
2717
+ }
2701
2718
  // Do not send a call that the advertised schema proves cannot
2702
- // pass validation. Keeping it out of the bridge also avoids
2703
- // poisoning the session-wide unresolved-failure ledger before
2704
- // the sequential retry gets a chance to recover.
2705
- 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.`;
2719
+ // pass validation, regardless of how it was parsed. A rejected
2720
+ // prose salvage gets ordinary corrective feedback and retains
2721
+ // speculation; native grammar violations keep the circuit breaker.
2722
+ output = grammarContractViolation
2723
+ ? `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.`
2724
+ : `ERROR: ${call.function.name} was not executed because its arguments are missing required fields: ${missingRequired.join(', ')}. Retry the call with all declared required fields.`;
2706
2725
  }
2707
2726
  else {
2708
2727
  try {
@@ -2733,6 +2752,7 @@ class MlxSession extends StreamingSessionBase {
2733
2752
  // the advertised schema. Sequential decode may still recover on
2734
2753
  // the next iteration, and the log now makes the skew explicit.
2735
2754
  if (validatorReportedMissingRequiredArgs(output) &&
2755
+ !proseCallIds.has(call.id) &&
2736
2756
  hermesRequiredArgGrammarRequested(body) &&
2737
2757
  !this.forceSequentialToolGrammar) {
2738
2758
  this.forceSequentialToolGrammar = true;
@@ -2747,7 +2767,7 @@ class MlxSession extends StreamingSessionBase {
2747
2767
  askedQuestionThisTurn = true;
2748
2768
  }
2749
2769
  if (isSuccessfulAsyncHandoff(call.function.name, output)) {
2750
- asyncHandoffCount++;
2770
+ asyncHandoffRecipients.push(asyncHandoffRecipient(output));
2751
2771
  }
2752
2772
  if ((immediateFileWriteTurn || writeContinuationActive) &&
2753
2773
  (call.function.name === 'write_file' || call.function.name === 'append_to_file') &&
@@ -2803,6 +2823,7 @@ class MlxSession extends StreamingSessionBase {
2803
2823
  }
2804
2824
  }
2805
2825
  const tracked = failureTracker.recordResult(call.function.name, output);
2826
+ writeRecovery.recordResult(call.function.name, output, tracked, tools);
2806
2827
  terminalActionClosing ??= terminalToolClosingText(this.deps.terminalToolPolicy, call.function.name, args, output);
2807
2828
  // Layer the same-args repeat tracker on top of the failure
2808
2829
  // tracker. The failure tracker catches "tool keeps erroring";
@@ -2877,7 +2898,7 @@ class MlxSession extends StreamingSessionBase {
2877
2898
  });
2878
2899
  }
2879
2900
  this.turnPolicy.checkpoint();
2880
- if (fileRepairTurn && repairMutationSucceeded) {
2901
+ if (fileRepairTurn && repairMutationSucceeded && !writeRecovery.active) {
2881
2902
  const closing = 'Updated the requested file.';
2882
2903
  this.messages.push({ role: 'assistant', content: closing });
2883
2904
  return closing;
@@ -2898,7 +2919,7 @@ class MlxSession extends StreamingSessionBase {
2898
2919
  log.info(`turn#${seq} END terminal-tool-bail afterMs=${Date.now() - start} loopTurns=${turn + 1}`);
2899
2920
  return fullText;
2900
2921
  }
2901
- if (immediateFileWritePaths.length > 0) {
2922
+ if (immediateFileWritePaths.length > 0 && !writeRecovery.active) {
2902
2923
  // File was truncated mid-content and we still have continuation
2903
2924
  // budget — keep the turn alive instead of bailing on a partial.
2904
2925
  // The tool result already carries the "append the rest" hint;
@@ -2921,12 +2942,12 @@ class MlxSession extends StreamingSessionBase {
2921
2942
  `${writeContinuations > 0 ? ` continuations=${writeContinuations}` : ''}`);
2922
2943
  return fullText;
2923
2944
  }
2924
- if (asyncHandoffCount > 0) {
2925
- const closingText = asyncHandoffClosing(asyncHandoffCount);
2945
+ if (asyncHandoffRecipients.length > 0) {
2946
+ const closingText = asyncHandoffClosing(asyncHandoffRecipients);
2926
2947
  this.messages.push({ role: 'assistant', content: closingText });
2927
2948
  fullText = closingText;
2928
2949
  log.info(`turn#${seq} END async-handoff-bail afterMs=${Date.now() - start} ` +
2929
- `handoffs=${asyncHandoffCount} loopTurns=${turn + 1}`);
2950
+ `handoffs=${asyncHandoffRecipients.length} loopTurns=${turn + 1}`);
2930
2951
  return fullText;
2931
2952
  }
2932
2953
  if (forceProjectMacroBail) {
@@ -2759,10 +2759,17 @@ class BatchEngine:
2759
2759
  cache_seed.trim_layers(cache_layers, excess)
2760
2760
  except Exception: # noqa: BLE001 — snapshot path covers hybrids
2761
2761
  pass
2762
+ cache_tokens = full_prompt + [int(t) for t in sub.token_ids]
2763
+ if cache_seed.all_trimmable(cache_layers):
2764
+ cache_tokens = spec_decode.cached_token_prefix(cache_layers, cache_tokens)
2765
+ if cache_tokens is None:
2766
+ # Leave any prior valid entry intact rather than persisting
2767
+ # cache layers whose token boundary cannot be established.
2768
+ cache_layers = None
2762
2769
  r = types.SimpleNamespace(
2763
2770
  finish_reason=finish_reason,
2764
- prompt_cache=list(cache_layers),
2765
- all_tokens=full_prompt + [int(t) for t in sub.token_ids],
2771
+ prompt_cache=list(cache_layers) if cache_layers is not None else None,
2772
+ all_tokens=cache_tokens,
2766
2773
  token=sub.token_ids[-1] if sub.token_ids else 0,
2767
2774
  )
2768
2775
  self._finish(sub, r)
@@ -117,8 +117,11 @@ def resolve_spec(args, model, text_tower: Optional[str], log=print) -> Optional[
117
117
  except Exception as exc: # noqa: BLE001
118
118
  _off(log, f"drafter incompatible with target: {exc}")
119
119
  return None
120
- if not install_chunked_draft_prefill(
121
- drafter, int(getattr(args, "prefill_step_size", 0) or 0), log=log
120
+ if (
121
+ callable(getattr(drafter, "prefill_from_target_hidden", None))
122
+ and not install_chunked_draft_prefill(
123
+ drafter, int(getattr(args, "prefill_step_size", 0) or 0), log=log
124
+ )
122
125
  ):
123
126
  log(
124
127
  "[spec] drafter has no incremental prefill surface — long prompts "
@@ -240,6 +243,11 @@ def draft_prefill_gate(spec: Optional[SpecState], prompt_len: int) -> Tuple[bool
240
243
  throw that work away."""
241
244
  if spec is None:
242
245
  return True, ""
246
+ # Shared-KV drafters (e.g. Gemma's assistant) have no separate prompt
247
+ # prefill. Both round loops skip this optional hook, so there is no
248
+ # quadratic drafter prefill to limit for that capability shape.
249
+ if not callable(getattr(spec.drafter, "prefill_from_target_hidden", None)):
250
+ return True, ""
243
251
  if getattr(spec.drafter, "_gezel_chunked_prefill", False):
244
252
  return True, ""
245
253
  if int(prompt_len) > UNCHUNKED_DRAFT_PREFILL_LIMIT:
@@ -278,9 +286,21 @@ def cache_matches_tower(layers, tower) -> bool:
278
286
  return False
279
287
  if layers is None or len(layers) != len(expect):
280
288
  return False
281
- return all(
282
- type(a).__name__ == type(b).__name__ for a, b in zip(layers, expect)
283
- )
289
+
290
+ def matches(actual, expected):
291
+ if type(actual).__name__ == type(expected).__name__:
292
+ return True
293
+ # Upstream MTP deliberately wraps a rotating cache in this subclass
294
+ # to roll back rejected drafts. It is still the same target layout;
295
+ # rejecting our own saved cache here forces every follow-up cold.
296
+ # Do not admit arbitrary subclasses (e.g. batch-shaped cache adapters).
297
+ return (
298
+ type(expected).__name__ == "RotatingKVCache"
299
+ and type(actual).__name__ == "BufferedRotatingKVCache"
300
+ and isinstance(actual, type(expected))
301
+ )
302
+
303
+ return all(matches(a, b) for a, b in zip(layers, expect))
284
304
 
285
305
 
286
306
  def clone_layers(layers) -> List[Any]:
@@ -294,6 +314,25 @@ def clone_layers(layers) -> List[Any]:
294
314
  return out
295
315
 
296
316
 
317
+ def cached_token_prefix(layers, tokens: Sequence[int]) -> Optional[List[int]]:
318
+ """Describe only tokens actually fed into a trimmable target cache.
319
+
320
+ A final bonus token can be emitted before it is fed back to the target.
321
+ Saving it as cached makes the next seed fail its offset consistency guard.
322
+ Unknown or disagreeing offsets must remain a miss, never guessed state.
323
+ """
324
+ try:
325
+ offsets = [int(c.offset) for c in layers]
326
+ except (AttributeError, TypeError, ValueError):
327
+ return None
328
+ if not offsets or len(set(offsets)) != 1:
329
+ return None
330
+ count = offsets[0]
331
+ if count <= 0 or count > len(tokens):
332
+ return None
333
+ return list(tokens[:count])
334
+
335
+
297
336
  def chunked_prefill_steps(
298
337
  tower,
299
338
  cache,