From 0c8fa63b935ddc17c1a60f8bd7698c04e02d5102 Mon Sep 17 00:00:00 2001 From: Jose E Velez Date: Sun, 1 Mar 2026 21:13:24 -0500 Subject: [PATCH] feat: lightweight bootstrap context mode for heartbeat/cron runs (openclaw#26064) thanks @jose-velez Verified: - pnpm build - pnpm check (fails on pre-existing unrelated repo issues in extensions/diffs and src/agents/tools/nodes-tool.test.ts) - pnpm vitest run src/agents/bootstrap-files.test.ts src/infra/heartbeat-runner.model-override.test.ts src/cli/cron-cli.test.ts - pnpm test:macmini (fails on pre-existing extensions/diffs import errors; touched suites pass) Co-authored-by: jose-velez <10926182+jose-velez@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + src/agents/bootstrap-files.test.ts | 29 ++++++++ src/agents/bootstrap-files.ts | 30 +++++++- src/agents/pi-embedded-runner/run/attempt.ts | 2 + src/agents/pi-embedded-runner/run/params.ts | 4 + .../reply/agent-runner-execution.ts | 2 + src/auto-reply/types.ts | 2 + src/cli/cron-cli.test.ts | 74 ++++++++++++++----- src/cli/cron-cli/register.cron-add.ts | 2 + src/cli/cron-cli/register.cron-edit.ts | 9 +++ src/config/types.agent-defaults.ts | 5 ++ src/config/zod-schema.agent-runtime.ts | 1 + src/cron/isolated-agent/run.ts | 2 + src/cron/types.ts | 4 + src/gateway/protocol/schema/cron.ts | 1 + .../heartbeat-runner.model-override.test.ts | 12 +++ src/infra/heartbeat-runner.ts | 11 ++- 17 files changed, 168 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c326ab33e6c..d19abc6835e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,7 @@ Docs: https://docs.openclaw.ai ### Changes - ACP/ACPX streaming: pin ACPX plugin support to `0.1.15`, add configurable ACPX command/version probing, and streamline ACP stream delivery (`final_only` default + reduced tool-event noise) with matching runtime and test updates. (#30036) Thanks @osolmaz. +- Cron/Heartbeat light bootstrap context: add opt-in lightweight bootstrap mode for automation runs (`--light-context` for cron agent turns and `agents.*.heartbeat.lightContext` for heartbeat), keeping only `HEARTBEAT.md` for heartbeat runs and skipping bootstrap-file injection for cron lightweight runs. (#26064) Thanks @jose-velez. - OpenAI/Streaming transport: make `openai` Responses WebSocket-first by default (`transport: "auto"` with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (`store` + `context_management`) on the WS path. - OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (`response.create` with `generate:false`), enable it by default for `openai/*`, and expose `params.openaiWsWarmup` for per-model enable/disable control. - Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (`task_completion`) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured `internalEvents`. diff --git a/src/agents/bootstrap-files.test.ts b/src/agents/bootstrap-files.test.ts index c5b869a72f1..11e3d0dd50b 100644 --- a/src/agents/bootstrap-files.test.ts +++ b/src/agents/bootstrap-files.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { @@ -97,4 +98,32 @@ describe("resolveBootstrapContextForRun", () => { expect(extra?.content).toBe("extra"); }); + + it("uses heartbeat-only bootstrap files in lightweight heartbeat mode", async () => { + const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); + await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), "check inbox", "utf8"); + await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "persona", "utf8"); + + const files = await resolveBootstrapFilesForRun({ + workspaceDir, + contextMode: "lightweight", + runKind: "heartbeat", + }); + + expect(files.length).toBeGreaterThan(0); + expect(files.every((file) => file.name === "HEARTBEAT.md")).toBe(true); + }); + + it("keeps bootstrap context empty in lightweight cron mode", async () => { + const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); + await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), "check inbox", "utf8"); + + const files = await resolveBootstrapFilesForRun({ + workspaceDir, + contextMode: "lightweight", + runKind: "cron", + }); + + expect(files).toEqual([]); + }); }); diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index a6e70a142d3..ae544ebbacb 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -13,6 +13,9 @@ import { type WorkspaceBootstrapFile, } from "./workspace.js"; +export type BootstrapContextMode = "full" | "lightweight"; +export type BootstrapContextRunKind = "default" | "heartbeat" | "cron"; + export function makeBootstrapWarn(params: { sessionLabel: string; warn?: (message: string) => void; @@ -41,6 +44,23 @@ function sanitizeBootstrapFiles( return sanitized; } +function applyContextModeFilter(params: { + files: WorkspaceBootstrapFile[]; + contextMode?: BootstrapContextMode; + runKind?: BootstrapContextRunKind; +}): WorkspaceBootstrapFile[] { + const contextMode = params.contextMode ?? "full"; + const runKind = params.runKind ?? "default"; + if (contextMode !== "lightweight") { + return params.files; + } + if (runKind === "heartbeat") { + return params.files.filter((file) => file.name === "HEARTBEAT.md"); + } + // cron/default lightweight mode keeps bootstrap context empty on purpose. + return []; +} + export async function resolveBootstrapFilesForRun(params: { workspaceDir: string; config?: OpenClawConfig; @@ -48,6 +68,8 @@ export async function resolveBootstrapFilesForRun(params: { sessionId?: string; agentId?: string; warn?: (message: string) => void; + contextMode?: BootstrapContextMode; + runKind?: BootstrapContextRunKind; }): Promise { const sessionKey = params.sessionKey ?? params.sessionId; const rawFiles = params.sessionKey @@ -56,7 +78,11 @@ export async function resolveBootstrapFilesForRun(params: { sessionKey: params.sessionKey, }) : await loadWorkspaceBootstrapFiles(params.workspaceDir); - const bootstrapFiles = filterBootstrapFilesForSession(rawFiles, sessionKey); + const bootstrapFiles = applyContextModeFilter({ + files: filterBootstrapFilesForSession(rawFiles, sessionKey), + contextMode: params.contextMode, + runKind: params.runKind, + }); const updated = await applyBootstrapHookOverrides({ files: bootstrapFiles, @@ -76,6 +102,8 @@ export async function resolveBootstrapContextForRun(params: { sessionId?: string; agentId?: string; warn?: (message: string) => void; + contextMode?: BootstrapContextMode; + runKind?: BootstrapContextRunKind; }): Promise<{ bootstrapFiles: WorkspaceBootstrapFile[]; contextFiles: EmbeddedContextFile[]; diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 29cb6122d8a..5a0aa1d0a72 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -524,6 +524,8 @@ export async function runEmbeddedAttempt( sessionKey: params.sessionKey, sessionId: params.sessionId, warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }), + contextMode: params.bootstrapContextMode, + runKind: params.bootstrapContextRunKind, }); const workspaceNotes = hookAdjustedBootstrapFiles.some( (file) => file.name === DEFAULT_BOOTSTRAP_FILENAME && !file.missing, diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index da0e9eae050..7362f7fcdc3 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -79,6 +79,10 @@ export type RunEmbeddedPiAgentParams = { toolResultFormat?: ToolResultFormat; /** If true, suppress tool error warning payloads for this run (including mutating tools). */ suppressToolErrorWarnings?: boolean; + /** Bootstrap context mode for workspace file injection. */ + bootstrapContextMode?: "full" | "lightweight"; + /** Run kind hint for context mode behavior. */ + bootstrapContextRunKind?: "default" | "heartbeat" | "cron"; execOverrides?: Pick; bashElevated?: ExecElevatedDefaults; timeoutMs: number; diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index a9bd537b527..70d7becf762 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -314,6 +314,8 @@ export async function runAgentTurnWithFallback(params: { return isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain"; })(), suppressToolErrorWarnings: params.opts?.suppressToolErrorWarnings, + bootstrapContextMode: params.opts?.bootstrapContextMode, + bootstrapContextRunKind: params.opts?.isHeartbeat ? "heartbeat" : "default", images: params.opts?.images, abortSignal: params.opts?.abortSignal, blockReplyBreak: params.resolvedBlockStreamingBreak, diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index 69ccead2adc..4692d442ea5 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -40,6 +40,8 @@ export type GetReplyOptions = { suppressTyping?: boolean; /** Resolved heartbeat model override (provider/model string from merged per-agent config). */ heartbeatModelOverride?: string; + /** Controls bootstrap workspace context injection (default: full). */ + bootstrapContextMode?: "full" | "lightweight"; /** If true, suppress tool error warning payloads for this run. */ suppressToolErrorWarnings?: boolean; onPartialReply?: (payload: ReplyPayload) => Promise | void; diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index 1867605d51e..998a6322c8d 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -40,7 +40,13 @@ const { registerCronCli } = await import("./cron-cli.js"); type CronUpdatePatch = { patch?: { schedule?: { kind?: string; expr?: string; tz?: string; staggerMs?: number }; - payload?: { kind?: string; message?: string; model?: string; thinking?: string }; + payload?: { + kind?: string; + message?: string; + model?: string; + thinking?: string; + lightContext?: boolean; + }; delivery?: { mode?: string; channel?: string; @@ -53,7 +59,7 @@ type CronUpdatePatch = { type CronAddParams = { schedule?: { kind?: string; staggerMs?: number }; - payload?: { model?: string; thinking?: string }; + payload?: { model?: string; thinking?: string; lightContext?: boolean }; delivery?: { mode?: string; accountId?: string }; deleteAfterRun?: boolean; agentId?: string; @@ -153,15 +159,17 @@ async function expectCronEditWithScheduleLookupExit( describe("cron cli", () => { it("exits 0 for cron run when job executes successfully", async () => { resetGatewayMock(); - callGatewayFromCli.mockImplementation(async (method: string) => { - if (method === "cron.status") { - return { enabled: true }; - } - if (method === "cron.run") { - return { ok: true, ran: true }; - } - return { ok: true }; - }); + callGatewayFromCli.mockImplementation( + async (method: string, _opts: unknown, params?: unknown) => { + if (method === "cron.status") { + return { enabled: true }; + } + if (method === "cron.run") { + return { ok: true, params, ran: true }; + } + return { ok: true, params }; + }, + ); const runtimeModule = await import("../runtime.js"); const runtime = runtimeModule.defaultRuntime as { exit: (code: number) => void }; @@ -179,15 +187,17 @@ describe("cron cli", () => { it("exits 1 for cron run when job does not execute", async () => { resetGatewayMock(); - callGatewayFromCli.mockImplementation(async (method: string) => { - if (method === "cron.status") { - return { enabled: true }; - } - if (method === "cron.run") { - return { ok: true, ran: false }; - } - return { ok: true }; - }); + callGatewayFromCli.mockImplementation( + async (method: string, _opts: unknown, params?: unknown) => { + if (method === "cron.status") { + return { enabled: true }; + } + if (method === "cron.run") { + return { ok: true, params, ran: false }; + } + return { ok: true, params }; + }, + ); const runtimeModule = await import("../runtime.js"); const runtime = runtimeModule.defaultRuntime as { exit: (code: number) => void }; @@ -367,6 +377,22 @@ describe("cron cli", () => { expect(params?.agentId).toBe("ops"); }); + it("sets lightContext on cron add when --light-context is passed", async () => { + const params = await runCronAddAndGetParams([ + "--name", + "Light context", + "--cron", + "* * * * *", + "--session", + "isolated", + "--message", + "hello", + "--light-context", + ]); + + expect(params?.payload?.lightContext).toBe(true); + }); + it.each([ { label: "omits empty model and thinking", @@ -409,6 +435,14 @@ describe("cron cli", () => { expect(patch?.patch?.payload?.thinking).toBe("low"); }); + it("sets and clears lightContext on cron edit", async () => { + const setPatch = await runCronEditAndGetPatch(["--light-context", "--message", "hello"]); + expect(setPatch?.patch?.payload?.lightContext).toBe(true); + + const clearPatch = await runCronEditAndGetPatch(["--no-light-context", "--message", "hello"]); + expect(clearPatch?.patch?.payload?.lightContext).toBe(false); + }); + it("updates delivery settings without requiring --message", async () => { await runCronCommand([ "cron", diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index 55c0d57cd78..59d1649af02 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -84,6 +84,7 @@ export function registerCronAddCommand(cron: Command) { .option("--thinking ", "Thinking level for agent jobs (off|minimal|low|medium|high)") .option("--model ", "Model override for agent jobs (provider/model or alias)") .option("--timeout-seconds ", "Timeout seconds for agent jobs") + .option("--light-context", "Use lightweight bootstrap context for agent jobs", false) .option("--announce", "Announce summary to a chat (subagent-style)", false) .option("--deliver", "Deprecated (use --announce). Announces a summary to a chat.") .option("--no-deliver", "Disable announce delivery and skip main-session summary") @@ -189,6 +190,7 @@ export function registerCronAddCommand(cron: Command) { : undefined, timeoutSeconds: timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined, + lightContext: opts.lightContext === true ? true : undefined, }; })(); diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts index ac473e53bf9..a7c21f8750b 100644 --- a/src/cli/cron-cli/register.cron-edit.ts +++ b/src/cli/cron-cli/register.cron-edit.ts @@ -51,6 +51,8 @@ export function registerCronEditCommand(cron: Command) { .option("--thinking ", "Thinking level for agent jobs") .option("--model ", "Model override for agent jobs") .option("--timeout-seconds ", "Timeout seconds for agent jobs") + .option("--light-context", "Enable lightweight bootstrap context for agent jobs") + .option("--no-light-context", "Disable lightweight bootstrap context for agent jobs") .option("--announce", "Announce summary to a chat (subagent-style)") .option("--deliver", "Deprecated (use --announce). Announces a summary to a chat.") .option("--no-deliver", "Disable announce delivery") @@ -226,6 +228,7 @@ export function registerCronEditCommand(cron: Command) { Boolean(model) || Boolean(thinking) || hasTimeoutSeconds || + typeof opts.lightContext === "boolean" || hasDeliveryModeFlag || hasDeliveryTarget || hasDeliveryAccount || @@ -244,6 +247,12 @@ export function registerCronEditCommand(cron: Command) { assignIf(payload, "model", model, Boolean(model)); assignIf(payload, "thinking", thinking, Boolean(thinking)); assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds); + assignIf( + payload, + "lightContext", + opts.lightContext, + typeof opts.lightContext === "boolean", + ); patch.payload = payload; } diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 00e51477634..5444f9e9950 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -235,6 +235,11 @@ export type AgentDefaultsConfig = { ackMaxChars?: number; /** Suppress tool error warning payloads during heartbeat runs. */ suppressToolErrorWarnings?: boolean; + /** + * If true, run heartbeat turns with lightweight bootstrap context. + * Lightweight mode keeps only HEARTBEAT.md from workspace bootstrap files. + */ + lightContext?: boolean; /** * When enabled, deliver the model's reasoning payload for heartbeat runs (when available) * as a separate message prefixed with `Reasoning:` (same as `/reasoning on`). diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 9df0776b956..497ab797471 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -32,6 +32,7 @@ export const HeartbeatSchema = z prompt: z.string().optional(), ackMaxChars: z.number().int().nonnegative().optional(), suppressToolErrorWarnings: z.boolean().optional(), + lightContext: z.boolean().optional(), }) .strict() .superRefine((val, ctx) => { diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index cf9cfcce716..adde8db59c2 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -507,6 +507,8 @@ export async function runCronIsolatedAgentTurn(params: { thinkLevel, verboseLevel: resolvedVerboseLevel, timeoutMs, + bootstrapContextMode: agentPayload?.lightContext ? "lightweight" : undefined, + bootstrapContextRunKind: "cron", runId: cronSession.sessionEntry.sessionId, // Only enforce an explicit message target when the cron delivery target // was successfully resolved. When resolution fails the agent should not diff --git a/src/cron/types.ts b/src/cron/types.ts index 401e07e6f5b..3d089b40f98 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -75,6 +75,8 @@ export type CronPayload = thinking?: string; timeoutSeconds?: number; allowUnsafeExternalContent?: boolean; + /** If true, run with lightweight bootstrap context. */ + lightContext?: boolean; deliver?: boolean; channel?: CronMessageChannel; to?: string; @@ -91,6 +93,8 @@ export type CronPayloadPatch = thinking?: string; timeoutSeconds?: number; allowUnsafeExternalContent?: boolean; + /** If true, run with lightweight bootstrap context. */ + lightContext?: boolean; deliver?: boolean; channel?: CronMessageChannel; to?: string; diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index 8a47e1ff36d..b4ca4fee17e 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -11,6 +11,7 @@ function cronAgentTurnPayloadSchema(params: { message: TSchema }) { thinking: Type.Optional(Type.String()), timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })), allowUnsafeExternalContent: Type.Optional(Type.Boolean()), + lightContext: Type.Optional(Type.Boolean()), deliver: Type.Optional(Type.Boolean()), channel: Type.Optional(Type.String()), to: Type.Optional(Type.String()), diff --git a/src/infra/heartbeat-runner.model-override.test.ts b/src/infra/heartbeat-runner.model-override.test.ts index 3897a24731c..6c7862fb84c 100644 --- a/src/infra/heartbeat-runner.model-override.test.ts +++ b/src/infra/heartbeat-runner.model-override.test.ts @@ -64,6 +64,7 @@ describe("runHeartbeatOnce – heartbeat model override", () => { async function runDefaultsHeartbeat(params: { model?: string; suppressToolErrorWarnings?: boolean; + lightContext?: boolean; }) { return withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => { const cfg: OpenClawConfig = { @@ -75,6 +76,7 @@ describe("runHeartbeatOnce – heartbeat model override", () => { target: "whatsapp", model: params.model, suppressToolErrorWarnings: params.suppressToolErrorWarnings, + lightContext: params.lightContext, }, }, }, @@ -121,6 +123,16 @@ describe("runHeartbeatOnce – heartbeat model override", () => { ); }); + it("passes bootstrapContextMode when heartbeat lightContext is enabled", async () => { + const replyOpts = await runDefaultsHeartbeat({ lightContext: true }); + expect(replyOpts).toEqual( + expect.objectContaining({ + isHeartbeat: true, + bootstrapContextMode: "lightweight", + }), + ); + }); + it("passes per-agent heartbeat model override (merged with defaults)", async () => { await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => { const cfg: OpenClawConfig = { diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 056142c4056..2d0bee48f0c 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -743,9 +743,16 @@ export async function runHeartbeatOnce(opts: { const heartbeatModelOverride = heartbeat?.model?.trim() || undefined; const suppressToolErrorWarnings = heartbeat?.suppressToolErrorWarnings === true; + const bootstrapContextMode: "lightweight" | undefined = + heartbeat?.lightContext === true ? "lightweight" : undefined; const replyOpts = heartbeatModelOverride - ? { isHeartbeat: true, heartbeatModelOverride, suppressToolErrorWarnings } - : { isHeartbeat: true, suppressToolErrorWarnings }; + ? { + isHeartbeat: true, + heartbeatModelOverride, + suppressToolErrorWarnings, + bootstrapContextMode, + } + : { isHeartbeat: true, suppressToolErrorWarnings, bootstrapContextMode }; const replyResult = await getReplyFromConfig(ctx, replyOpts, cfg); const replyPayload = resolveHeartbeatReplyPayload(replyResult); const includeReasoning = heartbeat?.includeReasoning === true;