@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,515 @@
1
+ /**
2
+ * `makeMlxStreamSimple` — the provider bridge's pi `streamSimple` seam.
3
+ *
4
+ * Every pi LLM call becomes one warm replay against the host's resident
5
+ * `ChatSession` (spike-proven pattern):
6
+ *
7
+ * resetPreservingNativeCacheForWarmReuse(session) // JS-state-only wipe
8
+ * session.primeHistory(contextToChatMessages(ctx)) // pi's full history
9
+ * session.startFromHistoryStream(config, signal) // cold replay, warm KV
10
+ *
11
+ * The whole per-call body — resident selection INCLUDED — runs inside one
12
+ * `MlxModelHost.runWithResident` closure, so concurrent pi calls (and
13
+ * model swaps) execute strictly sequentially and the session can never be
14
+ * swapped out mid-turn. Do not split this into `ensureResident` + a
15
+ * separate serialization step; that pattern has a stale-resident race.
16
+ *
17
+ * Contract (absolute): the returned function NEVER throws and its stream
18
+ * always terminates — with exactly ONE terminal event. Enforced in layers:
19
+ *
20
+ * - A turn-wide `terminated` flag: every ending routes through
21
+ * `terminalize` (or the native-final branch), so the first terminal
22
+ * wins and all later work — including a resident closure that was
23
+ * queued behind stalled inference/loading when the abort landed —
24
+ * observes the flag and skips ALL session work.
25
+ * - Abort coverage has no queued gap: an already-aborted signal
26
+ * terminates before the host is even engaged, and an abort listener
27
+ * spans the whole queued/running window so the stream terminates
28
+ * promptly even when `runWithResident` never yields.
29
+ * - Failures become stream events via `TurnEmitter` (`onError` /
30
+ * `onAborted`). Hostile error values are contained inside the emitter
31
+ * itself (`onError` shares the hardened `coerceErrorMessage`), so the
32
+ * TurnEmitter-independent failsafe below is defense in depth: if the
33
+ * emitter still fails — a synchronous setup throw from a hostile
34
+ * `Model` getter, or any residual defect — it pushes a minimal
35
+ * terminal directly onto the stream. A push/end failure at that last
36
+ * layer is swallowed: there is no further recovery surface.
37
+ */
38
+
39
+ import type {
40
+ Api,
41
+ AssistantMessage,
42
+ AssistantMessageEventStream,
43
+ Context,
44
+ Model,
45
+ SimpleStreamOptions,
46
+ } from '@earendil-works/pi-ai';
47
+ import { createAssistantMessageEventStream } from '@earendil-works/pi-ai';
48
+ import type { ChatSession, ChatStreamFinal, PerformanceMetrics } from '@mlx-node/lm';
49
+
50
+ import type { DiscoveredModelLike } from '../types.js';
51
+ import { buildChatConfig, resolveReasoningMode, type ResolvedReasoningMode } from './chat-config.js';
52
+ import { contextToChatMessages, toolsToDefinitions } from './convert-messages.js';
53
+ import { coerceErrorMessage } from './error-coercion.js';
54
+ import { emptyUsage, TurnEmitter } from './events.js';
55
+ import { resetPreservingNativeCacheForWarmReuse } from './warm-reuse.js';
56
+
57
+ /**
58
+ * The exact `MlxModelHost` surface the adapter consumes, kept structural
59
+ * so tests can drive the adapter with a scripted fake host. `MlxModelHost`
60
+ * satisfies this interface as-is.
61
+ */
62
+ export interface StreamSimpleHost {
63
+ /** Discovery record for `modelId` (source of the `ModelType` → launch preset). */
64
+ modelInfo(modelId: string): DiscoveredModelLike | undefined;
65
+ /**
66
+ * Atomic resident selection + serialized inference closure (see `MlxModelHost`).
67
+ * `fn` receives a `resident` boolean: `true` when the model was already warm
68
+ * (reused), `false` when this turn had to load/swap it.
69
+ */
70
+ runWithResident<T>(
71
+ modelId: string,
72
+ fn: (session: ChatSession, resident: boolean) => Promise<T>,
73
+ ownerId?: string,
74
+ ): Promise<T>;
75
+ /** Flag the resident as post-error so the next turn does a full reset (see `MlxModelHost`). */
76
+ markResidentDirty(modelId: string): void;
77
+ /** Read-and-clear the resident's post-error flag; `true` ⇒ full-reset this turn. */
78
+ consumeResidentDirty(modelId: string): boolean;
79
+ /** Drop the resident so the next turn reloads it (post-error reset failure). */
80
+ invalidateResident(modelId: string): void;
81
+ }
82
+
83
+ export type PerformanceRecorder = (message: AssistantMessage, performance: PerformanceMetrics) => void;
84
+ export type RootCacheOwnerResolver = () => string | undefined;
85
+ /** Resolves the root session's JSONL path for the metrics-trace root correlation. */
86
+ export type RootSessionFileResolver = () => string | undefined;
87
+
88
+ /**
89
+ * Durable per-turn telemetry hook. Fires exactly once, only on a SUCCESSFUL
90
+ * native final (never on abort / error / load failure), from the one seam
91
+ * that sees the raw `ChatStreamFinal` alongside the per-request
92
+ * `options.sessionId` and the turn's minted `traceId`. Best-effort: the
93
+ * adapter guards the call so a throwing recorder can never break inference.
94
+ */
95
+ export type TurnRecorder = (rec: {
96
+ traceId: string;
97
+ sessionId?: string;
98
+ /** Root session id/file snapshotted when this turn was submitted (see below). */
99
+ rootSessionId?: string;
100
+ rootSessionFile?: string;
101
+ model: string;
102
+ final: ChatStreamFinal;
103
+ /** Whole-turn wall-clock (ms): queue wait + resident selection + prefill + decode. */
104
+ durationMs: number;
105
+ /**
106
+ * Queue + cold-load wait (ms) BEFORE native work began this turn: the gap
107
+ * between turn submission and the serialized `runWithResident` callback
108
+ * firing (behind earlier inference and/or a model load/swap). Subtract from
109
+ * `durationMs` to recover execution-only time.
110
+ */
111
+ queueMs: number;
112
+ /** `true` when the model was already warm/resident, `false` on a cold load/swap this turn. */
113
+ resident: boolean;
114
+ }) => void;
115
+
116
+ /** Property read that must not throw (poisoned getters on a hostile `Model`). */
117
+ function safeString(read: () => string, fallback: string): string {
118
+ try {
119
+ const value = read();
120
+ return typeof value === 'string' ? value : fallback;
121
+ } catch {
122
+ return fallback;
123
+ }
124
+ }
125
+
126
+ /** Read numeric model metadata without trusting a user-configurable getter. */
127
+ function readPositiveSafeInteger(read: () => unknown): number | undefined {
128
+ try {
129
+ const value = read();
130
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined;
131
+ } catch {
132
+ return undefined;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Publish the native model's load-time physical context limit onto pi's shared
138
+ * model object. The parent session and every in-process subagent resolve this
139
+ * same object from one `ModelRegistry`, so the first completed model load gives
140
+ * all later turns the correct auto-compaction window without another channel.
141
+ *
142
+ * This is advisory only: the ChatSession preflight remains the correctness
143
+ * backstop for the first turn and for hostile/invalid getters. Never expand a
144
+ * discovery-time limit here, and never let metadata synchronization break an
145
+ * otherwise valid inference turn.
146
+ */
147
+ function publishEffectiveContextWindow(
148
+ model: Model<Api>,
149
+ session: ChatSession,
150
+ configuredModelMaxTokens: number | undefined,
151
+ ): void {
152
+ try {
153
+ const effective = Math.floor(session.contextLimits()?.effectiveWindowTokens ?? 0);
154
+ if (!Number.isSafeInteger(effective) || effective <= 0) return;
155
+ model.contextWindow = Math.min(model.contextWindow, effective);
156
+ // Use the already-validated snapshot. Reading the shared metadata here
157
+ // would let Math.min coerce invalid user configuration into a valid value
158
+ // that a later turn would then trust.
159
+ if (configuredModelMaxTokens !== undefined) {
160
+ model.maxTokens = Math.min(configuredModelMaxTokens, model.contextWindow);
161
+ }
162
+ } catch {
163
+ // Exact native preflight still protects capacity; keep serving the turn.
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Publish the loaded model's authoritative image capability onto Pi's shared
169
+ * model object. Discovery stays conservatively text-only; the first resident
170
+ * load upgrades the same object before Pi executes any tool call emitted by
171
+ * that inference turn. Return the native truth separately so a hostile/frozen
172
+ * Pi model object cannot prevent image bytes from reaching the provider.
173
+ */
174
+ function publishImageCapability(model: Model<Api>, session: ChatSession): boolean {
175
+ let supportsImages = false;
176
+ try {
177
+ supportsImages = session.supportsImages();
178
+ } catch {
179
+ return false;
180
+ }
181
+
182
+ try {
183
+ const advertisesImages = model.input.includes('image');
184
+ if (supportsImages && !advertisesImages) {
185
+ model.input = [...model.input, 'image'];
186
+ } else if (!supportsImages && advertisesImages) {
187
+ // Pi reuses model objects across resident swaps. Reconcile a stale
188
+ // positive capability without disturbing any other inputs or their
189
+ // order, so its tools do not return image blocks to a text-only model.
190
+ model.input = model.input.filter((input) => input !== 'image');
191
+ }
192
+ } catch {
193
+ // Native capability remains authoritative for this turn's conversion.
194
+ }
195
+ return supportsImages;
196
+ }
197
+
198
+ /**
199
+ * Minimal terminal `AssistantMessage` for the TurnEmitter-independent
200
+ * failsafe path. Every field read is guarded — this must stay
201
+ * constructible even when the `Model` object itself is hostile (it may be
202
+ * the very reason `TurnEmitter` construction failed).
203
+ */
204
+ function failsafeMessage(model: Model<Api>, reason: 'aborted' | 'error', message: string): AssistantMessage {
205
+ return {
206
+ role: 'assistant',
207
+ content: [],
208
+ api: safeString(() => model.api, 'unknown'),
209
+ provider: safeString(() => model.provider, 'unknown'),
210
+ model: safeString(() => model.id, 'unknown'),
211
+ usage: emptyUsage(),
212
+ stopReason: reason,
213
+ errorMessage: message,
214
+ timestamp: Date.now(),
215
+ };
216
+ }
217
+
218
+ export function makeMlxStreamSimple(
219
+ host: StreamSimpleHost,
220
+ onPerformance?: PerformanceRecorder,
221
+ resolveRootCacheOwner?: RootCacheOwnerResolver,
222
+ onTurnRecord?: TurnRecorder,
223
+ onTurnStart?: () => void,
224
+ resolveRootSessionFile?: RootSessionFileResolver,
225
+ resolveThinkingBudget?: () => number | undefined,
226
+ ): (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream {
227
+ return (model, context, options) => {
228
+ const stream = createAssistantMessageEventStream();
229
+ let thinkingTokenBudget: number | undefined;
230
+
231
+ /**
232
+ * Exactly-one-terminal guard for the WHOLE turn. `TurnEmitter` has its
233
+ * own `finished` flag, but it cannot cover pre-emitter failures or the
234
+ * failsafe path. Once set, late work — including a resident closure
235
+ * that finally runs after an abort-while-queued — must do nothing.
236
+ */
237
+ let terminated = false;
238
+ let emitter: TurnEmitter | undefined;
239
+ let signal: AbortSignal | undefined;
240
+ let rootCacheOwnerId: string | undefined;
241
+ let rootSessionFile: string | undefined;
242
+ let resolvedReasoning: ResolvedReasoningMode;
243
+ let detachAbort: (() => void) | undefined;
244
+
245
+ /**
246
+ * Last-resort terminal, independent of `TurnEmitter` (which may be
247
+ * broken or never constructed). Push/end failures are swallowed — the
248
+ * StreamFn contract forbids throwing into pi and there is no further
249
+ * recovery surface.
250
+ */
251
+ const pushFailsafeTerminal = (reason: 'aborted' | 'error', message: string): void => {
252
+ try {
253
+ stream.push({ type: 'error', reason, error: failsafeMessage(model, reason, message) });
254
+ stream.end();
255
+ } catch {
256
+ // No recovery surface left.
257
+ }
258
+ };
259
+
260
+ /**
261
+ * Idempotent terminal: the first caller wins, later callers no-op.
262
+ * Routes through `TurnEmitter` when possible; falls back to the
263
+ * direct-push failsafe when the emitter is missing or throws
264
+ * (defense in depth — `onError` shares the hardened coercion and is
265
+ * not expected to throw).
266
+ */
267
+ const terminalize = (kind: 'aborted' | 'error', err?: unknown): void => {
268
+ if (terminated) return;
269
+ terminated = true;
270
+ detachAbort?.();
271
+ detachAbort = undefined;
272
+ if (kind === 'error') {
273
+ // A native error mid-decode can leave the physical KV ahead of the
274
+ // committed history; flag the resident so the NEXT turn does a full
275
+ // reset (cold prefill) instead of a misaligned warm reuse. Only the
276
+ // error terminal marks dirty — abort / stop / length keep the cache
277
+ // consistent and preserve warm reuse. Guarded: a hostile `model.id`
278
+ // getter must not derail the terminal.
279
+ try {
280
+ host.markResidentDirty(model.id);
281
+ } catch {
282
+ // Nothing to mark — fail safe.
283
+ }
284
+ }
285
+ if (emitter) {
286
+ try {
287
+ if (kind === 'aborted') {
288
+ emitter.onAborted();
289
+ } else {
290
+ emitter.onError(err);
291
+ }
292
+ return;
293
+ } catch {
294
+ // TurnEmitter itself failed — fall through to the failsafe.
295
+ }
296
+ }
297
+ pushFailsafeTerminal(kind, kind === 'aborted' ? 'Request was aborted' : coerceErrorMessage(err));
298
+ };
299
+
300
+ const onAbort = (): void => {
301
+ terminalize('aborted');
302
+ };
303
+
304
+ try {
305
+ // Synchronous setup is inside the containment too: a hostile
306
+ // `options`/`Model` getter or a TurnEmitter constructor failure must
307
+ // become a stream terminal, never a synchronous throw into pi.
308
+ signal = options?.signal;
309
+ // Snapshot the top-level owner before this request can queue behind
310
+ // another inference. A later /new or /resume must not relabel an older
311
+ // request that was already submitted under the previous root. The metrics
312
+ // root id reuses this same session_start value; only the JSONL path is a
313
+ // separate snapshot, taken here so it can never drift from the id.
314
+ rootCacheOwnerId = resolveRootCacheOwner?.();
315
+ rootSessionFile = resolveRootSessionFile?.();
316
+ // Snapshot once: the native config and the replay provenance must describe
317
+ // the same resolved template mode. Presence alone is wrong for Pi's
318
+ // off is disabled; low remains enabled thinking.
319
+ thinkingTokenBudget = resolveThinkingBudget?.();
320
+ resolvedReasoning = resolveReasoningMode(options?.reasoning);
321
+ emitter = new TurnEmitter(stream, model, onPerformance, resolvedReasoning.thinkingEnabled);
322
+ } catch (err) {
323
+ terminalize('error', err);
324
+ return stream;
325
+ }
326
+ const turn = emitter;
327
+
328
+ // Abort coverage from here has no gap: pre-check a signal that is
329
+ // already aborted (never engage the host at all), then keep a listener
330
+ // installed across the whole queued/running window so a request parked
331
+ // behind stalled inference/loading still terminates promptly.
332
+ if (signal?.aborted) {
333
+ terminalize('aborted');
334
+ return stream;
335
+ }
336
+ if (signal) {
337
+ const s = signal;
338
+ s.addEventListener('abort', onAbort, { once: true });
339
+ detachAbort = () => {
340
+ s.removeEventListener('abort', onAbort);
341
+ };
342
+ }
343
+
344
+ void (async () => {
345
+ let sawNativeFinal = false;
346
+ // Bracket the whole serialized turn (queue wait + resident selection +
347
+ // prefill + decode) so a MetricsTrace record can report wall-clock
348
+ // duration. Read at the terminal only on the success path below.
349
+ const turnStartedAt = Date.now();
350
+ const runTurn = async (session: ChatSession, resident: boolean): Promise<void> => {
351
+ // Callback entry: the queue wait + any cold model load/swap have now
352
+ // resolved, but no native work (prime/prefill/decode) has started. This
353
+ // is the seam that separates queue+load latency from execution time —
354
+ // `durationMs` (read at the terminal) still brackets the whole turn.
355
+ const execStartedAt = Date.now();
356
+ const queueMs = Math.max(0, execStartedAt - turnStartedAt);
357
+ // Terminated while queued behind earlier inference/loading (or
358
+ // between stages below): the terminal already went out — skip ALL
359
+ // session work (no warm-reset, no prime, no stream).
360
+ if (terminated) return;
361
+ // Serialized-turn start: fire before any native work so a metrics
362
+ // consumer can snapshot process-wide cold-tier counters here and diff
363
+ // them at the success terminal. Because turns are serialized, a prior
364
+ // turn that aborted/errored has already fully drained by now, so its
365
+ // activity is baked into this snapshot and never attributed to this
366
+ // turn's delta. Best-effort — a throwing hook must not break inference.
367
+ if (onTurnStart) {
368
+ try {
369
+ onTurnStart();
370
+ } catch {
371
+ // Telemetry snapshot is best-effort; never break inference.
372
+ }
373
+ }
374
+ // Snapshot the composed budget before publishing native context limits.
375
+ // The advisory publisher must not read this raw value itself:
376
+ // Math.min can coerce hostile metadata (for example "512" or Infinity)
377
+ // into a finite number that a later turn would then trust.
378
+ const configuredModelMaxTokens = readPositiveSafeInteger(() => model.maxTokens);
379
+ publishEffectiveContextWindow(model, session, configuredModelMaxTokens);
380
+ const supportsImages = publishImageCapability(model, session);
381
+ const discovered = host.modelInfo(model.id);
382
+ if (!discovered) {
383
+ throw new Error(`mlx streamSimple: no discovery record for model "${model.id}"`);
384
+ }
385
+ if (host.consumeResidentDirty(model.id)) {
386
+ // Previous turn errored mid-decode: the physical KV may be ahead of
387
+ // the committed history, so a warm reuse would misalign this
388
+ // replay's prefix. Full-reset (clears native caches + history →
389
+ // hit=0 → cold prefill). If the reset itself fails the session is
390
+ // untrustworthy — drop the resident so the next call reloads it.
391
+ try {
392
+ await session.reset();
393
+ } catch (err) {
394
+ host.invalidateResident(model.id);
395
+ throw err;
396
+ }
397
+ } else {
398
+ await resetPreservingNativeCacheForWarmReuse(session);
399
+ }
400
+ if (terminated) return;
401
+ try {
402
+ session.primeHistory(contextToChatMessages(context, supportsImages));
403
+ const config = buildChatConfig(
404
+ discovered.modelType,
405
+ options,
406
+ toolsToDefinitions(context.tools),
407
+ rootCacheOwnerId,
408
+ resolvedReasoning,
409
+ configuredModelMaxTokens,
410
+ thinkingTokenBudget,
411
+ );
412
+ for await (const event of session.startFromHistoryStream(config, signal)) {
413
+ if (event.done) {
414
+ if (event.finishReason === 'error') {
415
+ // In-band native error terminal: chat-session yields a `done`
416
+ // event with `finishReason: 'error'` WITHOUT committing a final
417
+ // (no `sawFinal`), so the physical KV may be ahead of the
418
+ // committed history. Mark the resident dirty (synchronously,
419
+ // inside the callback, so a queued turn observes it) and route
420
+ // to onError — sending it to onFinal treats it as success and
421
+ // skips the dirty flag.
422
+ if (!terminated) {
423
+ terminated = true;
424
+ detachAbort?.();
425
+ detachAbort = undefined;
426
+ try {
427
+ host.markResidentDirty(model.id);
428
+ } catch {
429
+ // Nothing to mark — fail safe.
430
+ }
431
+ try {
432
+ turn.onError(new Error('native stream reported finishReason=error'));
433
+ } catch (err) {
434
+ pushFailsafeTerminal('error', coerceErrorMessage(err));
435
+ }
436
+ }
437
+ } else {
438
+ sawNativeFinal = true;
439
+ if (!terminated) {
440
+ terminated = true;
441
+ detachAbort?.();
442
+ detachAbort = undefined;
443
+ try {
444
+ turn.onFinal(event);
445
+ // Durable per-turn telemetry: only a successful final gets
446
+ // here. Guarded independently of onFinal so a throwing
447
+ // recorder cannot derail the (already-emitted) terminal.
448
+ if (onTurnRecord) {
449
+ try {
450
+ onTurnRecord({
451
+ traceId: turn.traceId,
452
+ sessionId: options?.sessionId,
453
+ // Root correlation is the SUBMIT-time snapshot, not the
454
+ // live root, so a /new or /resume that landed while this
455
+ // turn ran can't reattribute it (id == cacheOwner root).
456
+ rootSessionId: rootCacheOwnerId,
457
+ rootSessionFile,
458
+ model: model.id,
459
+ final: event,
460
+ durationMs: Math.max(0, Date.now() - turnStartedAt),
461
+ // Queue+load wait captured at callback entry, and the
462
+ // warm/cold distinction from `runWithResident` — so the
463
+ // record can attribute wait vs execution latency.
464
+ queueMs,
465
+ resident,
466
+ });
467
+ } catch {
468
+ // Telemetry is best-effort; never break inference.
469
+ }
470
+ }
471
+ } catch (err) {
472
+ pushFailsafeTerminal('error', coerceErrorMessage(err));
473
+ }
474
+ }
475
+ }
476
+ } else if (!terminated) {
477
+ turn.onDelta(event);
478
+ }
479
+ }
480
+ } catch (err) {
481
+ // A native decode fault thrown mid-stream can leave the physical KV
482
+ // ahead of the committed history. Flag the resident dirty so the NEXT
483
+ // turn full-resets — SYNCHRONOUSLY here, before this callback rejects
484
+ // and `runSerialized` releases the chain, so a queued turn observes
485
+ // dirty === true (the detached `.catch` terminalize runs too late for
486
+ // that). Abort is excluded: a clean cancel realigns the cache (warm
487
+ // reuse stays valid) and its terminal has already fired. Re-throw so
488
+ // the detached `.catch` still terminalizes the stream.
489
+ if (!signal?.aborted) {
490
+ try {
491
+ host.markResidentDirty(model.id);
492
+ } catch {
493
+ // Nothing to mark — fail safe.
494
+ }
495
+ }
496
+ throw err;
497
+ }
498
+ };
499
+ await host.runWithResident(model.id, runTurn, options?.sessionId);
500
+ if (!terminated && !sawNativeFinal) {
501
+ // An aborted native stream ends cleanly with NO final event; any
502
+ // other final-less ending is a native-protocol violation.
503
+ if (signal?.aborted) {
504
+ terminalize('aborted');
505
+ } else {
506
+ terminalize('error', new Error('stream ended without final event'));
507
+ }
508
+ }
509
+ })().catch((err: unknown) => {
510
+ terminalize('error', err);
511
+ });
512
+
513
+ return stream;
514
+ };
515
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Port of `packages/server/src/tool-call-buffer.ts` — the agent package
3
+ * must not depend on `@mlx-node/server`, so the class is duplicated here
4
+ * with identical semantics. Keep the two in sync.
5
+ *
6
+ * Buffers streaming text to detect and suppress model structural tags. Text
7
+ * that cannot be part of a partial tag is released immediately; once a
8
+ * full structural tag is seen, everything after it is suppressed until
9
+ * the stream ends.
10
+ */
11
+ export class ToolCallTagBuffer {
12
+ private static readonly TAGS = [
13
+ '<tool_call>',
14
+ '</tool_call>',
15
+ '<|tool_call>',
16
+ '<tool_call|>',
17
+ '<|tool_response>',
18
+ '<tool_response|>',
19
+ '<|tool>',
20
+ '<tool|>',
21
+ '<|channel>',
22
+ '<channel|>',
23
+ '<|turn>',
24
+ '<turn|>',
25
+ ] as const;
26
+ private pendingText = '';
27
+ private _suppressed = false;
28
+
29
+ get suppressed(): boolean {
30
+ return this._suppressed;
31
+ }
32
+
33
+ /**
34
+ * Feed text in. Returns `safeText` (emit as delta), `tagFound` (a full
35
+ * structural tag was just seen), and `cleanPrefix` (text before the tag
36
+ * when `tagFound` — may contain whitespace; use `.trim()` only for
37
+ * emptiness checks, never for emission).
38
+ */
39
+ push(text: string): { safeText: string; tagFound: boolean; cleanPrefix: string } {
40
+ if (this._suppressed) {
41
+ return { safeText: '', tagFound: false, cleanPrefix: '' };
42
+ }
43
+
44
+ this.pendingText += text;
45
+
46
+ let tagIdx = -1;
47
+ for (const tag of ToolCallTagBuffer.TAGS) {
48
+ const idx = this.pendingText.indexOf(tag);
49
+ if (idx >= 0 && (tagIdx < 0 || idx < tagIdx)) {
50
+ tagIdx = idx;
51
+ }
52
+ }
53
+ if (tagIdx >= 0) {
54
+ const cleanPrefix = this.pendingText.slice(0, tagIdx);
55
+ this._suppressed = true;
56
+ this.pendingText = '';
57
+ return { safeText: '', tagFound: true, cleanPrefix };
58
+ }
59
+
60
+ // Hold back any suffix that could be the start of the tag.
61
+ let safeLen = this.pendingText.length;
62
+ const maxTagLength = Math.max(...ToolCallTagBuffer.TAGS.map((tag) => tag.length));
63
+ for (let i = 1; i <= Math.min(this.pendingText.length, maxTagLength - 1); i++) {
64
+ const suffix = this.pendingText.slice(-i);
65
+ if (ToolCallTagBuffer.TAGS.some((tag) => tag.startsWith(suffix))) {
66
+ safeLen = this.pendingText.length - i;
67
+ break;
68
+ }
69
+ }
70
+
71
+ const safeText = this.pendingText.slice(0, safeLen);
72
+ this.pendingText = this.pendingText.slice(safeLen);
73
+ return { safeText, tagFound: false, cleanPrefix: '' };
74
+ }
75
+
76
+ /** Release any held-back text at stream end. */
77
+ flush(): string {
78
+ const text = this.pendingText;
79
+ this.pendingText = '';
80
+ return text;
81
+ }
82
+ }