fix(context-window): Tighten context limits and bound memory excerpts (#67277)

* Tighten context limits and bound memory excerpts

* Align startup context defaults in config docs

* Align qmd memory_get bounds with shared limits

* Preserve qmd partial memory reads

* Fix shared memory read type import

* Add changelog entry for context bounds
This commit is contained in:
Tak Hoffman
2026-04-15 13:06:02 -05:00
committed by GitHub
parent 89d2c145df
commit 4f00b76925
57 changed files with 1628 additions and 155 deletions

View File

@@ -9,7 +9,14 @@ export type SearchImpl = (opts?: {
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
}) => Promise<unknown[]>;
export type MemoryReadParams = { relPath: string; from?: number; lines?: number };
export type MemoryReadResult = { text: string; path: string };
export type MemoryReadResult = {
text: string;
path: string;
truncated?: boolean;
from?: number;
lines?: number;
nextFrom?: number;
};
type MemoryBackend = "builtin" | "qmd";
let backend: MemoryBackend = "builtin";
@@ -19,6 +26,8 @@ let searchImpl: SearchImpl = async () => [];
let readFileImpl: (params: MemoryReadParams) => Promise<MemoryReadResult> = async (params) => ({
text: "",
path: params.relPath,
from: params.from ?? 1,
lines: params.lines ?? 120,
});
const stubManager = {
@@ -94,7 +103,12 @@ export function resetMemoryToolMockState(overrides?: {
searchImpl = overrides?.searchImpl ?? (async () => []);
readFileImpl =
overrides?.readFileImpl ??
(async (params: MemoryReadParams) => ({ text: "", path: params.relPath }));
(async (params: MemoryReadParams) => ({
text: "",
path: params.relPath,
from: params.from ?? 1,
lines: params.lines ?? 120,
}));
vi.clearAllMocks();
}

View File

@@ -56,7 +56,92 @@ describe("MemoryIndexManager.readFile", () => {
from: 2,
lines: 1,
});
expect(result).toEqual({ text: "line 2", path: relPath });
expect(result).toEqual({
text: "line 2\n\n[More content available. Use from=3 to continue.]",
path: relPath,
from: 2,
lines: 1,
truncated: true,
nextFrom: 3,
});
});
it("returns a default-sized excerpt when no line range is provided", async () => {
const relPath = "memory/default-window.md";
const absPath = path.join(workspaceDir, relPath);
await fs.mkdir(path.dirname(absPath), { recursive: true });
await fs.writeFile(
absPath,
Array.from({ length: 150 }, (_, index) => `line ${index + 1}`).join("\n"),
"utf-8",
);
const result = await readMemoryFile({
workspaceDir,
extraPaths: [],
relPath,
});
expect(result.path).toBe(relPath);
expect(result.from).toBe(1);
expect(result.lines).toBe(120);
expect(result.truncated).toBe(true);
expect(result.nextFrom).toBe(121);
expect(result.text).toContain("line 1");
expect(result.text).toContain("line 120");
expect(result.text).not.toContain("line 121");
expect(result.text).toContain("Use from=121 to continue.");
});
it("returns a bounded window when from is provided without lines", async () => {
const relPath = "memory/from-only.md";
const absPath = path.join(workspaceDir, relPath);
await fs.mkdir(path.dirname(absPath), { recursive: true });
await fs.writeFile(
absPath,
Array.from({ length: 160 }, (_, index) => `line ${index + 1}`).join("\n"),
"utf-8",
);
const result = await readMemoryFile({
workspaceDir,
extraPaths: [],
relPath,
from: 21,
});
expect(result.from).toBe(21);
expect(result.lines).toBe(120);
expect(result.truncated).toBe(true);
expect(result.nextFrom).toBe(141);
expect(result.text).toContain("line 21");
expect(result.text).toContain("line 140");
expect(result.text).not.toContain("line 141");
});
it("honors injected defaultLines and maxChars overrides", async () => {
const relPath = "memory/agent-limits.md";
const absPath = path.join(workspaceDir, relPath);
await fs.mkdir(path.dirname(absPath), { recursive: true });
await fs.writeFile(
absPath,
Array.from({ length: 40 }, (_, index) => `line ${index + 1}: ${"x".repeat(40)}`).join("\n"),
"utf-8",
);
const result = await readMemoryFile({
workspaceDir,
extraPaths: [],
relPath,
defaultLines: 5,
maxChars: 220,
});
expect(result.from).toBe(1);
expect(result.lines).toBeLessThanOrEqual(5);
expect(result.truncated).toBe(true);
expect(result.nextFrom).toBeGreaterThan(1);
expect(result.text).toContain("Use from=");
});
it("returns empty text when the requested slice is past EOF", async () => {
@@ -72,7 +157,87 @@ describe("MemoryIndexManager.readFile", () => {
from: 10,
lines: 5,
});
expect(result).toEqual({ text: "", path: relPath });
expect(result).toEqual({ text: "", path: relPath, from: 10, lines: 0 });
});
it("caps returned text to the default max chars and exposes continuation metadata", async () => {
const relPath = "memory/char-cap.md";
const absPath = path.join(workspaceDir, relPath);
await fs.mkdir(path.dirname(absPath), { recursive: true });
await fs.writeFile(
absPath,
Array.from({ length: 200 }, (_, index) => `${index + 1}: ${"x".repeat(200)}`).join("\n"),
"utf-8",
);
const result = await readMemoryFile({
workspaceDir,
extraPaths: [],
relPath,
});
expect(result.truncated).toBe(true);
expect(result.nextFrom).toBeGreaterThan(1);
expect(result.lines).toBeLessThan(120);
expect(result.text.length).toBeLessThanOrEqual(12_000 + 64);
expect(result.text).toContain("Use from=");
});
it("suggests read fallback for pathological single-line truncation in workspace memory files", async () => {
const relPath = "memory/oversized-line.md";
const absPath = path.join(workspaceDir, relPath);
await fs.mkdir(path.dirname(absPath), { recursive: true });
await fs.writeFile(absPath, `1: ${"x".repeat(20_000)}`, "utf-8");
const result = await readMemoryFile({
workspaceDir,
extraPaths: [],
relPath,
});
expect(result.truncated).toBe(true);
expect(result.lines).toBe(1);
expect(result.nextFrom).toBeUndefined();
expect(result.text).toContain("use read on the source file");
expect(result.text).not.toContain("Use from=");
});
it("does not advertise line continuation when a single oversized line is cut mid-line", async () => {
const relPath = "memory/oversized-line-with-tail.md";
const absPath = path.join(workspaceDir, relPath);
await fs.mkdir(path.dirname(absPath), { recursive: true });
await fs.writeFile(absPath, [`1: ${"x".repeat(20_000)}`, "line 2"].join("\n"), "utf-8");
const result = await readMemoryFile({
workspaceDir,
extraPaths: [],
relPath,
});
expect(result.truncated).toBe(true);
expect(result.lines).toBe(1);
expect(result.nextFrom).toBeUndefined();
expect(result.text).not.toContain("Use from=");
});
it("omits truncation metadata when the full excerpt fits and no more lines remain", async () => {
const relPath = "memory/complete.md";
const absPath = path.join(workspaceDir, relPath);
await fs.mkdir(path.dirname(absPath), { recursive: true });
await fs.writeFile(absPath, ["alpha", "beta", "gamma"].join("\n"), "utf-8");
const result = await readMemoryFile({
workspaceDir,
extraPaths: [],
relPath,
});
expect(result).toEqual({
text: "alpha\nbeta\ngamma",
path: relPath,
from: 1,
lines: 3,
});
});
it("returns empty text when the file disappears after stat", async () => {
@@ -121,6 +286,7 @@ describe("MemoryIndexManager.readFile", () => {
it("allows additional memory paths and blocks symlinks", async () => {
await fs.mkdir(extraDir, { recursive: true });
await fs.writeFile(path.join(extraDir, "extra.md"), "Extra content.");
await fs.writeFile(path.join(extraDir, "oversized.md"), `1: ${"y".repeat(20_000)}`);
await expect(
readMemoryFile({
@@ -131,8 +297,18 @@ describe("MemoryIndexManager.readFile", () => {
).resolves.toEqual({
path: "extra/extra.md",
text: "Extra content.",
from: 1,
lines: 1,
});
const oversized = await readMemoryFile({
workspaceDir,
extraPaths: [extraDir],
relPath: "extra/oversized.md",
});
expect(oversized.truncated).toBe(true);
expect(oversized.text).not.toContain("use read on the source file");
const linkPath = path.join(extraDir, "linked.md");
let symlinkOk = true;
try {

View File

@@ -254,6 +254,8 @@ describe("QmdMemoryManager slugified path resolution", () => {
await expect(manager.readFile({ relPath: results[0].path })).resolves.toEqual({
path: actualRelative,
text: "line-1\nline-2\nline-3",
from: 1,
lines: 3,
});
});
@@ -324,6 +326,8 @@ describe("QmdMemoryManager slugified path resolution", () => {
await expect(manager.readFile({ relPath: results[0].path })).resolves.toEqual({
path: `qmd/${collectionName}/${actualRelative}`,
text: "vault memory",
from: 1,
lines: 1,
});
});
@@ -381,6 +385,8 @@ describe("QmdMemoryManager slugified path resolution", () => {
await expect(manager.readFile({ relPath: results[0].path })).resolves.toEqual({
path: exactRelative,
text: "exact slugified path",
from: 1,
lines: 1,
});
});
});

View File

@@ -2318,6 +2318,7 @@ describe("QmdMemoryManager", () => {
},
} as OpenClawConfig;
let expectedLimit = 0;
spawnMock.mockImplementation((cmd: string, args: string[]) => {
const child = createMockChild({ autoClose: false });
if (isMcporterCommand(cmd) && args[0] === "call") {
@@ -2325,7 +2326,7 @@ describe("QmdMemoryManager", () => {
const callArgs = JSON.parse(args[args.indexOf("--args") + 1]);
expect(callArgs).toMatchObject({
query: "hello",
limit: 6,
limit: expectedLimit,
minScore: 0,
collection: "workspace-main",
});
@@ -2338,7 +2339,8 @@ describe("QmdMemoryManager", () => {
return child;
});
const { manager } = await createManager();
const { manager, resolved } = await createManager();
expectedLimit = resolved.qmd?.limits.maxResults ?? 0;
await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
await manager.close();
});
@@ -2548,6 +2550,7 @@ describe("QmdMemoryManager", () => {
} as OpenClawConfig;
const selectors: string[] = [];
let expectedLimit = 0;
spawnMock.mockImplementation((cmd: string, args: string[]) => {
const child = createMockChild({ autoClose: false });
if (isMcporterCommand(cmd) && args[0] === "call") {
@@ -2564,7 +2567,7 @@ describe("QmdMemoryManager", () => {
expect(selector).toBe("qmd.search");
expect(callArgs).toMatchObject({
query: "hello",
limit: 6,
limit: expectedLimit,
minScore: 0,
});
emitAndClose(child, "stdout", JSON.stringify({ results: [] }));
@@ -2574,7 +2577,8 @@ describe("QmdMemoryManager", () => {
return child;
});
const { manager } = await createManager();
const { manager, resolved } = await createManager();
expectedLimit = resolved.qmd?.limits.maxResults ?? 0;
await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
expect(selectors).toEqual(["qmd.query", "qmd.search", "qmd.search"]);
@@ -2603,6 +2607,7 @@ describe("QmdMemoryManager", () => {
const selectors: string[] = [];
const collections: string[] = [];
let expectedLimit = 0;
spawnMock.mockImplementation((cmd: string, args: string[]) => {
const child = createMockChild({ autoClose: false });
if (isMcporterCommand(cmd) && args[0] === "call") {
@@ -2611,7 +2616,7 @@ describe("QmdMemoryManager", () => {
collections.push(String(callArgs.collection ?? ""));
expect(callArgs).toMatchObject({
query: "hello",
limit: 6,
limit: expectedLimit,
minScore: 0,
});
expect(callArgs).not.toHaveProperty("searches");
@@ -2623,7 +2628,8 @@ describe("QmdMemoryManager", () => {
return child;
});
const { manager } = await createManager();
const { manager, resolved } = await createManager();
expectedLimit = resolved.qmd?.limits.maxResults ?? 0;
await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
expect(selectors).toEqual(["qmd.hybrid_search", "qmd.hybrid_search"]);
@@ -3584,13 +3590,45 @@ describe("QmdMemoryManager", () => {
const { manager } = await createManager();
const result = await manager.readFile({ relPath, from: 10, lines: 3 });
expect(result.text).toBe("line-10\nline-11\nline-12");
expect(result).toEqual({
path: relPath,
text: "line-10\nline-11\nline-12\n\n[More content available. Use from=13 to continue.]",
from: 10,
lines: 3,
truncated: true,
nextFrom: 13,
});
expect(readFileSpy).not.toHaveBeenCalled();
await manager.close();
readFileSpy.mockRestore();
});
it("returns a bounded default excerpt for qmd memory reads without explicit lines", async () => {
const relPath = path.join("memory", "default-window.md");
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
await fs.writeFile(
path.join(workspaceDir, relPath),
Array.from({ length: 150 }, (_, index) => `line-${index + 1}`).join("\n"),
"utf-8",
);
const { manager } = await createManager();
const result = await manager.readFile({ relPath });
expect(result.path).toBe(relPath);
expect(result.from).toBe(1);
expect(result.lines).toBe(120);
expect(result.truncated).toBe(true);
expect(result.nextFrom).toBe(121);
expect(result.text).toContain("line-1");
expect(result.text).toContain("line-120");
expect(result.text).not.toContain("line-121");
expect(result.text).toContain("Use from=121 to continue.");
await manager.close();
});
it("returns empty text when qmd files are missing before or during read", async () => {
const relPath = path.join("memory", "qmd-window.md");
const absPath = path.join(workspaceDir, relPath);
@@ -3620,7 +3658,7 @@ describe("QmdMemoryManager", () => {
err.code = "ENOENT";
throw err;
}
return realOpen(target, options);
return await realOpen(target, options);
});
return () => openSpy.mockRestore();
},
@@ -4026,6 +4064,8 @@ describe("QmdMemoryManager", () => {
expect(readResult).toEqual({
path: "qmd/sessions-main/session-1.md",
text: "# Session session-1\n\nsession canary\n",
from: 1,
lines: 4,
});
} finally {
lstatSpy.mockRestore();

View File

@@ -29,7 +29,11 @@ import {
type SessionFileEntry,
} from "openclaw/plugin-sdk/memory-core-host-engine-qmd";
import {
buildMemoryReadResult,
buildMemoryReadResultFromSlice,
DEFAULT_MEMORY_READ_LINES,
isFileMissingError,
type MemoryReadResult,
requireNodeSqlite,
statRegularFile,
type MemoryEmbeddingProbeResult,
@@ -43,6 +47,7 @@ import {
type ResolvedQmdConfig,
type ResolvedQmdMcporterConfig,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import { resolveAgentContextLimits } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
import {
localeLowercasePreservingWhitespace,
normalizeLowercaseStringOrEmpty,
@@ -1180,7 +1185,7 @@ export class QmdMemoryManager implements MemorySearchManager {
relPath: string;
from?: number;
lines?: number;
}): Promise<{ text: string; path: string }> {
}): Promise<MemoryReadResult> {
const relPath = params.relPath?.trim();
if (!relPath) {
throw new Error("path required");
@@ -1193,18 +1198,38 @@ export class QmdMemoryManager implements MemorySearchManager {
if (statResult.missing) {
return { text: "", path: relPath };
}
const contextLimits = resolveAgentContextLimits(this.cfg, this.agentId);
if (params.from !== undefined || params.lines !== undefined) {
const partial = await this.readPartialText(absPath, params.from, params.lines);
const requestedCount = Math.max(
1,
params.lines ?? contextLimits?.memoryGetDefaultLines ?? DEFAULT_MEMORY_READ_LINES,
);
const partial = await this.readPartialText(absPath, params.from, requestedCount);
if (partial.missing) {
return { text: "", path: relPath };
}
return { text: partial.text, path: relPath };
return buildMemoryReadResultFromSlice({
selectedLines: partial.selectedLines,
relPath,
startLine: Math.max(1, params.from ?? 1),
moreSourceLinesRemain: partial.moreSourceLinesRemain,
maxChars: contextLimits?.memoryGetMaxChars,
suggestReadFallback: isDefaultMemoryPath(relPath),
});
}
const full = await this.readFullText(absPath);
if (full.missing) {
return { text: "", path: relPath };
}
return { text: full.text, path: relPath };
return buildMemoryReadResult({
content: full.text,
relPath,
from: params.from,
lines: params.lines,
defaultLines: contextLimits?.memoryGetDefaultLines ?? DEFAULT_MEMORY_READ_LINES,
maxChars: contextLimits?.memoryGetMaxChars,
suggestReadFallback: isDefaultMemoryPath(relPath),
});
}
status(): MemoryProviderStatus {
@@ -1919,7 +1944,10 @@ export class QmdMemoryManager implements MemorySearchManager {
absPath: string,
from?: number,
lines?: number,
): Promise<{ missing: true } | { missing: false; text: string }> {
): Promise<
| { missing: true }
| { missing: false; selectedLines: string[]; moreSourceLinesRemain: boolean }
> {
const start = Math.max(1, from ?? 1);
const count = Math.max(1, lines ?? Number.POSITIVE_INFINITY);
let handle;
@@ -1938,6 +1966,7 @@ export class QmdMemoryManager implements MemorySearchManager {
});
const selected: string[] = [];
let index = 0;
let moreSourceLinesRemain = false;
try {
for await (const line of rl) {
index += 1;
@@ -1945,6 +1974,7 @@ export class QmdMemoryManager implements MemorySearchManager {
continue;
}
if (selected.length >= count) {
moreSourceLinesRemain = true;
break;
}
selected.push(line);
@@ -1953,7 +1983,11 @@ export class QmdMemoryManager implements MemorySearchManager {
rl.close();
await handle.close();
}
return { missing: false, text: selected.slice(0, count).join("\n") };
return {
missing: false,
selectedLines: selected.slice(0, count),
moreSourceLinesRemain,
};
}
private async readFullText(

View File

@@ -60,7 +60,12 @@ beforeEach(() => {
source: "memory" as const,
},
],
readFileImpl: async (params: MemoryReadParams) => ({ text: "", path: params.relPath }),
readFileImpl: async (params: MemoryReadParams) => ({
text: "",
path: params.relPath,
from: params.from ?? 1,
lines: params.lines ?? 120,
}),
});
});
@@ -155,7 +160,7 @@ describe("memory tools", () => {
it("returns empty text without error when file does not exist (ENOENT)", async () => {
setMemoryReadFileImpl(async (_params: MemoryReadParams) => {
return { text: "", path: "memory/2026-02-19.md" };
return { text: "", path: "memory/2026-02-19.md", from: 1, lines: 0 };
});
const tool = createMemoryGetToolOrThrow();
@@ -164,6 +169,8 @@ describe("memory tools", () => {
expect(result.details).toEqual({
text: "",
path: "memory/2026-02-19.md",
from: 1,
lines: 0,
});
});
@@ -176,11 +183,37 @@ describe("memory tools", () => {
expect(result.details).toEqual({
text: "",
path: "memory/2026-02-19.md",
from: 1,
lines: 120,
});
expect(getReadAgentMemoryFileMockCalls()).toBe(1);
expect(getMemorySearchManagerMockCalls()).toBe(0);
});
it("returns truncation metadata and a continuation notice for partial memory_get results", async () => {
setMemoryBackend("builtin");
setMemoryReadFileImpl(async (params: MemoryReadParams) => ({
path: params.relPath,
text: "alpha\nbeta\n\n[More content available. Use from=41 to continue.]",
from: params.from ?? 1,
lines: 40,
truncated: true,
nextFrom: 41,
}));
const tool = createMemoryGetToolOrThrow();
const result = await tool.execute("call_partial", { path: "memory/partial.md" });
expect(result.details).toEqual({
path: "memory/partial.md",
text: "alpha\nbeta\n\n[More content available. Use from=41 to continue.]",
from: 1,
lines: 40,
truncated: true,
nextFrom: 41,
});
});
it("persists short-term recall events from memory_search tool hits", async () => {
const workspaceDir = await createTempWorkspace("memory-tools-recall-");
try {

View File

@@ -3,7 +3,6 @@ import {
jsonResult,
readNumberParam,
readStringParam,
type AnyAgentTool,
type OpenClawConfig,
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import type {
@@ -181,7 +180,7 @@ async function executeMemoryReadResult<T>(params: {
export function createMemorySearchTool(options: {
config?: OpenClawConfig;
agentSessionKey?: string;
}): AnyAgentTool | null {
}) {
return createMemoryTool({
options,
label: "Memory Search",
@@ -215,7 +214,9 @@ export function createMemorySearchTool(options: {
});
const searchStartedAt = Date.now();
let rawResults: MemorySearchResult[] = [];
let surfacedMemoryResults: Array<MemorySearchResult & { corpus: "memory" }> = [];
let surfacedMemoryResults: Array<
Record<string, unknown> & { corpus: "memory"; score: number; path: string }
> = [];
let provider: string | undefined;
let model: string | undefined;
let fallback: unknown;
@@ -320,13 +321,13 @@ export function createMemorySearchTool(options: {
export function createMemoryGetTool(options: {
config?: OpenClawConfig;
agentSessionKey?: string;
}): AnyAgentTool | null {
}) {
return createMemoryTool({
options,
label: "Memory Get",
name: "memory_get",
description:
"Safe snippet read from MEMORY.md or memory/*.md with optional from/lines; `corpus=wiki` reads from registered compiled-wiki supplements. Use after search to pull only the needed lines and keep context small.",
"Safe exact excerpt read from MEMORY.md or memory/*.md. Defaults to a bounded excerpt when lines are omitted, includes truncation/continuation info when more content exists, and `corpus=wiki` reads from registered compiled-wiki supplements.",
parameters: MemoryGetSchema,
execute:
({ cfg, agentId }) =>