@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,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
|
+
}
|