fix(bootstrap): close silent 10% content gap in trim ratios (openclaw#69114)

Verified:
- pnpm test src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts
- pnpm test src/agents/subagent-registry.steer-restart.test.ts

Co-authored-by: B.K. <263413630+BKF-Gitty@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
B.K.
2026-04-21 00:20:10 +03:00
committed by GitHub
parent de404de321
commit 4b4631cd48
4 changed files with 142 additions and 23 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
- Cron/delivery: treat explicit `delivery.mode: "none"` runs as not requested even if the runner reports `delivered: false`, so no-delivery cron jobs no longer persist false delivery failures or errors. (#69285) Thanks @matsuri1987.
- Plugins/install: repair active and default-enabled bundled plugin runtime dependencies before import in packaged installs, so bundled Discord, WhatsApp, Slack, Telegram, and provider plugins work without putting their dependency trees in core.
- BlueBubbles: raise the outbound `/api/v1/message/text` send timeout default from 10s to 30s, and add a configurable `channels.bluebubbles.sendTimeoutMs` (also per-account) so macOS 26 setups where Private API iMessage sends stall for 60+ seconds no longer silently lose messages at the 10s abort. Probes, chat lookups, and health checks keep the shorter 10s default. Fixes #67486. (#69193) Thanks @omarshahine.
- Agents/bootstrap: budget truncation markers against per-file caps, preserve source content instead of silently wasting bootstrap bytes, and avoid marker-only output in tiny-budget truncation cases. (#69114) Thanks @BKF-Gitty.
- Context engine/plugins: stop rejecting third-party context engines whose `info.id` differs from the registered plugin slot id. The strict-match contract added in 2026.4.14 broke `lossless-claw` and other plugins whose internal engine id does not equal the slot id they are registered under, producing repeated `info.id must match registered id` lane failures on every turn. Fixes #66601. (#66678) Thanks @GodsBoy.
- Agents/compaction: rename embedded Pi compaction lifecycle events to `compaction_start` / `compaction_end` so OpenClaw stays aligned with `pi-coding-agent` 0.66.1 event naming. (#67713) Thanks @mpz4life.
- Security/dotenv: block all `OPENCLAW_*` keys from untrusted workspace `.env` files so workspace-local env loading fails closed for new runtime-control variables instead of silently inheriting them. (#473)

View File

@@ -66,19 +66,72 @@ describe("buildBootstrapContextFiles", () => {
const files = [makeFile({ name: "TOOLS.md", content: long })];
const warnings: string[] = [];
const maxChars = 200;
const expectedTailChars = Math.floor(maxChars * 0.2);
const [result] = buildBootstrapContextFiles(files, {
maxChars,
warn: (message) => warnings.push(message),
});
const kept = result?.content.match(/kept (\d+)\+(\d+) chars/);
expect(kept).not.toBeNull();
if (!kept) {
throw new Error("missing truncation kept-count marker");
}
const headChars = Number(kept[1]);
const tailChars = Number(kept[2]);
expect(result?.content).toContain("[...truncated, read TOOLS.md for full content...]");
expect(result?.content.length).toBeLessThan(long.length);
expect(result?.content.startsWith(long.slice(0, 120))).toBe(true);
expect(result?.content.endsWith(long.slice(-expectedTailChars))).toBe(true);
expect(result?.content.length).toBeLessThanOrEqual(maxChars);
expect(result?.content.startsWith(long.slice(0, headChars))).toBe(true);
if (tailChars > 0) {
expect(result?.content.endsWith(long.slice(-tailChars))).toBe(true);
}
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("TOOLS.md");
expect(warnings[0]).toContain("limit 200");
});
it("fits the rendered truncation marker inside the per-file budget", () => {
const maxChars = DEFAULT_BOOTSTRAP_MAX_CHARS;
const files = [
makeFile({
name: "HEARTBEAT.md",
path: "/tmp/HEARTBEAT.md",
content: "a".repeat(maxChars * 2),
}),
];
const [result] = buildBootstrapContextFiles(files, { maxChars });
expect(result?.content).toContain("[...truncated, read HEARTBEAT.md for full content...]");
expect(result?.content.length).toBeLessThanOrEqual(maxChars);
});
it("keeps bootstrap bytes in tiny per-file budgets when the marker is longer than the limit", () => {
const maxChars = 64;
const content = `HEAD-${"a".repeat(1_000)}-TAIL`;
const files = [
makeFile({
name: "HEARTBEAT.md",
path: "/tmp/HEARTBEAT.md",
content,
}),
];
const [result] = buildBootstrapContextFiles(files, { maxChars });
expect(result?.content.startsWith("HEAD-")).toBe(true);
expect(result?.content.endsWith("-TAIL")).toBe(true);
expect(result?.content).toContain("truncated");
expect(result?.content.length).toBeLessThanOrEqual(maxChars);
});
it("keeps at least one bootstrap byte when only the compact marker fits", () => {
const maxChars = 22;
const content = `HEAD-${"a".repeat(1_000)}-TAIL`;
const files = [
makeFile({
name: "HEARTBEAT.md",
path: "/tmp/HEARTBEAT.md",
content,
}),
];
const [result] = buildBootstrapContextFiles(files, { maxChars });
expect(result?.content).toContain("truncated");
expect(result?.content.length).toBeLessThanOrEqual(maxChars);
expect(result?.content).toContain("H");
});
it("keeps content under the default limit", () => {
const long = "a".repeat(DEFAULT_BOOTSTRAP_MAX_CHARS - 10);
const files = [makeFile({ content: long })];

View File

@@ -88,8 +88,13 @@ 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;
const BOOTSTRAP_TAIL_RATIO = 0.2;
// Ratios split `contentBudget` (= maxChars marker.length join separators), not `maxChars`.
// The marker and "\n" separators are already reserved before this split runs; these ratios
// only divide what's left between head and tail. Ratios sum to 1.0 — the iteration loop,
// post-loop guard, and final `truncateUtf16Safe` clamp absorb any `Math.floor` residue.
const BOOTSTRAP_HEAD_RATIO = 0.75;
const BOOTSTRAP_TAIL_RATIO = 0.25;
const MIN_BOOTSTRAP_TRIMMED_CONTENT_CHARS = 16;
type TrimBootstrapResult = {
content: string;
@@ -139,20 +144,82 @@ function trimBootstrapContent(
};
}
const headChars = Math.floor(maxChars * BOOTSTRAP_HEAD_RATIO);
const tailChars = Math.floor(maxChars * BOOTSTRAP_TAIL_RATIO);
const markerTemplate = (headChars: number, tailChars: number) =>
[
"",
`[...truncated, read ${fileName} for full content...]`,
`…(truncated ${fileName}: kept ${headChars}+${tailChars} chars of ${trimmed.length})…`,
"",
].join("\n");
const compactMarkerTemplate = (headChars: number, tailChars: number) =>
`[…truncated ${headChars}+${tailChars}/${trimmed.length}]`;
const separatorCharsFor = (headCount: number, tailCount: number, markerContent: string) =>
markerContent.includes("\n") ? Number(headCount > 0) + Number(tailCount > 0) : 0;
const renderTruncatedContent = (head: string, markerContent: string, tail: string) =>
[head, markerContent, tail]
.filter((part) => part.length > 0)
.join(markerContent.includes("\n") ? "\n" : "");
const resolveMarkerTemplate = () => {
const fullMarker = markerTemplate(0, 0);
const fullContentBudget = maxChars - fullMarker.length - separatorCharsFor(1, 1, fullMarker);
return fullContentBudget >= MIN_BOOTSTRAP_TRIMMED_CONTENT_CHARS
? markerTemplate
: compactMarkerTemplate;
};
const resolvedMarkerTemplate = resolveMarkerTemplate();
let headChars = 0;
let tailChars = 0;
let marker = resolvedMarkerTemplate(headChars, tailChars);
for (let attempt = 0; attempt < 3; attempt += 1) {
const contentBudget = Math.max(
0,
maxChars - marker.length - separatorCharsFor(headChars, tailChars, marker),
);
const nextHeadChars = Math.floor(contentBudget * BOOTSTRAP_HEAD_RATIO);
const nextTailChars = Math.floor(contentBudget * BOOTSTRAP_TAIL_RATIO);
const nextMarker = resolvedMarkerTemplate(nextHeadChars, nextTailChars);
if (
nextHeadChars === headChars &&
nextTailChars === tailChars &&
nextMarker.length === marker.length
) {
break;
}
headChars = nextHeadChars;
tailChars = nextTailChars;
marker = nextMarker;
}
let renderedLength =
headChars + tailChars + marker.length + separatorCharsFor(headChars, tailChars, marker);
while (renderedLength > maxChars && (tailChars > 0 || headChars > 0)) {
const overflow = renderedLength - maxChars;
if (tailChars > 0) {
tailChars = Math.max(0, tailChars - overflow);
} else {
headChars = Math.max(0, headChars - overflow);
}
marker = resolvedMarkerTemplate(headChars, tailChars);
renderedLength =
headChars + tailChars + marker.length + separatorCharsFor(headChars, tailChars, marker);
}
if (headChars === 0 && tailChars === 0 && trimmed.length > 0) {
const singleHeadMarker = resolvedMarkerTemplate(1, 0);
const singleHeadLength = 1 + singleHeadMarker.length + separatorCharsFor(1, 0, singleHeadMarker);
if (singleHeadLength <= maxChars) {
headChars = 1;
marker = singleHeadMarker;
}
}
const head = trimmed.slice(0, headChars);
const tail = trimmed.slice(-tailChars);
const tail = tailChars > 0 ? trimmed.slice(-tailChars) : "";
const marker = [
"",
`[...truncated, read ${fileName} for full content...]`,
`…(truncated ${fileName}: kept ${headChars}+${tailChars} chars of ${trimmed.length})…`,
"",
].join("\n");
const contentWithMarker = [head, marker, tail].join("\n");
const contentWithMarker = renderTruncatedContent(head, marker, tail);
const boundedContent =
contentWithMarker.length > maxChars
? truncateUtf16Safe(contentWithMarker, maxChars)
: contentWithMarker;
return {
content: contentWithMarker,
content: boundedContent,
truncated: true,
maxChars,
originalLength: trimmed.length,

View File

@@ -563,13 +563,11 @@ describe("subagent registry steer restarts", () => {
expect(mod.isSubagentSessionRunActive(childSessionKey)).toBe(false);
const run = listMainRuns()[0];
expect(run?.outcome).toEqual({
status: "error",
error: "manual kill",
startedAt: expect.any(Number),
endedAt: expect.any(Number),
elapsedMs: expect.any(Number),
});
expect(run?.outcome).toMatchObject({ status: "error", error: "manual kill" });
expect(run?.outcome?.startedAt).toEqual(expect.any(Number));
expect(run?.outcome?.endedAt).toEqual(expect.any(Number));
expect(run?.outcome?.elapsedMs).toEqual(expect.any(Number));
expect(run?.outcome?.endedAt).toBeGreaterThanOrEqual(run?.outcome?.startedAt ?? 0);
expect(run?.cleanupHandled).toBe(true);
expect(typeof run?.cleanupCompletedAt).toBe("number");
await flushAnnounce();