@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.
- package/dist/catalog.d.ts +26 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +44 -0
- package/dist/extensions/approval-detail.d.ts +9 -0
- package/dist/extensions/approval-detail.d.ts.map +1 -0
- package/dist/extensions/approval-detail.js +53 -0
- package/dist/extensions/permission-gate.d.ts +30 -0
- package/dist/extensions/permission-gate.d.ts.map +1 -0
- package/dist/extensions/permission-gate.js +309 -0
- package/dist/extensions/subagent.d.ts +82 -0
- package/dist/extensions/subagent.d.ts.map +1 -0
- package/dist/extensions/subagent.js +539 -0
- package/dist/extensions/terminal-title.d.ts +10 -0
- package/dist/extensions/terminal-title.d.ts.map +1 -0
- package/dist/extensions/terminal-title.js +45 -0
- package/dist/extensions/trace-notice.d.ts +11 -0
- package/dist/extensions/trace-notice.d.ts.map +1 -0
- package/dist/extensions/trace-notice.js +34 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/provider/chat-config.d.ts +34 -0
- package/dist/provider/chat-config.d.ts.map +1 -0
- package/dist/provider/chat-config.js +103 -0
- package/dist/provider/convert-messages.d.ts +59 -0
- package/dist/provider/convert-messages.d.ts.map +1 -0
- package/dist/provider/convert-messages.js +248 -0
- package/dist/provider/error-coercion.d.ts +19 -0
- package/dist/provider/error-coercion.d.ts.map +1 -0
- package/dist/provider/error-coercion.js +38 -0
- package/dist/provider/events.d.ts +67 -0
- package/dist/provider/events.d.ts.map +1 -0
- package/dist/provider/events.js +307 -0
- package/dist/provider/index.d.ts +28 -0
- package/dist/provider/index.d.ts.map +1 -0
- package/dist/provider/index.js +64 -0
- package/dist/provider/inference-trace.d.ts +58 -0
- package/dist/provider/inference-trace.d.ts.map +1 -0
- package/dist/provider/inference-trace.js +205 -0
- package/dist/provider/model-host.d.ts +94 -0
- package/dist/provider/model-host.d.ts.map +1 -0
- package/dist/provider/model-host.js +134 -0
- package/dist/provider/model-registry-filter.d.ts +36 -0
- package/dist/provider/model-registry-filter.d.ts.map +1 -0
- package/dist/provider/model-registry-filter.js +82 -0
- package/dist/provider/models.d.ts +35 -0
- package/dist/provider/models.d.ts.map +1 -0
- package/dist/provider/models.js +132 -0
- package/dist/provider/performance-status.d.ts +28 -0
- package/dist/provider/performance-status.d.ts.map +1 -0
- package/dist/provider/performance-status.js +91 -0
- package/dist/provider/reasoning-tag-buffer.d.ts +23 -0
- package/dist/provider/reasoning-tag-buffer.d.ts.map +1 -0
- package/dist/provider/reasoning-tag-buffer.js +60 -0
- package/dist/provider/stream-adapter.d.ts +61 -0
- package/dist/provider/stream-adapter.d.ts.map +1 -0
- package/dist/provider/stream-adapter.js +358 -0
- package/dist/provider/tool-call-buffer.d.ts +30 -0
- package/dist/provider/tool-call-buffer.d.ts.map +1 -0
- package/dist/provider/tool-call-buffer.js +75 -0
- package/dist/provider/warm-reuse.d.ts +73 -0
- package/dist/provider/warm-reuse.d.ts.map +1 -0
- package/dist/provider/warm-reuse.js +88 -0
- package/dist/run-agent.d.ts +62 -0
- package/dist/run-agent.d.ts.map +1 -0
- package/dist/run-agent.js +86 -0
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +42 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `TurnEmitter` — maps one native `ChatStreamEvent` turn onto pi's
|
|
3
|
+
* `AssistantMessageEvent` protocol.
|
|
4
|
+
*
|
|
5
|
+
* Correctness contract (spike-proven):
|
|
6
|
+
* - pi's agent loop takes the final message from `stream.result()` and
|
|
7
|
+
* reads `stopReason` off THAT message — the `done` event's `reason`
|
|
8
|
+
* field is discarded. Both are still emitted protocol-correct.
|
|
9
|
+
* - Aborted native streams end with NO final event, so the caller must
|
|
10
|
+
* invoke {@link TurnEmitter.onAborted} to synthesize the terminal
|
|
11
|
+
* AssistantMessage (stopReason 'aborted', accumulated deltas intact).
|
|
12
|
+
* - Every method is throw-safe: the pi StreamFn contract does not allow
|
|
13
|
+
* the emitter to throw, so internal failures route to {@link onError}.
|
|
14
|
+
*/
|
|
15
|
+
import { coerceErrorMessage } from './error-coercion.js';
|
|
16
|
+
import { ReasoningTagBuffer } from './reasoning-tag-buffer.js';
|
|
17
|
+
import { ToolCallTagBuffer } from './tool-call-buffer.js';
|
|
18
|
+
/**
|
|
19
|
+
* All-zero usage. Shared with the stream adapter's TurnEmitter-independent
|
|
20
|
+
* failsafe terminal, so it must stay trivially non-throwing.
|
|
21
|
+
*/
|
|
22
|
+
export function emptyUsage() {
|
|
23
|
+
return {
|
|
24
|
+
input: 0,
|
|
25
|
+
output: 0,
|
|
26
|
+
cacheRead: 0,
|
|
27
|
+
cacheWrite: 0,
|
|
28
|
+
totalTokens: 0,
|
|
29
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* pi auto-compaction thresholds on `usage.totalTokens` vs
|
|
34
|
+
* `model.contextWindow`, so these numbers are load-bearing:
|
|
35
|
+
* input excludes the cache-served prefix, totalTokens is the full
|
|
36
|
+
* prompt + completion. All costs are 0 for local inference.
|
|
37
|
+
*/
|
|
38
|
+
function usageFromFinal(final) {
|
|
39
|
+
const cachedTokens = final.cachedTokens ?? 0;
|
|
40
|
+
return {
|
|
41
|
+
input: Math.max(0, final.promptTokens - cachedTokens),
|
|
42
|
+
output: final.numTokens,
|
|
43
|
+
cacheRead: cachedTokens,
|
|
44
|
+
cacheWrite: 0,
|
|
45
|
+
reasoning: final.reasoningTokens,
|
|
46
|
+
totalTokens: final.promptTokens + final.numTokens,
|
|
47
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Native `ToolCallResult` → pi `ToolCall`.
|
|
52
|
+
*
|
|
53
|
+
* Non-ok results (invalid_json / missing_name / parse_error) become a
|
|
54
|
+
* deliberately-invalid pi ToolCall so pi's own tool validation fails it
|
|
55
|
+
* and feeds an error tool result back to the model — pi owns that retry
|
|
56
|
+
* loop (spike-proven).
|
|
57
|
+
*/
|
|
58
|
+
function toPiToolCall(call) {
|
|
59
|
+
if (call.status === 'ok') {
|
|
60
|
+
const args = typeof call.arguments === 'object' && call.arguments !== null ? call.arguments : {};
|
|
61
|
+
return { type: 'toolCall', id: call.id, name: call.name, arguments: args };
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
type: 'toolCall',
|
|
65
|
+
id: call.id,
|
|
66
|
+
name: call.name || 'malformed_tool_call',
|
|
67
|
+
arguments: { raw: call.rawContent, error: call.error },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export class TurnEmitter {
|
|
71
|
+
onPerformance;
|
|
72
|
+
stream;
|
|
73
|
+
partial;
|
|
74
|
+
textBuffer = new ToolCallTagBuffer();
|
|
75
|
+
thinkingBuffer = new ReasoningTagBuffer();
|
|
76
|
+
/**
|
|
77
|
+
* Leading whitespace-only text parked before any text block exists, so
|
|
78
|
+
* a `"\n\n"` emitted right before `<tool_call>` markup never ratifies a
|
|
79
|
+
* whitespace-only text content block (mirrors the server endpoints).
|
|
80
|
+
* Joined onto the first non-whitespace text; dropped at terminal time.
|
|
81
|
+
*/
|
|
82
|
+
pendingLeadingWhitespace = '';
|
|
83
|
+
openBlock = null;
|
|
84
|
+
finished = false;
|
|
85
|
+
constructor(stream, model, onPerformance, thinkingEnabled) {
|
|
86
|
+
this.onPerformance = onPerformance;
|
|
87
|
+
this.stream = stream;
|
|
88
|
+
this.partial = {
|
|
89
|
+
role: 'assistant',
|
|
90
|
+
content: [],
|
|
91
|
+
api: model.api,
|
|
92
|
+
provider: model.provider,
|
|
93
|
+
model: model.id,
|
|
94
|
+
usage: emptyUsage(),
|
|
95
|
+
stopReason: 'stop',
|
|
96
|
+
timestamp: Date.now(),
|
|
97
|
+
};
|
|
98
|
+
// Pi persists provider messages as JSON without a provider-specific
|
|
99
|
+
// metadata bag. Keep this small enumerable provenance field on the
|
|
100
|
+
// assistant object so a later full-history replay can distinguish a
|
|
101
|
+
// disabled-thinking empty channel from an enabled-thinking turn that
|
|
102
|
+
// simply emitted no reasoning. Unknown fields survive Pi's session
|
|
103
|
+
// JSONL round-trip and are ignored by other providers.
|
|
104
|
+
if (thinkingEnabled !== undefined) {
|
|
105
|
+
this.partial.mlxThinkingEnabled = thinkingEnabled;
|
|
106
|
+
}
|
|
107
|
+
stream.push({ type: 'start', partial: this.partial });
|
|
108
|
+
}
|
|
109
|
+
onDelta(delta) {
|
|
110
|
+
if (this.finished)
|
|
111
|
+
return;
|
|
112
|
+
try {
|
|
113
|
+
if (delta.isReasoning === true) {
|
|
114
|
+
this.appendThinking(this.thinkingBuffer.push(delta.text));
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
// A reasoning suffix that only resembled a partial protocol tag is
|
|
118
|
+
// ordinary text. Release it before opening the visible-text block.
|
|
119
|
+
this.appendThinking(this.thinkingBuffer.flush());
|
|
120
|
+
// Text routes through the tag buffer: partial structural markup
|
|
121
|
+
// (`<tool_call>` etc.) must never leak into pi-visible text.
|
|
122
|
+
const { safeText, tagFound, cleanPrefix } = this.textBuffer.push(delta.text);
|
|
123
|
+
if (tagFound) {
|
|
124
|
+
this.appendVisibleText(cleanPrefix);
|
|
125
|
+
}
|
|
126
|
+
else if (safeText) {
|
|
127
|
+
this.appendVisibleText(safeText);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
this.onError(err);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
onFinal(final) {
|
|
136
|
+
if (this.finished)
|
|
137
|
+
return;
|
|
138
|
+
try {
|
|
139
|
+
this.partial.usage = usageFromFinal(final);
|
|
140
|
+
if (final.finishReason === 'error') {
|
|
141
|
+
this.finishWithError('error', 'model reported finishReason=error');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// Release held-back suffixes from both structural-tag buffers, then
|
|
145
|
+
// close the open block.
|
|
146
|
+
this.appendThinking(this.thinkingBuffer.flush());
|
|
147
|
+
this.appendVisibleText(this.textBuffer.flush());
|
|
148
|
+
this.closeOpenBlock();
|
|
149
|
+
let sawOkToolCall = false;
|
|
150
|
+
for (const call of final.toolCalls) {
|
|
151
|
+
if (call.status === 'ok')
|
|
152
|
+
sawOkToolCall = true;
|
|
153
|
+
const toolCall = toPiToolCall(call);
|
|
154
|
+
this.partial.content.push(toolCall);
|
|
155
|
+
const contentIndex = this.partial.content.length - 1;
|
|
156
|
+
this.stream.push({ type: 'toolcall_start', contentIndex, partial: this.partial });
|
|
157
|
+
this.stream.push({
|
|
158
|
+
type: 'toolcall_delta',
|
|
159
|
+
contentIndex,
|
|
160
|
+
delta: JSON.stringify(toolCall.arguments),
|
|
161
|
+
partial: this.partial,
|
|
162
|
+
});
|
|
163
|
+
this.stream.push({ type: 'toolcall_end', contentIndex, toolCall, partial: this.partial });
|
|
164
|
+
}
|
|
165
|
+
const reason = sawOkToolCall ? 'toolUse' : final.finishReason === 'length' ? 'length' : 'stop';
|
|
166
|
+
this.partial.stopReason = reason;
|
|
167
|
+
if (final.performance !== undefined && this.onPerformance !== undefined) {
|
|
168
|
+
try {
|
|
169
|
+
this.onPerformance(this.partial, final.performance);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// Footer telemetry is best-effort and must never break inference.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
this.finished = true;
|
|
176
|
+
this.stream.push({ type: 'done', reason, message: this.partial });
|
|
177
|
+
this.stream.end();
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
this.onError(err);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Synthesize the terminal message for an aborted native stream (which
|
|
185
|
+
* ends with no final event). Mirrors pi's provider abort pattern:
|
|
186
|
+
* `{type:'error', reason:'aborted', error: <partial message>}` with all
|
|
187
|
+
* accumulated text/thinking preserved on the message.
|
|
188
|
+
*/
|
|
189
|
+
onAborted() {
|
|
190
|
+
if (this.finished)
|
|
191
|
+
return;
|
|
192
|
+
this.finishWithError('aborted', 'Request was aborted');
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Terminal for internal/adapter failures. `err` is untrusted: coercion
|
|
196
|
+
* is fully guarded (shared `coerceErrorMessage`), so even a revoked
|
|
197
|
+
* Proxy or a poisoned `message` getter cannot throw out of here.
|
|
198
|
+
*/
|
|
199
|
+
onError(err) {
|
|
200
|
+
if (this.finished)
|
|
201
|
+
return;
|
|
202
|
+
this.finishWithError('error', coerceErrorMessage(err));
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Shared terminal path for every non-`done` ending (abort, native
|
|
206
|
+
* finishReason=error, internal failure). Recovers any held-back buffer
|
|
207
|
+
* residue and closes the open text/thinking block BEFORE emitting the
|
|
208
|
+
* terminal event — a stream must never end `text_start/text_delta/error`
|
|
209
|
+
* with no `text_end` (pi's reference providers balance all blocks
|
|
210
|
+
* before terminals).
|
|
211
|
+
*/
|
|
212
|
+
finishWithError(reason, message) {
|
|
213
|
+
this.finished = true;
|
|
214
|
+
try {
|
|
215
|
+
try {
|
|
216
|
+
this.appendThinking(this.thinkingBuffer.flush());
|
|
217
|
+
this.appendVisibleText(this.textBuffer.flush());
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// Preserving buffered residue is best-effort; the block close and
|
|
221
|
+
// terminal event below must still go out.
|
|
222
|
+
}
|
|
223
|
+
this.closeOpenBlock();
|
|
224
|
+
this.partial.stopReason = reason;
|
|
225
|
+
this.partial.errorMessage = message;
|
|
226
|
+
this.stream.push({ type: 'error', reason, error: this.partial });
|
|
227
|
+
this.stream.end();
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// The StreamFn contract forbids throwing; a push/end failure here
|
|
231
|
+
// has no further recovery surface.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
appendThinking(text) {
|
|
235
|
+
if (!text)
|
|
236
|
+
return;
|
|
237
|
+
let block = this.openBlock;
|
|
238
|
+
if (block?.type !== 'thinking') {
|
|
239
|
+
this.closeOpenBlock();
|
|
240
|
+
block = { type: 'thinking', thinking: '' };
|
|
241
|
+
this.partial.content.push(block);
|
|
242
|
+
this.openBlock = block;
|
|
243
|
+
this.stream.push({ type: 'thinking_start', contentIndex: this.blockIndex(), partial: this.partial });
|
|
244
|
+
}
|
|
245
|
+
block.thinking += text;
|
|
246
|
+
this.stream.push({
|
|
247
|
+
type: 'thinking_delta',
|
|
248
|
+
contentIndex: this.blockIndex(),
|
|
249
|
+
delta: text,
|
|
250
|
+
partial: this.partial,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
appendVisibleText(text) {
|
|
254
|
+
if (this.openBlock?.type === 'text') {
|
|
255
|
+
if (!text)
|
|
256
|
+
return;
|
|
257
|
+
this.openBlock.text += text;
|
|
258
|
+
this.stream.push({ type: 'text_delta', contentIndex: this.blockIndex(), delta: text, partial: this.partial });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// No text block open yet: whitespace-only text stays parked so it can
|
|
262
|
+
// front real text later or be dropped silently at tag/terminal time.
|
|
263
|
+
const combined = this.pendingLeadingWhitespace + text;
|
|
264
|
+
if (combined.trim().length === 0) {
|
|
265
|
+
this.pendingLeadingWhitespace = combined;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
this.pendingLeadingWhitespace = '';
|
|
269
|
+
this.closeOpenBlock();
|
|
270
|
+
const block = { type: 'text', text: '' };
|
|
271
|
+
this.partial.content.push(block);
|
|
272
|
+
this.openBlock = block;
|
|
273
|
+
this.stream.push({ type: 'text_start', contentIndex: this.blockIndex(), partial: this.partial });
|
|
274
|
+
block.text += combined;
|
|
275
|
+
this.stream.push({
|
|
276
|
+
type: 'text_delta',
|
|
277
|
+
contentIndex: this.blockIndex(),
|
|
278
|
+
delta: combined,
|
|
279
|
+
partial: this.partial,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
closeOpenBlock() {
|
|
283
|
+
const block = this.openBlock;
|
|
284
|
+
if (!block)
|
|
285
|
+
return;
|
|
286
|
+
this.openBlock = null;
|
|
287
|
+
if (block.type === 'text') {
|
|
288
|
+
this.stream.push({
|
|
289
|
+
type: 'text_end',
|
|
290
|
+
contentIndex: this.partial.content.indexOf(block),
|
|
291
|
+
content: block.text,
|
|
292
|
+
partial: this.partial,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
this.stream.push({
|
|
297
|
+
type: 'thinking_end',
|
|
298
|
+
contentIndex: this.partial.content.indexOf(block),
|
|
299
|
+
content: block.thinking,
|
|
300
|
+
partial: this.partial,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
blockIndex() {
|
|
305
|
+
return this.partial.content.length - 1;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createMlxProviderExtension` — the pi inline extension that registers
|
|
3
|
+
* the in-process `mlx` provider.
|
|
4
|
+
*
|
|
5
|
+
* Task 8's `runAgent` passes the returned extension into pi's `main()`
|
|
6
|
+
* via `extensionFactories`; pi calls the factory during extension load,
|
|
7
|
+
* and `registerProvider` makes every discovered local model resolvable
|
|
8
|
+
* as `mlx/<dir-name>` with no /login (the literal apiKey marks the
|
|
9
|
+
* models available).
|
|
10
|
+
*
|
|
11
|
+
* Import discipline (load-bearing): pi is import-order sensitive to its
|
|
12
|
+
* config env vars, so this module — which the CLI imports BEFORE those
|
|
13
|
+
* env vars are set — must not runtime-import `@earendil-works/pi-coding-agent`
|
|
14
|
+
* at module top level. Only type-only pi imports appear here; the
|
|
15
|
+
* `ExtensionAPI` value arrives as the factory argument.
|
|
16
|
+
*/
|
|
17
|
+
import type { InlineExtension } from '@earendil-works/pi-coding-agent';
|
|
18
|
+
import { MlxModelHost } from './model-host.js';
|
|
19
|
+
import type { MlxModelInfo } from './models.js';
|
|
20
|
+
/**
|
|
21
|
+
* Build the `mlx-provider` inline extension serving `models`. The host
|
|
22
|
+
* (one per process — it owns the single GPU-resident model) is created
|
|
23
|
+
* eagerly so repeated factory invocations can never spawn a second
|
|
24
|
+
* host, but stays lazy about weights: nothing loads until the first
|
|
25
|
+
* `streamSimple` call.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createMlxProviderExtension(models: MlxModelInfo[], host?: MlxModelHost): InlineExtension;
|
|
28
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/provider/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAgB,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAErF,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAIhD;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,YAAY,EAAE,EAAE,IAAI,CAAC,EAAE,YAAY,GAAG,eAAe,CAqCvG"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createMlxProviderExtension` — the pi inline extension that registers
|
|
3
|
+
* the in-process `mlx` provider.
|
|
4
|
+
*
|
|
5
|
+
* Task 8's `runAgent` passes the returned extension into pi's `main()`
|
|
6
|
+
* via `extensionFactories`; pi calls the factory during extension load,
|
|
7
|
+
* and `registerProvider` makes every discovered local model resolvable
|
|
8
|
+
* as `mlx/<dir-name>` with no /login (the literal apiKey marks the
|
|
9
|
+
* models available).
|
|
10
|
+
*
|
|
11
|
+
* Import discipline (load-bearing): pi is import-order sensitive to its
|
|
12
|
+
* config env vars, so this module — which the CLI imports BEFORE those
|
|
13
|
+
* env vars are set — must not runtime-import `@earendil-works/pi-coding-agent`
|
|
14
|
+
* at module top level. Only type-only pi imports appear here; the
|
|
15
|
+
* `ExtensionAPI` value arrives as the factory argument.
|
|
16
|
+
*/
|
|
17
|
+
import { MlxModelHost } from './model-host.js';
|
|
18
|
+
import { PerformanceStatus } from './performance-status.js';
|
|
19
|
+
import { makeMlxStreamSimple } from './stream-adapter.js';
|
|
20
|
+
/**
|
|
21
|
+
* Build the `mlx-provider` inline extension serving `models`. The host
|
|
22
|
+
* (one per process — it owns the single GPU-resident model) is created
|
|
23
|
+
* eagerly so repeated factory invocations can never spawn a second
|
|
24
|
+
* host, but stays lazy about weights: nothing loads until the first
|
|
25
|
+
* `streamSimple` call.
|
|
26
|
+
*/
|
|
27
|
+
export function createMlxProviderExtension(models, host) {
|
|
28
|
+
const resolvedHost = host ?? new MlxModelHost(models.map((m) => m.discovered));
|
|
29
|
+
const performanceStatus = new PerformanceStatus();
|
|
30
|
+
// This closure outlives Pi runtime replacement. Pi creates a replacement
|
|
31
|
+
// runtime for /new and /resume and reruns inline extension factories; each
|
|
32
|
+
// new factory's session_start updates the root while child sessions keep
|
|
33
|
+
// using this registered stream.
|
|
34
|
+
let rootCacheOwnerId;
|
|
35
|
+
const streamSimple = makeMlxStreamSimple(resolvedHost, performanceStatus.record, () => rootCacheOwnerId);
|
|
36
|
+
return {
|
|
37
|
+
name: 'mlx-provider',
|
|
38
|
+
factory: (pi) => {
|
|
39
|
+
pi.registerProvider('mlx', {
|
|
40
|
+
api: 'mlx',
|
|
41
|
+
baseUrl: 'mlx://local',
|
|
42
|
+
apiKey: 'mlx-local',
|
|
43
|
+
streamSimple,
|
|
44
|
+
models: models.map((m) => m.piModel),
|
|
45
|
+
});
|
|
46
|
+
pi.on('session_start', (_event, ctx) => {
|
|
47
|
+
rootCacheOwnerId = ctx.sessionManager.getSessionId();
|
|
48
|
+
});
|
|
49
|
+
pi.on('message_end', (event, ctx) => {
|
|
50
|
+
performanceStatus.showMessage(event, ctx);
|
|
51
|
+
});
|
|
52
|
+
// Do not clear on turn_start. Pi emits a fresh turn after every tool
|
|
53
|
+
// result, before the next inference has terminal metrics to replace the
|
|
54
|
+
// completed sample; clearing here makes the footer disappear precisely
|
|
55
|
+
// while a long tool-follow-up prefill is running.
|
|
56
|
+
pi.on('model_select', (_event, ctx) => {
|
|
57
|
+
performanceStatus.clear(ctx);
|
|
58
|
+
});
|
|
59
|
+
pi.on('session_shutdown', (_event, ctx) => {
|
|
60
|
+
performanceStatus.clear(ctx);
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Privacy-safe, best-effort inference tracing for the in-process mlx agent.
|
|
3
|
+
*
|
|
4
|
+
* The trace is deliberately metadata-only. Callers can only supply fields from
|
|
5
|
+
* the allowlist below; prompt/output text, tool payloads, paths, token ids, and
|
|
6
|
+
* error messages have no serialization path. Tracing is enabled only when
|
|
7
|
+
* `MLX_INFERENCE_TRACE` is explicitly truthy and
|
|
8
|
+
* `MLX_INFERENCE_TRACE_FILE` is non-empty.
|
|
9
|
+
*
|
|
10
|
+
* Writes are synchronous because this is a bounded, high-level lifecycle trace
|
|
11
|
+
* (roughly a dozen records per request), not a token/layer trace. Every write is
|
|
12
|
+
* swallowed on failure so observability can never change inference behavior.
|
|
13
|
+
*/
|
|
14
|
+
export type InferenceTraceEvent = 'request.created' | 'host.queue.enter' | 'host.start' | 'host.resident' | 'host.load.start' | 'host.load.end' | 'host.callback.end' | 'session.reset' | 'session.prime' | 'session.config' | 'native.stream.start' | 'native.stream.first_delta' | 'request.terminal';
|
|
15
|
+
export type InferenceTraceStatus = 'ok' | 'error' | 'aborted';
|
|
16
|
+
export interface InferenceTraceFields {
|
|
17
|
+
status?: InferenceTraceStatus;
|
|
18
|
+
resident?: 'hit' | 'load' | 'swap';
|
|
19
|
+
resetMode?: 'preserve' | 'full';
|
|
20
|
+
finishReason?: 'stop' | 'length' | 'cancelled' | 'error' | 'other';
|
|
21
|
+
reasoningEffort?: 'none' | 'low' | 'medium' | 'high';
|
|
22
|
+
modelType?: 'gemma4' | 'qwen3' | 'qwen3_5' | 'qwen3_5_moe' | 'lfm2' | 'lfm2_moe' | 'other';
|
|
23
|
+
streaming?: boolean;
|
|
24
|
+
reportPerformance?: boolean;
|
|
25
|
+
isReasoning?: boolean;
|
|
26
|
+
durationMs?: number;
|
|
27
|
+
queueMs?: number;
|
|
28
|
+
messageCount?: number;
|
|
29
|
+
toolCount?: number;
|
|
30
|
+
maxNewTokens?: number;
|
|
31
|
+
promptTokens?: number;
|
|
32
|
+
cachedTokens?: number;
|
|
33
|
+
generatedTokens?: number;
|
|
34
|
+
reasoningTokens?: number;
|
|
35
|
+
ttftMs?: number;
|
|
36
|
+
prefillTokensPerSecond?: number;
|
|
37
|
+
decodeTokensPerSecond?: number;
|
|
38
|
+
mtpMeanAcceptedTokens?: number;
|
|
39
|
+
mtpMeanAcceptedTokensTotal?: number;
|
|
40
|
+
mtpCycles?: number;
|
|
41
|
+
mtpMeanDepth?: number;
|
|
42
|
+
mtpAcceptanceByPosition?: readonly number[];
|
|
43
|
+
}
|
|
44
|
+
export interface InferenceTraceSink {
|
|
45
|
+
write(line: string): void;
|
|
46
|
+
}
|
|
47
|
+
export declare class InferenceTrace {
|
|
48
|
+
private readonly sink;
|
|
49
|
+
readonly requestId: string;
|
|
50
|
+
private terminalWritten;
|
|
51
|
+
constructor(sink?: InferenceTraceSink | undefined);
|
|
52
|
+
emit(event: InferenceTraceEvent, fields?: InferenceTraceFields): void;
|
|
53
|
+
terminal(status: InferenceTraceStatus, fields?: InferenceTraceFields): void;
|
|
54
|
+
}
|
|
55
|
+
export declare function createInferenceTrace(): InferenceTrace;
|
|
56
|
+
export declare function classifyFinishReason(value: string): NonNullable<InferenceTraceFields['finishReason']>;
|
|
57
|
+
export declare function classifyModelType(value: string): NonNullable<InferenceTraceFields['modelType']>;
|
|
58
|
+
//# sourceMappingURL=inference-trace.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"inference-trace.d.ts","sourceRoot":"","sources":["../../src/provider/inference-trace.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAIH,MAAM,MAAM,mBAAmB,GAC3B,iBAAiB,GACjB,kBAAkB,GAClB,YAAY,GACZ,eAAe,GACf,iBAAiB,GACjB,eAAe,GACf,mBAAmB,GACnB,eAAe,GACf,eAAe,GACf,gBAAgB,GAChB,qBAAqB,GACrB,2BAA2B,GAC3B,kBAAkB,CAAC;AAEvB,MAAM,MAAM,oBAAoB,GAAG,IAAI,GAAG,OAAO,GAAG,SAAS,CAAC;AAE9D,MAAM,WAAW,oBAAoB;IACnC,MAAM,CAAC,EAAE,oBAAoB,CAAC;IAC9B,QAAQ,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,MAAM,CAAC;IACnC,SAAS,CAAC,EAAE,UAAU,GAAG,MAAM,CAAC;IAChC,YAAY,CAAC,EAAE,MAAM,GAAG,QAAQ,GAAG,WAAW,GAAG,OAAO,GAAG,OAAO,CAAC;IACnE,eAAe,CAAC,EAAE,MAAM,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IACrD,SAAS,CAAC,EACN,QAAQ,GACR,OAAO,GACP,SAAS,GACT,aAAa,GACb,MAAM,GACN,UAAU,GACV,OAAO,CAAC;IACZ,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,0BAA0B,CAAC,EAAE,MAAM,CAAC;IACpC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uBAAuB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC7C;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AA+ID,qBAAa,cAAc;IAIb,OAAO,CAAC,QAAQ,CAAC,IAAI;IAHjC,QAAQ,CAAC,SAAS,SAAmB;IACrC,OAAO,CAAC,eAAe,CAAS;IAEhC,YAA6B,IAAI,GAAE,kBAAkB,GAAG,SAAyB,EAAI;IAErF,IAAI,CAAC,KAAK,EAAE,mBAAmB,EAAE,MAAM,GAAE,oBAAyB,GAAG,IAAI,CAiBxE;IAED,QAAQ,CAAC,MAAM,EAAE,oBAAoB,EAAE,MAAM,GAAE,oBAAyB,GAAG,IAAI,CAI9E;CACF;AAED,wBAAgB,oBAAoB,IAAI,cAAc,CAErD;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,CAAC,oBAAoB,CAAC,cAAc,CAAC,CAAC,CAUrG;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,CAAC,oBAAoB,CAAC,WAAW,CAAC,CAAC,CAY/F"}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Privacy-safe, best-effort inference tracing for the in-process mlx agent.
|
|
3
|
+
*
|
|
4
|
+
* The trace is deliberately metadata-only. Callers can only supply fields from
|
|
5
|
+
* the allowlist below; prompt/output text, tool payloads, paths, token ids, and
|
|
6
|
+
* error messages have no serialization path. Tracing is enabled only when
|
|
7
|
+
* `MLX_INFERENCE_TRACE` is explicitly truthy and
|
|
8
|
+
* `MLX_INFERENCE_TRACE_FILE` is non-empty.
|
|
9
|
+
*
|
|
10
|
+
* Writes are synchronous because this is a bounded, high-level lifecycle trace
|
|
11
|
+
* (roughly a dozen records per request), not a token/layer trace. Every write is
|
|
12
|
+
* swallowed on failure so observability can never change inference behavior.
|
|
13
|
+
*/
|
|
14
|
+
import { appendFileSync } from 'node:fs';
|
|
15
|
+
const EVENTS = new Set([
|
|
16
|
+
'request.created',
|
|
17
|
+
'host.queue.enter',
|
|
18
|
+
'host.start',
|
|
19
|
+
'host.resident',
|
|
20
|
+
'host.load.start',
|
|
21
|
+
'host.load.end',
|
|
22
|
+
'host.callback.end',
|
|
23
|
+
'session.reset',
|
|
24
|
+
'session.prime',
|
|
25
|
+
'session.config',
|
|
26
|
+
'native.stream.start',
|
|
27
|
+
'native.stream.first_delta',
|
|
28
|
+
'request.terminal',
|
|
29
|
+
]);
|
|
30
|
+
const STATUS = new Set(['ok', 'error', 'aborted']);
|
|
31
|
+
const RESIDENT = new Set(['hit', 'load', 'swap']);
|
|
32
|
+
const RESET_MODE = new Set(['preserve', 'full']);
|
|
33
|
+
const FINISH_REASON = new Set([
|
|
34
|
+
'stop',
|
|
35
|
+
'length',
|
|
36
|
+
'cancelled',
|
|
37
|
+
'error',
|
|
38
|
+
'other',
|
|
39
|
+
]);
|
|
40
|
+
const REASONING_EFFORT = new Set([
|
|
41
|
+
'none',
|
|
42
|
+
'low',
|
|
43
|
+
'medium',
|
|
44
|
+
'high',
|
|
45
|
+
]);
|
|
46
|
+
const MODEL_TYPE = new Set([
|
|
47
|
+
'gemma4',
|
|
48
|
+
'qwen3',
|
|
49
|
+
'qwen3_5',
|
|
50
|
+
'qwen3_5_moe',
|
|
51
|
+
'lfm2',
|
|
52
|
+
'lfm2_moe',
|
|
53
|
+
'other',
|
|
54
|
+
]);
|
|
55
|
+
let requestSequence = 0;
|
|
56
|
+
let eventSequence = 0;
|
|
57
|
+
function envFlagEnabled(value) {
|
|
58
|
+
if (value == null)
|
|
59
|
+
return false;
|
|
60
|
+
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
|
|
61
|
+
}
|
|
62
|
+
function defaultSink() {
|
|
63
|
+
if (!envFlagEnabled(process.env.MLX_INFERENCE_TRACE))
|
|
64
|
+
return undefined;
|
|
65
|
+
const path = process.env.MLX_INFERENCE_TRACE_FILE?.trim();
|
|
66
|
+
if (!path)
|
|
67
|
+
return undefined;
|
|
68
|
+
return {
|
|
69
|
+
write(line) {
|
|
70
|
+
appendFileSync(path, line, { encoding: 'utf8', flag: 'a', mode: 0o600 });
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function nextRequestId() {
|
|
75
|
+
requestSequence += 1;
|
|
76
|
+
return `agent-${process.pid.toString(36)}-${requestSequence.toString(36)}`;
|
|
77
|
+
}
|
|
78
|
+
function nonNegativeFinite(value) {
|
|
79
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
80
|
+
}
|
|
81
|
+
function nonNegativeInteger(value) {
|
|
82
|
+
const n = nonNegativeFinite(value);
|
|
83
|
+
return n !== undefined && Number.isSafeInteger(n) ? n : undefined;
|
|
84
|
+
}
|
|
85
|
+
function copyNumber(out, fields, key, integer) {
|
|
86
|
+
const value = integer ? nonNegativeInteger(fields[key]) : nonNegativeFinite(fields[key]);
|
|
87
|
+
if (value !== undefined)
|
|
88
|
+
out[key] = value;
|
|
89
|
+
}
|
|
90
|
+
/** Runtime allowlist: protects the file even if an untyped caller supplies extras. */
|
|
91
|
+
function sanitizeFields(fields) {
|
|
92
|
+
const out = {};
|
|
93
|
+
if (fields.status !== undefined && STATUS.has(fields.status))
|
|
94
|
+
out.status = fields.status;
|
|
95
|
+
if (fields.resident !== undefined && RESIDENT.has(fields.resident))
|
|
96
|
+
out.resident = fields.resident;
|
|
97
|
+
if (fields.resetMode !== undefined && RESET_MODE.has(fields.resetMode))
|
|
98
|
+
out.resetMode = fields.resetMode;
|
|
99
|
+
if (fields.finishReason !== undefined && FINISH_REASON.has(fields.finishReason)) {
|
|
100
|
+
out.finishReason = fields.finishReason;
|
|
101
|
+
}
|
|
102
|
+
if (fields.reasoningEffort !== undefined && REASONING_EFFORT.has(fields.reasoningEffort)) {
|
|
103
|
+
out.reasoningEffort = fields.reasoningEffort;
|
|
104
|
+
}
|
|
105
|
+
if (fields.modelType !== undefined && MODEL_TYPE.has(fields.modelType))
|
|
106
|
+
out.modelType = fields.modelType;
|
|
107
|
+
for (const key of ['streaming', 'reportPerformance', 'isReasoning']) {
|
|
108
|
+
if (typeof fields[key] === 'boolean')
|
|
109
|
+
out[key] = fields[key];
|
|
110
|
+
}
|
|
111
|
+
for (const key of [
|
|
112
|
+
'durationMs',
|
|
113
|
+
'queueMs',
|
|
114
|
+
'ttftMs',
|
|
115
|
+
'prefillTokensPerSecond',
|
|
116
|
+
'decodeTokensPerSecond',
|
|
117
|
+
'mtpMeanAcceptedTokens',
|
|
118
|
+
'mtpMeanAcceptedTokensTotal',
|
|
119
|
+
'mtpMeanDepth',
|
|
120
|
+
]) {
|
|
121
|
+
copyNumber(out, fields, key, false);
|
|
122
|
+
}
|
|
123
|
+
for (const key of [
|
|
124
|
+
'messageCount',
|
|
125
|
+
'toolCount',
|
|
126
|
+
'maxNewTokens',
|
|
127
|
+
'promptTokens',
|
|
128
|
+
'cachedTokens',
|
|
129
|
+
'generatedTokens',
|
|
130
|
+
'reasoningTokens',
|
|
131
|
+
'mtpCycles',
|
|
132
|
+
]) {
|
|
133
|
+
copyNumber(out, fields, key, true);
|
|
134
|
+
}
|
|
135
|
+
if (Array.isArray(fields.mtpAcceptanceByPosition)) {
|
|
136
|
+
const values = fields.mtpAcceptanceByPosition
|
|
137
|
+
.slice(0, 16)
|
|
138
|
+
.map(nonNegativeFinite)
|
|
139
|
+
.filter((value) => value !== undefined);
|
|
140
|
+
if (values.length > 0)
|
|
141
|
+
out.mtpAcceptanceByPosition = values;
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
export class InferenceTrace {
|
|
146
|
+
sink;
|
|
147
|
+
requestId = nextRequestId();
|
|
148
|
+
terminalWritten = false;
|
|
149
|
+
constructor(sink = defaultSink()) {
|
|
150
|
+
this.sink = sink;
|
|
151
|
+
}
|
|
152
|
+
emit(event, fields = {}) {
|
|
153
|
+
if (!this.sink || !EVENTS.has(event))
|
|
154
|
+
return;
|
|
155
|
+
try {
|
|
156
|
+
eventSequence += 1;
|
|
157
|
+
const record = {
|
|
158
|
+
timestamp: new Date().toISOString(),
|
|
159
|
+
pid: process.pid,
|
|
160
|
+
seq: eventSequence,
|
|
161
|
+
requestId: this.requestId,
|
|
162
|
+
component: 'agent',
|
|
163
|
+
event,
|
|
164
|
+
...sanitizeFields(fields),
|
|
165
|
+
};
|
|
166
|
+
this.sink.write(`${JSON.stringify(record)}\n`);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// Diagnostics are best-effort and must never affect inference.
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
terminal(status, fields = {}) {
|
|
173
|
+
if (this.terminalWritten)
|
|
174
|
+
return;
|
|
175
|
+
this.terminalWritten = true;
|
|
176
|
+
this.emit('request.terminal', { ...fields, status });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
export function createInferenceTrace() {
|
|
180
|
+
return new InferenceTrace();
|
|
181
|
+
}
|
|
182
|
+
export function classifyFinishReason(value) {
|
|
183
|
+
switch (value) {
|
|
184
|
+
case 'stop':
|
|
185
|
+
case 'length':
|
|
186
|
+
case 'cancelled':
|
|
187
|
+
case 'error':
|
|
188
|
+
return value;
|
|
189
|
+
default:
|
|
190
|
+
return 'other';
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
export function classifyModelType(value) {
|
|
194
|
+
switch (value) {
|
|
195
|
+
case 'gemma4':
|
|
196
|
+
case 'qwen3':
|
|
197
|
+
case 'qwen3_5':
|
|
198
|
+
case 'qwen3_5_moe':
|
|
199
|
+
case 'lfm2':
|
|
200
|
+
case 'lfm2_moe':
|
|
201
|
+
return value;
|
|
202
|
+
default:
|
|
203
|
+
return 'other';
|
|
204
|
+
}
|
|
205
|
+
}
|