mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
committed by
GitHub
parent
3ad3a90db3
commit
e4b4486a96
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
397
src/agents/bootstrap-budget.test.ts
Normal file
397
src/agents/bootstrap-budget.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
349
src/agents/bootstrap-budget.ts
Normal file
349
src/agents/bootstrap-budget.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -30,6 +30,8 @@ export type EmbeddedRunAttemptResult = {
|
||||
timedOutDuringCompaction: boolean;
|
||||
promptError: unknown;
|
||||
sessionIdUsed: string;
|
||||
bootstrapPromptWarningSignaturesSeen?: string[];
|
||||
bootstrapPromptWarningSignature?: string;
|
||||
systemPromptReport?: SessionSystemPromptReport;
|
||||
messagesSnapshot: AgentMessage[];
|
||||
assistantTexts: string[];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, "");
|
||||
}
|
||||
|
||||
@@ -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<void> = 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<void> = 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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<R
|
||||
: "Tools: (none)";
|
||||
const systemPromptLine = `System prompt (${report.source}): ${formatCharsAndTokens(report.systemPrompt.chars)} (Project Context ${formatCharsAndTokens(report.systemPrompt.projectContextChars)})`;
|
||||
const workspaceLabel = report.workspaceDir ?? params.workspaceDir;
|
||||
const bootstrapMaxLabel =
|
||||
typeof report.bootstrapMaxChars === "number"
|
||||
? `${formatInt(report.bootstrapMaxChars)} chars`
|
||||
: "? chars";
|
||||
const bootstrapTotalLabel =
|
||||
typeof report.bootstrapTotalMaxChars === "number"
|
||||
? `${formatInt(report.bootstrapTotalMaxChars)} chars`
|
||||
: "? chars";
|
||||
const bootstrapMaxChars = report.bootstrapMaxChars;
|
||||
const bootstrapTotalMaxChars = report.bootstrapTotalMaxChars;
|
||||
const nonMissingBootstrapFiles = report.injectedWorkspaceFiles.filter((f) => !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.",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, SessionEntry> = {
|
||||
[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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
77
src/commands/doctor-bootstrap-size.test.ts
Normal file
77
src/commands/doctor-bootstrap-size.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
101
src/commands/doctor-bootstrap-size.ts
Normal file
101
src/commands/doctor-bootstrap-size.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -705,6 +705,8 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"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":
|
||||
|
||||
@@ -278,6 +278,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user