@mlx-node/agent 0.0.12 → 0.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/catalog.d.ts +10 -1
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +11 -2
- package/dist/delegate.d.ts +29 -0
- package/dist/delegate.d.ts.map +1 -0
- package/dist/delegate.js +106 -0
- package/dist/extensions/delegation.d.ts +15 -0
- package/dist/extensions/delegation.d.ts.map +1 -0
- package/dist/extensions/delegation.js +93 -0
- package/dist/paths.d.ts +6 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +16 -0
- package/dist/provider/chat-config.d.ts +6 -5
- package/dist/provider/chat-config.d.ts.map +1 -1
- package/dist/provider/chat-config.js +21 -7
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +8 -1
- package/dist/provider/model-host.d.ts +1 -1
- package/dist/provider/model-host.d.ts.map +1 -1
- package/dist/provider/model-host.js +25 -7
- package/dist/provider/models.d.ts +3 -14
- package/dist/provider/models.d.ts.map +1 -1
- package/dist/provider/models.js +17 -239
- package/dist/provider/stream-adapter.d.ts +2 -2
- package/dist/provider/stream-adapter.d.ts.map +1 -1
- package/dist/provider/stream-adapter.js +8 -5
- package/dist/run-agent.d.ts +4 -0
- package/dist/run-agent.d.ts.map +1 -1
- package/dist/run-agent.js +8 -2
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +23 -5
- package/src/catalog.ts +194 -0
- package/src/cold-tier.ts +152 -0
- package/src/delegate.ts +136 -0
- package/src/extensions/approval-detail.ts +57 -0
- package/src/extensions/delegation.ts +109 -0
- package/src/extensions/local-image-input.ts +132 -0
- package/src/extensions/permission-gate.ts +347 -0
- package/src/extensions/subagent.ts +743 -0
- package/src/extensions/terminal-title.ts +53 -0
- package/src/extensions/trace-notice.ts +37 -0
- package/src/index.ts +23 -0
- package/src/paths.ts +36 -0
- package/src/provider/chat-config.ts +132 -0
- package/src/provider/convert-messages.ts +273 -0
- package/src/provider/error-coercion.ts +36 -0
- package/src/provider/events.ts +341 -0
- package/src/provider/index.ts +255 -0
- package/src/provider/metrics-trace.ts +380 -0
- package/src/provider/mlx-identity.ts +16 -0
- package/src/provider/model-host.ts +276 -0
- package/src/provider/model-registry-filter.ts +336 -0
- package/src/provider/models.ts +48 -0
- package/src/provider/performance-status.ts +112 -0
- package/src/provider/reasoning-tag-buffer.ts +67 -0
- package/src/provider/stream-adapter.ts +515 -0
- package/src/provider/tool-call-buffer.ts +82 -0
- package/src/provider/warm-reuse.ts +125 -0
- package/src/run-agent.ts +178 -0
- package/src/types.ts +10 -0
|
@@ -0,0 +1,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
|
+
}
|