Files
openclaw/src/agents/bootstrap-files.ts
Brad be8b4dc845 fix(agents): honor hook bootstrap content (#77501)
* Problem: `agent:bootstrap` hooks can inject `BOOTSTRAP.md` content, but embedded-runner bootstrap routing decided whether bootstrap was pending before hook-adjusted files were considered.
* Fix: preload hook-adjusted bootstrap files before routing, treat non-empty hook-provided `BOOTSTRAP.md` as pending and accessible bootstrap content, and reuse the preloaded files when building Project Context.
* Tests: added routing + context-engine regression coverage for hook-injected bootstrap content.

Co-authored-by: ificator <8387253+ificator@users.noreply.github.com>
Co-authored-by: galiniliev <galini@microsoft.com>
2026-05-04 13:48:40 -07:00

302 lines
9.2 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import type { AgentContextInjection } from "../config/types.agent-defaults.js";
import type { OpenClawConfig } from "../config/types.openclaw.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,
isWorkspaceBootstrapPending,
loadWorkspaceBootstrapFiles,
type WorkspaceBootstrapFile,
} from "./workspace.js";
export type BootstrapContextMode = "full" | "lightweight";
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";
const BOOTSTRAP_WARNING_DEDUPE_LIMIT = 1024;
const seenBootstrapWarnings = new Set<string>();
const bootstrapWarningOrder: string[] = [];
function rememberBootstrapWarning(key: string): boolean {
if (seenBootstrapWarnings.has(key)) {
return false;
}
if (seenBootstrapWarnings.size >= BOOTSTRAP_WARNING_DEDUPE_LIMIT) {
const oldest = bootstrapWarningOrder.shift();
if (oldest) {
seenBootstrapWarnings.delete(oldest);
}
}
seenBootstrapWarnings.add(key);
bootstrapWarningOrder.push(key);
return true;
}
export function _resetBootstrapWarningCacheForTest(): void {
seenBootstrapWarnings.clear();
bootstrapWarningOrder.length = 0;
}
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;
workspaceDir?: string;
warn?: (message: string) => void;
}): ((message: string) => void) | undefined {
const warn = params.warn;
if (!warn) {
return undefined;
}
const workspacePrefix = params.workspaceDir ?? "";
return (message: string) => {
const key = `${workspacePrefix}\u0000${params.sessionLabel}\u0000${message}`;
if (!rememberBootstrapWarning(key)) {
return;
}
warn(`${message} (sessionKey=${params.sessionLabel})`);
};
}
function sanitizeBootstrapFiles(
files: WorkspaceBootstrapFile[],
workspaceDir: string,
warn?: (message: string) => void,
): WorkspaceBootstrapFile[] {
const workspaceRoot = path.resolve(workspaceDir);
const seenPaths = new Set<string>();
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;
}
const resolvedPath = path.isAbsolute(pathValue)
? path.resolve(pathValue)
: path.resolve(workspaceRoot, pathValue);
const dedupeKey = path.normalize(path.relative(workspaceRoot, resolvedPath));
if (seenPaths.has(dedupeKey)) {
continue;
}
seenPaths.add(dedupeKey);
sanitized.push({ ...file, path: resolvedPath });
}
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.workspaceDir,
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 = buildBootstrapContextForFiles(bootstrapFiles, params);
return { bootstrapFiles, contextFiles };
}
export function buildBootstrapContextForFiles(
bootstrapFiles: WorkspaceBootstrapFile[],
params: {
config?: OpenClawConfig;
warn?: (message: string) => void;
},
): EmbeddedContextFile[] {
const contextFiles = buildBootstrapContextFiles(bootstrapFiles, {
maxChars: resolveBootstrapMaxChars(params.config),
totalMaxChars: resolveBootstrapTotalMaxChars(params.config),
warn: params.warn,
});
return contextFiles;
}
export { isWorkspaceBootstrapPending };