diff --git a/src/agents/bash-tools.e2e.test.ts b/src/agents/bash-tools.e2e.test.ts index fa2adb4dc80..9a53cf6a5d3 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"]), @@ -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", () => { diff --git a/src/agents/bash-tools.exec-runtime.ts b/src/agents/bash-tools.exec-runtime.ts index 925d350b2c7..04c800d5732 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) { + return; + } const summary = output ? `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel}) :: ${output}` : `Exec ${status} (${session.id.slice(0, 8)}, ${exitLabel})`; diff --git a/src/agents/bash-tools.process.ts b/src/agents/bash-tools.process.ts index 52edd9d450a..ca1ffca7e39 100644 --- a/src/agents/bash-tools.process.ts +++ b/src/agents/bash-tools.process.ts @@ -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, diff --git a/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.e2e.test.ts b/src/agents/pi-embedded-helpers.buildbootstrapcontextfiles.e2e.test.ts index a646098c250..7bf758e7981 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,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...]"); + }); }); diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 74c8b8c625f..8c1598e36af 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -1,6 +1,7 @@ export { buildBootstrapContextFiles, DEFAULT_BOOTSTRAP_MAX_CHARS, + DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS, ensureSessionHeader, resolveBootstrapMaxChars, stripThoughtSignatures, diff --git a/src/agents/pi-embedded-helpers/bootstrap.ts b/src/agents/pi-embedded-helpers/bootstrap.ts index 7c141a09b7f..979e9a3cd18 100644 --- a/src/agents/pi-embedded-helpers/bootstrap.ts +++ b/src/agents/pi-embedded-helpers/bootstrap.ts @@ -82,6 +82,7 @@ export function stripThoughtSignatures( } 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, 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}` : ""})`;