From 4b4631cd4859b2b01d3182bc1b23692aa8782c8f Mon Sep 17 00:00:00 2001 From: "B.K." Date: Tue, 21 Apr 2026 00:20:10 +0300 Subject: [PATCH] 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> --- CHANGELOG.md | 1 + ...helpers.buildbootstrapcontextfiles.test.ts | 59 +++++++++++- src/agents/pi-embedded-helpers/bootstrap.ts | 93 ++++++++++++++++--- .../subagent-registry.steer-restart.test.ts | 12 +-- 4 files changed, 142 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f85581bf8c..b3f05a74534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts b/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts index 9c39ac100bc..c9e20be1cd7 100644 --- a/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts +++ b/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts @@ -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 })]; diff --git a/src/agents/pi-embedded-helpers/bootstrap.ts b/src/agents/pi-embedded-helpers/bootstrap.ts index bca9f64107b..c5e608cee00 100644 --- a/src/agents/pi-embedded-helpers/bootstrap.ts +++ b/src/agents/pi-embedded-helpers/bootstrap.ts @@ -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, diff --git a/src/agents/subagent-registry.steer-restart.test.ts b/src/agents/subagent-registry.steer-restart.test.ts index b51f76e949c..b00acc208a4 100644 --- a/src/agents/subagent-registry.steer-restart.test.ts +++ b/src/agents/subagent-registry.steer-restart.test.ts @@ -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();