diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dce89f66ef..120e1df27d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 47d84462069..6f93d512805 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -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 diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index 255f4e1eac2..37feea5403b 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -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 diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 9b29b875c09..235ca17a7e2 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -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. diff --git a/docs/reference/token-use.md b/docs/reference/token-use.md index a9052c99226..939d1fcbcf3 100644 --- a/docs/reference/token-use.md +++ b/docs/reference/token-use.md @@ -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: diff --git a/extensions/browser/src/browser/constants.ts b/extensions/browser/src/browser/constants.ts index 952bf9190a5..942a05b8206 100644 --- a/extensions/browser/src/browser/constants.ts +++ b/extensions/browser/src/browser/constants.ts @@ -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; diff --git a/extensions/memory-core/src/memory-tool-manager-mock.ts b/extensions/memory-core/src/memory-tool-manager-mock.ts index 965cf81d53f..c37f6cf4cca 100644 --- a/extensions/memory-core/src/memory-tool-manager-mock.ts +++ b/extensions/memory-core/src/memory-tool-manager-mock.ts @@ -9,7 +9,14 @@ export type SearchImpl = (opts?: { onDebug?: (debug: MemorySearchRuntimeDebug) => void; }) => Promise; 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 = 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(); } diff --git a/extensions/memory-core/src/memory/manager.read-file.test.ts b/extensions/memory-core/src/memory/manager.read-file.test.ts index 19d06ab9619..37d1f0ccb4d 100644 --- a/extensions/memory-core/src/memory/manager.read-file.test.ts +++ b/extensions/memory-core/src/memory/manager.read-file.test.ts @@ -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 { diff --git a/extensions/memory-core/src/memory/qmd-manager.slugified-paths.test.ts b/extensions/memory-core/src/memory/qmd-manager.slugified-paths.test.ts index 958cfa54554..91a8fed7d01 100644 --- a/extensions/memory-core/src/memory/qmd-manager.slugified-paths.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.slugified-paths.test.ts @@ -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, }); }); }); diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 4b7c511c8dc..7fbff7cddaa 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -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(); diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index 41fe8ecf941..427931d89f5 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -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 { 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( diff --git a/extensions/memory-core/src/tools.citations.test.ts b/extensions/memory-core/src/tools.citations.test.ts index cc5461d711d..c609c9fa9d6 100644 --- a/extensions/memory-core/src/tools.citations.test.ts +++ b/extensions/memory-core/src/tools.citations.test.ts @@ -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 { diff --git a/extensions/memory-core/src/tools.ts b/extensions/memory-core/src/tools.ts index 6d3c76e9fa3..bc682b64ab4 100644 --- a/extensions/memory-core/src/tools.ts +++ b/extensions/memory-core/src/tools.ts @@ -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(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 = []; + let surfacedMemoryResults: Array< + Record & { 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 }) => diff --git a/packages/memory-host-sdk/src/host/backend-config.ts b/packages/memory-host-sdk/src/host/backend-config.ts index 54c503955bf..a66286cb3e9 100644 --- a/packages/memory-host-sdk/src/host/backend-config.ts +++ b/packages/memory-host-sdk/src/host/backend-config.ts @@ -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 = { diff --git a/packages/memory-host-sdk/src/host/read-file.ts b/packages/memory-host-sdk/src/host/read-file.ts index d8940acb26b..7543b416e68 100644 --- a/packages/memory-host-sdk/src/host/read-file.ts +++ b/packages/memory-host-sdk/src/host/read-file.ts @@ -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 { 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 { 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, }); } diff --git a/src/agents/agent-scope-config.ts b/src/agents/agent-scope-config.ts index 59d5fb56184..fe7850675ca 100644 --- a/src/agents/agent-scope-config.ts +++ b/src/agents/agent-scope-config.ts @@ -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(); diff --git a/src/agents/agent-scope.test.ts b/src/agents/agent-scope.test.ts index 150d2581dfb..d303f541e9f 100644 --- a/src/agents/agent-scope.test.ts +++ b/src/agents/agent-scope.test.ts @@ -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: { diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index de421fd9f51..19bd7ca1240 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -19,6 +19,7 @@ import { listAgentEntries, listAgentIds, resolveAgentConfig, + resolveAgentContextLimits, resolveAgentDir, resolveAgentWorkspaceDir, resolveDefaultAgentId, @@ -29,6 +30,7 @@ export { listAgentEntries, listAgentIds, resolveAgentConfig, + resolveAgentContextLimits, resolveAgentDir, resolveAgentWorkspaceDir, resolveDefaultAgentId, diff --git a/src/agents/pi-embedded-helpers/bootstrap.ts b/src/agents/pi-embedded-helpers/bootstrap.ts index 2bd63eab842..51d728ba422 100644 --- a/src/agents/pi-embedded-helpers/bootstrap.ts +++ b/src/agents/pi-embedded-helpers/bootstrap.ts @@ -83,8 +83,8 @@ export function stripThoughtSignatures( }) 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; diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index ed8738ec1e2..25cbaf2272b 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -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, }); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 92522104099..2a0b3b04f83 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -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, }); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index b1a44fc05ce..58638523b31 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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, diff --git a/src/agents/pi-embedded-runner/run/preemptive-compaction.test.ts b/src/agents/pi-embedded-runner/run/preemptive-compaction.test.ts index f5325fc4331..cfd1aeb9797 100644 --- a/src/agents/pi-embedded-runner/run/preemptive-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run/preemptive-compaction.test.ts @@ -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); diff --git a/src/agents/pi-embedded-runner/run/preemptive-compaction.ts b/src/agents/pi-embedded-runner/run/preemptive-compaction.ts index 8d550855d79..de12ededa38 100644 --- a/src/agents/pi-embedded-runner/run/preemptive-compaction.ts +++ b/src/agents/pi-embedded-runner/run/preemptive-compaction.ts @@ -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; diff --git a/src/agents/pi-embedded-runner/tool-result-truncation.test.ts b/src/agents/pi-embedded-runner/tool-result-truncation.test.ts index e0499ca5598..16ca994fd39 100644 --- a/src/agents/pi-embedded-runner/tool-result-truncation.test.ts +++ b/src/agents/pi-embedded-runner/tool-result-truncation.test.ts @@ -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", () => { diff --git a/src/agents/pi-embedded-runner/tool-result-truncation.ts b/src/agents/pi-embedded-runner/tool-result-truncation.ts index ac08aa907a0..73c004e36f4 100644 --- a/src/agents/pi-embedded-runner/tool-result-truncation.ts +++ b/src/agents/pi-embedded-runner/tool-result-truncation.ts @@ -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; diff --git a/src/agents/pi-tools.read.ts b/src/agents/pi-tools.read.ts index fefe6bc28ab..9da1f3b095e 100644 --- a/src/agents/pi-tools.read.ts +++ b/src/agents/pi-tools.read.ts @@ -41,11 +41,11 @@ type ToolContentBlock = AgentToolResult["content"][number]; type ImageContentBlock = Extract; type TextContentBlock = Extract; -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; diff --git a/src/agents/session-tool-result-guard-wrapper.ts b/src/agents/session-tool-result-guard-wrapper.ts index d250796e4b6..cdfd499d90a 100644 --- a/src/agents/session-tool-result-guard-wrapper.ts +++ b/src/agents/session-tool-result-guard-wrapper.ts @@ -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; @@ -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; diff --git a/src/agents/session-tool-result-guard.test.ts b/src/agents/session-tool-result-guard.test.ts index cf0b734cddd..93fa78083a9 100644 --- a/src/agents/session-tool-result-guard.test.ts +++ b/src/agents/session-tool-result-guard.test.ts @@ -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); diff --git a/src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts b/src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts index e92ee49fb11..3cce8a8fe60 100644 --- a/src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts +++ b/src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts @@ -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"); + }); }); diff --git a/src/agents/session-tool-result-guard.ts b/src/agents/session-tool-result-guard.ts index ecefb2dd6da..242e26ac0eb 100644 --- a/src/agents/session-tool-result-guard.ts +++ b/src/agents/session-tool-result-guard.ts @@ -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. diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index cb40afb59c4..4eec92a24c3 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -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"); diff --git a/src/agents/skills/agent-filter.ts b/src/agents/skills/agent-filter.ts index e8d98e7fe5f..dfb3a1c2b01 100644 --- a/src/agents/skills/agent-filter.ts +++ b/src/agents/skills/agent-filter.ts @@ -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["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; +} diff --git a/src/agents/skills/workspace.ts b/src/agents/skills/workspace.ts index e3dcb903005..8900953f3b3 100644 --- a/src/agents/skills/workspace.ts +++ b/src/agents/skills/workspace.ts @@ -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.` diff --git a/src/agents/tools/web-fetch.ts b/src/agents/tools/web-fetch.ts index 676726a5541..0a640fd3e3c 100644 --- a/src/agents/tools/web-fetch.ts +++ b/src/agents/tools/web-fetch.ts @@ -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; diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index a787d3bbbec..2ed953e1232 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -206,10 +206,10 @@ async function appendPostCompactionRefreshPrompt(params: { cfg: OpenClawConfig; followupRun: FollowupRun; }): Promise { - 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; } diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index c4f4d2c5f3f..6b4d21d65d6 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -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 }); diff --git a/src/auto-reply/reply/post-compaction-context.test.ts b/src/auto-reply/reply/post-compaction-context.test.ts index 820bb2f96a9..981304c4a90 100644 --- a/src/auto-reply/reply/post-compaction-context.test.ts +++ b/src/auto-reply/reply/post-compaction-context.test.ts @@ -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"); }); diff --git a/src/auto-reply/reply/post-compaction-context.ts b/src/auto-reply/reply/post-compaction-context.ts index 9f53216225c..241e20d2c04 100644 --- a/src/auto-reply/reply/post-compaction-context.ts +++ b/src/auto-reply/reply/post-compaction-context.ts @@ -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 { + 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 diff --git a/src/auto-reply/reply/startup-context.ts b/src/auto-reply/reply/startup-context.ts index 84b7394f9bf..118455f76ff 100644 --- a/src/auto-reply/reply/startup-context.ts +++ b/src/auto-reply/reply/startup-context.ts @@ -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; diff --git a/src/config/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts index f852c1bfe41..91908ca1154 100644 --- a/src/config/config.schema-regressions.test.ts +++ b/src/config/config.schema-regressions.test.ts @@ -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", diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index ffa5411c706..a1f0e8e2111 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -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": { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index c138a5fb064..820529bb4fa 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -196,8 +196,32 @@ export const FIELD_HELP: Record = { "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 = { "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": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 8f1b63c9bf4..f889efeda92 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -67,10 +67,22 @@ export const FIELD_LABELS: Record = { "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", diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 4e7b53c01bb..d662a6e3db7 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -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"; /** diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index 59e6b6d7483..ddfa362b617 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -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; + /** Optional per-agent overrides for selected context/token-heavy limits. */ + contextLimits?: AgentContextLimitsConfig; /** Optional per-agent heartbeat overrides. */ heartbeat?: AgentDefaultsConfig["heartbeat"]; identity?: IdentityConfig; diff --git a/src/config/zod-schema.agent-defaults.test.ts b/src/config/zod-schema.agent-defaults.test.ts index 7a193ffccbe..7f0779decd9 100644 --- a/src/config/zod-schema.agent-defaults.test.ts +++ b/src/config/zod-schema.agent-defaults.test.ts @@ -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 }, diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index cf4e07c3400..4a553a5e226 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -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(), diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 2ee7cea1eb3..c620c46c800 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -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, diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index ce7b63cbfde..3c351398a2a 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -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; diff --git a/src/media/input-files.ts b/src/media/input-files.ts index 6b9262614a0..d7583aba3ea 100644 --- a/src/media/input-files.ts +++ b/src/media/input-files.ts @@ -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; diff --git a/src/memory-host-sdk/engine-foundation.ts b/src/memory-host-sdk/engine-foundation.ts index e98e81601ce..3276b797d19 100644 --- a/src/memory-host-sdk/engine-foundation.ts +++ b/src/memory-host-sdk/engine-foundation.ts @@ -1,6 +1,7 @@ // Real workspace contract for memory engine foundation concerns. export { + resolveAgentContextLimits, resolveAgentDir, resolveAgentWorkspaceDir, resolveDefaultAgentId, diff --git a/src/memory-host-sdk/engine-storage.ts b/src/memory-host-sdk/engine-storage.ts index c8f348903c4..480279257e6 100644 --- a/src/memory-host-sdk/engine-storage.ts +++ b/src/memory-host-sdk/engine-storage.ts @@ -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, diff --git a/src/memory-host-sdk/host/backend-config.ts b/src/memory-host-sdk/host/backend-config.ts index ae6a2da55d3..ade1d458bbd 100644 --- a/src/memory-host-sdk/host/backend-config.ts +++ b/src/memory-host-sdk/host/backend-config.ts @@ -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 = { diff --git a/src/memory-host-sdk/host/read-file-shared.ts b/src/memory-host-sdk/host/read-file-shared.ts new file mode 100644 index 00000000000..e9fd4906408 --- /dev/null +++ b/src/memory-host-sdk/host/read-file-shared.ts @@ -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, + }); +} diff --git a/src/memory-host-sdk/host/read-file.ts b/src/memory-host-sdk/host/read-file.ts index 9a966b315c8..e7f01102168 100644 --- a/src/memory-host-sdk/host/read-file.ts +++ b/src/memory-host-sdk/host/read-file.ts @@ -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 { 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 { 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, }); } diff --git a/src/memory-host-sdk/host/types.ts b/src/memory-host-sdk/host/types.ts index 8707d8b355b..992cda6579c 100644 --- a/src/memory-host-sdk/host/types.ts +++ b/src/memory-host-sdk/host/types.ts @@ -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; status(): MemoryProviderStatus; sync?(params?: { reason?: string;