fix(bootstrap): workspace bootstrap prompt routing (#68000)

* fix(bootstrap): workspace bootstrap prompt routing

* Fix bootstrap routing edge cases

* Refine bootstrap mode routing and reset prompts

* Fix bootstrap workspace routing for embedded runs

* Fix embedded bootstrap compile follow-up

* Align bare reset bootstrap file access

* Honor reset override model for bootstrap gating

* Align chat reset bootstrap topology
This commit is contained in:
Tak Hoffman
2026-04-17 10:18:50 -05:00
committed by GitHub
parent 4d7d14cfa7
commit 62703d8430
23 changed files with 1073 additions and 99 deletions

View File

@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/bootstrap: resolve bootstrap from workspace truth instead of stale session transcript markers, keep embedded bootstrap instructions on a hidden user-context prelude, suppress normal `/new` and `/reset` greetings while `BOOTSTRAP.md` is still pending, and make the embedded runner read the bootstrap ritual before replying normally.
- Onboarding/non-interactive: preserve existing gateway auth tokens during re-onboard so active local gateway clients are not disconnected by an implicit token rotation. (#67821) Thanks @BKF-Gitty.
- Gateway/hello-ok: always report negotiated auth metadata for successful shared-auth handshakes, including control-ui bypass coverage when no device token is issued. (#67810) Thanks @BunsDev.
- OpenAI Codex/Responses: unify native Responses API capability detection so Codex OAuth requests emit the required `store: false` field on the native Responses path. (#67918) Thanks @obviyus.

View File

@@ -107,6 +107,18 @@ describe("resolveBootstrapContextForRun", () => {
expect(extra?.content).toBe("extra");
});
it("keeps BOOTSTRAP.md available in shared injected context for non-attempt consumers", async () => {
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "ritual", "utf8");
await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "rules", "utf8");
const result = await resolveBootstrapContextForRun({ workspaceDir });
expect(result.bootstrapFiles.some((file) => file.name === "BOOTSTRAP.md")).toBe(true);
expect(result.contextFiles.some((file) => file.path.endsWith("BOOTSTRAP.md"))).toBe(true);
expect(result.contextFiles.some((file) => file.path.endsWith("AGENTS.md"))).toBe(true);
});
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");

View File

@@ -15,6 +15,7 @@ import {
import {
DEFAULT_HEARTBEAT_FILENAME,
filterBootstrapFilesForSession,
isWorkspaceBootstrapPending,
loadWorkspaceBootstrapFiles,
type WorkspaceBootstrapFile,
} from "./workspace.js";
@@ -272,3 +273,5 @@ export async function resolveBootstrapContextForRun(params: {
});
return { bootstrapFiles, contextFiles };
}
export { isWorkspaceBootstrapPending };

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { resolveBootstrapMode } from "./bootstrap-mode.js";
describe("resolveBootstrapMode", () => {
it("returns none when bootstrap is not pending", () => {
expect(
resolveBootstrapMode({
bootstrapPending: false,
runKind: "default",
isInteractiveUserFacing: true,
isPrimaryRun: true,
isCanonicalWorkspace: true,
hasBootstrapFileAccess: true,
}),
).toBe("none");
});
it("returns full for primary interactive canonical runs with file access", () => {
expect(
resolveBootstrapMode({
bootstrapPending: true,
runKind: "default",
isInteractiveUserFacing: true,
isPrimaryRun: true,
isCanonicalWorkspace: true,
hasBootstrapFileAccess: true,
}),
).toBe("full");
});
it("returns limited for primary interactive copied-sandbox runs with file access", () => {
expect(
resolveBootstrapMode({
bootstrapPending: true,
runKind: "default",
isInteractiveUserFacing: true,
isPrimaryRun: true,
isCanonicalWorkspace: false,
hasBootstrapFileAccess: true,
}),
).toBe("limited");
});
it("returns none for cron, heartbeat, and non-primary runs", () => {
expect(
resolveBootstrapMode({
bootstrapPending: true,
runKind: "cron",
isInteractiveUserFacing: true,
isPrimaryRun: true,
isCanonicalWorkspace: true,
hasBootstrapFileAccess: true,
}),
).toBe("none");
expect(
resolveBootstrapMode({
bootstrapPending: true,
runKind: "heartbeat",
isInteractiveUserFacing: true,
isPrimaryRun: true,
isCanonicalWorkspace: true,
hasBootstrapFileAccess: true,
}),
).toBe("none");
expect(
resolveBootstrapMode({
bootstrapPending: true,
runKind: "default",
isInteractiveUserFacing: true,
isPrimaryRun: false,
isCanonicalWorkspace: true,
hasBootstrapFileAccess: true,
}),
).toBe("none");
});
it("returns none when the run cannot access bootstrap files normally", () => {
expect(
resolveBootstrapMode({
bootstrapPending: true,
runKind: "default",
isInteractiveUserFacing: true,
isPrimaryRun: true,
isCanonicalWorkspace: true,
hasBootstrapFileAccess: false,
}),
).toBe("none");
});
});

View File

@@ -0,0 +1,24 @@
export type BootstrapMode = "full" | "limited" | "none";
export function resolveBootstrapMode(params: {
bootstrapPending: boolean;
runKind?: "default" | "heartbeat" | "cron";
isInteractiveUserFacing: boolean;
isPrimaryRun: boolean;
isCanonicalWorkspace: boolean;
hasBootstrapFileAccess: boolean;
}): BootstrapMode {
if (!params.bootstrapPending) {
return "none";
}
if (params.runKind === "heartbeat" || params.runKind === "cron") {
return "none";
}
if (!params.isPrimaryRun || !params.isInteractiveUserFacing) {
return "none";
}
if (!params.hasBootstrapFileAccess) {
return "none";
}
return params.isCanonicalWorkspace ? "full" : "limited";
}

View File

@@ -0,0 +1,25 @@
export function buildFullBootstrapPromptLines(params: {
readLine: string;
firstReplyLine: string;
}): string[] {
return [
params.readLine,
"If this run can complete the BOOTSTRAP.md workflow, do so.",
"If it cannot, explain the blocker briefly, continue with any bootstrap steps that are still possible here, and offer the simplest next step.",
"Do not pretend bootstrap is complete when it is not.",
"Do not use a generic first greeting or reply normally until after you have handled BOOTSTRAP.md.",
params.firstReplyLine,
];
}
export function buildLimitedBootstrapPromptLines(params: {
introLine: string;
nextStepLine: string;
}): string[] {
return [
params.introLine,
"Do not claim bootstrap is complete, and do not use a generic first greeting.",
"Briefly explain the limitation, continue only with any bootstrap steps that are still safely possible here, and offer the simplest next step.",
params.nextStepLine,
];
}

View File

@@ -11,11 +11,13 @@ import { enqueueCommandInLane } from "../../process/command-queue.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { sanitizeForLog } from "../../terminal/ansi.js";
import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js";
import { resolveUserPath } from "../../utils.js";
import { resolveOpenClawAgentDir } from "../agent-paths.js";
import {
hasConfiguredModelFallbacks,
resolveAgentExecutionContract,
resolveSessionAgentIds,
resolveAgentWorkspaceDir,
} from "../agent-scope.js";
import {
type AuthProfileFailureReason,
@@ -255,6 +257,10 @@ export async function runEmbeddedPiAgent(
config: params.config,
});
const resolvedWorkspace = workspaceResolution.workspaceDir;
const canonicalWorkspace = resolveUserPath(
resolveAgentWorkspaceDir(params.config ?? {}, workspaceResolution.agentId),
);
const isCanonicalWorkspace = canonicalWorkspace === resolvedWorkspace;
const redactedSessionId = redactRunIdentifier(params.sessionId);
const redactedSessionKey = redactRunIdentifier(params.sessionKey);
const redactedWorkspace = redactRunIdentifier(resolvedWorkspace);
@@ -682,6 +688,7 @@ export async function runEmbeddedPiAgent(
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
isCanonicalWorkspace,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,

View File

@@ -2,6 +2,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import type { MemoryCitationsMode } from "../../../config/types.memory.js";
import type { ContextEngine, ContextEngineRuntimeContext } from "../../../context-engine/types.js";
import type { BootstrapMode } from "../../bootstrap-mode.js";
import { normalizeUsage, type NormalizedUsage } from "../../usage.js";
import type { PromptCacheChange } from "../prompt-cache-observability.js";
import type { EmbeddedRunAttemptResult } from "./types.js";
@@ -19,6 +20,7 @@ export async function resolveAttemptBootstrapContext<
contextInjectionMode: "always" | "continuation-skip";
bootstrapContextMode?: string;
bootstrapContextRunKind?: string;
bootstrapMode?: BootstrapMode;
sessionFile: string;
hasCompletedBootstrapTurn: (sessionFile: string) => Promise<boolean>;
resolveBootstrapContextForRun: () => Promise<TContext>;
@@ -29,13 +31,15 @@ export async function resolveAttemptBootstrapContext<
}
> {
const isContinuationTurn =
params.bootstrapMode !== "full" &&
params.contextInjectionMode === "continuation-skip" &&
params.bootstrapContextRunKind !== "heartbeat" &&
(await params.hasCompletedBootstrapTurn(params.sessionFile));
const shouldRecordCompletedBootstrapTurn =
!isContinuationTurn &&
params.bootstrapContextMode !== "lightweight" &&
params.bootstrapContextRunKind !== "heartbeat";
params.bootstrapContextRunKind !== "heartbeat" &&
params.bootstrapMode === "full";
const context = isContinuationTurn
? ({ bootstrapFiles: [], contextFiles: [] } as unknown as TContext)

View File

@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
cleanupTempPaths,
createContextEngineAttemptRunner,
getHoisted,
resetEmbeddedAttemptHarness,
} from "./attempt.spawn-workspace.test-support.js";
const hoisted = getHoisted();
describe("runEmbeddedAttempt bootstrap routing", () => {
const tempPaths: string[] = [];
beforeEach(() => {
resetEmbeddedAttemptHarness();
});
afterEach(async () => {
await cleanupTempPaths(tempPaths);
});
it("resolves bootstrap pending from the canonical workspace instead of a copied sandbox", async () => {
const sandboxWorkspace = "/tmp/openclaw-sandbox-copy";
let capturedPrompt = "";
hoisted.resolveSandboxContextMock.mockResolvedValue({
enabled: true,
workspaceAccess: "ro",
workspaceDir: sandboxWorkspace,
});
hoisted.isWorkspaceBootstrapPendingMock.mockImplementation(async (workspaceDir: string) => {
return workspaceDir === sandboxWorkspace;
});
await createContextEngineAttemptRunner({
sessionKey: "agent:main:bootstrap-canonical-workspace",
tempPaths,
contextEngine: {
assemble: async ({ messages }) => ({
messages,
estimatedTokens: 1,
}),
},
attemptOverrides: {
disableTools: true,
},
sessionPrompt: async (session, prompt) => {
capturedPrompt = prompt;
session.messages = [
...session.messages,
{ role: "assistant", content: "done", timestamp: 2 } as never,
];
},
});
expect(hoisted.isWorkspaceBootstrapPendingMock).toHaveBeenCalledTimes(1);
expect(hoisted.isWorkspaceBootstrapPendingMock).not.toHaveBeenCalledWith(sandboxWorkspace);
expect(capturedPrompt).not.toContain("[Bootstrap pending]");
});
});

View File

@@ -15,6 +15,7 @@ async function resolveBootstrapContext(params: {
contextInjectionMode?: "always" | "continuation-skip";
bootstrapContextMode?: string;
bootstrapContextRunKind?: string;
bootstrapMode?: "full" | "limited" | "none";
completed?: boolean;
resolver?: () => Promise<{ bootstrapFiles: unknown[]; contextFiles: unknown[] }>;
}) {
@@ -30,6 +31,7 @@ async function resolveBootstrapContext(params: {
contextInjectionMode: params.contextInjectionMode ?? "always",
bootstrapContextMode: params.bootstrapContextMode ?? "full",
bootstrapContextRunKind: params.bootstrapContextRunKind ?? "default",
bootstrapMode: params.bootstrapMode ?? "none",
sessionFile: "/tmp/session.jsonl",
hasCompletedBootstrapTurn,
resolveBootstrapContextForRun,
@@ -75,6 +77,26 @@ describe("embedded attempt context injection", () => {
expect(resolver).toHaveBeenCalledTimes(1);
});
it("does not let a stale completed marker suppress pending workspace bootstrap", async () => {
const resolver = vi.fn(async () => ({
bootstrapFiles: [{ name: "BOOTSTRAP.md" }],
contextFiles: [{ path: "BOOTSTRAP.md" }],
}));
const { result, hasCompletedBootstrapTurn } = await resolveBootstrapContext({
contextInjectionMode: "continuation-skip",
bootstrapMode: "full",
completed: true,
resolver,
});
expect(result.isContinuationTurn).toBe(false);
expect(result.bootstrapFiles).toEqual([{ name: "BOOTSTRAP.md" }]);
expect(result.contextFiles).toEqual([{ path: "BOOTSTRAP.md" }]);
expect(hasCompletedBootstrapTurn).not.toHaveBeenCalled();
expect(resolver).toHaveBeenCalledTimes(1);
});
it("forwards senderIsOwner into embedded message-action discovery", async () => {
const input = buildEmbeddedMessageActionDiscoveryInput({
cfg: {},
@@ -128,6 +150,7 @@ describe("embedded attempt context injection", () => {
const { result } = await resolveBootstrapContext({
bootstrapContextMode: "full",
bootstrapContextRunKind: "default",
bootstrapMode: "full",
resolver,
});
@@ -139,11 +162,26 @@ describe("embedded attempt context injection", () => {
const { result } = await resolveBootstrapContext({
bootstrapContextMode: "lightweight",
bootstrapContextRunKind: "heartbeat",
bootstrapMode: "none",
});
expect(result.shouldRecordCompletedBootstrapTurn).toBe(false);
});
it("allows continuation skip again for limited bootstrap mode", async () => {
const { result, hasCompletedBootstrapTurn, resolveBootstrapContextForRun } =
await resolveBootstrapContext({
contextInjectionMode: "continuation-skip",
bootstrapMode: "limited",
completed: true,
});
expect(result.isContinuationTurn).toBe(true);
expect(hasCompletedBootstrapTurn).toHaveBeenCalledWith("/tmp/session.jsonl");
expect(resolveBootstrapContextForRun).not.toHaveBeenCalled();
expect(result.shouldRecordCompletedBootstrapTurn).toBe(false);
});
it("filters no-op heartbeat pairs before history limiting and context-engine assembly", async () => {
const assemble = vi.fn(async ({ messages }: { messages: AgentMessage[] }) => ({
messages,

View File

@@ -62,8 +62,10 @@ type AttemptSpawnWorkspaceHoisted = {
flushPendingToolResultsAfterIdleMock: AsyncUnknownMock;
releaseWsSessionMock: UnknownMock;
resolveBootstrapContextForRunMock: Mock<() => Promise<BootstrapContext>>;
isWorkspaceBootstrapPendingMock: Mock<(workspaceDir: string) => Promise<boolean>>;
resolveContextInjectionModeMock: Mock<() => "always" | "continuation-skip">;
hasCompletedBootstrapTurnMock: Mock<() => Promise<boolean>>;
supportsModelToolsMock: Mock<(model?: unknown) => boolean>;
getGlobalHookRunnerMock: Mock<() => unknown>;
initializeGlobalHookRunnerMock: UnknownMock;
runContextEngineMaintenanceMock: AsyncUnknownMock;
@@ -118,10 +120,14 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
bootstrapFiles: [],
contextFiles: [],
}));
const isWorkspaceBootstrapPendingMock = vi.fn<(workspaceDir: string) => Promise<boolean>>(
async () => false,
);
const resolveContextInjectionModeMock = vi.fn<() => "always" | "continuation-skip">(
() => "always",
);
const hasCompletedBootstrapTurnMock = vi.fn<() => Promise<boolean>>(async () => false);
const supportsModelToolsMock = vi.fn<(model?: unknown) => boolean>(() => true);
const getGlobalHookRunnerMock = vi.fn<() => unknown>(() => undefined);
const initializeGlobalHookRunnerMock = vi.fn();
const runContextEngineMaintenanceMock = vi.fn(async (_params?: unknown) => undefined);
@@ -154,8 +160,10 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
flushPendingToolResultsAfterIdleMock,
releaseWsSessionMock,
resolveBootstrapContextForRunMock,
isWorkspaceBootstrapPendingMock,
resolveContextInjectionModeMock,
hasCompletedBootstrapTurnMock,
supportsModelToolsMock,
getGlobalHookRunnerMock,
initializeGlobalHookRunnerMock,
runContextEngineMaintenanceMock,
@@ -234,6 +242,7 @@ vi.mock("../../bootstrap-files.js", async () => {
return {
...actual,
makeBootstrapWarn: () => () => {},
isWorkspaceBootstrapPending: hoisted.isWorkspaceBootstrapPendingMock,
resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock,
resolveContextInjectionMode: hoisted.resolveContextInjectionModeMock,
hasCompletedBootstrapTurn: hoisted.hasCompletedBootstrapTurnMock,
@@ -446,7 +455,7 @@ vi.mock("../../model-auth.js", () => ({
}));
vi.mock("../../model-tool-support.js", () => ({
supportsModelTools: () => true,
supportsModelTools: (...args: unknown[]) => hoisted.supportsModelToolsMock(...args),
}));
vi.mock("../../provider-stream.js", () => ({
@@ -727,8 +736,10 @@ export function resetEmbeddedAttemptHarness(
bootstrapFiles: [],
contextFiles: [],
});
hoisted.isWorkspaceBootstrapPendingMock.mockReset().mockResolvedValue(false);
hoisted.resolveContextInjectionModeMock.mockReset().mockReturnValue("always");
hoisted.hasCompletedBootstrapTurnMock.mockReset().mockResolvedValue(false);
hoisted.supportsModelToolsMock.mockReset().mockReturnValue(true);
hoisted.getGlobalHookRunnerMock.mockReset().mockReturnValue(undefined);
hoisted.runContextEngineMaintenanceMock.mockReset().mockResolvedValue(undefined);
hoisted.getDmHistoryLimitFromSessionKeyMock.mockReset().mockReturnValue(undefined);

View File

@@ -10,8 +10,10 @@ import {
buildAfterTurnRuntimeContextFromUsage,
composeSystemPromptWithHookContext,
decodeHtmlEntitiesInObject,
isPrimaryBootstrapRun,
mergeOrphanedTrailingUserPrompt,
prependSystemPromptAddition,
remapInjectedContextFilesToWorkspace,
resetEmbeddedAgentBaseStreamFnCacheForTest,
resolveEmbeddedAgentBaseStreamFn,
resolveAttemptFsWorkspaceOnly,
@@ -19,6 +21,7 @@ import {
resolveUnknownToolGuardThreshold,
resolvePromptBuildHookResult,
resolvePromptModeForSession,
shouldStripBootstrapFromEmbeddedContext,
shouldWarnOnOrphanedUserRepair,
wrapStreamFnRepairMalformedToolCallArguments,
wrapStreamFnSanitizeMalformedToolCalls,
@@ -220,6 +223,63 @@ describe("resolvePromptModeForSession", () => {
});
});
describe("shouldStripBootstrapFromEmbeddedContext", () => {
it("never injects raw BOOTSTRAP.md into embedded system context", () => {
expect(shouldStripBootstrapFromEmbeddedContext({ bootstrapMode: "full" })).toBe(true);
expect(shouldStripBootstrapFromEmbeddedContext({ bootstrapMode: "limited" })).toBe(true);
expect(shouldStripBootstrapFromEmbeddedContext({ bootstrapMode: "none" })).toBe(true);
});
});
describe("isPrimaryBootstrapRun", () => {
it("treats regular sessions as primary bootstrap runs", () => {
expect(isPrimaryBootstrapRun("agent:main:main")).toBe(true);
});
it("suppresses bootstrap ownership for subagent and ACP/helper sessions", () => {
expect(isPrimaryBootstrapRun("agent:main:subagent:worker")).toBe(false);
expect(isPrimaryBootstrapRun("agent:main:acp:worker")).toBe(false);
});
});
describe("remapInjectedContextFilesToWorkspace", () => {
it("rewrites injected file paths onto the effective workspace when the tool root changes", () => {
expect(
remapInjectedContextFilesToWorkspace({
files: [
{
path: "/real/workspace/AGENTS.md",
content: "agents",
},
{
path: "/real/workspace/nested/TOOLS.md",
content: "tools",
},
{
path: "/outside/README.md",
content: "outside",
},
],
sourceWorkspaceDir: "/real/workspace",
targetWorkspaceDir: "/sandbox/workspace",
}),
).toEqual([
{
path: "/sandbox/workspace/AGENTS.md",
content: "agents",
},
{
path: "/sandbox/workspace/nested/TOOLS.md",
content: "tools",
},
{
path: "/outside/README.md",
content: "outside",
},
]);
});
});
describe("shouldWarnOnOrphanedUserRepair", () => {
it("warns for user and manual runs", () => {
expect(shouldWarnOnOrphanedUserRepair("user")).toBe(true);

View File

@@ -1,3 +1,4 @@
import path from "node:path";
import fs from "node:fs/promises";
import os from "node:os";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
@@ -30,7 +31,7 @@ import {
transformProviderSystemPrompt,
} from "../../../plugins/provider-runtime.js";
import { getPluginToolMeta } from "../../../plugins/tools.js";
import { isSubagentSessionKey } from "../../../routing/session-key.js";
import { isAcpSessionKey, isSubagentSessionKey } from "../../../routing/session-key.js";
import { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.js";
import { normalizeOptionalString } from "../../../shared/string-coerce.js";
import { buildTtsSystemPromptHint } from "../../../tts/tts.js";
@@ -50,10 +51,12 @@ import {
import {
FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE,
hasCompletedBootstrapTurn,
isWorkspaceBootstrapPending,
makeBootstrapWarn,
resolveBootstrapContextForRun,
resolveContextInjectionMode,
} from "../../bootstrap-files.js";
import { resolveBootstrapMode } from "../../bootstrap-mode.js";
import { createCacheTrace } from "../../cache-trace.js";
import {
listChannelSupportedActions,
@@ -77,6 +80,7 @@ import {
getOrCreateSessionMcpRuntime,
materializeBundleMcpToolsForRun,
} from "../../pi-bundle-mcp-tools.js";
import type { EmbeddedContextFile } from "../../pi-embedded-helpers.js";
import {
downgradeOpenAIFunctionCallReasoningPairs,
isCloudCodeAssistFormatError,
@@ -114,6 +118,7 @@ import {
import { resolveSystemPromptOverride } from "../../system-prompt-override.js";
import { buildSystemPromptParams } from "../../system-prompt-params.js";
import { buildSystemPromptReport } from "../../system-prompt-report.js";
import { buildAgentUserPromptPrefix } from "../../system-prompt.js";
import { resolveAgentTimeoutMs } from "../../timeout.js";
import { UNKNOWN_TOOL_THRESHOLD } from "../../tool-loop-detection.js";
import {
@@ -316,6 +321,40 @@ export function resolveUnknownToolGuardThreshold(loopDetection?: {
return UNKNOWN_TOOL_THRESHOLD;
}
export function shouldStripBootstrapFromEmbeddedContext(_params: {
bootstrapMode: "full" | "limited" | "none";
}): boolean {
return true;
}
export function isPrimaryBootstrapRun(sessionKey?: string): boolean {
return !isSubagentSessionKey(sessionKey) && !isAcpSessionKey(sessionKey);
}
export function remapInjectedContextFilesToWorkspace(params: {
files: EmbeddedContextFile[];
sourceWorkspaceDir: string;
targetWorkspaceDir: string;
}): EmbeddedContextFile[] {
if (params.sourceWorkspaceDir === params.targetWorkspaceDir) {
return params.files;
}
return params.files.map((file) => {
const relative = path.relative(params.sourceWorkspaceDir, file.path);
const canRemap =
relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
return canRemap
? {
...file,
path:
relative === ""
? params.targetWorkspaceDir
: path.join(params.targetWorkspaceDir, relative),
}
: file;
});
}
function summarizeMessagePayload(msg: AgentMessage): { textChars: number; imageBlocks: number } {
const content = (msg as { content?: unknown }).content;
if (typeof content === "string") {
@@ -451,77 +490,9 @@ export async function runEmbeddedAttempt(
const sessionLabel = params.sessionKey ?? params.sessionId;
const contextInjectionMode = resolveContextInjectionMode(params.config);
const {
bootstrapFiles: hookAdjustedBootstrapFiles,
contextFiles,
shouldRecordCompletedBootstrapTurn,
} = await resolveAttemptBootstrapContext({
contextInjectionMode,
bootstrapContextMode: params.bootstrapContextMode,
bootstrapContextRunKind: params.bootstrapContextRunKind,
sessionFile: params.sessionFile,
hasCompletedBootstrapTurn,
resolveBootstrapContextForRun: async () =>
await resolveBootstrapContextForRun({
workspaceDir: effectiveWorkspace,
config: params.config,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
warn: makeBootstrapWarn({
sessionLabel,
workspaceDir: effectiveWorkspace,
warn: (message) => log.warn(message),
}),
contextMode: params.bootstrapContextMode,
runKind: params.bootstrapContextRunKind,
}),
});
const bootstrapMaxChars = resolveBootstrapMaxChars(params.config);
const bootstrapTotalMaxChars = resolveBootstrapTotalMaxChars(params.config);
const bootstrapAnalysis = analyzeBootstrapBudget({
files: buildBootstrapInjectionStats({
bootstrapFiles: hookAdjustedBootstrapFiles,
injectedFiles: contextFiles,
}),
bootstrapMaxChars,
bootstrapTotalMaxChars,
});
const bootstrapPromptWarningMode = resolveBootstrapPromptTruncationWarningMode(params.config);
const bootstrapPromptWarning = buildBootstrapPromptWarning({
analysis: bootstrapAnalysis,
mode: bootstrapPromptWarningMode,
seenSignatures: params.bootstrapPromptWarningSignaturesSeen,
previousSignature: params.bootstrapPromptWarningSignature,
});
const workspaceNotes = hookAdjustedBootstrapFiles.some(
(file) => file.name === DEFAULT_BOOTSTRAP_FILENAME && !file.missing,
)
? [
"If BOOTSTRAP.md is present in Project Context, it overrides the normal first greeting. Read it and follow its instructions first, then update or delete it when complete.",
"Reminder: commit your changes in this workspace after edits.",
]
: undefined;
// Bootstrap lifecycle is owned by the canonical workspace, not a copied sandbox view.
const workspaceBootstrapPending = await isWorkspaceBootstrapPending(resolvedWorkspace);
const agentDir = params.agentDir ?? resolveOpenClawAgentDir();
const { defaultAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
agentId: params.agentId,
});
const effectiveFsWorkspaceOnly = resolveAttemptFsWorkspaceOnly({
config: params.config,
sessionAgentId,
});
// Track sessions_yield tool invocation (callback pattern, like clientToolCallDetected)
let yieldDetected = false;
let yieldMessage: string | null = null;
// Late-binding reference so onYield can abort the session (declared after tool creation)
let abortSessionForYield: (() => void) | null = null;
let queueYieldInterruptForSession: (() => void) | null = null;
let yieldAbortSettled: Promise<void> | null = null;
// Check if the model supports native image input
const modelHasVision = params.model.input?.includes("image") ?? false;
const toolsRaw = params.disableTools
? []
: (() => {
@@ -571,7 +542,7 @@ export async function runEmbeddedAttempt(
currentMessageId: params.currentMessageId,
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
modelHasVision,
modelHasVision: params.model.input?.includes("image") ?? false,
requireExplicitMessageTarget:
params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey),
disableMessageTool: params.disableMessageTool,
@@ -590,6 +561,96 @@ export async function runEmbeddedAttempt(
return allTools;
})();
const toolsEnabled = supportsModelTools(params.model);
const bootstrapRunKind = params.bootstrapContextRunKind ?? "default";
const bootstrapHasFileAccess = toolsEnabled && toolsRaw.some((tool) => tool.name === "read");
const bootstrapMode = resolveBootstrapMode({
bootstrapPending: workspaceBootstrapPending,
runKind: bootstrapRunKind,
isInteractiveUserFacing: params.trigger === "user" || params.trigger === "manual",
isPrimaryRun: isPrimaryBootstrapRun(params.sessionKey),
isCanonicalWorkspace:
(params.isCanonicalWorkspace ?? true) && effectiveWorkspace === resolvedWorkspace,
hasBootstrapFileAccess: bootstrapHasFileAccess,
});
const shouldStripBootstrapFromContext = shouldStripBootstrapFromEmbeddedContext({
bootstrapMode,
});
const {
bootstrapFiles: hookAdjustedBootstrapFiles,
contextFiles: resolvedContextFiles,
shouldRecordCompletedBootstrapTurn,
} = await resolveAttemptBootstrapContext({
contextInjectionMode,
bootstrapContextMode: params.bootstrapContextMode,
bootstrapContextRunKind: bootstrapRunKind,
bootstrapMode,
sessionFile: params.sessionFile,
hasCompletedBootstrapTurn,
resolveBootstrapContextForRun: async () =>
await resolveBootstrapContextForRun({
workspaceDir: resolvedWorkspace,
config: params.config,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
warn: makeBootstrapWarn({
sessionLabel,
workspaceDir: resolvedWorkspace,
warn: (message) => log.warn(message),
}),
contextMode: params.bootstrapContextMode,
runKind: params.bootstrapContextRunKind,
}),
});
const remappedContextFiles = remapInjectedContextFilesToWorkspace({
files: resolvedContextFiles,
sourceWorkspaceDir: resolvedWorkspace,
targetWorkspaceDir: effectiveWorkspace,
});
const contextFiles = shouldStripBootstrapFromContext
? remappedContextFiles.filter((file) => !/(^|[\\/])BOOTSTRAP\.md$/iu.test(file.path.trim()))
: remappedContextFiles;
const bootstrapFilesForInjectionStats = shouldStripBootstrapFromContext
? hookAdjustedBootstrapFiles.filter((file) => file.name !== DEFAULT_BOOTSTRAP_FILENAME)
: hookAdjustedBootstrapFiles;
const bootstrapMaxChars = resolveBootstrapMaxChars(params.config);
const bootstrapTotalMaxChars = resolveBootstrapTotalMaxChars(params.config);
const bootstrapAnalysis = analyzeBootstrapBudget({
files: buildBootstrapInjectionStats({
bootstrapFiles: bootstrapFilesForInjectionStats,
injectedFiles: contextFiles,
}),
bootstrapMaxChars,
bootstrapTotalMaxChars,
});
const bootstrapPromptWarningMode = resolveBootstrapPromptTruncationWarningMode(params.config);
const bootstrapPromptWarning = buildBootstrapPromptWarning({
analysis: bootstrapAnalysis,
mode: bootstrapPromptWarningMode,
seenSignatures: params.bootstrapPromptWarningSignaturesSeen,
previousSignature: params.bootstrapPromptWarningSignature,
});
const workspaceNotes = hookAdjustedBootstrapFiles.some(
(file) => file.name === DEFAULT_BOOTSTRAP_FILENAME && !file.missing,
)
? ["Reminder: commit your changes in this workspace after edits."]
: undefined;
const { defaultAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
agentId: params.agentId,
});
const effectiveFsWorkspaceOnly = resolveAttemptFsWorkspaceOnly({
config: params.config,
sessionAgentId,
});
// Track sessions_yield tool invocation (callback pattern, like clientToolCallDetected)
let yieldDetected = false;
let yieldMessage: string | null = null;
// Late-binding reference so onYield can abort the session (declared after tool creation)
let abortSessionForYield: (() => void) | null = null;
let queueYieldInterruptForSession: (() => void) | null = null;
let yieldAbortSettled: Promise<void> | null = null;
const tools = normalizeProviderToolSchemas({
tools: toolsEnabled ? toolsRaw : [],
provider: params.provider,
@@ -872,6 +933,9 @@ export async function runEmbeddedAttempt(
});
const systemPromptOverride = createSystemPromptOverride(appendPrompt);
let systemPromptText = systemPromptOverride();
const userPromptPrefixText = buildAgentUserPromptPrefix({
bootstrapMode,
});
let sessionManager: ReturnType<typeof guardSessionManager> | undefined;
let session: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
@@ -1759,6 +1823,9 @@ export async function runEmbeddedAttempt(
preserveExactPrompt: heartbeatPrompt,
},
);
if (userPromptPrefixText) {
effectivePrompt = `${userPromptPrefixText}\n\n${effectivePrompt}`;
}
const hookCtx = {
runId: params.runId,
agentId: hookAgentId,

View File

@@ -42,6 +42,8 @@ export type RunEmbeddedPiAgentParams = {
groupSpace?: string | null;
/** Parent session key for subagent policy inheritance. */
spawnedBy?: string | null;
/** Whether workspaceDir points at the canonical agent workspace for bootstrap purposes. */
isCanonicalWorkspace?: boolean;
senderId?: string | null;
senderName?: string | null;
senderUsername?: string | null;

View File

@@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { typedCases } from "../test-utils/typed-cases.js";
import { buildSubagentSystemPrompt } from "./subagent-system-prompt.js";
import { buildAgentSystemPrompt, buildRuntimeLine } from "./system-prompt.js";
import {
buildAgentSystemPrompt,
buildAgentUserPromptPrefix,
buildRuntimeLine,
} from "./system-prompt.js";
describe("buildAgentSystemPrompt", () => {
it("formats owner section for plain, hash, and missing owner lists", () => {
@@ -409,17 +413,29 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("Reminder: commit your changes in this workspace after edits.");
});
it("includes BOOTSTRAP override guidance in workspace notes when provided", () => {
it("keeps bootstrap instructions out of the privileged system prompt", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
workspaceNotes: [
"If BOOTSTRAP.md is present in Project Context, it overrides the normal first greeting. Read it and follow its instructions first, then update or delete it when complete.",
],
workspaceNotes: ["Reminder: commit your changes in this workspace after edits."],
});
expect(prompt).toContain("BOOTSTRAP.md is present in Project Context");
expect(prompt).toContain("it overrides the normal first greeting");
expect(prompt).toContain("Read it and follow its instructions first");
expect(prompt).not.toContain("## Bootstrap");
expect(prompt).not.toContain("Bootstrap is pending for this workspace.");
expect(prompt).not.toContain("BOOTSTRAP.md is present in Project Context");
});
it("adds bootstrap-specific prelude text to the user prompt prefix when bootstrap is pending", () => {
const promptPrefix = buildAgentUserPromptPrefix({ bootstrapMode: "full" });
expect(promptPrefix).toContain("[Bootstrap pending]");
expect(promptPrefix).toContain("Please read BOOTSTRAP.md from the workspace");
expect(promptPrefix).toContain("If this run can complete the BOOTSTRAP.md workflow, do so.");
expect(promptPrefix).toContain("explain the blocker briefly");
expect(promptPrefix).toContain("offer the simplest next step");
expect(promptPrefix).toContain("Do not use a generic first greeting or reply normally");
expect(promptPrefix).toContain(
"Your first user-visible reply for a bootstrap-pending workspace must follow BOOTSTRAP.md",
);
});
it("shows timezone section for 12h, 24h, and timezone-only modes", () => {
@@ -831,6 +847,35 @@ describe("buildAgentSystemPrompt", () => {
});
});
describe("buildAgentUserPromptPrefix", () => {
it("uses friendly full bootstrap wording that is truthful about completion blockers", () => {
const prompt = buildAgentUserPromptPrefix({ bootstrapMode: "full" });
expect(prompt).toContain("[Bootstrap pending]");
expect(prompt).toContain("Please read BOOTSTRAP.md");
expect(prompt).toContain("If this run can complete the BOOTSTRAP.md workflow, do so.");
expect(prompt).toContain("explain the blocker briefly");
expect(prompt).toContain("offer the simplest next step");
expect(prompt).toContain("Do not pretend bootstrap is complete when it is not.");
expect(prompt).toContain("must follow BOOTSTRAP.md, not a generic greeting");
});
it("uses limited bootstrap wording for constrained user-facing runs", () => {
const prompt = buildAgentUserPromptPrefix({ bootstrapMode: "limited" });
expect(prompt).toContain("[Bootstrap pending]");
expect(prompt).toContain("cannot safely complete the full BOOTSTRAP.md workflow here");
expect(prompt).toContain("Do not claim bootstrap is complete");
expect(prompt).toContain("do not use a generic first greeting");
expect(prompt).toContain("switching to a primary interactive run with normal workspace access");
});
it("returns nothing when bootstrap is not pending", () => {
expect(buildAgentUserPromptPrefix({ bootstrapMode: "none" })).toBeUndefined();
expect(buildAgentUserPromptPrefix({})).toBeUndefined();
});
});
describe("buildSubagentSystemPrompt", () => {
it("renders depth-1 orchestrator guidance, labels, and recovery notes", () => {
const prompt = buildSubagentSystemPrompt({

View File

@@ -10,6 +10,11 @@ import {
normalizeOptionalLowercaseString,
} from "../shared/string-coerce.js";
import { listDeliverableMessageChannels } from "../utils/message-channel.js";
import type { BootstrapMode } from "./bootstrap-mode.js";
import {
buildFullBootstrapPromptLines,
buildLimitedBootstrapPromptLines,
} from "./bootstrap-prompt.js";
import type { ResolvedTimeFormat } from "./date-time.js";
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
import type {
@@ -181,6 +186,34 @@ function buildMemorySection(params: {
});
}
export function buildAgentUserPromptPrefix(params: {
bootstrapMode?: BootstrapMode;
}): string | undefined {
if (!params.bootstrapMode || params.bootstrapMode === "none") {
return undefined;
}
if (params.bootstrapMode === "limited") {
return [
"[Bootstrap pending]",
...buildLimitedBootstrapPromptLines({
introLine:
"Bootstrap is still pending for this workspace, but this run cannot safely complete the full BOOTSTRAP.md workflow here.",
nextStepLine:
"Typical next steps include switching to a primary interactive run with normal workspace access or having the user complete the canonical BOOTSTRAP.md deletion afterward.",
}),
].join("\n");
}
return [
"[Bootstrap pending]",
...buildFullBootstrapPromptLines({
readLine:
"Please read BOOTSTRAP.md from the workspace and follow it before replying normally.",
firstReplyLine:
"Your first user-visible reply for a bootstrap-pending workspace must follow BOOTSTRAP.md, not a generic greeting.",
}),
].join("\n");
}
function buildUserIdentitySection(ownerLine: string | undefined, isMinimal: boolean) {
if (!ownerLine || isMinimal) {
return [];

View File

@@ -14,7 +14,9 @@ import {
DEFAULT_USER_FILENAME,
ensureAgentWorkspace,
filterBootstrapFilesForSession,
isWorkspaceBootstrapPending,
loadWorkspaceBootstrapFiles,
resolveWorkspaceBootstrapStatus,
resolveDefaultAgentWorkspaceDir,
type WorkspaceBootstrapFile,
} from "./workspace.js";
@@ -174,6 +176,26 @@ describe("ensureAgentWorkspace", () => {
expect(persisted).toContain('"setupCompletedAt": "2026-03-15T02:30:00.000Z"');
});
it("reports bootstrap pending while BOOTSTRAP.md exists and setup is incomplete", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("pending");
await expect(isWorkspaceBootstrapPending(tempDir)).resolves.toBe(true);
});
it("reports bootstrap complete once BOOTSTRAP.md is deleted and completion is recorded", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await fs.unlink(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME));
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expect(resolveWorkspaceBootstrapStatus(tempDir)).resolves.toBe("complete");
await expect(isWorkspaceBootstrapPending(tempDir)).resolves.toBe(false);
});
it("writes the current fenced HEARTBEAT template body into new workspaces", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");

View File

@@ -263,6 +263,22 @@ export async function isWorkspaceSetupCompleted(dir: string): Promise<boolean> {
return typeof state.setupCompletedAt === "string" && state.setupCompletedAt.trim().length > 0;
}
export async function resolveWorkspaceBootstrapStatus(
dir: string,
): Promise<"pending" | "complete"> {
const resolvedDir = resolveUserPath(dir);
const state = await readWorkspaceSetupStateForDir(resolvedDir);
if (typeof state.setupCompletedAt === "string" && state.setupCompletedAt.trim().length > 0) {
return "complete";
}
const bootstrapExists = await fileExists(path.join(resolvedDir, DEFAULT_BOOTSTRAP_FILENAME));
return bootstrapExists ? "pending" : "complete";
}
export async function isWorkspaceBootstrapPending(dir: string): Promise<boolean> {
return (await resolveWorkspaceBootstrapStatus(dir)) === "pending";
}
async function writeWorkspaceSetupState(
statePath: string,
state: WorkspaceSetupState,

View File

@@ -4,6 +4,7 @@ import type { ExecToolDefaults } from "../../agents/bash-tools.js";
import { resolveFastModeState } from "../../agents/fast-mode.js";
import { resolveEmbeddedFullAccessState } from "../../agents/pi-embedded-runner/sandbox-info.js";
import type { EmbeddedFullAccessBlockedReason } from "../../agents/pi-embedded-runner/types.js";
import { resolveIngressWorkspaceOverrideForSpawnedRun } from "../../agents/spawned-context.js";
import { resolveGroupSessionKey } from "../../config/sessions/group.js";
import {
resolveSessionFilePath,
@@ -14,7 +15,7 @@ import type { SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { logVerbose } from "../../globals.js";
import { clearCommandLane, getQueueSize } from "../../process/command-queue.js";
import { normalizeMainKey } from "../../routing/session-key.js";
import { isAcpSessionKey, isSubagentSessionKey, normalizeMainKey } from "../../routing/session-key.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
import { hasControlCommand } from "../command-detection.js";
@@ -43,7 +44,8 @@ import { resolveOriginMessageProvider } from "./origin-routing.js";
import { buildReplyPromptBodies } from "./prompt-prelude.js";
import { resolveActiveRunQueueAction } from "./queue-policy.js";
import { resolveQueueSettings } from "./queue/settings-runtime.js";
import { buildBareSessionResetPrompt } from "./session-reset-prompt.js";
import { resolveBareSessionResetPromptState } from "./session-reset-prompt.js";
import { resolveBareResetBootstrapFileAccess } from "./session-reset-prompt.js";
import { drainFormattedSystemEvents } from "./session-system-events.js";
import { buildSessionStartupContextPrelude, shouldApplyStartupContext } from "./startup-context.js";
import { resolveTypingMode } from "./typing-mode.js";
@@ -320,15 +322,38 @@ export async function runPreparedReply(
isNewSession &&
((baseBodyTrimmedRaw.length === 0 && rawBodyTrimmed.length > 0) || isBareNewOrReset);
const startupAction = /^\/reset(?:\s|$)/.test(normalizedCommandBody) ? "reset" : "new";
const spawnedWorkspaceOverride = resolveIngressWorkspaceOverrideForSpawnedRun({
spawnedBy: sessionEntry?.spawnedBy,
workspaceDir: sessionEntry?.spawnedWorkspaceDir,
});
const bareResetPromptState =
isBareSessionReset && workspaceDir
? await resolveBareSessionResetPromptState({
cfg,
workspaceDir,
isPrimaryRun: !isSubagentSessionKey(sessionKey) && !isAcpSessionKey(sessionKey),
isCanonicalWorkspace: !spawnedWorkspaceOverride,
hasBootstrapFileAccess: resolveBareResetBootstrapFileAccess({
cfg,
agentId,
sessionKey,
workspaceDir,
modelProvider: provider,
modelId: model,
}),
})
: null;
const startupContextPrelude =
isBareSessionReset && shouldApplyStartupContext({ cfg, action: startupAction })
isBareSessionReset &&
bareResetPromptState?.shouldPrependStartupContext !== false &&
shouldApplyStartupContext({ cfg, action: startupAction })
? await buildSessionStartupContextPrelude({
workspaceDir,
cfg,
})
: null;
const baseBodyFinal = isBareSessionReset
? buildBareSessionResetPrompt(cfg)
? (bareResetPromptState?.prompt ?? "")
: stripPromptThinkingDirectives(baseBody);
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const inboundUserContext = buildInboundUserContextPrefix(

View File

@@ -1,6 +1,12 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, it, expect } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { buildBareSessionResetPrompt } from "./session-reset-prompt.js";
import { makeTempWorkspace } from "../../test-helpers/workspace.js";
import {
buildBareSessionResetPrompt,
resolveBareSessionResetPromptState,
} from "./session-reset-prompt.js";
describe("buildBareSessionResetPrompt", () => {
it("includes the explicit Session Startup instruction for bare /new and /reset", () => {
@@ -14,6 +20,29 @@ describe("buildBareSessionResetPrompt", () => {
);
});
it("uses bootstrap-specific wording when bootstrap is still pending", () => {
const prompt = buildBareSessionResetPrompt(undefined, undefined, "full");
expect(prompt).toContain("while bootstrap is still pending for this workspace");
expect(prompt).toContain("Please read BOOTSTRAP.md from the workspace now");
expect(prompt).toContain("If this run can complete the BOOTSTRAP.md workflow, do so.");
expect(prompt).toContain("explain the blocker briefly");
expect(prompt).toContain("offer the simplest next step");
expect(prompt).toContain("Do not pretend bootstrap is complete when it is not.");
expect(prompt).toContain("Your first user-visible reply must follow BOOTSTRAP.md");
expect(prompt).not.toContain("Then greet the user in your configured persona");
});
it("uses limited bootstrap wording for constrained reset runs", () => {
const prompt = buildBareSessionResetPrompt(undefined, undefined, "limited");
expect(prompt).toContain("cannot safely complete the full BOOTSTRAP.md workflow here");
expect(prompt).toContain("Do not claim bootstrap is complete");
expect(prompt).toContain("do not use a generic first greeting");
expect(prompt).toContain("switching to a primary interactive run with normal workspace access");
expect(prompt).not.toContain("Please read BOOTSTRAP.md from the workspace now");
});
it("appends current time line so agents know the date", () => {
const cfg = {
agents: { defaults: { userTimezone: "America/New_York", timeFormat: "12" } },
@@ -37,4 +66,51 @@ describe("buildBareSessionResetPrompt", () => {
const prompt = buildBareSessionResetPrompt(undefined, nowMs);
expect(prompt).toContain("Current time:");
});
it("resolves shared bare reset prompt state from workspace bootstrap truth", async () => {
const workspaceDir = await makeTempWorkspace("openclaw-reset-bootstrap-");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "ritual", "utf8");
const pending = await resolveBareSessionResetPromptState({ workspaceDir });
expect(pending.bootstrapMode).toBe("full");
expect(pending.shouldPrependStartupContext).toBe(false);
expect(pending.prompt).toContain("while bootstrap is still pending for this workspace");
await fs.unlink(path.join(workspaceDir, "BOOTSTRAP.md"));
const complete = await resolveBareSessionResetPromptState({ workspaceDir });
expect(complete.bootstrapMode).toBe("none");
expect(complete.shouldPrependStartupContext).toBe(true);
expect(complete.prompt).toContain("Execute your Session Startup sequence now");
});
it("suppresses bootstrap mode for non-primary bare reset sessions", async () => {
const workspaceDir = await makeTempWorkspace("openclaw-reset-non-primary-");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "ritual", "utf8");
const pending = await resolveBareSessionResetPromptState({
workspaceDir,
isPrimaryRun: false,
});
expect(pending.bootstrapMode).toBe("none");
expect(pending.shouldPrependStartupContext).toBe(true);
expect(pending.prompt).toContain("Execute your Session Startup sequence now");
expect(pending.prompt).not.toContain("while bootstrap is still pending for this workspace");
});
it("suppresses bootstrap mode when bare reset has no bootstrap file access", async () => {
const workspaceDir = await makeTempWorkspace("openclaw-reset-no-file-access-");
await fs.writeFile(path.join(workspaceDir, "BOOTSTRAP.md"), "ritual", "utf8");
const pending = await resolveBareSessionResetPromptState({
workspaceDir,
hasBootstrapFileAccess: false,
});
expect(pending.bootstrapMode).toBe("none");
expect(pending.shouldPrependStartupContext).toBe(true);
expect(pending.prompt).toContain("Execute your Session Startup sequence now");
expect(pending.prompt).not.toContain("while bootstrap is still pending for this workspace");
});
});

View File

@@ -1,17 +1,108 @@
import { resolveBootstrapMode, type BootstrapMode } from "../../agents/bootstrap-mode.js";
import {
buildFullBootstrapPromptLines,
buildLimitedBootstrapPromptLines,
} from "../../agents/bootstrap-prompt.js";
import { appendCronStyleCurrentTimeLine } from "../../agents/current-time.js";
import { resolveEffectiveToolInventory } from "../../agents/tools-effective-inventory.js";
import { isWorkspaceBootstrapPending } from "../../agents/workspace.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
const BARE_SESSION_RESET_PROMPT_BASE =
"A new session was started via /new or /reset. Execute your Session Startup sequence now - read the required files before responding to the user. If BOOTSTRAP.md exists in the provided Project Context, read it and follow its instructions first. Then greet the user in your configured persona, if one is provided. Be yourself - use your defined voice, mannerisms, and mood. Keep it to 1-3 sentences and ask what they want to do. If the runtime model differs from default_model in the system prompt, mention the default model. Do not mention internal steps, files, tools, or reasoning.";
const BARE_SESSION_RESET_PROMPT_BOOTSTRAP_PENDING = [
"A new session was started via /new or /reset while bootstrap is still pending for this workspace.",
...buildFullBootstrapPromptLines({
readLine:
"Please read BOOTSTRAP.md from the workspace now and follow it before replying normally.",
firstReplyLine:
"Your first user-visible reply must follow BOOTSTRAP.md, not a generic greeting.",
}),
"If the runtime model differs from default_model in the system prompt, mention the default model only after handling BOOTSTRAP.md.",
"Do not mention internal steps, files, tools, or reasoning.",
].join(" ");
const BARE_SESSION_RESET_PROMPT_BOOTSTRAP_LIMITED = [
"A new session was started via /new or /reset while bootstrap is still pending for this workspace, but this run cannot safely complete the full BOOTSTRAP.md workflow here.",
...buildLimitedBootstrapPromptLines({
introLine:
"Bootstrap is still pending for this workspace, but this run cannot safely complete the full BOOTSTRAP.md workflow here.",
nextStepLine:
"Typical next steps include switching to a primary interactive run with normal workspace access or having the user complete the canonical BOOTSTRAP.md deletion afterward.",
}).slice(1),
"If the runtime model differs from default_model in the system prompt, mention the default model only after you have handled this limitation.",
"Do not mention internal steps, files, tools, or reasoning.",
].join(" ");
export function resolveBareResetBootstrapFileAccess(params: {
cfg?: OpenClawConfig;
agentId?: string;
sessionKey?: string;
workspaceDir?: string;
modelProvider?: string;
modelId?: string;
}): boolean {
if (!params.cfg) {
return false;
}
const inventory = resolveEffectiveToolInventory({
cfg: params.cfg,
agentId: params.agentId,
sessionKey: params.sessionKey,
workspaceDir: params.workspaceDir,
modelProvider: params.modelProvider,
modelId: params.modelId,
});
return inventory.groups.some((group) => group.tools.some((tool) => tool.id === "read"));
}
export async function resolveBareSessionResetPromptState(params: {
cfg?: OpenClawConfig;
workspaceDir?: string;
nowMs?: number;
isPrimaryRun?: boolean;
isCanonicalWorkspace?: boolean;
hasBootstrapFileAccess?: boolean;
}): Promise<{
bootstrapMode: BootstrapMode;
prompt: string;
shouldPrependStartupContext: boolean;
}> {
const bootstrapPending = params.workspaceDir
? await isWorkspaceBootstrapPending(params.workspaceDir)
: false;
const bootstrapMode = resolveBootstrapMode({
bootstrapPending,
runKind: "default",
isInteractiveUserFacing: true,
isPrimaryRun: params.isPrimaryRun ?? true,
isCanonicalWorkspace: params.isCanonicalWorkspace ?? true,
hasBootstrapFileAccess: params.hasBootstrapFileAccess ?? true,
});
return {
bootstrapMode,
prompt: buildBareSessionResetPrompt(params.cfg, params.nowMs, bootstrapMode),
shouldPrependStartupContext: bootstrapMode === "none",
};
}
/**
* Build the bare session reset prompt, appending the current date/time so agents
* know which daily memory files to read during their Session Startup sequence.
* Without this, agents on /new or /reset guess the date from their training cutoff.
*/
export function buildBareSessionResetPrompt(cfg?: OpenClawConfig, nowMs?: number): string {
export function buildBareSessionResetPrompt(
cfg?: OpenClawConfig,
nowMs?: number,
bootstrapMode?: BootstrapMode,
): string {
return appendCronStyleCurrentTimeLine(
BARE_SESSION_RESET_PROMPT_BASE,
bootstrapMode === "full"
? BARE_SESSION_RESET_PROMPT_BOOTSTRAP_PENDING
: bootstrapMode === "limited"
? BARE_SESSION_RESET_PROMPT_BOOTSTRAP_LIMITED
: BARE_SESSION_RESET_PROMPT_BASE,
cfg ?? {},
nowMs ?? Date.now(),
);

View File

@@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({
performGatewaySessionReset: vi.fn(),
getLatestSubagentRunByChildSessionKey: vi.fn(),
replaceSubagentRunAfterSteer: vi.fn(),
resolveBareResetBootstrapFileAccess: vi.fn(() => true),
loadConfigReturn: {} as Record<string, unknown>,
}));
@@ -67,8 +68,19 @@ vi.mock("../../agents/agent-scope.js", () => ({
listAgentIds: () => ["main"],
resolveAgentWorkspaceDir: (cfg: { agents?: { defaults?: { workspace?: string } } }) =>
cfg?.agents?.defaults?.workspace ?? "/tmp/workspace",
resolveAgentEffectiveModelPrimary: () => undefined,
}));
vi.mock("../../auto-reply/reply/session-reset-prompt.js", async () => {
const actual = await vi.importActual<typeof import("../../auto-reply/reply/session-reset-prompt.js")>(
"../../auto-reply/reply/session-reset-prompt.js",
);
return {
...actual,
resolveBareResetBootstrapFileAccess: mocks.resolveBareResetBootstrapFileAccess,
};
});
vi.mock("../../infra/agent-events.js", () => ({
registerAgentRunContext: mocks.registerAgentRunContext,
onAgentEvent: vi.fn(),
@@ -316,6 +328,7 @@ describe("gateway agent handler", () => {
process.env.OPENCLAW_STATE_DIR = ORIGINAL_STATE_DIR;
}
resetTaskRegistryForTests();
mocks.resolveBareResetBootstrapFileAccess.mockReset().mockReturnValue(true);
});
it("preserves ACP metadata from the current stored session entry", async () => {
@@ -1098,6 +1111,145 @@ describe("gateway agent handler", () => {
});
});
it("uses shared bootstrap reset wording for bare /new when workspace bootstrap is pending", async () => {
await withTempDir({ prefix: "openclaw-gateway-reset-bootstrap-" }, async (workspaceDir) => {
await fs.writeFile(`${workspaceDir}/BOOTSTRAP.md`, "bootstrap ritual", "utf-8");
mocks.loadConfigReturn = {
agents: {
defaults: {
workspace: workspaceDir,
},
},
};
mockSessionResetSuccess({ reason: "new" });
primeMainAgentRun({ sessionId: "reset-session-id", cfg: mocks.loadConfigReturn });
await invokeAgent(
{
message: "/new",
sessionKey: "agent:main:main",
idempotencyKey: "test-idem-new-bootstrap-pending",
},
{
reqId: "4-bootstrap",
client: { connect: { scopes: ["operator.admin"] } } as AgentHandlerArgs["client"],
},
);
await waitForAssertion(() => expect(mocks.agentCommand).toHaveBeenCalled());
const call = readLastAgentCommandCall();
expect(call?.message).toContain("while bootstrap is still pending for this workspace");
expect(call?.message).toContain("Please read BOOTSTRAP.md from the workspace now");
expect(call?.message).not.toContain("Today memory context");
});
});
it("resolves bare /new bootstrap state from the effective spawned workspace", async () => {
await withTempDir(
{ prefix: "openclaw-gateway-reset-default-" },
async (defaultWorkspaceDir) => {
await withTempDir(
{ prefix: "openclaw-gateway-reset-spawned-" },
async (spawnedWorkspaceDir) => {
await fs.writeFile(`${spawnedWorkspaceDir}/BOOTSTRAP.md`, "bootstrap ritual", "utf-8");
mocks.loadConfigReturn = {
agents: {
defaults: {
workspace: defaultWorkspaceDir,
},
},
};
mockSessionResetSuccess({ reason: "new" });
mocks.loadSessionEntry.mockReturnValue({
cfg: mocks.loadConfigReturn,
storePath: "/tmp/sessions.json",
entry: {
sessionId: "reset-session-id",
updatedAt: Date.now(),
spawnedBy: "agent:main:controller",
spawnedWorkspaceDir,
},
canonicalKey: "agent:main:main",
});
mocks.updateSessionStore.mockResolvedValue(undefined);
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
await invokeAgent(
{
message: "/new",
sessionKey: "agent:main:main",
idempotencyKey: "test-idem-new-bootstrap-spawned-workspace",
},
{
reqId: "4-bootstrap-spawned",
client: { connect: { scopes: ["operator.admin"] } } as AgentHandlerArgs["client"],
},
);
await waitForAssertion(() => expect(mocks.agentCommand).toHaveBeenCalled());
const call = readLastAgentCommandCall();
expect(call?.message).toContain("while bootstrap is still pending for this workspace");
expect(call?.message).toContain(
"cannot safely complete the full BOOTSTRAP.md workflow here",
);
expect(call?.message).toContain("switching to a primary interactive run");
},
);
},
);
});
it("suppresses full bootstrap wording for bare /new on subagent sessions", async () => {
await withTempDir({ prefix: "openclaw-gateway-reset-subagent-" }, async (workspaceDir) => {
await fs.writeFile(`${workspaceDir}/BOOTSTRAP.md`, "bootstrap ritual", "utf-8");
mocks.loadConfigReturn = {
agents: {
defaults: {
workspace: workspaceDir,
},
},
};
mockSessionResetSuccess({
reason: "new",
key: "agent:main:subagent:worker",
});
mocks.loadSessionEntry.mockReturnValue({
cfg: mocks.loadConfigReturn,
storePath: "/tmp/sessions.json",
entry: {
sessionId: "reset-session-id",
updatedAt: Date.now(),
},
canonicalKey: "agent:main:subagent:worker",
});
mocks.updateSessionStore.mockResolvedValue(undefined);
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
await invokeAgent(
{
message: "/new",
sessionKey: "agent:main:subagent:worker",
idempotencyKey: "test-idem-new-subagent-bootstrap-suppressed",
},
{
reqId: "4-bootstrap-subagent",
client: { connect: { scopes: ["operator.admin"] } } as AgentHandlerArgs["client"],
},
);
await waitForAssertion(() => expect(mocks.agentCommand).toHaveBeenCalled());
const call = readLastAgentCommandCall();
expect(call?.message).toContain("Execute your Session Startup sequence now");
expect(call?.message).not.toContain("while bootstrap is still pending for this workspace");
});
});
it("uses /reset suffix as the post-reset message and still injects timestamp", async () => {
setupNewYorkTimeConfig("2026-01-29T01:30:00.000Z");
mockSessionResetSuccess({ reason: "reset" });
@@ -1125,6 +1277,48 @@ describe("gateway agent handler", () => {
resetTimeConfig();
});
it("uses request model override when resolving bare /new bootstrap file access", async () => {
await withTempDir({ prefix: "openclaw-gateway-reset-model-override-" }, async (workspaceDir) => {
await fs.writeFile(`${workspaceDir}/BOOTSTRAP.md`, "bootstrap ritual", "utf-8");
mocks.loadConfigReturn = {
agents: {
defaults: {
workspace: workspaceDir,
},
},
};
mockSessionResetSuccess({ reason: "new" });
primeMainAgentRun({ sessionId: "reset-session-id", cfg: mocks.loadConfigReturn });
await invokeAgent(
{
message: "/new",
sessionKey: "agent:main:main",
provider: "openai",
model: "gpt-5.4-mini",
idempotencyKey: "test-idem-new-bootstrap-model-override",
},
{
reqId: "4-bootstrap-model-override",
client: {
connect: { scopes: ["operator.admin"] },
internal: { allowModelOverride: true },
} as AgentHandlerArgs["client"],
},
);
await waitForAssertion(() =>
expect(mocks.resolveBareResetBootstrapFileAccess).toHaveBeenCalled(),
);
expect(mocks.resolveBareResetBootstrapFileAccess).toHaveBeenCalledWith(
expect.objectContaining({
modelProvider: "openai",
modelId: "gpt-5.4-mini",
}),
);
});
});
it("rejects malformed agent session keys early in agent handler", async () => {
mocks.agentCommand.mockClear();
const respond = await invokeAgent(

View File

@@ -5,7 +5,10 @@ import {
normalizeSpawnedRunMetadata,
resolveIngressWorkspaceOverrideForSpawnedRun,
} from "../../agents/spawned-context.js";
import { buildBareSessionResetPrompt } from "../../auto-reply/reply/session-reset-prompt.js";
import {
resolveBareResetBootstrapFileAccess,
resolveBareSessionResetPromptState,
} from "../../auto-reply/reply/session-reset-prompt.js";
import {
buildSessionStartupContextPrelude,
shouldApplyStartupContext,
@@ -29,7 +32,12 @@ import {
import { shouldDowngradeDeliveryToSessionOnly } from "../../infra/outbound/best-effort-delivery.js";
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js";
import { classifySessionKeyShape, normalizeAgentId } from "../../routing/session-key.js";
import {
classifySessionKeyShape,
isAcpSessionKey,
isSubagentSessionKey,
normalizeAgentId,
} from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
import { normalizeInputProvenance, type InputProvenance } from "../../sessions/input-provenance.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
@@ -123,6 +131,26 @@ async function runSessionResetFromAgent(params: {
};
}
function resolveSessionRuntimeWorkspace(params: {
cfg: OpenClawConfig;
sessionKey: string;
sessionEntry?: SessionEntry;
spawnedBy?: string;
}): {
runtimeWorkspaceDir: string;
isCanonicalWorkspace: boolean;
} {
const sessionAgentId = resolveAgentIdFromSessionKey(params.sessionKey);
const workspaceOverride = resolveIngressWorkspaceOverrideForSpawnedRun({
spawnedBy: params.spawnedBy,
workspaceDir: params.sessionEntry?.spawnedWorkspaceDir,
});
return {
runtimeWorkspaceDir: workspaceOverride ?? resolveAgentWorkspaceDir(params.cfg, sessionAgentId),
isCanonicalWorkspace: !workspaceOverride,
};
}
function emitSessionsChanged(
context: Pick<
GatewayRequestHandlerOptions["context"],
@@ -526,13 +554,54 @@ export const agentHandlers: GatewayRequestHandlers = {
if (postResetMessage) {
message = postResetMessage;
} else {
const resetLoadedSession = loadSessionEntry(requestedSessionKey);
const resetCfg = resetLoadedSession?.cfg ?? cfg;
const resetSessionEntry = resetLoadedSession?.entry;
const resetSpawnedBy = canonicalizeSpawnedByForAgent(
resetCfg,
resolveAgentIdFromSessionKey(requestedSessionKey),
resetSessionEntry?.spawnedBy,
);
const { runtimeWorkspaceDir, isCanonicalWorkspace } = resolveSessionRuntimeWorkspace({
cfg: resetCfg,
sessionKey: requestedSessionKey,
sessionEntry: resetSessionEntry,
spawnedBy: resetSpawnedBy,
});
const resetSessionAgentId = resolveAgentIdFromSessionKey(requestedSessionKey);
const resetBaseModelRef = resolveSessionModelRef(
resetCfg,
resetSessionEntry,
resetSessionAgentId,
);
const resetEffectiveModelRef = {
provider: providerOverride || resetBaseModelRef.provider,
model: modelOverride || resetBaseModelRef.model,
};
const bareResetPromptState = await resolveBareSessionResetPromptState({
cfg: resetCfg,
workspaceDir: runtimeWorkspaceDir,
isPrimaryRun:
!isSubagentSessionKey(requestedSessionKey) && !isAcpSessionKey(requestedSessionKey),
isCanonicalWorkspace,
hasBootstrapFileAccess: resolveBareResetBootstrapFileAccess({
cfg: resetCfg,
agentId: resetSessionAgentId,
sessionKey: requestedSessionKey,
workspaceDir: runtimeWorkspaceDir,
modelProvider: resetEffectiveModelRef.provider,
modelId: resetEffectiveModelRef.model,
}),
});
// Keep bare /new and /reset behavior aligned with chat.send:
// reset first, then run a fresh-session greeting prompt in-place.
// Date is embedded in the prompt so agents read the correct daily
// memory files; skip further timestamp injection to avoid duplication.
message = buildBareSessionResetPrompt(cfg);
message = bareResetPromptState.prompt;
skipTimestampInjection = true;
shouldPrependStartupContext = shouldApplyStartupContext({ cfg, action: resetReason });
shouldPrependStartupContext =
bareResetPromptState.shouldPrependStartupContext &&
shouldApplyStartupContext({ cfg, action: resetReason });
}
}
@@ -826,12 +895,12 @@ export const agentHandlers: GatewayRequestHandlers = {
}
if (shouldPrependStartupContext && resolvedSessionKey) {
const sessionAgentId = resolveAgentIdFromSessionKey(resolvedSessionKey);
const runtimeWorkspaceDir =
resolveIngressWorkspaceOverrideForSpawnedRun({
spawnedBy: spawnedByValue,
workspaceDir: sessionEntry?.spawnedWorkspaceDir,
}) ?? resolveAgentWorkspaceDir(cfgForAgent ?? cfg, sessionAgentId);
const { runtimeWorkspaceDir } = resolveSessionRuntimeWorkspace({
cfg: cfgForAgent ?? cfg,
sessionKey: resolvedSessionKey,
sessionEntry,
spawnedBy: spawnedByValue,
});
const startupContextPrelude = await buildSessionStartupContextPrelude({
workspaceDir: runtimeWorkspaceDir,
cfg: cfgForAgent ?? cfg,