mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-22 22:52:03 +00:00
243 lines
7.5 KiB
TypeScript
243 lines
7.5 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import type { AgentContextInjection } from "../config/types.agent-defaults.js";
|
|
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
|
import { resolveSessionAgentIds } from "./agent-scope.js";
|
|
import { getOrLoadBootstrapFiles } from "./bootstrap-cache.js";
|
|
import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js";
|
|
import { shouldIncludeHeartbeatGuidanceForSystemPrompt } from "./heartbeat-system-prompt.js";
|
|
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
|
|
import {
|
|
buildBootstrapContextFiles,
|
|
resolveBootstrapMaxChars,
|
|
resolveBootstrapTotalMaxChars,
|
|
} from "./pi-embedded-helpers.js";
|
|
import {
|
|
DEFAULT_HEARTBEAT_FILENAME,
|
|
filterBootstrapFilesForSession,
|
|
loadWorkspaceBootstrapFiles,
|
|
type WorkspaceBootstrapFile,
|
|
} from "./workspace.js";
|
|
|
|
export type BootstrapContextMode = "full" | "lightweight";
|
|
export type BootstrapContextRunKind = "default" | "heartbeat" | "cron";
|
|
|
|
const CONTINUATION_SCAN_MAX_TAIL_BYTES = 256 * 1024;
|
|
const CONTINUATION_SCAN_MAX_RECORDS = 500;
|
|
export const FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE = "openclaw:bootstrap-context:full";
|
|
|
|
export function resolveContextInjectionMode(config?: OpenClawConfig): AgentContextInjection {
|
|
return config?.agents?.defaults?.contextInjection ?? "always";
|
|
}
|
|
|
|
export async function hasCompletedBootstrapTurn(sessionFile: string): Promise<boolean> {
|
|
try {
|
|
const stat = await fs.lstat(sessionFile);
|
|
if (stat.isSymbolicLink()) {
|
|
return false;
|
|
}
|
|
|
|
const fh = await fs.open(sessionFile, "r");
|
|
try {
|
|
const bytesToRead = Math.min(stat.size, CONTINUATION_SCAN_MAX_TAIL_BYTES);
|
|
if (bytesToRead <= 0) {
|
|
return false;
|
|
}
|
|
const start = stat.size - bytesToRead;
|
|
const buffer = Buffer.allocUnsafe(bytesToRead);
|
|
const { bytesRead } = await fh.read(buffer, 0, bytesToRead, start);
|
|
let text = buffer.toString("utf-8", 0, bytesRead);
|
|
if (start > 0) {
|
|
const firstNewline = text.indexOf("\n");
|
|
if (firstNewline === -1) {
|
|
return false;
|
|
}
|
|
text = text.slice(firstNewline + 1);
|
|
}
|
|
|
|
const records = text
|
|
.split(/\r?\n/u)
|
|
.filter((line) => line.trim().length > 0)
|
|
.slice(-CONTINUATION_SCAN_MAX_RECORDS);
|
|
let compactedAfterLatestAssistant = false;
|
|
|
|
for (let i = records.length - 1; i >= 0; i--) {
|
|
const line = records[i];
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
let entry: unknown;
|
|
try {
|
|
entry = JSON.parse(line);
|
|
} catch {
|
|
continue;
|
|
}
|
|
const record = entry as
|
|
| {
|
|
type?: string;
|
|
customType?: string;
|
|
message?: { role?: string };
|
|
}
|
|
| null
|
|
| undefined;
|
|
if (record?.type === "compaction") {
|
|
compactedAfterLatestAssistant = true;
|
|
continue;
|
|
}
|
|
if (
|
|
record?.type === "custom" &&
|
|
record.customType === FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE
|
|
) {
|
|
return !compactedAfterLatestAssistant;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
} finally {
|
|
await fh.close();
|
|
}
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function makeBootstrapWarn(params: {
|
|
sessionLabel: string;
|
|
warn?: (message: string) => void;
|
|
}): ((message: string) => void) | undefined {
|
|
if (!params.warn) {
|
|
return undefined;
|
|
}
|
|
return (message: string) => params.warn?.(`${message} (sessionKey=${params.sessionLabel})`);
|
|
}
|
|
|
|
function sanitizeBootstrapFiles(
|
|
files: WorkspaceBootstrapFile[],
|
|
warn?: (message: string) => void,
|
|
): WorkspaceBootstrapFile[] {
|
|
const sanitized: WorkspaceBootstrapFile[] = [];
|
|
for (const file of files) {
|
|
const pathValue = normalizeOptionalString(file.path) ?? "";
|
|
if (!pathValue) {
|
|
warn?.(
|
|
`skipping bootstrap file "${file.name}" — missing or invalid "path" field (hook may have used "filePath" instead)`,
|
|
);
|
|
continue;
|
|
}
|
|
sanitized.push({ ...file, path: pathValue });
|
|
}
|
|
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 [];
|
|
}
|
|
|
|
function shouldExcludeHeartbeatBootstrapFile(params: {
|
|
config?: OpenClawConfig;
|
|
sessionKey?: string;
|
|
sessionId?: string;
|
|
agentId?: string;
|
|
runKind?: BootstrapContextRunKind;
|
|
}): boolean {
|
|
if (!params.config || params.runKind === "heartbeat") {
|
|
return false;
|
|
}
|
|
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
|
sessionKey: params.sessionKey ?? params.sessionId,
|
|
config: params.config,
|
|
agentId: params.agentId,
|
|
});
|
|
if (sessionAgentId !== defaultAgentId) {
|
|
return false;
|
|
}
|
|
return !shouldIncludeHeartbeatGuidanceForSystemPrompt({
|
|
config: params.config,
|
|
agentId: sessionAgentId,
|
|
defaultAgentId,
|
|
});
|
|
}
|
|
|
|
function filterHeartbeatBootstrapFile(
|
|
files: WorkspaceBootstrapFile[],
|
|
excludeHeartbeatBootstrapFile: boolean,
|
|
): WorkspaceBootstrapFile[] {
|
|
if (!excludeHeartbeatBootstrapFile) {
|
|
return files;
|
|
}
|
|
return files.filter((file) => file.name !== DEFAULT_HEARTBEAT_FILENAME);
|
|
}
|
|
|
|
export async function resolveBootstrapFilesForRun(params: {
|
|
workspaceDir: string;
|
|
config?: OpenClawConfig;
|
|
sessionKey?: string;
|
|
sessionId?: string;
|
|
agentId?: string;
|
|
warn?: (message: string) => void;
|
|
contextMode?: BootstrapContextMode;
|
|
runKind?: BootstrapContextRunKind;
|
|
}): Promise<WorkspaceBootstrapFile[]> {
|
|
const excludeHeartbeatBootstrapFile = shouldExcludeHeartbeatBootstrapFile(params);
|
|
const sessionKey = params.sessionKey ?? params.sessionId;
|
|
const rawFiles = params.sessionKey
|
|
? await getOrLoadBootstrapFiles({
|
|
workspaceDir: params.workspaceDir,
|
|
sessionKey: params.sessionKey,
|
|
})
|
|
: await loadWorkspaceBootstrapFiles(params.workspaceDir);
|
|
const bootstrapFiles = applyContextModeFilter({
|
|
files: filterBootstrapFilesForSession(rawFiles, sessionKey),
|
|
contextMode: params.contextMode,
|
|
runKind: params.runKind,
|
|
});
|
|
|
|
const updated = await applyBootstrapHookOverrides({
|
|
files: bootstrapFiles,
|
|
workspaceDir: params.workspaceDir,
|
|
config: params.config,
|
|
sessionKey: params.sessionKey,
|
|
sessionId: params.sessionId,
|
|
agentId: params.agentId,
|
|
});
|
|
return sanitizeBootstrapFiles(
|
|
filterHeartbeatBootstrapFile(updated, excludeHeartbeatBootstrapFile),
|
|
params.warn,
|
|
);
|
|
}
|
|
|
|
export async function resolveBootstrapContextForRun(params: {
|
|
workspaceDir: string;
|
|
config?: OpenClawConfig;
|
|
sessionKey?: string;
|
|
sessionId?: string;
|
|
agentId?: string;
|
|
warn?: (message: string) => void;
|
|
contextMode?: BootstrapContextMode;
|
|
runKind?: BootstrapContextRunKind;
|
|
}): Promise<{
|
|
bootstrapFiles: WorkspaceBootstrapFile[];
|
|
contextFiles: EmbeddedContextFile[];
|
|
}> {
|
|
const bootstrapFiles = await resolveBootstrapFilesForRun(params);
|
|
const contextFiles = buildBootstrapContextFiles(bootstrapFiles, {
|
|
maxChars: resolveBootstrapMaxChars(params.config),
|
|
totalMaxChars: resolveBootstrapTotalMaxChars(params.config),
|
|
warn: params.warn,
|
|
});
|
|
return { bootstrapFiles, contextFiles };
|
|
}
|