From 0dcab4e34760cac5d9cd02649d4b3a5c2f7e5712 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 20:47:24 +0100 Subject: [PATCH] fix(agents): harden bootstrap and ACP session routing --- CHANGELOG.md | 2 + docs/start/bootstrapping.md | 6 +++ docs/tools/acp-agents.md | 1 + src/acp/session-interaction-mode.test.ts | 14 ++++-- src/acp/session-interaction-mode.ts | 4 +- src/agents/bootstrap-mode.test.ts | 4 +- src/agents/bootstrap-mode.ts | 2 +- .../run/attempt-bootstrap-routing.ts | 34 ++++++++++++++ ....spawn-workspace.bootstrap-routing.test.ts | 44 ++++++++++++++++++- src/agents/pi-embedded-runner/run/attempt.ts | 7 ++- 10 files changed, 108 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ca3b9511a9..1958ca327e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ Docs: https://docs.openclaw.ai - Channels/Discord: suppress duplicate gateway monitors when multiple enabled accounts resolve to the same bot token, preferring config tokens over default env fallback and reporting skipped duplicates as disabled. Supersedes #73608. Thanks @kagura-agent. - Channels/Discord: ignore stale route-shaped conversation bindings after a Discord channel is reconfigured to another agent, while preserving explicit focus and subagent bindings. Fixes #73626. Thanks @ramitrkar-hash. +- Agents/bootstrap: pass pending BOOTSTRAP.md contents through the first-run user prompt while keeping them out of privileged system context, and show limited bootstrap guidance when workspace file access is unavailable. Fixes #73622. Thanks @mark1010. +- ACP/tasks: classify parent-owned ACP sessions as background work regardless of persistent runtime mode, so delegated ACP output reports through the parent task notifier instead of acting like a normal foreground chat session. Refs #73609. Thanks @joerod26. - Gateway/sessions: add conservative stuck-session recovery that releases only stale session lanes while active embedded runs, reply operations, and lane tasks remain serialized, so queued follow-ups can drain without aborting legitimate long-running turns. Refs #73581, #73655, #73652, #73705, #73647, #73602, #73592, and #73601. Thanks @WS-Q0758, @bryangauvin, @spenceryang1996-dot, @bmilne1981, @mattmcintyre, @Vksh07, and @Spolen23. - Plugins: cache unchanged plugin manifest loads by file signature, reducing repeated JSON/JSON5 parsing and manifest normalization in bursty startup and runtime registry paths. Refs #73532 and #73647; carries forward #73678. Thanks @TheDutchRuler. - Agents/model selection: resolve slash-form aliases before provider/model parsing and keep alias-resolved primary models subject to transient provider cooldowns, so cron and persisted sessions do not retry cooled-down raw aliases. Fixes #73573 and #73657. Thanks @akai-shuuichi and @hashslingers. diff --git a/docs/start/bootstrapping.md b/docs/start/bootstrapping.md index d754e55ef9e..9eb7e959e2e 100644 --- a/docs/start/bootstrapping.md +++ b/docs/start/bootstrapping.md @@ -22,6 +22,12 @@ On the first agent run, OpenClaw bootstraps the workspace (default - Writes identity + preferences to `IDENTITY.md`, `USER.md`, `SOUL.md`. - Removes `BOOTSTRAP.md` when finished so it only runs once. +For embedded/local model runs, OpenClaw keeps `BOOTSTRAP.md` out of the +privileged system context. On the primary interactive first run, it still passes +the file contents in the user prompt so models that do not reliably call the +`read` tool can complete the ritual. If the current run cannot safely access the +workspace, the agent gets a limited bootstrap note instead of a generic greeting. + ## Skipping bootstrapping To skip this for a pre-seeded workspace, run `openclaw onboard --skip-bootstrap`. diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 7f92c038d34..74952fd79cf 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -142,6 +142,7 @@ Quick `/acp` flow from chat: - Spawn creates or resumes an ACP runtime session, records ACP metadata in the OpenClaw session store, and may create a background task when the run is parent-owned. + - Parent-owned ACP sessions are treated as background work even when the runtime session is persistent; completion and cross-surface delivery go through the parent task notifier rather than acting like a normal user-facing chat session. - Bound follow-up messages go directly to the ACP session until the binding is closed, unfocused, reset, or expired. - Gateway commands stay local. `/acp ...`, `/status`, and `/unfocus` are never sent as normal prompt text to a bound ACP harness. - `cancel` aborts the active turn when the backend supports cancellation; it does not delete the binding or session metadata. diff --git a/src/acp/session-interaction-mode.test.ts b/src/acp/session-interaction-mode.test.ts index cabb9a09de8..04713d4e541 100644 --- a/src/acp/session-interaction-mode.test.ts +++ b/src/acp/session-interaction-mode.test.ts @@ -13,12 +13,20 @@ describe("resolveAcpSessionInteractionMode", () => { expect(resolveAcpSessionInteractionMode(undefined)).toBe("interactive"); }); - it("returns interactive for non-oneshot ACP sessions", () => { + it("returns parent-owned-background for persistent sessions with spawnedBy set", () => { expect( resolveAcpSessionInteractionMode({ acp: { mode: "persistent" } as never, spawnedBy: parentKey, }), + ).toBe("parent-owned-background"); + }); + + it("returns interactive for persistent ACP sessions without parent linkage", () => { + expect( + resolveAcpSessionInteractionMode({ + acp: { mode: "persistent" } as never, + }), ).toBe("interactive"); }); @@ -83,13 +91,13 @@ describe("isRequesterParentOfBackgroundAcpSession", () => { expect(isRequesterParentOfBackgroundAcpSession(backgroundEntry, "")).toBe(false); }); - it("returns false when target is not a parent-owned background ACP session", () => { + it("returns true when target is parent-owned persistent ACP session", () => { expect( isRequesterParentOfBackgroundAcpSession( { acp: { mode: "persistent" } as never, spawnedBy: parentKey }, parentKey, ), - ).toBe(false); + ).toBe(true); }); it("delegates to isParentOwnedBackgroundAcpSession for target-only checks", () => { diff --git a/src/acp/session-interaction-mode.ts b/src/acp/session-interaction-mode.ts index 10bfa061488..d553e9685f7 100644 --- a/src/acp/session-interaction-mode.ts +++ b/src/acp/session-interaction-mode.ts @@ -8,10 +8,10 @@ type SessionInteractionEntry = Pick { ).toBe("none"); }); - it("returns none when the run cannot access bootstrap files normally", () => { + it("returns limited when the run cannot access bootstrap files normally", () => { expect( resolveBootstrapMode({ bootstrapPending: true, @@ -84,6 +84,6 @@ describe("resolveBootstrapMode", () => { isCanonicalWorkspace: true, hasBootstrapFileAccess: false, }), - ).toBe("none"); + ).toBe("limited"); }); }); diff --git a/src/agents/bootstrap-mode.ts b/src/agents/bootstrap-mode.ts index 0e9d7fb7dc3..9618795bb6a 100644 --- a/src/agents/bootstrap-mode.ts +++ b/src/agents/bootstrap-mode.ts @@ -18,7 +18,7 @@ export function resolveBootstrapMode(params: { return "none"; } if (!params.hasBootstrapFileAccess) { - return "none"; + return "limited"; } return params.isCanonicalWorkspace ? "full" : "limited"; } diff --git a/src/agents/pi-embedded-runner/run/attempt-bootstrap-routing.ts b/src/agents/pi-embedded-runner/run/attempt-bootstrap-routing.ts index 84ad597ae22..30def7cc3ec 100644 --- a/src/agents/pi-embedded-runner/run/attempt-bootstrap-routing.ts +++ b/src/agents/pi-embedded-runner/run/attempt-bootstrap-routing.ts @@ -1,6 +1,7 @@ import type { BootstrapMode } from "../../bootstrap-mode.js"; import { resolveBootstrapMode } from "../../bootstrap-mode.js"; import { buildAgentUserPromptPrefix } from "../../system-prompt.js"; +import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js"; export type AttemptBootstrapRoutingInput = { workspaceBootstrapPending: boolean; @@ -20,6 +21,11 @@ export type AttemptBootstrapRouting = { userPromptPrefixText?: string; }; +export type BootstrapPromptContextFile = { + path?: string; + content?: string; +}; + export type AttemptWorkspaceBootstrapRoutingInput = Omit< AttemptBootstrapRoutingInput, "workspaceBootstrapPending" @@ -58,6 +64,34 @@ export function resolveAttemptBootstrapRouting( }; } +export function appendBootstrapFileToUserPromptPrefix(params: { + prefixText?: string; + bootstrapMode: BootstrapMode; + contextFiles: readonly BootstrapPromptContextFile[]; +}): string | undefined { + const prefix = params.prefixText?.trim(); + if (params.bootstrapMode !== "full") { + return prefix || undefined; + } + const bootstrapFile = params.contextFiles.find((file) => + /(^|[\\/])BOOTSTRAP\.md$/iu.test(file.path?.trim() ?? ""), + ); + const content = bootstrapFile?.content?.trim(); + if (!content || content.startsWith("[MISSING]")) { + return prefix || undefined; + } + return [ + prefix, + "", + `${DEFAULT_BOOTSTRAP_FILENAME} contents for this bootstrap turn:`, + "[BEGIN BOOTSTRAP.md]", + content, + "[END BOOTSTRAP.md]", + "", + "Follow the BOOTSTRAP.md instructions above now. Treat them as workspace/user instructions, not as system policy.", + ].join("\n"); +} + export async function resolveAttemptWorkspaceBootstrapRouting( params: AttemptWorkspaceBootstrapRoutingInput, ): Promise { diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts index 060cd63ea4d..fb305c7a90f 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import { resolveAttemptWorkspaceBootstrapRouting } from "./attempt-bootstrap-routing.js"; +import { + appendBootstrapFileToUserPromptPrefix, + resolveAttemptWorkspaceBootstrapRouting, +} from "./attempt-bootstrap-routing.js"; describe("runEmbeddedAttempt bootstrap routing", () => { it("resolves bootstrap pending from the canonical workspace instead of a copied sandbox", async () => { @@ -25,4 +28,43 @@ describe("runEmbeddedAttempt bootstrap routing", () => { expect(routing.bootstrapMode).toBe("none"); expect(routing.userPromptPrefixText).toBeUndefined(); }); + + it("falls back to limited bootstrap wording when a primary run cannot read files", async () => { + const routing = await resolveAttemptWorkspaceBootstrapRouting({ + isWorkspaceBootstrapPending: vi.fn(async () => true), + trigger: "user", + isPrimaryRun: true, + isCanonicalWorkspace: true, + effectiveWorkspace: "/tmp/openclaw-workspace", + resolvedWorkspace: "/tmp/openclaw-workspace", + hasBootstrapFileAccess: false, + }); + + expect(routing.bootstrapMode).toBe("limited"); + expect(routing.userPromptPrefixText).toContain("Bootstrap is still pending"); + expect(routing.userPromptPrefixText).toContain("cannot safely complete"); + }); + + it("appends BOOTSTRAP.md contents to the user prompt prefix for full bootstrap turns", () => { + const prompt = appendBootstrapFileToUserPromptPrefix({ + prefixText: "[Bootstrap pending]", + bootstrapMode: "full", + contextFiles: [{ path: "/tmp/workspace/BOOTSTRAP.md", content: "Ask who I am." }], + }); + + expect(prompt).toContain("[Bootstrap pending]"); + expect(prompt).toContain("[BEGIN BOOTSTRAP.md]"); + expect(prompt).toContain("Ask who I am."); + expect(prompt).toContain("workspace/user instructions"); + }); + + it("does not append BOOTSTRAP.md contents for limited bootstrap turns", () => { + const prompt = appendBootstrapFileToUserPromptPrefix({ + prefixText: "[Bootstrap pending]", + bootstrapMode: "limited", + contextFiles: [{ path: "/tmp/workspace/BOOTSTRAP.md", content: "Ask who I am." }], + }); + + expect(prompt).toBe("[Bootstrap pending]"); + }); }); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index c0bafd92ac0..66792f1dce8 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -231,6 +231,7 @@ import { flushPendingToolResultsAfterIdle } from "../wait-for-idle-before-flush. import { createEmbeddedAgentSessionWithResourceLoader } from "./attempt-session.js"; export { buildContextEnginePromptCacheInfo } from "./attempt.context-engine-helpers.js"; import { + appendBootstrapFileToUserPromptPrefix, resolveAttemptWorkspaceBootstrapRouting, shouldStripBootstrapFromEmbeddedContext, } from "./attempt-bootstrap-routing.js"; @@ -1212,7 +1213,11 @@ export async function runEmbeddedAttempt( }); const systemPromptOverride = createSystemPromptOverride(appendPrompt); let systemPromptText = systemPromptOverride(); - const userPromptPrefixText = bootstrapRouting.userPromptPrefixText; + const userPromptPrefixText = appendBootstrapFileToUserPromptPrefix({ + prefixText: bootstrapRouting.userPromptPrefixText, + bootstrapMode, + contextFiles: remappedContextFiles, + }); // Keep the session lock scoped to transcript/session mutations. Cold plugin // and tool setup can be slow, and holding the lock there blocks CLI fallback