@mlx-node/agent 0.0.12 → 0.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/catalog.d.ts +10 -1
  2. package/dist/catalog.d.ts.map +1 -1
  3. package/dist/catalog.js +11 -2
  4. package/dist/delegate.d.ts +29 -0
  5. package/dist/delegate.d.ts.map +1 -0
  6. package/dist/delegate.js +106 -0
  7. package/dist/extensions/delegation.d.ts +15 -0
  8. package/dist/extensions/delegation.d.ts.map +1 -0
  9. package/dist/extensions/delegation.js +93 -0
  10. package/dist/paths.d.ts +6 -0
  11. package/dist/paths.d.ts.map +1 -1
  12. package/dist/paths.js +16 -0
  13. package/dist/provider/chat-config.d.ts +6 -5
  14. package/dist/provider/chat-config.d.ts.map +1 -1
  15. package/dist/provider/chat-config.js +21 -7
  16. package/dist/provider/index.d.ts.map +1 -1
  17. package/dist/provider/index.js +8 -1
  18. package/dist/provider/model-host.d.ts +1 -1
  19. package/dist/provider/model-host.d.ts.map +1 -1
  20. package/dist/provider/model-host.js +25 -7
  21. package/dist/provider/models.d.ts +3 -14
  22. package/dist/provider/models.d.ts.map +1 -1
  23. package/dist/provider/models.js +17 -239
  24. package/dist/provider/stream-adapter.d.ts +2 -2
  25. package/dist/provider/stream-adapter.d.ts.map +1 -1
  26. package/dist/provider/stream-adapter.js +8 -5
  27. package/dist/run-agent.d.ts +4 -0
  28. package/dist/run-agent.d.ts.map +1 -1
  29. package/dist/run-agent.js +8 -2
  30. package/dist/types.d.ts +1 -1
  31. package/dist/types.d.ts.map +1 -1
  32. package/package.json +23 -5
  33. package/src/catalog.ts +194 -0
  34. package/src/cold-tier.ts +152 -0
  35. package/src/delegate.ts +136 -0
  36. package/src/extensions/approval-detail.ts +57 -0
  37. package/src/extensions/delegation.ts +109 -0
  38. package/src/extensions/local-image-input.ts +132 -0
  39. package/src/extensions/permission-gate.ts +347 -0
  40. package/src/extensions/subagent.ts +743 -0
  41. package/src/extensions/terminal-title.ts +53 -0
  42. package/src/extensions/trace-notice.ts +37 -0
  43. package/src/index.ts +23 -0
  44. package/src/paths.ts +36 -0
  45. package/src/provider/chat-config.ts +132 -0
  46. package/src/provider/convert-messages.ts +273 -0
  47. package/src/provider/error-coercion.ts +36 -0
  48. package/src/provider/events.ts +341 -0
  49. package/src/provider/index.ts +255 -0
  50. package/src/provider/metrics-trace.ts +380 -0
  51. package/src/provider/mlx-identity.ts +16 -0
  52. package/src/provider/model-host.ts +276 -0
  53. package/src/provider/model-registry-filter.ts +336 -0
  54. package/src/provider/models.ts +48 -0
  55. package/src/provider/performance-status.ts +112 -0
  56. package/src/provider/reasoning-tag-buffer.ts +67 -0
  57. package/src/provider/stream-adapter.ts +515 -0
  58. package/src/provider/tool-call-buffer.ts +82 -0
  59. package/src/provider/warm-reuse.ts +125 -0
  60. package/src/run-agent.ts +178 -0
  61. package/src/types.ts +10 -0
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Keep pi's terminal title branded for the mlx CLI.
3
+ *
4
+ * Pi refreshes its own title after `session_start`, so startup/rebind updates
5
+ * must run on the next task. Session-name changes are emitted after pi updates
6
+ * its title and can be handled immediately.
7
+ */
8
+
9
+ import { basename } from 'node:path';
10
+
11
+ import type { ExtensionAPI, ExtensionContext, InlineExtension } from '@earendil-works/pi-coding-agent';
12
+
13
+ function buildTerminalTitle(pi: ExtensionAPI, ctx: ExtensionContext): string {
14
+ const cwd = basename(ctx.cwd);
15
+ const context = pi.getSessionName() ?? ctx.model?.id;
16
+ return context ? `mlx - ${context} - ${cwd}` : `mlx - ${cwd}`;
17
+ }
18
+
19
+ export function createTerminalTitleExtension(): InlineExtension {
20
+ return {
21
+ name: 'mlx-terminal-title',
22
+ factory: (pi: ExtensionAPI) => {
23
+ let pendingUpdate: ReturnType<typeof setTimeout> | undefined;
24
+
25
+ const updateTitle = (ctx: ExtensionContext): void => {
26
+ if (ctx.mode === 'tui') {
27
+ ctx.ui.setTitle(buildTerminalTitle(pi, ctx));
28
+ }
29
+ };
30
+
31
+ pi.on('session_start', (_event, ctx) => {
32
+ if (pendingUpdate !== undefined) clearTimeout(pendingUpdate);
33
+ pendingUpdate = setTimeout(() => {
34
+ pendingUpdate = undefined;
35
+ updateTitle(ctx);
36
+ }, 0);
37
+ });
38
+
39
+ pi.on('session_info_changed', (_event, ctx) => {
40
+ updateTitle(ctx);
41
+ });
42
+
43
+ pi.on('model_select', (_event, ctx) => {
44
+ updateTitle(ctx);
45
+ });
46
+
47
+ pi.on('session_shutdown', () => {
48
+ if (pendingUpdate !== undefined) clearTimeout(pendingUpdate);
49
+ pendingUpdate = undefined;
50
+ });
51
+ },
52
+ };
53
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Surface the native inference-log path after Pi owns the terminal.
3
+ *
4
+ * The CLI must configure tracing before importing the native addon, but a
5
+ * message printed at that point is erased when Pi starts its fullscreen TUI.
6
+ * Announcing from `session_start` keeps the early subscriber setup while
7
+ * placing the path in Pi's persistent chat/status area.
8
+ */
9
+
10
+ import type { ExtensionAPI, InlineExtension } from '@earendil-works/pi-coding-agent';
11
+
12
+ export function createTraceNoticeExtension(logFile: string): InlineExtension {
13
+ return {
14
+ name: 'mlx-trace-notice',
15
+ factory: (pi: ExtensionAPI) => {
16
+ let pendingNotice: ReturnType<typeof setTimeout> | undefined;
17
+
18
+ pi.on('session_start', (_event, ctx) => {
19
+ if (pendingNotice !== undefined) clearTimeout(pendingNotice);
20
+ if (ctx.mode !== 'tui') return;
21
+
22
+ // Pi renders restored session messages immediately after extension
23
+ // binding. Defer one task so the notice is appended after that history
24
+ // instead of being buried above it in resumed sessions.
25
+ pendingNotice = setTimeout(() => {
26
+ pendingNotice = undefined;
27
+ ctx.ui.notify(`mlx agent: inference log ${logFile}`, 'info');
28
+ }, 0);
29
+ });
30
+
31
+ pi.on('session_shutdown', () => {
32
+ if (pendingNotice !== undefined) clearTimeout(pendingNotice);
33
+ pendingNotice = undefined;
34
+ });
35
+ },
36
+ };
37
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ export type { DiscoveredModelLike } from './types.js';
2
+
3
+ export { type CatalogEntry, catalogRepo, catalogRepoFor, MODEL_CATALOG, visibleCatalog } from './catalog.js';
4
+ export { createPermissionGateExtension } from './extensions/permission-gate.js';
5
+ export {
6
+ createSubagentExtension,
7
+ discoverSubagents,
8
+ normalizeSubagentMode,
9
+ type InProcessSubagentSession,
10
+ type SubagentConfig,
11
+ type SubagentExtensionOptions,
12
+ type SubagentMode,
13
+ type SubagentSessionCreateOptions,
14
+ } from './extensions/subagent.js';
15
+ export { createTerminalTitleExtension } from './extensions/terminal-title.js';
16
+ export { buildChatConfig } from './provider/chat-config.js';
17
+ export { contextToChatMessages, toolsToDefinitions } from './provider/convert-messages.js';
18
+ export { TurnEmitter } from './provider/events.js';
19
+ export { createMlxProviderExtension } from './provider/index.js';
20
+ export { MlxModelHost, type MlxModelHostOptions } from './provider/model-host.js';
21
+ export { discoverMlxModels, type MlxModelInfo } from './provider/models.js';
22
+ export { runAgent, type RunAgentMain, type RunAgentOptions } from './run-agent.js';
23
+ export { makeMlxStreamSimple, type StreamSimpleHost } from './provider/stream-adapter.js';
package/src/paths.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `$HOME/.mlx-node` layout helpers owned by the agent.
3
+ *
4
+ * The agent must not import `@mlx-node/cli` (wrong dependency direction), so
5
+ * the small home-directory layout it needs lives here. Mirrors
6
+ * `resolveMlxNodeHome()` in `@mlx-node/server/host/paths`
7
+ * (`packages/server/src/host/paths.ts`).
8
+ */
9
+
10
+ import { homedir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ /**
15
+ * Expand an agent directory for both the CLI and desktop settings readers.
16
+ * Accept home-relative paths and file URLs without trimming literal paths or
17
+ * expanding another user's `~user` prefix. `home` is a test seam.
18
+ */
19
+ export function expandPiAgentDir(dir: string, home: string = homedir()): string {
20
+ if (dir === '~') return home;
21
+ if (dir.startsWith('~/') || (process.platform === 'win32' && dir.startsWith('~\\'))) {
22
+ return join(home, dir.slice(2));
23
+ }
24
+ if (dir.startsWith('file://')) return fileURLToPath(dir);
25
+ return dir;
26
+ }
27
+
28
+ /** Absolute path to `$HOME/.mlx-node`. */
29
+ export function mlxNodeHome(): string {
30
+ return join(homedir(), '.mlx-node');
31
+ }
32
+
33
+ /** Directory holding per-process `MetricsTrace` JSONL files. */
34
+ export function metricsTraceDir(): string {
35
+ return join(mlxNodeHome(), 'metrics', 'traces');
36
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Per-call `ChatConfig` assembly for the provider bridge.
3
+ *
4
+ * Base sampling + output budget come from the family-data launch preset
5
+ * (`@mlx-node/lm`), then pi's per-call `SimpleStreamOptions` overlay on top.
6
+ */
7
+
8
+ import type { SimpleStreamOptions, ThinkingLevel } from '@earendil-works/pi-ai';
9
+ import {
10
+ launchPresetFor,
11
+ MODEL_FAMILY_DATA,
12
+ type ChatConfig,
13
+ type ModelFamilyData,
14
+ type ModelType,
15
+ type ToolDefinition,
16
+ } from '@mlx-node/lm';
17
+
18
+ /**
19
+ * Model types the no-preset error names: trainable rows, then loadable rows,
20
+ * each in registry order. Pinned byte-exactly by
21
+ * `packages/agent/__test__/chat-config.test.ts`.
22
+ */
23
+ const KNOWN_PRESET_MODEL_TYPES: readonly string[] = (() => {
24
+ const rows: readonly ModelFamilyData[] = MODEL_FAMILY_DATA;
25
+ const chatRows = rows.filter((row) => row.kind === 'trainable' || row.kind === 'loadable');
26
+ return [
27
+ ...chatRows.filter((row) => row.kind === 'trainable'),
28
+ ...chatRows.filter((row) => row.kind === 'loadable'),
29
+ ].map((row) => row.id);
30
+ })();
31
+
32
+ /**
33
+ * pi thinking level → native `reasoningEffort`. pi never delivers 'off'
34
+ * here (the agent loop converts it to `undefined` before the provider
35
+ * sees it), so `undefined` is the "thinking disabled" signal → 'none'.
36
+ */
37
+ const THINKING_LEVEL_TO_EFFORT: Record<ThinkingLevel, 'low' | 'medium' | 'high' | 'xhigh' | 'max'> = {
38
+ minimal: 'low',
39
+ low: 'low',
40
+ medium: 'medium',
41
+ high: 'high',
42
+ xhigh: 'xhigh',
43
+ max: 'max',
44
+ };
45
+
46
+ export interface ResolvedReasoningMode {
47
+ reasoningEffort: 'none' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
48
+ /** The `enable_thinking` value implied by `reasoningEffort` for templates. */
49
+ thinkingEnabled: boolean;
50
+ }
51
+
52
+ function isPositiveSafeInteger(value: unknown): value is number {
53
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
54
+ }
55
+
56
+ /**
57
+ * Resolve Pi's thinking level once for both native config and persisted replay
58
+ * provenance. Keeping these values together gives generation and replay the same
59
+ * enabled-thinking state, including low/minimal turns.
60
+ */
61
+ export function resolveReasoningMode(reasoning: ThinkingLevel | undefined): ResolvedReasoningMode {
62
+ const reasoningEffort = reasoning === undefined ? 'none' : THINKING_LEVEL_TO_EFFORT[reasoning];
63
+ return {
64
+ reasoningEffort,
65
+ thinkingEnabled: reasoningEffort !== 'none',
66
+ };
67
+ }
68
+
69
+ /** An explicit hard cap, independent from the model's reasoning effort. */
70
+ export function parseThinkingBudget(value: unknown): number | undefined {
71
+ if (value === undefined) return undefined;
72
+ const budget = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value;
73
+ if (typeof budget !== 'number' || !Number.isSafeInteger(budget) || budget < 0 || budget > 2_147_483_647) {
74
+ throw new Error('Thinking budget must be an integer between 0 and 2147483647.');
75
+ }
76
+ return budget;
77
+ }
78
+
79
+ export function buildChatConfig(
80
+ modelType: ModelType,
81
+ options: SimpleStreamOptions | undefined,
82
+ tools: ToolDefinition[] | undefined,
83
+ rootCacheOwnerId?: string,
84
+ resolvedReasoning = resolveReasoningMode(options?.reasoning),
85
+ modelMaxTokens?: unknown,
86
+ thinkingTokenBudget?: number,
87
+ ): ChatConfig {
88
+ const preset = launchPresetFor(modelType);
89
+ if (!preset) {
90
+ const known = KNOWN_PRESET_MODEL_TYPES.join(', ');
91
+ throw new Error(`buildChatConfig: no launch preset for model type "${modelType}" (known types: ${known})`);
92
+ }
93
+
94
+ // Pi's numeric budget settings currently stop at high. Preserve effort
95
+ // itself while using that highest configured cap for xhigh/max.
96
+ const budgetLevel = options?.reasoning === 'xhigh' || options?.reasoning === 'max' ? 'high' : options?.reasoning;
97
+ const budget = parseThinkingBudget(
98
+ thinkingTokenBudget ?? (budgetLevel === undefined ? undefined : options?.thinkingBudgets?.[budgetLevel]),
99
+ );
100
+ const config: ChatConfig = {
101
+ ...(budget === undefined ? {} : { thinkingTokenBudget: budget }),
102
+ ...preset.sampling,
103
+ maxNewTokens: preset.maxOutputTokens,
104
+ reasoningEffort: resolvedReasoning.reasoningEffort,
105
+ // The terminal native chunk carries TTFT/prefill/decode telemetry when
106
+ // requested. The provider keeps it transient and only renders it in TUI.
107
+ reportPerformance: true,
108
+ };
109
+ // Pi assigns one stable id to the root AgentSession and a distinct id to
110
+ // every in-memory subagent session. Native Qwen3.5 uses this only to retain
111
+ // GDN sidecars per logical branch; PagedAttention KV blocks remain shared by
112
+ // their existing exact content hashes.
113
+ if (options?.sessionId !== undefined) config.cacheOwnerId = options.sessionId;
114
+ // The active owner above can be a child AgentSession. Keep the current
115
+ // top-level session identity separate so a /new or /resume rotation updates
116
+ // which branch the bounded GDN sidecar store protects from child eviction.
117
+ if (rootCacheOwnerId !== undefined) config.cacheRootOwnerId = rootCacheOwnerId;
118
+ const explicitMaxTokens = options?.maxTokens;
119
+ if (isPositiveSafeInteger(explicitMaxTokens)) {
120
+ // A valid per-call provider option is the topmost layer.
121
+ config.maxNewTokens = explicitMaxTokens;
122
+ } else if (isPositiveSafeInteger(modelMaxTokens)) {
123
+ // Normal Pi agent turns omit SimpleStreamOptions.maxTokens. Honor the
124
+ // composed Model metadata (including models.json modelOverrides) without
125
+ // allowing malformed/hostile metadata to replace the family preset.
126
+ config.maxNewTokens = modelMaxTokens;
127
+ }
128
+ if (options?.temperature !== undefined) config.temperature = options.temperature;
129
+ if (tools && tools.length > 0) config.tools = tools;
130
+ // `reuseCache` is deliberately NOT set: ChatSession.mergeConfig forces it on.
131
+ return config;
132
+ }
@@ -0,0 +1,273 @@
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
+
11
+ import type { Context, ImageContent, Message, TextContent, Tool } from '@earendil-works/pi-ai';
12
+ import type { ChatMessage, ToolDefinition } from '@mlx-node/lm';
13
+
14
+ const IMAGE_PLACEHOLDER = '[image omitted]';
15
+ const PI_NON_VISION_IMAGE_NOTE =
16
+ '[Current model does not support images. The image will be omitted from this request.]';
17
+ const TOOL_RESULT_IMAGE_PLACEHOLDER = '(see attached image)';
18
+ const TOOL_RESULT_IMAGE_PROMPT = 'Attached image(s) from tool result:';
19
+
20
+ interface ConvertedParts {
21
+ content: string;
22
+ images?: Uint8Array[];
23
+ }
24
+
25
+ interface ConvertedMessage {
26
+ message: ChatMessage;
27
+ toolResultImages?: Uint8Array[];
28
+ }
29
+
30
+ /**
31
+ * Convert Pi's mixed text/image blocks into the native message shape.
32
+ *
33
+ * Text-only models retain the historical byte-stable placeholder rendering.
34
+ * Image-capable models keep text order and image order independently — the
35
+ * most ordering the native `ChatMessage { content, images }` shape can express
36
+ * — while decoding Pi's base64 payloads into the bytes consumed by NAPI.
37
+ */
38
+ function convertParts(
39
+ parts: ReadonlyArray<TextContent | ImageContent>,
40
+ supportsImages: boolean,
41
+ stripStaleToolImageNote = false,
42
+ ): ConvertedParts {
43
+ if (!supportsImages) {
44
+ return {
45
+ content: parts.map((part) => (part.type === 'image' ? IMAGE_PLACEHOLDER : part.text)).join('\n'),
46
+ };
47
+ }
48
+
49
+ const text: string[] = [];
50
+ const images: Uint8Array[] = [];
51
+ for (const part of parts) {
52
+ if (part.type === 'image') {
53
+ images.push(Buffer.from(part.data, 'base64'));
54
+ } else {
55
+ // Pi added this exact standalone line to image tool results before the
56
+ // loaded native capability could be published. A resumed pre-fix history
57
+ // still contains it; replaying the warning contradicts the now-loaded
58
+ // capability even when image processing failed before producing bytes.
59
+ // Scope cleanup to tool results: identical direct-user text is literal.
60
+ text.push(
61
+ stripStaleToolImageNote
62
+ ? part.text
63
+ .split('\n')
64
+ .filter((line) => line !== PI_NON_VISION_IMAGE_NOTE)
65
+ .join('\n')
66
+ : part.text,
67
+ );
68
+ }
69
+ }
70
+ return {
71
+ content: text.join('\n'),
72
+ ...(images.length > 0 ? { images } : {}),
73
+ };
74
+ }
75
+
76
+ /** Per-message conversion (byte-stable joins). Never drops — the drop / orphan
77
+ * repair and grouped tool-result image turn live in
78
+ * {@link contextToChatMessages}, mirroring pi's transformMessages and OpenAI
79
+ * provider conversion. */
80
+ function convertMessage(message: Message, supportsImages: boolean): ConvertedMessage {
81
+ switch (message.role) {
82
+ case 'user': {
83
+ if (typeof message.content === 'string') {
84
+ return { message: { role: 'user', content: message.content } };
85
+ }
86
+ return { message: { role: 'user', ...convertParts(message.content, supportsImages) } };
87
+ }
88
+ case 'assistant': {
89
+ // Preserve the parser's reasoning body so thinking-capable templates can
90
+ // reconstruct the exact channel/tag sequence generated on the prior
91
+ // turn. The native Gemma4 parser already removes its fixed `thought\n`
92
+ // channel label; the template adds that label back during replay.
93
+ const reasoningContent = message.content
94
+ .filter((part) => part.type === 'thinking')
95
+ .map((part) => part.thinking)
96
+ .join('');
97
+ const text = message.content
98
+ .filter((part): part is TextContent => part.type === 'text')
99
+ .map((part) => part.text)
100
+ .join('\n');
101
+ const toolCalls = message.content
102
+ .filter((part) => part.type === 'toolCall')
103
+ .map((part) => ({ id: part.id, name: part.name, arguments: JSON.stringify(part.arguments) }));
104
+ const converted: ChatMessage = { role: 'assistant', content: text };
105
+ if (reasoningContent.length > 0) converted.reasoningContent = reasoningContent;
106
+ const thinkingEnabled = (message as typeof message & { mlxThinkingEnabled?: boolean }).mlxThinkingEnabled;
107
+ if (thinkingEnabled !== undefined) converted.thinkingEnabled = thinkingEnabled;
108
+ if (toolCalls.length > 0) converted.toolCalls = toolCalls;
109
+ return { message: converted };
110
+ }
111
+ case 'toolResult': {
112
+ const converted = convertParts(message.content, supportsImages, true);
113
+ const images = converted.images ?? [];
114
+ return {
115
+ message: {
116
+ role: 'tool',
117
+ content:
118
+ converted.content.length > 0
119
+ ? converted.content
120
+ : images.length > 0
121
+ ? TOOL_RESULT_IMAGE_PLACEHOLDER
122
+ : converted.content,
123
+ toolCallId: message.toolCallId,
124
+ isError: message.isError,
125
+ },
126
+ ...(images.length > 0 ? { toolResultImages: images } : {}),
127
+ };
128
+ }
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Convert a pi `Context` into the `ChatMessage[]` accepted by
134
+ * `ChatSession.primeHistory()`.
135
+ *
136
+ * - `systemPrompt` becomes the leading `system` message.
137
+ * - For text-only models (the default), image parts become literal
138
+ * `[image omitted]` lines.
139
+ * - For an image-capable loaded model, user images stay on their native user
140
+ * message. Images from a consecutive tool-result run are decoded, collected
141
+ * in source order, and emitted on one synthetic user message after every
142
+ * textual tool message in that run. This mirrors pi's OpenAI conversion and
143
+ * avoids templates that ignore images attached to the `tool` role.
144
+ *
145
+ * Two-pass mirror of pi's canonical `transformMessages` (pi-ai
146
+ * `dist/api/transform-messages.js`). That transform normally sanitizes the
147
+ * history INSIDE pi's built-in providers, but our custom `streamSimple` bypasses
148
+ * it (and `defaultConvertToLlm` filters by role only), so the same two passes
149
+ * must run here or a failed/interrupted turn reaches `primeHistory` unchanged:
150
+ *
151
+ * 1. DROP every assistant turn whose `stopReason` is `error` or `aborted` —
152
+ * partial or not. These incomplete turns (partial text, a half-emitted tool
153
+ * call) must not be replayed: after a native error (R2-3 resets the native
154
+ * cache) or an Esc/abort, priming the invalid partial turn garbles the
155
+ * continuation or leaves a dangling `<tool_call>` and corrupts the native
156
+ * `unresolvedOkToolCallCount`. A dropped turn's tool calls are NOT tracked.
157
+ * 2. ORPHAN-REPAIR: track the tool-call ids of each RETAINED assistant and,
158
+ * before every following user/assistant message and at the end, synthesize a
159
+ * native tool result (`{ role: 'tool', content: 'No result provided',
160
+ * isError: true }`) for any tracked call with no matching `toolResult`
161
+ * (pi's `insertSyntheticToolResults`), so no assistant tool call is left
162
+ * unanswered in the primed history.
163
+ *
164
+ * The happy path (every assistant completes, every tool call answered) is
165
+ * untouched, so the byte-stable joins that keep the replayed KV prefix stable
166
+ * are preserved.
167
+ */
168
+ export function contextToChatMessages(context: Context, supportsImages = false): ChatMessage[] {
169
+ const messages: ChatMessage[] = [];
170
+ if (context.systemPrompt) {
171
+ messages.push({ role: 'system', content: context.systemPrompt });
172
+ }
173
+
174
+ // Orphan-repair state: the tool-call ids awaiting a result from the most
175
+ // recent RETAINED assistant, and the result ids seen since.
176
+ let pendingToolCallIds: string[] = [];
177
+ let seenToolResultIds = new Set<string>();
178
+ let pendingToolResultImages: Uint8Array[] = [];
179
+
180
+ const flushOrphans = (): void => {
181
+ if (pendingToolCallIds.length === 0) return;
182
+ for (const id of pendingToolCallIds) {
183
+ if (!seenToolResultIds.has(id)) {
184
+ messages.push({ role: 'tool', content: 'No result provided', toolCallId: id, isError: true });
185
+ }
186
+ }
187
+ pendingToolCallIds = [];
188
+ seenToolResultIds = new Set();
189
+ };
190
+
191
+ const flushToolResultImages = (): void => {
192
+ if (pendingToolResultImages.length === 0) return;
193
+ messages.push({
194
+ role: 'user',
195
+ content: TOOL_RESULT_IMAGE_PROMPT,
196
+ images: pendingToolResultImages,
197
+ });
198
+ pendingToolResultImages = [];
199
+ };
200
+
201
+ const flushToolResultBoundary = (): void => {
202
+ // A grouped image attachment is logically a user turn. Repair any missing
203
+ // sibling tool result before that boundary, then append the single image
204
+ // turn after every real/synthetic tool result.
205
+ flushOrphans();
206
+ flushToolResultImages();
207
+ };
208
+
209
+ for (const message of context.messages) {
210
+ switch (message.role) {
211
+ case 'user':
212
+ flushToolResultBoundary();
213
+ messages.push(convertMessage(message, supportsImages).message);
214
+ break;
215
+ case 'assistant': {
216
+ flushToolResultBoundary();
217
+ if (message.stopReason === 'error' || message.stopReason === 'aborted') {
218
+ break; // dropped: not primed, and its tool calls are NOT tracked
219
+ }
220
+ const converted = convertMessage(message, supportsImages).message;
221
+ messages.push(converted);
222
+ if (converted.toolCalls && converted.toolCalls.length > 0) {
223
+ // Native ToolCall.id is optional; only ids can be matched against a
224
+ // tool result, so an id-less call is never tracked for orphan repair.
225
+ pendingToolCallIds = converted.toolCalls.map((tc) => tc.id).filter((id): id is string => id !== undefined);
226
+ seenToolResultIds = new Set();
227
+ }
228
+ break;
229
+ }
230
+ case 'toolResult': {
231
+ seenToolResultIds.add(message.toolCallId);
232
+ const converted = convertMessage(message, supportsImages);
233
+ messages.push(converted.message);
234
+ if (converted.toolResultImages) {
235
+ pendingToolResultImages.push(...converted.toolResultImages);
236
+ }
237
+ break;
238
+ }
239
+ }
240
+ }
241
+ flushToolResultBoundary();
242
+ return messages;
243
+ }
244
+
245
+ /**
246
+ * Convert pi `Tool[]` (TypeBox-built plain JSON Schema objects) into the
247
+ * native OpenAI-style `ToolDefinition[]`.
248
+ *
249
+ * The NAPI layer requires `parameters.properties` as a JSON string;
250
+ * `JSON.stringify` preserves the schema's own key order, keeping the
251
+ * rendered tool block byte-stable across replays. Returns `undefined`
252
+ * for an absent or empty tool list so `ChatConfig.tools` stays unset.
253
+ */
254
+ export function toolsToDefinitions(tools: Tool[] | undefined): ToolDefinition[] | undefined {
255
+ if (!tools || tools.length === 0) return undefined;
256
+ return tools.map((tool) => {
257
+ // pi's Tool.parameters is a TSchema — at runtime a plain JSON Schema
258
+ // object (TypeBox kind markers live on symbols, which JSON ignores).
259
+ const schema = tool.parameters as { properties?: Record<string, unknown>; required?: string[] };
260
+ return {
261
+ type: 'function' as const,
262
+ function: {
263
+ name: tool.name,
264
+ description: tool.description,
265
+ parameters: {
266
+ type: 'object' as const,
267
+ properties: JSON.stringify(schema.properties ?? {}),
268
+ required: schema.required,
269
+ },
270
+ },
271
+ };
272
+ });
273
+ }
@@ -0,0 +1,36 @@
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
+ /**
10
+ * Coerce an arbitrary thrown value to a message string without trusting
11
+ * it: an `Error` whose `message` getter throws, an object with a poisoned
12
+ * `toString` / `Symbol.toPrimitive`, a null-prototype object (where
13
+ * `String(err)` itself throws), and a revoked Proxy — where even
14
+ * `err instanceof Error` throws, because `instanceof` walks the prototype
15
+ * chain through the (revoked or throwing) `getPrototypeOf` trap — all
16
+ * land on the constant fallback instead of escaping. Circular objects are
17
+ * fine — `String` never serializes deeply.
18
+ */
19
+ export function coerceErrorMessage(err: unknown): string {
20
+ try {
21
+ // The `instanceof` check MUST live inside the guard: on a revoked
22
+ // Proxy (or any Proxy with a throwing `getPrototypeOf` trap) the
23
+ // check itself throws a TypeError before any property is read.
24
+ if (err instanceof Error) {
25
+ const { message } = err;
26
+ if (typeof message === 'string' && message.length > 0) return message;
27
+ }
28
+ } catch {
29
+ // hostile prototype walk or poisoned `message` getter — fall through
30
+ }
31
+ try {
32
+ return String(err);
33
+ } catch {
34
+ return 'unserializable error';
35
+ }
36
+ }