agents: reduce prompt token bloat from exec and context (#16539)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 8e1635fa3f
Co-authored-by: CharlieGreenman <8540141+CharlieGreenman@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Charlie Greenman
2026-02-14 18:32:45 -05:00
committed by GitHub
parent 2547514b47
commit dec6859702
23 changed files with 403 additions and 39 deletions

View File

@@ -31,6 +31,7 @@ export interface ProcessSession {
scopeKey?: string;
sessionKey?: string;
notifyOnExit?: boolean;
notifyOnExitEmptySuccess?: boolean;
exitNotified?: boolean;
child?: ChildProcessWithoutNullStreams;
stdin?: SessionStdin;

View File

@@ -221,6 +221,28 @@ describe("exec tool backgrounding", () => {
expect(status).toBe("completed");
});
it("defaults process log to a bounded tail when no window is provided", async () => {
const lines = Array.from({ length: 260 }, (_value, index) => `line-${index + 1}`);
const result = await execTool.execute("call1", {
command: echoLines(lines),
background: true,
});
const sessionId = (result.details as { sessionId: string }).sessionId;
await waitForCompletion(sessionId);
const log = await processTool.execute("call2", {
action: "log",
sessionId,
});
const textBlock = log.content.find((c) => c.type === "text")?.text ?? "";
const firstLine = textBlock.split("\n")[0]?.trim();
expect(textBlock).toContain("showing last 200 of 260 lines");
expect(firstLine).toBe("line-61");
expect(textBlock).toContain("line-61");
expect(textBlock).toContain("line-260");
expect((log.details as { totalLines?: number }).totalLines).toBe(260);
});
it("supports line offsets for log slices", async () => {
const result = await execTool.execute("call1", {
command: echoLines(["alpha", "beta", "gamma"]),
@@ -239,6 +261,29 @@ describe("exec tool backgrounding", () => {
expect(normalizeText(textBlock?.text)).toBe("beta");
});
it("keeps offset-only log requests unbounded by default tail mode", async () => {
const lines = Array.from({ length: 260 }, (_value, index) => `line-${index + 1}`);
const result = await execTool.execute("call1", {
command: echoLines(lines),
background: true,
});
const sessionId = (result.details as { sessionId: string }).sessionId;
await waitForCompletion(sessionId);
const log = await processTool.execute("call2", {
action: "log",
sessionId,
offset: 30,
});
const textBlock = log.content.find((c) => c.type === "text")?.text ?? "";
const renderedLines = textBlock.split("\n");
expect(renderedLines[0]?.trim()).toBe("line-31");
expect(renderedLines[renderedLines.length - 1]?.trim()).toBe("line-260");
expect(textBlock).not.toContain("showing last 200");
expect((log.details as { totalLines?: number }).totalLines).toBe(260);
});
it("scopes process sessions by scopeKey", async () => {
const bashA = createExecTool({ backgroundMs: 10, scopeKey: "agent:alpha" });
const processA = createProcessTool({ scopeKey: "agent:alpha" });
@@ -300,6 +345,49 @@ describe("exec notifyOnExit", () => {
expect(finished).toBeTruthy();
expect(hasEvent).toBe(true);
});
it("skips no-op completion events when command succeeds without output", async () => {
const tool = createExecTool({
allowBackground: true,
backgroundMs: 0,
notifyOnExit: true,
sessionKey: "agent:main:main",
});
const result = await tool.execute("call2", {
command: shortDelayCmd,
background: true,
});
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
const status = await waitForCompletion(sessionId);
expect(status).toBe("completed");
expect(peekSystemEvents("agent:main:main")).toEqual([]);
});
it("can re-enable no-op completion events via notifyOnExitEmptySuccess", async () => {
const tool = createExecTool({
allowBackground: true,
backgroundMs: 0,
notifyOnExit: true,
notifyOnExitEmptySuccess: true,
sessionKey: "agent:main:main",
});
const result = await tool.execute("call3", {
command: shortDelayCmd,
background: true,
});
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
const status = await waitForCompletion(sessionId);
expect(status).toBe("completed");
const events = peekSystemEvents("agent:main:main");
expect(events.length).toBeGreaterThan(0);
expect(events.some((event) => event.includes("Exec completed"))).toBe(true);
});
});
describe("exec PATH handling", () => {

View File

@@ -84,13 +84,14 @@ export const DEFAULT_MAX_OUTPUT = clampWithDefault(
);
export const DEFAULT_PENDING_MAX_OUTPUT = clampWithDefault(
readEnvInt("OPENCLAW_BASH_PENDING_MAX_OUTPUT_CHARS"),
200_000,
30_000,
1_000,
200_000,
);
export const DEFAULT_PATH =
process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
export const DEFAULT_NOTIFY_TAIL_CHARS = 400;
const DEFAULT_NOTIFY_SNIPPET_CHARS = 180;
export const DEFAULT_APPROVAL_TIMEOUT_MS = 120_000;
export const DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS = 130_000;
const DEFAULT_APPROVAL_RUNNING_NOTICE_MS = 10_000;
@@ -214,6 +215,18 @@ export function normalizeNotifyOutput(value: string) {
return value.replace(/\s+/g, " ").trim();
}
function compactNotifyOutput(value: string, maxChars = DEFAULT_NOTIFY_SNIPPET_CHARS) {
const normalized = normalizeNotifyOutput(value);
if (!normalized) {
return "";
}
if (normalized.length <= maxChars) {
return normalized;
}
const safe = Math.max(1, maxChars - 1);
return `${normalized.slice(0, safe)}`;
}
export function normalizePathPrepend(entries?: string[]) {
if (!Array.isArray(entries)) {
return [];
@@ -300,9 +313,12 @@ function maybeNotifyOnExit(session: ProcessSession, status: "completed" | "faile
const exitLabel = session.exitSignal
? `signal ${session.exitSignal}`
: `code ${session.exitCode ?? 0}`;
const output = normalizeNotifyOutput(
const output = compactNotifyOutput(
tail(session.tail || session.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS),
);
if (status === "completed" && !output && session.notifyOnExitEmptySuccess !== true) {
return;
}
const summary = output
? `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel}) :: ${output}`
: `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel})`;
@@ -350,6 +366,7 @@ export async function runExecProcess(opts: {
maxOutput: number;
pendingMaxOutput: number;
notifyOnExit: boolean;
notifyOnExitEmptySuccess?: boolean;
scopeKey?: string;
sessionKey?: string;
timeoutSec: number;
@@ -515,6 +532,7 @@ export async function runExecProcess(opts: {
scopeKey: opts.scopeKey,
sessionKey: opts.sessionKey,
notifyOnExit: opts.notifyOnExit,
notifyOnExitEmptySuccess: opts.notifyOnExitEmptySuccess === true,
exitNotified: false,
child: child ?? undefined,
stdin,

View File

@@ -79,6 +79,7 @@ export type ExecToolDefaults = {
sessionKey?: string;
messageProvider?: string;
notifyOnExit?: boolean;
notifyOnExitEmptySuccess?: boolean;
cwd?: string;
};
@@ -135,6 +136,7 @@ export function createExecTool(
const defaultPathPrepend = normalizePathPrepend(defaults?.pathPrepend);
const safeBins = resolveSafeBins(defaults?.safeBins);
const notifyOnExit = defaults?.notifyOnExit !== false;
const notifyOnExitEmptySuccess = defaults?.notifyOnExitEmptySuccess === true;
const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
const approvalRunningNoticeMs = resolveApprovalRunningNoticeMs(defaults?.approvalRunningNoticeMs);
// Derive agentId only when sessionKey is an agent session key.
@@ -749,6 +751,7 @@ export function createExecTool(
maxOutput,
pendingMaxOutput,
notifyOnExit: false,
notifyOnExitEmptySuccess: false,
scopeKey: defaults?.scopeKey,
sessionKey: notifySessionKey,
timeoutSec: effectiveTimeout,
@@ -883,6 +886,7 @@ export function createExecTool(
maxOutput,
pendingMaxOutput,
notifyOnExit,
notifyOnExitEmptySuccess,
scopeKey: defaults?.scopeKey,
sessionKey: notifySessionKey,
timeoutSec: effectiveTimeout,

View File

@@ -30,6 +30,25 @@ type WritableStdin = {
end: () => void;
destroyed?: boolean;
};
const DEFAULT_LOG_TAIL_LINES = 200;
function resolveLogSliceWindow(offset?: number, limit?: number) {
const usingDefaultTail = offset === undefined && limit === undefined;
const effectiveLimit =
typeof limit === "number" && Number.isFinite(limit)
? limit
: usingDefaultTail
? DEFAULT_LOG_TAIL_LINES
: undefined;
return { effectiveOffset: offset, effectiveLimit, usingDefaultTail };
}
function defaultTailNote(totalLines: number, usingDefaultTail: boolean) {
if (!usingDefaultTail || totalLines <= DEFAULT_LOG_TAIL_LINES) {
return "";
}
return `\n\n[showing last ${DEFAULT_LOG_TAIL_LINES} of ${totalLines} lines; pass offset/limit to page]`;
}
const processSchema = Type.Object({
action: Type.String({ description: "Process action" }),
@@ -294,13 +313,15 @@ export function createProcessTool(
details: { status: "failed" },
};
}
const window = resolveLogSliceWindow(params.offset, params.limit);
const { slice, totalLines, totalChars } = sliceLogLines(
scopedSession.aggregated,
params.offset,
params.limit,
window.effectiveOffset,
window.effectiveLimit,
);
const logDefaultTailNote = defaultTailNote(totalLines, window.usingDefaultTail);
return {
content: [{ type: "text", text: slice || "(no output yet)" }],
content: [{ type: "text", text: (slice || "(no output yet)") + logDefaultTailNote }],
details: {
status: scopedSession.exited ? "completed" : "running",
sessionId: params.sessionId,
@@ -313,14 +334,18 @@ export function createProcessTool(
};
}
if (scopedFinished) {
const window = resolveLogSliceWindow(params.offset, params.limit);
const { slice, totalLines, totalChars } = sliceLogLines(
scopedFinished.aggregated,
params.offset,
params.limit,
window.effectiveOffset,
window.effectiveLimit,
);
const status = scopedFinished.status === "completed" ? "completed" : "failed";
const logDefaultTailNote = defaultTailNote(totalLines, window.usingDefaultTail);
return {
content: [{ type: "text", text: slice || "(no output recorded)" }],
content: [
{ type: "text", text: (slice || "(no output recorded)") + logDefaultTailNote },
],
details: {
status,
sessionId: params.sessionId,

View File

@@ -1,7 +1,11 @@
import type { OpenClawConfig } from "../config/config.js";
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js";
import { buildBootstrapContextFiles, resolveBootstrapMaxChars } from "./pi-embedded-helpers.js";
import {
buildBootstrapContextFiles,
resolveBootstrapMaxChars,
resolveBootstrapTotalMaxChars,
} from "./pi-embedded-helpers.js";
import {
filterBootstrapFilesForSession,
loadWorkspaceBootstrapFiles,
@@ -55,6 +59,7 @@ export async function resolveBootstrapContextForRun(params: {
const bootstrapFiles = await resolveBootstrapFilesForRun(params);
const contextFiles = buildBootstrapContextFiles(bootstrapFiles, {
maxChars: resolveBootstrapMaxChars(params.config),
totalMaxChars: resolveBootstrapTotalMaxChars(params.config),
warn: params.warn,
});
return { bootstrapFiles, contextFiles };

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { buildBootstrapContextFiles, DEFAULT_BOOTSTRAP_MAX_CHARS } from "./pi-embedded-helpers.js";
import {
buildBootstrapContextFiles,
DEFAULT_BOOTSTRAP_MAX_CHARS,
DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS,
} from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
@@ -50,4 +54,49 @@ describe("buildBootstrapContextFiles", () => {
expect(result?.content).toBe(long);
expect(result?.content).not.toContain("[...truncated, read AGENTS.md for full content...]");
});
it("caps total injected bootstrap characters across files", () => {
const files = [
makeFile({ name: "AGENTS.md", content: "a".repeat(10_000) }),
makeFile({ name: "SOUL.md", path: "/tmp/SOUL.md", content: "b".repeat(10_000) }),
makeFile({ name: "USER.md", path: "/tmp/USER.md", content: "c".repeat(10_000) }),
];
const result = buildBootstrapContextFiles(files);
const totalChars = result.reduce((sum, entry) => sum + entry.content.length, 0);
expect(totalChars).toBeLessThanOrEqual(DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS);
expect(result).toHaveLength(3);
expect(result[2]?.content).toContain("[...truncated, read USER.md for full content...]");
});
it("enforces strict total cap even when truncation markers are present", () => {
const files = [
makeFile({ name: "AGENTS.md", content: "a".repeat(1_000) }),
makeFile({ name: "SOUL.md", path: "/tmp/SOUL.md", content: "b".repeat(1_000) }),
];
const result = buildBootstrapContextFiles(files, {
maxChars: 100,
totalMaxChars: 150,
});
const totalChars = result.reduce((sum, entry) => sum + entry.content.length, 0);
expect(totalChars).toBeLessThanOrEqual(150);
});
it("skips bootstrap injection when remaining total budget is too small", () => {
const files = [makeFile({ name: "AGENTS.md", content: "a".repeat(1_000) })];
const result = buildBootstrapContextFiles(files, {
maxChars: 200,
totalMaxChars: 40,
});
expect(result).toEqual([]);
});
it("keeps missing markers under small total budgets", () => {
const files = [makeFile({ missing: true, content: undefined })];
const result = buildBootstrapContextFiles(files, {
totalMaxChars: 20,
});
expect(result).toHaveLength(1);
expect(result[0]?.content.length).toBeLessThanOrEqual(20);
expect(result[0]?.content.startsWith("[MISSING]")).toBe(true);
});
});

View File

@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { DEFAULT_BOOTSTRAP_MAX_CHARS, resolveBootstrapMaxChars } from "./pi-embedded-helpers.js";
import {
DEFAULT_BOOTSTRAP_MAX_CHARS,
DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS,
resolveBootstrapMaxChars,
resolveBootstrapTotalMaxChars,
} from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
@@ -27,3 +32,21 @@ describe("resolveBootstrapMaxChars", () => {
expect(resolveBootstrapMaxChars(cfg)).toBe(DEFAULT_BOOTSTRAP_MAX_CHARS);
});
});
describe("resolveBootstrapTotalMaxChars", () => {
it("returns default when unset", () => {
expect(resolveBootstrapTotalMaxChars()).toBe(DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS);
});
it("uses configured value when valid", () => {
const cfg = {
agents: { defaults: { bootstrapTotalMaxChars: 12345 } },
} as OpenClawConfig;
expect(resolveBootstrapTotalMaxChars(cfg)).toBe(12345);
});
it("falls back when invalid", () => {
const cfg = {
agents: { defaults: { bootstrapTotalMaxChars: -1 } },
} as OpenClawConfig;
expect(resolveBootstrapTotalMaxChars(cfg)).toBe(DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS);
});
});

View File

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

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import type { OpenClawConfig } from "../../config/config.js";
import type { WorkspaceBootstrapFile } from "../workspace.js";
import type { EmbeddedContextFile } from "./types.js";
import { truncateUtf16Safe } from "../../utils.js";
type ContentBlockWithSignature = {
thought_signature?: unknown;
@@ -82,6 +83,8 @@ export function stripThoughtSignatures<T>(
}
export const DEFAULT_BOOTSTRAP_MAX_CHARS = 20_000;
export const DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS = 24_000;
const MIN_BOOTSTRAP_FILE_BUDGET_CHARS = 64;
const BOOTSTRAP_HEAD_RATIO = 0.7;
const BOOTSTRAP_TAIL_RATIO = 0.2;
@@ -100,6 +103,14 @@ export function resolveBootstrapMaxChars(cfg?: OpenClawConfig): number {
return DEFAULT_BOOTSTRAP_MAX_CHARS;
}
export function resolveBootstrapTotalMaxChars(cfg?: OpenClawConfig): number {
const raw = cfg?.agents?.defaults?.bootstrapTotalMaxChars;
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
return Math.floor(raw);
}
return DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS;
}
function trimBootstrapContent(
content: string,
fileName: string,
@@ -135,6 +146,20 @@ function trimBootstrapContent(
};
}
function clampToBudget(content: string, budget: number): string {
if (budget <= 0) {
return "";
}
if (content.length <= budget) {
return content;
}
if (budget <= 3) {
return truncateUtf16Safe(content, budget);
}
const safe = Math.max(1, budget - 1);
return `${truncateUtf16Safe(content, safe)}`;
}
export async function ensureSessionHeader(params: {
sessionFile: string;
sessionId: string;
@@ -161,30 +186,53 @@ export async function ensureSessionHeader(params: {
export function buildBootstrapContextFiles(
files: WorkspaceBootstrapFile[],
opts?: { warn?: (message: string) => void; maxChars?: number },
opts?: { warn?: (message: string) => void; maxChars?: number; totalMaxChars?: number },
): EmbeddedContextFile[] {
const maxChars = opts?.maxChars ?? DEFAULT_BOOTSTRAP_MAX_CHARS;
const totalMaxChars = Math.max(
1,
Math.floor(opts?.totalMaxChars ?? Math.max(maxChars, DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS)),
);
let remainingTotalChars = totalMaxChars;
const result: EmbeddedContextFile[] = [];
for (const file of files) {
if (remainingTotalChars <= 0) {
break;
}
if (file.missing) {
const missingText = `[MISSING] Expected at: ${file.path}`;
const cappedMissingText = clampToBudget(missingText, remainingTotalChars);
if (!cappedMissingText) {
break;
}
remainingTotalChars = Math.max(0, remainingTotalChars - cappedMissingText.length);
result.push({
path: file.path,
content: `[MISSING] Expected at: ${file.path}`,
content: cappedMissingText,
});
continue;
}
const trimmed = trimBootstrapContent(file.content ?? "", file.name, maxChars);
if (!trimmed.content) {
if (remainingTotalChars < MIN_BOOTSTRAP_FILE_BUDGET_CHARS) {
opts?.warn?.(
`remaining bootstrap budget is ${remainingTotalChars} chars (<${MIN_BOOTSTRAP_FILE_BUDGET_CHARS}); skipping additional bootstrap files`,
);
break;
}
const fileMaxChars = Math.max(1, Math.min(maxChars, remainingTotalChars));
const trimmed = trimBootstrapContent(file.content ?? "", file.name, fileMaxChars);
const contentWithinBudget = clampToBudget(trimmed.content, remainingTotalChars);
if (!contentWithinBudget) {
continue;
}
if (trimmed.truncated) {
if (trimmed.truncated || contentWithinBudget.length < trimmed.content.length) {
opts?.warn?.(
`workspace bootstrap file ${file.name} is ${trimmed.originalLength} chars (limit ${trimmed.maxChars}); truncating in injected context`,
);
}
remainingTotalChars = Math.max(0, remainingTotalChars - contentWithinBudget.length);
result.push({
path: file.path,
content: trimmed.content,
content: contentWithinBudget,
});
}
return result;

View File

@@ -105,6 +105,8 @@ function resolveExecConfig(params: { cfg?: OpenClawConfig; agentId?: string }) {
agentExec?.approvalRunningNoticeMs ?? globalExec?.approvalRunningNoticeMs,
cleanupMs: agentExec?.cleanupMs ?? globalExec?.cleanupMs,
notifyOnExit: agentExec?.notifyOnExit ?? globalExec?.notifyOnExit,
notifyOnExitEmptySuccess:
agentExec?.notifyOnExitEmptySuccess ?? globalExec?.notifyOnExitEmptySuccess,
applyPatch: agentExec?.applyPatch ?? globalExec?.applyPatch,
};
}
@@ -329,6 +331,8 @@ export function createOpenClawCodingTools(options?: {
approvalRunningNoticeMs:
options?.exec?.approvalRunningNoticeMs ?? execConfig.approvalRunningNoticeMs,
notifyOnExit: options?.exec?.notifyOnExit ?? execConfig.notifyOnExit,
notifyOnExitEmptySuccess:
options?.exec?.notifyOnExitEmptySuccess ?? execConfig.notifyOnExitEmptySuccess,
sandbox: sandbox
? {
containerName: sandbox.containerName,