@mlx-node/agent 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/catalog.d.ts +26 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +44 -0
- package/dist/extensions/approval-detail.d.ts +9 -0
- package/dist/extensions/approval-detail.d.ts.map +1 -0
- package/dist/extensions/approval-detail.js +53 -0
- package/dist/extensions/permission-gate.d.ts +30 -0
- package/dist/extensions/permission-gate.d.ts.map +1 -0
- package/dist/extensions/permission-gate.js +309 -0
- package/dist/extensions/subagent.d.ts +82 -0
- package/dist/extensions/subagent.d.ts.map +1 -0
- package/dist/extensions/subagent.js +539 -0
- package/dist/extensions/terminal-title.d.ts +10 -0
- package/dist/extensions/terminal-title.d.ts.map +1 -0
- package/dist/extensions/terminal-title.js +45 -0
- package/dist/extensions/trace-notice.d.ts +11 -0
- package/dist/extensions/trace-notice.d.ts.map +1 -0
- package/dist/extensions/trace-notice.js +34 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/provider/chat-config.d.ts +34 -0
- package/dist/provider/chat-config.d.ts.map +1 -0
- package/dist/provider/chat-config.js +103 -0
- package/dist/provider/convert-messages.d.ts +59 -0
- package/dist/provider/convert-messages.d.ts.map +1 -0
- package/dist/provider/convert-messages.js +248 -0
- package/dist/provider/error-coercion.d.ts +19 -0
- package/dist/provider/error-coercion.d.ts.map +1 -0
- package/dist/provider/error-coercion.js +38 -0
- package/dist/provider/events.d.ts +67 -0
- package/dist/provider/events.d.ts.map +1 -0
- package/dist/provider/events.js +307 -0
- package/dist/provider/index.d.ts +28 -0
- package/dist/provider/index.d.ts.map +1 -0
- package/dist/provider/index.js +64 -0
- package/dist/provider/inference-trace.d.ts +58 -0
- package/dist/provider/inference-trace.d.ts.map +1 -0
- package/dist/provider/inference-trace.js +205 -0
- package/dist/provider/model-host.d.ts +94 -0
- package/dist/provider/model-host.d.ts.map +1 -0
- package/dist/provider/model-host.js +134 -0
- package/dist/provider/model-registry-filter.d.ts +36 -0
- package/dist/provider/model-registry-filter.d.ts.map +1 -0
- package/dist/provider/model-registry-filter.js +82 -0
- package/dist/provider/models.d.ts +35 -0
- package/dist/provider/models.d.ts.map +1 -0
- package/dist/provider/models.js +132 -0
- package/dist/provider/performance-status.d.ts +28 -0
- package/dist/provider/performance-status.d.ts.map +1 -0
- package/dist/provider/performance-status.js +91 -0
- package/dist/provider/reasoning-tag-buffer.d.ts +23 -0
- package/dist/provider/reasoning-tag-buffer.d.ts.map +1 -0
- package/dist/provider/reasoning-tag-buffer.js +60 -0
- package/dist/provider/stream-adapter.d.ts +61 -0
- package/dist/provider/stream-adapter.d.ts.map +1 -0
- package/dist/provider/stream-adapter.js +358 -0
- package/dist/provider/tool-call-buffer.d.ts +30 -0
- package/dist/provider/tool-call-buffer.d.ts.map +1 -0
- package/dist/provider/tool-call-buffer.js +75 -0
- package/dist/provider/warm-reuse.d.ts +73 -0
- package/dist/provider/warm-reuse.d.ts.map +1 -0
- package/dist/provider/warm-reuse.js +88 -0
- package/dist/run-agent.d.ts +62 -0
- package/dist/run-agent.d.ts.map +1 -0
- package/dist/run-agent.js +86 -0
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +42 -0
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"}
|