mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-20 13:41:30 +00:00
agents: reduce prompt token bloat from exec and context
This commit is contained in:
committed by
Gustavo Madeira Santana
parent
2547514b47
commit
bf054d0597
@@ -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"]),
|
||||
@@ -300,6 +322,26 @@ 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([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec PATH handling", () => {
|
||||
|
||||
@@ -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) {
|
||||
return;
|
||||
}
|
||||
const summary = output
|
||||
? `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel}) :: ${output}`
|
||||
: `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel})`;
|
||||
|
||||
@@ -30,6 +30,7 @@ type WritableStdin = {
|
||||
end: () => void;
|
||||
destroyed?: boolean;
|
||||
};
|
||||
const DEFAULT_LOG_TAIL_LINES = 200;
|
||||
|
||||
const processSchema = Type.Object({
|
||||
action: Type.String({ description: "Process action" }),
|
||||
@@ -294,13 +295,23 @@ export function createProcessTool(
|
||||
details: { status: "failed" },
|
||||
};
|
||||
}
|
||||
const effectiveOffset = params.offset;
|
||||
const effectiveLimit =
|
||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||
? params.limit
|
||||
: DEFAULT_LOG_TAIL_LINES;
|
||||
const { slice, totalLines, totalChars } = sliceLogLines(
|
||||
scopedSession.aggregated,
|
||||
params.offset,
|
||||
params.limit,
|
||||
effectiveOffset,
|
||||
effectiveLimit,
|
||||
);
|
||||
const usingDefaultTail = params.offset === undefined && params.limit === undefined;
|
||||
const defaultTailNote =
|
||||
usingDefaultTail && totalLines > DEFAULT_LOG_TAIL_LINES
|
||||
? `\n\n[showing last ${DEFAULT_LOG_TAIL_LINES} of ${totalLines} lines; pass offset/limit to page]`
|
||||
: "";
|
||||
return {
|
||||
content: [{ type: "text", text: slice || "(no output yet)" }],
|
||||
content: [{ type: "text", text: (slice || "(no output yet)") + defaultTailNote }],
|
||||
details: {
|
||||
status: scopedSession.exited ? "completed" : "running",
|
||||
sessionId: params.sessionId,
|
||||
@@ -313,14 +324,26 @@ export function createProcessTool(
|
||||
};
|
||||
}
|
||||
if (scopedFinished) {
|
||||
const effectiveOffset = params.offset;
|
||||
const effectiveLimit =
|
||||
typeof params.limit === "number" && Number.isFinite(params.limit)
|
||||
? params.limit
|
||||
: DEFAULT_LOG_TAIL_LINES;
|
||||
const { slice, totalLines, totalChars } = sliceLogLines(
|
||||
scopedFinished.aggregated,
|
||||
params.offset,
|
||||
params.limit,
|
||||
effectiveOffset,
|
||||
effectiveLimit,
|
||||
);
|
||||
const status = scopedFinished.status === "completed" ? "completed" : "failed";
|
||||
const usingDefaultTail = params.offset === undefined && params.limit === undefined;
|
||||
const defaultTailNote =
|
||||
usingDefaultTail && totalLines > DEFAULT_LOG_TAIL_LINES
|
||||
? `\n\n[showing last ${DEFAULT_LOG_TAIL_LINES} of ${totalLines} lines; pass offset/limit to page]`
|
||||
: "";
|
||||
return {
|
||||
content: [{ type: "text", text: slice || "(no output recorded)" }],
|
||||
content: [
|
||||
{ type: "text", text: (slice || "(no output recorded)") + defaultTailNote },
|
||||
],
|
||||
details: {
|
||||
status,
|
||||
sessionId: params.sessionId,
|
||||
|
||||
@@ -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,17 @@ 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...]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export {
|
||||
buildBootstrapContextFiles,
|
||||
DEFAULT_BOOTSTRAP_MAX_CHARS,
|
||||
DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS,
|
||||
ensureSessionHeader,
|
||||
resolveBootstrapMaxChars,
|
||||
stripThoughtSignatures,
|
||||
|
||||
@@ -82,6 +82,7 @@ export function stripThoughtSignatures<T>(
|
||||
}
|
||||
|
||||
export const DEFAULT_BOOTSTRAP_MAX_CHARS = 20_000;
|
||||
export const DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS = 24_000;
|
||||
const BOOTSTRAP_HEAD_RATIO = 0.7;
|
||||
const BOOTSTRAP_TAIL_RATIO = 0.2;
|
||||
|
||||
@@ -161,11 +162,19 @@ 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) {
|
||||
result.push({
|
||||
path: file.path,
|
||||
@@ -173,7 +182,8 @@ export function buildBootstrapContextFiles(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const trimmed = trimBootstrapContent(file.content ?? "", file.name, maxChars);
|
||||
const fileMaxChars = Math.max(1, Math.min(maxChars, remainingTotalChars));
|
||||
const trimmed = trimBootstrapContent(file.content ?? "", file.name, fileMaxChars);
|
||||
if (!trimmed.content) {
|
||||
continue;
|
||||
}
|
||||
@@ -182,6 +192,7 @@ export function buildBootstrapContextFiles(
|
||||
`workspace bootstrap file ${file.name} is ${trimmed.originalLength} chars (limit ${trimmed.maxChars}); truncating in injected context`,
|
||||
);
|
||||
}
|
||||
remainingTotalChars = Math.max(0, remainingTotalChars - trimmed.content.length);
|
||||
result.push({
|
||||
path: file.path,
|
||||
content: trimmed.content,
|
||||
|
||||
@@ -83,6 +83,42 @@ describe("node exec events", () => {
|
||||
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({ reason: "exec-event" });
|
||||
});
|
||||
|
||||
it("suppresses noisy exec.finished success events with empty output", async () => {
|
||||
const ctx = buildCtx();
|
||||
await handleNodeEvent(ctx, "node-2", {
|
||||
event: "exec.finished",
|
||||
payloadJSON: JSON.stringify({
|
||||
runId: "run-quiet",
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
output: " ",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
expect(requestHeartbeatNowMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("truncates long exec.finished output in system events", async () => {
|
||||
const ctx = buildCtx();
|
||||
await handleNodeEvent(ctx, "node-2", {
|
||||
event: "exec.finished",
|
||||
payloadJSON: JSON.stringify({
|
||||
runId: "run-long",
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
output: "x".repeat(600),
|
||||
}),
|
||||
});
|
||||
|
||||
const [[text]] = enqueueSystemEventMock.mock.calls;
|
||||
expect(typeof text).toBe("string");
|
||||
expect(text.startsWith("Exec finished (node=node-2 id=run-long, code 0)\n")).toBe(true);
|
||||
expect(text.endsWith("…")).toBe(true);
|
||||
expect(text.length).toBeLessThan(280);
|
||||
expect(requestHeartbeatNowMock).toHaveBeenCalledWith({ reason: "exec-event" });
|
||||
});
|
||||
|
||||
it("enqueues exec.denied events with reason", async () => {
|
||||
const ctx = buildCtx();
|
||||
await handleNodeEvent(ctx, "node-3", {
|
||||
|
||||
@@ -15,6 +15,20 @@ import {
|
||||
} from "./session-utils.js";
|
||||
import { formatForLog } from "./ws-log.js";
|
||||
|
||||
const MAX_EXEC_EVENT_OUTPUT_CHARS = 180;
|
||||
|
||||
function compactExecEventOutput(raw: string) {
|
||||
const normalized = raw.replace(/\s+/g, " ").trim();
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
if (normalized.length <= MAX_EXEC_EVENT_OUTPUT_CHARS) {
|
||||
return normalized;
|
||||
}
|
||||
const safe = Math.max(1, MAX_EXEC_EVENT_OUTPUT_CHARS - 1);
|
||||
return `${normalized.slice(0, safe)}…`;
|
||||
}
|
||||
|
||||
export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt: NodeEvent) => {
|
||||
switch (evt.event) {
|
||||
case "voice.transcript": {
|
||||
@@ -244,9 +258,14 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
||||
}
|
||||
} else if (evt.event === "exec.finished") {
|
||||
const exitLabel = timedOut ? "timeout" : `code ${exitCode ?? "?"}`;
|
||||
const compactOutput = compactExecEventOutput(output);
|
||||
const shouldNotify = timedOut || exitCode !== 0 || compactOutput.length > 0;
|
||||
if (!shouldNotify) {
|
||||
return;
|
||||
}
|
||||
text = `Exec finished (node=${nodeId}${runId ? ` id=${runId}` : ""}, ${exitLabel})`;
|
||||
if (output) {
|
||||
text += `\n${output}`;
|
||||
if (compactOutput) {
|
||||
text += `\n${compactOutput}`;
|
||||
}
|
||||
} else {
|
||||
text = `Exec denied (node=${nodeId}${runId ? ` id=${runId}` : ""}${reason ? `, ${reason}` : ""})`;
|
||||
|
||||
Reference in New Issue
Block a user