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 9a53cf6a5d3..067ca8067ff 100644 --- a/src/agents/bash-tools.e2e.test.ts +++ b/src/agents/bash-tools.e2e.test.ts @@ -342,6 +342,29 @@ describe("exec notifyOnExit", () => { 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 04c800d5732..770960dc436 100644 --- a/src/agents/bash-tools.exec-runtime.ts +++ b/src/agents/bash-tools.exec-runtime.ts @@ -316,7 +316,7 @@ function maybeNotifyOnExit(session: ProcessSession, status: "completed" | "faile const output = compactNotifyOutput( tail(session.tail || session.aggregated || "", DEFAULT_NOTIFY_TAIL_CHARS), ); - if (status === "completed" && !output) { + if (status === "completed" && !output && session.notifyOnExitEmptySuccess !== true) { return; } const summary = output @@ -366,6 +366,7 @@ export async function runExecProcess(opts: { maxOutput: number; pendingMaxOutput: number; notifyOnExit: boolean; + notifyOnExitEmptySuccess?: boolean; scopeKey?: string; sessionKey?: string; timeoutSec: number; @@ -531,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/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/config/schema.help.ts b/src/config/schema.help.ts index aef5edf83bf..9c07993a00c 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.", diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 7afdf6c4eeb..2c5e090642d 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", 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-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(),