Agent: unify bootstrap truncation warning handling (#32769)

Merged via squash.

Prepared head SHA: 5d6d4ddfa6
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-03-03 16:28:38 -05:00
committed by GitHub
parent 3ad3a90db3
commit e4b4486a96
34 changed files with 1488 additions and 224 deletions

View File

@@ -0,0 +1,397 @@
import { describe, expect, it } from "vitest";
import {
analyzeBootstrapBudget,
buildBootstrapInjectionStats,
buildBootstrapPromptWarning,
buildBootstrapTruncationReportMeta,
buildBootstrapTruncationSignature,
formatBootstrapTruncationWarningLines,
resolveBootstrapWarningSignaturesSeen,
} from "./bootstrap-budget.js";
import type { WorkspaceBootstrapFile } from "./workspace.js";
describe("buildBootstrapInjectionStats", () => {
it("maps raw and injected sizes and marks truncation", () => {
const bootstrapFiles: WorkspaceBootstrapFile[] = [
{
name: "AGENTS.md",
path: "/tmp/AGENTS.md",
content: "a".repeat(100),
missing: false,
},
{
name: "SOUL.md",
path: "/tmp/SOUL.md",
content: "b".repeat(50),
missing: false,
},
];
const injectedFiles = [
{ path: "/tmp/AGENTS.md", content: "a".repeat(100) },
{ path: "/tmp/SOUL.md", content: "b".repeat(20) },
];
const stats = buildBootstrapInjectionStats({
bootstrapFiles,
injectedFiles,
});
expect(stats).toHaveLength(2);
expect(stats[0]).toMatchObject({
name: "AGENTS.md",
rawChars: 100,
injectedChars: 100,
truncated: false,
});
expect(stats[1]).toMatchObject({
name: "SOUL.md",
rawChars: 50,
injectedChars: 20,
truncated: true,
});
});
});
describe("analyzeBootstrapBudget", () => {
it("reports per-file and total-limit causes", () => {
const analysis = analyzeBootstrapBudget({
files: [
{
name: "AGENTS.md",
path: "/tmp/AGENTS.md",
missing: false,
rawChars: 150,
injectedChars: 120,
truncated: true,
},
{
name: "SOUL.md",
path: "/tmp/SOUL.md",
missing: false,
rawChars: 90,
injectedChars: 80,
truncated: true,
},
],
bootstrapMaxChars: 120,
bootstrapTotalMaxChars: 200,
});
expect(analysis.hasTruncation).toBe(true);
expect(analysis.totalNearLimit).toBe(true);
expect(analysis.truncatedFiles).toHaveLength(2);
const agents = analysis.truncatedFiles.find((file) => file.name === "AGENTS.md");
const soul = analysis.truncatedFiles.find((file) => file.name === "SOUL.md");
expect(agents?.causes).toContain("per-file-limit");
expect(agents?.causes).toContain("total-limit");
expect(soul?.causes).toContain("total-limit");
});
it("does not force a total-limit cause when totals are within limits", () => {
const analysis = analyzeBootstrapBudget({
files: [
{
name: "AGENTS.md",
path: "/tmp/AGENTS.md",
missing: false,
rawChars: 90,
injectedChars: 40,
truncated: true,
},
],
bootstrapMaxChars: 120,
bootstrapTotalMaxChars: 200,
});
expect(analysis.truncatedFiles[0]?.causes).toEqual([]);
});
});
describe("bootstrap prompt warnings", () => {
it("resolves seen signatures from report history or legacy single signature", () => {
expect(
resolveBootstrapWarningSignaturesSeen({
bootstrapTruncation: {
warningSignaturesSeen: ["sig-a", " ", "sig-b", "sig-a"],
promptWarningSignature: "legacy-ignored",
},
}),
).toEqual(["sig-a", "sig-b"]);
expect(
resolveBootstrapWarningSignaturesSeen({
bootstrapTruncation: {
promptWarningSignature: "legacy-only",
},
}),
).toEqual(["legacy-only"]);
expect(resolveBootstrapWarningSignaturesSeen(undefined)).toEqual([]);
});
it("ignores single-signature fallback when warning mode is off", () => {
expect(
resolveBootstrapWarningSignaturesSeen({
bootstrapTruncation: {
warningMode: "off",
promptWarningSignature: "off-mode-signature",
},
}),
).toEqual([]);
expect(
resolveBootstrapWarningSignaturesSeen({
bootstrapTruncation: {
warningMode: "off",
warningSignaturesSeen: ["prior-once-signature"],
promptWarningSignature: "off-mode-signature",
},
}),
).toEqual(["prior-once-signature"]);
});
it("dedupes warnings in once mode by signature", () => {
const analysis = analyzeBootstrapBudget({
files: [
{
name: "AGENTS.md",
path: "/tmp/AGENTS.md",
missing: false,
rawChars: 150,
injectedChars: 100,
truncated: true,
},
],
bootstrapMaxChars: 120,
bootstrapTotalMaxChars: 200,
});
const first = buildBootstrapPromptWarning({
analysis,
mode: "once",
});
expect(first.warningShown).toBe(true);
expect(first.signature).toBeTruthy();
expect(first.lines.join("\n")).toContain("AGENTS.md");
const second = buildBootstrapPromptWarning({
analysis,
mode: "once",
seenSignatures: first.warningSignaturesSeen,
});
expect(second.warningShown).toBe(false);
expect(second.lines).toEqual([]);
});
it("dedupes once mode across non-consecutive repeated signatures", () => {
const analysisA = analyzeBootstrapBudget({
files: [
{
name: "A.md",
path: "/tmp/A.md",
missing: false,
rawChars: 150,
injectedChars: 100,
truncated: true,
},
],
bootstrapMaxChars: 120,
bootstrapTotalMaxChars: 200,
});
const analysisB = analyzeBootstrapBudget({
files: [
{
name: "B.md",
path: "/tmp/B.md",
missing: false,
rawChars: 150,
injectedChars: 100,
truncated: true,
},
],
bootstrapMaxChars: 120,
bootstrapTotalMaxChars: 200,
});
const firstA = buildBootstrapPromptWarning({
analysis: analysisA,
mode: "once",
});
expect(firstA.warningShown).toBe(true);
const firstB = buildBootstrapPromptWarning({
analysis: analysisB,
mode: "once",
seenSignatures: firstA.warningSignaturesSeen,
});
expect(firstB.warningShown).toBe(true);
const secondA = buildBootstrapPromptWarning({
analysis: analysisA,
mode: "once",
seenSignatures: firstB.warningSignaturesSeen,
});
expect(secondA.warningShown).toBe(false);
});
it("includes overflow line when more files are truncated than shown", () => {
const analysis = analyzeBootstrapBudget({
files: [
{
name: "A.md",
path: "/tmp/A.md",
missing: false,
rawChars: 10,
injectedChars: 1,
truncated: true,
},
{
name: "B.md",
path: "/tmp/B.md",
missing: false,
rawChars: 10,
injectedChars: 1,
truncated: true,
},
{
name: "C.md",
path: "/tmp/C.md",
missing: false,
rawChars: 10,
injectedChars: 1,
truncated: true,
},
],
bootstrapMaxChars: 20,
bootstrapTotalMaxChars: 10,
});
const lines = formatBootstrapTruncationWarningLines({
analysis,
maxFiles: 2,
});
expect(lines).toContain("+1 more truncated file(s).");
});
it("disambiguates duplicate file names in warning lines", () => {
const analysis = analyzeBootstrapBudget({
files: [
{
name: "AGENTS.md",
path: "/tmp/a/AGENTS.md",
missing: false,
rawChars: 150,
injectedChars: 100,
truncated: true,
},
{
name: "AGENTS.md",
path: "/tmp/b/AGENTS.md",
missing: false,
rawChars: 140,
injectedChars: 100,
truncated: true,
},
],
bootstrapMaxChars: 120,
bootstrapTotalMaxChars: 300,
});
const lines = formatBootstrapTruncationWarningLines({
analysis,
});
expect(lines.join("\n")).toContain("AGENTS.md (/tmp/a/AGENTS.md)");
expect(lines.join("\n")).toContain("AGENTS.md (/tmp/b/AGENTS.md)");
});
it("respects off/always warning modes", () => {
const analysis = analyzeBootstrapBudget({
files: [
{
name: "AGENTS.md",
path: "/tmp/AGENTS.md",
missing: false,
rawChars: 150,
injectedChars: 100,
truncated: true,
},
],
bootstrapMaxChars: 120,
bootstrapTotalMaxChars: 200,
});
const signature = buildBootstrapTruncationSignature(analysis);
const off = buildBootstrapPromptWarning({
analysis,
mode: "off",
seenSignatures: [signature ?? ""],
previousSignature: signature,
});
expect(off.warningShown).toBe(false);
expect(off.lines).toEqual([]);
const always = buildBootstrapPromptWarning({
analysis,
mode: "always",
seenSignatures: [signature ?? ""],
previousSignature: signature,
});
expect(always.warningShown).toBe(true);
expect(always.lines.length).toBeGreaterThan(0);
});
it("uses file path in signature to avoid collisions for duplicate names", () => {
const left = analyzeBootstrapBudget({
files: [
{
name: "AGENTS.md",
path: "/tmp/a/AGENTS.md",
missing: false,
rawChars: 150,
injectedChars: 100,
truncated: true,
},
],
bootstrapMaxChars: 120,
bootstrapTotalMaxChars: 200,
});
const right = analyzeBootstrapBudget({
files: [
{
name: "AGENTS.md",
path: "/tmp/b/AGENTS.md",
missing: false,
rawChars: 150,
injectedChars: 100,
truncated: true,
},
],
bootstrapMaxChars: 120,
bootstrapTotalMaxChars: 200,
});
expect(buildBootstrapTruncationSignature(left)).not.toBe(
buildBootstrapTruncationSignature(right),
);
});
it("builds truncation report metadata from analysis + warning decision", () => {
const analysis = analyzeBootstrapBudget({
files: [
{
name: "AGENTS.md",
path: "/tmp/AGENTS.md",
missing: false,
rawChars: 150,
injectedChars: 100,
truncated: true,
},
],
bootstrapMaxChars: 120,
bootstrapTotalMaxChars: 200,
});
const warning = buildBootstrapPromptWarning({
analysis,
mode: "once",
});
const meta = buildBootstrapTruncationReportMeta({
analysis,
warningMode: "once",
warning,
});
expect(meta.warningMode).toBe("once");
expect(meta.warningShown).toBe(true);
expect(meta.truncatedFiles).toBe(1);
expect(meta.nearLimitFiles).toBeGreaterThanOrEqual(1);
expect(meta.promptWarningSignature).toBeTruthy();
expect(meta.warningSignaturesSeen?.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,349 @@
import path from "node:path";
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
import type { WorkspaceBootstrapFile } from "./workspace.js";
export const DEFAULT_BOOTSTRAP_NEAR_LIMIT_RATIO = 0.85;
export const DEFAULT_BOOTSTRAP_PROMPT_WARNING_MAX_FILES = 3;
export const DEFAULT_BOOTSTRAP_PROMPT_WARNING_SIGNATURE_HISTORY_MAX = 32;
export type BootstrapTruncationCause = "per-file-limit" | "total-limit";
export type BootstrapPromptWarningMode = "off" | "once" | "always";
export type BootstrapInjectionStat = {
name: string;
path: string;
missing: boolean;
rawChars: number;
injectedChars: number;
truncated: boolean;
};
export type BootstrapAnalyzedFile = BootstrapInjectionStat & {
nearLimit: boolean;
causes: BootstrapTruncationCause[];
};
export type BootstrapBudgetAnalysis = {
files: BootstrapAnalyzedFile[];
truncatedFiles: BootstrapAnalyzedFile[];
nearLimitFiles: BootstrapAnalyzedFile[];
totalNearLimit: boolean;
hasTruncation: boolean;
totals: {
rawChars: number;
injectedChars: number;
truncatedChars: number;
bootstrapMaxChars: number;
bootstrapTotalMaxChars: number;
nearLimitRatio: number;
};
};
export type BootstrapPromptWarning = {
signature?: string;
warningShown: boolean;
lines: string[];
warningSignaturesSeen: string[];
};
export type BootstrapTruncationReportMeta = {
warningMode: BootstrapPromptWarningMode;
warningShown: boolean;
promptWarningSignature?: string;
warningSignaturesSeen?: string[];
truncatedFiles: number;
nearLimitFiles: number;
totalNearLimit: boolean;
};
function normalizePositiveLimit(value: number): number {
if (!Number.isFinite(value) || value <= 0) {
return 1;
}
return Math.floor(value);
}
function formatWarningCause(cause: BootstrapTruncationCause): string {
return cause === "per-file-limit" ? "max/file" : "max/total";
}
function normalizeSeenSignatures(signatures?: string[]): string[] {
if (!Array.isArray(signatures) || signatures.length === 0) {
return [];
}
const seen = new Set<string>();
const result: string[] = [];
for (const signature of signatures) {
const value = typeof signature === "string" ? signature.trim() : "";
if (!value || seen.has(value)) {
continue;
}
seen.add(value);
result.push(value);
}
return result;
}
function appendSeenSignature(signatures: string[], signature: string): string[] {
if (!signature.trim()) {
return signatures;
}
if (signatures.includes(signature)) {
return signatures;
}
const next = [...signatures, signature];
if (next.length <= DEFAULT_BOOTSTRAP_PROMPT_WARNING_SIGNATURE_HISTORY_MAX) {
return next;
}
return next.slice(-DEFAULT_BOOTSTRAP_PROMPT_WARNING_SIGNATURE_HISTORY_MAX);
}
export function resolveBootstrapWarningSignaturesSeen(report?: {
bootstrapTruncation?: {
warningMode?: BootstrapPromptWarningMode;
warningSignaturesSeen?: string[];
promptWarningSignature?: string;
};
}): string[] {
const truncation = report?.bootstrapTruncation;
const seenFromReport = normalizeSeenSignatures(truncation?.warningSignaturesSeen);
if (seenFromReport.length > 0) {
return seenFromReport;
}
// In off mode, signature metadata should not seed once-mode dedupe state.
if (truncation?.warningMode === "off") {
return [];
}
const single =
typeof truncation?.promptWarningSignature === "string"
? truncation.promptWarningSignature.trim()
: "";
return single ? [single] : [];
}
export function buildBootstrapInjectionStats(params: {
bootstrapFiles: WorkspaceBootstrapFile[];
injectedFiles: EmbeddedContextFile[];
}): BootstrapInjectionStat[] {
const injectedByPath = new Map<string, string>();
const injectedByBaseName = new Map<string, string>();
for (const file of params.injectedFiles) {
const pathValue = typeof file.path === "string" ? file.path.trim() : "";
if (!pathValue) {
continue;
}
if (!injectedByPath.has(pathValue)) {
injectedByPath.set(pathValue, file.content);
}
const normalizedPath = pathValue.replace(/\\/g, "/");
const baseName = path.posix.basename(normalizedPath);
if (!injectedByBaseName.has(baseName)) {
injectedByBaseName.set(baseName, file.content);
}
}
return params.bootstrapFiles.map((file) => {
const pathValue = typeof file.path === "string" ? file.path.trim() : "";
const rawChars = file.missing ? 0 : (file.content ?? "").trimEnd().length;
const injected =
(pathValue ? injectedByPath.get(pathValue) : undefined) ??
injectedByPath.get(file.name) ??
injectedByBaseName.get(file.name);
const injectedChars = injected ? injected.length : 0;
const truncated = !file.missing && injectedChars < rawChars;
return {
name: file.name,
path: pathValue || file.name,
missing: file.missing,
rawChars,
injectedChars,
truncated,
};
});
}
export function analyzeBootstrapBudget(params: {
files: BootstrapInjectionStat[];
bootstrapMaxChars: number;
bootstrapTotalMaxChars: number;
nearLimitRatio?: number;
}): BootstrapBudgetAnalysis {
const bootstrapMaxChars = normalizePositiveLimit(params.bootstrapMaxChars);
const bootstrapTotalMaxChars = normalizePositiveLimit(params.bootstrapTotalMaxChars);
const nearLimitRatio =
typeof params.nearLimitRatio === "number" &&
Number.isFinite(params.nearLimitRatio) &&
params.nearLimitRatio > 0 &&
params.nearLimitRatio < 1
? params.nearLimitRatio
: DEFAULT_BOOTSTRAP_NEAR_LIMIT_RATIO;
const nonMissing = params.files.filter((file) => !file.missing);
const rawChars = nonMissing.reduce((sum, file) => sum + file.rawChars, 0);
const injectedChars = nonMissing.reduce((sum, file) => sum + file.injectedChars, 0);
const totalNearLimit = injectedChars >= Math.ceil(bootstrapTotalMaxChars * nearLimitRatio);
const totalOverLimit = injectedChars >= bootstrapTotalMaxChars;
const files = params.files.map((file) => {
if (file.missing) {
return { ...file, nearLimit: false, causes: [] };
}
const perFileOverLimit = file.rawChars > bootstrapMaxChars;
const nearLimit = file.rawChars >= Math.ceil(bootstrapMaxChars * nearLimitRatio);
const causes: BootstrapTruncationCause[] = [];
if (file.truncated) {
if (perFileOverLimit) {
causes.push("per-file-limit");
}
if (totalOverLimit) {
causes.push("total-limit");
}
}
return { ...file, nearLimit, causes };
});
const truncatedFiles = files.filter((file) => file.truncated);
const nearLimitFiles = files.filter((file) => file.nearLimit);
return {
files,
truncatedFiles,
nearLimitFiles,
totalNearLimit,
hasTruncation: truncatedFiles.length > 0,
totals: {
rawChars,
injectedChars,
truncatedChars: Math.max(0, rawChars - injectedChars),
bootstrapMaxChars,
bootstrapTotalMaxChars,
nearLimitRatio,
},
};
}
export function buildBootstrapTruncationSignature(
analysis: BootstrapBudgetAnalysis,
): string | undefined {
if (!analysis.hasTruncation) {
return undefined;
}
const files = analysis.truncatedFiles
.map((file) => ({
path: file.path || file.name,
rawChars: file.rawChars,
injectedChars: file.injectedChars,
causes: [...file.causes].toSorted(),
}))
.toSorted((a, b) => {
const pathCmp = a.path.localeCompare(b.path);
if (pathCmp !== 0) {
return pathCmp;
}
if (a.rawChars !== b.rawChars) {
return a.rawChars - b.rawChars;
}
if (a.injectedChars !== b.injectedChars) {
return a.injectedChars - b.injectedChars;
}
return a.causes.join("+").localeCompare(b.causes.join("+"));
});
return JSON.stringify({
bootstrapMaxChars: analysis.totals.bootstrapMaxChars,
bootstrapTotalMaxChars: analysis.totals.bootstrapTotalMaxChars,
files,
});
}
export function formatBootstrapTruncationWarningLines(params: {
analysis: BootstrapBudgetAnalysis;
maxFiles?: number;
}): string[] {
if (!params.analysis.hasTruncation) {
return [];
}
const maxFiles =
typeof params.maxFiles === "number" && Number.isFinite(params.maxFiles) && params.maxFiles > 0
? Math.floor(params.maxFiles)
: DEFAULT_BOOTSTRAP_PROMPT_WARNING_MAX_FILES;
const lines: string[] = [];
const duplicateNameCounts = params.analysis.truncatedFiles.reduce((acc, file) => {
acc.set(file.name, (acc.get(file.name) ?? 0) + 1);
return acc;
}, new Map<string, number>());
const topFiles = params.analysis.truncatedFiles.slice(0, maxFiles);
for (const file of topFiles) {
const pct =
file.rawChars > 0
? Math.round(((file.rawChars - file.injectedChars) / file.rawChars) * 100)
: 0;
const causeText =
file.causes.length > 0
? file.causes.map((cause) => formatWarningCause(cause)).join(", ")
: "";
const nameLabel =
(duplicateNameCounts.get(file.name) ?? 0) > 1 && file.path.trim().length > 0
? `${file.name} (${file.path})`
: file.name;
lines.push(
`${nameLabel}: ${file.rawChars} raw -> ${file.injectedChars} injected (~${Math.max(0, pct)}% removed${causeText ? `; ${causeText}` : ""}).`,
);
}
if (params.analysis.truncatedFiles.length > topFiles.length) {
lines.push(
`+${params.analysis.truncatedFiles.length - topFiles.length} more truncated file(s).`,
);
}
lines.push(
"If unintentional, raise agents.defaults.bootstrapMaxChars and/or agents.defaults.bootstrapTotalMaxChars.",
);
return lines;
}
export function buildBootstrapPromptWarning(params: {
analysis: BootstrapBudgetAnalysis;
mode: BootstrapPromptWarningMode;
previousSignature?: string;
seenSignatures?: string[];
maxFiles?: number;
}): BootstrapPromptWarning {
const signature = buildBootstrapTruncationSignature(params.analysis);
let seenSignatures = normalizeSeenSignatures(params.seenSignatures);
if (params.previousSignature && !seenSignatures.includes(params.previousSignature)) {
seenSignatures = appendSeenSignature(seenSignatures, params.previousSignature);
}
const hasSeenSignature = Boolean(signature && seenSignatures.includes(signature));
const warningShown =
params.mode !== "off" && Boolean(signature) && (params.mode === "always" || !hasSeenSignature);
const warningSignaturesSeen =
signature && params.mode !== "off"
? appendSeenSignature(seenSignatures, signature)
: seenSignatures;
return {
signature,
warningShown,
lines: warningShown
? formatBootstrapTruncationWarningLines({
analysis: params.analysis,
maxFiles: params.maxFiles,
})
: [],
warningSignaturesSeen,
};
}
export function buildBootstrapTruncationReportMeta(params: {
analysis: BootstrapBudgetAnalysis;
warningMode: BootstrapPromptWarningMode;
warning: BootstrapPromptWarning;
}): BootstrapTruncationReportMeta {
return {
warningMode: params.warningMode,
warningShown: params.warning.warningShown,
promptWarningSignature: params.warning.signature,
...(params.warning.warningSignaturesSeen.length > 0
? { warningSignaturesSeen: params.warning.warningSignaturesSeen }
: {}),
truncatedFiles: params.analysis.truncatedFiles.length,
nearLimitFiles: params.analysis.nearLimitFiles.length,
totalNearLimit: params.analysis.totalNearLimit,
};
}

View File

@@ -7,6 +7,12 @@ import { isTruthyEnvValue } from "../infra/env.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { getProcessSupervisor } from "../process/supervisor/index.js";
import { resolveSessionAgentIds } from "./agent-scope.js";
import {
analyzeBootstrapBudget,
buildBootstrapInjectionStats,
buildBootstrapPromptWarning,
buildBootstrapTruncationReportMeta,
} from "./bootstrap-budget.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js";
import { resolveCliBackendConfig } from "./cli-backends.js";
import {
@@ -26,8 +32,15 @@ import {
} from "./cli-runner/helpers.js";
import { resolveOpenClawDocsPath } from "./docs-path.js";
import { FailoverError, resolveFailoverStatus } from "./failover-error.js";
import { classifyFailoverReason, isFailoverErrorMessage } from "./pi-embedded-helpers.js";
import {
classifyFailoverReason,
isFailoverErrorMessage,
resolveBootstrapMaxChars,
resolveBootstrapPromptTruncationWarningMode,
resolveBootstrapTotalMaxChars,
} from "./pi-embedded-helpers.js";
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
import { buildSystemPromptReport } from "./system-prompt-report.js";
import { redactRunIdentifier, resolveRunWorkspaceDir } from "./workspace-run.js";
const log = createSubsystemLogger("agent/claude-cli");
@@ -49,6 +62,9 @@ export async function runCliAgent(params: {
streamParams?: import("../commands/agent/types.js").AgentStreamParams;
ownerNumbers?: string[];
cliSessionId?: string;
bootstrapPromptWarningSignaturesSeen?: string[];
/** Backward-compat fallback when only the previous signature is available. */
bootstrapPromptWarningSignature?: string;
images?: ImageContent[];
}): Promise<EmbeddedPiRunResult> {
const started = Date.now();
@@ -86,13 +102,30 @@ export async function runCliAgent(params: {
.join("\n");
const sessionLabel = params.sessionKey ?? params.sessionId;
const { contextFiles } = await resolveBootstrapContextForRun({
const { bootstrapFiles, contextFiles } = await resolveBootstrapContextForRun({
workspaceDir,
config: params.config,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
});
const bootstrapMaxChars = resolveBootstrapMaxChars(params.config);
const bootstrapTotalMaxChars = resolveBootstrapTotalMaxChars(params.config);
const bootstrapAnalysis = analyzeBootstrapBudget({
files: buildBootstrapInjectionStats({
bootstrapFiles,
injectedFiles: contextFiles,
}),
bootstrapMaxChars,
bootstrapTotalMaxChars,
});
const bootstrapPromptWarningMode = resolveBootstrapPromptTruncationWarningMode(params.config);
const bootstrapPromptWarning = buildBootstrapPromptWarning({
analysis: bootstrapAnalysis,
mode: bootstrapPromptWarningMode,
seenSignatures: params.bootstrapPromptWarningSignaturesSeen,
previousSignature: params.bootstrapPromptWarningSignature,
});
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
@@ -118,9 +151,32 @@ export async function runCliAgent(params: {
docsPath: docsPath ?? undefined,
tools: [],
contextFiles,
bootstrapTruncationWarningLines: bootstrapPromptWarning.lines,
modelDisplay,
agentId: sessionAgentId,
});
const systemPromptReport = buildSystemPromptReport({
source: "run",
generatedAt: Date.now(),
sessionId: params.sessionId,
sessionKey: params.sessionKey,
provider: params.provider,
model: modelId,
workspaceDir,
bootstrapMaxChars,
bootstrapTotalMaxChars,
bootstrapTruncation: buildBootstrapTruncationReportMeta({
analysis: bootstrapAnalysis,
warningMode: bootstrapPromptWarningMode,
warning: bootstrapPromptWarning,
}),
sandbox: { mode: "off", sandboxed: false },
systemPrompt,
bootstrapFiles,
injectedFiles: contextFiles,
skillsPrompt: "",
tools: [],
});
// Helper function to execute CLI with given session ID
const executeCliWithSession = async (
@@ -344,6 +400,7 @@ export async function runCliAgent(params: {
payloads,
meta: {
durationMs: Date.now() - started,
systemPromptReport,
agentMeta: {
sessionId: output.sessionId ?? params.cliSessionId ?? params.sessionId ?? "",
provider: params.provider,
@@ -373,6 +430,7 @@ export async function runCliAgent(params: {
payloads,
meta: {
durationMs: Date.now() - started,
systemPromptReport,
agentMeta: {
sessionId: output.sessionId ?? params.sessionId ?? "",
provider: params.provider,

View File

@@ -48,6 +48,7 @@ export function buildSystemPrompt(params: {
docsPath?: string;
tools: AgentTool[];
contextFiles?: EmbeddedContextFile[];
bootstrapTruncationWarningLines?: string[];
modelDisplay: string;
agentId?: string;
}) {
@@ -91,6 +92,7 @@ export function buildSystemPrompt(params: {
userTime,
userTimeFormat,
contextFiles: params.contextFiles,
bootstrapTruncationWarningLines: params.bootstrapTruncationWarningLines,
ttsHint,
memoryCitationsMode: params.config?.memory?.citations,
});

View File

@@ -3,8 +3,10 @@ import type { OpenClawConfig } from "../config/config.js";
import {
buildBootstrapContextFiles,
DEFAULT_BOOTSTRAP_MAX_CHARS,
DEFAULT_BOOTSTRAP_PROMPT_TRUNCATION_WARNING_MODE,
DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS,
resolveBootstrapMaxChars,
resolveBootstrapPromptTruncationWarningMode,
resolveBootstrapTotalMaxChars,
} from "./pi-embedded-helpers.js";
import type { WorkspaceBootstrapFile } from "./workspace.js";
@@ -194,3 +196,32 @@ describe("bootstrap limit resolvers", () => {
}
});
});
describe("resolveBootstrapPromptTruncationWarningMode", () => {
it("defaults to once", () => {
expect(resolveBootstrapPromptTruncationWarningMode()).toBe(
DEFAULT_BOOTSTRAP_PROMPT_TRUNCATION_WARNING_MODE,
);
});
it("accepts explicit valid modes", () => {
expect(
resolveBootstrapPromptTruncationWarningMode({
agents: { defaults: { bootstrapPromptTruncationWarning: "off" } },
} as OpenClawConfig),
).toBe("off");
expect(
resolveBootstrapPromptTruncationWarningMode({
agents: { defaults: { bootstrapPromptTruncationWarning: "always" } },
} as OpenClawConfig),
).toBe("always");
});
it("falls back to default for invalid values", () => {
expect(
resolveBootstrapPromptTruncationWarningMode({
agents: { defaults: { bootstrapPromptTruncationWarning: "invalid" } },
} as unknown as OpenClawConfig),
).toBe(DEFAULT_BOOTSTRAP_PROMPT_TRUNCATION_WARNING_MODE);
});
});

View File

@@ -1,9 +1,11 @@
export {
buildBootstrapContextFiles,
DEFAULT_BOOTSTRAP_MAX_CHARS,
DEFAULT_BOOTSTRAP_PROMPT_TRUNCATION_WARNING_MODE,
DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS,
ensureSessionHeader,
resolveBootstrapMaxChars,
resolveBootstrapPromptTruncationWarningMode,
resolveBootstrapTotalMaxChars,
stripThoughtSignatures,
} from "./pi-embedded-helpers/bootstrap.js";

View File

@@ -84,6 +84,7 @@ export function stripThoughtSignatures<T>(
export const DEFAULT_BOOTSTRAP_MAX_CHARS = 20_000;
export const DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS = 150_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;
@@ -111,6 +112,16 @@ export function resolveBootstrapTotalMaxChars(cfg?: OpenClawConfig): number {
return DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS;
}
export function resolveBootstrapPromptTruncationWarningMode(
cfg?: OpenClawConfig,
): "off" | "once" | "always" {
const raw = cfg?.agents?.defaults?.bootstrapPromptTruncationWarning;
if (raw === "off" || raw === "once" || raw === "always") {
return raw;
}
return DEFAULT_BOOTSTRAP_PROMPT_TRUNCATION_WARNING_MODE;
}
function trimBootstrapContent(
content: string,
fileName: string,

View File

@@ -651,6 +651,9 @@ export async function runEmbeddedPiAgent(
const MAX_RUN_LOOP_ITERATIONS = resolveMaxRunRetryIterations(profileCandidates.length);
let overflowCompactionAttempts = 0;
let toolResultTruncationAttempted = false;
let bootstrapPromptWarningSignaturesSeen =
params.bootstrapPromptWarningSignaturesSeen ??
(params.bootstrapPromptWarningSignature ? [params.bootstrapPromptWarningSignature] : []);
const usageAccumulator = createUsageAccumulator();
let lastRunPromptUsage: ReturnType<typeof normalizeUsage> | undefined;
let autoCompactionCount = 0;
@@ -774,6 +777,9 @@ export async function runEmbeddedPiAgent(
streamParams: params.streamParams,
ownerNumbers: params.ownerNumbers,
enforceFinalTag: params.enforceFinalTag,
bootstrapPromptWarningSignaturesSeen,
bootstrapPromptWarningSignature:
bootstrapPromptWarningSignaturesSeen[bootstrapPromptWarningSignaturesSeen.length - 1],
});
const {
@@ -784,6 +790,16 @@ export async function runEmbeddedPiAgent(
sessionIdUsed,
lastAssistant,
} = attempt;
bootstrapPromptWarningSignaturesSeen =
attempt.bootstrapPromptWarningSignaturesSeen ??
(attempt.bootstrapPromptWarningSignature
? Array.from(
new Set([
...bootstrapPromptWarningSignaturesSeen,
attempt.bootstrapPromptWarningSignature,
]),
)
: bootstrapPromptWarningSignaturesSeen);
const lastAssistantUsage = normalizeUsage(lastAssistant?.usage as UsageLike);
const attemptUsage = attempt.attemptUsage ?? lastAssistantUsage;
mergeUsageIntoAccumulator(usageAccumulator, attemptUsage);

View File

@@ -29,6 +29,12 @@ import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
import { resolveOpenClawAgentDir } from "../../agent-paths.js";
import { resolveSessionAgentIds } from "../../agent-scope.js";
import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js";
import {
analyzeBootstrapBudget,
buildBootstrapPromptWarning,
buildBootstrapTruncationReportMeta,
buildBootstrapInjectionStats,
} from "../../bootstrap-budget.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
import { createCacheTrace } from "../../cache-trace.js";
import {
@@ -48,6 +54,7 @@ import {
downgradeOpenAIFunctionCallReasoningPairs,
isCloudCodeAssistFormatError,
resolveBootstrapMaxChars,
resolveBootstrapPromptTruncationWarningMode,
resolveBootstrapTotalMaxChars,
validateAnthropicTurns,
validateGeminiTurns,
@@ -603,6 +610,23 @@ export async function runEmbeddedAttempt(
contextMode: params.bootstrapContextMode,
runKind: params.bootstrapContextRunKind,
});
const bootstrapMaxChars = resolveBootstrapMaxChars(params.config);
const bootstrapTotalMaxChars = resolveBootstrapTotalMaxChars(params.config);
const bootstrapAnalysis = analyzeBootstrapBudget({
files: buildBootstrapInjectionStats({
bootstrapFiles: hookAdjustedBootstrapFiles,
injectedFiles: contextFiles,
}),
bootstrapMaxChars,
bootstrapTotalMaxChars,
});
const bootstrapPromptWarningMode = resolveBootstrapPromptTruncationWarningMode(params.config);
const bootstrapPromptWarning = buildBootstrapPromptWarning({
analysis: bootstrapAnalysis,
mode: bootstrapPromptWarningMode,
seenSignatures: params.bootstrapPromptWarningSignaturesSeen,
previousSignature: params.bootstrapPromptWarningSignature,
});
const workspaceNotes = hookAdjustedBootstrapFiles.some(
(file) => file.name === DEFAULT_BOOTSTRAP_FILENAME && !file.missing,
)
@@ -798,6 +822,7 @@ export async function runEmbeddedAttempt(
userTime,
userTimeFormat,
contextFiles,
bootstrapTruncationWarningLines: bootstrapPromptWarning.lines,
memoryCitationsMode: params.config?.memory?.citations,
});
const systemPromptReport = buildSystemPromptReport({
@@ -808,8 +833,13 @@ export async function runEmbeddedAttempt(
provider: params.provider,
model: params.modelId,
workspaceDir: effectiveWorkspace,
bootstrapMaxChars: resolveBootstrapMaxChars(params.config),
bootstrapTotalMaxChars: resolveBootstrapTotalMaxChars(params.config),
bootstrapMaxChars,
bootstrapTotalMaxChars,
bootstrapTruncation: buildBootstrapTruncationReportMeta({
analysis: bootstrapAnalysis,
warningMode: bootstrapPromptWarningMode,
warning: bootstrapPromptWarning,
}),
sandbox: (() => {
const runtime = resolveSandboxRuntimeStatus({
cfg: params.config,
@@ -1681,6 +1711,8 @@ export async function runEmbeddedAttempt(
timedOutDuringCompaction,
promptError,
sessionIdUsed,
bootstrapPromptWarningSignaturesSeen: bootstrapPromptWarning.warningSignaturesSeen,
bootstrapPromptWarningSignature: bootstrapPromptWarning.signature,
systemPromptReport,
messagesSnapshot,
assistantTexts,

View File

@@ -85,6 +85,10 @@ export type RunEmbeddedPiAgentParams = {
bootstrapContextMode?: "full" | "lightweight";
/** Run kind hint for context mode behavior. */
bootstrapContextRunKind?: "default" | "heartbeat" | "cron";
/** Seen bootstrap truncation warning signatures for this session (once mode dedupe). */
bootstrapPromptWarningSignaturesSeen?: string[];
/** Last shown bootstrap truncation warning signature for this session. */
bootstrapPromptWarningSignature?: string;
execOverrides?: Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
bashElevated?: ExecElevatedDefaults;
timeoutMs: number;

View File

@@ -30,6 +30,8 @@ export type EmbeddedRunAttemptResult = {
timedOutDuringCompaction: boolean;
promptError: unknown;
sessionIdUsed: string;
bootstrapPromptWarningSignaturesSeen?: string[];
bootstrapPromptWarningSignature?: string;
systemPromptReport?: SessionSystemPromptReport;
messagesSnapshot: AgentMessage[];
assistantTexts: string[];

View File

@@ -51,6 +51,7 @@ export function buildEmbeddedSystemPrompt(params: {
userTime?: string;
userTimeFormat?: ResolvedTimeFormat;
contextFiles?: EmbeddedContextFile[];
bootstrapTruncationWarningLines?: string[];
memoryCitationsMode?: MemoryCitationsMode;
}): string {
return buildAgentSystemPrompt({
@@ -80,6 +81,7 @@ export function buildEmbeddedSystemPrompt(params: {
userTime: params.userTime,
userTimeFormat: params.userTimeFormat,
contextFiles: params.contextFiles,
bootstrapTruncationWarningLines: params.bootstrapTruncationWarningLines,
memoryCitationsMode: params.memoryCitationsMode,
});
}

View File

@@ -1,6 +1,6 @@
import path from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { SessionSystemPromptReport } from "../config/sessions/types.js";
import { buildBootstrapInjectionStats } from "./bootstrap-budget.js";
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
import type { WorkspaceBootstrapFile } from "./workspace.js";
@@ -36,46 +36,6 @@ function parseSkillBlocks(skillsPrompt: string): Array<{ name: string; blockChar
.filter((b) => b.blockChars > 0);
}
function buildInjectedWorkspaceFiles(params: {
bootstrapFiles: WorkspaceBootstrapFile[];
injectedFiles: EmbeddedContextFile[];
}): SessionSystemPromptReport["injectedWorkspaceFiles"] {
const injectedByPath = new Map<string, string>();
const injectedByBaseName = new Map<string, string>();
for (const file of params.injectedFiles) {
const pathValue = typeof file.path === "string" ? file.path.trim() : "";
if (!pathValue) {
continue;
}
if (!injectedByPath.has(pathValue)) {
injectedByPath.set(pathValue, file.content);
}
const normalizedPath = pathValue.replace(/\\/g, "/");
const baseName = path.posix.basename(normalizedPath);
if (!injectedByBaseName.has(baseName)) {
injectedByBaseName.set(baseName, file.content);
}
}
return params.bootstrapFiles.map((file) => {
const pathValue = typeof file.path === "string" ? file.path.trim() : "";
const rawChars = file.missing ? 0 : (file.content ?? "").trimEnd().length;
const injected =
(pathValue ? injectedByPath.get(pathValue) : undefined) ??
injectedByPath.get(file.name) ??
injectedByBaseName.get(file.name);
const injectedChars = injected ? injected.length : 0;
const truncated = !file.missing && injectedChars < rawChars;
return {
name: file.name,
path: pathValue || file.name,
missing: file.missing,
rawChars,
injectedChars,
truncated,
};
});
}
function buildToolsEntries(tools: AgentTool[]): SessionSystemPromptReport["tools"]["entries"] {
return tools.map((tool) => {
const name = tool.name;
@@ -127,6 +87,7 @@ export function buildSystemPromptReport(params: {
workspaceDir?: string;
bootstrapMaxChars: number;
bootstrapTotalMaxChars?: number;
bootstrapTruncation?: SessionSystemPromptReport["bootstrapTruncation"];
sandbox?: SessionSystemPromptReport["sandbox"];
systemPrompt: string;
bootstrapFiles: WorkspaceBootstrapFile[];
@@ -157,13 +118,14 @@ export function buildSystemPromptReport(params: {
workspaceDir: params.workspaceDir,
bootstrapMaxChars: params.bootstrapMaxChars,
bootstrapTotalMaxChars: params.bootstrapTotalMaxChars,
...(params.bootstrapTruncation ? { bootstrapTruncation: params.bootstrapTruncation } : {}),
sandbox: params.sandbox,
systemPrompt: {
chars: systemPrompt.length,
projectContextChars,
nonProjectContextChars: Math.max(0, systemPrompt.length - projectContextChars),
},
injectedWorkspaceFiles: buildInjectedWorkspaceFiles({
injectedWorkspaceFiles: buildBootstrapInjectionStats({
bootstrapFiles: params.bootstrapFiles,
injectedFiles: params.injectedFiles,
}),

View File

@@ -527,6 +527,18 @@ describe("buildAgentSystemPrompt", () => {
);
});
it("renders bootstrap truncation warning even when no context files are injected", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
bootstrapTruncationWarningLines: ["AGENTS.md: 200 raw -> 0 injected"],
contextFiles: [],
});
expect(prompt).toContain("# Project Context");
expect(prompt).toContain("⚠ Bootstrap truncation warning:");
expect(prompt).toContain("- AGENTS.md: 200 raw -> 0 injected");
});
it("summarizes the message tool when available", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",

View File

@@ -201,6 +201,7 @@ export function buildAgentSystemPrompt(params: {
userTime?: string;
userTimeFormat?: ResolvedTimeFormat;
contextFiles?: EmbeddedContextFile[];
bootstrapTruncationWarningLines?: string[];
skillsPrompt?: string;
heartbeatPrompt?: string;
docsPath?: string;
@@ -609,22 +610,35 @@ export function buildAgentSystemPrompt(params: {
}
const contextFiles = params.contextFiles ?? [];
const bootstrapTruncationWarningLines = (params.bootstrapTruncationWarningLines ?? []).filter(
(line) => line.trim().length > 0,
);
const validContextFiles = contextFiles.filter(
(file) => typeof file.path === "string" && file.path.trim().length > 0,
);
if (validContextFiles.length > 0) {
const hasSoulFile = validContextFiles.some((file) => {
const normalizedPath = file.path.trim().replace(/\\/g, "/");
const baseName = normalizedPath.split("/").pop() ?? normalizedPath;
return baseName.toLowerCase() === "soul.md";
});
lines.push("# Project Context", "", "The following project context files have been loaded:");
if (hasSoulFile) {
lines.push(
"If SOUL.md is present, embody its persona and tone. Avoid stiff, generic replies; follow its guidance unless higher-priority instructions override it.",
);
if (validContextFiles.length > 0 || bootstrapTruncationWarningLines.length > 0) {
lines.push("# Project Context", "");
if (validContextFiles.length > 0) {
const hasSoulFile = validContextFiles.some((file) => {
const normalizedPath = file.path.trim().replace(/\\/g, "/");
const baseName = normalizedPath.split("/").pop() ?? normalizedPath;
return baseName.toLowerCase() === "soul.md";
});
lines.push("The following project context files have been loaded:");
if (hasSoulFile) {
lines.push(
"If SOUL.md is present, embody its persona and tone. Avoid stiff, generic replies; follow its guidance unless higher-priority instructions override it.",
);
}
lines.push("");
}
if (bootstrapTruncationWarningLines.length > 0) {
lines.push("⚠ Bootstrap truncation warning:");
for (const warningLine of bootstrapTruncationWarningLines) {
lines.push(`- ${warningLine}`);
}
lines.push("");
}
lines.push("");
for (const file of validContextFiles) {
lines.push(`## ${file.path}`, "", file.content, "");
}