mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
fix(context-window): Tighten context limits and bound memory excerpts (#67277)
* Tighten context limits and bound memory excerpts * Align startup context defaults in config docs * Align qmd memory_get bounds with shared limits * Preserve qmd partial memory reads * Fix shared memory read type import * Add changelog entry for context bounds
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,7 +9,14 @@ export type SearchImpl = (opts?: {
|
||||
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
|
||||
}) => Promise<unknown[]>;
|
||||
export type MemoryReadParams = { relPath: string; from?: number; lines?: number };
|
||||
export type MemoryReadResult = { text: string; path: string };
|
||||
export type MemoryReadResult = {
|
||||
text: string;
|
||||
path: string;
|
||||
truncated?: boolean;
|
||||
from?: number;
|
||||
lines?: number;
|
||||
nextFrom?: number;
|
||||
};
|
||||
type MemoryBackend = "builtin" | "qmd";
|
||||
|
||||
let backend: MemoryBackend = "builtin";
|
||||
@@ -19,6 +26,8 @@ let searchImpl: SearchImpl = async () => [];
|
||||
let readFileImpl: (params: MemoryReadParams) => Promise<MemoryReadResult> = async (params) => ({
|
||||
text: "",
|
||||
path: params.relPath,
|
||||
from: params.from ?? 1,
|
||||
lines: params.lines ?? 120,
|
||||
});
|
||||
|
||||
const stubManager = {
|
||||
@@ -94,7 +103,12 @@ export function resetMemoryToolMockState(overrides?: {
|
||||
searchImpl = overrides?.searchImpl ?? (async () => []);
|
||||
readFileImpl =
|
||||
overrides?.readFileImpl ??
|
||||
(async (params: MemoryReadParams) => ({ text: "", path: params.relPath }));
|
||||
(async (params: MemoryReadParams) => ({
|
||||
text: "",
|
||||
path: params.relPath,
|
||||
from: params.from ?? 1,
|
||||
lines: params.lines ?? 120,
|
||||
}));
|
||||
vi.clearAllMocks();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -29,7 +29,11 @@ import {
|
||||
type SessionFileEntry,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-qmd";
|
||||
import {
|
||||
buildMemoryReadResult,
|
||||
buildMemoryReadResultFromSlice,
|
||||
DEFAULT_MEMORY_READ_LINES,
|
||||
isFileMissingError,
|
||||
type MemoryReadResult,
|
||||
requireNodeSqlite,
|
||||
statRegularFile,
|
||||
type MemoryEmbeddingProbeResult,
|
||||
@@ -43,6 +47,7 @@ import {
|
||||
type ResolvedQmdConfig,
|
||||
type ResolvedQmdMcporterConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import { resolveAgentContextLimits } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
|
||||
import {
|
||||
localeLowercasePreservingWhitespace,
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
@@ -1180,7 +1185,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
relPath: string;
|
||||
from?: number;
|
||||
lines?: number;
|
||||
}): Promise<{ text: string; path: string }> {
|
||||
}): Promise<MemoryReadResult> {
|
||||
const relPath = params.relPath?.trim();
|
||||
if (!relPath) {
|
||||
throw new Error("path required");
|
||||
@@ -1193,18 +1198,38 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
if (statResult.missing) {
|
||||
return { text: "", path: relPath };
|
||||
}
|
||||
const contextLimits = resolveAgentContextLimits(this.cfg, this.agentId);
|
||||
if (params.from !== undefined || params.lines !== undefined) {
|
||||
const partial = await this.readPartialText(absPath, params.from, params.lines);
|
||||
const requestedCount = Math.max(
|
||||
1,
|
||||
params.lines ?? contextLimits?.memoryGetDefaultLines ?? DEFAULT_MEMORY_READ_LINES,
|
||||
);
|
||||
const partial = await this.readPartialText(absPath, params.from, requestedCount);
|
||||
if (partial.missing) {
|
||||
return { text: "", path: relPath };
|
||||
}
|
||||
return { text: partial.text, path: relPath };
|
||||
return buildMemoryReadResultFromSlice({
|
||||
selectedLines: partial.selectedLines,
|
||||
relPath,
|
||||
startLine: Math.max(1, params.from ?? 1),
|
||||
moreSourceLinesRemain: partial.moreSourceLinesRemain,
|
||||
maxChars: contextLimits?.memoryGetMaxChars,
|
||||
suggestReadFallback: isDefaultMemoryPath(relPath),
|
||||
});
|
||||
}
|
||||
const full = await this.readFullText(absPath);
|
||||
if (full.missing) {
|
||||
return { text: "", path: relPath };
|
||||
}
|
||||
return { text: full.text, path: relPath };
|
||||
return buildMemoryReadResult({
|
||||
content: full.text,
|
||||
relPath,
|
||||
from: params.from,
|
||||
lines: params.lines,
|
||||
defaultLines: contextLimits?.memoryGetDefaultLines ?? DEFAULT_MEMORY_READ_LINES,
|
||||
maxChars: contextLimits?.memoryGetMaxChars,
|
||||
suggestReadFallback: isDefaultMemoryPath(relPath),
|
||||
});
|
||||
}
|
||||
|
||||
status(): MemoryProviderStatus {
|
||||
@@ -1919,7 +1944,10 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
absPath: string,
|
||||
from?: number,
|
||||
lines?: number,
|
||||
): Promise<{ missing: true } | { missing: false; text: string }> {
|
||||
): Promise<
|
||||
| { missing: true }
|
||||
| { missing: false; selectedLines: string[]; moreSourceLinesRemain: boolean }
|
||||
> {
|
||||
const start = Math.max(1, from ?? 1);
|
||||
const count = Math.max(1, lines ?? Number.POSITIVE_INFINITY);
|
||||
let handle;
|
||||
@@ -1938,6 +1966,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
});
|
||||
const selected: string[] = [];
|
||||
let index = 0;
|
||||
let moreSourceLinesRemain = false;
|
||||
try {
|
||||
for await (const line of rl) {
|
||||
index += 1;
|
||||
@@ -1945,6 +1974,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
continue;
|
||||
}
|
||||
if (selected.length >= count) {
|
||||
moreSourceLinesRemain = true;
|
||||
break;
|
||||
}
|
||||
selected.push(line);
|
||||
@@ -1953,7 +1983,11 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
rl.close();
|
||||
await handle.close();
|
||||
}
|
||||
return { missing: false, text: selected.slice(0, count).join("\n") };
|
||||
return {
|
||||
missing: false,
|
||||
selectedLines: selected.slice(0, count),
|
||||
moreSourceLinesRemain,
|
||||
};
|
||||
}
|
||||
|
||||
private async readFullText(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readStringParam,
|
||||
type AnyAgentTool,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import type {
|
||||
@@ -181,7 +180,7 @@ async function executeMemoryReadResult<T>(params: {
|
||||
export function createMemorySearchTool(options: {
|
||||
config?: OpenClawConfig;
|
||||
agentSessionKey?: string;
|
||||
}): AnyAgentTool | null {
|
||||
}) {
|
||||
return createMemoryTool({
|
||||
options,
|
||||
label: "Memory Search",
|
||||
@@ -215,7 +214,9 @@ export function createMemorySearchTool(options: {
|
||||
});
|
||||
const searchStartedAt = Date.now();
|
||||
let rawResults: MemorySearchResult[] = [];
|
||||
let surfacedMemoryResults: Array<MemorySearchResult & { corpus: "memory" }> = [];
|
||||
let surfacedMemoryResults: Array<
|
||||
Record<string, unknown> & { corpus: "memory"; score: number; path: string }
|
||||
> = [];
|
||||
let provider: string | undefined;
|
||||
let model: string | undefined;
|
||||
let fallback: unknown;
|
||||
@@ -320,13 +321,13 @@ export function createMemorySearchTool(options: {
|
||||
export function createMemoryGetTool(options: {
|
||||
config?: OpenClawConfig;
|
||||
agentSessionKey?: string;
|
||||
}): AnyAgentTool | null {
|
||||
}) {
|
||||
return createMemoryTool({
|
||||
options,
|
||||
label: "Memory Get",
|
||||
name: "memory_get",
|
||||
description:
|
||||
"Safe snippet read from MEMORY.md or memory/*.md with optional from/lines; `corpus=wiki` reads from registered compiled-wiki supplements. Use after search to pull only the needed lines and keep context small.",
|
||||
"Safe exact excerpt read from MEMORY.md or memory/*.md. Defaults to a bounded excerpt when lines are omitted, includes truncation/continuation info when more content exists, and `corpus=wiki` reads from registered compiled-wiki supplements.",
|
||||
parameters: MemoryGetSchema,
|
||||
execute:
|
||||
({ cfg, agentId }) =>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolveAgentWorkspaceDir } from "../../../../src/agents/agent-scope.js";
|
||||
import {
|
||||
resolveAgentContextLimits,
|
||||
resolveAgentWorkspaceDir,
|
||||
} from "../../../../src/agents/agent-scope.js";
|
||||
import { resolveMemorySearchConfig } from "../../../../src/agents/memory-search.js";
|
||||
import type { OpenClawConfig } from "../../../../src/config/config.js";
|
||||
import {
|
||||
buildMemoryReadResult,
|
||||
DEFAULT_MEMORY_READ_LINES,
|
||||
type MemoryReadResult,
|
||||
} from "../../../../src/memory-host-sdk/host/read-file-shared.js";
|
||||
import { isFileMissingError, statRegularFile } from "./fs-utils.js";
|
||||
import { isMemoryPath, normalizeExtraMemoryPaths } from "./internal.js";
|
||||
|
||||
@@ -12,7 +20,9 @@ export async function readMemoryFile(params: {
|
||||
relPath: string;
|
||||
from?: number;
|
||||
lines?: number;
|
||||
}): Promise<{ text: string; path: string }> {
|
||||
defaultLines?: number;
|
||||
maxChars?: number;
|
||||
}): Promise<MemoryReadResult> {
|
||||
const rawPath = params.relPath.trim();
|
||||
if (!rawPath) {
|
||||
throw new Error("path required");
|
||||
@@ -65,14 +75,15 @@ export async function readMemoryFile(params: {
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (!params.from && !params.lines) {
|
||||
return { text: content, path: relPath };
|
||||
}
|
||||
const fileLines = content.split("\n");
|
||||
const start = Math.max(1, params.from ?? 1);
|
||||
const count = Math.max(1, params.lines ?? fileLines.length);
|
||||
const slice = fileLines.slice(start - 1, start - 1 + count);
|
||||
return { text: slice.join("\n"), path: relPath };
|
||||
return buildMemoryReadResult({
|
||||
content,
|
||||
relPath,
|
||||
from: params.from,
|
||||
lines: params.lines,
|
||||
defaultLines: params.defaultLines ?? DEFAULT_MEMORY_READ_LINES,
|
||||
maxChars: params.maxChars,
|
||||
suggestReadFallback: allowedWorkspace,
|
||||
});
|
||||
}
|
||||
|
||||
export async function readAgentMemoryFile(params: {
|
||||
@@ -81,16 +92,19 @@ export async function readAgentMemoryFile(params: {
|
||||
relPath: string;
|
||||
from?: number;
|
||||
lines?: number;
|
||||
}): Promise<{ text: string; path: string }> {
|
||||
}): Promise<MemoryReadResult> {
|
||||
const settings = resolveMemorySearchConfig(params.cfg, params.agentId);
|
||||
if (!settings) {
|
||||
throw new Error("memory search disabled");
|
||||
}
|
||||
const contextLimits = resolveAgentContextLimits(params.cfg, params.agentId);
|
||||
return await readMemoryFile({
|
||||
workspaceDir: resolveAgentWorkspaceDir(params.cfg, params.agentId),
|
||||
extraPaths: settings.extraPaths,
|
||||
relPath: params.relPath,
|
||||
from: params.from,
|
||||
lines: params.lines,
|
||||
defaultLines: contextLimits?.memoryGetDefaultLines,
|
||||
maxChars: contextLimits?.memoryGetMaxChars,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
listAgentEntries,
|
||||
listAgentIds,
|
||||
resolveAgentConfig,
|
||||
resolveAgentContextLimits,
|
||||
resolveAgentDir,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
@@ -29,6 +30,7 @@ export {
|
||||
listAgentEntries,
|
||||
listAgentIds,
|
||||
resolveAgentConfig,
|
||||
resolveAgentContextLimits,
|
||||
resolveAgentDir,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
|
||||
@@ -83,8 +83,8 @@ export function stripThoughtSignatures<T>(
|
||||
}) as T;
|
||||
}
|
||||
|
||||
export const DEFAULT_BOOTSTRAP_MAX_CHARS = 20_000;
|
||||
export const DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS = 150_000;
|
||||
export const DEFAULT_BOOTSTRAP_MAX_CHARS = 12_000;
|
||||
export const DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS = 60_000;
|
||||
export const DEFAULT_BOOTSTRAP_PROMPT_TRUNCATION_WARNING_MODE = "once";
|
||||
const MIN_BOOTSTRAP_FILE_BUDGET_CHARS = 64;
|
||||
const BOOTSTRAP_HEAD_RATIO = 0.7;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -41,11 +41,11 @@ type ToolContentBlock = AgentToolResult<unknown>["content"][number];
|
||||
type ImageContentBlock = Extract<ToolContentBlock, { type: "image" }>;
|
||||
type TextContentBlock = Extract<ToolContentBlock, { type: "text" }>;
|
||||
|
||||
const DEFAULT_READ_PAGE_MAX_BYTES = 50 * 1024;
|
||||
const MAX_ADAPTIVE_READ_MAX_BYTES = 512 * 1024;
|
||||
const ADAPTIVE_READ_CONTEXT_SHARE = 0.2;
|
||||
const DEFAULT_READ_PAGE_MAX_BYTES = 32 * 1024;
|
||||
const MAX_ADAPTIVE_READ_MAX_BYTES = 128 * 1024;
|
||||
const ADAPTIVE_READ_CONTEXT_SHARE = 0.1;
|
||||
const CHARS_PER_TOKEN_ESTIMATE = 4;
|
||||
const MAX_ADAPTIVE_READ_PAGES = 8;
|
||||
const MAX_ADAPTIVE_READ_PAGES = 4;
|
||||
|
||||
type OpenClawReadToolOptions = {
|
||||
modelContextWindowTokens?: number;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
||||
import {
|
||||
applyInputProvenanceToUserMessage,
|
||||
type InputProvenance,
|
||||
} from "../sessions/input-provenance.js";
|
||||
import { resolveLiveToolResultMaxChars } from "./pi-embedded-runner/tool-result-truncation.js";
|
||||
import { installSessionToolResultGuard } from "./session-tool-result-guard.js";
|
||||
|
||||
export type GuardedSessionManager = SessionManager & {
|
||||
@@ -23,6 +25,8 @@ export function guardSessionManager(
|
||||
opts?: {
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
config?: OpenClawConfig;
|
||||
contextWindowTokens?: number;
|
||||
inputProvenance?: InputProvenance;
|
||||
allowSyntheticToolResults?: boolean;
|
||||
allowedToolNames?: Iterable<string>;
|
||||
@@ -73,6 +77,14 @@ export function guardSessionManager(
|
||||
allowSyntheticToolResults: opts?.allowSyntheticToolResults,
|
||||
allowedToolNames: opts?.allowedToolNames,
|
||||
beforeMessageWriteHook: beforeMessageWrite,
|
||||
maxToolResultChars:
|
||||
typeof opts?.contextWindowTokens === "number"
|
||||
? resolveLiveToolResultMaxChars({
|
||||
contextWindowTokens: opts.contextWindowTokens,
|
||||
cfg: opts.config,
|
||||
agentId: opts.agentId,
|
||||
})
|
||||
: undefined,
|
||||
});
|
||||
(sessionManager as GuardedSessionManager).flushPendingToolResults = guard.flushPendingToolResults;
|
||||
(sessionManager as GuardedSessionManager).clearPendingToolResults = guard.clearPendingToolResults;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -2,6 +2,21 @@ import type { OpenClawConfig } from "../../config/types.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { normalizeSkillFilter } from "./filter.js";
|
||||
|
||||
type AgentSkillsLimits = {
|
||||
maxSkillsPromptChars?: number;
|
||||
};
|
||||
|
||||
function resolveAgentEntry(
|
||||
cfg: OpenClawConfig | undefined,
|
||||
agentId: string | undefined,
|
||||
): NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[number] | undefined {
|
||||
if (!cfg) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedAgentId = normalizeAgentId(agentId);
|
||||
return cfg.agents?.list?.find((entry) => normalizeAgentId(entry.id) === normalizedAgentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit per-agent skills win when present; otherwise fall back to shared defaults.
|
||||
* Unknown agent ids also fall back to defaults so legacy/unresolved callers do not widen access.
|
||||
@@ -13,12 +28,24 @@ export function resolveEffectiveAgentSkillFilter(
|
||||
if (!cfg) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedAgentId = normalizeAgentId(agentId);
|
||||
const agentEntry = cfg.agents?.list?.find(
|
||||
(entry) => normalizeAgentId(entry.id) === normalizedAgentId,
|
||||
);
|
||||
const agentEntry = resolveAgentEntry(cfg, agentId);
|
||||
if (agentEntry && Object.hasOwn(agentEntry, "skills")) {
|
||||
return normalizeSkillFilter(agentEntry.skills);
|
||||
}
|
||||
return normalizeSkillFilter(cfg.agents?.defaults?.skills);
|
||||
}
|
||||
|
||||
export function resolveEffectiveAgentSkillsLimits(
|
||||
cfg: OpenClawConfig | undefined,
|
||||
agentId: string | undefined,
|
||||
): AgentSkillsLimits | undefined {
|
||||
if (!agentId) {
|
||||
return undefined;
|
||||
}
|
||||
const agentEntry = resolveAgentEntry(cfg, agentId);
|
||||
if (!agentEntry || !Object.hasOwn(agentEntry, "skillsLimits")) {
|
||||
return undefined;
|
||||
}
|
||||
const { maxSkillsPromptChars } = agentEntry.skillsLimits ?? {};
|
||||
return typeof maxSkillsPromptChars === "number" ? { maxSkillsPromptChars } : undefined;
|
||||
}
|
||||
|
||||
@@ -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.`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -206,10 +206,10 @@ async function appendPostCompactionRefreshPrompt(params: {
|
||||
cfg: OpenClawConfig;
|
||||
followupRun: FollowupRun;
|
||||
}): Promise<void> {
|
||||
const refreshPrompt = await readPostCompactionContext(
|
||||
params.followupRun.run.workspaceDir,
|
||||
params.cfg,
|
||||
);
|
||||
const refreshPrompt = await readPostCompactionContext(params.followupRun.run.workspaceDir, {
|
||||
cfg: params.cfg,
|
||||
agentId: params.followupRun.run.agentId,
|
||||
});
|
||||
if (!refreshPrompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveAgentContextLimits } from "../../agents/agent-scope.js";
|
||||
import { resolveCronStyleNow } from "../../agents/current-time.js";
|
||||
import { resolveUserTimezone } from "../../agents/date-time.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { openBoundaryFile } from "../../infra/boundary-file-read.js";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
|
||||
|
||||
const MAX_CONTEXT_CHARS = 3000;
|
||||
const MAX_CONTEXT_CHARS = 1800;
|
||||
const DEFAULT_POST_COMPACTION_SECTIONS = ["Session Startup", "Red Lines"];
|
||||
const LEGACY_POST_COMPACTION_SECTIONS = ["Every Session", "Safety"];
|
||||
|
||||
@@ -61,11 +62,19 @@ function formatDateStamp(nowMs: number, timezone: string): string {
|
||||
* Substitutes YYYY-MM-DD placeholders with the real date so agents read the correct
|
||||
* daily memory files instead of guessing based on training cutoff.
|
||||
*/
|
||||
export type PostCompactionContextOptions = {
|
||||
cfg?: OpenClawConfig;
|
||||
agentId?: string;
|
||||
nowMs?: number;
|
||||
};
|
||||
|
||||
export async function readPostCompactionContext(
|
||||
workspaceDir: string,
|
||||
cfg?: OpenClawConfig,
|
||||
nowMs?: number,
|
||||
options?: PostCompactionContextOptions,
|
||||
): Promise<string | null> {
|
||||
const cfg = options?.cfg;
|
||||
const agentId = options?.agentId;
|
||||
const effectiveNowMs = options?.nowMs;
|
||||
const agentsPath = path.join(workspaceDir, "AGENTS.md");
|
||||
|
||||
try {
|
||||
@@ -118,17 +127,19 @@ export async function readPostCompactionContext(
|
||||
// Only reference section names that were actually found and injected.
|
||||
const displayNames = foundSectionNames.length > 0 ? foundSectionNames : sectionNames;
|
||||
|
||||
const resolvedNowMs = nowMs ?? Date.now();
|
||||
const resolvedNowMs = effectiveNowMs ?? Date.now();
|
||||
const timezone = resolveUserTimezone(cfg?.agents?.defaults?.userTimezone);
|
||||
const dateStamp = formatDateStamp(resolvedNowMs, timezone);
|
||||
const maxContextChars =
|
||||
resolveAgentContextLimits(cfg, agentId)?.postCompactionMaxChars ?? MAX_CONTEXT_CHARS;
|
||||
// Always append the real runtime timestamp — AGENTS.md content may itself contain
|
||||
// "Current time:" as user-authored text, so we must not gate on that substring.
|
||||
const { timeLine } = resolveCronStyleNow(cfg ?? {}, resolvedNowMs);
|
||||
|
||||
const combined = sections.join("\n\n").replaceAll("YYYY-MM-DD", dateStamp);
|
||||
const safeContent =
|
||||
combined.length > MAX_CONTEXT_CHARS
|
||||
? combined.slice(0, MAX_CONTEXT_CHARS) + "\n...[truncated]..."
|
||||
combined.length > maxContextChars
|
||||
? combined.slice(0, maxContextChars) + "\n...[truncated]..."
|
||||
: combined;
|
||||
|
||||
// When using the default section set, use precise prose that names the
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -196,8 +196,32 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"Shared default settings inherited by agents unless overridden per entry in agents.list. Use defaults to enforce consistent baseline behavior and reduce duplicated per-agent configuration.",
|
||||
"agents.defaults.skills":
|
||||
"Optional default skill allowlist inherited by agents that omit agents.list[].skills. Omit for unrestricted skills, set [] to give inheriting agents no skills, and remember explicit agents.list[].skills replaces this default instead of merging with it.",
|
||||
"agents.defaults.contextLimits":
|
||||
"Focused per-agent-context budget defaults for selected high-volume excerpts and injected prompt blocks. Use this to tune bounded read/injection sizes without reopening any unbounded call paths.",
|
||||
"agents.defaults.contextLimits.memoryGetMaxChars":
|
||||
"Default max characters returned by memory_get before truncation metadata and continuation notice are added. Increase to approximate older larger excerpts, but keep it bounded.",
|
||||
"agents.defaults.contextLimits.memoryGetDefaultLines":
|
||||
"Default memory_get line window used when requests omit lines. This controls how many source lines are selected before the max-char cap is applied.",
|
||||
"agents.defaults.contextLimits.toolResultMaxChars":
|
||||
"Default max characters kept for a single live tool result before truncation. This affects both persisted live tool-result writes and overflow-recovery truncation heuristics.",
|
||||
"agents.defaults.contextLimits.postCompactionMaxChars":
|
||||
"Default max characters retained from AGENTS.md during post-compaction context refresh injection. Lower this to make compaction recovery cheaper, or raise it for agents that depend on longer startup guidance.",
|
||||
"agents.list":
|
||||
"Explicit list of configured agents with IDs and optional overrides for model, tools, identity, and workspace. Keep IDs stable over time so bindings, approvals, and session routing remain deterministic.",
|
||||
"agents.list[].skillsLimits":
|
||||
"Optional per-agent overrides for skills subsystem budgets. Use this when an agent needs a different skills prompt budget without introducing a second generic context-limits path.",
|
||||
"agents.list[].skillsLimits.maxSkillsPromptChars":
|
||||
"Per-agent override for the skills prompt character budget. This extends the existing skills.limits.maxSkillsPromptChars path instead of routing the same budget through contextLimits.",
|
||||
"agents.list[].contextLimits":
|
||||
"Optional per-agent overrides for the focused context budget knobs. Omitted fields inherit agents.defaults.contextLimits.",
|
||||
"agents.list[].contextLimits.memoryGetMaxChars":
|
||||
"Per-agent override for the default memory_get max character budget.",
|
||||
"agents.list[].contextLimits.memoryGetDefaultLines":
|
||||
"Per-agent override for the default memory_get line window when lines is omitted.",
|
||||
"agents.list[].contextLimits.toolResultMaxChars":
|
||||
"Per-agent override for the live tool-result max character budget.",
|
||||
"agents.list[].contextLimits.postCompactionMaxChars":
|
||||
"Per-agent override for the post-compaction AGENTS.md excerpt budget.",
|
||||
"agents.list[].thinkingDefault":
|
||||
"Optional per-agent default thinking level. Overrides agents.defaults.thinkingDefault for this agent when no per-message or session override is set.",
|
||||
"agents.list[].reasoningDefault":
|
||||
@@ -865,9 +889,9 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"agents.defaults.startupContext.maxFileBytes":
|
||||
"Maximum bytes allowed per daily memory file when building startup context (default: 16384). Files over this boundary-safe read limit are skipped.",
|
||||
"agents.defaults.startupContext.maxFileChars":
|
||||
"Maximum characters retained from each loaded daily memory file in the startup prelude (default: 2000).",
|
||||
"Maximum characters retained from each loaded daily memory file in the startup prelude (default: 1200).",
|
||||
"agents.defaults.startupContext.maxTotalChars":
|
||||
"Maximum total characters retained across all loaded daily memory files in the startup prelude (default: 4500). Additional files are truncated from the prelude once this cap is reached.",
|
||||
"Maximum total characters retained across all loaded daily memory files in the startup prelude (default: 2800). Additional files are truncated from the prelude once this cap is reached.",
|
||||
"agents.defaults.repoRoot":
|
||||
"Optional repository root shown in the system prompt runtime line (overrides auto-detect).",
|
||||
"agents.defaults.envelopeTimezone":
|
||||
|
||||
@@ -67,10 +67,22 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"agents.list[].fastModeDefault": "Agent Fast Mode Default",
|
||||
agents: "Agents",
|
||||
"agents.defaults": "Agent Defaults",
|
||||
"agents.defaults.contextLimits": "Default Context Limits",
|
||||
"agents.defaults.contextLimits.memoryGetMaxChars": "Default memory_get Max Chars",
|
||||
"agents.defaults.contextLimits.memoryGetDefaultLines": "Default memory_get Line Window",
|
||||
"agents.defaults.contextLimits.toolResultMaxChars": "Default Tool Result Max Chars",
|
||||
"agents.defaults.contextLimits.postCompactionMaxChars": "Default Post-compaction Max Chars",
|
||||
"agents.defaults.embeddedHarness": "Default Embedded Harness",
|
||||
"agents.defaults.embeddedHarness.runtime": "Default Embedded Harness Runtime",
|
||||
"agents.defaults.embeddedHarness.fallback": "Default Embedded Harness Fallback",
|
||||
"agents.list": "Agent List",
|
||||
"agents.list[].skillsLimits": "Agent Skills Limits",
|
||||
"agents.list[].skillsLimits.maxSkillsPromptChars": "Agent Skills Prompt Max Chars",
|
||||
"agents.list[].contextLimits": "Agent Context Limits",
|
||||
"agents.list[].contextLimits.memoryGetMaxChars": "Agent memory_get Max Chars",
|
||||
"agents.list[].contextLimits.memoryGetDefaultLines": "Agent memory_get Line Window",
|
||||
"agents.list[].contextLimits.toolResultMaxChars": "Agent Tool Result Max Chars",
|
||||
"agents.list[].contextLimits.postCompactionMaxChars": "Agent Post-compaction Max Chars",
|
||||
"agents.list.*.embeddedHarness": "Agent Embedded Harness",
|
||||
"agents.list.*.embeddedHarness.runtime": "Agent Embedded Harness Runtime",
|
||||
"agents.list.*.embeddedHarness.fallback": "Agent Embedded Harness Fallback",
|
||||
|
||||
@@ -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";
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { ChatType } from "../channels/chat-type.js";
|
||||
import type { AgentDefaultsConfig, EmbeddedPiExecutionContract } from "./types.agent-defaults.js";
|
||||
import type {
|
||||
AgentContextLimitsConfig,
|
||||
AgentDefaultsConfig,
|
||||
EmbeddedPiExecutionContract,
|
||||
} from "./types.agent-defaults.js";
|
||||
import type {
|
||||
AgentEmbeddedHarnessConfig,
|
||||
AgentModelConfig,
|
||||
@@ -7,6 +11,7 @@ import type {
|
||||
} from "./types.agents-shared.js";
|
||||
import type { HumanDelayConfig, IdentityConfig } from "./types.base.js";
|
||||
import type { GroupChatConfig } from "./types.messages.js";
|
||||
import type { SkillsLimitsConfig } from "./types.skills.js";
|
||||
import type { AgentToolsConfig, MemorySearchConfig } from "./types.tools.js";
|
||||
|
||||
export type AgentRuntimeAcpConfig = {
|
||||
@@ -86,6 +91,10 @@ export type AgentConfig = {
|
||||
memorySearch?: MemorySearchConfig;
|
||||
/** Human-like delay between block replies for this agent. */
|
||||
humanDelay?: HumanDelayConfig;
|
||||
/** Optional per-agent skills subsystem overrides. */
|
||||
skillsLimits?: Pick<SkillsLimitsConfig, "maxSkillsPromptChars">;
|
||||
/** Optional per-agent overrides for selected context/token-heavy limits. */
|
||||
contextLimits?: AgentContextLimitsConfig;
|
||||
/** Optional per-agent heartbeat overrides. */
|
||||
heartbeat?: AgentDefaultsConfig["heartbeat"];
|
||||
identity?: IdentityConfig;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Real workspace contract for memory engine foundation concerns.
|
||||
|
||||
export {
|
||||
resolveAgentContextLimits,
|
||||
resolveAgentDir,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
114
src/memory-host-sdk/host/read-file-shared.ts
Normal file
114
src/memory-host-sdk/host/read-file-shared.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { MemoryReadResult } from "./types.js";
|
||||
|
||||
export const DEFAULT_MEMORY_READ_LINES = 120;
|
||||
export const DEFAULT_MEMORY_READ_MAX_CHARS = 12_000;
|
||||
|
||||
export type { MemoryReadResult } from "./types.js";
|
||||
|
||||
function buildContinuationNotice(params: {
|
||||
nextFrom: number | undefined;
|
||||
suggestReadFallback?: boolean;
|
||||
}): string {
|
||||
const base =
|
||||
typeof params.nextFrom === "number"
|
||||
? `[More content available. Use from=${params.nextFrom} to continue.]`
|
||||
: "[More content available. Requested excerpt exceeded the default maxChars budget.]";
|
||||
const fallback = params.suggestReadFallback
|
||||
? " If you need the full raw line, use read on the source file."
|
||||
: "";
|
||||
return `\n\n${base.slice(0, -1)}${fallback}]`;
|
||||
}
|
||||
|
||||
function fitLinesToCharBudget(params: { lines: string[]; maxChars: number }): {
|
||||
text: string;
|
||||
includedLines: number;
|
||||
hardTruncatedSingleLine: boolean;
|
||||
} {
|
||||
const { lines, maxChars } = params;
|
||||
if (lines.length === 0) {
|
||||
return { text: "", includedLines: 0, hardTruncatedSingleLine: false };
|
||||
}
|
||||
|
||||
let includedLines = lines.length;
|
||||
let text = lines.join("\n");
|
||||
while (includedLines > 1 && text.length > maxChars) {
|
||||
includedLines -= 1;
|
||||
text = lines.slice(0, includedLines).join("\n");
|
||||
}
|
||||
|
||||
if (text.length <= maxChars) {
|
||||
return { text, includedLines, hardTruncatedSingleLine: false };
|
||||
}
|
||||
|
||||
return {
|
||||
text: text.slice(0, maxChars),
|
||||
includedLines: 1,
|
||||
hardTruncatedSingleLine: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMemoryReadResultFromSlice(params: {
|
||||
selectedLines: string[];
|
||||
relPath: string;
|
||||
startLine: number;
|
||||
moreSourceLinesRemain?: boolean;
|
||||
maxChars?: number;
|
||||
suggestReadFallback?: boolean;
|
||||
}): MemoryReadResult {
|
||||
const start = Math.max(1, params.startLine);
|
||||
const fitted = fitLinesToCharBudget({
|
||||
lines: params.selectedLines,
|
||||
maxChars: Math.max(1, params.maxChars ?? DEFAULT_MEMORY_READ_MAX_CHARS),
|
||||
});
|
||||
const moreSourceLinesRemain = params.moreSourceLinesRemain ?? false;
|
||||
const charCapTruncated =
|
||||
fitted.hardTruncatedSingleLine || fitted.includedLines < params.selectedLines.length;
|
||||
const nextFrom =
|
||||
!fitted.hardTruncatedSingleLine &&
|
||||
(moreSourceLinesRemain || fitted.includedLines < params.selectedLines.length)
|
||||
? start + fitted.includedLines
|
||||
: undefined;
|
||||
const truncated = charCapTruncated || moreSourceLinesRemain;
|
||||
const text =
|
||||
truncated && fitted.text
|
||||
? `${fitted.text}${buildContinuationNotice({
|
||||
nextFrom,
|
||||
suggestReadFallback: fitted.hardTruncatedSingleLine && params.suggestReadFallback,
|
||||
})}`
|
||||
: fitted.text;
|
||||
return {
|
||||
text,
|
||||
path: params.relPath,
|
||||
from: start,
|
||||
lines: fitted.includedLines,
|
||||
...(truncated ? { truncated: true } : {}),
|
||||
...(typeof nextFrom === "number" ? { nextFrom } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMemoryReadResult(params: {
|
||||
content: string;
|
||||
relPath: string;
|
||||
from?: number;
|
||||
lines?: number;
|
||||
defaultLines?: number;
|
||||
maxChars?: number;
|
||||
suggestReadFallback?: boolean;
|
||||
}): MemoryReadResult {
|
||||
const fileLines = params.content.split("\n");
|
||||
const start = Math.max(1, params.from ?? 1);
|
||||
const requestedCount = Math.max(
|
||||
1,
|
||||
params.lines ?? params.defaultLines ?? DEFAULT_MEMORY_READ_LINES,
|
||||
);
|
||||
const selectedLines = fileLines.slice(start - 1, start - 1 + requestedCount);
|
||||
const moreSourceLinesRemain = start - 1 + selectedLines.length < fileLines.length;
|
||||
return buildMemoryReadResultFromSlice({
|
||||
selectedLines,
|
||||
relPath: params.relPath,
|
||||
startLine: start,
|
||||
moreSourceLinesRemain,
|
||||
maxChars: params.maxChars,
|
||||
suggestReadFallback: params.suggestReadFallback,
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
|
||||
import { resolveAgentContextLimits, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
|
||||
import { resolveMemorySearchConfig } from "../../agents/memory-search.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { isFileMissingError, statRegularFile } from "./fs-utils.js";
|
||||
import { isMemoryPath, normalizeExtraMemoryPaths } from "./internal.js";
|
||||
import {
|
||||
buildMemoryReadResult,
|
||||
DEFAULT_MEMORY_READ_LINES,
|
||||
type MemoryReadResult,
|
||||
} from "./read-file-shared.js";
|
||||
|
||||
export async function readMemoryFile(params: {
|
||||
workspaceDir: string;
|
||||
@@ -12,7 +17,9 @@ export async function readMemoryFile(params: {
|
||||
relPath: string;
|
||||
from?: number;
|
||||
lines?: number;
|
||||
}): Promise<{ text: string; path: string }> {
|
||||
defaultLines?: number;
|
||||
maxChars?: number;
|
||||
}): Promise<MemoryReadResult> {
|
||||
const rawPath = params.relPath.trim();
|
||||
if (!rawPath) {
|
||||
throw new Error("path required");
|
||||
@@ -65,14 +72,15 @@ export async function readMemoryFile(params: {
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (!params.from && !params.lines) {
|
||||
return { text: content, path: relPath };
|
||||
}
|
||||
const fileLines = content.split("\n");
|
||||
const start = Math.max(1, params.from ?? 1);
|
||||
const count = Math.max(1, params.lines ?? fileLines.length);
|
||||
const slice = fileLines.slice(start - 1, start - 1 + count);
|
||||
return { text: slice.join("\n"), path: relPath };
|
||||
return buildMemoryReadResult({
|
||||
content,
|
||||
relPath,
|
||||
from: params.from,
|
||||
lines: params.lines,
|
||||
defaultLines: params.defaultLines ?? DEFAULT_MEMORY_READ_LINES,
|
||||
maxChars: params.maxChars,
|
||||
suggestReadFallback: allowedWorkspace,
|
||||
});
|
||||
}
|
||||
|
||||
export async function readAgentMemoryFile(params: {
|
||||
@@ -81,16 +89,19 @@ export async function readAgentMemoryFile(params: {
|
||||
relPath: string;
|
||||
from?: number;
|
||||
lines?: number;
|
||||
}): Promise<{ text: string; path: string }> {
|
||||
}): Promise<MemoryReadResult> {
|
||||
const settings = resolveMemorySearchConfig(params.cfg, params.agentId);
|
||||
if (!settings) {
|
||||
throw new Error("memory search disabled");
|
||||
}
|
||||
const contextLimits = resolveAgentContextLimits(params.cfg, params.agentId);
|
||||
return await readMemoryFile({
|
||||
workspaceDir: resolveAgentWorkspaceDir(params.cfg, params.agentId),
|
||||
extraPaths: settings.extraPaths,
|
||||
relPath: params.relPath,
|
||||
from: params.from,
|
||||
lines: params.lines,
|
||||
defaultLines: contextLimits?.memoryGetDefaultLines,
|
||||
maxChars: contextLimits?.memoryGetMaxChars,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,6 +28,15 @@ export type MemorySearchRuntimeDebug = {
|
||||
fallback?: string;
|
||||
};
|
||||
|
||||
export type MemoryReadResult = {
|
||||
text: string;
|
||||
path: string;
|
||||
truncated?: boolean;
|
||||
from?: number;
|
||||
lines?: number;
|
||||
nextFrom?: number;
|
||||
};
|
||||
|
||||
export type MemoryProviderStatus = {
|
||||
backend: "builtin" | "qmd";
|
||||
provider: string;
|
||||
@@ -80,7 +89,7 @@ export interface MemorySearchManager {
|
||||
relPath: string;
|
||||
from?: number;
|
||||
lines?: number;
|
||||
}): Promise<{ text: string; path: string }>;
|
||||
}): Promise<MemoryReadResult>;
|
||||
status(): MemoryProviderStatus;
|
||||
sync?(params?: {
|
||||
reason?: string;
|
||||
|
||||
Reference in New Issue
Block a user