diff --git a/docs/concepts/context.md b/docs/concepts/context.md index 78d755f8576..d7a16fa70fa 100644 --- a/docs/concepts/context.md +++ b/docs/concepts/context.md @@ -114,6 +114,8 @@ By default, OpenClaw injects a fixed set of workspace files (if present): Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `20000` chars). OpenClaw also enforces a total bootstrap injection cap across files with `agents.defaults.bootstrapTotalMaxChars` (default `150000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened. +When truncation occurs, the runtime can inject an in-prompt warning block under Project Context. Configure this with `agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`; default `once`). + ## Skills: what’s injected vs loaded on-demand The system prompt includes a compact **skills list** (name + description + location). This list has real overhead. diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index b7ed42534b3..1a5edfcc6e3 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -73,7 +73,10 @@ compaction. Large files are truncated with a marker. The max per-file size is controlled by `agents.defaults.bootstrapMaxChars` (default: 20000). Total injected bootstrap content across files is capped by `agents.defaults.bootstrapTotalMaxChars` -(default: 150000). Missing files inject a short missing-file marker. +(default: 150000). Missing files inject a short missing-file marker. When truncation +occurs, OpenClaw can inject a warning block in Project Context; control this with +`agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`; +default: `once`). Sub-agent sessions only inject `AGENTS.md` and `TOOLS.md` (other bootstrap files are filtered out to keep the sub-agent context small). diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 2daafd801e8..d84e3626198 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -801,6 +801,21 @@ Max total characters injected across all workspace bootstrap files. Default: `15 } ``` +### `agents.defaults.bootstrapPromptTruncationWarning` + +Controls agent-visible warning text when bootstrap context is truncated. +Default: `"once"`. + +- `"off"`: never inject warning text into the system prompt. +- `"once"`: inject warning once per unique truncation signature (recommended). +- `"always"`: inject warning on every run when truncation exists. + +```json5 +{ + agents: { defaults: { bootstrapPromptTruncationWarning: "once" } }, // off | once | always +} +``` + ### `agents.defaults.imageMaxDimensionPx` Max pixel size for the longest image side in transcript/tool image blocks before provider calls. diff --git a/src/agents/bootstrap-budget.test.ts b/src/agents/bootstrap-budget.test.ts new file mode 100644 index 00000000000..bee7a2d9036 --- /dev/null +++ b/src/agents/bootstrap-budget.test.ts @@ -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); + }); +}); diff --git a/src/agents/bootstrap-budget.ts b/src/agents/bootstrap-budget.ts new file mode 100644 index 00000000000..ddfd4fb5d06 --- /dev/null +++ b/src/agents/bootstrap-budget.ts @@ -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(); + 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(); + const injectedByBaseName = new Map(); + 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()); + 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, + }; +} diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 0757483b549..0ceca9979d0 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -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 { 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, diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index 96ec35540be..7f0598cfaab 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -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, }); diff --git a/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts b/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts index 5e809e5cca9..a1d69af02fe 100644 --- a/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts +++ b/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.test.ts @@ -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); + }); +}); diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 7c48a346e4d..34a54a2405e 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -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"; diff --git a/src/agents/pi-embedded-helpers/bootstrap.ts b/src/agents/pi-embedded-helpers/bootstrap.ts index 6853bfbe92f..e6e0792f4ba 100644 --- a/src/agents/pi-embedded-helpers/bootstrap.ts +++ b/src/agents/pi-embedded-helpers/bootstrap.ts @@ -84,6 +84,7 @@ export function stripThoughtSignatures( 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, diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index bfda498f5e3..b07b5185be8 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -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 | 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); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 63898d4dfe0..2f65542a171 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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, diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index 647d9dd4a32..048efd2cbe4 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -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; bashElevated?: ExecElevatedDefaults; timeoutMs: number; diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index 469ff8bb33a..35251edd807 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -30,6 +30,8 @@ export type EmbeddedRunAttemptResult = { timedOutDuringCompaction: boolean; promptError: unknown; sessionIdUsed: string; + bootstrapPromptWarningSignaturesSeen?: string[]; + bootstrapPromptWarningSignature?: string; systemPromptReport?: SessionSystemPromptReport; messagesSnapshot: AgentMessage[]; assistantTexts: string[]; diff --git a/src/agents/pi-embedded-runner/system-prompt.ts b/src/agents/pi-embedded-runner/system-prompt.ts index ef246d1af23..ac2662f127f 100644 --- a/src/agents/pi-embedded-runner/system-prompt.ts +++ b/src/agents/pi-embedded-runner/system-prompt.ts @@ -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, }); } diff --git a/src/agents/system-prompt-report.ts b/src/agents/system-prompt-report.ts index 6461e34af09..863c53a0f27 100644 --- a/src/agents/system-prompt-report.ts +++ b/src/agents/system-prompt-report.ts @@ -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(); - const injectedByBaseName = new Map(); - 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, }), diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 8a2d34c8e24..c1bcb1f4e67 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -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", diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 97b8321ed15..440fde78708 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -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, ""); } diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index ea8c25c1e52..ca5d5272221 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import fs from "node:fs"; +import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js"; import { runCliAgent } from "../../agents/cli-runner.js"; import { getCliSessionId } from "../../agents/cli-session.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; @@ -125,6 +126,9 @@ export async function runAgentTurnWithFallback(params: { let fallbackAttempts: RuntimeFallbackAttempt[] = []; let didResetAfterCompactionFailure = false; let didRetryTransientHttpError = false; + let bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( + params.getActiveSessionEntry()?.systemPromptReport, + ); while (true) { try { @@ -222,8 +226,16 @@ export async function runAgentTurnWithFallback(params: { extraSystemPrompt: params.followupRun.run.extraSystemPrompt, ownerNumbers: params.followupRun.run.ownerNumbers, cliSessionId, + bootstrapPromptWarningSignaturesSeen, + bootstrapPromptWarningSignature: + bootstrapPromptWarningSignaturesSeen[ + bootstrapPromptWarningSignaturesSeen.length - 1 + ], images: params.opts?.images, }); + bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( + result.meta?.systemPromptReport, + ); // CLI backends don't emit streaming assistant events, so we need to // emit one with the final text so server-chat can populate its buffer @@ -293,140 +305,151 @@ export async function runAgentTurnWithFallback(params: { runId, authProfile, }); - return runEmbeddedPiAgent({ - ...embeddedContext, - trigger: params.isHeartbeat ? "heartbeat" : "user", - groupId: resolveGroupSessionKey(params.sessionCtx)?.id, - groupChannel: - params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(), - groupSpace: params.sessionCtx.GroupSpace?.trim() ?? undefined, - ...senderContext, - ...runBaseParams, - prompt: params.commandBody, - extraSystemPrompt: params.followupRun.run.extraSystemPrompt, - toolResultFormat: (() => { - const channel = resolveMessageChannel( - params.sessionCtx.Surface, - params.sessionCtx.Provider, - ); - if (!channel) { - return "markdown"; - } - return isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain"; - })(), - suppressToolErrorWarnings: params.opts?.suppressToolErrorWarnings, - bootstrapContextMode: params.opts?.bootstrapContextMode, - bootstrapContextRunKind: params.opts?.isHeartbeat ? "heartbeat" : "default", - images: params.opts?.images, - abortSignal: params.opts?.abortSignal, - blockReplyBreak: params.resolvedBlockStreamingBreak, - blockReplyChunking: params.blockReplyChunking, - onPartialReply: async (payload) => { - const textForTyping = await handlePartialForTyping(payload); - if (!params.opts?.onPartialReply || textForTyping === undefined) { - return; - } - await params.opts.onPartialReply({ - text: textForTyping, - mediaUrls: payload.mediaUrls, - }); - }, - onAssistantMessageStart: async () => { - await params.typingSignals.signalMessageStart(); - await params.opts?.onAssistantMessageStart?.(); - }, - onReasoningStream: - params.typingSignals.shouldStartOnReasoning || params.opts?.onReasoningStream - ? async (payload) => { - await params.typingSignals.signalReasoningDelta(); - await params.opts?.onReasoningStream?.({ - text: payload.text, - mediaUrls: payload.mediaUrls, - }); - } - : undefined, - onReasoningEnd: params.opts?.onReasoningEnd, - onAgentEvent: async (evt) => { - // Signal run start only after the embedded agent emits real activity. - const hasLifecyclePhase = - evt.stream === "lifecycle" && typeof evt.data.phase === "string"; - if (evt.stream !== "lifecycle" || hasLifecyclePhase) { - notifyAgentRunStart(); - } - // Trigger typing when tools start executing. - // Must await to ensure typing indicator starts before tool summaries are emitted. - if (evt.stream === "tool") { - const phase = typeof evt.data.phase === "string" ? evt.data.phase : ""; - const name = typeof evt.data.name === "string" ? evt.data.name : undefined; - if (phase === "start" || phase === "update") { - await params.typingSignals.signalToolStart(); - await params.opts?.onToolStart?.({ name, phase }); + return (async () => { + const result = await runEmbeddedPiAgent({ + ...embeddedContext, + trigger: params.isHeartbeat ? "heartbeat" : "user", + groupId: resolveGroupSessionKey(params.sessionCtx)?.id, + groupChannel: + params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(), + groupSpace: params.sessionCtx.GroupSpace?.trim() ?? undefined, + ...senderContext, + ...runBaseParams, + prompt: params.commandBody, + extraSystemPrompt: params.followupRun.run.extraSystemPrompt, + toolResultFormat: (() => { + const channel = resolveMessageChannel( + params.sessionCtx.Surface, + params.sessionCtx.Provider, + ); + if (!channel) { + return "markdown"; } - } - // Track auto-compaction completion - if (evt.stream === "compaction") { - const phase = typeof evt.data.phase === "string" ? evt.data.phase : ""; - if (phase === "end") { - autoCompactionCompleted = true; + return isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain"; + })(), + suppressToolErrorWarnings: params.opts?.suppressToolErrorWarnings, + bootstrapContextMode: params.opts?.bootstrapContextMode, + bootstrapContextRunKind: params.opts?.isHeartbeat ? "heartbeat" : "default", + images: params.opts?.images, + abortSignal: params.opts?.abortSignal, + blockReplyBreak: params.resolvedBlockStreamingBreak, + blockReplyChunking: params.blockReplyChunking, + onPartialReply: async (payload) => { + const textForTyping = await handlePartialForTyping(payload); + if (!params.opts?.onPartialReply || textForTyping === undefined) { + return; } - } - }, - // Always pass onBlockReply so flushBlockReplyBuffer works before tool execution, - // even when regular block streaming is disabled. The handler sends directly - // via opts.onBlockReply when the pipeline isn't available. - onBlockReply: params.opts?.onBlockReply - ? createBlockReplyDeliveryHandler({ - onBlockReply: params.opts.onBlockReply, - currentMessageId: - params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid, - normalizeStreamingText, - applyReplyToMode: params.applyReplyToMode, - typingSignals: params.typingSignals, - blockStreamingEnabled: params.blockStreamingEnabled, - blockReplyPipeline, - directlySentBlockKeys, - }) - : undefined, - onBlockReplyFlush: - params.blockStreamingEnabled && blockReplyPipeline - ? async () => { - await blockReplyPipeline.flush({ force: true }); - } - : undefined, - shouldEmitToolResult: params.shouldEmitToolResult, - shouldEmitToolOutput: params.shouldEmitToolOutput, - onToolResult: onToolResult - ? (() => { - // Serialize tool result delivery to preserve message ordering. - // Without this, concurrent tool callbacks race through typing signals - // and message sends, causing out-of-order delivery to the user. - // See: https://github.com/openclaw/openclaw/issues/11044 - let toolResultChain: Promise = Promise.resolve(); - return (payload: ReplyPayload) => { - toolResultChain = toolResultChain - .then(async () => { - const { text, skip } = normalizeStreamingText(payload); - if (skip) { - return; - } - await params.typingSignals.signalTextDelta(text); - await onToolResult({ - text, - mediaUrls: payload.mediaUrls, - }); - }) - .catch((err) => { - // Keep chain healthy after an error so later tool results still deliver. - logVerbose(`tool result delivery failed: ${String(err)}`); + await params.opts.onPartialReply({ + text: textForTyping, + mediaUrls: payload.mediaUrls, + }); + }, + onAssistantMessageStart: async () => { + await params.typingSignals.signalMessageStart(); + await params.opts?.onAssistantMessageStart?.(); + }, + onReasoningStream: + params.typingSignals.shouldStartOnReasoning || params.opts?.onReasoningStream + ? async (payload) => { + await params.typingSignals.signalReasoningDelta(); + await params.opts?.onReasoningStream?.({ + text: payload.text, + mediaUrls: payload.mediaUrls, }); - const task = toolResultChain.finally(() => { - params.pendingToolTasks.delete(task); - }); - params.pendingToolTasks.add(task); - }; - })() - : undefined, - }); + } + : undefined, + onReasoningEnd: params.opts?.onReasoningEnd, + onAgentEvent: async (evt) => { + // Signal run start only after the embedded agent emits real activity. + const hasLifecyclePhase = + evt.stream === "lifecycle" && typeof evt.data.phase === "string"; + if (evt.stream !== "lifecycle" || hasLifecyclePhase) { + notifyAgentRunStart(); + } + // Trigger typing when tools start executing. + // Must await to ensure typing indicator starts before tool summaries are emitted. + if (evt.stream === "tool") { + const phase = typeof evt.data.phase === "string" ? evt.data.phase : ""; + const name = typeof evt.data.name === "string" ? evt.data.name : undefined; + if (phase === "start" || phase === "update") { + await params.typingSignals.signalToolStart(); + await params.opts?.onToolStart?.({ name, phase }); + } + } + // Track auto-compaction completion + if (evt.stream === "compaction") { + const phase = typeof evt.data.phase === "string" ? evt.data.phase : ""; + if (phase === "end") { + autoCompactionCompleted = true; + } + } + }, + // Always pass onBlockReply so flushBlockReplyBuffer works before tool execution, + // even when regular block streaming is disabled. The handler sends directly + // via opts.onBlockReply when the pipeline isn't available. + onBlockReply: params.opts?.onBlockReply + ? createBlockReplyDeliveryHandler({ + onBlockReply: params.opts.onBlockReply, + currentMessageId: + params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid, + normalizeStreamingText, + applyReplyToMode: params.applyReplyToMode, + typingSignals: params.typingSignals, + blockStreamingEnabled: params.blockStreamingEnabled, + blockReplyPipeline, + directlySentBlockKeys, + }) + : undefined, + onBlockReplyFlush: + params.blockStreamingEnabled && blockReplyPipeline + ? async () => { + await blockReplyPipeline.flush({ force: true }); + } + : undefined, + shouldEmitToolResult: params.shouldEmitToolResult, + shouldEmitToolOutput: params.shouldEmitToolOutput, + bootstrapPromptWarningSignaturesSeen, + bootstrapPromptWarningSignature: + bootstrapPromptWarningSignaturesSeen[ + bootstrapPromptWarningSignaturesSeen.length - 1 + ], + onToolResult: onToolResult + ? (() => { + // Serialize tool result delivery to preserve message ordering. + // Without this, concurrent tool callbacks race through typing signals + // and message sends, causing out-of-order delivery to the user. + // See: https://github.com/openclaw/openclaw/issues/11044 + let toolResultChain: Promise = Promise.resolve(); + return (payload: ReplyPayload) => { + toolResultChain = toolResultChain + .then(async () => { + const { text, skip } = normalizeStreamingText(payload); + if (skip) { + return; + } + await params.typingSignals.signalTextDelta(text); + await onToolResult({ + text, + mediaUrls: payload.mediaUrls, + }); + }) + .catch((err) => { + // Keep chain healthy after an error so later tool results still deliver. + logVerbose(`tool result delivery failed: ${String(err)}`); + }); + const task = toolResultChain.finally(() => { + params.pendingToolTasks.delete(task); + }); + params.pendingToolTasks.add(task); + }; + })() + : undefined, + }); + bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( + result.meta?.systemPromptReport, + ); + return result; + })(); }, }); runResult = fallbackResult.result; diff --git a/src/auto-reply/reply/commands-context-report.test.ts b/src/auto-reply/reply/commands-context-report.test.ts index 515e2c8f6f3..105a641f59a 100644 --- a/src/auto-reply/reply/commands-context-report.test.ts +++ b/src/auto-reply/reply/commands-context-report.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest"; import { buildContextReply } from "./commands-context-report.js"; import type { HandleCommandsParams } from "./commands-types.js"; -function makeParams(commandBodyNormalized: string, truncated: boolean): HandleCommandsParams { +function makeParams( + commandBodyNormalized: string, + truncated: boolean, + options?: { omitBootstrapLimits?: boolean }, +): HandleCommandsParams { return { command: { commandBodyNormalized, @@ -25,8 +29,8 @@ function makeParams(commandBodyNormalized: string, truncated: boolean): HandleCo source: "run", generatedAt: Date.now(), workspaceDir: "/tmp/workspace", - bootstrapMaxChars: 20_000, - bootstrapTotalMaxChars: 150_000, + bootstrapMaxChars: options?.omitBootstrapLimits ? undefined : 20_000, + bootstrapTotalMaxChars: options?.omitBootstrapLimits ? undefined : 150_000, sandbox: { mode: "off", sandboxed: false }, systemPrompt: { chars: 1_000, @@ -67,13 +71,22 @@ describe("buildContextReply", () => { const result = await buildContextReply(makeParams("/context list", true)); expect(result.text).toContain("Bootstrap max/total: 150,000 chars"); expect(result.text).toContain("⚠ Bootstrap context is over configured limits"); - expect(result.text).toContain( - "Causes: 1 file(s) exceeded max/file; raw total exceeded max/total.", - ); + expect(result.text).toContain("Causes: 1 file(s) exceeded max/file."); }); it("does not show bootstrap truncation warning when there is no truncation", async () => { const result = await buildContextReply(makeParams("/context list", false)); expect(result.text).not.toContain("Bootstrap context is over configured limits"); }); + + it("falls back to config defaults when legacy reports are missing bootstrap limits", async () => { + const result = await buildContextReply( + makeParams("/context list", false, { + omitBootstrapLimits: true, + }), + ); + expect(result.text).toContain("Bootstrap max/file: 20,000 chars"); + expect(result.text).toContain("Bootstrap max/total: 150,000 chars"); + expect(result.text).not.toContain("Bootstrap max/file: ? chars"); + }); }); diff --git a/src/auto-reply/reply/commands-context-report.ts b/src/auto-reply/reply/commands-context-report.ts index fd6df7d70a1..cbf190c4c88 100644 --- a/src/auto-reply/reply/commands-context-report.ts +++ b/src/auto-reply/reply/commands-context-report.ts @@ -1,3 +1,4 @@ +import { analyzeBootstrapBudget } from "../../agents/bootstrap-budget.js"; import { resolveBootstrapMaxChars, resolveBootstrapTotalMaxChars, @@ -141,37 +142,49 @@ export async function buildContextReply(params: HandleCommandsParams): Promise !f.missing); - const truncatedBootstrapFiles = nonMissingBootstrapFiles.filter((f) => f.truncated); - const rawBootstrapChars = nonMissingBootstrapFiles.reduce((sum, file) => sum + file.rawChars, 0); - const injectedBootstrapChars = nonMissingBootstrapFiles.reduce( - (sum, file) => sum + file.injectedChars, - 0, + const bootstrapMaxChars = + typeof report.bootstrapMaxChars === "number" && + Number.isFinite(report.bootstrapMaxChars) && + report.bootstrapMaxChars > 0 + ? report.bootstrapMaxChars + : resolveBootstrapMaxChars(params.cfg); + const bootstrapTotalMaxChars = + typeof report.bootstrapTotalMaxChars === "number" && + Number.isFinite(report.bootstrapTotalMaxChars) && + report.bootstrapTotalMaxChars > 0 + ? report.bootstrapTotalMaxChars + : resolveBootstrapTotalMaxChars(params.cfg); + const bootstrapMaxLabel = `${formatInt(bootstrapMaxChars)} chars`; + const bootstrapTotalLabel = `${formatInt(bootstrapTotalMaxChars)} chars`; + const bootstrapAnalysis = analyzeBootstrapBudget({ + files: report.injectedWorkspaceFiles, + bootstrapMaxChars, + bootstrapTotalMaxChars, + }); + const truncatedBootstrapFiles = bootstrapAnalysis.truncatedFiles; + const truncationCauseCounts = truncatedBootstrapFiles.reduce( + (acc, file) => { + for (const cause of file.causes) { + if (cause === "per-file-limit") { + acc.perFile += 1; + } else if (cause === "total-limit") { + acc.total += 1; + } + } + return acc; + }, + { perFile: 0, total: 0 }, ); - const perFileOverLimitCount = - typeof bootstrapMaxChars === "number" - ? nonMissingBootstrapFiles.filter((f) => f.rawChars > bootstrapMaxChars).length - : 0; - const totalOverLimit = - typeof bootstrapTotalMaxChars === "number" && rawBootstrapChars > bootstrapTotalMaxChars; const truncationCauseParts = [ - perFileOverLimitCount > 0 ? `${perFileOverLimitCount} file(s) exceeded max/file` : null, - totalOverLimit ? "raw total exceeded max/total" : null, + truncationCauseCounts.perFile > 0 + ? `${truncationCauseCounts.perFile} file(s) exceeded max/file` + : null, + truncationCauseCounts.total > 0 ? `${truncationCauseCounts.total} file(s) hit max/total` : null, ].filter(Boolean); const bootstrapWarningLines = truncatedBootstrapFiles.length > 0 ? [ - `⚠ Bootstrap context is over configured limits: ${truncatedBootstrapFiles.length} file(s) truncated (${formatInt(rawBootstrapChars)} raw chars -> ${formatInt(injectedBootstrapChars)} injected chars).`, + `⚠ Bootstrap context is over configured limits: ${truncatedBootstrapFiles.length} file(s) truncated (${formatInt(bootstrapAnalysis.totals.rawChars)} raw chars -> ${formatInt(bootstrapAnalysis.totals.injectedChars)} injected chars).`, ...(truncationCauseParts.length ? [`Causes: ${truncationCauseParts.join("; ")}.`] : []), "Tip: increase `agents.defaults.bootstrapMaxChars` and/or `agents.defaults.bootstrapTotalMaxChars` if this truncation is not intentional.", ] diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 32fbd3b2adc..4817a0be8ab 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -14,6 +14,7 @@ import { } from "../agents/agent-scope.js"; import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; import { clearSessionAuthProfileOverride } from "../agents/auth-profiles/session-override.js"; +import { resolveBootstrapWarningSignaturesSeen } from "../agents/bootstrap-budget.js"; import { runCliAgent } from "../agents/cli-runner.js"; import { getCliSessionId, setCliSessionId } from "../agents/cli-session.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; @@ -178,6 +179,11 @@ function runAgentAttempt(params: { body: params.body, isFallbackRetry: params.isFallbackRetry, }); + const bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( + params.sessionEntry?.systemPromptReport, + ); + const bootstrapPromptWarningSignature = + bootstrapPromptWarningSignaturesSeen[bootstrapPromptWarningSignaturesSeen.length - 1]; if (isCliProvider(params.providerOverride, params.cfg)) { const cliSessionId = getCliSessionId(params.sessionEntry, params.providerOverride); const runCliWithSession = (nextCliSessionId: string | undefined) => @@ -196,6 +202,8 @@ function runAgentAttempt(params: { runId: params.runId, extraSystemPrompt: params.opts.extraSystemPrompt, cliSessionId: nextCliSessionId, + bootstrapPromptWarningSignaturesSeen, + bootstrapPromptWarningSignature, images: params.isFallbackRetry ? undefined : params.opts.images, streamParams: params.opts.streamParams, }); @@ -317,6 +325,8 @@ function runAgentAttempt(params: { streamParams: params.opts.streamParams, agentDir: params.agentDir, onAgentEvent: params.onAgentEvent, + bootstrapPromptWarningSignaturesSeen, + bootstrapPromptWarningSignature, }); } diff --git a/src/commands/agent/session-store.test.ts b/src/commands/agent/session-store.test.ts index 19de2486cbb..89af0b29f65 100644 --- a/src/commands/agent/session-store.test.ts +++ b/src/commands/agent/session-store.test.ts @@ -63,4 +63,65 @@ describe("updateSessionStoreAfterAgentRun", () => { expect(persisted?.acp).toBeDefined(); expect(staleInMemory[sessionKey]?.acp).toBeDefined(); }); + + it("persists latest systemPromptReport for downstream warning dedupe", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-store-")); + const storePath = path.join(dir, "sessions.json"); + const sessionKey = `agent:codex:report:${randomUUID()}`; + const sessionId = randomUUID(); + + const sessionStore: Record = { + [sessionKey]: { + sessionId, + updatedAt: Date.now(), + }, + }; + await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf8"); + + const report = { + source: "run" as const, + generatedAt: Date.now(), + bootstrapTruncation: { + warningMode: "once" as const, + warningSignaturesSeen: ["sig-a", "sig-b"], + }, + systemPrompt: { + chars: 1, + projectContextChars: 1, + nonProjectContextChars: 0, + }, + injectedWorkspaceFiles: [], + skills: { promptChars: 0, entries: [] }, + tools: { listChars: 0, schemaChars: 0, entries: [] }, + }; + + await updateSessionStoreAfterAgentRun({ + cfg: {} as never, + sessionId, + sessionKey, + storePath, + sessionStore, + defaultProvider: "openai", + defaultModel: "gpt-5.3-codex", + result: { + payloads: [], + meta: { + agentMeta: { + provider: "openai", + model: "gpt-5.3-codex", + }, + systemPromptReport: report, + }, + } as never, + }); + + const persisted = loadSessionStore(storePath, { skipCache: true })[sessionKey]; + expect(persisted?.systemPromptReport?.bootstrapTruncation?.warningSignaturesSeen).toEqual([ + "sig-a", + "sig-b", + ]); + expect(sessionStore[sessionKey]?.systemPromptReport?.bootstrapTruncation?.warningMode).toBe( + "once", + ); + }); }); diff --git a/src/commands/agent/session-store.ts b/src/commands/agent/session-store.ts index 9285268d216..08bde6bb9a8 100644 --- a/src/commands/agent/session-store.ts +++ b/src/commands/agent/session-store.ts @@ -76,6 +76,9 @@ export async function updateSessionStoreAfterAgentRun(params: { } } next.abortedLastRun = result.meta.aborted ?? false; + if (result.meta.systemPromptReport) { + next.systemPromptReport = result.meta.systemPromptReport; + } if (hasNonzeroUsage(usage)) { const input = usage.input ?? 0; const output = usage.output ?? 0; diff --git a/src/commands/doctor-bootstrap-size.test.ts b/src/commands/doctor-bootstrap-size.test.ts new file mode 100644 index 00000000000..654601619e8 --- /dev/null +++ b/src/commands/doctor-bootstrap-size.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; + +const note = vi.hoisted(() => vi.fn()); +const resolveAgentWorkspaceDir = vi.hoisted(() => vi.fn(() => "/tmp/workspace")); +const resolveDefaultAgentId = vi.hoisted(() => vi.fn(() => "main")); +const resolveBootstrapContextForRun = vi.hoisted(() => vi.fn()); +const resolveBootstrapMaxChars = vi.hoisted(() => vi.fn(() => 20_000)); +const resolveBootstrapTotalMaxChars = vi.hoisted(() => vi.fn(() => 150_000)); + +vi.mock("../terminal/note.js", () => ({ + note, +})); + +vi.mock("../agents/agent-scope.js", () => ({ + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +})); + +vi.mock("../agents/bootstrap-files.js", () => ({ + resolveBootstrapContextForRun, +})); + +vi.mock("../agents/pi-embedded-helpers.js", () => ({ + resolveBootstrapMaxChars, + resolveBootstrapTotalMaxChars, +})); + +import { noteBootstrapFileSize } from "./doctor-bootstrap-size.js"; + +describe("noteBootstrapFileSize", () => { + beforeEach(() => { + note.mockClear(); + resolveBootstrapContextForRun.mockReset(); + resolveBootstrapContextForRun.mockResolvedValue({ + bootstrapFiles: [], + contextFiles: [], + }); + }); + + it("emits a warning when bootstrap files are truncated", async () => { + resolveBootstrapContextForRun.mockResolvedValue({ + bootstrapFiles: [ + { + name: "AGENTS.md", + path: "/tmp/workspace/AGENTS.md", + content: "a".repeat(25_000), + missing: false, + }, + ], + contextFiles: [{ path: "/tmp/workspace/AGENTS.md", content: "a".repeat(20_000) }], + }); + await noteBootstrapFileSize({} as OpenClawConfig); + expect(note).toHaveBeenCalledTimes(1); + const [message, title] = note.mock.calls[0] ?? []; + expect(String(title)).toBe("Bootstrap file size"); + expect(String(message)).toContain("will be truncated"); + expect(String(message)).toContain("AGENTS.md"); + expect(String(message)).toContain("max/file"); + }); + + it("stays silent when files are comfortably within limits", async () => { + resolveBootstrapContextForRun.mockResolvedValue({ + bootstrapFiles: [ + { + name: "AGENTS.md", + path: "/tmp/workspace/AGENTS.md", + content: "a".repeat(1_000), + missing: false, + }, + ], + contextFiles: [{ path: "/tmp/workspace/AGENTS.md", content: "a".repeat(1_000) }], + }); + await noteBootstrapFileSize({} as OpenClawConfig); + expect(note).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/doctor-bootstrap-size.ts b/src/commands/doctor-bootstrap-size.ts new file mode 100644 index 00000000000..b7dd55243c0 --- /dev/null +++ b/src/commands/doctor-bootstrap-size.ts @@ -0,0 +1,101 @@ +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { + buildBootstrapInjectionStats, + analyzeBootstrapBudget, +} from "../agents/bootstrap-budget.js"; +import { resolveBootstrapContextForRun } from "../agents/bootstrap-files.js"; +import { + resolveBootstrapMaxChars, + resolveBootstrapTotalMaxChars, +} from "../agents/pi-embedded-helpers.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { note } from "../terminal/note.js"; + +function formatInt(value: number): string { + return new Intl.NumberFormat("en-US").format(Math.max(0, Math.floor(value))); +} + +function formatPercent(numerator: number, denominator: number): string { + if (!Number.isFinite(denominator) || denominator <= 0) { + return "0%"; + } + const pct = Math.min(100, Math.max(0, Math.round((numerator / denominator) * 100))); + return `${pct}%`; +} + +function formatCauses(causes: Array<"per-file-limit" | "total-limit">): string { + if (causes.length === 0) { + return "unknown"; + } + return causes.map((cause) => (cause === "per-file-limit" ? "max/file" : "max/total")).join(", "); +} + +export async function noteBootstrapFileSize(cfg: OpenClawConfig) { + const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); + const bootstrapMaxChars = resolveBootstrapMaxChars(cfg); + const bootstrapTotalMaxChars = resolveBootstrapTotalMaxChars(cfg); + const { bootstrapFiles, contextFiles } = await resolveBootstrapContextForRun({ + workspaceDir, + config: cfg, + }); + const stats = buildBootstrapInjectionStats({ + bootstrapFiles, + injectedFiles: contextFiles, + }); + const analysis = analyzeBootstrapBudget({ + files: stats, + bootstrapMaxChars, + bootstrapTotalMaxChars, + }); + if (!analysis.hasTruncation && analysis.nearLimitFiles.length === 0 && !analysis.totalNearLimit) { + return analysis; + } + + const lines: string[] = []; + if (analysis.hasTruncation) { + lines.push("Workspace bootstrap files exceed limits and will be truncated:"); + for (const file of analysis.truncatedFiles) { + const truncatedChars = Math.max(0, file.rawChars - file.injectedChars); + lines.push( + `- ${file.name}: ${formatInt(file.rawChars)} raw / ${formatInt(file.injectedChars)} injected (${formatPercent(truncatedChars, file.rawChars)} truncated; ${formatCauses(file.causes)})`, + ); + } + } else { + lines.push("Workspace bootstrap files are near configured limits:"); + } + + const nonTruncatedNearLimit = analysis.nearLimitFiles.filter((file) => !file.truncated); + if (nonTruncatedNearLimit.length > 0) { + for (const file of nonTruncatedNearLimit) { + lines.push( + `- ${file.name}: ${formatInt(file.rawChars)} chars (${formatPercent(file.rawChars, bootstrapMaxChars)} of max/file ${formatInt(bootstrapMaxChars)})`, + ); + } + } + + lines.push( + `Total bootstrap injected chars: ${formatInt(analysis.totals.injectedChars)} (${formatPercent(analysis.totals.injectedChars, bootstrapTotalMaxChars)} of max/total ${formatInt(bootstrapTotalMaxChars)}).`, + ); + lines.push( + `Total bootstrap raw chars (before truncation): ${formatInt(analysis.totals.rawChars)}.`, + ); + + const needsPerFileTip = + analysis.truncatedFiles.some((file) => file.causes.includes("per-file-limit")) || + analysis.nearLimitFiles.length > 0; + const needsTotalTip = + analysis.truncatedFiles.some((file) => file.causes.includes("total-limit")) || + analysis.totalNearLimit; + if (needsPerFileTip || needsTotalTip) { + lines.push(""); + } + if (needsPerFileTip) { + lines.push("- Tip: tune `agents.defaults.bootstrapMaxChars` for per-file limits."); + } + if (needsTotalTip) { + lines.push("- Tip: tune `agents.defaults.bootstrapTotalMaxChars` for total-budget limits."); + } + + note(lines.join("\n"), "Bootstrap file size"); + return analysis; +} diff --git a/src/commands/doctor.fast-path-mocks.ts b/src/commands/doctor.fast-path-mocks.ts index 045d8d21f79..f05e3d929a7 100644 --- a/src/commands/doctor.fast-path-mocks.ts +++ b/src/commands/doctor.fast-path-mocks.ts @@ -4,6 +4,10 @@ vi.mock("./doctor-completion.js", () => ({ doctorShellCompletion: vi.fn().mockResolvedValue(undefined), })); +vi.mock("./doctor-bootstrap-size.js", () => ({ + noteBootstrapFileSize: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("./doctor-gateway-daemon-flow.js", () => ({ maybeRepairGatewayDaemon: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 0f5fb199f80..6335c67502f 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -26,6 +26,7 @@ import { maybeRepairAnthropicOAuthProfileId, noteAuthProfileHealth, } from "./doctor-auth.js"; +import { noteBootstrapFileSize } from "./doctor-bootstrap-size.js"; import { doctorShellCompletion } from "./doctor-completion.js"; import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; import { maybeRepairGatewayDaemon } from "./doctor-gateway-daemon-flow.js"; @@ -271,6 +272,7 @@ export async function doctorCommand( } noteWorkspaceStatus(cfg); + await noteBootstrapFileSize(cfg); // Check and fix shell completion await doctorShellCompletion(runtime, prompter, { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index a6a49fae033..1f0a77980c7 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -705,6 +705,8 @@ export const FIELD_HELP: Record = { "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", "agents.defaults.bootstrapTotalMaxChars": "Max total characters across all injected workspace bootstrap files (default: 150000).", + "agents.defaults.bootstrapPromptTruncationWarning": + 'Inject agent-visible warning text when bootstrap files are truncated: "off", "once" (default), or "always".', "agents.defaults.repoRoot": "Optional repository root shown in the system prompt runtime line (overrides auto-detect).", "agents.defaults.envelopeTimezone": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 35ad9db80f9..1248f95b275 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -278,6 +278,7 @@ export const FIELD_LABELS: Record = { "agents.defaults.repoRoot": "Repo Root", "agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars", "agents.defaults.bootstrapTotalMaxChars": "Bootstrap Total Max Chars", + "agents.defaults.bootstrapPromptTruncationWarning": "Bootstrap Prompt Truncation Warning", "agents.defaults.envelopeTimezone": "Envelope Timezone", "agents.defaults.envelopeTimestamp": "Envelope Timestamp", "agents.defaults.envelopeElapsed": "Envelope Elapsed", diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index a8fa15278c6..81d67d13011 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -328,6 +328,15 @@ export type SessionSystemPromptReport = { workspaceDir?: string; bootstrapMaxChars?: number; bootstrapTotalMaxChars?: number; + bootstrapTruncation?: { + warningMode?: "off" | "once" | "always"; + warningShown?: boolean; + promptWarningSignature?: string; + warningSignaturesSeen?: string[]; + truncatedFiles?: number; + nearLimitFiles?: number; + totalNearLimit?: boolean; + }; sandbox?: { mode?: string; sandboxed?: boolean; diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 209961da045..1f20579d0bf 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -140,6 +140,13 @@ export type AgentDefaultsConfig = { bootstrapMaxChars?: number; /** Max total chars across all injected bootstrap files (default: 150000). */ bootstrapTotalMaxChars?: number; + /** + * Agent-visible bootstrap truncation warning mode: + * - off: do not inject warning text + * - once: inject once per unique truncation signature (default) + * - always: inject on every run with truncation + */ + bootstrapPromptTruncationWarning?: "off" | "once" | "always"; /** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */ userTimezone?: string; /** Time format in system prompt: auto (OS preference), 12-hour, or 24-hour. */ diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 0f0f2d408e9..aad541d6d1d 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -40,6 +40,9 @@ export const AgentDefaultsSchema = z skipBootstrap: z.boolean().optional(), bootstrapMaxChars: z.number().int().positive().optional(), bootstrapTotalMaxChars: z.number().int().positive().optional(), + bootstrapPromptTruncationWarning: z + .union([z.literal("off"), z.literal("once"), z.literal("always")]) + .optional(), userTimezone: z.string().optional(), timeFormat: z.union([z.literal("auto"), z.literal("12"), z.literal("24")]).optional(), envelopeTimezone: z.string().optional(), diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 028b2e3ce36..2e6020a1fe1 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -6,6 +6,7 @@ import { resolveDefaultAgentId, } from "../../agents/agent-scope.js"; import { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js"; +import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js"; import { runCliAgent } from "../../agents/cli-runner.js"; import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js"; import { lookupContextTokens } from "../../agents/context.js"; @@ -450,6 +451,9 @@ export async function runCronIsolatedAgentTurn(params: { params.job.payload.kind === "agentTurn" && Array.isArray(params.job.payload.fallbacks) ? params.job.payload.fallbacks : undefined; + let bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( + cronSession.sessionEntry.systemPromptReport, + ); const fallbackResult = await runWithModelFallback({ cfg: cfgWithAgentDefaults, provider, @@ -457,10 +461,12 @@ export async function runCronIsolatedAgentTurn(params: { agentDir, fallbacksOverride: payloadFallbacks ?? resolveAgentModelFallbacksOverride(params.cfg, agentId), - run: (providerOverride, modelOverride) => { + run: async (providerOverride, modelOverride) => { if (abortSignal?.aborted) { throw new Error(abortReason()); } + const bootstrapPromptWarningSignature = + bootstrapPromptWarningSignaturesSeen[bootstrapPromptWarningSignaturesSeen.length - 1]; if (isCliProvider(providerOverride, cfgWithAgentDefaults)) { // Fresh isolated cron sessions must not reuse a stored CLI session ID. // Passing an existing ID activates the resume watchdog profile @@ -470,7 +476,7 @@ export async function runCronIsolatedAgentTurn(params: { const cliSessionId = cronSession.isNewSession ? undefined : getCliSessionId(cronSession.sessionEntry, providerOverride); - return runCliAgent({ + const result = await runCliAgent({ sessionId: cronSession.sessionEntry.sessionId, sessionKey: agentSessionKey, agentId, @@ -484,9 +490,15 @@ export async function runCronIsolatedAgentTurn(params: { timeoutMs, runId: cronSession.sessionEntry.sessionId, cliSessionId, + bootstrapPromptWarningSignaturesSeen, + bootstrapPromptWarningSignature, }); + bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( + result.meta?.systemPromptReport, + ); + return result; } - return runEmbeddedPiAgent({ + const result = await runEmbeddedPiAgent({ sessionId: cronSession.sessionEntry.sessionId, sessionKey: agentSessionKey, agentId, @@ -516,7 +528,13 @@ export async function runCronIsolatedAgentTurn(params: { requireExplicitMessageTarget: deliveryRequested && resolvedDelivery.ok, disableMessageTool: deliveryRequested || deliveryPlan.mode === "none", abortSignal, + bootstrapPromptWarningSignaturesSeen, + bootstrapPromptWarningSignature, }); + bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( + result.meta?.systemPromptReport, + ); + return result; }, }); runResult = fallbackResult.result; @@ -537,6 +555,9 @@ export async function runCronIsolatedAgentTurn(params: { // Also collect best-effort telemetry for the cron run log. let telemetry: CronRunTelemetry | undefined; { + if (runResult.meta?.systemPromptReport) { + cronSession.sessionEntry.systemPromptReport = runResult.meta.systemPromptReport; + } const usage = runResult.meta?.agentMeta?.usage; const promptTokens = runResult.meta?.agentMeta?.promptTokens; const modelUsed = runResult.meta?.agentMeta?.model ?? fallbackModel ?? model;