@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.
- package/dist/catalog.d.ts +10 -1
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +11 -2
- package/dist/delegate.d.ts +29 -0
- package/dist/delegate.d.ts.map +1 -0
- package/dist/delegate.js +106 -0
- package/dist/extensions/delegation.d.ts +15 -0
- package/dist/extensions/delegation.d.ts.map +1 -0
- package/dist/extensions/delegation.js +93 -0
- package/dist/paths.d.ts +6 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +16 -0
- package/dist/provider/chat-config.d.ts +6 -5
- package/dist/provider/chat-config.d.ts.map +1 -1
- package/dist/provider/chat-config.js +21 -7
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +8 -1
- package/dist/provider/model-host.d.ts +1 -1
- package/dist/provider/model-host.d.ts.map +1 -1
- package/dist/provider/model-host.js +25 -7
- package/dist/provider/models.d.ts +3 -14
- package/dist/provider/models.d.ts.map +1 -1
- package/dist/provider/models.js +17 -239
- package/dist/provider/stream-adapter.d.ts +2 -2
- package/dist/provider/stream-adapter.d.ts.map +1 -1
- package/dist/provider/stream-adapter.js +8 -5
- package/dist/run-agent.d.ts +4 -0
- package/dist/run-agent.d.ts.map +1 -1
- package/dist/run-agent.js +8 -2
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +23 -5
- package/src/catalog.ts +194 -0
- package/src/cold-tier.ts +152 -0
- package/src/delegate.ts +136 -0
- package/src/extensions/approval-detail.ts +57 -0
- package/src/extensions/delegation.ts +109 -0
- package/src/extensions/local-image-input.ts +132 -0
- package/src/extensions/permission-gate.ts +347 -0
- package/src/extensions/subagent.ts +743 -0
- package/src/extensions/terminal-title.ts +53 -0
- package/src/extensions/trace-notice.ts +37 -0
- package/src/index.ts +23 -0
- package/src/paths.ts +36 -0
- package/src/provider/chat-config.ts +132 -0
- package/src/provider/convert-messages.ts +273 -0
- package/src/provider/error-coercion.ts +36 -0
- package/src/provider/events.ts +341 -0
- package/src/provider/index.ts +255 -0
- package/src/provider/metrics-trace.ts +380 -0
- package/src/provider/mlx-identity.ts +16 -0
- package/src/provider/model-host.ts +276 -0
- package/src/provider/model-registry-filter.ts +336 -0
- package/src/provider/models.ts +48 -0
- package/src/provider/performance-status.ts +112 -0
- package/src/provider/reasoning-tag-buffer.ts +67 -0
- package/src/provider/stream-adapter.ts +515 -0
- package/src/provider/tool-call-buffer.ts +82 -0
- package/src/provider/warm-reuse.ts +125 -0
- package/src/run-agent.ts +178 -0
- 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
|
+
}
|