@mlx-node/agent 0.0.7

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 (70) hide show
  1. package/dist/catalog.d.ts +26 -0
  2. package/dist/catalog.d.ts.map +1 -0
  3. package/dist/catalog.js +44 -0
  4. package/dist/extensions/approval-detail.d.ts +9 -0
  5. package/dist/extensions/approval-detail.d.ts.map +1 -0
  6. package/dist/extensions/approval-detail.js +53 -0
  7. package/dist/extensions/permission-gate.d.ts +30 -0
  8. package/dist/extensions/permission-gate.d.ts.map +1 -0
  9. package/dist/extensions/permission-gate.js +309 -0
  10. package/dist/extensions/subagent.d.ts +82 -0
  11. package/dist/extensions/subagent.d.ts.map +1 -0
  12. package/dist/extensions/subagent.js +539 -0
  13. package/dist/extensions/terminal-title.d.ts +10 -0
  14. package/dist/extensions/terminal-title.d.ts.map +1 -0
  15. package/dist/extensions/terminal-title.js +45 -0
  16. package/dist/extensions/trace-notice.d.ts +11 -0
  17. package/dist/extensions/trace-notice.d.ts.map +1 -0
  18. package/dist/extensions/trace-notice.js +34 -0
  19. package/dist/index.d.ts +14 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +12 -0
  22. package/dist/provider/chat-config.d.ts +34 -0
  23. package/dist/provider/chat-config.d.ts.map +1 -0
  24. package/dist/provider/chat-config.js +103 -0
  25. package/dist/provider/convert-messages.d.ts +59 -0
  26. package/dist/provider/convert-messages.d.ts.map +1 -0
  27. package/dist/provider/convert-messages.js +248 -0
  28. package/dist/provider/error-coercion.d.ts +19 -0
  29. package/dist/provider/error-coercion.d.ts.map +1 -0
  30. package/dist/provider/error-coercion.js +38 -0
  31. package/dist/provider/events.d.ts +67 -0
  32. package/dist/provider/events.d.ts.map +1 -0
  33. package/dist/provider/events.js +307 -0
  34. package/dist/provider/index.d.ts +28 -0
  35. package/dist/provider/index.d.ts.map +1 -0
  36. package/dist/provider/index.js +64 -0
  37. package/dist/provider/inference-trace.d.ts +58 -0
  38. package/dist/provider/inference-trace.d.ts.map +1 -0
  39. package/dist/provider/inference-trace.js +205 -0
  40. package/dist/provider/model-host.d.ts +94 -0
  41. package/dist/provider/model-host.d.ts.map +1 -0
  42. package/dist/provider/model-host.js +134 -0
  43. package/dist/provider/model-registry-filter.d.ts +36 -0
  44. package/dist/provider/model-registry-filter.d.ts.map +1 -0
  45. package/dist/provider/model-registry-filter.js +82 -0
  46. package/dist/provider/models.d.ts +35 -0
  47. package/dist/provider/models.d.ts.map +1 -0
  48. package/dist/provider/models.js +132 -0
  49. package/dist/provider/performance-status.d.ts +28 -0
  50. package/dist/provider/performance-status.d.ts.map +1 -0
  51. package/dist/provider/performance-status.js +91 -0
  52. package/dist/provider/reasoning-tag-buffer.d.ts +23 -0
  53. package/dist/provider/reasoning-tag-buffer.d.ts.map +1 -0
  54. package/dist/provider/reasoning-tag-buffer.js +60 -0
  55. package/dist/provider/stream-adapter.d.ts +61 -0
  56. package/dist/provider/stream-adapter.d.ts.map +1 -0
  57. package/dist/provider/stream-adapter.js +358 -0
  58. package/dist/provider/tool-call-buffer.d.ts +30 -0
  59. package/dist/provider/tool-call-buffer.d.ts.map +1 -0
  60. package/dist/provider/tool-call-buffer.js +75 -0
  61. package/dist/provider/warm-reuse.d.ts +73 -0
  62. package/dist/provider/warm-reuse.d.ts.map +1 -0
  63. package/dist/provider/warm-reuse.js +88 -0
  64. package/dist/run-agent.d.ts +62 -0
  65. package/dist/run-agent.d.ts.map +1 -0
  66. package/dist/run-agent.js +86 -0
  67. package/dist/types.d.ts +8 -0
  68. package/dist/types.d.ts.map +1 -0
  69. package/dist/types.js +1 -0
  70. package/package.json +42 -0
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Removes model-protocol thinking tags from raw native reasoning deltas.
3
+ *
4
+ * The native stream deliberately exposes raw ChatML text, so `<think>` /
5
+ * `</think>` (and the LongCat variants) may arrive as complete tags or split
6
+ * across multiple deltas. Pi's `ThinkingContent` is already structured and
7
+ * must contain only the reasoning body.
8
+ *
9
+ * Text that cannot belong to a partial structural tag is released
10
+ * immediately. An ambiguous suffix is held until another delta disambiguates
11
+ * it or `flush()` recovers it at a terminal boundary.
12
+ */
13
+ export declare class ReasoningTagBuffer {
14
+ private static readonly TAGS;
15
+ private pendingText;
16
+ /** Feed one raw reasoning delta and return protocol-tag-free text. */
17
+ push(text: string): string;
18
+ /** Release an incomplete, therefore non-structural, tag prefix at stream end. */
19
+ flush(): string;
20
+ private findFirstTag;
21
+ private safePrefixLength;
22
+ }
23
+ //# sourceMappingURL=reasoning-tag-buffer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reasoning-tag-buffer.d.ts","sourceRoot":"","sources":["../../src/provider/reasoning-tag-buffer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,qBAAa,kBAAkB;IAC7B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAA2E;IACvG,OAAO,CAAC,WAAW,CAAM;IAEzB,sEAAsE;IACtE,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAmBzB;IAED,iFAAiF;IACjF,KAAK,IAAI,MAAM,CAId;IAED,OAAO,CAAC,YAAY;IAWpB,OAAO,CAAC,gBAAgB;CAUzB"}
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Removes model-protocol thinking tags from raw native reasoning deltas.
3
+ *
4
+ * The native stream deliberately exposes raw ChatML text, so `<think>` /
5
+ * `</think>` (and the LongCat variants) may arrive as complete tags or split
6
+ * across multiple deltas. Pi's `ThinkingContent` is already structured and
7
+ * must contain only the reasoning body.
8
+ *
9
+ * Text that cannot belong to a partial structural tag is released
10
+ * immediately. An ambiguous suffix is held until another delta disambiguates
11
+ * it or `flush()` recovers it at a terminal boundary.
12
+ */
13
+ export class ReasoningTagBuffer {
14
+ static TAGS = ['<think>', '</think>', '<longcat_think>', '</longcat_think>'];
15
+ pendingText = '';
16
+ /** Feed one raw reasoning delta and return protocol-tag-free text. */
17
+ push(text) {
18
+ this.pendingText += text;
19
+ let safeText = '';
20
+ while (this.pendingText) {
21
+ const match = this.findFirstTag();
22
+ if (match) {
23
+ safeText += this.pendingText.slice(0, match.index);
24
+ this.pendingText = this.pendingText.slice(match.index + match.tag.length);
25
+ continue;
26
+ }
27
+ const safeLen = this.safePrefixLength();
28
+ safeText += this.pendingText.slice(0, safeLen);
29
+ this.pendingText = this.pendingText.slice(safeLen);
30
+ break;
31
+ }
32
+ return safeText;
33
+ }
34
+ /** Release an incomplete, therefore non-structural, tag prefix at stream end. */
35
+ flush() {
36
+ const text = this.pendingText;
37
+ this.pendingText = '';
38
+ return text;
39
+ }
40
+ findFirstTag() {
41
+ let first = null;
42
+ for (const tag of ReasoningTagBuffer.TAGS) {
43
+ const index = this.pendingText.indexOf(tag);
44
+ if (index >= 0 && (first === null || index < first.index)) {
45
+ first = { index, tag };
46
+ }
47
+ }
48
+ return first;
49
+ }
50
+ safePrefixLength() {
51
+ const maxTagLength = Math.max(...ReasoningTagBuffer.TAGS.map((tag) => tag.length));
52
+ for (let length = 1; length <= Math.min(this.pendingText.length, maxTagLength - 1); length++) {
53
+ const suffix = this.pendingText.slice(-length);
54
+ if (ReasoningTagBuffer.TAGS.some((tag) => tag.startsWith(suffix))) {
55
+ return this.pendingText.length - length;
56
+ }
57
+ }
58
+ return this.pendingText.length;
59
+ }
60
+ }
@@ -0,0 +1,61 @@
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
+ import type { Api, AssistantMessage, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from '@earendil-works/pi-ai';
39
+ import type { ChatSession, PerformanceMetrics } from '@mlx-node/lm';
40
+ import type { DiscoveredModelLike } from '../types.js';
41
+ /**
42
+ * The exact `MlxModelHost` surface the adapter consumes, kept structural
43
+ * so tests can drive the adapter with a scripted fake host. `MlxModelHost`
44
+ * satisfies this interface as-is.
45
+ */
46
+ export interface StreamSimpleHost {
47
+ /** Discovery record for `modelId` (source of the `ModelType` → launch preset). */
48
+ modelInfo(modelId: string): DiscoveredModelLike | undefined;
49
+ /** Atomic resident selection + serialized inference closure (see `MlxModelHost`). */
50
+ runWithResident<T>(modelId: string, fn: (session: ChatSession) => Promise<T>): Promise<T>;
51
+ /** Flag the resident as post-error so the next turn does a full reset (see `MlxModelHost`). */
52
+ markResidentDirty(modelId: string): void;
53
+ /** Read-and-clear the resident's post-error flag; `true` ⇒ full-reset this turn. */
54
+ consumeResidentDirty(modelId: string): boolean;
55
+ /** Drop the resident so the next turn reloads it (post-error reset failure). */
56
+ invalidateResident(modelId: string): void;
57
+ }
58
+ export type PerformanceRecorder = (message: AssistantMessage, performance: PerformanceMetrics) => void;
59
+ export type RootCacheOwnerResolver = () => string | undefined;
60
+ export declare function makeMlxStreamSimple(host: StreamSimpleHost, onPerformance?: PerformanceRecorder, resolveRootCacheOwner?: RootCacheOwnerResolver): (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
61
+ //# sourceMappingURL=stream-adapter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stream-adapter.d.ts","sourceRoot":"","sources":["../../src/provider/stream-adapter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,OAAO,KAAK,EACV,GAAG,EACH,gBAAgB,EAChB,2BAA2B,EAC3B,OAAO,EACP,KAAK,EACL,mBAAmB,EACpB,MAAM,uBAAuB,CAAC;AAE/B,OAAO,KAAK,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAEpE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAOvD;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,kFAAkF;IAClF,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,mBAAmB,GAAG,SAAS,CAAC;IAC5D,qFAAqF;IACrF,eAAe,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IAC1F,+FAA+F;IAC/F,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACzC,oFAAoF;IACpF,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;IAC/C,gFAAgF;IAChF,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3C;AAED,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,kBAAkB,KAAK,IAAI,CAAC;AACvG,MAAM,MAAM,sBAAsB,GAAG,MAAM,MAAM,GAAG,SAAS,CAAC;AAqF9D,wBAAgB,mBAAmB,CACjC,IAAI,EAAE,gBAAgB,EACtB,aAAa,CAAC,EAAE,mBAAmB,EACnC,qBAAqB,CAAC,EAAE,sBAAsB,GAC7C,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,mBAAmB,KAAK,2BAA2B,CAkOrG"}
@@ -0,0 +1,358 @@
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
+ import { createAssistantMessageEventStream } from '@earendil-works/pi-ai';
39
+ import { buildChatConfig, resolveReasoningMode } from './chat-config.js';
40
+ import { contextToChatMessages, toolsToDefinitions } from './convert-messages.js';
41
+ import { coerceErrorMessage } from './error-coercion.js';
42
+ import { emptyUsage, TurnEmitter } from './events.js';
43
+ import { resetPreservingNativeCacheForWarmReuse } from './warm-reuse.js';
44
+ /** Property read that must not throw (poisoned getters on a hostile `Model`). */
45
+ function safeString(read, fallback) {
46
+ try {
47
+ const value = read();
48
+ return typeof value === 'string' ? value : fallback;
49
+ }
50
+ catch {
51
+ return fallback;
52
+ }
53
+ }
54
+ /**
55
+ * Publish the native model's load-time physical context limit onto pi's shared
56
+ * model object. The parent session and every in-process subagent resolve this
57
+ * same object from one `ModelRegistry`, so the first completed model load gives
58
+ * all later turns the correct auto-compaction window without another channel.
59
+ *
60
+ * This is advisory only: the ChatSession preflight remains the correctness
61
+ * backstop for the first turn and for hostile/invalid getters. Never expand a
62
+ * discovery-time limit here, and never let metadata synchronization break an
63
+ * otherwise valid inference turn.
64
+ */
65
+ function publishEffectiveContextWindow(model, session) {
66
+ try {
67
+ const effective = Math.floor(session.contextLimits()?.effectiveWindowTokens ?? 0);
68
+ if (!Number.isSafeInteger(effective) || effective <= 0)
69
+ return;
70
+ model.contextWindow = Math.min(model.contextWindow, effective);
71
+ model.maxTokens = Math.min(model.maxTokens, model.contextWindow);
72
+ }
73
+ catch {
74
+ // Exact native preflight still protects capacity; keep serving the turn.
75
+ }
76
+ }
77
+ /**
78
+ * Publish the loaded model's authoritative image capability onto Pi's shared
79
+ * model object. Discovery stays conservatively text-only; the first resident
80
+ * load upgrades the same object before Pi executes any tool call emitted by
81
+ * that inference turn. Return the native truth separately so a hostile/frozen
82
+ * Pi model object cannot prevent image bytes from reaching the provider.
83
+ */
84
+ function publishImageCapability(model, session) {
85
+ let supportsImages = false;
86
+ try {
87
+ supportsImages = session.supportsImages();
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ try {
93
+ const advertisesImages = model.input.includes('image');
94
+ if (supportsImages && !advertisesImages) {
95
+ model.input = [...model.input, 'image'];
96
+ }
97
+ else if (!supportsImages && advertisesImages) {
98
+ // Pi reuses model objects across resident swaps. Reconcile a stale
99
+ // positive capability without disturbing any other inputs or their
100
+ // order, so its tools do not return image blocks to a text-only model.
101
+ model.input = model.input.filter((input) => input !== 'image');
102
+ }
103
+ }
104
+ catch {
105
+ // Native capability remains authoritative for this turn's conversion.
106
+ }
107
+ return supportsImages;
108
+ }
109
+ /**
110
+ * Minimal terminal `AssistantMessage` for the TurnEmitter-independent
111
+ * failsafe path. Every field read is guarded — this must stay
112
+ * constructible even when the `Model` object itself is hostile (it may be
113
+ * the very reason `TurnEmitter` construction failed).
114
+ */
115
+ function failsafeMessage(model, reason, message) {
116
+ return {
117
+ role: 'assistant',
118
+ content: [],
119
+ api: safeString(() => model.api, 'unknown'),
120
+ provider: safeString(() => model.provider, 'unknown'),
121
+ model: safeString(() => model.id, 'unknown'),
122
+ usage: emptyUsage(),
123
+ stopReason: reason,
124
+ errorMessage: message,
125
+ timestamp: Date.now(),
126
+ };
127
+ }
128
+ export function makeMlxStreamSimple(host, onPerformance, resolveRootCacheOwner) {
129
+ return (model, context, options) => {
130
+ const stream = createAssistantMessageEventStream();
131
+ /**
132
+ * Exactly-one-terminal guard for the WHOLE turn. `TurnEmitter` has its
133
+ * own `finished` flag, but it cannot cover pre-emitter failures or the
134
+ * failsafe path. Once set, late work — including a resident closure
135
+ * that finally runs after an abort-while-queued — must do nothing.
136
+ */
137
+ let terminated = false;
138
+ let emitter;
139
+ let signal;
140
+ let rootCacheOwnerId;
141
+ let resolvedReasoning;
142
+ let detachAbort;
143
+ /**
144
+ * Last-resort terminal, independent of `TurnEmitter` (which may be
145
+ * broken or never constructed). Push/end failures are swallowed — the
146
+ * StreamFn contract forbids throwing into pi and there is no further
147
+ * recovery surface.
148
+ */
149
+ const pushFailsafeTerminal = (reason, message) => {
150
+ try {
151
+ stream.push({ type: 'error', reason, error: failsafeMessage(model, reason, message) });
152
+ stream.end();
153
+ }
154
+ catch {
155
+ // No recovery surface left.
156
+ }
157
+ };
158
+ /**
159
+ * Idempotent terminal: the first caller wins, later callers no-op.
160
+ * Routes through `TurnEmitter` when possible; falls back to the
161
+ * direct-push failsafe when the emitter is missing or throws
162
+ * (defense in depth — `onError` shares the hardened coercion and is
163
+ * not expected to throw).
164
+ */
165
+ const terminalize = (kind, err) => {
166
+ if (terminated)
167
+ return;
168
+ terminated = true;
169
+ detachAbort?.();
170
+ detachAbort = undefined;
171
+ if (kind === 'error') {
172
+ // A native error mid-decode can leave the physical KV ahead of the
173
+ // committed history; flag the resident so the NEXT turn does a full
174
+ // reset (cold prefill) instead of a misaligned warm reuse. Only the
175
+ // error terminal marks dirty — abort / stop / length keep the cache
176
+ // consistent and preserve warm reuse. Guarded: a hostile `model.id`
177
+ // getter must not derail the terminal.
178
+ try {
179
+ host.markResidentDirty(model.id);
180
+ }
181
+ catch {
182
+ // Nothing to mark — fail safe.
183
+ }
184
+ }
185
+ if (emitter) {
186
+ try {
187
+ if (kind === 'aborted') {
188
+ emitter.onAborted();
189
+ }
190
+ else {
191
+ emitter.onError(err);
192
+ }
193
+ return;
194
+ }
195
+ catch {
196
+ // TurnEmitter itself failed — fall through to the failsafe.
197
+ }
198
+ }
199
+ pushFailsafeTerminal(kind, kind === 'aborted' ? 'Request was aborted' : coerceErrorMessage(err));
200
+ };
201
+ const onAbort = () => {
202
+ terminalize('aborted');
203
+ };
204
+ try {
205
+ // Synchronous setup is inside the containment too: a hostile
206
+ // `options`/`Model` getter or a TurnEmitter constructor failure must
207
+ // become a stream terminal, never a synchronous throw into pi.
208
+ signal = options?.signal;
209
+ // Snapshot the top-level owner before this request can queue behind
210
+ // another inference. A later /new or /resume must not relabel an older
211
+ // request that was already submitted under the previous root.
212
+ rootCacheOwnerId = resolveRootCacheOwner?.();
213
+ // Snapshot once: the native config and the replay provenance must describe
214
+ // the same resolved template mode. Presence alone is wrong for Pi's
215
+ // minimal/low levels, both of which resolve to disabled thinking.
216
+ resolvedReasoning = resolveReasoningMode(options?.reasoning);
217
+ emitter = new TurnEmitter(stream, model, onPerformance, resolvedReasoning.thinkingEnabled);
218
+ }
219
+ catch (err) {
220
+ terminalize('error', err);
221
+ return stream;
222
+ }
223
+ const turn = emitter;
224
+ // Abort coverage from here has no gap: pre-check a signal that is
225
+ // already aborted (never engage the host at all), then keep a listener
226
+ // installed across the whole queued/running window so a request parked
227
+ // behind stalled inference/loading still terminates promptly.
228
+ if (signal?.aborted) {
229
+ terminalize('aborted');
230
+ return stream;
231
+ }
232
+ if (signal) {
233
+ const s = signal;
234
+ s.addEventListener('abort', onAbort, { once: true });
235
+ detachAbort = () => {
236
+ s.removeEventListener('abort', onAbort);
237
+ };
238
+ }
239
+ void (async () => {
240
+ let sawNativeFinal = false;
241
+ await host.runWithResident(model.id, async (session) => {
242
+ // Terminated while queued behind earlier inference/loading (or
243
+ // between stages below): the terminal already went out — skip ALL
244
+ // session work (no warm-reset, no prime, no stream).
245
+ if (terminated)
246
+ return;
247
+ publishEffectiveContextWindow(model, session);
248
+ const supportsImages = publishImageCapability(model, session);
249
+ const discovered = host.modelInfo(model.id);
250
+ if (!discovered) {
251
+ throw new Error(`mlx streamSimple: no discovery record for model "${model.id}"`);
252
+ }
253
+ if (host.consumeResidentDirty(model.id)) {
254
+ // Previous turn errored mid-decode: the physical KV may be ahead of
255
+ // the committed history, so a warm reuse would misalign this
256
+ // replay's prefix. Full-reset (clears native caches + history →
257
+ // hit=0 → cold prefill). If the reset itself fails the session is
258
+ // untrustworthy — drop the resident so the next call reloads it.
259
+ try {
260
+ await session.reset();
261
+ }
262
+ catch (err) {
263
+ host.invalidateResident(model.id);
264
+ throw err;
265
+ }
266
+ }
267
+ else {
268
+ await resetPreservingNativeCacheForWarmReuse(session);
269
+ }
270
+ if (terminated)
271
+ return;
272
+ try {
273
+ session.primeHistory(contextToChatMessages(context, supportsImages));
274
+ const config = buildChatConfig(discovered.modelType, options, toolsToDefinitions(context.tools), rootCacheOwnerId, resolvedReasoning);
275
+ for await (const event of session.startFromHistoryStream(config, signal)) {
276
+ if (event.done) {
277
+ if (event.finishReason === 'error') {
278
+ // In-band native error terminal: chat-session yields a `done`
279
+ // event with `finishReason: 'error'` WITHOUT committing a final
280
+ // (no `sawFinal`), so the physical KV may be ahead of the
281
+ // committed history. Mark the resident dirty (synchronously,
282
+ // inside the callback, so a queued turn observes it) and route
283
+ // to onError — sending it to onFinal treats it as success and
284
+ // skips the dirty flag.
285
+ if (!terminated) {
286
+ terminated = true;
287
+ detachAbort?.();
288
+ detachAbort = undefined;
289
+ try {
290
+ host.markResidentDirty(model.id);
291
+ }
292
+ catch {
293
+ // Nothing to mark — fail safe.
294
+ }
295
+ try {
296
+ turn.onError(new Error('native stream reported finishReason=error'));
297
+ }
298
+ catch (err) {
299
+ pushFailsafeTerminal('error', coerceErrorMessage(err));
300
+ }
301
+ }
302
+ }
303
+ else {
304
+ sawNativeFinal = true;
305
+ if (!terminated) {
306
+ terminated = true;
307
+ detachAbort?.();
308
+ detachAbort = undefined;
309
+ try {
310
+ turn.onFinal(event);
311
+ }
312
+ catch (err) {
313
+ pushFailsafeTerminal('error', coerceErrorMessage(err));
314
+ }
315
+ }
316
+ }
317
+ }
318
+ else if (!terminated) {
319
+ turn.onDelta(event);
320
+ }
321
+ }
322
+ }
323
+ catch (err) {
324
+ // A native decode fault thrown mid-stream can leave the physical KV
325
+ // ahead of the committed history. Flag the resident dirty so the NEXT
326
+ // turn full-resets — SYNCHRONOUSLY here, before this callback rejects
327
+ // and `runSerialized` releases the chain, so a queued turn observes
328
+ // dirty === true (the detached `.catch` terminalize runs too late for
329
+ // that). Abort is excluded: a clean cancel realigns the cache (warm
330
+ // reuse stays valid) and its terminal has already fired. Re-throw so
331
+ // the detached `.catch` still terminalizes the stream.
332
+ if (!signal?.aborted) {
333
+ try {
334
+ host.markResidentDirty(model.id);
335
+ }
336
+ catch {
337
+ // Nothing to mark — fail safe.
338
+ }
339
+ }
340
+ throw err;
341
+ }
342
+ });
343
+ if (!terminated && !sawNativeFinal) {
344
+ // An aborted native stream ends cleanly with NO final event; any
345
+ // other final-less ending is a native-protocol violation.
346
+ if (signal?.aborted) {
347
+ terminalize('aborted');
348
+ }
349
+ else {
350
+ terminalize('error', new Error('stream ended without final event'));
351
+ }
352
+ }
353
+ })().catch((err) => {
354
+ terminalize('error', err);
355
+ });
356
+ return stream;
357
+ };
358
+ }
@@ -0,0 +1,30 @@
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 declare class ToolCallTagBuffer {
12
+ private static readonly TAGS;
13
+ private pendingText;
14
+ private _suppressed;
15
+ get suppressed(): boolean;
16
+ /**
17
+ * Feed text in. Returns `safeText` (emit as delta), `tagFound` (a full
18
+ * structural tag was just seen), and `cleanPrefix` (text before the tag
19
+ * when `tagFound` — may contain whitespace; use `.trim()` only for
20
+ * emptiness checks, never for emission).
21
+ */
22
+ push(text: string): {
23
+ safeText: string;
24
+ tagFound: boolean;
25
+ cleanPrefix: string;
26
+ };
27
+ /** Release any held-back text at stream end. */
28
+ flush(): string;
29
+ }
30
+ //# sourceMappingURL=tool-call-buffer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-call-buffer.d.ts","sourceRoot":"","sources":["../../src/provider/tool-call-buffer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAajB;IACX,OAAO,CAAC,WAAW,CAAM;IACzB,OAAO,CAAC,WAAW,CAAS;IAE5B,IAAI,UAAU,IAAI,OAAO,CAExB;IAED;;;;;OAKG;IACH,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,OAAO,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAmC/E;IAED,gDAAgD;IAChD,KAAK,IAAI,MAAM,CAId;CACF"}
@@ -0,0 +1,75 @@
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
+ static 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
+ ];
26
+ pendingText = '';
27
+ _suppressed = false;
28
+ get suppressed() {
29
+ return this._suppressed;
30
+ }
31
+ /**
32
+ * Feed text in. Returns `safeText` (emit as delta), `tagFound` (a full
33
+ * structural tag was just seen), and `cleanPrefix` (text before the tag
34
+ * when `tagFound` — may contain whitespace; use `.trim()` only for
35
+ * emptiness checks, never for emission).
36
+ */
37
+ push(text) {
38
+ if (this._suppressed) {
39
+ return { safeText: '', tagFound: false, cleanPrefix: '' };
40
+ }
41
+ this.pendingText += text;
42
+ let tagIdx = -1;
43
+ for (const tag of ToolCallTagBuffer.TAGS) {
44
+ const idx = this.pendingText.indexOf(tag);
45
+ if (idx >= 0 && (tagIdx < 0 || idx < tagIdx)) {
46
+ tagIdx = idx;
47
+ }
48
+ }
49
+ if (tagIdx >= 0) {
50
+ const cleanPrefix = this.pendingText.slice(0, tagIdx);
51
+ this._suppressed = true;
52
+ this.pendingText = '';
53
+ return { safeText: '', tagFound: true, cleanPrefix };
54
+ }
55
+ // Hold back any suffix that could be the start of the tag.
56
+ let safeLen = this.pendingText.length;
57
+ const maxTagLength = Math.max(...ToolCallTagBuffer.TAGS.map((tag) => tag.length));
58
+ for (let i = 1; i <= Math.min(this.pendingText.length, maxTagLength - 1); i++) {
59
+ const suffix = this.pendingText.slice(-i);
60
+ if (ToolCallTagBuffer.TAGS.some((tag) => tag.startsWith(suffix))) {
61
+ safeLen = this.pendingText.length - i;
62
+ break;
63
+ }
64
+ }
65
+ const safeText = this.pendingText.slice(0, safeLen);
66
+ this.pendingText = this.pendingText.slice(safeLen);
67
+ return { safeText, tagFound: false, cleanPrefix: '' };
68
+ }
69
+ /** Release any held-back text at stream end. */
70
+ flush() {
71
+ const text = this.pendingText;
72
+ this.pendingText = '';
73
+ return text;
74
+ }
75
+ }