4fcebcea0b
`MnemopiSessionState.retainMessages` hands `embed()` the chronological multi-turn transcript (oldest -> newest). A naive `slice(0, max)` cap landed on the oldest turns and dropped the most recent content, so every retained episode past the cap collapsed onto essentially the same prefix vector and dense recall could not match topics introduced after the first 8192 chars. `capInputs` now routes oversized inputs through `clipToWindow`, which keeps roughly half the cap from the head and half from the tail with a small `[...]` elision marker between them. Short inputs and array reference pass- through are unchanged. Falls back to a tail-only clip when `max` is too small to fit a useful split. Locked in by a new `embedding-input-cap.test.ts` case that pins markers at both ends of a 50k transcript and asserts both survive the clip. Fixes #3126
123 lines
4.0 KiB
TypeScript
123 lines
4.0 KiB
TypeScript
import { afterEach, describe, expect, it } from "bun:test";
|
|
import "./setup";
|
|
import {
|
|
embed,
|
|
resetEmbeddingProviderForTests,
|
|
setEmbeddingProviderForTests,
|
|
} from "@oh-my-pi/pi-mnemopi/core/embeddings";
|
|
import { withMnemopiRuntimeOptions } from "@oh-my-pi/pi-mnemopi/core/runtime-options";
|
|
|
|
/**
|
|
* Regression coverage for issue #3126: `MnemopiSessionState.retainMessages`
|
|
* passes the entire multi-turn transcript to `embed()`. Long sessions used to
|
|
* overflow whatever ctx the embedding server was started with — llama.cpp
|
|
* rejects oversized requests with `request (N tokens) exceeds the available
|
|
* context size`, OpenAI silently right-truncates. `embed()` now caps each
|
|
* input to `MNEMOPI_EMBEDDING_MAX_INPUT_CHARS` (default 8192) before the
|
|
* provider sees it.
|
|
*/
|
|
function captureProvider(): {
|
|
embed: (texts: readonly string[]) => AsyncGenerator<number[][]>;
|
|
calls: string[][];
|
|
} {
|
|
const calls: string[][] = [];
|
|
return {
|
|
calls,
|
|
async *embed(texts) {
|
|
calls.push([...texts]);
|
|
yield texts.map(text => [text.length]);
|
|
},
|
|
};
|
|
}
|
|
|
|
const ENV_KEY = "MNEMOPI_EMBEDDING_MAX_INPUT_CHARS";
|
|
|
|
function withEnvValue<T>(value: string | undefined, fn: () => Promise<T>): Promise<T> {
|
|
const previous = process.env[ENV_KEY];
|
|
if (value === undefined) delete process.env[ENV_KEY];
|
|
else process.env[ENV_KEY] = value;
|
|
return fn().finally(() => {
|
|
if (previous === undefined) delete process.env[ENV_KEY];
|
|
else process.env[ENV_KEY] = previous;
|
|
});
|
|
}
|
|
|
|
afterEach(() => {
|
|
resetEmbeddingProviderForTests();
|
|
});
|
|
|
|
describe("embed() input cap (#3126)", () => {
|
|
it("truncates oversized inputs to the default cap before reaching the provider", async () => {
|
|
const provider = captureProvider();
|
|
setEmbeddingProviderForTests(provider);
|
|
|
|
const huge = "x".repeat(40_000);
|
|
await withEnvValue(undefined, () => embed(["short", huge]));
|
|
|
|
expect(provider.calls).toHaveLength(1);
|
|
const [seenShort, seenHuge] = provider.calls[0] ?? [];
|
|
expect(seenShort).toBe("short");
|
|
expect(seenHuge?.length).toBe(8192);
|
|
});
|
|
|
|
it("honors MNEMOPI_EMBEDDING_MAX_INPUT_CHARS env override", async () => {
|
|
const provider = captureProvider();
|
|
setEmbeddingProviderForTests(provider);
|
|
|
|
await withEnvValue("1024", () => embed(["y".repeat(5000)]));
|
|
|
|
expect(provider.calls[0]?.[0]?.length).toBe(1024);
|
|
});
|
|
|
|
it("disables the cap when the env override is 0", async () => {
|
|
const provider = captureProvider();
|
|
setEmbeddingProviderForTests(provider);
|
|
|
|
const huge = "z".repeat(50_000);
|
|
await withEnvValue("0", () => embed([huge]));
|
|
|
|
expect(provider.calls[0]?.[0]).toBe(huge);
|
|
});
|
|
|
|
it("respects a constructor-scoped maxInputChars override", async () => {
|
|
const provider = captureProvider();
|
|
setEmbeddingProviderForTests(provider);
|
|
|
|
await withEnvValue(undefined, () =>
|
|
withMnemopiRuntimeOptions({ embeddings: { maxInputChars: 256 } }, () => embed(["w".repeat(10_000)])),
|
|
);
|
|
|
|
expect(provider.calls[0]?.[0]?.length).toBe(256);
|
|
});
|
|
|
|
it("preserves both ends of a chronological transcript via the head/tail clip", async () => {
|
|
const provider = captureProvider();
|
|
setEmbeddingProviderForTests(provider);
|
|
|
|
// `MnemopiSessionState.retainMessages` hands `embed()` the full
|
|
// chronological transcript. Before the head/tail clip, a `slice(0, max)`
|
|
// would land on the oldest turns and drop the most recent (and most
|
|
// semantically loaded) content. Verify both ends survive.
|
|
const earliest = "OPENING_TURN_MARKER";
|
|
const latest = "FINAL_TURN_MARKER";
|
|
const transcript = `${earliest}${"x".repeat(50_000)}${latest}`;
|
|
|
|
await withEnvValue(undefined, () => embed([transcript]));
|
|
|
|
const seen = provider.calls[0]?.[0] ?? "";
|
|
expect(seen.length).toBe(8192);
|
|
expect(seen.startsWith(earliest)).toBe(true);
|
|
expect(seen.endsWith(latest)).toBe(true);
|
|
expect(seen).toContain("[...]");
|
|
});
|
|
|
|
it("returns the original array reference when no input needs trimming", async () => {
|
|
const provider = captureProvider();
|
|
setEmbeddingProviderForTests(provider);
|
|
|
|
await withEnvValue("1024", () => embed(["fits", "still fits"]));
|
|
|
|
expect(provider.calls[0]).toEqual(["fits", "still fits"]);
|
|
});
|
|
});
|