mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 12:07:40 +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
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user