fix(agents): harden bootstrap and ACP session routing

This commit is contained in:
Peter Steinberger
2026-04-28 20:47:24 +01:00
parent 3ae69498e2
commit 0dcab4e347
10 changed files with 108 additions and 10 deletions

View File

@@ -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.

View File

@@ -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`.

View File

@@ -142,6 +142,7 @@ Quick `/acp` flow from chat:
<AccordionGroup>
<Accordion title="Lifecycle details">
- 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.

View File

@@ -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", () => {

View File

@@ -8,10 +8,10 @@ type SessionInteractionEntry = Pick<SessionEntry, "spawnedBy" | "parentSessionKe
export function resolveAcpSessionInteractionMode(
entry?: SessionInteractionEntry | null,
): AcpSessionInteractionMode {
// Parent-owned oneshot ACP sessions are background work delegated from another session.
// Parent-owned ACP sessions are background work delegated from another session.
// They should report back through the parent task notifier instead of speaking directly
// on the user-facing channel themselves.
if (entry?.acp?.mode !== "oneshot") {
if (!entry?.acp) {
return "interactive";
}
if (normalizeOptionalString(entry.spawnedBy) || normalizeOptionalString(entry.parentSessionKey)) {

View File

@@ -74,7 +74,7 @@ describe("resolveBootstrapMode", () => {
).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");
});
});

View File

@@ -18,7 +18,7 @@ export function resolveBootstrapMode(params: {
return "none";
}
if (!params.hasBootstrapFileAccess) {
return "none";
return "limited";
}
return params.isCanonicalWorkspace ? "full" : "limited";
}

View File

@@ -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<AttemptBootstrapRouting> {

View File

@@ -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]");
});
});

View File

@@ -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