diff --git a/CHANGELOG.md b/CHANGELOG.md index 02166cfa4b7..bb3cc561285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/bash-process-registry.ts b/src/agents/bash-process-registry.ts index 171b5f4527f..0e84065c7f2 100644 --- a/src/agents/bash-process-registry.ts +++ b/src/agents/bash-process-registry.ts @@ -31,6 +31,7 @@ export interface ProcessSession { scopeKey?: string; sessionKey?: string; notifyOnExit?: boolean; + notifyOnExitEmptySuccess?: boolean; exitNotified?: boolean; child?: ChildProcessWithoutNullStreams; stdin?: SessionStdin; diff --git a/src/agents/bash-tools.e2e.test.ts b/src/agents/bash-tools.e2e.test.ts index fa2adb4dc80..99f31b89b39 100644 --- a/src/agents/bash-tools.e2e.test.ts +++ b/src/agents/bash-tools.e2e.test.ts @@ -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", () => { diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 925d350b2c7..770960dc436 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -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, diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 9b17e9bfdfe..b9a7e83b28a 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -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, diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index 52edd9d450a..3fa32438f57 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -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, diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index 0954cd40e15..50df5dfdd94 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -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 }; diff --git a/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.e2e.test.ts b/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.e2e.test.ts index a646098c250..9cd60cb59e0 100644 --- a/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.e2e.test.ts +++ b/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.e2e.test.ts @@ -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 => ({ @@ -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); + }); }); diff --git a/src/agents/pi-embedded-helpers.resolvebootstrapmaxchars.e2e.test.ts b/src/agents/pi-embedded-helpers.resolvebootstrapmaxchars.e2e.test.ts index 021da973420..c4a0e7471c2 100644 --- a/src/agents/pi-embedded-helpers.resolvebootstrapmaxchars.e2e.test.ts +++ b/src/agents/pi-embedded-helpers.resolvebootstrapmaxchars.e2e.test.ts @@ -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 => ({ @@ -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); + }); +}); diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 74c8b8c625f..5c45fb05093 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -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 { diff --git a/src/agents/pi-embedded-helpers/bootstrap.ts b/src/agents/pi-embedded-helpers/bootstrap.ts index 7c141a09b7f..8a86206ce6c 100644 --- a/src/agents/pi-embedded-helpers/bootstrap.ts +++ b/src/agents/pi-embedded-helpers/bootstrap.ts @@ -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( } 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; diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index bab76895740..ae162c85ba4 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -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, diff --git a/src/auto-reply/reply/bash-command.ts b/src/auto-reply/reply/bash-command.ts index b52f84ccc11..7912bc02ff0 100644 --- a/src/auto-reply/reply/bash-command.ts +++ b/src/auto-reply/reply/bash-command.ts @@ -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, diff --git a/src/cli/program.nodes-basic.e2e.test.ts b/src/cli/program.nodes-basic.e2e.test.ts index 7e778132c28..ae9c2439ce9 100644 --- a/src/cli/program.nodes-basic.e2e.test.ts +++ b/src/cli/program.nodes-basic.e2e.test.ts @@ -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"); }); diff --git a/src/cli/program.test-mocks.ts b/src/cli/program.test-mocks.ts index efc4d3f46f8..524c6b3a88e 100644 --- a/src/cli/program.test-mocks.ts +++ b/src/cli/program.test-mocks.ts @@ -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(() => { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index aef5edf83bf..97e43e5b187 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -64,6 +64,8 @@ export const FIELD_HELP: Record = { '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 = { "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": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 7afdf6c4eeb..60a467fd02c 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -75,6 +75,7 @@ export const FIELD_LABELS: Record = { "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 = { "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", diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 217e8f12559..72d49e1b285 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -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. */ diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index fe9feba2615..e6fa1eec10b 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -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). */ diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 8aa43933c55..31dd1f672cd 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -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(), diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 04ac8f20da8..b806825c6c7 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -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(), diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index f21bb2fe2f5..5341d5b0947 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -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", { diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index b841b58671f..9c5008852b3 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -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}` : ""})`;