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

@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
- Cron/announce delivery: suppress mixed-content isolated cron announce replies that end with `NO_REPLY` so trailing silent sentinels no longer leak summary text to the target channel. (#65004) thanks @neo1027144-creator.
- Plugins/bundled channels: partition bundled channel lazy caches by active bundled root so `OPENCLAW_BUNDLED_PLUGINS_DIR` flips stop reusing stale plugin, setup, secrets, and runtime state. (#67200) Thanks @gumadeiras.
- Packaging/plugins: prune common test/spec cargo from bundled plugin runtime dependencies and fail npm release validation if packaged test cargo reappears, keeping published tarballs leaner without plugin-specific special cases. (#67275) thanks @gumadeiras.
- Agents/context + Memory: trim default startup/skills prompt budgets, cap `memory_get` excerpts by default with explicit continuation metadata, and keep QMD reads aligned with the same bounded excerpt contract so long sessions pull less context by default without losing deterministic follow-up reads.
## 2026.4.15-beta.1
@@ -29,7 +30,6 @@ Docs: https://docs.openclaw.ai
- Docs/showcase: add a scannable hero, complete section jump links, and a responsive video grid for community examples. (#48493) Thanks @jchopard69.
### Fixes
- Security/approvals: redact secrets in exec approval prompts so inline approval review can no longer leak credential material in rendered prompt content. (#61077, #64790)
- CLI/configure: re-read the persisted config hash after writes so config updates stop failing with stale-hash races. (#64188, #66528)
- CLI/update: prune stale packaged `dist` chunks after npm upgrades and keep downgrade/verify inventory checks compat-safe so global upgrades stop failing on stale chunk imports. (#66959) Thanks @obviyus.

View File

@@ -1,4 +1,4 @@
900c26a9b060f1dfa712abfba877bd3bf9c7b0c9f2294faf9834038283ec24b6 config-baseline.json
d956a1d60f776bba712cb04374a4f5657cad95bb088b536c5e3e4e29d4a21328 config-baseline.core.json
32d4b07b5a5fbe1c8d299f60b1b9a17c5dc6fc743ec007db212336d7878f125e config-baseline.json
48d00213069fa979cacff0e268da241f01c09aa259c19bec86a68dbea4f21bea config-baseline.core.json
ef83a06633fc001b5b2535566939186ecb49d05cd1a90b40e54cc58d3e6e44e3 config-baseline.channel.json
5f5d4e850df6e9854a85b5d008236854ce185c707fdbb566efcf00f8c08b36e3 config-baseline.plugin.json

View File

@@ -177,6 +177,19 @@ and the effective agent skill allowlist when `agents.defaults.skills` or
This keeps the base prompt small while still enabling targeted skill usage.
The skills list budget is owned by the skills subsystem:
- Global default: `skills.limits.maxSkillsPromptChars`
- Per-agent override: `agents.list[].skillsLimits.maxSkillsPromptChars`
Generic bounded runtime excerpts use a different surface:
- `agents.defaults.contextLimits.*`
- `agents.list[].contextLimits.*`
That split keeps skills sizing separate from runtime read/injection sizing such
as `memory_get`, live tool results, and post-compaction AGENTS.md refreshes.
## Documentation
When available, the system prompt includes a **Documentation** section that points to the

View File

@@ -988,6 +988,142 @@ Default: `"once"`.
}
```
### Context budget ownership map
OpenClaw has multiple high-volume prompt/context budgets, and they are
intentionally split by subsystem instead of all flowing through one generic
knob.
- `agents.defaults.bootstrapMaxChars` /
`agents.defaults.bootstrapTotalMaxChars`:
normal workspace bootstrap injection.
- `agents.defaults.startupContext.*`:
one-shot `/new` and `/reset` startup prelude, including recent daily
`memory/*.md` files.
- `skills.limits.*`:
the compact skills list injected into the system prompt.
- `agents.defaults.contextLimits.*`:
bounded runtime excerpts and injected runtime-owned blocks.
- `memory.qmd.limits.*`:
indexed memory-search snippet and injection sizing.
Use the matching per-agent override only when one agent needs a different
budget:
- `agents.list[].skillsLimits.maxSkillsPromptChars`
- `agents.list[].contextLimits.*`
#### `agents.defaults.startupContext`
Controls the first-turn startup prelude injected on bare `/new` and `/reset`
runs.
```json5
{
agents: {
defaults: {
startupContext: {
enabled: true,
applyOn: ["new", "reset"],
dailyMemoryDays: 2,
maxFileBytes: 16384,
maxFileChars: 1200,
maxTotalChars: 2800,
},
},
},
}
```
#### `agents.defaults.contextLimits`
Shared defaults for bounded runtime context surfaces.
```json5
{
agents: {
defaults: {
contextLimits: {
memoryGetMaxChars: 12000,
memoryGetDefaultLines: 120,
toolResultMaxChars: 16000,
postCompactionMaxChars: 1800,
},
},
},
}
```
- `memoryGetMaxChars`: default `memory_get` excerpt cap before truncation
metadata and continuation notice are added.
- `memoryGetDefaultLines`: default `memory_get` line window when `lines` is
omitted.
- `toolResultMaxChars`: live tool-result cap used for persisted results and
overflow recovery.
- `postCompactionMaxChars`: AGENTS.md excerpt cap used during post-compaction
refresh injection.
#### `agents.list[].contextLimits`
Per-agent override for the shared `contextLimits` knobs. Omitted fields inherit
from `agents.defaults.contextLimits`.
```json5
{
agents: {
defaults: {
contextLimits: {
memoryGetMaxChars: 12000,
toolResultMaxChars: 16000,
},
},
list: [
{
id: "tiny-local",
contextLimits: {
memoryGetMaxChars: 6000,
toolResultMaxChars: 8000,
},
},
],
},
}
```
#### `skills.limits.maxSkillsPromptChars`
Global cap for the compact skills list injected into the system prompt. This
does not affect reading `SKILL.md` files on demand.
```json5
{
skills: {
limits: {
maxSkillsPromptChars: 18000,
},
},
}
```
#### `agents.list[].skillsLimits.maxSkillsPromptChars`
Per-agent override for the skills prompt budget.
```json5
{
agents: {
list: [
{
id: "tiny-local",
skillsLimits: {
maxSkillsPromptChars: 6000,
},
},
],
},
}
```
### `agents.defaults.imageMaxDimensionPx`
Max pixel size for the longest image side in transcript/tool image blocks before provider calls.

View File

@@ -16,9 +16,12 @@ OpenAI-style models average ~4 characters per token for English text.
OpenClaw assembles its own system prompt on every run. It includes:
- Tool list + short descriptions
- Skills list (only metadata; instructions are loaded on demand with `read`)
- Skills list (only metadata; instructions are loaded on demand with `read`).
The compact skills block is bounded by `skills.limits.maxSkillsPromptChars`,
with optional per-agent override at
`agents.list[].skillsLimits.maxSkillsPromptChars`.
- Self-update instructions
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present or `memory.md` as a lowercase fallback). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 150000). `memory/*.md` daily files are not part of the normal bootstrap prompt; they remain on-demand via memory tools on ordinary turns, but bare `/new` and `/reset` can prepend a one-shot startup-context block with recent daily memory for that first turn. That startup prelude is controlled by `agents.defaults.startupContext`.
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present or `memory.md` as a lowercase fallback). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 12000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 60000). `memory/*.md` daily files are not part of the normal bootstrap prompt; they remain on-demand via memory tools on ordinary turns, but bare `/new` and `/reset` can prepend a one-shot startup-context block with recent daily memory for that first turn. That startup prelude is controlled by `agents.defaults.startupContext`.
- Time (UTC + user timezone)
- Reply tags + heartbeat behavior
- Runtime metadata (host/OS/model/thinking)
@@ -36,6 +39,18 @@ Everything the model receives counts toward the context limit:
- Compaction summaries and pruning artifacts
- Provider wrappers or safety headers (not visible, but still counted)
Some runtime-heavy surfaces have their own explicit caps:
- `agents.defaults.contextLimits.memoryGetMaxChars`
- `agents.defaults.contextLimits.memoryGetDefaultLines`
- `agents.defaults.contextLimits.toolResultMaxChars`
- `agents.defaults.contextLimits.postCompactionMaxChars`
Per-agent overrides live under `agents.list[].contextLimits`. These knobs are
for bounded runtime excerpts and injected runtime-owned blocks. They are
separate from bootstrap limits, startup-context limits, and skills prompt
limits.
For images, OpenClaw downscales transcript/tool image payloads before provider calls.
Use `agents.defaults.imageMaxDimensionPx` (default: `1200`) to tune this:

View File

@@ -3,6 +3,6 @@ export const DEFAULT_BROWSER_EVALUATE_ENABLED = true;
export const DEFAULT_OPENCLAW_BROWSER_COLOR = "#FF4500";
export const DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME = "openclaw";
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "openclaw";
export const DEFAULT_AI_SNAPSHOT_MAX_CHARS = 80_000;
export const DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS = 10_000;
export const DEFAULT_AI_SNAPSHOT_MAX_CHARS = 40_000;
export const DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS = 8_000;
export const DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH = 6;

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 }) =>

View File

@@ -86,9 +86,9 @@ const DEFAULT_QMD_COMMAND_TIMEOUT_MS = 30_000;
const DEFAULT_QMD_UPDATE_TIMEOUT_MS = 120_000;
const DEFAULT_QMD_EMBED_TIMEOUT_MS = 120_000;
const DEFAULT_QMD_LIMITS: ResolvedQmdLimitsConfig = {
maxResults: 6,
maxSnippetChars: 700,
maxInjectedChars: 4_000,
maxResults: 4,
maxSnippetChars: 450,
maxInjectedChars: 2_200,
timeoutMs: DEFAULT_QMD_TIMEOUT_MS,
};
const DEFAULT_QMD_MCPORTER: ResolvedQmdMcporterConfig = {

View File

@@ -1,8 +1,16 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveAgentWorkspaceDir } from "../../../../src/agents/agent-scope.js";
import {
resolveAgentContextLimits,
resolveAgentWorkspaceDir,
} from "../../../../src/agents/agent-scope.js";
import { resolveMemorySearchConfig } from "../../../../src/agents/memory-search.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import {
buildMemoryReadResult,
DEFAULT_MEMORY_READ_LINES,
type MemoryReadResult,
} from "../../../../src/memory-host-sdk/host/read-file-shared.js";
import { isFileMissingError, statRegularFile } from "./fs-utils.js";
import { isMemoryPath, normalizeExtraMemoryPaths } from "./internal.js";
@@ -12,7 +20,9 @@ export async function readMemoryFile(params: {
relPath: string;
from?: number;
lines?: number;
}): Promise<{ text: string; path: string }> {
defaultLines?: number;
maxChars?: number;
}): Promise<MemoryReadResult> {
const rawPath = params.relPath.trim();
if (!rawPath) {
throw new Error("path required");
@@ -65,14 +75,15 @@ export async function readMemoryFile(params: {
}
throw err;
}
if (!params.from && !params.lines) {
return { text: content, path: relPath };
}
const fileLines = content.split("\n");
const start = Math.max(1, params.from ?? 1);
const count = Math.max(1, params.lines ?? fileLines.length);
const slice = fileLines.slice(start - 1, start - 1 + count);
return { text: slice.join("\n"), path: relPath };
return buildMemoryReadResult({
content,
relPath,
from: params.from,
lines: params.lines,
defaultLines: params.defaultLines ?? DEFAULT_MEMORY_READ_LINES,
maxChars: params.maxChars,
suggestReadFallback: allowedWorkspace,
});
}
export async function readAgentMemoryFile(params: {
@@ -81,16 +92,19 @@ export async function readAgentMemoryFile(params: {
relPath: string;
from?: number;
lines?: number;
}): Promise<{ text: string; path: string }> {
}): Promise<MemoryReadResult> {
const settings = resolveMemorySearchConfig(params.cfg, params.agentId);
if (!settings) {
throw new Error("memory search disabled");
}
const contextLimits = resolveAgentContextLimits(params.cfg, params.agentId);
return await readMemoryFile({
workspaceDir: resolveAgentWorkspaceDir(params.cfg, params.agentId),
extraPaths: settings.extraPaths,
relPath: params.relPath,
from: params.from,
lines: params.lines,
defaultLines: contextLimits?.memoryGetDefaultLines,
maxChars: contextLimits?.memoryGetMaxChars,
});
}

View File

@@ -1,6 +1,9 @@
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
import type {
AgentContextLimitsConfig,
AgentDefaultsConfig,
} from "../config/types.agent-defaults.js";
import type { OpenClawConfig } from "../config/types.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
@@ -23,6 +26,7 @@ export type ResolvedAgentConfig = {
skills?: AgentEntry["skills"];
memorySearch?: AgentEntry["memorySearch"];
humanDelay?: AgentEntry["humanDelay"];
contextLimits?: AgentContextLimitsConfig;
heartbeat?: AgentEntry["heartbeat"];
identity?: AgentEntry["identity"];
groupChat?: AgentEntry["groupChat"];
@@ -116,6 +120,10 @@ export function resolveAgentConfig(
skills: Array.isArray(entry.skills) ? entry.skills : undefined,
memorySearch: entry.memorySearch,
humanDelay: entry.humanDelay,
contextLimits:
typeof entry.contextLimits === "object" && entry.contextLimits
? { ...agentDefaults?.contextLimits, ...entry.contextLimits }
: agentDefaults?.contextLimits,
heartbeat: entry.heartbeat,
identity: entry.identity,
groupChat: entry.groupChat,
@@ -127,6 +135,17 @@ export function resolveAgentConfig(
};
}
export function resolveAgentContextLimits(
cfg: OpenClawConfig | undefined,
agentId?: string | null,
): AgentContextLimitsConfig | undefined {
const defaults = cfg?.agents?.defaults?.contextLimits;
if (!cfg || !agentId) {
return defaults;
}
return resolveAgentConfig(cfg, agentId)?.contextLimits ?? defaults;
}
export function resolveAgentWorkspaceDir(cfg: OpenClawConfig, agentId: string) {
const id = normalizeAgentId(agentId);
const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();

View File

@@ -86,6 +86,37 @@ describe("resolveAgentConfig", () => {
expect(resolveAgentConfig(cfg, "main")?.verboseDefault).toBe("on");
});
it("merges contextLimits from defaults with per-agent overrides", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
contextLimits: {
memoryGetMaxChars: 20_000,
memoryGetDefaultLines: 180,
toolResultMaxChars: 18_000,
},
},
list: [
{
id: "main",
skillsLimits: {
maxSkillsPromptChars: 30_000,
},
contextLimits: {
memoryGetMaxChars: 24_000,
},
},
],
},
};
expect(resolveAgentConfig(cfg, "main")?.contextLimits).toEqual({
memoryGetMaxChars: 24_000,
memoryGetDefaultLines: 180,
toolResultMaxChars: 18_000,
});
});
it("resolves explicit and effective model primary separately", () => {
const cfgWithStringDefault = {
agents: {

View File

@@ -19,6 +19,7 @@ import {
listAgentEntries,
listAgentIds,
resolveAgentConfig,
resolveAgentContextLimits,
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
@@ -29,6 +30,7 @@ export {
listAgentEntries,
listAgentIds,
resolveAgentConfig,
resolveAgentContextLimits,
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,

View File

@@ -83,8 +83,8 @@ export function stripThoughtSignatures<T>(
}) as T;
}
export const DEFAULT_BOOTSTRAP_MAX_CHARS = 20_000;
export const DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS = 150_000;
export const DEFAULT_BOOTSTRAP_MAX_CHARS = 12_000;
export const DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS = 60_000;
export const DEFAULT_BOOTSTRAP_PROMPT_TRUNCATION_WARNING_MODE = "once";
const MIN_BOOTSTRAP_FILE_BUDGET_CHARS = 64;
const BOOTSTRAP_HEAD_RATIO = 0.7;

View File

@@ -773,6 +773,8 @@ export async function compactEmbeddedPiSessionDirect(
const sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
agentId: sessionAgentId,
sessionKey: params.sessionKey,
config: params.config,
contextWindowTokens: ctxInfo.tokens,
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
allowedToolNames,
});

View File

@@ -114,6 +114,7 @@ import { handleRetryLimitExhaustion } from "./run/retry-limit.js";
import { resolveEffectiveRuntimeModel, resolveHookModelSelection } from "./run/setup.js";
import { mergeAttemptToolMediaPayloads } from "./run/tool-media-payloads.js";
import {
resolveLiveToolResultMaxChars,
sessionLikelyHasOversizedToolResults,
truncateOversizedToolResultsInSession,
} from "./tool-result-truncation.js";
@@ -1100,6 +1101,11 @@ export async function runEmbeddedPiAgent(
const truncResult = await truncateOversizedToolResultsInSession({
sessionFile: params.sessionFile,
contextWindowTokens: ctxInfo.tokens,
maxCharsOverride: resolveLiveToolResultMaxChars({
contextWindowTokens: ctxInfo.tokens,
cfg: params.config,
agentId: sessionAgentId,
}),
sessionId: params.sessionId,
sessionKey: params.sessionKey,
});
@@ -1125,10 +1131,16 @@ export async function runEmbeddedPiAgent(
}
if (!toolResultTruncationAttempted) {
const contextWindowTokens = ctxInfo.tokens;
const toolResultMaxChars = resolveLiveToolResultMaxChars({
contextWindowTokens,
cfg: params.config,
agentId: sessionAgentId,
});
const hasOversized = attempt.messagesSnapshot
? sessionLikelyHasOversizedToolResults({
messages: attempt.messagesSnapshot,
contextWindowTokens,
maxCharsOverride: toolResultMaxChars,
})
: false;
@@ -1141,6 +1153,7 @@ export async function runEmbeddedPiAgent(
const truncResult = await truncateOversizedToolResultsInSession({
sessionFile: params.sessionFile,
contextWindowTokens,
maxCharsOverride: toolResultMaxChars,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
});

View File

@@ -165,7 +165,10 @@ import {
installContextEngineLoopHook,
installToolResultContextGuard,
} from "../tool-result-context-guard.js";
import { truncateOversizedToolResultsInSessionManager } from "../tool-result-truncation.js";
import {
resolveLiveToolResultMaxChars,
truncateOversizedToolResultsInSessionManager,
} from "../tool-result-truncation.js";
import {
logProviderToolSchemaDiagnostics,
normalizeProviderToolSchemas,
@@ -870,6 +873,8 @@ export async function runEmbeddedAttempt(
sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
agentId: sessionAgentId,
sessionKey: params.sessionKey,
config: params.config,
contextWindowTokens: params.contextTokenBudget,
inputProvenance: params.inputProvenance,
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
allowedToolNames,
@@ -1935,11 +1940,22 @@ export async function runEmbeddedAttempt(
prompt: effectivePrompt,
contextTokenBudget,
reserveTokens,
toolResultMaxChars: resolveLiveToolResultMaxChars({
contextWindowTokens: contextTokenBudget,
cfg: params.config,
agentId: sessionAgentId,
}),
});
if (preemptiveCompaction.route === "truncate_tool_results_only") {
const toolResultMaxChars = resolveLiveToolResultMaxChars({
contextWindowTokens: contextTokenBudget,
cfg: params.config,
agentId: sessionAgentId,
});
const truncationResult = truncateOversizedToolResultsInSessionManager({
sessionManager,
contextWindowTokens: contextTokenBudget,
maxCharsOverride: toolResultMaxChars,
sessionFile: params.sessionFile,
sessionId: params.sessionId,
sessionKey: params.sessionKey,

View File

@@ -199,7 +199,7 @@ describe("preemptive-compaction", () => {
expect(potential.oversizedReducibleChars).toBeGreaterThan(0);
expect(potential.aggregateReducibleChars).toBeGreaterThan(0);
expect(potential.oversizedReducibleChars).toBeLessThan(desiredOverflowTokens * 4);
expect(potential.oversizedReducibleChars).toBeLessThan(potential.maxReducibleChars);
expect(potential.maxReducibleChars).toBeGreaterThan(desiredOverflowTokens * 4);
expect(result.route).toBe("truncate_tool_results_only");
expect(result.shouldCompact).toBe(false);

View File

@@ -1,12 +1,12 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { estimateTokens } from "@mariozechner/pi-coding-agent";
import { SAFETY_MARGIN, estimateMessagesTokens } from "../../compaction.js";
import { estimateToolResultReductionPotential } from "../tool-result-truncation.js";
import type { PreemptiveCompactionRoute } from "./preemptive-compaction.types.js";
import {
MIN_PROMPT_BUDGET_RATIO,
MIN_PROMPT_BUDGET_TOKENS,
} from "../../pi-compaction-constants.js";
import { estimateToolResultReductionPotential } from "../tool-result-truncation.js";
import type { PreemptiveCompactionRoute } from "./preemptive-compaction.types.js";
export const PREEMPTIVE_OVERFLOW_ERROR_TEXT =
"Context overflow: prompt too large for the model (precheck).";
@@ -44,6 +44,7 @@ export function shouldPreemptivelyCompactBeforePrompt(params: {
prompt: string;
contextTokenBudget: number;
reserveTokens: number;
toolResultMaxChars?: number;
}): {
route: PreemptiveCompactionRoute;
shouldCompact: boolean;
@@ -69,6 +70,7 @@ export function shouldPreemptivelyCompactBeforePrompt(params: {
const toolResultPotential = estimateToolResultReductionPotential({
messages: params.messages,
contextWindowTokens: params.contextTokenBudget,
maxCharsOverride: params.toolResultMaxChars,
});
const overflowChars = overflowTokens * ESTIMATED_CHARS_PER_TOKEN;
const truncationBufferChars = TRUNCATION_ROUTE_BUFFER_TOKENS * ESTIMATED_CHARS_PER_TOKEN;

View File

@@ -10,6 +10,7 @@ import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixture
let truncateToolResultText: typeof import("./tool-result-truncation.js").truncateToolResultText;
let truncateToolResultMessage: typeof import("./tool-result-truncation.js").truncateToolResultMessage;
let calculateMaxToolResultChars: typeof import("./tool-result-truncation.js").calculateMaxToolResultChars;
let calculateMaxToolResultCharsWithCap: typeof import("./tool-result-truncation.js").calculateMaxToolResultCharsWithCap;
let getToolResultTextLength: typeof import("./tool-result-truncation.js").getToolResultTextLength;
let truncateOversizedToolResultsInMessages: typeof import("./tool-result-truncation.js").truncateOversizedToolResultsInMessages;
let truncateOversizedToolResultsInSession: typeof import("./tool-result-truncation.js").truncateOversizedToolResultsInSession;
@@ -18,6 +19,7 @@ let sessionLikelyHasOversizedToolResults: typeof import("./tool-result-truncatio
let estimateToolResultReductionPotential: typeof import("./tool-result-truncation.js").estimateToolResultReductionPotential;
let DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS: typeof import("./tool-result-truncation.js").DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS;
let HARD_MAX_TOOL_RESULT_CHARS: typeof import("./tool-result-truncation.js").HARD_MAX_TOOL_RESULT_CHARS;
let resolveLiveToolResultMaxChars: typeof import("./tool-result-truncation.js").resolveLiveToolResultMaxChars;
let tmpDir: string | undefined;
async function loadFreshToolResultTruncationModuleForTest() {
@@ -25,6 +27,7 @@ async function loadFreshToolResultTruncationModuleForTest() {
truncateToolResultText,
truncateToolResultMessage,
calculateMaxToolResultChars,
calculateMaxToolResultCharsWithCap,
getToolResultTextLength,
truncateOversizedToolResultsInMessages,
truncateOversizedToolResultsInSession,
@@ -33,6 +36,7 @@ async function loadFreshToolResultTruncationModuleForTest() {
estimateToolResultReductionPotential,
DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS,
HARD_MAX_TOOL_RESULT_CHARS,
resolveLiveToolResultMaxChars,
} = await import("./tool-result-truncation.js"));
}
@@ -105,9 +109,9 @@ describe("truncateToolResultText", () => {
expect(result).toContain("truncated");
});
it("preserves at least MIN_KEEP_CHARS (2000)", () => {
it("preserves at least MIN_KEEP_CHARS (2000) when the budget allows it", () => {
const text = "x".repeat(50_000);
const result = truncateToolResultText(text, 100); // Even with small limit
const result = truncateToolResultText(text, 3_000);
expect(result.length).toBeGreaterThan(2000);
});
@@ -188,13 +192,13 @@ describe("truncateToolResultMessage", () => {
describe("calculateMaxToolResultChars", () => {
it("scales with context window size", () => {
const small = calculateMaxToolResultChars(32_000);
const small = calculateMaxToolResultChars(8_000);
const large = calculateMaxToolResultChars(200_000);
expect(large).toBeGreaterThan(small);
});
it("exports the live cap through both constant names", () => {
expect(DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS).toBe(40_000);
expect(DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS).toBe(16_000);
expect(HARD_MAX_TOOL_RESULT_CHARS).toBe(DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS);
});
@@ -207,6 +211,29 @@ describe("calculateMaxToolResultChars", () => {
const result = calculateMaxToolResultChars(128_000);
expect(result).toBe(DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS);
});
it("supports a higher configured hard cap", () => {
const result = calculateMaxToolResultCharsWithCap(128_000, 32_000);
expect(result).toBe(32_000);
});
it("resolves per-agent tool-result cap overrides", () => {
const result = resolveLiveToolResultMaxChars({
contextWindowTokens: 128_000,
cfg: {
agents: {
defaults: {
contextLimits: {
toolResultMaxChars: 24_000,
},
},
list: [{ id: "writer" }],
},
},
agentId: "writer",
});
expect(result).toBe(24_000);
});
});
describe("isOversizedToolResult", () => {
@@ -220,6 +247,11 @@ describe("isOversizedToolResult", () => {
expect(isOversizedToolResult(msg, 128_000)).toBe(true);
});
it("honors an explicit higher maxChars override", () => {
const msg = makeToolResult("x".repeat(20_000));
expect(isOversizedToolResult(msg, 128_000, 24_000)).toBe(false);
});
it("returns false for non-toolResult messages", () => {
const msg = makeUserMessage("x".repeat(500_000));
expect(isOversizedToolResult(msg, 128_000)).toBe(false);
@@ -261,7 +293,7 @@ describe("estimateToolResultReductionPotential", () => {
});
it("estimates reducible chars for aggregate medium tool-result tails", () => {
const medium = "alpha beta gamma delta epsilon ".repeat(600);
const medium = "alpha beta gamma delta epsilon ".repeat(400);
const messages: AgentMessage[] = [
makeToolResult(medium, "call_1"),
makeToolResult(medium, "call_2"),
@@ -300,6 +332,26 @@ describe("estimateToolResultReductionPotential", () => {
estimate.oversizedReducibleChars + estimate.aggregateReducibleChars,
);
});
it("lets tiny caps drive aggregate recovery estimates without the old floor", () => {
const medium = "alpha beta gamma delta epsilon ".repeat(600);
const messages: AgentMessage[] = [
makeToolResult(medium, "call_1"),
makeToolResult(medium, "call_2"),
makeToolResult(medium, "call_3"),
];
const estimate = estimateToolResultReductionPotential({
messages,
contextWindowTokens: 128_000,
maxCharsOverride: 120,
});
expect(estimate.maxChars).toBe(120);
expect(estimate.aggregateBudgetChars).toBe(120);
expect(estimate.oversizedCount).toBe(3);
expect(estimate.aggregateReducibleChars).toBeGreaterThan(0);
});
});
describe("truncateOversizedToolResultsInMessages", () => {
@@ -429,8 +481,8 @@ describe("truncateOversizedToolResultsInSession", () => {
const sm = SessionManager.create(dir, dir);
sm.appendMessage(makeUserMessage("hello"));
sm.appendMessage(makeAssistantMessage("calling tools"));
const olderLarge = "older-large ".repeat(2_000);
const newerEnough = "newer-enough ".repeat(1_400);
const olderLarge = "older-large ".repeat(1_000);
const newerEnough = "newer-enough ".repeat(500);
sm.appendMessage(makeToolResult(olderLarge, "call_1"));
sm.appendMessage(makeToolResult(newerEnough, "call_2"));
const sessionFile = sm.getSessionFile()!;
@@ -518,9 +570,46 @@ describe("truncateOversizedToolResultsInSession", () => {
);
expect(toolTexts[0]).toContain("truncated");
expect(toolTexts[1]).toContain("truncated");
expect(toolTexts[1].length).toBeGreaterThan(0);
expect(toolTexts[2].length).toBeGreaterThan(0);
});
it("lets aggregate recovery honor a tiny explicit cap during persisted rewrite", async () => {
const dir = await createTmpDir();
const sm = SessionManager.create(dir, dir);
sm.appendMessage(makeUserMessage("hello"));
sm.appendMessage(makeAssistantMessage("calling tools"));
const medium = "alpha beta gamma delta epsilon ".repeat(800);
sm.appendMessage(makeToolResult(medium, "call_1"));
sm.appendMessage(makeToolResult(medium, "call_2"));
sm.appendMessage(makeToolResult(medium, "call_3"));
const sessionFile = sm.getSessionFile()!;
const result = await truncateOversizedToolResultsInSession({
sessionFile,
contextWindowTokens: 128_000,
maxCharsOverride: 120,
});
expect(result.truncated).toBe(true);
const afterBranch = SessionManager.open(sessionFile).getBranch();
const toolResults = afterBranch.filter(
(entry) => entry.type === "message" && entry.message.role === "toolResult",
);
const totalChars = toolResults.reduce(
(sum, entry) => sum + (entry.type === "message" ? getToolResultTextLength(entry.message) : 0),
0,
);
expect(totalChars).toBeLessThanOrEqual(120);
expect(
toolResults.some((entry) =>
entry.type === "message"
? getFirstToolResultText(entry.message).includes("truncated")
: false,
),
).toBe(true);
});
});
describe("truncateToolResultText head+tail strategy", () => {

View File

@@ -1,9 +1,11 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { TextContent } from "@mariozechner/pi-ai";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
import { resolveAgentContextLimits } from "../agent-scope.js";
import { acquireSessionWriteLock } from "../session-write-lock.js";
import { log } from "./logger.js";
import { formatContextLimitTruncationNotice } from "./tool-result-context-guard.js";
@@ -23,7 +25,7 @@ const MAX_TOOL_RESULT_CONTEXT_SHARE = 0.3;
* for compaction summaries. For the live request path we still keep a bounded
* request-local ceiling so oversized tool output cannot dominate the next turn.
*/
export const DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS = 40_000;
export const DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS = 16_000;
/**
* Backwards-compatible alias for older call sites/tests.
@@ -46,7 +48,6 @@ type ToolResultTruncationOptions = {
const DEFAULT_SUFFIX = (truncatedChars: number) =>
formatContextLimitTruncationNotice(truncatedChars);
export const MIN_TRUNCATED_TEXT_CHARS = MIN_KEEP_CHARS + DEFAULT_SUFFIX(1).length;
const RECOVERY_MIN_TRUNCATED_TEXT_CHARS = RECOVERY_MIN_KEEP_CHARS + DEFAULT_SUFFIX(1).length;
function resolveSuffixFactory(
suffix: ToolResultTruncationOptions["suffix"],
@@ -60,6 +61,39 @@ function resolveSuffixFactory(
return DEFAULT_SUFFIX;
}
function resolveEffectiveMinKeepChars(params: {
maxChars: number;
minKeepChars: number;
suffixFactory: (truncatedChars: number) => string;
}): number {
const suffixFloor = params.suffixFactory(1).length;
return Math.max(0, Math.min(params.minKeepChars, Math.max(0, params.maxChars - suffixFloor)));
}
function appendBoundedTruncationSuffix(params: {
keptText: string;
originalTextLength: number;
maxChars: number;
suffixFactory: (truncatedChars: number) => string;
}): string {
const build = (keptText: string) =>
keptText + params.suffixFactory(Math.max(1, params.originalTextLength - keptText.length));
let keptText = params.keptText;
while (true) {
const finalText = build(keptText);
if (finalText.length <= params.maxChars) {
return finalText;
}
if (keptText.length === 0) {
return finalText.slice(0, params.maxChars);
}
const overflow = finalText.length - params.maxChars;
const nextKeptText = keptText.slice(0, Math.max(0, keptText.length - overflow));
keptText = nextKeptText.length < keptText.length ? nextKeptText : keptText.slice(0, -1);
}
}
/**
* Marker inserted between head and tail when using head+tail truncation.
*/
@@ -96,7 +130,11 @@ export function truncateToolResultText(
options: ToolResultTruncationOptions = {},
): string {
const suffixFactory = resolveSuffixFactory(options.suffix);
const minKeepChars = options.minKeepChars ?? MIN_KEEP_CHARS;
const minKeepChars = resolveEffectiveMinKeepChars({
maxChars,
minKeepChars: options.minKeepChars ?? MIN_KEEP_CHARS,
suffixFactory,
});
if (text.length <= maxChars) {
return text;
}
@@ -123,8 +161,12 @@ export function truncateToolResultText(
}
const keptText = text.slice(0, headCut) + MIDDLE_OMISSION_MARKER + text.slice(tailStart);
const suffix = suffixFactory(Math.max(1, text.length - keptText.length));
return keptText + suffix;
return appendBoundedTruncationSuffix({
keptText,
originalTextLength: text.length,
maxChars,
suffixFactory,
});
}
}
@@ -135,8 +177,12 @@ export function truncateToolResultText(
cutPoint = lastNewline;
}
const keptText = text.slice(0, cutPoint);
const suffix = suffixFactory(Math.max(1, text.length - keptText.length));
return keptText + suffix;
return appendBoundedTruncationSuffix({
keptText,
originalTextLength: text.length,
maxChars,
suffixFactory,
});
}
/**
@@ -147,10 +193,31 @@ export function truncateToolResultText(
* actual ratio varies by tokenizer).
*/
export function calculateMaxToolResultChars(contextWindowTokens: number): number {
return calculateMaxToolResultCharsWithCap(
contextWindowTokens,
DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS,
);
}
export function calculateMaxToolResultCharsWithCap(
contextWindowTokens: number,
hardCapChars: number,
): number {
const maxTokens = Math.floor(contextWindowTokens * MAX_TOOL_RESULT_CONTEXT_SHARE);
// Rough conversion: ~4 chars per token on average
const maxChars = maxTokens * 4;
return Math.min(maxChars, DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS);
return Math.min(maxChars, Math.max(1, hardCapChars));
}
export function resolveLiveToolResultMaxChars(params: {
contextWindowTokens: number;
cfg?: OpenClawConfig;
agentId?: string | null;
}): number {
const configuredCap =
resolveAgentContextLimits(params.cfg, params.agentId)?.toolResultMaxChars ??
DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS;
return calculateMaxToolResultCharsWithCap(params.contextWindowTokens, configuredCap);
}
/**
@@ -186,7 +253,11 @@ export function truncateToolResultMessage(
options: ToolResultTruncationOptions = {},
): AgentMessage {
const suffixFactory = resolveSuffixFactory(options.suffix);
const minKeepChars = options.minKeepChars ?? MIN_KEEP_CHARS;
const minKeepChars = resolveEffectiveMinKeepChars({
maxChars,
minKeepChars: options.minKeepChars ?? MIN_KEEP_CHARS,
suffixFactory,
});
const content = (msg as { content?: unknown }).content;
if (!Array.isArray(content)) {
return msg;
@@ -212,9 +283,10 @@ export function truncateToolResultMessage(
const defaultSuffix = suffixFactory(
Math.max(1, textBlock.text.length - Math.floor(maxChars * blockShare)),
);
const proportionalBudget = Math.floor(maxChars * blockShare);
const blockBudget = Math.max(
minKeepChars + defaultSuffix.length,
Math.floor(maxChars * blockShare),
1,
Math.min(maxChars, Math.max(minKeepChars + defaultSuffix.length, proportionalBudget)),
);
return {
...textBlock,
@@ -238,8 +310,12 @@ export function truncateToolResultMessage(
export function truncateOversizedToolResultsInMessages(
messages: AgentMessage[],
contextWindowTokens: number,
maxCharsOverride?: number,
): { messages: AgentMessage[]; truncatedCount: number } {
const maxChars = calculateMaxToolResultChars(contextWindowTokens);
const maxChars = Math.max(
1,
maxCharsOverride ?? calculateMaxToolResultChars(contextWindowTokens),
);
let truncatedCount = 0;
const result = messages.map((msg) => {
@@ -257,11 +333,11 @@ export function truncateOversizedToolResultsInMessages(
return { messages: result, truncatedCount };
}
function calculateRecoveryAggregateToolResultChars(contextWindowTokens: number): number {
return Math.max(
calculateMaxToolResultChars(contextWindowTokens),
RECOVERY_MIN_TRUNCATED_TEXT_CHARS,
);
function calculateRecoveryAggregateToolResultChars(
contextWindowTokens: number,
maxCharsOverride?: number,
): number {
return Math.max(1, maxCharsOverride ?? calculateMaxToolResultChars(contextWindowTokens));
}
export type ToolResultReductionPotential = {
@@ -481,10 +557,17 @@ function buildToolResultReplacementPlan(params: {
export function estimateToolResultReductionPotential(params: {
messages: AgentMessage[];
contextWindowTokens: number;
maxCharsOverride?: number;
}): ToolResultReductionPotential {
const { messages, contextWindowTokens } = params;
const maxChars = calculateMaxToolResultChars(contextWindowTokens);
const aggregateBudgetChars = calculateRecoveryAggregateToolResultChars(contextWindowTokens);
const maxChars = Math.max(
1,
params.maxCharsOverride ?? calculateMaxToolResultChars(contextWindowTokens),
);
const aggregateBudgetChars = calculateRecoveryAggregateToolResultChars(
contextWindowTokens,
maxChars,
);
const branch = messages.map((message, index) => ({
id: `message-${index}`,
type: "message",
@@ -527,13 +610,20 @@ export function estimateToolResultReductionPotential(params: {
function truncateOversizedToolResultsInExistingSessionManager(params: {
sessionManager: SessionManager;
contextWindowTokens: number;
maxCharsOverride?: number;
sessionFile?: string;
sessionId?: string;
sessionKey?: string;
}): { truncated: boolean; truncatedCount: number; reason?: string } {
const { sessionManager, contextWindowTokens } = params;
const maxChars = calculateMaxToolResultChars(contextWindowTokens);
const aggregateBudgetChars = calculateRecoveryAggregateToolResultChars(contextWindowTokens);
const maxChars = Math.max(
1,
params.maxCharsOverride ?? calculateMaxToolResultChars(contextWindowTokens),
);
const aggregateBudgetChars = calculateRecoveryAggregateToolResultChars(
contextWindowTokens,
maxChars,
);
const branch = sessionManager.getBranch() as ToolResultBranchEntry[];
if (branch.length === 0) {
@@ -578,6 +668,7 @@ function truncateOversizedToolResultsInExistingSessionManager(params: {
export function truncateOversizedToolResultsInSessionManager(params: {
sessionManager: SessionManager;
contextWindowTokens: number;
maxCharsOverride?: number;
sessionFile?: string;
sessionId?: string;
sessionKey?: string;
@@ -594,6 +685,7 @@ export function truncateOversizedToolResultsInSessionManager(params: {
export async function truncateOversizedToolResultsInSession(params: {
sessionFile: string;
contextWindowTokens: number;
maxCharsOverride?: number;
sessionId?: string;
sessionKey?: string;
}): Promise<{ truncated: boolean; truncatedCount: number; reason?: string }> {
@@ -606,6 +698,7 @@ export async function truncateOversizedToolResultsInSession(params: {
return truncateOversizedToolResultsInExistingSessionManager({
sessionManager,
contextWindowTokens,
maxCharsOverride: params.maxCharsOverride,
sessionFile,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
@@ -622,17 +715,25 @@ export async function truncateOversizedToolResultsInSession(params: {
/**
* Check if a tool result message exceeds the size limit for a given context window.
*/
export function isOversizedToolResult(msg: AgentMessage, contextWindowTokens: number): boolean {
export function isOversizedToolResult(
msg: AgentMessage,
contextWindowTokens: number,
maxCharsOverride?: number,
): boolean {
if ((msg as { role?: string }).role !== "toolResult") {
return false;
}
const maxChars = calculateMaxToolResultChars(contextWindowTokens);
const maxChars = Math.max(
1,
maxCharsOverride ?? calculateMaxToolResultChars(contextWindowTokens),
);
return getToolResultTextLength(msg) > maxChars;
}
export function sessionLikelyHasOversizedToolResults(params: {
messages: AgentMessage[];
contextWindowTokens: number;
maxCharsOverride?: number;
}): boolean {
const estimate = estimateToolResultReductionPotential(params);
return estimate.oversizedCount > 0 || estimate.aggregateReducibleChars > 0;

View File

@@ -41,11 +41,11 @@ type ToolContentBlock = AgentToolResult<unknown>["content"][number];
type ImageContentBlock = Extract<ToolContentBlock, { type: "image" }>;
type TextContentBlock = Extract<ToolContentBlock, { type: "text" }>;
const DEFAULT_READ_PAGE_MAX_BYTES = 50 * 1024;
const MAX_ADAPTIVE_READ_MAX_BYTES = 512 * 1024;
const ADAPTIVE_READ_CONTEXT_SHARE = 0.2;
const DEFAULT_READ_PAGE_MAX_BYTES = 32 * 1024;
const MAX_ADAPTIVE_READ_MAX_BYTES = 128 * 1024;
const ADAPTIVE_READ_CONTEXT_SHARE = 0.1;
const CHARS_PER_TOKEN_ESTIMATE = 4;
const MAX_ADAPTIVE_READ_PAGES = 8;
const MAX_ADAPTIVE_READ_PAGES = 4;
type OpenClawReadToolOptions = {
modelContextWindowTokens?: number;

View File

@@ -1,10 +1,12 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { SessionManager } from "@mariozechner/pi-coding-agent";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import {
applyInputProvenanceToUserMessage,
type InputProvenance,
} from "../sessions/input-provenance.js";
import { resolveLiveToolResultMaxChars } from "./pi-embedded-runner/tool-result-truncation.js";
import { installSessionToolResultGuard } from "./session-tool-result-guard.js";
export type GuardedSessionManager = SessionManager & {
@@ -23,6 +25,8 @@ export function guardSessionManager(
opts?: {
agentId?: string;
sessionKey?: string;
config?: OpenClawConfig;
contextWindowTokens?: number;
inputProvenance?: InputProvenance;
allowSyntheticToolResults?: boolean;
allowedToolNames?: Iterable<string>;
@@ -73,6 +77,14 @@ export function guardSessionManager(
allowSyntheticToolResults: opts?.allowSyntheticToolResults,
allowedToolNames: opts?.allowedToolNames,
beforeMessageWriteHook: beforeMessageWrite,
maxToolResultChars:
typeof opts?.contextWindowTokens === "number"
? resolveLiveToolResultMaxChars({
contextWindowTokens: opts.contextWindowTokens,
cfg: opts.config,
agentId: opts.agentId,
})
: undefined,
});
(sessionManager as GuardedSessionManager).flushPendingToolResults = guard.flushPendingToolResults;
(sessionManager as GuardedSessionManager).clearPendingToolResults = guard.clearPendingToolResults;

View File

@@ -169,6 +169,19 @@ describe("installSessionToolResultGuard", () => {
expect(text).toMatch(/\[\.\.\. \d+ more characters truncated\]$/);
});
it("honors tiny configured tool-result caps truthfully", () => {
const sm = SessionManager.inMemory();
installSessionToolResultGuard(sm, {
maxToolResultChars: 120,
});
appendToolResultText(sm, "x".repeat(80_000));
const text = getToolResultText(getPersistedMessages(sm));
expect(text.length).toBeLessThanOrEqual(120);
expect(text).toContain("truncated");
});
it("backfills blank toolResult names from pending tool calls", () => {
const sm = SessionManager.inMemory();
installSessionToolResultGuard(sm);

View File

@@ -134,6 +134,51 @@ describe("tool_result_persist hook", () => {
expect(toolResult.toolCallId).toBe("call_1");
expect(Array.isArray(toolResult.content)).toBe(true);
});
it("reapplies the cap after tool_result_persist expands a tool result", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-toolpersist-expand-"));
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writeTempPlugin({
dir: tmp,
id: "persist-expand",
body: `export default { id: "persist-expand", register(api) {
api.on("tool_result_persist", (event) => {
return {
message: {
...event.message,
content: [{ type: "text", text: "y".repeat(5000) }],
},
};
}, { priority: 10 });
} };`,
});
const registry = loadOpenClawPlugins({
cache: false,
workspaceDir: tmp,
config: {
plugins: {
load: { paths: [plugin] },
allow: ["persist-expand"],
},
},
});
initializeGlobalHookRunner(registry);
const sm = guardSessionManager(SessionManager.inMemory(), {
agentId: "main",
sessionKey: "main",
contextWindowTokens: 100,
});
appendToolCallAndResult(sm);
const toolResult = getPersistedToolResult(sm);
const text = toolResult.content.find((block: { type: string }) => block.type === "text")?.text;
expect(typeof text).toBe("string");
expect(text.length).toBeLessThanOrEqual(120);
expect(text).toContain("truncated");
});
});
describe("before_message_write hook", () => {
@@ -182,4 +227,50 @@ describe("before_message_write hook", () => {
expect(messages).toHaveLength(1);
expect(messages[0]?.role).toBe("user");
});
it("reapplies the cap after before_message_write expands a tool result", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-before-write-expand-"));
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writeTempPlugin({
dir: tmp,
id: "before-write-expand",
body: `export default { id: "before-write-expand", register(api) {
api.on("before_message_write", (event) => {
if (event.message?.role !== "toolResult") return;
return {
message: {
...event.message,
content: [{ type: "text", text: "z".repeat(5000) }],
},
};
}, { priority: 10 });
} };`,
});
const registry = loadOpenClawPlugins({
cache: false,
workspaceDir: tmp,
config: {
plugins: {
load: { paths: [plugin] },
allow: ["before-write-expand"],
},
},
});
initializeGlobalHookRunner(registry);
const sm = guardSessionManager(SessionManager.inMemory(), {
agentId: "main",
sessionKey: "main",
contextWindowTokens: 100,
});
appendToolCallAndResult(sm);
const toolResult = getPersistedToolResult(sm);
const text = toolResult.content.find((block: { type: string }) => block.type === "text")?.text;
expect(typeof text).toBe("string");
expect(text.length).toBeLessThanOrEqual(120);
expect(text).toContain("truncated");
});
});

View File

@@ -24,16 +24,20 @@ import { extractToolCallsFromAssistant, extractToolResultId } from "./tool-call-
* Returns the original message if under the limit, or a new message with
* truncated text blocks otherwise.
*/
function capToolResultSize(msg: AgentMessage): AgentMessage {
function capToolResultSize(msg: AgentMessage, maxChars: number): AgentMessage {
if ((msg as { role?: string }).role !== "toolResult") {
return msg;
}
return truncateToolResultMessage(msg, DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS, {
return truncateToolResultMessage(msg, maxChars, {
suffix: (truncatedChars) => formatContextLimitTruncationNotice(truncatedChars),
minKeepChars: 2_000,
});
}
function resolveMaxToolResultChars(opts?: { maxToolResultChars?: number }): number {
return Math.max(1, opts?.maxToolResultChars ?? DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS);
}
function normalizePersistedToolResultName(
message: AgentMessage,
fallbackName?: string,
@@ -99,6 +103,7 @@ export function installSessionToolResultGuard(
beforeMessageWriteHook?: (
event: PluginHookBeforeMessageWriteEvent,
) => PluginHookBeforeMessageWriteResult | undefined;
maxToolResultChars?: number;
},
): {
flushPendingToolResults: () => void;
@@ -123,6 +128,7 @@ export function installSessionToolResultGuard(
const allowSyntheticToolResults = opts?.allowSyntheticToolResults ?? true;
const beforeWrite = opts?.beforeMessageWriteHook;
const maxToolResultChars = resolveMaxToolResultChars(opts);
/**
* Run the before_message_write hook. Returns the (possibly modified) message,
@@ -157,7 +163,7 @@ export function installSessionToolResultGuard(
}),
);
if (flushed) {
originalAppend(flushed as never);
originalAppend(capToolResultSize(flushed, maxToolResultChars) as never);
}
}
}
@@ -194,7 +200,7 @@ export function installSessionToolResultGuard(
const normalizedToolResult = normalizePersistedToolResultName(nextMessage, toolName);
// Apply hard size cap before persistence to prevent oversized tool results
// from consuming the entire context window on subsequent LLM calls.
const capped = capToolResultSize(persistMessage(normalizedToolResult));
const capped = capToolResultSize(persistMessage(normalizedToolResult), maxToolResultChars);
const persisted = applyBeforeWriteHook(
persistToolResult(capped, {
toolCallId: id ?? undefined,
@@ -205,7 +211,7 @@ export function installSessionToolResultGuard(
if (!persisted) {
return undefined;
}
return originalAppend(persisted as never);
return originalAppend(capToolResultSize(persisted, maxToolResultChars) as never);
}
// Skip tool call extraction for aborted/errored assistant messages.

View File

@@ -293,6 +293,84 @@ describe("buildWorkspaceSkillsPrompt", () => {
expect(prompt).toContain(path.join(bundledSkillDir, "SKILL.md"));
});
it("applies per-agent skillsLimits.maxSkillsPromptChars", async () => {
const workspaceDir = await makeWorkspace();
for (const name of ["alpha-skill", "beta-skill", "gamma-skill"]) {
await writeSkill({
dir: path.join(workspaceDir, "skills", name),
name,
description: "D".repeat(240),
});
}
const prompt = withWorkspaceHome(workspaceDir, () =>
buildWorkspaceSkillsPrompt(workspaceDir, {
...resolveTestSkillDirs(workspaceDir),
config: {
skills: {
limits: {
maxSkillsPromptChars: 4_000,
},
},
agents: {
list: [
{
id: "writer",
workspace: workspaceDir,
skillsLimits: {
maxSkillsPromptChars: 220,
},
},
],
},
},
agentId: "writer",
}),
);
expect(prompt).toContain("Skills truncated: included 0 of 3");
});
it("does not apply agents.list[].skillsLimits without an explicit agent id", async () => {
const workspaceDir = await makeWorkspace();
for (const name of ["alpha-skill", "beta-skill", "gamma-skill"]) {
await writeSkill({
dir: path.join(workspaceDir, "skills", name),
name,
description: "D".repeat(240),
});
}
const prompt = withWorkspaceHome(workspaceDir, () =>
buildWorkspaceSkillsPrompt(workspaceDir, {
...resolveTestSkillDirs(workspaceDir),
config: {
skills: {
limits: {
maxSkillsPromptChars: 4_000,
},
},
agents: {
list: [
{
id: "main",
workspace: workspaceDir,
skillsLimits: {
maxSkillsPromptChars: 220,
},
},
],
},
},
}),
);
expect(prompt).not.toContain("Skills truncated:");
expect(prompt).toContain("alpha-skill");
expect(prompt).toContain("beta-skill");
expect(prompt).toContain("gamma-skill");
});
it("loads extra skill folders from config (lowest precedence)", async () => {
const workspaceDir = await makeWorkspace();
const extraDir = path.join(workspaceDir, ".extra");

View File

@@ -2,6 +2,21 @@ import type { OpenClawConfig } from "../../config/types.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { normalizeSkillFilter } from "./filter.js";
type AgentSkillsLimits = {
maxSkillsPromptChars?: number;
};
function resolveAgentEntry(
cfg: OpenClawConfig | undefined,
agentId: string | undefined,
): NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[number] | undefined {
if (!cfg) {
return undefined;
}
const normalizedAgentId = normalizeAgentId(agentId);
return cfg.agents?.list?.find((entry) => normalizeAgentId(entry.id) === normalizedAgentId);
}
/**
* Explicit per-agent skills win when present; otherwise fall back to shared defaults.
* Unknown agent ids also fall back to defaults so legacy/unresolved callers do not widen access.
@@ -13,12 +28,24 @@ export function resolveEffectiveAgentSkillFilter(
if (!cfg) {
return undefined;
}
const normalizedAgentId = normalizeAgentId(agentId);
const agentEntry = cfg.agents?.list?.find(
(entry) => normalizeAgentId(entry.id) === normalizedAgentId,
);
const agentEntry = resolveAgentEntry(cfg, agentId);
if (agentEntry && Object.hasOwn(agentEntry, "skills")) {
return normalizeSkillFilter(agentEntry.skills);
}
return normalizeSkillFilter(cfg.agents?.defaults?.skills);
}
export function resolveEffectiveAgentSkillsLimits(
cfg: OpenClawConfig | undefined,
agentId: string | undefined,
): AgentSkillsLimits | undefined {
if (!agentId) {
return undefined;
}
const agentEntry = resolveAgentEntry(cfg, agentId);
if (!agentEntry || !Object.hasOwn(agentEntry, "skillsLimits")) {
return undefined;
}
const { maxSkillsPromptChars } = agentEntry.skillsLimits ?? {};
return typeof maxSkillsPromptChars === "number" ? { maxSkillsPromptChars } : undefined;
}

View File

@@ -7,7 +7,10 @@ import { createSubsystemLogger } from "../../logging/subsystem.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { CONFIG_DIR, resolveHomeDir, resolveUserPath } from "../../utils.js";
import { resolveSandboxPath } from "../sandbox-paths.js";
import { resolveEffectiveAgentSkillFilter } from "./agent-filter.js";
import {
resolveEffectiveAgentSkillFilter,
resolveEffectiveAgentSkillsLimits,
} from "./agent-filter.js";
import { resolveBundledSkillsDir } from "./bundled-dir.js";
import { shouldIncludeSkill } from "./config.js";
import { normalizeSkillFilter } from "./filter.js";
@@ -114,7 +117,7 @@ function filterSkillEntries(
const DEFAULT_MAX_CANDIDATES_PER_ROOT = 300;
const DEFAULT_MAX_SKILLS_LOADED_PER_SOURCE = 200;
const DEFAULT_MAX_SKILLS_IN_PROMPT = 150;
const DEFAULT_MAX_SKILLS_PROMPT_CHARS = 30_000;
const DEFAULT_MAX_SKILLS_PROMPT_CHARS = 18_000;
const DEFAULT_MAX_SKILL_FILE_BYTES = 256_000;
type ResolvedSkillsLimits = {
@@ -125,14 +128,18 @@ type ResolvedSkillsLimits = {
maxSkillFileBytes: number;
};
function resolveSkillsLimits(config?: OpenClawConfig): ResolvedSkillsLimits {
function resolveSkillsLimits(config?: OpenClawConfig, agentId?: string): ResolvedSkillsLimits {
const limits = config?.skills?.limits;
const agentSkillsLimits = resolveEffectiveAgentSkillsLimits(config, agentId);
return {
maxCandidatesPerRoot: limits?.maxCandidatesPerRoot ?? DEFAULT_MAX_CANDIDATES_PER_ROOT,
maxSkillsLoadedPerSource:
limits?.maxSkillsLoadedPerSource ?? DEFAULT_MAX_SKILLS_LOADED_PER_SOURCE,
maxSkillsInPrompt: limits?.maxSkillsInPrompt ?? DEFAULT_MAX_SKILLS_IN_PROMPT,
maxSkillsPromptChars: limits?.maxSkillsPromptChars ?? DEFAULT_MAX_SKILLS_PROMPT_CHARS,
maxSkillsPromptChars:
agentSkillsLimits?.maxSkillsPromptChars ??
limits?.maxSkillsPromptChars ??
DEFAULT_MAX_SKILLS_PROMPT_CHARS,
maxSkillFileBytes: limits?.maxSkillFileBytes ?? DEFAULT_MAX_SKILL_FILE_BYTES,
};
}
@@ -346,7 +353,7 @@ function loadSkillEntries(
bundledSkillsDir?: string;
},
): SkillEntry[] {
const limits = resolveSkillsLimits(opts?.config);
const limits = resolveSkillsLimits(opts?.config, opts?.agentId);
const loadSkills = (params: { dir: string; source: string }): Skill[] => {
const rootDir = path.resolve(params.dir);
@@ -628,12 +635,16 @@ export function formatSkillsCompact(skills: Skill[]): string {
// Budget reserved for the compact-mode warning line prepended by the caller.
const COMPACT_WARNING_OVERHEAD = 150;
function applySkillsPromptLimits(params: { skills: Skill[]; config?: OpenClawConfig }): {
function applySkillsPromptLimits(params: {
skills: Skill[];
config?: OpenClawConfig;
agentId?: string;
}): {
skillsForPrompt: Skill[];
truncated: boolean;
compact: boolean;
} {
const limits = resolveSkillsLimits(params.config);
const limits = resolveSkillsLimits(params.config, params.agentId);
const total = params.skills.length;
const byCount = params.skills.slice(0, Math.max(0, limits.maxSkillsInPrompt));
@@ -752,6 +763,7 @@ function resolveWorkspaceSkillPromptState(
const { skillsForPrompt, truncated, compact } = applySkillsPromptLimits({
skills: promptSkills,
config: opts?.config,
agentId: opts?.agentId,
});
const truncationNote = truncated
? `⚠️ Skills truncated: included ${skillsForPrompt.length} of ${resolvedSkills.length}${compact ? " (compact format, descriptions omitted)" : ""}. Run \`openclaw skills check\` to audit.`

View File

@@ -40,8 +40,8 @@ export { extractReadableContent } from "./web-fetch-utils.js";
const EXTRACT_MODES = ["markdown", "text"] as const;
const DEFAULT_FETCH_MAX_CHARS = 50_000;
const DEFAULT_FETCH_MAX_RESPONSE_BYTES = 2_000_000;
const DEFAULT_FETCH_MAX_CHARS = 20_000;
const DEFAULT_FETCH_MAX_RESPONSE_BYTES = 750_000;
const FETCH_MAX_RESPONSE_BYTES_MIN = 32_000;
const FETCH_MAX_RESPONSE_BYTES_MAX = 10_000_000;
const DEFAULT_FETCH_MAX_REDIRECTS = 3;

View File

@@ -206,10 +206,10 @@ async function appendPostCompactionRefreshPrompt(params: {
cfg: OpenClawConfig;
followupRun: FollowupRun;
}): Promise<void> {
const refreshPrompt = await readPostCompactionContext(
params.followupRun.run.workspaceDir,
params.cfg,
);
const refreshPrompt = await readPostCompactionContext(params.followupRun.run.workspaceDir, {
cfg: params.cfg,
agentId: params.followupRun.run.agentId,
});
if (!refreshPrompt) {
return;
}

View File

@@ -1,5 +1,5 @@
import fs from "node:fs/promises";
import { hasConfiguredModelFallbacks } from "../../agents/agent-scope.js";
import { hasConfiguredModelFallbacks, resolveSessionAgentId } from "../../agents/agent-scope.js";
import { resolveContextTokensForModel } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { resolveModelAuthMode } from "../../agents/model-auth.js";
@@ -1558,7 +1558,10 @@ export async function runReplyAgent(params: {
// Inject post-compaction workspace context for the next agent turn
if (sessionKey) {
const workspaceDir = process.cwd();
readPostCompactionContext(workspaceDir, cfg)
readPostCompactionContext(workspaceDir, {
cfg,
agentId: resolveSessionAgentId({ sessionKey, config: cfg }),
})
.then((contextContent) => {
if (contextContent) {
enqueueSystemEvent(contextContent, { sessionKey });

View File

@@ -28,7 +28,7 @@ describe("readPostCompactionContext", () => {
},
},
} as OpenClawConfig;
const result = await readPostCompactionContext(tmpDir, cfg);
const result = await readPostCompactionContext(tmpDir, { cfg });
expect(result).not.toBeNull();
expect(result).toContain("Do startup things");
expect(result).toContain("Be safe");
@@ -118,6 +118,35 @@ Ignore this.
const result = await readPostCompactionContext(tmpDir);
expect(result).not.toBeNull();
expect(result).toContain("[truncated]");
expect(result!.length).toBeLessThan(2600);
});
it("honors per-agent post-compaction context limit overrides", async () => {
const longContent =
"## Session Startup\n\n" + "B".repeat(4000) + "\n\n## Red Lines\n\nGuardrails.";
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), longContent);
const cfg = {
agents: {
defaults: {
contextLimits: {
postCompactionMaxChars: 1800,
},
},
list: [
{
id: "writer",
contextLimits: {
postCompactionMaxChars: 300,
},
},
],
},
} as OpenClawConfig;
const result = await readPostCompactionContext(tmpDir, { cfg, agentId: "writer" });
expect(result).not.toBeNull();
expect(result).toContain("[truncated]");
expect(result!.length).toBeLessThan(1_200);
});
it("matches section names case-insensitively", async () => {
@@ -229,7 +258,7 @@ Never modify memory/YYYY-MM-DD.md destructively.
} as OpenClawConfig;
// 2026-03-03 14:00 UTC = 2026-03-03 09:00 EST
const nowMs = Date.UTC(2026, 2, 3, 14, 0, 0);
const result = await readPostCompactionContext(tmpDir, cfg, nowMs);
const result = await readPostCompactionContext(tmpDir, { cfg, nowMs });
expect(result).not.toBeNull();
expect(result).toContain("memory/2026-03-03.md");
expect(result).not.toContain("memory/YYYY-MM-DD.md");
@@ -245,7 +274,7 @@ Read WORKFLOW.md on startup.
`;
fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content);
const nowMs = Date.UTC(2026, 2, 3, 14, 0, 0);
const result = await readPostCompactionContext(tmpDir, undefined, nowMs);
const result = await readPostCompactionContext(tmpDir, { nowMs });
expect(result).not.toBeNull();
expect(result).toContain("Current time:");
});
@@ -273,7 +302,7 @@ Read WORKFLOW.md on startup.
},
},
} as OpenClawConfig;
const result = await readPostCompactionContext(tmpDir, cfg);
const result = await readPostCompactionContext(tmpDir, { cfg });
expect(result).not.toBeNull();
expect(result).toContain("Critical Rules");
expect(result).toContain("My custom rules");
@@ -292,7 +321,7 @@ Read WORKFLOW.md on startup.
},
},
} as OpenClawConfig;
const result = await readPostCompactionContext(tmpDir, cfg);
const result = await readPostCompactionContext(tmpDir, { cfg });
expect(result).not.toBeNull();
expect(result).toContain("Onboard things");
expect(result).toContain("Safe things");
@@ -309,7 +338,7 @@ Read WORKFLOW.md on startup.
},
},
} as OpenClawConfig;
const result = await readPostCompactionContext(tmpDir, cfg);
const result = await readPostCompactionContext(tmpDir, { cfg });
// Empty array = opt-out: no post-compaction context injection
expect(result).toBeNull();
});
@@ -324,7 +353,7 @@ Read WORKFLOW.md on startup.
},
},
} as OpenClawConfig;
const result = await readPostCompactionContext(tmpDir, cfg);
const result = await readPostCompactionContext(tmpDir, { cfg });
expect(result).toBeNull();
});
@@ -341,7 +370,7 @@ Read WORKFLOW.md on startup.
},
},
} as OpenClawConfig;
const result = await readPostCompactionContext(tmpDir, cfg);
const result = await readPostCompactionContext(tmpDir, { cfg });
expect(result).not.toBeNull();
// Must not reference the hardcoded default section name
expect(result).not.toContain("Session Startup");
@@ -378,7 +407,7 @@ Read WORKFLOW.md on startup.
},
},
} as OpenClawConfig;
const result = await readPostCompactionContext(tmpDir, cfg);
const result = await readPostCompactionContext(tmpDir, { cfg });
expect(result).not.toBeNull();
expect(result).toContain("Init things");
});

View File

@@ -1,12 +1,13 @@
import fs from "node:fs";
import path from "node:path";
import { resolveAgentContextLimits } from "../../agents/agent-scope.js";
import { resolveCronStyleNow } from "../../agents/current-time.js";
import { resolveUserTimezone } from "../../agents/date-time.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { openBoundaryFile } from "../../infra/boundary-file-read.js";
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
const MAX_CONTEXT_CHARS = 3000;
const MAX_CONTEXT_CHARS = 1800;
const DEFAULT_POST_COMPACTION_SECTIONS = ["Session Startup", "Red Lines"];
const LEGACY_POST_COMPACTION_SECTIONS = ["Every Session", "Safety"];
@@ -61,11 +62,19 @@ function formatDateStamp(nowMs: number, timezone: string): string {
* Substitutes YYYY-MM-DD placeholders with the real date so agents read the correct
* daily memory files instead of guessing based on training cutoff.
*/
export type PostCompactionContextOptions = {
cfg?: OpenClawConfig;
agentId?: string;
nowMs?: number;
};
export async function readPostCompactionContext(
workspaceDir: string,
cfg?: OpenClawConfig,
nowMs?: number,
options?: PostCompactionContextOptions,
): Promise<string | null> {
const cfg = options?.cfg;
const agentId = options?.agentId;
const effectiveNowMs = options?.nowMs;
const agentsPath = path.join(workspaceDir, "AGENTS.md");
try {
@@ -118,17 +127,19 @@ export async function readPostCompactionContext(
// Only reference section names that were actually found and injected.
const displayNames = foundSectionNames.length > 0 ? foundSectionNames : sectionNames;
const resolvedNowMs = nowMs ?? Date.now();
const resolvedNowMs = effectiveNowMs ?? Date.now();
const timezone = resolveUserTimezone(cfg?.agents?.defaults?.userTimezone);
const dateStamp = formatDateStamp(resolvedNowMs, timezone);
const maxContextChars =
resolveAgentContextLimits(cfg, agentId)?.postCompactionMaxChars ?? MAX_CONTEXT_CHARS;
// Always append the real runtime timestamp — AGENTS.md content may itself contain
// "Current time:" as user-authored text, so we must not gate on that substring.
const { timeLine } = resolveCronStyleNow(cfg ?? {}, resolvedNowMs);
const combined = sections.join("\n\n").replaceAll("YYYY-MM-DD", dateStamp);
const safeContent =
combined.length > MAX_CONTEXT_CHARS
? combined.slice(0, MAX_CONTEXT_CHARS) + "\n...[truncated]..."
combined.length > maxContextChars
? combined.slice(0, maxContextChars) + "\n...[truncated]..."
: combined;
// When using the default section set, use precise prose that names the

View File

@@ -5,8 +5,8 @@ import type { OpenClawConfig } from "../../config/config.js";
import { openBoundaryFile } from "../../infra/boundary-file-read.js";
const STARTUP_MEMORY_FILE_MAX_BYTES = 16_384;
const STARTUP_MEMORY_FILE_MAX_CHARS = 2_000;
const STARTUP_MEMORY_TOTAL_MAX_CHARS = 4_500;
const STARTUP_MEMORY_FILE_MAX_CHARS = 1_200;
const STARTUP_MEMORY_TOTAL_MAX_CHARS = 2_800;
const STARTUP_MEMORY_DAILY_DAYS = 2;
const STARTUP_MEMORY_FILE_MAX_BYTES_CAP = 64 * 1024;
const STARTUP_MEMORY_FILE_MAX_CHARS_CAP = 10_000;

View File

@@ -181,6 +181,34 @@ describe("config schema regressions", () => {
expect(res.ok).toBe(false);
});
it("accepts agents.defaults and agents.list contextLimits overrides", () => {
const res = validateConfigObject({
agents: {
defaults: {
contextLimits: {
memoryGetMaxChars: 20_000,
memoryGetDefaultLines: 180,
toolResultMaxChars: 24_000,
postCompactionMaxChars: 4_000,
},
},
list: [
{
id: "writer",
skillsLimits: {
maxSkillsPromptChars: 30_000,
},
contextLimits: {
memoryGetMaxChars: 24_000,
},
},
],
},
});
expect(res.ok).toBe(true);
});
it("accepts safe iMessage remoteHost", () => {
const res = IMessageConfigSchema.safeParse({
remoteHost: "bot@gateway-host",

View File

@@ -3329,7 +3329,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
maximum: 10000,
title: "Startup Context Max File Chars",
description:
"Maximum characters retained from each loaded daily memory file in the startup prelude (default: 2000).",
"Maximum characters retained from each loaded daily memory file in the startup prelude (default: 1200).",
},
maxTotalChars: {
type: "integer",
@@ -3337,7 +3337,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
maximum: 50000,
title: "Startup Context Max Total Chars",
description:
"Maximum total characters retained across all loaded daily memory files in the startup prelude (default: 4500). Additional files are truncated from the prelude once this cap is reached.",
"Maximum total characters retained across all loaded daily memory files in the startup prelude (default: 2800). Additional files are truncated from the prelude once this cap is reached.",
},
},
additionalProperties: false,
@@ -3345,6 +3345,47 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
description:
'Runtime-owned first-turn prelude for bare "/new" and "/reset". Use this to control whether recent daily memory files are preloaded into the first prompt instead of asking the model to decide what to read.',
},
contextLimits: {
type: "object",
properties: {
memoryGetMaxChars: {
type: "integer",
minimum: 1,
maximum: 250000,
title: "Default memory_get Max Chars",
description:
"Default max characters returned by memory_get before truncation metadata and continuation notice are added. Increase to approximate older larger excerpts, but keep it bounded.",
},
memoryGetDefaultLines: {
type: "integer",
minimum: 1,
maximum: 5000,
title: "Default memory_get Line Window",
description:
"Default memory_get line window used when requests omit lines. This controls how many source lines are selected before the max-char cap is applied.",
},
toolResultMaxChars: {
type: "integer",
minimum: 1,
maximum: 250000,
title: "Default Tool Result Max Chars",
description:
"Default max characters kept for a single live tool result before truncation. This affects both persisted live tool-result writes and overflow-recovery truncation heuristics.",
},
postCompactionMaxChars: {
type: "integer",
minimum: 1,
maximum: 50000,
title: "Default Post-compaction Max Chars",
description:
"Default max characters retained from AGENTS.md during post-compaction context refresh injection. Lower this to make compaction recovery cheaper, or raise it for agents that depend on longer startup guidance.",
},
},
additionalProperties: false,
title: "Default Context Limits",
description:
"Focused per-agent-context budget defaults for selected high-volume excerpts and injected prompt blocks. Use this to tune bounded read/injection sizes without reopening any unbounded call paths.",
},
timeFormat: {
anyOf: [
{
@@ -6068,6 +6109,64 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
},
additionalProperties: false,
},
skillsLimits: {
type: "object",
properties: {
maxSkillsPromptChars: {
type: "integer",
minimum: 0,
maximum: 9007199254740991,
title: "Agent Skills Prompt Max Chars",
description:
"Per-agent override for the skills prompt character budget. This extends the existing skills.limits.maxSkillsPromptChars path instead of routing the same budget through contextLimits.",
},
},
additionalProperties: false,
title: "Agent Skills Limits",
description:
"Optional per-agent overrides for skills subsystem budgets. Use this when an agent needs a different skills prompt budget without introducing a second generic context-limits path.",
},
contextLimits: {
type: "object",
properties: {
memoryGetMaxChars: {
type: "integer",
minimum: 1,
maximum: 250000,
title: "Agent memory_get Max Chars",
description:
"Per-agent override for the default memory_get max character budget.",
},
memoryGetDefaultLines: {
type: "integer",
minimum: 1,
maximum: 5000,
title: "Agent memory_get Line Window",
description:
"Per-agent override for the default memory_get line window when lines is omitted.",
},
toolResultMaxChars: {
type: "integer",
minimum: 1,
maximum: 250000,
title: "Agent Tool Result Max Chars",
description:
"Per-agent override for the live tool-result max character budget.",
},
postCompactionMaxChars: {
type: "integer",
minimum: 1,
maximum: 50000,
title: "Agent Post-compaction Max Chars",
description:
"Per-agent override for the post-compaction AGENTS.md excerpt budget.",
},
},
additionalProperties: false,
title: "Agent Context Limits",
description:
"Optional per-agent overrides for the focused context budget knobs. Omitted fields inherit agents.defaults.contextLimits.",
},
heartbeat: {
type: "object",
properties: {
@@ -23095,6 +23194,31 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
help: "Shared default settings inherited by agents unless overridden per entry in agents.list. Use defaults to enforce consistent baseline behavior and reduce duplicated per-agent configuration.",
tags: ["advanced"],
},
"agents.defaults.contextLimits": {
label: "Default Context Limits",
help: "Focused per-agent-context budget defaults for selected high-volume excerpts and injected prompt blocks. Use this to tune bounded read/injection sizes without reopening any unbounded call paths.",
tags: ["performance"],
},
"agents.defaults.contextLimits.memoryGetMaxChars": {
label: "Default memory_get Max Chars",
help: "Default max characters returned by memory_get before truncation metadata and continuation notice are added. Increase to approximate older larger excerpts, but keep it bounded.",
tags: ["performance"],
},
"agents.defaults.contextLimits.memoryGetDefaultLines": {
label: "Default memory_get Line Window",
help: "Default memory_get line window used when requests omit lines. This controls how many source lines are selected before the max-char cap is applied.",
tags: ["performance"],
},
"agents.defaults.contextLimits.toolResultMaxChars": {
label: "Default Tool Result Max Chars",
help: "Default max characters kept for a single live tool result before truncation. This affects both persisted live tool-result writes and overflow-recovery truncation heuristics.",
tags: ["performance"],
},
"agents.defaults.contextLimits.postCompactionMaxChars": {
label: "Default Post-compaction Max Chars",
help: "Default max characters retained from AGENTS.md during post-compaction context refresh injection. Lower this to make compaction recovery cheaper, or raise it for agents that depend on longer startup guidance.",
tags: ["performance"],
},
"agents.defaults.embeddedHarness": {
label: "Default Embedded Harness",
help: "Default embedded agent harness policy. Use runtime=auto for plugin harness selection, runtime=pi for built-in PI, or a registered harness id such as codex.",
@@ -23115,6 +23239,41 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
help: "Explicit list of configured agents with IDs and optional overrides for model, tools, identity, and workspace. Keep IDs stable over time so bindings, approvals, and session routing remain deterministic.",
tags: ["advanced"],
},
"agents.list[].skillsLimits": {
label: "Agent Skills Limits",
help: "Optional per-agent overrides for skills subsystem budgets. Use this when an agent needs a different skills prompt budget without introducing a second generic context-limits path.",
tags: ["performance"],
},
"agents.list[].skillsLimits.maxSkillsPromptChars": {
label: "Agent Skills Prompt Max Chars",
help: "Per-agent override for the skills prompt character budget. This extends the existing skills.limits.maxSkillsPromptChars path instead of routing the same budget through contextLimits.",
tags: ["performance"],
},
"agents.list[].contextLimits": {
label: "Agent Context Limits",
help: "Optional per-agent overrides for the focused context budget knobs. Omitted fields inherit agents.defaults.contextLimits.",
tags: ["performance"],
},
"agents.list[].contextLimits.memoryGetMaxChars": {
label: "Agent memory_get Max Chars",
help: "Per-agent override for the default memory_get max character budget.",
tags: ["performance"],
},
"agents.list[].contextLimits.memoryGetDefaultLines": {
label: "Agent memory_get Line Window",
help: "Per-agent override for the default memory_get line window when lines is omitted.",
tags: ["performance"],
},
"agents.list[].contextLimits.toolResultMaxChars": {
label: "Agent Tool Result Max Chars",
help: "Per-agent override for the live tool-result max character budget.",
tags: ["performance"],
},
"agents.list[].contextLimits.postCompactionMaxChars": {
label: "Agent Post-compaction Max Chars",
help: "Per-agent override for the post-compaction AGENTS.md excerpt budget.",
tags: ["performance"],
},
"agents.list.*.embeddedHarness": {
label: "Agent Embedded Harness",
help: "Per-agent embedded harness policy override. Use fallback=none to make this agent fail instead of falling back to PI.",
@@ -24570,12 +24729,12 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
},
"agents.defaults.startupContext.maxFileChars": {
label: "Startup Context Max File Chars",
help: "Maximum characters retained from each loaded daily memory file in the startup prelude (default: 2000).",
help: "Maximum characters retained from each loaded daily memory file in the startup prelude (default: 1200).",
tags: ["performance", "storage"],
},
"agents.defaults.startupContext.maxTotalChars": {
label: "Startup Context Max Total Chars",
help: "Maximum total characters retained across all loaded daily memory files in the startup prelude (default: 4500). Additional files are truncated from the prelude once this cap is reached.",
help: "Maximum total characters retained across all loaded daily memory files in the startup prelude (default: 2800). Additional files are truncated from the prelude once this cap is reached.",
tags: ["performance"],
},
"agents.defaults.envelopeTimezone": {

View File

@@ -196,8 +196,32 @@ export const FIELD_HELP: Record<string, string> = {
"Shared default settings inherited by agents unless overridden per entry in agents.list. Use defaults to enforce consistent baseline behavior and reduce duplicated per-agent configuration.",
"agents.defaults.skills":
"Optional default skill allowlist inherited by agents that omit agents.list[].skills. Omit for unrestricted skills, set [] to give inheriting agents no skills, and remember explicit agents.list[].skills replaces this default instead of merging with it.",
"agents.defaults.contextLimits":
"Focused per-agent-context budget defaults for selected high-volume excerpts and injected prompt blocks. Use this to tune bounded read/injection sizes without reopening any unbounded call paths.",
"agents.defaults.contextLimits.memoryGetMaxChars":
"Default max characters returned by memory_get before truncation metadata and continuation notice are added. Increase to approximate older larger excerpts, but keep it bounded.",
"agents.defaults.contextLimits.memoryGetDefaultLines":
"Default memory_get line window used when requests omit lines. This controls how many source lines are selected before the max-char cap is applied.",
"agents.defaults.contextLimits.toolResultMaxChars":
"Default max characters kept for a single live tool result before truncation. This affects both persisted live tool-result writes and overflow-recovery truncation heuristics.",
"agents.defaults.contextLimits.postCompactionMaxChars":
"Default max characters retained from AGENTS.md during post-compaction context refresh injection. Lower this to make compaction recovery cheaper, or raise it for agents that depend on longer startup guidance.",
"agents.list":
"Explicit list of configured agents with IDs and optional overrides for model, tools, identity, and workspace. Keep IDs stable over time so bindings, approvals, and session routing remain deterministic.",
"agents.list[].skillsLimits":
"Optional per-agent overrides for skills subsystem budgets. Use this when an agent needs a different skills prompt budget without introducing a second generic context-limits path.",
"agents.list[].skillsLimits.maxSkillsPromptChars":
"Per-agent override for the skills prompt character budget. This extends the existing skills.limits.maxSkillsPromptChars path instead of routing the same budget through contextLimits.",
"agents.list[].contextLimits":
"Optional per-agent overrides for the focused context budget knobs. Omitted fields inherit agents.defaults.contextLimits.",
"agents.list[].contextLimits.memoryGetMaxChars":
"Per-agent override for the default memory_get max character budget.",
"agents.list[].contextLimits.memoryGetDefaultLines":
"Per-agent override for the default memory_get line window when lines is omitted.",
"agents.list[].contextLimits.toolResultMaxChars":
"Per-agent override for the live tool-result max character budget.",
"agents.list[].contextLimits.postCompactionMaxChars":
"Per-agent override for the post-compaction AGENTS.md excerpt budget.",
"agents.list[].thinkingDefault":
"Optional per-agent default thinking level. Overrides agents.defaults.thinkingDefault for this agent when no per-message or session override is set.",
"agents.list[].reasoningDefault":
@@ -865,9 +889,9 @@ export const FIELD_HELP: Record<string, string> = {
"agents.defaults.startupContext.maxFileBytes":
"Maximum bytes allowed per daily memory file when building startup context (default: 16384). Files over this boundary-safe read limit are skipped.",
"agents.defaults.startupContext.maxFileChars":
"Maximum characters retained from each loaded daily memory file in the startup prelude (default: 2000).",
"Maximum characters retained from each loaded daily memory file in the startup prelude (default: 1200).",
"agents.defaults.startupContext.maxTotalChars":
"Maximum total characters retained across all loaded daily memory files in the startup prelude (default: 4500). Additional files are truncated from the prelude once this cap is reached.",
"Maximum total characters retained across all loaded daily memory files in the startup prelude (default: 2800). Additional files are truncated from the prelude once this cap is reached.",
"agents.defaults.repoRoot":
"Optional repository root shown in the system prompt runtime line (overrides auto-detect).",
"agents.defaults.envelopeTimezone":

View File

@@ -67,10 +67,22 @@ export const FIELD_LABELS: Record<string, string> = {
"agents.list[].fastModeDefault": "Agent Fast Mode Default",
agents: "Agents",
"agents.defaults": "Agent Defaults",
"agents.defaults.contextLimits": "Default Context Limits",
"agents.defaults.contextLimits.memoryGetMaxChars": "Default memory_get Max Chars",
"agents.defaults.contextLimits.memoryGetDefaultLines": "Default memory_get Line Window",
"agents.defaults.contextLimits.toolResultMaxChars": "Default Tool Result Max Chars",
"agents.defaults.contextLimits.postCompactionMaxChars": "Default Post-compaction Max Chars",
"agents.defaults.embeddedHarness": "Default Embedded Harness",
"agents.defaults.embeddedHarness.runtime": "Default Embedded Harness Runtime",
"agents.defaults.embeddedHarness.fallback": "Default Embedded Harness Fallback",
"agents.list": "Agent List",
"agents.list[].skillsLimits": "Agent Skills Limits",
"agents.list[].skillsLimits.maxSkillsPromptChars": "Agent Skills Prompt Max Chars",
"agents.list[].contextLimits": "Agent Context Limits",
"agents.list[].contextLimits.memoryGetMaxChars": "Agent memory_get Max Chars",
"agents.list[].contextLimits.memoryGetDefaultLines": "Agent memory_get Line Window",
"agents.list[].contextLimits.toolResultMaxChars": "Agent Tool Result Max Chars",
"agents.list[].contextLimits.postCompactionMaxChars": "Agent Post-compaction Max Chars",
"agents.list.*.embeddedHarness": "Agent Embedded Harness",
"agents.list.*.embeddedHarness.runtime": "Agent Embedded Harness Runtime",
"agents.list.*.embeddedHarness.fallback": "Agent Embedded Harness Fallback",

View File

@@ -59,12 +59,23 @@ export type AgentStartupContextConfig = {
dailyMemoryDays?: number;
/** Max bytes to read from each daily memory file before skipping (default: 16384). */
maxFileBytes?: number;
/** Max characters retained from each daily memory file (default: 2000). */
/** Max characters retained from each daily memory file (default: 1200). */
maxFileChars?: number;
/** Max total characters retained across the startup prelude (default: 4500). */
/** Max total characters retained across the startup prelude (default: 2800). */
maxTotalChars?: number;
};
export type AgentContextLimitsConfig = {
/** Default max chars returned by memory_get before truncation metadata/notice (default: 12000). */
memoryGetMaxChars?: number;
/** Default line window for memory_get when lines is omitted (default: 120). */
memoryGetDefaultLines?: number;
/** Max chars kept for a single live tool result before truncation (default: 16000). */
toolResultMaxChars?: number;
/** Max chars retained from post-compaction AGENTS.md context injection (default: 1800). */
postCompactionMaxChars?: number;
};
export type CliBackendConfig = {
/** CLI command to execute (absolute path or on PATH). */
command: string;
@@ -217,6 +228,8 @@ export type AgentDefaultsConfig = {
userTimezone?: string;
/** Runtime-owned first-turn startup context for bare /new and /reset. */
startupContext?: AgentStartupContextConfig;
/** Focused context-budget overrides for high-volume injected/read surfaces. */
contextLimits?: AgentContextLimitsConfig;
/** Time format in system prompt: auto (OS preference), 12-hour, or 24-hour. */
timeFormat?: "auto" | "12" | "24";
/**

View File

@@ -1,5 +1,9 @@
import type { ChatType } from "../channels/chat-type.js";
import type { AgentDefaultsConfig, EmbeddedPiExecutionContract } from "./types.agent-defaults.js";
import type {
AgentContextLimitsConfig,
AgentDefaultsConfig,
EmbeddedPiExecutionContract,
} from "./types.agent-defaults.js";
import type {
AgentEmbeddedHarnessConfig,
AgentModelConfig,
@@ -7,6 +11,7 @@ import type {
} from "./types.agents-shared.js";
import type { HumanDelayConfig, IdentityConfig } from "./types.base.js";
import type { GroupChatConfig } from "./types.messages.js";
import type { SkillsLimitsConfig } from "./types.skills.js";
import type { AgentToolsConfig, MemorySearchConfig } from "./types.tools.js";
export type AgentRuntimeAcpConfig = {
@@ -86,6 +91,10 @@ export type AgentConfig = {
memorySearch?: MemorySearchConfig;
/** Human-like delay between block replies for this agent. */
humanDelay?: HumanDelayConfig;
/** Optional per-agent skills subsystem overrides. */
skillsLimits?: Pick<SkillsLimitsConfig, "maxSkillsPromptChars">;
/** Optional per-agent overrides for selected context/token-heavy limits. */
contextLimits?: AgentContextLimitsConfig;
/** Optional per-agent heartbeat overrides. */
heartbeat?: AgentDefaultsConfig["heartbeat"];
identity?: IdentityConfig;

View File

@@ -64,6 +64,32 @@ describe("agent defaults schema", () => {
expect(result.embeddedPi?.executionContract).toBe("strict-agentic");
});
it("accepts focused contextLimits on defaults and agent entries", () => {
const defaults = AgentDefaultsSchema.parse({
contextLimits: {
memoryGetMaxChars: 20_000,
memoryGetDefaultLines: 200,
toolResultMaxChars: 24_000,
postCompactionMaxChars: 4_000,
},
})!;
const agent = AgentEntrySchema.parse({
id: "ops",
skillsLimits: {
maxSkillsPromptChars: 30_000,
},
contextLimits: {
memoryGetMaxChars: 18_000,
},
});
expect(defaults.contextLimits?.memoryGetMaxChars).toBe(20_000);
expect(defaults.contextLimits?.memoryGetDefaultLines).toBe(200);
expect(defaults.contextLimits?.toolResultMaxChars).toBe(24_000);
expect(agent.skillsLimits?.maxSkillsPromptChars).toBe(30_000);
expect(agent.contextLimits?.memoryGetMaxChars).toBe(18_000);
});
it("accepts positive heartbeat timeoutSeconds on defaults and agent entries", () => {
const defaults = AgentDefaultsSchema.parse({
heartbeat: { timeoutSeconds: 45 },

View File

@@ -4,6 +4,7 @@ import { isValidNonNegativeByteSizeString } from "./byte-size.js";
import {
HeartbeatSchema,
AgentSandboxSchema,
AgentContextLimitsSchema,
AgentEmbeddedHarnessSchema,
AgentModelSchema,
MemorySearchSchema,
@@ -78,6 +79,7 @@ export const AgentDefaultsSchema = z
})
.strict()
.optional(),
contextLimits: AgentContextLimitsSchema,
timeFormat: z.union([z.literal("auto"), z.literal("12"), z.literal("24")]).optional(),
envelopeTimezone: z.string().optional(),
envelopeTimestamp: z.union([z.literal("on"), z.literal("off")]).optional(),

View File

@@ -248,6 +248,23 @@ export const SandboxPruneSchema = z
.strict()
.optional();
export const AgentContextLimitsSchema = z
.object({
memoryGetMaxChars: z.number().int().min(1).max(250_000).optional(),
memoryGetDefaultLines: z.number().int().min(1).max(5_000).optional(),
toolResultMaxChars: z.number().int().min(1).max(250_000).optional(),
postCompactionMaxChars: z.number().int().min(1).max(50_000).optional(),
})
.strict()
.optional();
export const AgentSkillsLimitsSchema = z
.object({
maxSkillsPromptChars: z.number().int().min(0).optional(),
})
.strict()
.optional();
const ToolPolicyBaseSchema = z
.object({
allow: z.array(z.string()).optional(),
@@ -809,6 +826,8 @@ export const AgentEntrySchema = z
skills: z.array(z.string()).optional(),
memorySearch: MemorySearchSchema,
humanDelay: HumanDelaySchema.optional(),
skillsLimits: AgentSkillsLimitsSchema,
contextLimits: AgentContextLimitsSchema,
heartbeat: HeartbeatSchema,
identity: IdentitySchema,
groupChat: GroupChatSchema,

View File

@@ -144,7 +144,7 @@ async function buildWebchatAudioOnlyAssistantMessage(
};
}
export const DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS = 12_000;
export const DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS = 8_000;
const CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES = 128 * 1024;
const CHAT_HISTORY_OVERSIZED_PLACEHOLDER = "[chat.history omitted: message too large]";
let chatHistoryPlaceholderEmitCount = 0;

View File

@@ -109,7 +109,7 @@ export const DEFAULT_INPUT_FILE_MIMES = [
];
export const DEFAULT_INPUT_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
export const DEFAULT_INPUT_FILE_MAX_BYTES = 5 * 1024 * 1024;
export const DEFAULT_INPUT_FILE_MAX_CHARS = 200_000;
export const DEFAULT_INPUT_FILE_MAX_CHARS = 60_000;
export const DEFAULT_INPUT_MAX_REDIRECTS = 3;
export const DEFAULT_INPUT_TIMEOUT_MS = 10_000;
export const DEFAULT_INPUT_PDF_MAX_PAGES = 4;

View File

@@ -1,6 +1,7 @@
// Real workspace contract for memory engine foundation concerns.
export {
resolveAgentContextLimits,
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,

View File

@@ -16,6 +16,13 @@ export {
type MemoryFileEntry,
} from "./host/internal.js";
export { readMemoryFile } from "./host/read-file.js";
export {
buildMemoryReadResult,
buildMemoryReadResultFromSlice,
DEFAULT_MEMORY_READ_LINES,
DEFAULT_MEMORY_READ_MAX_CHARS,
type MemoryReadResult,
} from "./host/read-file-shared.js";
export { resolveMemoryBackendConfig } from "./host/backend-config.js";
export type {
ResolvedMemoryBackendConfig,

View File

@@ -89,9 +89,9 @@ const DEFAULT_QMD_COMMAND_TIMEOUT_MS = 30_000;
const DEFAULT_QMD_UPDATE_TIMEOUT_MS = 120_000;
const DEFAULT_QMD_EMBED_TIMEOUT_MS = 120_000;
const DEFAULT_QMD_LIMITS: ResolvedQmdLimitsConfig = {
maxResults: 6,
maxSnippetChars: 700,
maxInjectedChars: 4_000,
maxResults: 4,
maxSnippetChars: 450,
maxInjectedChars: 2_200,
timeoutMs: DEFAULT_QMD_TIMEOUT_MS,
};
const DEFAULT_QMD_MCPORTER: ResolvedQmdMcporterConfig = {

View File

@@ -0,0 +1,114 @@
import type { MemoryReadResult } from "./types.js";
export const DEFAULT_MEMORY_READ_LINES = 120;
export const DEFAULT_MEMORY_READ_MAX_CHARS = 12_000;
export type { MemoryReadResult } from "./types.js";
function buildContinuationNotice(params: {
nextFrom: number | undefined;
suggestReadFallback?: boolean;
}): string {
const base =
typeof params.nextFrom === "number"
? `[More content available. Use from=${params.nextFrom} to continue.]`
: "[More content available. Requested excerpt exceeded the default maxChars budget.]";
const fallback = params.suggestReadFallback
? " If you need the full raw line, use read on the source file."
: "";
return `\n\n${base.slice(0, -1)}${fallback}]`;
}
function fitLinesToCharBudget(params: { lines: string[]; maxChars: number }): {
text: string;
includedLines: number;
hardTruncatedSingleLine: boolean;
} {
const { lines, maxChars } = params;
if (lines.length === 0) {
return { text: "", includedLines: 0, hardTruncatedSingleLine: false };
}
let includedLines = lines.length;
let text = lines.join("\n");
while (includedLines > 1 && text.length > maxChars) {
includedLines -= 1;
text = lines.slice(0, includedLines).join("\n");
}
if (text.length <= maxChars) {
return { text, includedLines, hardTruncatedSingleLine: false };
}
return {
text: text.slice(0, maxChars),
includedLines: 1,
hardTruncatedSingleLine: true,
};
}
export function buildMemoryReadResultFromSlice(params: {
selectedLines: string[];
relPath: string;
startLine: number;
moreSourceLinesRemain?: boolean;
maxChars?: number;
suggestReadFallback?: boolean;
}): MemoryReadResult {
const start = Math.max(1, params.startLine);
const fitted = fitLinesToCharBudget({
lines: params.selectedLines,
maxChars: Math.max(1, params.maxChars ?? DEFAULT_MEMORY_READ_MAX_CHARS),
});
const moreSourceLinesRemain = params.moreSourceLinesRemain ?? false;
const charCapTruncated =
fitted.hardTruncatedSingleLine || fitted.includedLines < params.selectedLines.length;
const nextFrom =
!fitted.hardTruncatedSingleLine &&
(moreSourceLinesRemain || fitted.includedLines < params.selectedLines.length)
? start + fitted.includedLines
: undefined;
const truncated = charCapTruncated || moreSourceLinesRemain;
const text =
truncated && fitted.text
? `${fitted.text}${buildContinuationNotice({
nextFrom,
suggestReadFallback: fitted.hardTruncatedSingleLine && params.suggestReadFallback,
})}`
: fitted.text;
return {
text,
path: params.relPath,
from: start,
lines: fitted.includedLines,
...(truncated ? { truncated: true } : {}),
...(typeof nextFrom === "number" ? { nextFrom } : {}),
};
}
export function buildMemoryReadResult(params: {
content: string;
relPath: string;
from?: number;
lines?: number;
defaultLines?: number;
maxChars?: number;
suggestReadFallback?: boolean;
}): MemoryReadResult {
const fileLines = params.content.split("\n");
const start = Math.max(1, params.from ?? 1);
const requestedCount = Math.max(
1,
params.lines ?? params.defaultLines ?? DEFAULT_MEMORY_READ_LINES,
);
const selectedLines = fileLines.slice(start - 1, start - 1 + requestedCount);
const moreSourceLinesRemain = start - 1 + selectedLines.length < fileLines.length;
return buildMemoryReadResultFromSlice({
selectedLines,
relPath: params.relPath,
startLine: start,
moreSourceLinesRemain,
maxChars: params.maxChars,
suggestReadFallback: params.suggestReadFallback,
});
}

View File

@@ -1,10 +1,15 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
import { resolveAgentContextLimits, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
import { resolveMemorySearchConfig } from "../../agents/memory-search.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { isFileMissingError, statRegularFile } from "./fs-utils.js";
import { isMemoryPath, normalizeExtraMemoryPaths } from "./internal.js";
import {
buildMemoryReadResult,
DEFAULT_MEMORY_READ_LINES,
type MemoryReadResult,
} from "./read-file-shared.js";
export async function readMemoryFile(params: {
workspaceDir: string;
@@ -12,7 +17,9 @@ export async function readMemoryFile(params: {
relPath: string;
from?: number;
lines?: number;
}): Promise<{ text: string; path: string }> {
defaultLines?: number;
maxChars?: number;
}): Promise<MemoryReadResult> {
const rawPath = params.relPath.trim();
if (!rawPath) {
throw new Error("path required");
@@ -65,14 +72,15 @@ export async function readMemoryFile(params: {
}
throw err;
}
if (!params.from && !params.lines) {
return { text: content, path: relPath };
}
const fileLines = content.split("\n");
const start = Math.max(1, params.from ?? 1);
const count = Math.max(1, params.lines ?? fileLines.length);
const slice = fileLines.slice(start - 1, start - 1 + count);
return { text: slice.join("\n"), path: relPath };
return buildMemoryReadResult({
content,
relPath,
from: params.from,
lines: params.lines,
defaultLines: params.defaultLines ?? DEFAULT_MEMORY_READ_LINES,
maxChars: params.maxChars,
suggestReadFallback: allowedWorkspace,
});
}
export async function readAgentMemoryFile(params: {
@@ -81,16 +89,19 @@ export async function readAgentMemoryFile(params: {
relPath: string;
from?: number;
lines?: number;
}): Promise<{ text: string; path: string }> {
}): Promise<MemoryReadResult> {
const settings = resolveMemorySearchConfig(params.cfg, params.agentId);
if (!settings) {
throw new Error("memory search disabled");
}
const contextLimits = resolveAgentContextLimits(params.cfg, params.agentId);
return await readMemoryFile({
workspaceDir: resolveAgentWorkspaceDir(params.cfg, params.agentId),
extraPaths: settings.extraPaths,
relPath: params.relPath,
from: params.from,
lines: params.lines,
defaultLines: contextLimits?.memoryGetDefaultLines,
maxChars: contextLimits?.memoryGetMaxChars,
});
}

View File

@@ -28,6 +28,15 @@ export type MemorySearchRuntimeDebug = {
fallback?: string;
};
export type MemoryReadResult = {
text: string;
path: string;
truncated?: boolean;
from?: number;
lines?: number;
nextFrom?: number;
};
export type MemoryProviderStatus = {
backend: "builtin" | "qmd";
provider: string;
@@ -80,7 +89,7 @@ export interface MemorySearchManager {
relPath: string;
from?: number;
lines?: number;
}): Promise<{ text: string; path: string }>;
}): Promise<MemoryReadResult>;
status(): MemoryProviderStatus;
sync?(params?: {
reason?: string;