mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 18:51:37 +00:00
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>
This commit is contained in:
@@ -91,6 +91,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
### Changes
|
### 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.
|
- 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/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.
|
- 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`.
|
- 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`.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
@@ -97,4 +98,32 @@ describe("resolveBootstrapContextForRun", () => {
|
|||||||
|
|
||||||
expect(extra?.content).toBe("extra");
|
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([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import {
|
|||||||
type WorkspaceBootstrapFile,
|
type WorkspaceBootstrapFile,
|
||||||
} from "./workspace.js";
|
} from "./workspace.js";
|
||||||
|
|
||||||
|
export type BootstrapContextMode = "full" | "lightweight";
|
||||||
|
export type BootstrapContextRunKind = "default" | "heartbeat" | "cron";
|
||||||
|
|
||||||
export function makeBootstrapWarn(params: {
|
export function makeBootstrapWarn(params: {
|
||||||
sessionLabel: string;
|
sessionLabel: string;
|
||||||
warn?: (message: string) => void;
|
warn?: (message: string) => void;
|
||||||
@@ -41,6 +44,23 @@ function sanitizeBootstrapFiles(
|
|||||||
return sanitized;
|
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: {
|
export async function resolveBootstrapFilesForRun(params: {
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
config?: OpenClawConfig;
|
config?: OpenClawConfig;
|
||||||
@@ -48,6 +68,8 @@ export async function resolveBootstrapFilesForRun(params: {
|
|||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
warn?: (message: string) => void;
|
warn?: (message: string) => void;
|
||||||
|
contextMode?: BootstrapContextMode;
|
||||||
|
runKind?: BootstrapContextRunKind;
|
||||||
}): Promise<WorkspaceBootstrapFile[]> {
|
}): Promise<WorkspaceBootstrapFile[]> {
|
||||||
const sessionKey = params.sessionKey ?? params.sessionId;
|
const sessionKey = params.sessionKey ?? params.sessionId;
|
||||||
const rawFiles = params.sessionKey
|
const rawFiles = params.sessionKey
|
||||||
@@ -56,7 +78,11 @@ export async function resolveBootstrapFilesForRun(params: {
|
|||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
})
|
})
|
||||||
: await loadWorkspaceBootstrapFiles(params.workspaceDir);
|
: 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({
|
const updated = await applyBootstrapHookOverrides({
|
||||||
files: bootstrapFiles,
|
files: bootstrapFiles,
|
||||||
@@ -76,6 +102,8 @@ export async function resolveBootstrapContextForRun(params: {
|
|||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
warn?: (message: string) => void;
|
warn?: (message: string) => void;
|
||||||
|
contextMode?: BootstrapContextMode;
|
||||||
|
runKind?: BootstrapContextRunKind;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
bootstrapFiles: WorkspaceBootstrapFile[];
|
bootstrapFiles: WorkspaceBootstrapFile[];
|
||||||
contextFiles: EmbeddedContextFile[];
|
contextFiles: EmbeddedContextFile[];
|
||||||
|
|||||||
@@ -524,6 +524,8 @@ export async function runEmbeddedAttempt(
|
|||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
|
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
|
||||||
|
contextMode: params.bootstrapContextMode,
|
||||||
|
runKind: params.bootstrapContextRunKind,
|
||||||
});
|
});
|
||||||
const workspaceNotes = hookAdjustedBootstrapFiles.some(
|
const workspaceNotes = hookAdjustedBootstrapFiles.some(
|
||||||
(file) => file.name === DEFAULT_BOOTSTRAP_FILENAME && !file.missing,
|
(file) => file.name === DEFAULT_BOOTSTRAP_FILENAME && !file.missing,
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ export type RunEmbeddedPiAgentParams = {
|
|||||||
toolResultFormat?: ToolResultFormat;
|
toolResultFormat?: ToolResultFormat;
|
||||||
/** If true, suppress tool error warning payloads for this run (including mutating tools). */
|
/** If true, suppress tool error warning payloads for this run (including mutating tools). */
|
||||||
suppressToolErrorWarnings?: boolean;
|
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<ExecToolDefaults, "host" | "security" | "ask" | "node">;
|
execOverrides?: Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
|
||||||
bashElevated?: ExecElevatedDefaults;
|
bashElevated?: ExecElevatedDefaults;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
|
|||||||
@@ -314,6 +314,8 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
return isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain";
|
return isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain";
|
||||||
})(),
|
})(),
|
||||||
suppressToolErrorWarnings: params.opts?.suppressToolErrorWarnings,
|
suppressToolErrorWarnings: params.opts?.suppressToolErrorWarnings,
|
||||||
|
bootstrapContextMode: params.opts?.bootstrapContextMode,
|
||||||
|
bootstrapContextRunKind: params.opts?.isHeartbeat ? "heartbeat" : "default",
|
||||||
images: params.opts?.images,
|
images: params.opts?.images,
|
||||||
abortSignal: params.opts?.abortSignal,
|
abortSignal: params.opts?.abortSignal,
|
||||||
blockReplyBreak: params.resolvedBlockStreamingBreak,
|
blockReplyBreak: params.resolvedBlockStreamingBreak,
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export type GetReplyOptions = {
|
|||||||
suppressTyping?: boolean;
|
suppressTyping?: boolean;
|
||||||
/** Resolved heartbeat model override (provider/model string from merged per-agent config). */
|
/** Resolved heartbeat model override (provider/model string from merged per-agent config). */
|
||||||
heartbeatModelOverride?: string;
|
heartbeatModelOverride?: string;
|
||||||
|
/** Controls bootstrap workspace context injection (default: full). */
|
||||||
|
bootstrapContextMode?: "full" | "lightweight";
|
||||||
/** If true, suppress tool error warning payloads for this run. */
|
/** If true, suppress tool error warning payloads for this run. */
|
||||||
suppressToolErrorWarnings?: boolean;
|
suppressToolErrorWarnings?: boolean;
|
||||||
onPartialReply?: (payload: ReplyPayload) => Promise<void> | void;
|
onPartialReply?: (payload: ReplyPayload) => Promise<void> | void;
|
||||||
|
|||||||
@@ -40,7 +40,13 @@ const { registerCronCli } = await import("./cron-cli.js");
|
|||||||
type CronUpdatePatch = {
|
type CronUpdatePatch = {
|
||||||
patch?: {
|
patch?: {
|
||||||
schedule?: { kind?: string; expr?: string; tz?: string; staggerMs?: number };
|
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?: {
|
delivery?: {
|
||||||
mode?: string;
|
mode?: string;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
@@ -53,7 +59,7 @@ type CronUpdatePatch = {
|
|||||||
|
|
||||||
type CronAddParams = {
|
type CronAddParams = {
|
||||||
schedule?: { kind?: string; staggerMs?: number };
|
schedule?: { kind?: string; staggerMs?: number };
|
||||||
payload?: { model?: string; thinking?: string };
|
payload?: { model?: string; thinking?: string; lightContext?: boolean };
|
||||||
delivery?: { mode?: string; accountId?: string };
|
delivery?: { mode?: string; accountId?: string };
|
||||||
deleteAfterRun?: boolean;
|
deleteAfterRun?: boolean;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
@@ -153,15 +159,17 @@ async function expectCronEditWithScheduleLookupExit(
|
|||||||
describe("cron cli", () => {
|
describe("cron cli", () => {
|
||||||
it("exits 0 for cron run when job executes successfully", async () => {
|
it("exits 0 for cron run when job executes successfully", async () => {
|
||||||
resetGatewayMock();
|
resetGatewayMock();
|
||||||
callGatewayFromCli.mockImplementation(async (method: string) => {
|
callGatewayFromCli.mockImplementation(
|
||||||
if (method === "cron.status") {
|
async (method: string, _opts: unknown, params?: unknown) => {
|
||||||
return { enabled: true };
|
if (method === "cron.status") {
|
||||||
}
|
return { enabled: true };
|
||||||
if (method === "cron.run") {
|
}
|
||||||
return { ok: true, ran: true };
|
if (method === "cron.run") {
|
||||||
}
|
return { ok: true, params, ran: true };
|
||||||
return { ok: true };
|
}
|
||||||
});
|
return { ok: true, params };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const runtimeModule = await import("../runtime.js");
|
const runtimeModule = await import("../runtime.js");
|
||||||
const runtime = runtimeModule.defaultRuntime as { exit: (code: number) => void };
|
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 () => {
|
it("exits 1 for cron run when job does not execute", async () => {
|
||||||
resetGatewayMock();
|
resetGatewayMock();
|
||||||
callGatewayFromCli.mockImplementation(async (method: string) => {
|
callGatewayFromCli.mockImplementation(
|
||||||
if (method === "cron.status") {
|
async (method: string, _opts: unknown, params?: unknown) => {
|
||||||
return { enabled: true };
|
if (method === "cron.status") {
|
||||||
}
|
return { enabled: true };
|
||||||
if (method === "cron.run") {
|
}
|
||||||
return { ok: true, ran: false };
|
if (method === "cron.run") {
|
||||||
}
|
return { ok: true, params, ran: false };
|
||||||
return { ok: true };
|
}
|
||||||
});
|
return { ok: true, params };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const runtimeModule = await import("../runtime.js");
|
const runtimeModule = await import("../runtime.js");
|
||||||
const runtime = runtimeModule.defaultRuntime as { exit: (code: number) => void };
|
const runtime = runtimeModule.defaultRuntime as { exit: (code: number) => void };
|
||||||
@@ -367,6 +377,22 @@ describe("cron cli", () => {
|
|||||||
expect(params?.agentId).toBe("ops");
|
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([
|
it.each([
|
||||||
{
|
{
|
||||||
label: "omits empty model and thinking",
|
label: "omits empty model and thinking",
|
||||||
@@ -409,6 +435,14 @@ describe("cron cli", () => {
|
|||||||
expect(patch?.patch?.payload?.thinking).toBe("low");
|
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 () => {
|
it("updates delivery settings without requiring --message", async () => {
|
||||||
await runCronCommand([
|
await runCronCommand([
|
||||||
"cron",
|
"cron",
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export function registerCronAddCommand(cron: Command) {
|
|||||||
.option("--thinking <level>", "Thinking level for agent jobs (off|minimal|low|medium|high)")
|
.option("--thinking <level>", "Thinking level for agent jobs (off|minimal|low|medium|high)")
|
||||||
.option("--model <model>", "Model override for agent jobs (provider/model or alias)")
|
.option("--model <model>", "Model override for agent jobs (provider/model or alias)")
|
||||||
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
|
.option("--timeout-seconds <n>", "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("--announce", "Announce summary to a chat (subagent-style)", false)
|
||||||
.option("--deliver", "Deprecated (use --announce). Announces a summary to a chat.")
|
.option("--deliver", "Deprecated (use --announce). Announces a summary to a chat.")
|
||||||
.option("--no-deliver", "Disable announce delivery and skip main-session summary")
|
.option("--no-deliver", "Disable announce delivery and skip main-session summary")
|
||||||
@@ -189,6 +190,7 @@ export function registerCronAddCommand(cron: Command) {
|
|||||||
: undefined,
|
: undefined,
|
||||||
timeoutSeconds:
|
timeoutSeconds:
|
||||||
timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined,
|
timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined,
|
||||||
|
lightContext: opts.lightContext === true ? true : undefined,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export function registerCronEditCommand(cron: Command) {
|
|||||||
.option("--thinking <level>", "Thinking level for agent jobs")
|
.option("--thinking <level>", "Thinking level for agent jobs")
|
||||||
.option("--model <model>", "Model override for agent jobs")
|
.option("--model <model>", "Model override for agent jobs")
|
||||||
.option("--timeout-seconds <n>", "Timeout seconds for agent jobs")
|
.option("--timeout-seconds <n>", "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("--announce", "Announce summary to a chat (subagent-style)")
|
||||||
.option("--deliver", "Deprecated (use --announce). Announces a summary to a chat.")
|
.option("--deliver", "Deprecated (use --announce). Announces a summary to a chat.")
|
||||||
.option("--no-deliver", "Disable announce delivery")
|
.option("--no-deliver", "Disable announce delivery")
|
||||||
@@ -226,6 +228,7 @@ export function registerCronEditCommand(cron: Command) {
|
|||||||
Boolean(model) ||
|
Boolean(model) ||
|
||||||
Boolean(thinking) ||
|
Boolean(thinking) ||
|
||||||
hasTimeoutSeconds ||
|
hasTimeoutSeconds ||
|
||||||
|
typeof opts.lightContext === "boolean" ||
|
||||||
hasDeliveryModeFlag ||
|
hasDeliveryModeFlag ||
|
||||||
hasDeliveryTarget ||
|
hasDeliveryTarget ||
|
||||||
hasDeliveryAccount ||
|
hasDeliveryAccount ||
|
||||||
@@ -244,6 +247,12 @@ export function registerCronEditCommand(cron: Command) {
|
|||||||
assignIf(payload, "model", model, Boolean(model));
|
assignIf(payload, "model", model, Boolean(model));
|
||||||
assignIf(payload, "thinking", thinking, Boolean(thinking));
|
assignIf(payload, "thinking", thinking, Boolean(thinking));
|
||||||
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
|
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
|
||||||
|
assignIf(
|
||||||
|
payload,
|
||||||
|
"lightContext",
|
||||||
|
opts.lightContext,
|
||||||
|
typeof opts.lightContext === "boolean",
|
||||||
|
);
|
||||||
patch.payload = payload;
|
patch.payload = payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -235,6 +235,11 @@ export type AgentDefaultsConfig = {
|
|||||||
ackMaxChars?: number;
|
ackMaxChars?: number;
|
||||||
/** Suppress tool error warning payloads during heartbeat runs. */
|
/** Suppress tool error warning payloads during heartbeat runs. */
|
||||||
suppressToolErrorWarnings?: boolean;
|
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)
|
* When enabled, deliver the model's reasoning payload for heartbeat runs (when available)
|
||||||
* as a separate message prefixed with `Reasoning:` (same as `/reasoning on`).
|
* as a separate message prefixed with `Reasoning:` (same as `/reasoning on`).
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const HeartbeatSchema = z
|
|||||||
prompt: z.string().optional(),
|
prompt: z.string().optional(),
|
||||||
ackMaxChars: z.number().int().nonnegative().optional(),
|
ackMaxChars: z.number().int().nonnegative().optional(),
|
||||||
suppressToolErrorWarnings: z.boolean().optional(),
|
suppressToolErrorWarnings: z.boolean().optional(),
|
||||||
|
lightContext: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.superRefine((val, ctx) => {
|
.superRefine((val, ctx) => {
|
||||||
|
|||||||
@@ -507,6 +507,8 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
thinkLevel,
|
thinkLevel,
|
||||||
verboseLevel: resolvedVerboseLevel,
|
verboseLevel: resolvedVerboseLevel,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
|
bootstrapContextMode: agentPayload?.lightContext ? "lightweight" : undefined,
|
||||||
|
bootstrapContextRunKind: "cron",
|
||||||
runId: cronSession.sessionEntry.sessionId,
|
runId: cronSession.sessionEntry.sessionId,
|
||||||
// Only enforce an explicit message target when the cron delivery target
|
// Only enforce an explicit message target when the cron delivery target
|
||||||
// was successfully resolved. When resolution fails the agent should not
|
// was successfully resolved. When resolution fails the agent should not
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export type CronPayload =
|
|||||||
thinking?: string;
|
thinking?: string;
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
allowUnsafeExternalContent?: boolean;
|
allowUnsafeExternalContent?: boolean;
|
||||||
|
/** If true, run with lightweight bootstrap context. */
|
||||||
|
lightContext?: boolean;
|
||||||
deliver?: boolean;
|
deliver?: boolean;
|
||||||
channel?: CronMessageChannel;
|
channel?: CronMessageChannel;
|
||||||
to?: string;
|
to?: string;
|
||||||
@@ -91,6 +93,8 @@ export type CronPayloadPatch =
|
|||||||
thinking?: string;
|
thinking?: string;
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
allowUnsafeExternalContent?: boolean;
|
allowUnsafeExternalContent?: boolean;
|
||||||
|
/** If true, run with lightweight bootstrap context. */
|
||||||
|
lightContext?: boolean;
|
||||||
deliver?: boolean;
|
deliver?: boolean;
|
||||||
channel?: CronMessageChannel;
|
channel?: CronMessageChannel;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ function cronAgentTurnPayloadSchema(params: { message: TSchema }) {
|
|||||||
thinking: Type.Optional(Type.String()),
|
thinking: Type.Optional(Type.String()),
|
||||||
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
|
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||||
allowUnsafeExternalContent: Type.Optional(Type.Boolean()),
|
allowUnsafeExternalContent: Type.Optional(Type.Boolean()),
|
||||||
|
lightContext: Type.Optional(Type.Boolean()),
|
||||||
deliver: Type.Optional(Type.Boolean()),
|
deliver: Type.Optional(Type.Boolean()),
|
||||||
channel: Type.Optional(Type.String()),
|
channel: Type.Optional(Type.String()),
|
||||||
to: Type.Optional(Type.String()),
|
to: Type.Optional(Type.String()),
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ describe("runHeartbeatOnce – heartbeat model override", () => {
|
|||||||
async function runDefaultsHeartbeat(params: {
|
async function runDefaultsHeartbeat(params: {
|
||||||
model?: string;
|
model?: string;
|
||||||
suppressToolErrorWarnings?: boolean;
|
suppressToolErrorWarnings?: boolean;
|
||||||
|
lightContext?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => {
|
return withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => {
|
||||||
const cfg: OpenClawConfig = {
|
const cfg: OpenClawConfig = {
|
||||||
@@ -75,6 +76,7 @@ describe("runHeartbeatOnce – heartbeat model override", () => {
|
|||||||
target: "whatsapp",
|
target: "whatsapp",
|
||||||
model: params.model,
|
model: params.model,
|
||||||
suppressToolErrorWarnings: params.suppressToolErrorWarnings,
|
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 () => {
|
it("passes per-agent heartbeat model override (merged with defaults)", async () => {
|
||||||
await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => {
|
await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => {
|
||||||
const cfg: OpenClawConfig = {
|
const cfg: OpenClawConfig = {
|
||||||
|
|||||||
@@ -743,9 +743,16 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
|
|
||||||
const heartbeatModelOverride = heartbeat?.model?.trim() || undefined;
|
const heartbeatModelOverride = heartbeat?.model?.trim() || undefined;
|
||||||
const suppressToolErrorWarnings = heartbeat?.suppressToolErrorWarnings === true;
|
const suppressToolErrorWarnings = heartbeat?.suppressToolErrorWarnings === true;
|
||||||
|
const bootstrapContextMode: "lightweight" | undefined =
|
||||||
|
heartbeat?.lightContext === true ? "lightweight" : undefined;
|
||||||
const replyOpts = heartbeatModelOverride
|
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 replyResult = await getReplyFromConfig(ctx, replyOpts, cfg);
|
||||||
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
|
const replyPayload = resolveHeartbeatReplyPayload(replyResult);
|
||||||
const includeReasoning = heartbeat?.includeReasoning === true;
|
const includeReasoning = heartbeat?.includeReasoning === true;
|
||||||
|
|||||||
Reference in New Issue
Block a user