@mlx-node/agent 0.0.12 → 0.0.15

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 (61) hide show
  1. package/dist/catalog.d.ts +10 -1
  2. package/dist/catalog.d.ts.map +1 -1
  3. package/dist/catalog.js +11 -2
  4. package/dist/delegate.d.ts +29 -0
  5. package/dist/delegate.d.ts.map +1 -0
  6. package/dist/delegate.js +106 -0
  7. package/dist/extensions/delegation.d.ts +15 -0
  8. package/dist/extensions/delegation.d.ts.map +1 -0
  9. package/dist/extensions/delegation.js +93 -0
  10. package/dist/paths.d.ts +6 -0
  11. package/dist/paths.d.ts.map +1 -1
  12. package/dist/paths.js +16 -0
  13. package/dist/provider/chat-config.d.ts +6 -5
  14. package/dist/provider/chat-config.d.ts.map +1 -1
  15. package/dist/provider/chat-config.js +21 -7
  16. package/dist/provider/index.d.ts.map +1 -1
  17. package/dist/provider/index.js +8 -1
  18. package/dist/provider/model-host.d.ts +1 -1
  19. package/dist/provider/model-host.d.ts.map +1 -1
  20. package/dist/provider/model-host.js +25 -7
  21. package/dist/provider/models.d.ts +3 -14
  22. package/dist/provider/models.d.ts.map +1 -1
  23. package/dist/provider/models.js +17 -239
  24. package/dist/provider/stream-adapter.d.ts +2 -2
  25. package/dist/provider/stream-adapter.d.ts.map +1 -1
  26. package/dist/provider/stream-adapter.js +8 -5
  27. package/dist/run-agent.d.ts +4 -0
  28. package/dist/run-agent.d.ts.map +1 -1
  29. package/dist/run-agent.js +8 -2
  30. package/dist/types.d.ts +1 -1
  31. package/dist/types.d.ts.map +1 -1
  32. package/package.json +23 -5
  33. package/src/catalog.ts +194 -0
  34. package/src/cold-tier.ts +152 -0
  35. package/src/delegate.ts +136 -0
  36. package/src/extensions/approval-detail.ts +57 -0
  37. package/src/extensions/delegation.ts +109 -0
  38. package/src/extensions/local-image-input.ts +132 -0
  39. package/src/extensions/permission-gate.ts +347 -0
  40. package/src/extensions/subagent.ts +743 -0
  41. package/src/extensions/terminal-title.ts +53 -0
  42. package/src/extensions/trace-notice.ts +37 -0
  43. package/src/index.ts +23 -0
  44. package/src/paths.ts +36 -0
  45. package/src/provider/chat-config.ts +132 -0
  46. package/src/provider/convert-messages.ts +273 -0
  47. package/src/provider/error-coercion.ts +36 -0
  48. package/src/provider/events.ts +341 -0
  49. package/src/provider/index.ts +255 -0
  50. package/src/provider/metrics-trace.ts +380 -0
  51. package/src/provider/mlx-identity.ts +16 -0
  52. package/src/provider/model-host.ts +276 -0
  53. package/src/provider/model-registry-filter.ts +336 -0
  54. package/src/provider/models.ts +48 -0
  55. package/src/provider/performance-status.ts +112 -0
  56. package/src/provider/reasoning-tag-buffer.ts +67 -0
  57. package/src/provider/stream-adapter.ts +515 -0
  58. package/src/provider/tool-call-buffer.ts +82 -0
  59. package/src/provider/warm-reuse.ts +125 -0
  60. package/src/run-agent.ts +178 -0
  61. package/src/types.ts +10 -0
@@ -0,0 +1,380 @@
1
+ /**
2
+ * `MetricsTrace` — always-on per-turn inference telemetry, appended as JSON
3
+ * Lines to `$HOME/.mlx-node/metrics/traces/<YYYY-MM-DD>-<pid>.jsonl`.
4
+ *
5
+ * This is a durable sink that complements the transient in-memory
6
+ * {@link ./performance-status.ts} WeakMap (which only feeds the live TUI
7
+ * footer). One record is written per successful inference turn so the
8
+ * dashboard can correlate throughput, cache reuse, and cold-tier deltas back
9
+ * to the pi session that produced them via `mlxTraceId`.
10
+ *
11
+ * Contract:
12
+ * - Default-on; the `MLX_AGENT_METRICS` env var set to `0` / `false` / `off`
13
+ * (case-insensitive) is the only kill switch.
14
+ * - `record()` NEVER throws: every field is allowlisted (no free text ever
15
+ * lands on disk) and all fs work is wrapped — telemetry must never break
16
+ * an inference turn.
17
+ */
18
+
19
+ import { appendFileSync, mkdirSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+
22
+ import { metricsTraceDir } from '../paths.js';
23
+
24
+ /**
25
+ * Per-turn delta of every COUNTER on the native `ColdCacheStats` — the paged
26
+ * K/V block traffic. One entry per native counter field, named
27
+ * `cold` + PascalCase(nativeKey); the three non-counter fields (`enabled`,
28
+ * `root`, `quotaBytes`) describe the tier's identity rather than a turn and are
29
+ * carried separately as {@link MetricsTraceRecord.coldEnabled} /
30
+ * {@link MetricsTraceRecord.coldRoot}.
31
+ *
32
+ * This is the TS half of a cross-language invariant.
33
+ * `__test__/cold-counter-fields.test.ts` derives the same names from
34
+ * `coldCacheStats()` at runtime and demands an exact match, so a counter added
35
+ * natively but not here — or, the failure this list exists for, one quietly
36
+ * dropped from here — is a red test rather than an empty dashboard column.
37
+ */
38
+ export const COLD_COUNTER_FIELDS = [
39
+ 'coldHits',
40
+ 'coldMisses',
41
+ 'coldEnqueued',
42
+ 'coldQueueDrops',
43
+ 'coldBytesWritten',
44
+ 'coldBytesRestored',
45
+ 'coldEvictions',
46
+ 'coldCorruptions',
47
+ 'coldWriteErrors',
48
+ 'coldRestoreDeclines',
49
+ ] as const;
50
+
51
+ /**
52
+ * Per-turn delta of every counter on the native `ColdSidecarStats` — the
53
+ * recurrent / sliding-window state that lives OUTSIDE the paged pool. Named
54
+ * `coldSidecar` + PascalCase(nativeKey).
55
+ *
56
+ * Deliberately a second, differently-prefixed list rather than a merge: both
57
+ * native structs carry `enqueued` and `queueDrops`, and they count different
58
+ * objects (blocks vs sidecars). Flattening them into one namespace would make
59
+ * two unrelated numbers collide on one column.
60
+ */
61
+ export const COLD_SIDECAR_FIELDS = [
62
+ 'coldSidecarCaptureReached',
63
+ 'coldSidecarChainEmpty',
64
+ 'coldSidecarBoundarySkips',
65
+ 'coldSidecarAlreadyPersisted',
66
+ 'coldSidecarEnqueued',
67
+ 'coldSidecarQueueDrops',
68
+ 'coldSidecarInstalled',
69
+ 'coldSidecarRestoreSuppressed',
70
+ ] as const;
71
+
72
+ /** Every cold-tier per-turn delta field, in native-struct order. */
73
+ export const COLD_DELTA_FIELDS = [...COLD_COUNTER_FIELDS, ...COLD_SIDECAR_FIELDS] as const;
74
+
75
+ /** A key of {@link COLD_DELTA_FIELDS}; every one is an optional `number`. */
76
+ export type ColdDeltaField = (typeof COLD_DELTA_FIELDS)[number];
77
+
78
+ /**
79
+ * One inference turn's telemetry. Every field is a number, a small
80
+ * enumerated string (`finishReason`), or an identifier — never model output
81
+ * text, tool arguments, or prompt content.
82
+ */
83
+ export interface MetricsTraceRecord extends Partial<Record<ColdDeltaField, number>> {
84
+ /** Schema version. */
85
+ v: 1;
86
+ /** Join key minted by `TurnEmitter`; also stamped on the pi message as `mlxTraceId`. */
87
+ traceId: string;
88
+ /** Wall-clock write time (ms since epoch). */
89
+ ts: number;
90
+ /** pi per-request session id (parent vs each subagent differ). */
91
+ sessionId?: string;
92
+ /** Root pi session id the turn was SUBMITTED under (snapshotted at submit). */
93
+ rootSessionId?: string;
94
+ /** Root pi session JSONL file path the turn was SUBMITTED under (snapshotted at submit). */
95
+ rootSessionFile?: string;
96
+ /** Model id served this turn (`mlx/<dir-name>`'s `<dir-name>`). */
97
+ model: string;
98
+ /** Turn duration (ms) bracketing resident selection + prefill + decode. */
99
+ durationMs: number;
100
+ /**
101
+ * Queue + cold-load wait (ms) before native work began this turn — the gap
102
+ * between turn submission and the serialized inference callback firing.
103
+ * Subtract from `durationMs` to isolate execution-only latency.
104
+ */
105
+ queueMs?: number;
106
+ /** `true` when the model was already warm/resident, `false` on a cold load/swap. */
107
+ resident?: boolean;
108
+ /** Native finish reason (`stop` / `length` / `tool_calls` / …). */
109
+ finishReason: string;
110
+ promptTokens: number;
111
+ cachedTokens: number;
112
+ outputTokens: number;
113
+ reasoningTokens: number;
114
+ ttftMs?: number;
115
+ prefillTps?: number;
116
+ decodeTps?: number;
117
+ mtpCycles?: number;
118
+ mtpMeanAccepted?: number;
119
+ /** Cold-tier restore hits accrued this turn (synchronous counter — exact). */
120
+ coldHits?: number;
121
+ /** Cold-tier lookup misses accrued this turn (synchronous counter — exact). */
122
+ coldMisses?: number;
123
+ /**
124
+ * Cold-tier bytes that LANDED this turn — credited natively only after the
125
+ * payload sync, the commit rename and the directory fsync all succeeded, so
126
+ * a write that failed contributes nothing here and one {@link
127
+ * coldWriteErrors} instead.
128
+ *
129
+ * Advances on an async writer thread, so this delta is APPROXIMATE — it may
130
+ * attribute a prior turn's flush to the turn that observes it, and a write
131
+ * still in flight at the end of the turn lands in a later one. That is the
132
+ * only sense in which it lags; it is never an enqueue-time estimate.
133
+ */
134
+ coldBytesWritten?: number;
135
+ /** Cold-tier bytes read back on validated hits this turn (synchronous — exact). */
136
+ coldBytesRestored?: number;
137
+ /**
138
+ * Canonical cold-cache root this turn's tier was operating in, as produced by
139
+ * `canonicalCacheRoot` — the JOIN KEY the dashboard scopes its hit-rate and
140
+ * trend to. Written ONLY when the tier was actually open; a turn that ran
141
+ * with the tier off carries `coldEnabled: false` and NO root, and a record
142
+ * written by a build that predates this field carries neither. Those two
143
+ * states are deliberately distinguishable: the dashboard reports "recorded
144
+ * before cache attribution existed" separately from "the tier was off".
145
+ */
146
+ coldRoot?: string;
147
+ /** Whether the cold tier was open for this turn (`ColdCacheStats.enabled`). */
148
+ coldEnabled?: boolean;
149
+ /**
150
+ * Cold-tier write-queue submissions accrued this turn. Incremented on the
151
+ * calling thread, so this delta is EXACT.
152
+ */
153
+ coldEnqueued?: number;
154
+ /**
155
+ * Cold-tier writes REFUSED at admission this turn because the bounded queue
156
+ * was full. Incremented on the calling thread, so this delta is EXACT.
157
+ *
158
+ * Now admission refusals only: the commit-rename-failure arm used to be
159
+ * counted here too, which put one event under a name describing a different
160
+ * cause. It is a {@link coldWriteErrors} instead, and the two are disjoint —
161
+ * never sum them.
162
+ */
163
+ coldQueueDrops?: number;
164
+ /**
165
+ * Cold-tier objects evicted by quota enforcement this turn. Advances on the
166
+ * background writer thread — APPROXIMATE, same caveat as `coldBytesWritten`.
167
+ */
168
+ coldEvictions?: number;
169
+ /**
170
+ * Cold-tier objects that failed validation on restore this turn. Incremented
171
+ * on the calling thread, so this delta is EXACT. Always accompanied by a
172
+ * miss, i.e. corruptions are a SUBSET of `coldMisses` — never add the two.
173
+ */
174
+ coldCorruptions?: number;
175
+ /**
176
+ * CUMULATIVE corruptions since the tier opened in this process, not a delta.
177
+ * Carried because the acceptance bar is "corruptions must be 0" and a delta
178
+ * alone cannot prove it: a turn that aborts or errors never reaches
179
+ * `record()`, so a corruption during it lands in no delta at all. The next
180
+ * successful turn's absolute total still includes it, so `MAX(total) > 0`
181
+ * over any window is the sound "did this ever happen" latch.
182
+ */
183
+ coldCorruptionsTotal?: number;
184
+ /**
185
+ * CUMULATIVE queue drops since the tier opened in this process, for the same
186
+ * reason as {@link coldCorruptionsTotal}: an errored turn's dropped writes
187
+ * would otherwise be invisible.
188
+ */
189
+ coldQueueDropsTotal?: number;
190
+ /**
191
+ * Cold-tier writes this turn that the queue ACCEPTED and that never reached
192
+ * disk — a read-only, full or unmounted cache root, a quota the object
193
+ * cannot fit, a failed rename, a failed fsync.
194
+ *
195
+ * The native writer is deliberately fail-open (a broken cache root must not
196
+ * change a single emitted token) and it returns its error to nobody, so
197
+ * before this counter existed the entire class was invisible: a turn against
198
+ * an unwritable root reported `coldQueueDrops 0`, `coldCorruptions 0`, an
199
+ * empty stderr and a clean exit. Advances on the background writer thread,
200
+ * so APPROXIMATE per turn in the same way {@link coldBytesWritten} is —
201
+ * non-zero over a window is the signal, not the exact attribution.
202
+ */
203
+ coldWriteErrors?: number;
204
+ /**
205
+ * Restores REFUSED this turn: the tier held candidate blocks and the walk
206
+ * handed back none of them.
207
+ *
208
+ * Neither a hit nor a miss, because both of those count per-block lookups
209
+ * and a refusal happens instead of one — which is why a refused restore
210
+ * reported `coldHits 0, coldMisses 0`, exactly the row a turn that never
211
+ * consulted the tier produces. Incremented on the calling thread, so EXACT.
212
+ * The reason (`no_backed_boundary`, `parent_chain_unavailable`,
213
+ * `restore_short_of_boundary`) and the block geometry go to the
214
+ * `[MLX_TRACE] paged cold_restore_declined` line.
215
+ */
216
+ coldRestoreDeclines?: number;
217
+ /**
218
+ * CUMULATIVE write errors since the tier opened in this process, for the
219
+ * same reason as {@link coldCorruptionsTotal} — and more sharply, since this
220
+ * counter advances on the background writer: the error covering the last
221
+ * turn before a crash reaches no delta at all.
222
+ *
223
+ * No such total exists for {@link coldRestoreDeclines} on purpose. A "did it
224
+ * ever happen" latch is only meaningful for a counter whose healthy value is
225
+ * zero; the first turn of any new prompt legitimately declines, so a latch
226
+ * there would be pinned on from the first minute and mean nothing.
227
+ */
228
+ coldWriteErrorsTotal?: number;
229
+ /**
230
+ * Turns this turn's process spent reaching a family's sidecar capture. Every
231
+ * other `coldSidecar*` counter is a sub-count of this one, so `0` here while
232
+ * blocks were written means the finalize path never called the capture at
233
+ * all — a different bug from a capture that ran and declined.
234
+ */
235
+ coldSidecarCaptureReached?: number;
236
+ /**
237
+ * Sidecar captures that found no whole persisted block to anchor recurrent
238
+ * state under.
239
+ */
240
+ coldSidecarChainEmpty?: number;
241
+ /**
242
+ * Sidecar captures whose chain covered blocks but where no retained
243
+ * checkpoint sat at or below its reach — the "ladder collapsed to its
244
+ * deepest rung" signature.
245
+ */
246
+ coldSidecarBoundarySkips?: number;
247
+ /**
248
+ * Sidecar captures that selected a boundary already on disk. The STEADY
249
+ * STATE of a repeated prompt, not a fault: without it a healthy run
250
+ * (`coldSidecarEnqueued == 0` because the first turn already wrote it) is
251
+ * indistinguishable from a broken one.
252
+ */
253
+ coldSidecarAlreadyPersisted?: number;
254
+ /**
255
+ * Sidecars handed to the writer queue this turn. A SUBSET of
256
+ * {@link coldEnqueued}, which counts every object that took a queue slot —
257
+ * so summing them double-counts each sidecar.
258
+ */
259
+ coldSidecarEnqueued?: number;
260
+ /**
261
+ * Sidecars the writer queue refused because it was full. Likewise a subset
262
+ * of {@link coldQueueDrops}, not a separate population.
263
+ */
264
+ coldSidecarQueueDrops?: number;
265
+ /**
266
+ * Restored sidecars a family INSTALLED as its live per-turn state. The one
267
+ * read-side sidecar counter, and the only thing that separates "restored and
268
+ * used" from "restored, then silently re-derived by a full O(prefix) replay":
269
+ * the replay produces correct state, so `cachedTokens`, `coldHits`,
270
+ * `coldCorruptions` and the output text are all identical either way.
271
+ */
272
+ coldSidecarInstalled?: number;
273
+ /**
274
+ * Restored prefixes a family THREW AWAY this turn, releasing the request and
275
+ * restarting the turn cold (gemma4's large-sliding-restore suppression).
276
+ *
277
+ * A different event from {@link coldRestoreDeclines}: there the walk refused
278
+ * to serve anything, here it served and the family discarded it. This one is
279
+ * therefore preceded by real `coldHits` and `coldBytesRestored`, so the turn
280
+ * reads as successful reuse right up to the point where it recomputes the
281
+ * whole prompt anyway.
282
+ */
283
+ coldSidecarRestoreSuppressed?: number;
284
+ }
285
+
286
+ type MetricsTraceInput = Omit<MetricsTraceRecord, 'v'>;
287
+
288
+ function envDisabled(): boolean {
289
+ const raw = process.env.MLX_AGENT_METRICS;
290
+ if (raw === undefined) return false;
291
+ const normalized = raw.trim().toLowerCase();
292
+ return normalized === '0' || normalized === 'false' || normalized === 'off';
293
+ }
294
+
295
+ /** A finite number, or `undefined` if the value is absent / non-finite. */
296
+ function finite(value: number | undefined): number | undefined {
297
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
298
+ }
299
+
300
+ export class MetricsTrace {
301
+ readonly enabled: boolean;
302
+ private readonly dir: string;
303
+ private readonly now: () => number;
304
+
305
+ constructor(opts?: { dir?: string; now?: () => number }) {
306
+ this.enabled = !envDisabled();
307
+ this.dir = opts?.dir ?? metricsTraceDir();
308
+ this.now = opts?.now ?? Date.now;
309
+ }
310
+
311
+ /** `<dir>/<YYYY-MM-DD>-<pid>.jsonl` — UTC date so rotation is timezone-stable. */
312
+ currentFile(): string {
313
+ const date = new Date(this.now()).toISOString().slice(0, 10);
314
+ return join(this.dir, `${date}-${process.pid}.jsonl`);
315
+ }
316
+
317
+ /**
318
+ * Append one allowlisted JSON line. Never throws: a broken sink must not
319
+ * surface into the inference path. Excess input properties are dropped — the
320
+ * record is rebuilt field by field so free text can never reach disk.
321
+ */
322
+ record(rec: MetricsTraceInput): void {
323
+ if (!this.enabled) return;
324
+ try {
325
+ const out: MetricsTraceRecord = {
326
+ v: 1,
327
+ traceId: rec.traceId,
328
+ ts: rec.ts,
329
+ model: rec.model,
330
+ durationMs: rec.durationMs,
331
+ finishReason: rec.finishReason,
332
+ promptTokens: rec.promptTokens,
333
+ cachedTokens: rec.cachedTokens,
334
+ outputTokens: rec.outputTokens,
335
+ reasoningTokens: rec.reasoningTokens,
336
+ };
337
+ if (rec.sessionId !== undefined) out.sessionId = rec.sessionId;
338
+ if (rec.rootSessionId !== undefined) out.rootSessionId = rec.rootSessionId;
339
+ if (rec.rootSessionFile !== undefined) out.rootSessionFile = rec.rootSessionFile;
340
+ const queueMs = finite(rec.queueMs);
341
+ if (queueMs !== undefined) out.queueMs = queueMs;
342
+ if (typeof rec.resident === 'boolean') out.resident = rec.resident;
343
+ const ttftMs = finite(rec.ttftMs);
344
+ if (ttftMs !== undefined) out.ttftMs = ttftMs;
345
+ const prefillTps = finite(rec.prefillTps);
346
+ if (prefillTps !== undefined) out.prefillTps = prefillTps;
347
+ const decodeTps = finite(rec.decodeTps);
348
+ if (decodeTps !== undefined) out.decodeTps = decodeTps;
349
+ const mtpCycles = finite(rec.mtpCycles);
350
+ if (mtpCycles !== undefined) out.mtpCycles = mtpCycles;
351
+ const mtpMeanAccepted = finite(rec.mtpMeanAccepted);
352
+ if (mtpMeanAccepted !== undefined) out.mtpMeanAccepted = mtpMeanAccepted;
353
+ // Every cold-tier delta comes off ONE list, so a counter can only be
354
+ // dropped from the JSONL by being dropped from `COLD_DELTA_FIELDS` —
355
+ // which `__test__/cold-counter-fields.test.ts` pins to the native structs.
356
+ // Spelling them out here is what let four `coldCacheStats()` counters sit
357
+ // unwritten for the life of the feature.
358
+ for (const key of COLD_DELTA_FIELDS) {
359
+ const value = finite(rec[key]);
360
+ if (value !== undefined) out[key] = value;
361
+ }
362
+ // A cache identity is only meaningful for a tier that was actually open;
363
+ // an empty root would create a bucket no dashboard root can ever match.
364
+ if (typeof rec.coldRoot === 'string' && rec.coldRoot.length > 0) out.coldRoot = rec.coldRoot;
365
+ if (typeof rec.coldEnabled === 'boolean') out.coldEnabled = rec.coldEnabled;
366
+ const coldCorruptionsTotal = finite(rec.coldCorruptionsTotal);
367
+ if (coldCorruptionsTotal !== undefined) out.coldCorruptionsTotal = coldCorruptionsTotal;
368
+ const coldQueueDropsTotal = finite(rec.coldQueueDropsTotal);
369
+ if (coldQueueDropsTotal !== undefined) out.coldQueueDropsTotal = coldQueueDropsTotal;
370
+ const coldWriteErrorsTotal = finite(rec.coldWriteErrorsTotal);
371
+ if (coldWriteErrorsTotal !== undefined) out.coldWriteErrorsTotal = coldWriteErrorsTotal;
372
+
373
+ const file = this.currentFile();
374
+ mkdirSync(dirname(file), { recursive: true });
375
+ appendFileSync(file, `${JSON.stringify(out)}\n`);
376
+ } catch {
377
+ // Telemetry is best-effort; a broken sink must never break inference.
378
+ }
379
+ }
380
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Identity of the single in-process `mlx` provider, shared between the provider
3
+ * registration ({@link ../provider/index.ts}) and the mlx-only policy adapter
4
+ * ({@link ./model-registry-filter.ts}) so the two can never drift. In particular
5
+ * the adapter pins mlx auth to {@link MLX_API_KEY}/{@link MLX_BASE_URL}, which
6
+ * MUST match what `registerProvider('mlx', …)` sets.
7
+ */
8
+
9
+ /** Reserved provider id for the local mlx provider. */
10
+ export const MLX_PROVIDER_ID = 'mlx';
11
+ /** Provider `api` tag for local mlx models. */
12
+ export const MLX_API = 'mlx';
13
+ /** Local (non-network) base URL for the mlx provider. */
14
+ export const MLX_BASE_URL = 'mlx://local';
15
+ /** Literal marker apiKey — flags the provider configured; never a real key. */
16
+ export const MLX_API_KEY = 'mlx-local';
@@ -0,0 +1,276 @@
1
+ /**
2
+ * `MlxModelHost` — single-resident, lazily-loaded model + `ChatSession`
3
+ * owner for the provider bridge.
4
+ *
5
+ * Mirrors the CLI launch-claude swap semantics (drop-then-load, one
6
+ * serialized operation chain) without the registry/alias machinery: the
7
+ * agent process serves exactly one model at a time, and every operation
8
+ * that touches the resident runs on one promise chain. Crucially the
9
+ * resident check/load AND the caller's full inference callback execute
10
+ * inside the SAME serialized closure ({@link MlxModelHost.runWithResident}),
11
+ * so a queued swap to another model can never replace the resident while
12
+ * an earlier caller is still mid-turn on it (stale session handle,
13
+ * overlapping native activity on the compiled-path globals).
14
+ */
15
+
16
+ import { ChatSession, loadModel, type SessionCapableModel } from '@mlx-node/lm';
17
+ import { findDFlash2Draft } from '@mlx-node/lm/draft-companion';
18
+
19
+ import { COLD_TIER_RESTORE_FAMILIES } from '../cold-tier.js';
20
+ import type { DiscoveredModelLike } from '../types.js';
21
+
22
+ /**
23
+ * Re-exported so the HOST consults exactly the symbol the drift guard and the
24
+ * `--no-persist-cache` help text consult. The definition lives in the
25
+ * native-free `../cold-tier.js` leaf (reachable off-package through the
26
+ * `@mlx-node/agent/catalog` subpath, which re-exports it)
27
+ * because this module value-imports `@mlx-node/lm`, which loads the native
28
+ * addon — the dashboard and the CLI help path must be able to read the list
29
+ * without that.
30
+ */
31
+ export { COLD_TIER_RESTORE_FAMILIES };
32
+
33
+ /** Per-load policy handed to {@link MlxModelHostOptions.resolveModelPathFn}. */
34
+ export interface ModelLoadPolicy {
35
+ /**
36
+ * Authoritative cold-tier directive for the config overlay
37
+ * (`persist_paged_cache` in the cloned config.json). Present ONLY for loads
38
+ * of a {@link COLD_TIER_RESTORE_FAMILIES} family, carrying the resolved
39
+ * {@link MlxModelHostOptions.persistPagedCache} value as an EXPLICIT
40
+ * boolean: `true` enables the SSD cold tier, `false` authoritatively
41
+ * disables it — overriding any `persist_paged_cache` the checkpoint's own
42
+ * config.json hard-codes, so `mlx agent --no-persist-cache` truly wins.
43
+ * Every other family receives no policy at all, so the overlay never touches
44
+ * the field for them.
45
+ */
46
+ persistPagedCache: boolean;
47
+ }
48
+
49
+ export interface MlxModelHostOptions {
50
+ /** Injectable model loader so tests can stub native loading. */
51
+ loadModelFn?: typeof loadModel;
52
+ /**
53
+ * Optional load-path policy. `mlx agent` uses this to point the loader at
54
+ * an ephemeral config overlay with block-paged attention enabled while
55
+ * leaving the checkpoint directory untouched. The optional per-load policy
56
+ * carries the qwen3 cold-tier opt-in (see {@link ModelLoadPolicy}).
57
+ */
58
+ resolveModelPathFn?: (model: DiscoveredModelLike, policy?: ModelLoadPolicy) => Promise<string>;
59
+ /**
60
+ * Reject a loaded model unless its native paged-cache adapter is active.
61
+ * The agent entrypoint enables this so a model/platform incompatibility
62
+ * fails clearly instead of silently falling back to flat KV cache. Gemma4
63
+ * with an attached external draft is the deliberate exception used only by
64
+ * the agent's explicit draft opt-in: its DSpark / assistant speculative
65
+ * executor is currently flat-cache-only.
66
+ */
67
+ requirePagedCache?: boolean;
68
+ /**
69
+ * Enable the cold tier (persisted paged prefix blocks) by default. `mlx
70
+ * agent` turns this on; `mlx agent --no-persist-cache` sets it false.
71
+ * Applied only to loads of a {@link COLD_TIER_RESTORE_FAMILIES} family —
72
+ * every other family keeps per-layer state outside the paged pool, so its
73
+ * prefix cannot be restored soundly. Defaults true.
74
+ */
75
+ persistPagedCache?: boolean;
76
+ }
77
+
78
+ interface ResidentModel {
79
+ id: string;
80
+ session: ChatSession;
81
+ /** A ChatSession's native owner is immutable, including across /new and subagent turns. */
82
+ ownerId?: string;
83
+ /** Kept solely so a swap can explicitly drop the native ref before loading. */
84
+ model: object;
85
+ /**
86
+ * Set when the previous turn on this resident ended in a native ERROR
87
+ * terminal. A native error mid-decode can leave the physical KV cache
88
+ * advanced past the committed `cached_token_history`, so the next warm
89
+ * reuse would replay pi's history onto a misaligned prefix (garbled
90
+ * continuation). While dirty, the next turn does a FULL `session.reset()`
91
+ * (cold prefill) instead of the warm-reuse wipe. Cleared on consume; NOT
92
+ * set on abort / stop / length (those leave the cache consistent).
93
+ */
94
+ dirty: boolean;
95
+ }
96
+
97
+ export class MlxModelHost {
98
+ private readonly byName = new Map<string, DiscoveredModelLike>();
99
+ private readonly loadModelFn: typeof loadModel;
100
+ private readonly resolveModelPathFn: (model: DiscoveredModelLike, policy?: ModelLoadPolicy) => Promise<string>;
101
+ private readonly requirePagedCache: boolean;
102
+ private readonly persistPagedCache: boolean;
103
+ private resident: ResidentModel | null = null;
104
+ private chain: Promise<unknown> = Promise.resolve();
105
+
106
+ constructor(models: DiscoveredModelLike[], opts: MlxModelHostOptions = {}) {
107
+ for (const model of models) this.byName.set(model.name, model);
108
+ this.loadModelFn = opts.loadModelFn ?? loadModel;
109
+ this.resolveModelPathFn = opts.resolveModelPathFn ?? (async (model) => model.path);
110
+ this.requirePagedCache = opts.requirePagedCache ?? false;
111
+ this.persistPagedCache = opts.persistPagedCache ?? true;
112
+ }
113
+
114
+ get residentId(): string | null {
115
+ return this.resident?.id ?? null;
116
+ }
117
+
118
+ /**
119
+ * Read-only lookup of the discovery record behind `modelId` (name, path,
120
+ * `ModelType`). Pure map read — never touches the serialized chain or
121
+ * the resident. The stream adapter uses it to pick the launch preset
122
+ * for the model it is about to run.
123
+ */
124
+ modelInfo(modelId: string): DiscoveredModelLike | undefined {
125
+ return this.byName.get(modelId);
126
+ }
127
+
128
+ /**
129
+ * Make `modelId` resident (loading or swapping on demand) and run `fn`
130
+ * against its `ChatSession` — both inside one serialized closure, so no
131
+ * other queued operation (in particular a swap to a different model)
132
+ * can touch the resident until `fn` settles. This is the ONLY way to
133
+ * use the resident session; there is deliberately no method that
134
+ * returns a session outside the serialized section.
135
+ *
136
+ * `fn` also receives a `resident` boolean: `true` when the turn reused
137
+ * the already-loaded model (warm — no load happened), `false` when it had
138
+ * to load or swap the checkpoint first. This is the same warm/cold
139
+ * distinction the branch below already makes; surfacing it lets a metrics
140
+ * consumer separate queue wait from cold-load time without another channel.
141
+ *
142
+ * Swaps drop the old session + model refs BEFORE loading the new
143
+ * checkpoint so GC + native destructors can reclaim the old weights
144
+ * during the load. A load failure leaves no resident (next call
145
+ * retries); a failure thrown by `fn` rejects only this call's promise
146
+ * and keeps the resident loaded for later callers.
147
+ */
148
+ runWithResident<T>(
149
+ modelId: string,
150
+ fn: (session: ChatSession, resident: boolean) => Promise<T>,
151
+ ownerId?: string,
152
+ ): Promise<T> {
153
+ ownerId = ownerId || undefined;
154
+ const entry = this.byName.get(modelId);
155
+ if (!entry) {
156
+ const known = [...this.byName.keys()].join(', ');
157
+ return Promise.reject(new Error(`MlxModelHost: unknown model "${modelId}" (known models: ${known})`));
158
+ }
159
+ return this.runSerialized(async () => {
160
+ let session: ChatSession;
161
+ // `true` when this turn reuses the loaded resident (warm), `false` when
162
+ // it loaded/swapped the checkpoint first (cold).
163
+ let resident: boolean;
164
+ if (this.resident?.id === modelId) {
165
+ if (this.resident.ownerId !== ownerId) {
166
+ // Stay on the same serialization chain: the preceding native turn must
167
+ // finish before its owner is released. Keep weights loaded, but never
168
+ // reassign the immutable owner of the old ChatSession.
169
+ try {
170
+ await this.resident.session.dispose();
171
+ } catch (error) {
172
+ this.resident = null;
173
+ throw error;
174
+ }
175
+ this.resident.session = new ChatSession(this.resident.model as SessionCapableModel);
176
+ this.resident.ownerId = ownerId;
177
+ this.resident.dirty = false;
178
+ }
179
+ session = this.resident.session;
180
+ resident = true;
181
+ } else {
182
+ resident = false;
183
+ this.resident = null;
184
+ // Only a COLD_TIER_RESTORE_FAMILIES family has a sound paged cold
185
+ // restore. Hand it an EXPLICIT tri-state directive so the overlay can
186
+ // authoritatively set the flag either way (default-on, or
187
+ // `--no-persist-cache` off — overriding any value in the checkpoint's
188
+ // config.json). Every other family gets no policy at all, so the
189
+ // overlay never touches the field for them.
190
+ const resolvedPath = COLD_TIER_RESTORE_FAMILIES.has(entry.modelType)
191
+ ? await this.resolveModelPathFn(entry, { persistPagedCache: this.persistPagedCache })
192
+ : await this.resolveModelPathFn(entry);
193
+ // Preserve the ordinary one-argument call for unpaired checkpoints.
194
+ // Supplied paths are authoritative across draft families. Let the
195
+ // loader validate them and report errors; only absent paths opt into
196
+ // Qwen DFlash2 discovery, resolved against the original target path.
197
+ const draftModelPath = entry.draftModelPath ?? findDFlash2Draft(entry.path, entry.modelType);
198
+ const model =
199
+ draftModelPath === undefined
200
+ ? await this.loadModelFn(resolvedPath)
201
+ : await this.loadModelFn(resolvedPath, { draftModelPath });
202
+ const sessionModel = model as unknown as SessionCapableModel;
203
+ const gemmaDraftActive = entry.modelType === 'gemma4' && sessionModel.hasMtpWeights?.() === true;
204
+ if (this.requirePagedCache && sessionModel.hasBlockPagedCache?.() !== true && !gemmaDraftActive) {
205
+ throw new Error(
206
+ `MlxModelHost: model "${modelId}" (${entry.modelType}) loaded without an active ` +
207
+ `PagedAttention cache; this checkpoint, quantization, or platform is not compatible ` +
208
+ `with the mlx agent paged-cache requirement`,
209
+ );
210
+ }
211
+ if (this.requirePagedCache && entry.modelType === 'qwen3_5_moe' && sessionModel.hasMtpWeights?.() === true) {
212
+ throw new Error(
213
+ `MlxModelHost: model "${modelId}" has Qwen3.5 MoE MTP weights, but the native MoE ` +
214
+ `backend cannot combine MTP with PagedAttention yet; refusing to silently downgrade ` +
215
+ `this agent session to paged autoregressive decoding`,
216
+ );
217
+ }
218
+ session = new ChatSession(sessionModel);
219
+ this.resident = { id: modelId, session, model, ownerId, dirty: false };
220
+ }
221
+ return await fn(session, resident);
222
+ });
223
+ }
224
+
225
+ /**
226
+ * Flag the current resident as post-error so the next turn does a full
227
+ * reset instead of a warm reuse. No-op unless `modelId` is the live
228
+ * resident (a load failure or a swap already dropped/replaced it, and a
229
+ * reloaded model starts with a clean cache).
230
+ */
231
+ markResidentDirty(modelId: string): void {
232
+ if (this.resident?.id === modelId) {
233
+ this.resident.dirty = true;
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Read-and-clear the resident's post-error `dirty` flag. Returns `true`
239
+ * only when `modelId` is the live resident AND it was dirty — the signal
240
+ * for the caller to run a full `session.reset()` this turn instead of the
241
+ * warm-reuse wipe.
242
+ */
243
+ consumeResidentDirty(modelId: string): boolean {
244
+ if (this.resident?.id !== modelId) {
245
+ return false;
246
+ }
247
+ const wasDirty = this.resident.dirty;
248
+ this.resident.dirty = false;
249
+ return wasDirty;
250
+ }
251
+
252
+ /**
253
+ * Drop the current resident so the next `runWithResident` reloads it from
254
+ * scratch. Used when a post-error full reset itself fails and the session
255
+ * can no longer be trusted. No-op unless `modelId` is the live resident.
256
+ */
257
+ invalidateResident(modelId: string): void {
258
+ if (this.resident?.id === modelId) {
259
+ this.resident = null;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Run `fn` after every previously queued operation completes. The
265
+ * chain advances regardless of `fn`'s outcome — a rejection reaches
266
+ * only this call's returned promise, never later queued operations.
267
+ */
268
+ private runSerialized<T>(fn: () => Promise<T>): Promise<T> {
269
+ const result = this.chain.then(fn);
270
+ this.chain = result.then(
271
+ () => undefined,
272
+ () => undefined,
273
+ );
274
+ return result;
275
+ }
276
+ }