mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:10:43 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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 })];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user