From 62703d84308ab526d968bf0d42c330685a9bfb90 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:18:50 -0500 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + src/agents/bootstrap-files.test.ts | 12 + src/agents/bootstrap-files.ts | 3 + src/agents/bootstrap-mode.test.ts | 89 ++++++++ src/agents/bootstrap-mode.ts | 24 ++ src/agents/bootstrap-prompt.ts | 25 +++ src/agents/pi-embedded-runner/run.ts | 7 + .../run/attempt.context-engine-helpers.ts | 6 +- ....spawn-workspace.bootstrap-routing.test.ts | 60 +++++ ....spawn-workspace.context-injection.test.ts | 38 ++++ .../attempt.spawn-workspace.test-support.ts | 13 +- .../pi-embedded-runner/run/attempt.test.ts | 60 +++++ src/agents/pi-embedded-runner/run/attempt.ts | 211 ++++++++++++------ src/agents/pi-embedded-runner/run/params.ts | 2 + src/agents/system-prompt.test.ts | 61 ++++- src/agents/system-prompt.ts | 33 +++ src/agents/workspace.test.ts | 22 ++ src/agents/workspace.ts | 16 ++ src/auto-reply/reply/get-reply-run.ts | 33 ++- .../reply/session-reset-prompt.test.ts | 78 ++++++- src/auto-reply/reply/session-reset-prompt.ts | 95 +++++++- src/gateway/server-methods/agent.test.ts | 194 ++++++++++++++++ src/gateway/server-methods/agent.ts | 89 +++++++- 23 files changed, 1073 insertions(+), 99 deletions(-) create mode 100644 src/agents/bootstrap-mode.test.ts create mode 100644 src/agents/bootstrap-mode.ts create mode 100644 src/agents/bootstrap-prompt.ts create mode 100644 src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ff27d9f538..8a7137e076c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/bootstrap-files.test.ts b/src/agents/bootstrap-files.test.ts index 711c4771939..23e023d87c5 100644 --- a/src/agents/bootstrap-files.test.ts +++ b/src/agents/bootstrap-files.test.ts @@ -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"); diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index 5a3b7c8abe4..c4957881068 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -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 }; diff --git a/src/agents/bootstrap-mode.test.ts b/src/agents/bootstrap-mode.test.ts new file mode 100644 index 00000000000..328c591ab72 --- /dev/null +++ b/src/agents/bootstrap-mode.test.ts @@ -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"); + }); +}); diff --git a/src/agents/bootstrap-mode.ts b/src/agents/bootstrap-mode.ts new file mode 100644 index 00000000000..0e9d7fb7dc3 --- /dev/null +++ b/src/agents/bootstrap-mode.ts @@ -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"; +} diff --git a/src/agents/bootstrap-prompt.ts b/src/agents/bootstrap-prompt.ts new file mode 100644 index 00000000000..6c8bcf7b348 --- /dev/null +++ b/src/agents/bootstrap-prompt.ts @@ -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, + ]; +} diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 2a0b3b04f83..e4af90a2115 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -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, diff --git a/src/agents/pi-embedded-runner/run/attempt.context-engine-helpers.ts b/src/agents/pi-embedded-runner/run/attempt.context-engine-helpers.ts index 7e95d6c0951..305d65632f8 100644 --- a/src/agents/pi-embedded-runner/run/attempt.context-engine-helpers.ts +++ b/src/agents/pi-embedded-runner/run/attempt.context-engine-helpers.ts @@ -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; resolveBootstrapContextForRun: () => Promise; @@ -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) 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 new file mode 100644 index 00000000000..a0acd53906e --- /dev/null +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.bootstrap-routing.test.ts @@ -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]"); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts index 1444cf8d7b8..18e65c7778f 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts @@ -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, diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts index 554b8da6db3..c01ccf9581a 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts @@ -62,8 +62,10 @@ type AttemptSpawnWorkspaceHoisted = { flushPendingToolResultsAfterIdleMock: AsyncUnknownMock; releaseWsSessionMock: UnknownMock; resolveBootstrapContextForRunMock: Mock<() => Promise>; + isWorkspaceBootstrapPendingMock: Mock<(workspaceDir: string) => Promise>; resolveContextInjectionModeMock: Mock<() => "always" | "continuation-skip">; hasCompletedBootstrapTurnMock: Mock<() => Promise>; + 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>( + async () => false, + ); const resolveContextInjectionModeMock = vi.fn<() => "always" | "continuation-skip">( () => "always", ); const hasCompletedBootstrapTurnMock = vi.fn<() => Promise>(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); diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index b7c1a75e2de..9cf5fcb1a20 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -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); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 6fac45ebd4a..c05d3a155cf 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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 | 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 | 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 | undefined; let session: Awaited>["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, diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index 74d6839d578..5de331c48c6 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -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; diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index c2cc13868f1..9daf3e0ea7a 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -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({ diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 33fcb7045d3..0cc151ce16d 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -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 []; diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index 07f1d292002..f427decc213 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -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-"); diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index e23334d89b8..ac504de338e 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -263,6 +263,22 @@ export async function isWorkspaceSetupCompleted(dir: string): Promise { 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 { + return (await resolveWorkspaceBootstrapStatus(dir)) === "pending"; +} + async function writeWorkspaceSetupState( statePath: string, state: WorkspaceSetupState, diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 01406e5970e..6943847ccda 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -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( diff --git a/src/auto-reply/reply/session-reset-prompt.test.ts b/src/auto-reply/reply/session-reset-prompt.test.ts index fe4d757bc2e..2230fc3bdf5 100644 --- a/src/auto-reply/reply/session-reset-prompt.test.ts +++ b/src/auto-reply/reply/session-reset-prompt.test.ts @@ -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"); + }); }); diff --git a/src/auto-reply/reply/session-reset-prompt.ts b/src/auto-reply/reply/session-reset-prompt.ts index a0c89c4afce..673b4826e4d 100644 --- a/src/auto-reply/reply/session-reset-prompt.ts +++ b/src/auto-reply/reply/session-reset-prompt.ts @@ -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(), ); diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index 26b26bbdd94..b22eb368212 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({ performGatewaySessionReset: vi.fn(), getLatestSubagentRunByChildSessionKey: vi.fn(), replaceSubagentRunAfterSteer: vi.fn(), + resolveBareResetBootstrapFileAccess: vi.fn(() => true), loadConfigReturn: {} as Record, })); @@ -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( + "../../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( diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 3ac792b7963..9f1e9ee37fd 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -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,