mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
fix(agents): harden bootstrap and ACP session routing
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ export function resolveBootstrapMode(params: {
|
||||
return "none";
|
||||
}
|
||||
if (!params.hasBootstrapFileAccess) {
|
||||
return "none";
|
||||
return "limited";
|
||||
}
|
||||
return params.isCanonicalWorkspace ? "full" : "limited";
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user