agents: reduce prompt token bloat from exec and context (#16539)
Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 8e1635fa3fdfb199a58bd53e816abc41cd400d44 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:
@@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Models/CLI: guard `models status` string trimming paths to prevent crashes from malformed non-string config values. (#16395) Thanks @BinHPdev.
|
||||
- Agents: add a safety timeout around embedded `session.compact()` to ensure stalled compaction runs settle and release blocked session lanes. (#16331) Thanks @BinHPdev.
|
||||
- Gateway/Sessions: abort active embedded runs and clear queued session work before `sessions.reset`, returning unavailable if the run does not stop in time. (#16576) Thanks @Grynn.
|
||||
- Agents/Process/Bootstrap: preserve unbounded `process log` offset-only pagination (default tail applies only when both `offset` and `limit` are omitted) and enforce strict `bootstrapTotalMaxChars` budgeting across injected bootstrap content (including markers), skipping additional injection when remaining budget is too small. (#16539) Thanks @CharlieGreenman.
|
||||
- Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla.
|
||||
- Agents: keep unresolved mutating tool failures visible until the same action retry succeeds, scope mutation-error surfacing to mutating calls (including `session_status` model changes), and dedupe duplicate failure warnings in outbound replies. (#16131) Thanks @Swader.
|
||||
- Agents: classify external timeout aborts during compaction the same as internal timeouts, preventing unnecessary auth-profile rotation and preserving compaction-timeout snapshot fallback behavior. (#9855) Thanks @mverrilli.
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface ProcessSession {
|
||||
scopeKey?: string;
|
||||
sessionKey?: string;
|
||||
notifyOnExit?: boolean;
|
||||
notifyOnExitEmptySuccess?: boolean;
|
||||
exitNotified?: boolean;
|
||||
child?: ChildProcessWithoutNullStreams;
|
||||
stdin?: SessionStdin;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -331,12 +331,14 @@ export async function handleBashChatCommand(params: {
|
||||
const shouldBackgroundImmediately = foregroundMs <= 0;
|
||||
const timeoutSec = params.cfg.tools?.exec?.timeoutSec;
|
||||
const notifyOnExit = params.cfg.tools?.exec?.notifyOnExit;
|
||||
const notifyOnExitEmptySuccess = params.cfg.tools?.exec?.notifyOnExitEmptySuccess;
|
||||
const execTool = createExecTool({
|
||||
scopeKey: CHAT_BASH_SCOPE_KEY,
|
||||
allowBackground: true,
|
||||
timeoutSec,
|
||||
sessionKey: params.sessionKey,
|
||||
notifyOnExit,
|
||||
notifyOnExitEmptySuccess,
|
||||
elevated: {
|
||||
enabled: params.elevated.enabled,
|
||||
allowed: params.elevated.allowed,
|
||||
|
||||
@@ -5,6 +5,23 @@ installBaseProgramMocks();
|
||||
|
||||
const { buildProgram } = await import("./program.js");
|
||||
|
||||
function formatRuntimeLogCallArg(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
||||
return String(value);
|
||||
}
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return "[unserializable]";
|
||||
}
|
||||
}
|
||||
|
||||
describe("cli program (nodes basics)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -57,7 +74,7 @@ describe("cli program (nodes basics)", () => {
|
||||
await program.parseAsync(["nodes", "list", "--connected"], { from: "user" });
|
||||
|
||||
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.list" }));
|
||||
const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n");
|
||||
const output = runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n");
|
||||
expect(output).toContain("One");
|
||||
expect(output).not.toContain("Two");
|
||||
});
|
||||
@@ -92,7 +109,7 @@ describe("cli program (nodes basics)", () => {
|
||||
});
|
||||
|
||||
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.pair.list" }));
|
||||
const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n");
|
||||
const output = runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n");
|
||||
expect(output).toContain("One");
|
||||
expect(output).not.toContain("Two");
|
||||
});
|
||||
@@ -121,7 +138,7 @@ describe("cli program (nodes basics)", () => {
|
||||
expect.objectContaining({ method: "node.list", params: {} }),
|
||||
);
|
||||
|
||||
const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n");
|
||||
const output = runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n");
|
||||
expect(output).toContain("Known: 1 · Paired: 1 · Connected: 1");
|
||||
expect(output).toContain("iOS Node");
|
||||
expect(output).toContain("Detail");
|
||||
@@ -154,7 +171,7 @@ describe("cli program (nodes basics)", () => {
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(["nodes", "status"], { from: "user" });
|
||||
|
||||
const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n");
|
||||
const output = runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n");
|
||||
expect(output).toContain("Known: 1 · Paired: 0 · Connected: 1");
|
||||
expect(output).toContain("Peter's Tab");
|
||||
expect(output).toContain("S10 Ultra");
|
||||
@@ -214,7 +231,7 @@ describe("cli program (nodes basics)", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const out = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n");
|
||||
const out = runtime.log.mock.calls.map((c) => formatRuntimeLogCallArg(c[0])).join("\n");
|
||||
expect(out).toContain("Commands");
|
||||
expect(out).toContain("canvas.eval");
|
||||
});
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { vi, type Mock } from "vitest";
|
||||
import { Mock, vi } from "vitest";
|
||||
|
||||
export const messageCommand: Mock = vi.fn();
|
||||
export const statusCommand: Mock = vi.fn();
|
||||
export const configureCommand: Mock = vi.fn();
|
||||
export const configureCommandWithSections: Mock = vi.fn();
|
||||
export const setupCommand: Mock = vi.fn();
|
||||
export const onboardCommand: Mock = vi.fn();
|
||||
export const callGateway: Mock = vi.fn();
|
||||
export const runChannelLogin: Mock = vi.fn();
|
||||
export const runChannelLogout: Mock = vi.fn();
|
||||
export const runTui: Mock = vi.fn();
|
||||
export const loadAndMaybeMigrateDoctorConfig: Mock = vi.fn();
|
||||
export const ensureConfigReady: Mock = vi.fn();
|
||||
export const ensurePluginRegistryLoaded: Mock = vi.fn();
|
||||
export const runtime: { log: Mock; error: Mock; exit: Mock<() => never> } = {
|
||||
export const messageCommand: Mock<(...args: unknown[]) => unknown> = vi.fn();
|
||||
export const statusCommand: Mock<(...args: unknown[]) => unknown> = vi.fn();
|
||||
export const configureCommand: Mock<(...args: unknown[]) => unknown> = vi.fn();
|
||||
export const configureCommandWithSections: Mock<(...args: unknown[]) => unknown> = vi.fn();
|
||||
export const setupCommand: Mock<(...args: unknown[]) => unknown> = vi.fn();
|
||||
export const onboardCommand: Mock<(...args: unknown[]) => unknown> = vi.fn();
|
||||
export const callGateway: Mock<(...args: unknown[]) => unknown> = vi.fn();
|
||||
export const runChannelLogin: Mock<(...args: unknown[]) => unknown> = vi.fn();
|
||||
export const runChannelLogout: Mock<(...args: unknown[]) => unknown> = vi.fn();
|
||||
export const runTui: Mock<(...args: unknown[]) => unknown> = vi.fn();
|
||||
|
||||
export const loadAndMaybeMigrateDoctorConfig: Mock<(...args: unknown[]) => unknown> = vi.fn();
|
||||
export const ensureConfigReady: Mock<(...args: unknown[]) => unknown> = vi.fn();
|
||||
export const ensurePluginRegistryLoaded: Mock<(...args: unknown[]) => unknown> = vi.fn();
|
||||
|
||||
export const runtime: {
|
||||
log: Mock<(...args: unknown[]) => void>;
|
||||
error: Mock<(...args: unknown[]) => void>;
|
||||
exit: Mock<(...args: unknown[]) => never>;
|
||||
} = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(() => {
|
||||
|
||||
@@ -64,6 +64,8 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
'Optional allowlist of model ids (e.g. "gpt-5.2" or "openai/gpt-5.2").',
|
||||
"tools.exec.notifyOnExit":
|
||||
"When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.",
|
||||
"tools.exec.notifyOnExitEmptySuccess":
|
||||
"When true, successful backgrounded exec exits with empty output still enqueue a completion system event (default: false).",
|
||||
"tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).",
|
||||
"tools.exec.safeBins":
|
||||
"Allow stdin-only safe binaries to run without explicit allowlist entries.",
|
||||
@@ -140,6 +142,8 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).",
|
||||
"agents.defaults.bootstrapMaxChars":
|
||||
"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: 24000).",
|
||||
"agents.defaults.repoRoot":
|
||||
"Optional repository root shown in the system prompt runtime line (overrides auto-detect).",
|
||||
"agents.defaults.envelopeTimezone":
|
||||
|
||||
@@ -75,6 +75,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist",
|
||||
"tools.fs.workspaceOnly": "Workspace-only FS tools",
|
||||
"tools.exec.notifyOnExit": "Exec Notify On Exit",
|
||||
"tools.exec.notifyOnExitEmptySuccess": "Exec Notify On Empty Success",
|
||||
"tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)",
|
||||
"tools.exec.host": "Exec Host",
|
||||
"tools.exec.security": "Exec Security",
|
||||
@@ -120,6 +121,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"agents.defaults.workspace": "Workspace",
|
||||
"agents.defaults.repoRoot": "Repo Root",
|
||||
"agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars",
|
||||
"agents.defaults.bootstrapTotalMaxChars": "Bootstrap Total Max Chars",
|
||||
"agents.defaults.envelopeTimezone": "Envelope Timezone",
|
||||
"agents.defaults.envelopeTimestamp": "Envelope Timestamp",
|
||||
"agents.defaults.envelopeElapsed": "Envelope Elapsed",
|
||||
|
||||
@@ -108,6 +108,8 @@ export type AgentDefaultsConfig = {
|
||||
skipBootstrap?: boolean;
|
||||
/** Max chars for injected bootstrap files before truncation (default: 20000). */
|
||||
bootstrapMaxChars?: number;
|
||||
/** Max total chars across all injected bootstrap files (default: 24000). */
|
||||
bootstrapTotalMaxChars?: number;
|
||||
/** 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. */
|
||||
|
||||
@@ -183,6 +183,11 @@ export type ExecToolConfig = {
|
||||
cleanupMs?: number;
|
||||
/** Emit a system event and heartbeat when a backgrounded exec exits. */
|
||||
notifyOnExit?: boolean;
|
||||
/**
|
||||
* Also emit success exit notifications when a backgrounded exec has no output.
|
||||
* Default false to reduce context noise.
|
||||
*/
|
||||
notifyOnExitEmptySuccess?: boolean;
|
||||
/** apply_patch subtool configuration (experimental). */
|
||||
applyPatch?: {
|
||||
/** Enable apply_patch for OpenAI models (default: false). */
|
||||
|
||||
@@ -47,6 +47,7 @@ export const AgentDefaultsSchema = z
|
||||
repoRoot: z.string().optional(),
|
||||
skipBootstrap: z.boolean().optional(),
|
||||
bootstrapMaxChars: z.number().int().positive().optional(),
|
||||
bootstrapTotalMaxChars: z.number().int().positive().optional(),
|
||||
userTimezone: z.string().optional(),
|
||||
timeFormat: z.union([z.literal("auto"), z.literal("12"), z.literal("24")]).optional(),
|
||||
envelopeTimezone: z.string().optional(),
|
||||
|
||||
@@ -288,6 +288,7 @@ export const AgentToolsSchema = z
|
||||
approvalRunningNoticeMs: z.number().int().nonnegative().optional(),
|
||||
cleanupMs: z.number().int().positive().optional(),
|
||||
notifyOnExit: z.boolean().optional(),
|
||||
notifyOnExitEmptySuccess: z.boolean().optional(),
|
||||
applyPatch: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
@@ -546,6 +547,7 @@ export const ToolsSchema = z
|
||||
timeoutSec: z.number().int().positive().optional(),
|
||||
cleanupMs: z.number().int().positive().optional(),
|
||||
notifyOnExit: z.boolean().optional(),
|
||||
notifyOnExitEmptySuccess: z.boolean().optional(),
|
||||
applyPatch: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
|
||||
@@ -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