fix: fixed tool-call compatibility and tiny runtime resolver behavior

- Updated tool-call handling to accept string and object arguments.
- Stored object tool-call args directly into block.partialArgs and block.arguments.
- Added compiled runtime module resolution honoring exports, main, and index fallbacks.
- Patched tiny runtime loading to install resolver stubs and load the resolved entry file.
This commit is contained in:
can1357
2026-06-04 05:40:17 +02:00
parent 40ba8c19cf
commit 7490967f0e
7 changed files with 269 additions and 15 deletions
@@ -851,7 +851,10 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
if (toolCall.id) block.id = toolCall.id;
if (toolCall.function?.name) block.name = toolCall.function.name;
let delta = "";
const rawArgs: unknown = toolCall.function?.arguments;
// The OpenAI SDK types `function.arguments` as a JSON string, but MiniMax-compatible
// hosts stream a fully-formed object instead. Model both shapes so the branches below
// narrow honestly rather than widening through `unknown`.
const rawArgs = toolCall.function?.arguments as string | Record<string, unknown> | undefined;
if (typeof rawArgs === "string") {
if (rawArgs.length > 0) {
delta = rawArgs;
@@ -868,10 +871,9 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
// single delta instead of the OpenAI JSON-string contract. Hold the object directly
// — no `[object Object]` round-trip through the string buffer — and serialize once for
// the wire delta that proxy servers forward verbatim as `input_json_delta`.
const objectArgs = rawArgs as Record<string, unknown>;
block.partialArgs = objectArgs;
block.arguments = objectArgs;
delta = JSON.stringify(objectArgs);
block.partialArgs = rawArgs;
block.arguments = rawArgs;
delta = JSON.stringify(rawArgs);
}
stream.push({
type: "toolcall_delta",
+3 -2
View File
@@ -26,7 +26,6 @@
### Changed
- Changed the `task` tool description to tag read-only agents and explicitly forbid assigning them file edits/commands or offloading reasoning to `quick_task`/`explore`.
- Changed Redis and SQL session storage initialization to load only indexed metadata (`size`, `mtimeMs`) instead of full session content
- Changed `SessionStorage` read paths to rely on backend-backed metadata/indexed storage, so session content is fetched on demand rather than cached as full in-memory mirrors
- Changed session-list slice reads to go through `SessionStorage.readTextSlices` across all backends, removing the file-only single-open branch and caller-managed buffers. `FileSessionStorage` now reads both windows via `peekFileEnds`, while Redis and SQL backends encode session content once per combined read.
@@ -37,6 +36,8 @@
### Fixed
- Fixed tiny-model startup in compiled binaries by resolving `@huggingface/transformers` and its runtime dependencies from the installed cache using `package.json` `exports`/`main` metadata, preventing module-resolution failures when launching models
- Fixed tiny runtime installation flow in compiled binaries by using the build-time resolved `@huggingface/transformers` version and ensuring the runtime lock directory’s parent exists before acquiring the install lock, preventing mismatch and setup failures on fresh installs
- Fixed the terminal protocol debug probe reusing one stable Kitty graphics id across repeated panels, which could move/replace an earlier swatch instead of rendering a new one.
- Fixed selector dialogs (the `ask` tool, hook prompts) collapsing to a single visible option on shorter terminals when options carried long descriptions: the highlighted option's wrapped description consumed the entire row budget, hiding every other option and making the menu feel unnavigable (down moved the lone visible entry, left/right did nothing). When the fully-expanded list overflows, `HookSelectorComponent` now renders a compact list — every option label stays on screen and only the highlighted option expands its description, truncated to the remaining rows — so the whole menu is always visible and the detail pane follows the cursor.
- Fixed `read` failing with "Path not found" on web URLs whose scheme `//` collapsed to a single `/` (e.g. `https:/github.com/...`), which happens when a URL is routed through Node's `path.normalize`/`path.resolve`. The fetch URL recognizer now accepts a single-slash scheme and repairs it back to `//` before fetching, so collapsed URLs resolve instead of falling through to filesystem lookup.
@@ -9292,4 +9293,4 @@ Initial public release.
- Git branch display in footer
- Message queueing during streaming responses
- OAuth integration for Gmail and Google Calendar access
- HTML export with syntax highlighting and collapsible sections
- HTML export with syntax highlighting and collapsible sections
@@ -1,10 +1,20 @@
#!/usr/bin/env bun
import { createRequire } from "node:module";
import * as path from "node:path";
const packageDir = path.join(import.meta.dir, "..");
const outputPath = path.join(packageDir, "dist", "omp");
// Transformers.js is an optional, native-heavy dependency that is never bundled
// into the binary; the tiny-model worker `bun install`s it into a runtime cache
// on first use. The `catalog:` spec cannot be resolved from inside the compiled
// bunfs (issue #1763), so embed the concrete installed version here for the
// worker to pin its runtime install against.
const transformersVersion = (
createRequire(import.meta.url)("@huggingface/transformers/package.json") as { version: string }
).version;
function shouldAdhocSignDarwinBinary(): boolean {
return process.platform === "darwin";
}
@@ -40,6 +50,8 @@ async function main(): Promise<void> {
"--keep-names",
"--define",
'process.env.PI_COMPILED="true"',
"--define",
`process.env.PI_TINY_TRANSFORMERS_VERSION=${JSON.stringify(transformersVersion)}`,
"--external",
"mupdf",
"--root",
@@ -0,0 +1,179 @@
import * as fs from "node:fs";
import * as Module from "node:module";
import * as path from "node:path";
/**
* Bun's compiled-binary module resolver only finds `<pkg>/index.js` for bare
* specifiers loaded from the *real* filesystem — it ignores `main`/`exports`
* (issue #1763). The tiny-model Transformers.js runtime is `bun install`ed into
* a cache directory at runtime, and its graph (`@huggingface/transformers` →
* `onnxruntime-node` → `onnxruntime-common`, plus an eager `require("sharp")`)
* all point `main`/`exports` at nested files, so the stock resolver cannot load
* any of them. We patch `Module._resolveFilename` to resolve those bare
* specifiers against the cache ourselves, honoring `main`/`exports`.
*
* This module is filesystem-pure aside from {@link installRuntimeModuleResolver}
* mutating the `node:module` resolver, so the resolution logic is unit-testable
* without a compiled binary.
*/
/** Conditions honored when resolving an `exports` map for a CommonJS `require`. */
const RUNTIME_CONDITIONS: Record<string, true> = { node: true, require: true, default: true };
/** Extension probes appended to a `main`/`exports` target that lacks one. */
const RUNTIME_EXTENSIONS: readonly string[] = [".js", ".cjs", ".mjs", ".json", ".node"];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Walk a conditional `exports` target (string, array of fallbacks, or a
* condition object) and return the first relative path that matches a runtime
* condition in declaration order. Returns `null` when nothing applies (e.g.
* `import`-only targets).
*/
export function selectConditionalTarget(target: unknown): string | null {
if (typeof target === "string") return target;
if (Array.isArray(target)) {
for (const entry of target) {
const resolved = selectConditionalTarget(entry);
if (resolved) return resolved;
}
return null;
}
if (isRecord(target)) {
for (const condition in target) {
if (!RUNTIME_CONDITIONS[condition]) continue;
const resolved = selectConditionalTarget(target[condition]);
if (resolved) return resolved;
}
}
return null;
}
/** Resolve a relative target inside a package to a concrete file path, probing extensions and `index`. */
function resolveFileTarget(pkgDir: string, relative: string): string | null {
const base = path.join(pkgDir, relative);
const candidates = [base, ...RUNTIME_EXTENSIONS.map(ext => base + ext)];
for (const candidate of candidates) {
try {
const stat = fs.statSync(candidate);
if (stat.isFile()) return candidate;
if (stat.isDirectory()) {
const indexed = resolveFileTarget(candidate, "index");
if (indexed) return indexed;
}
} catch {
// missing candidate — keep probing
}
}
return null;
}
function resolveExportsEntry(
pkgDir: string,
exports: Record<string, unknown>,
subpath: string | undefined,
): string | null {
let subpathMap = false;
for (const key in exports) {
subpathMap = key === "." || key.startsWith("./");
break;
}
if (subpathMap) {
const key = subpath ? `./${subpath}` : ".";
if (!(key in exports)) return null;
const target = selectConditionalTarget(exports[key]);
return target ? resolveFileTarget(pkgDir, target) : null;
}
// A bare condition map only describes the package root, so a subpath
// request falls through to plain path joining at the call site.
if (subpath) return null;
const target = selectConditionalTarget(exports);
return target ? resolveFileTarget(pkgDir, target) : null;
}
/**
* Split a bare specifier into its package name and optional subpath, handling
* scoped packages (`@scope/name/sub` → `@scope/name` + `sub`).
*/
export function splitBareSpecifier(specifier: string): { packageName: string; subpath: string | undefined } {
const segments = specifier.split("/");
const take = specifier.startsWith("@") ? 2 : 1;
const packageName = segments.slice(0, take).join("/");
const subpath = segments.length > take ? segments.slice(take).join("/") : undefined;
return { packageName, subpath };
}
/**
* Resolve a bare specifier against an installed `node_modules` directory,
* honoring `exports` (CommonJS conditions), then `main`, then `index.js`.
* Returns an absolute file path, or `null` when the package/entry is absent.
*/
export function resolveRuntimeModule(runtimeNodeModules: string, specifier: string): string | null {
const { packageName, subpath } = splitBareSpecifier(specifier);
const pkgDir = path.join(runtimeNodeModules, ...packageName.split("/"));
const manifest = readManifest(pkgDir);
if (!manifest) return subpath ? resolveFileTarget(pkgDir, subpath) : null;
const { exports } = manifest;
if (typeof exports === "string" || isRecord(exports)) {
const map = typeof exports === "string" ? { ".": exports } : exports;
const resolved = resolveExportsEntry(pkgDir, map, subpath);
if (resolved) return resolved;
}
if (subpath) return resolveFileTarget(pkgDir, subpath);
if (typeof manifest.main === "string") {
const resolved = resolveFileTarget(pkgDir, manifest.main);
if (resolved) return resolved;
}
return resolveFileTarget(pkgDir, "index.js");
}
function readManifest(pkgDir: string): Record<string, unknown> | null {
try {
const parsed: unknown = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8"));
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
interface ModuleResolver {
_resolveFilename(request: string, parent: unknown, isMain: boolean, options?: unknown): string;
}
const PATCHED = Symbol.for("omp.tiny.compiledRuntimeResolver");
interface ResolverOptions {
/** Absolute path to the runtime cache's `node_modules`. */
runtimeNodeModules: string;
/** Bare specifier → absolute file path overrides (e.g. `sharp` → no-op stub). */
stubs?: Record<string, string>;
}
/**
* Patch `node:module`'s resolver (idempotently) so bare specifiers that the
* stock compiled-binary resolver cannot find fall back to the runtime cache.
* Stock resolution is tried first, so this never changes behavior for modules
* that already resolve (the worker's own bundled imports, node builtins).
*/
export function installRuntimeModuleResolver({ runtimeNodeModules, stubs = {} }: ResolverOptions): void {
const resolver = (Module as unknown as { default?: ModuleResolver } & ModuleResolver).default ?? Module;
const target = resolver as unknown as ModuleResolver & { [PATCHED]?: boolean };
if (target[PATCHED]) return;
const original = target._resolveFilename.bind(target);
target._resolveFilename = (request: string, parent: unknown, isMain: boolean, options?: unknown): string => {
try {
return original(request, parent, isMain, options);
} catch (error) {
const stub = stubs[request];
if (stub) return stub;
const resolved = resolveRuntimeModule(runtimeNodeModules, request);
if (resolved) return resolved;
throw error;
}
};
target[PATCHED] = true;
}
+24 -2
View File
@@ -10,6 +10,7 @@ import type {
import { getTinyModelsCacheDir, isCompiledBinary, prompt } from "@oh-my-pi/pi-utils";
import packageJson from "../../package.json" with { type: "json" };
import tinyTitleSystemPrompt from "../prompts/system/tiny-title-system.md" with { type: "text" };
import { installRuntimeModuleResolver, resolveRuntimeModule } from "./compiled-runtime";
import { resolveTinyModelDevicePreference, type TinyModelDevice, tinyModelDeviceLoadOrder } from "./device";
import { resolveTinyModelDtypeOverride, type TinyModelDtype } from "./dtype";
import {
@@ -28,6 +29,7 @@ const STOP_DECODE_WINDOW_TOKENS = 32;
const MEMORY_COMPLETION_MAX_NEW_TOKENS = 256;
const TINY_TITLE_SYSTEM_PROMPT = prompt.render(tinyTitleSystemPrompt);
const TRANSFORMERS_PACKAGE = "@huggingface/transformers";
const COMPILED_TRANSFORMERS_VERSION = process.env.PI_TINY_TRANSFORMERS_VERSION;
const sourceRequire = createRequire(import.meta.url);
const INSTALL_LOCK_ATTEMPTS = 240;
const INSTALL_LOCK_SLEEP_MS = 250;
@@ -67,6 +69,7 @@ function resolveTransformersVersionSpec(): string {
manifest.optionalDependencies?.[TRANSFORMERS_PACKAGE] ?? manifest.dependencies?.[TRANSFORMERS_PACKAGE];
if (!versionSpec) throw new Error(`${TRANSFORMERS_PACKAGE} is missing from package.json optionalDependencies`);
if (!versionSpec.startsWith("catalog:")) return versionSpec;
if (COMPILED_TRANSFORMERS_VERSION) return COMPILED_TRANSFORMERS_VERSION;
const installed = sourceRequire(`${TRANSFORMERS_PACKAGE}/package.json`) as { version: string };
return installed.version;
}
@@ -117,6 +120,7 @@ function getTinyTitleRuntimeDir(): string {
async function acquireInstallLock(runtimeDir: string): Promise<() => Promise<void>> {
const lockDir = `${runtimeDir}.lock`;
await fs.mkdir(path.dirname(lockDir), { recursive: true });
for (let attempt = 0; attempt < INSTALL_LOCK_ATTEMPTS; attempt++) {
try {
await fs.mkdir(lockDir);
@@ -216,6 +220,23 @@ async function ensureCompiledTransformersRuntime(
}
}
/**
* Prepare the freshly-installed compiled runtime for loading: stub `sharp`
* (the tiny models are text-generation only, so the native image pipeline is
* dead weight) and patch the module resolver so Transformers.js's bare requires
* (`onnxruntime-node`, `onnxruntime-common`) resolve against the cache. Returns
* the absolute Transformers.js entrypoint to `require`.
*/
async function prepareCompiledRuntime(runtimeDir: string): Promise<string> {
const nodeModules = path.join(runtimeDir, "node_modules");
const sharpStub = path.join(runtimeDir, "omp-sharp-stub.cjs");
await Bun.write(sharpStub, "module.exports = {};\n");
installRuntimeModuleResolver({ runtimeNodeModules: nodeModules, stubs: { sharp: sharpStub } });
const entry = resolveRuntimeModule(nodeModules, TRANSFORMERS_PACKAGE);
if (!entry) throw new Error(`Unable to resolve ${TRANSFORMERS_PACKAGE} in compiled runtime at ${nodeModules}`);
return entry;
}
function configureTransformers(transformers: TransformersRuntime): TransformersRuntime {
transformers.env.cacheDir = getTinyModelsCacheDir();
transformers.env.allowLocalModels = false;
@@ -232,8 +253,9 @@ async function loadTransformers(
transformersRuntime = (async () => {
if (!isCompiledBinary()) return configureTransformers(sourceRequire(TRANSFORMERS_PACKAGE) as TransformersRuntime);
const runtimeDir = await ensureCompiledTransformersRuntime(transport, requestId, modelKey);
const require_ = createRequire(path.join(runtimeDir, "package.json"));
return configureTransformers(require_(TRANSFORMERS_PACKAGE) as TransformersRuntime);
const entry = await prepareCompiledRuntime(runtimeDir);
const require_ = createRequire(entry);
return configureTransformers(require_(entry) as TransformersRuntime);
})().catch(error => {
transformersRuntime = null;
throw error;
+4 -1
View File
@@ -1525,7 +1525,10 @@ export class TUI extends Container {
cursorPos === null
? null
: { row: paddedViewportTop + (cursorPos.row - newViewportTop), col: cursorPos.col };
return { kind: "deferredMutation", cursor: { position: mappedCursor, totalLines: this.#previousLines.length } };
return {
kind: "deferredMutation",
cursor: { position: mappedCursor, totalLines: this.#previousLines.length },
};
}
return { kind: "deferredShrink", paddedLength: this.#previousLines.length };
}
+40 -5
View File
@@ -2067,13 +2067,27 @@ describe("TUI terminal-state regressions", () => {
try {
tui.start();
await settle(term);
expect(visible(term).map(l => l.trim())).toEqual(["line-24", "line-25", "line-26", "line-27", "line-28", "line-29"]);
expect(visible(term).map(l => l.trim())).toEqual([
"line-24",
"line-25",
"line-26",
"line-27",
"line-28",
"line-29",
]);
// Delete one offscreen row (index 5): the last six rows are unchanged,
// only their absolute indices shift.
component.setLines([...initial.slice(0, 5), ...initial.slice(6)]);
tui.requestRender(true, { allowUnknownViewportMutation: true });
await settle(term);
expect(visible(term).map(l => l.trim())).toEqual(["line-24", "line-25", "line-26", "line-27", "line-28", "line-29"]);
expect(visible(term).map(l => l.trim())).toEqual([
"line-24",
"line-25",
"line-26",
"line-27",
"line-28",
"line-29",
]);
} finally {
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
tui.stop();
@@ -2096,7 +2110,14 @@ describe("TUI terminal-state regressions", () => {
component.setLines([...initial.slice(0, 5), ...initial.slice(6, -1), "prompt-updated"]);
tui.requestRender(true, { allowUnknownViewportMutation: true });
await settle(term);
expect(visible(term).map(l => l.trim())).toEqual(["line-24", "line-25", "line-26", "line-27", "line-28", "prompt-updated"]);
expect(visible(term).map(l => l.trim())).toEqual([
"line-24",
"line-25",
"line-26",
"line-27",
"line-28",
"prompt-updated",
]);
} finally {
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
tui.stop();
@@ -2121,7 +2142,14 @@ describe("TUI terminal-state regressions", () => {
component.setLines([...initial.slice(0, 5), ...initial.slice(6, -1), `prompt>ab${CURSOR_MARKER}`]);
tui.requestRender(true, { allowUnknownViewportMutation: true });
await settle(term);
expect(visible(term).map(l => l.trim())).toEqual(["line-24", "line-25", "line-26", "line-27", "line-28", "prompt>ab"]);
expect(visible(term).map(l => l.trim())).toEqual([
"line-24",
"line-25",
"line-26",
"line-27",
"line-28",
"prompt>ab",
]);
expect(term.getCursor()).toEqual({ row: 5, col: "prompt>ab".length });
} finally {
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
@@ -2146,7 +2174,14 @@ describe("TUI terminal-state regressions", () => {
component.setLines([...initial.slice(0, 5), ...initial.slice(6)]);
tui.requestRender();
await settle(term);
expect(visible(term).map(l => l.trim())).toEqual(["line-24", "line-25", "line-26", "line-27", "line-28", "line-29"]);
expect(visible(term).map(l => l.trim())).toEqual([
"line-24",
"line-25",
"line-26",
"line-27",
"line-28",
"line-29",
]);
} finally {
tui.stop();
}