@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
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ export { MODEL_CATALOG, visibleCatalog } from './catalog.js';
2
+ export { createPermissionGateExtension } from './extensions/permission-gate.js';
3
+ export { createSubagentExtension, discoverSubagents, normalizeSubagentMode, } from './extensions/subagent.js';
4
+ export { createTerminalTitleExtension } from './extensions/terminal-title.js';
5
+ export { buildChatConfig } from './provider/chat-config.js';
6
+ export { contextToChatMessages, toolsToDefinitions } from './provider/convert-messages.js';
7
+ export { TurnEmitter } from './provider/events.js';
8
+ export { createMlxProviderExtension } from './provider/index.js';
9
+ export { MlxModelHost } from './provider/model-host.js';
10
+ export { discoverMlxModels } from './provider/models.js';
11
+ export { runAgent } from './run-agent.js';
12
+ export { makeMlxStreamSimple } from './provider/stream-adapter.js';
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Per-call `ChatConfig` assembly for the provider bridge.
3
+ *
4
+ * Base sampling + output budget come from `@mlx-node/server`'s
5
+ * `LAUNCH_PRESETS` (the ONLY allowed server import in this package —
6
+ * presets/preset types, nothing else) extended by the agent-local
7
+ * {@link AGENT_LAUNCH_PRESETS}, then pi's per-call `SimpleStreamOptions`
8
+ * overlay on top.
9
+ */
10
+ import type { SimpleStreamOptions, ThinkingLevel } from '@earendil-works/pi-ai';
11
+ import type { ChatConfig, ModelType, ToolDefinition } from '@mlx-node/lm';
12
+ import { type LaunchPreset } from '@mlx-node/server';
13
+ /**
14
+ * Preset lookup — agent-local entries win over `LAUNCH_PRESETS` (they
15
+ * exist precisely because the server table has no correct entry for the
16
+ * type). This is the ONE preset resolution shared by discovery
17
+ * (`models.ts`) and per-call config assembly, so a model can never be
18
+ * discovered without also being streamable (and vice versa).
19
+ */
20
+ export declare function launchPresetFor(modelType: ModelType): LaunchPreset | undefined;
21
+ export interface ResolvedReasoningMode {
22
+ reasoningEffort: 'none' | 'low' | 'medium' | 'high';
23
+ /** The `enable_thinking` value implied by `reasoningEffort` for templates. */
24
+ thinkingEnabled: boolean;
25
+ }
26
+ /**
27
+ * Resolve Pi's thinking level once for both native config and persisted replay
28
+ * provenance. Keeping these values together prevents a low/minimal turn from
29
+ * being replayed later as an enabled-thinking turn merely because the Pi
30
+ * option was present.
31
+ */
32
+ export declare function resolveReasoningMode(reasoning: ThinkingLevel | undefined): ResolvedReasoningMode;
33
+ export declare function buildChatConfig(modelType: ModelType, options: SimpleStreamOptions | undefined, tools: ToolDefinition[] | undefined, rootCacheOwnerId?: string, resolvedReasoning?: ResolvedReasoningMode): ChatConfig;
34
+ //# sourceMappingURL=chat-config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chat-config.d.ts","sourceRoot":"","sources":["../../src/provider/chat-config.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAChF,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC1E,OAAO,EAAkB,KAAK,YAAY,EAAE,MAAM,kBAAkB,CAAC;AA0BrE;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,SAAS,EAAE,SAAS,GAAG,YAAY,GAAG,SAAS,CAE9E;AAgBD,MAAM,WAAW,qBAAqB;IACpC,eAAe,EAAE,MAAM,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IACpD,8EAA8E;IAC9E,eAAe,EAAE,OAAO,CAAC;CAC1B;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,aAAa,GAAG,SAAS,GAAG,qBAAqB,CAMhG;AAED,wBAAgB,eAAe,CAC7B,SAAS,EAAE,SAAS,EACpB,OAAO,EAAE,mBAAmB,GAAG,SAAS,EACxC,KAAK,EAAE,cAAc,EAAE,GAAG,SAAS,EACnC,gBAAgB,CAAC,EAAE,MAAM,EACzB,iBAAiB,wBAA2C,GAC3D,UAAU,CA6BZ"}
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Per-call `ChatConfig` assembly for the provider bridge.
3
+ *
4
+ * Base sampling + output budget come from `@mlx-node/server`'s
5
+ * `LAUNCH_PRESETS` (the ONLY allowed server import in this package —
6
+ * presets/preset types, nothing else) extended by the agent-local
7
+ * {@link AGENT_LAUNCH_PRESETS}, then pi's per-call `SimpleStreamOptions`
8
+ * overlay on top.
9
+ */
10
+ import { LAUNCH_PRESETS } from '@mlx-node/server';
11
+ /**
12
+ * Agent-local launch presets for model types `LAUNCH_PRESETS` does not
13
+ * cover (kept here — this package must not fork `packages/server`).
14
+ *
15
+ * `lfm2_moe` (LFM2.5-8B-A1B): LiquidAI's HF model card for the MoE
16
+ * checkpoint recommends temperature 0.2 / top_k 80 — deliberately NOT
17
+ * the dense `lfm2` preset (LFM2.5-1.2B guidance: temperature 0.05 /
18
+ * top_k 50). repetitionPenalty 1.05 and the 8192-token output budget
19
+ * match the dense family entry.
20
+ */
21
+ const AGENT_LAUNCH_PRESETS = {
22
+ lfm2_moe: {
23
+ sampling: {
24
+ temperature: 0.2,
25
+ topP: 1.0,
26
+ topK: 80,
27
+ minP: 0.0,
28
+ presencePenalty: 0.0,
29
+ repetitionPenalty: 1.05,
30
+ },
31
+ maxOutputTokens: 8192,
32
+ },
33
+ };
34
+ /**
35
+ * Preset lookup — agent-local entries win over `LAUNCH_PRESETS` (they
36
+ * exist precisely because the server table has no correct entry for the
37
+ * type). This is the ONE preset resolution shared by discovery
38
+ * (`models.ts`) and per-call config assembly, so a model can never be
39
+ * discovered without also being streamable (and vice versa).
40
+ */
41
+ export function launchPresetFor(modelType) {
42
+ return AGENT_LAUNCH_PRESETS[modelType] ?? LAUNCH_PRESETS[modelType];
43
+ }
44
+ /**
45
+ * pi thinking level → native `reasoningEffort`. pi never delivers 'off'
46
+ * here (the agent loop converts it to `undefined` before the provider
47
+ * sees it), so `undefined` is the "thinking disabled" signal → 'none'.
48
+ */
49
+ const THINKING_LEVEL_TO_EFFORT = {
50
+ minimal: 'low',
51
+ low: 'low',
52
+ medium: 'medium',
53
+ high: 'high',
54
+ xhigh: 'high',
55
+ max: 'high',
56
+ };
57
+ /**
58
+ * Resolve Pi's thinking level once for both native config and persisted replay
59
+ * provenance. Keeping these values together prevents a low/minimal turn from
60
+ * being replayed later as an enabled-thinking turn merely because the Pi
61
+ * option was present.
62
+ */
63
+ export function resolveReasoningMode(reasoning) {
64
+ const reasoningEffort = reasoning === undefined ? 'none' : THINKING_LEVEL_TO_EFFORT[reasoning];
65
+ return {
66
+ reasoningEffort,
67
+ thinkingEnabled: reasoningEffort === 'medium' || reasoningEffort === 'high',
68
+ };
69
+ }
70
+ export function buildChatConfig(modelType, options, tools, rootCacheOwnerId, resolvedReasoning = resolveReasoningMode(options?.reasoning)) {
71
+ const preset = launchPresetFor(modelType);
72
+ if (!preset) {
73
+ const known = [...new Set([...Object.keys(LAUNCH_PRESETS), ...Object.keys(AGENT_LAUNCH_PRESETS)])].join(', ');
74
+ throw new Error(`buildChatConfig: no launch preset for model type "${modelType}" (known types: ${known})`);
75
+ }
76
+ const config = {
77
+ ...preset.sampling,
78
+ maxNewTokens: preset.maxOutputTokens,
79
+ reasoningEffort: resolvedReasoning.reasoningEffort,
80
+ // The terminal native chunk carries TTFT/prefill/decode telemetry when
81
+ // requested. The provider keeps it transient and only renders it in TUI.
82
+ reportPerformance: true,
83
+ };
84
+ // Pi assigns one stable id to the root AgentSession and a distinct id to
85
+ // every in-memory subagent session. Native Qwen3.5 uses this only to retain
86
+ // GDN sidecars per logical branch; PagedAttention KV blocks remain shared by
87
+ // their existing exact content hashes.
88
+ if (options?.sessionId !== undefined)
89
+ config.cacheOwnerId = options.sessionId;
90
+ // The active owner above can be a child AgentSession. Keep the current
91
+ // top-level session identity separate so a /new or /resume rotation updates
92
+ // which branch the bounded GDN sidecar store protects from child eviction.
93
+ if (rootCacheOwnerId !== undefined)
94
+ config.cacheRootOwnerId = rootCacheOwnerId;
95
+ if (options?.maxTokens !== undefined)
96
+ config.maxNewTokens = options.maxTokens;
97
+ if (options?.temperature !== undefined)
98
+ config.temperature = options.temperature;
99
+ if (tools && tools.length > 0)
100
+ config.tools = tools;
101
+ // `reuseCache` is deliberately NOT set: ChatSession.mergeConfig forces it on.
102
+ return config;
103
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * pi `Context` → native `ChatMessage[]` / `ToolDefinition[]` conversion.
3
+ *
4
+ * The provider bridge replays pi's full message history through
5
+ * `ChatSession.primeHistory()` on every LLM call, so this conversion must
6
+ * be deterministic and byte-stable: an unstable rendering (key-order
7
+ * churn, nondeterministic joins) would change the token prefix between
8
+ * replays and silently kill native KV-cache reuse.
9
+ */
10
+ import type { Context, Tool } from '@earendil-works/pi-ai';
11
+ import type { ChatMessage, ToolDefinition } from '@mlx-node/lm';
12
+ /**
13
+ * Convert a pi `Context` into the `ChatMessage[]` accepted by
14
+ * `ChatSession.primeHistory()`.
15
+ *
16
+ * - `systemPrompt` becomes the leading `system` message.
17
+ * - For text-only models (the default), image parts become literal
18
+ * `[image omitted]` lines.
19
+ * - For an image-capable loaded model, user images stay on their native user
20
+ * message. Images from a consecutive tool-result run are decoded, collected
21
+ * in source order, and emitted on one synthetic user message after every
22
+ * textual tool message in that run. This mirrors pi's OpenAI conversion and
23
+ * avoids templates that ignore images attached to the `tool` role.
24
+ *
25
+ * Two-pass mirror of pi's canonical `transformMessages` (pi-ai
26
+ * `dist/api/transform-messages.js`). That transform normally sanitizes the
27
+ * history INSIDE pi's built-in providers, but our custom `streamSimple` bypasses
28
+ * it (and `defaultConvertToLlm` filters by role only), so the same two passes
29
+ * must run here or a failed/interrupted turn reaches `primeHistory` unchanged:
30
+ *
31
+ * 1. DROP every assistant turn whose `stopReason` is `error` or `aborted` —
32
+ * partial or not. These incomplete turns (partial text, a half-emitted tool
33
+ * call) must not be replayed: after a native error (R2-3 resets the native
34
+ * cache) or an Esc/abort, priming the invalid partial turn garbles the
35
+ * continuation or leaves a dangling `<tool_call>` and corrupts the native
36
+ * `unresolvedOkToolCallCount`. A dropped turn's tool calls are NOT tracked.
37
+ * 2. ORPHAN-REPAIR: track the tool-call ids of each RETAINED assistant and,
38
+ * before every following user/assistant message and at the end, synthesize a
39
+ * native tool result (`{ role: 'tool', content: 'No result provided',
40
+ * isError: true }`) for any tracked call with no matching `toolResult`
41
+ * (pi's `insertSyntheticToolResults`), so no assistant tool call is left
42
+ * unanswered in the primed history.
43
+ *
44
+ * The happy path (every assistant completes, every tool call answered) is
45
+ * untouched, so the byte-stable joins that keep the replayed KV prefix stable
46
+ * are preserved.
47
+ */
48
+ export declare function contextToChatMessages(context: Context, supportsImages?: boolean): ChatMessage[];
49
+ /**
50
+ * Convert pi `Tool[]` (TypeBox-built plain JSON Schema objects) into the
51
+ * native OpenAI-style `ToolDefinition[]`.
52
+ *
53
+ * The NAPI layer requires `parameters.properties` as a JSON string;
54
+ * `JSON.stringify` preserves the schema's own key order, keeping the
55
+ * rendered tool block byte-stable across replays. Returns `undefined`
56
+ * for an absent or empty tool list so `ChatConfig.tools` stays unset.
57
+ */
58
+ export declare function toolsToDefinitions(tools: Tool[] | undefined): ToolDefinition[] | undefined;
59
+ //# sourceMappingURL=convert-messages.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"convert-messages.d.ts","sourceRoot":"","sources":["../../src/provider/convert-messages.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAsC,IAAI,EAAE,MAAM,uBAAuB,CAAC;AAC/F,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAwHhE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,EAAE,cAAc,UAAQ,GAAG,WAAW,EAAE,CA2E7F;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,SAAS,GAAG,cAAc,EAAE,GAAG,SAAS,CAmB1F"}
@@ -0,0 +1,248 @@
1
+ /**
2
+ * pi `Context` → native `ChatMessage[]` / `ToolDefinition[]` conversion.
3
+ *
4
+ * The provider bridge replays pi's full message history through
5
+ * `ChatSession.primeHistory()` on every LLM call, so this conversion must
6
+ * be deterministic and byte-stable: an unstable rendering (key-order
7
+ * churn, nondeterministic joins) would change the token prefix between
8
+ * replays and silently kill native KV-cache reuse.
9
+ */
10
+ const IMAGE_PLACEHOLDER = '[image omitted]';
11
+ const PI_NON_VISION_IMAGE_NOTE = '[Current model does not support images. The image will be omitted from this request.]';
12
+ const TOOL_RESULT_IMAGE_PLACEHOLDER = '(see attached image)';
13
+ const TOOL_RESULT_IMAGE_PROMPT = 'Attached image(s) from tool result:';
14
+ /**
15
+ * Convert Pi's mixed text/image blocks into the native message shape.
16
+ *
17
+ * Text-only models retain the historical byte-stable placeholder rendering.
18
+ * Image-capable models keep text order and image order independently — the
19
+ * most ordering the native `ChatMessage { content, images }` shape can express
20
+ * — while decoding Pi's base64 payloads into the bytes consumed by NAPI.
21
+ */
22
+ function convertParts(parts, supportsImages, stripStaleToolImageNote = false) {
23
+ if (!supportsImages) {
24
+ return {
25
+ content: parts.map((part) => (part.type === 'image' ? IMAGE_PLACEHOLDER : part.text)).join('\n'),
26
+ };
27
+ }
28
+ const text = [];
29
+ const images = [];
30
+ for (const part of parts) {
31
+ if (part.type === 'image') {
32
+ images.push(Buffer.from(part.data, 'base64'));
33
+ }
34
+ else {
35
+ // Pi added this exact standalone line to image tool results before the
36
+ // loaded native capability could be published. A resumed pre-fix history
37
+ // still contains it; replaying the warning contradicts the now-loaded
38
+ // capability even when image processing failed before producing bytes.
39
+ // Scope cleanup to tool results: identical direct-user text is literal.
40
+ text.push(stripStaleToolImageNote
41
+ ? part.text
42
+ .split('\n')
43
+ .filter((line) => line !== PI_NON_VISION_IMAGE_NOTE)
44
+ .join('\n')
45
+ : part.text);
46
+ }
47
+ }
48
+ return {
49
+ content: text.join('\n'),
50
+ ...(images.length > 0 ? { images } : {}),
51
+ };
52
+ }
53
+ /** Per-message conversion (byte-stable joins). Never drops — the drop / orphan
54
+ * repair and grouped tool-result image turn live in
55
+ * {@link contextToChatMessages}, mirroring pi's transformMessages and OpenAI
56
+ * provider conversion. */
57
+ function convertMessage(message, supportsImages) {
58
+ switch (message.role) {
59
+ case 'user': {
60
+ if (typeof message.content === 'string') {
61
+ return { message: { role: 'user', content: message.content } };
62
+ }
63
+ return { message: { role: 'user', ...convertParts(message.content, supportsImages) } };
64
+ }
65
+ case 'assistant': {
66
+ // Preserve the parser's reasoning body so thinking-capable templates can
67
+ // reconstruct the exact channel/tag sequence generated on the prior
68
+ // turn. The native Gemma4 parser already removes its fixed `thought\n`
69
+ // channel label; the template adds that label back during replay.
70
+ const reasoningContent = message.content
71
+ .filter((part) => part.type === 'thinking')
72
+ .map((part) => part.thinking)
73
+ .join('');
74
+ const text = message.content
75
+ .filter((part) => part.type === 'text')
76
+ .map((part) => part.text)
77
+ .join('\n');
78
+ const toolCalls = message.content
79
+ .filter((part) => part.type === 'toolCall')
80
+ .map((part) => ({ id: part.id, name: part.name, arguments: JSON.stringify(part.arguments) }));
81
+ const converted = { role: 'assistant', content: text };
82
+ if (reasoningContent.length > 0)
83
+ converted.reasoningContent = reasoningContent;
84
+ const thinkingEnabled = message.mlxThinkingEnabled;
85
+ if (thinkingEnabled !== undefined)
86
+ converted.thinkingEnabled = thinkingEnabled;
87
+ if (toolCalls.length > 0)
88
+ converted.toolCalls = toolCalls;
89
+ return { message: converted };
90
+ }
91
+ case 'toolResult': {
92
+ const converted = convertParts(message.content, supportsImages, true);
93
+ const images = converted.images ?? [];
94
+ return {
95
+ message: {
96
+ role: 'tool',
97
+ content: converted.content.length > 0
98
+ ? converted.content
99
+ : images.length > 0
100
+ ? TOOL_RESULT_IMAGE_PLACEHOLDER
101
+ : converted.content,
102
+ toolCallId: message.toolCallId,
103
+ isError: message.isError,
104
+ },
105
+ ...(images.length > 0 ? { toolResultImages: images } : {}),
106
+ };
107
+ }
108
+ }
109
+ }
110
+ /**
111
+ * Convert a pi `Context` into the `ChatMessage[]` accepted by
112
+ * `ChatSession.primeHistory()`.
113
+ *
114
+ * - `systemPrompt` becomes the leading `system` message.
115
+ * - For text-only models (the default), image parts become literal
116
+ * `[image omitted]` lines.
117
+ * - For an image-capable loaded model, user images stay on their native user
118
+ * message. Images from a consecutive tool-result run are decoded, collected
119
+ * in source order, and emitted on one synthetic user message after every
120
+ * textual tool message in that run. This mirrors pi's OpenAI conversion and
121
+ * avoids templates that ignore images attached to the `tool` role.
122
+ *
123
+ * Two-pass mirror of pi's canonical `transformMessages` (pi-ai
124
+ * `dist/api/transform-messages.js`). That transform normally sanitizes the
125
+ * history INSIDE pi's built-in providers, but our custom `streamSimple` bypasses
126
+ * it (and `defaultConvertToLlm` filters by role only), so the same two passes
127
+ * must run here or a failed/interrupted turn reaches `primeHistory` unchanged:
128
+ *
129
+ * 1. DROP every assistant turn whose `stopReason` is `error` or `aborted` —
130
+ * partial or not. These incomplete turns (partial text, a half-emitted tool
131
+ * call) must not be replayed: after a native error (R2-3 resets the native
132
+ * cache) or an Esc/abort, priming the invalid partial turn garbles the
133
+ * continuation or leaves a dangling `<tool_call>` and corrupts the native
134
+ * `unresolvedOkToolCallCount`. A dropped turn's tool calls are NOT tracked.
135
+ * 2. ORPHAN-REPAIR: track the tool-call ids of each RETAINED assistant and,
136
+ * before every following user/assistant message and at the end, synthesize a
137
+ * native tool result (`{ role: 'tool', content: 'No result provided',
138
+ * isError: true }`) for any tracked call with no matching `toolResult`
139
+ * (pi's `insertSyntheticToolResults`), so no assistant tool call is left
140
+ * unanswered in the primed history.
141
+ *
142
+ * The happy path (every assistant completes, every tool call answered) is
143
+ * untouched, so the byte-stable joins that keep the replayed KV prefix stable
144
+ * are preserved.
145
+ */
146
+ export function contextToChatMessages(context, supportsImages = false) {
147
+ const messages = [];
148
+ if (context.systemPrompt) {
149
+ messages.push({ role: 'system', content: context.systemPrompt });
150
+ }
151
+ // Orphan-repair state: the tool-call ids awaiting a result from the most
152
+ // recent RETAINED assistant, and the result ids seen since.
153
+ let pendingToolCallIds = [];
154
+ let seenToolResultIds = new Set();
155
+ let pendingToolResultImages = [];
156
+ const flushOrphans = () => {
157
+ if (pendingToolCallIds.length === 0)
158
+ return;
159
+ for (const id of pendingToolCallIds) {
160
+ if (!seenToolResultIds.has(id)) {
161
+ messages.push({ role: 'tool', content: 'No result provided', toolCallId: id, isError: true });
162
+ }
163
+ }
164
+ pendingToolCallIds = [];
165
+ seenToolResultIds = new Set();
166
+ };
167
+ const flushToolResultImages = () => {
168
+ if (pendingToolResultImages.length === 0)
169
+ return;
170
+ messages.push({
171
+ role: 'user',
172
+ content: TOOL_RESULT_IMAGE_PROMPT,
173
+ images: pendingToolResultImages,
174
+ });
175
+ pendingToolResultImages = [];
176
+ };
177
+ const flushToolResultBoundary = () => {
178
+ // A grouped image attachment is logically a user turn. Repair any missing
179
+ // sibling tool result before that boundary, then append the single image
180
+ // turn after every real/synthetic tool result.
181
+ flushOrphans();
182
+ flushToolResultImages();
183
+ };
184
+ for (const message of context.messages) {
185
+ switch (message.role) {
186
+ case 'user':
187
+ flushToolResultBoundary();
188
+ messages.push(convertMessage(message, supportsImages).message);
189
+ break;
190
+ case 'assistant': {
191
+ flushToolResultBoundary();
192
+ if (message.stopReason === 'error' || message.stopReason === 'aborted') {
193
+ break; // dropped: not primed, and its tool calls are NOT tracked
194
+ }
195
+ const converted = convertMessage(message, supportsImages).message;
196
+ messages.push(converted);
197
+ if (converted.toolCalls && converted.toolCalls.length > 0) {
198
+ // Native ToolCall.id is optional; only ids can be matched against a
199
+ // tool result, so an id-less call is never tracked for orphan repair.
200
+ pendingToolCallIds = converted.toolCalls.map((tc) => tc.id).filter((id) => id !== undefined);
201
+ seenToolResultIds = new Set();
202
+ }
203
+ break;
204
+ }
205
+ case 'toolResult': {
206
+ seenToolResultIds.add(message.toolCallId);
207
+ const converted = convertMessage(message, supportsImages);
208
+ messages.push(converted.message);
209
+ if (converted.toolResultImages) {
210
+ pendingToolResultImages.push(...converted.toolResultImages);
211
+ }
212
+ break;
213
+ }
214
+ }
215
+ }
216
+ flushToolResultBoundary();
217
+ return messages;
218
+ }
219
+ /**
220
+ * Convert pi `Tool[]` (TypeBox-built plain JSON Schema objects) into the
221
+ * native OpenAI-style `ToolDefinition[]`.
222
+ *
223
+ * The NAPI layer requires `parameters.properties` as a JSON string;
224
+ * `JSON.stringify` preserves the schema's own key order, keeping the
225
+ * rendered tool block byte-stable across replays. Returns `undefined`
226
+ * for an absent or empty tool list so `ChatConfig.tools` stays unset.
227
+ */
228
+ export function toolsToDefinitions(tools) {
229
+ if (!tools || tools.length === 0)
230
+ return undefined;
231
+ return tools.map((tool) => {
232
+ // pi's Tool.parameters is a TSchema — at runtime a plain JSON Schema
233
+ // object (TypeBox kind markers live on symbols, which JSON ignores).
234
+ const schema = tool.parameters;
235
+ return {
236
+ type: 'function',
237
+ function: {
238
+ name: tool.name,
239
+ description: tool.description,
240
+ parameters: {
241
+ type: 'object',
242
+ properties: JSON.stringify(schema.properties ?? {}),
243
+ required: schema.required,
244
+ },
245
+ },
246
+ };
247
+ });
248
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Hardened coercion of arbitrary thrown values to message strings.
3
+ *
4
+ * Shared by the stream adapter's TurnEmitter-independent failsafe path
5
+ * and `TurnEmitter.onError`: both receive caller-supplied error values
6
+ * and both promise never to throw, so every read here is guarded.
7
+ */
8
+ /**
9
+ * Coerce an arbitrary thrown value to a message string without trusting
10
+ * it: an `Error` whose `message` getter throws, an object with a poisoned
11
+ * `toString` / `Symbol.toPrimitive`, a null-prototype object (where
12
+ * `String(err)` itself throws), and a revoked Proxy — where even
13
+ * `err instanceof Error` throws, because `instanceof` walks the prototype
14
+ * chain through the (revoked or throwing) `getPrototypeOf` trap — all
15
+ * land on the constant fallback instead of escaping. Circular objects are
16
+ * fine — `String` never serializes deeply.
17
+ */
18
+ export declare function coerceErrorMessage(err: unknown): string;
19
+ //# sourceMappingURL=error-coercion.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"error-coercion.d.ts","sourceRoot":"","sources":["../../src/provider/error-coercion.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAiBvD"}
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Hardened coercion of arbitrary thrown values to message strings.
3
+ *
4
+ * Shared by the stream adapter's TurnEmitter-independent failsafe path
5
+ * and `TurnEmitter.onError`: both receive caller-supplied error values
6
+ * and both promise never to throw, so every read here is guarded.
7
+ */
8
+ /**
9
+ * Coerce an arbitrary thrown value to a message string without trusting
10
+ * it: an `Error` whose `message` getter throws, an object with a poisoned
11
+ * `toString` / `Symbol.toPrimitive`, a null-prototype object (where
12
+ * `String(err)` itself throws), and a revoked Proxy — where even
13
+ * `err instanceof Error` throws, because `instanceof` walks the prototype
14
+ * chain through the (revoked or throwing) `getPrototypeOf` trap — all
15
+ * land on the constant fallback instead of escaping. Circular objects are
16
+ * fine — `String` never serializes deeply.
17
+ */
18
+ export function coerceErrorMessage(err) {
19
+ try {
20
+ // The `instanceof` check MUST live inside the guard: on a revoked
21
+ // Proxy (or any Proxy with a throwing `getPrototypeOf` trap) the
22
+ // check itself throws a TypeError before any property is read.
23
+ if (err instanceof Error) {
24
+ const { message } = err;
25
+ if (typeof message === 'string' && message.length > 0)
26
+ return message;
27
+ }
28
+ }
29
+ catch {
30
+ // hostile prototype walk or poisoned `message` getter — fall through
31
+ }
32
+ try {
33
+ return String(err);
34
+ }
35
+ catch {
36
+ return 'unserializable error';
37
+ }
38
+ }
@@ -0,0 +1,67 @@
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 type { Api, AssistantMessage, AssistantMessageEventStream, Model, Usage } from '@earendil-works/pi-ai';
16
+ import type { ChatStreamDelta, ChatStreamFinal, PerformanceMetrics } from '@mlx-node/lm';
17
+ /**
18
+ * All-zero usage. Shared with the stream adapter's TurnEmitter-independent
19
+ * failsafe terminal, so it must stay trivially non-throwing.
20
+ */
21
+ export declare function emptyUsage(): Usage;
22
+ export declare class TurnEmitter {
23
+ private readonly onPerformance?;
24
+ private readonly stream;
25
+ private readonly partial;
26
+ private readonly textBuffer;
27
+ private readonly thinkingBuffer;
28
+ /**
29
+ * Leading whitespace-only text parked before any text block exists, so
30
+ * a `"\n\n"` emitted right before `<tool_call>` markup never ratifies a
31
+ * whitespace-only text content block (mirrors the server endpoints).
32
+ * Joined onto the first non-whitespace text; dropped at terminal time.
33
+ */
34
+ private pendingLeadingWhitespace;
35
+ private openBlock;
36
+ private finished;
37
+ constructor(stream: AssistantMessageEventStream, model: Model<Api>, onPerformance?: ((message: AssistantMessage, performance: PerformanceMetrics) => void) | undefined, thinkingEnabled?: boolean);
38
+ onDelta(delta: ChatStreamDelta): void;
39
+ onFinal(final: ChatStreamFinal): void;
40
+ /**
41
+ * Synthesize the terminal message for an aborted native stream (which
42
+ * ends with no final event). Mirrors pi's provider abort pattern:
43
+ * `{type:'error', reason:'aborted', error: <partial message>}` with all
44
+ * accumulated text/thinking preserved on the message.
45
+ */
46
+ onAborted(): void;
47
+ /**
48
+ * Terminal for internal/adapter failures. `err` is untrusted: coercion
49
+ * is fully guarded (shared `coerceErrorMessage`), so even a revoked
50
+ * Proxy or a poisoned `message` getter cannot throw out of here.
51
+ */
52
+ onError(err: unknown): void;
53
+ /**
54
+ * Shared terminal path for every non-`done` ending (abort, native
55
+ * finishReason=error, internal failure). Recovers any held-back buffer
56
+ * residue and closes the open text/thinking block BEFORE emitting the
57
+ * terminal event — a stream must never end `text_start/text_delta/error`
58
+ * with no `text_end` (pi's reference providers balance all blocks
59
+ * before terminals).
60
+ */
61
+ private finishWithError;
62
+ private appendThinking;
63
+ private appendVisibleText;
64
+ private closeOpenBlock;
65
+ private blockIndex;
66
+ }
67
+ //# sourceMappingURL=events.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../src/provider/events.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EACV,GAAG,EACH,gBAAgB,EAChB,2BAA2B,EAC3B,KAAK,EAIL,KAAK,EACN,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EAAE,eAAe,EAAE,eAAe,EAAE,kBAAkB,EAAkB,MAAM,cAAc,CAAC;AAMzG;;;GAGG;AACH,wBAAgB,UAAU,IAAI,KAAK,CASlC;AA2CD,qBAAa,WAAW;IAkBpB,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAC;IAjBjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA8B;IACrD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmB;IAC3C,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA2B;IACtD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA4B;IAC3D;;;;;OAKG;IACH,OAAO,CAAC,wBAAwB,CAAM;IACtC,OAAO,CAAC,SAAS,CAA8C;IAC/D,OAAO,CAAC,QAAQ,CAAS;IAEzB,YACE,MAAM,EAAE,2BAA2B,EACnC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,EACA,aAAa,CAAC,GAAE,CAAC,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,kBAAkB,KAAK,IAAI,aAAA,EACrG,eAAe,CAAC,EAAE,OAAO,EAuB1B;IAED,OAAO,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,CAqBpC;IAED,OAAO,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,CA8CpC;IAED;;;;;OAKG;IACH,SAAS,IAAI,IAAI,CAGhB;IAED;;;;OAIG;IACH,OAAO,CAAC,GAAG,EAAE,OAAO,GAAG,IAAI,CAG1B;IAED;;;;;;;OAOG;IACH,OAAO,CAAC,eAAe;IAqBvB,OAAO,CAAC,cAAc;IAmBtB,OAAO,CAAC,iBAAiB;IA6BzB,OAAO,CAAC,cAAc;IAqBtB,OAAO,CAAC,UAAU;CAGnB"}