diff --git a/CHANGELOG.md b/CHANGELOG.md index e00b7ac2870..479c7f111b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai - Memory/dreaming: drop generic date/day headings from daily-note chunk prefixes while keeping meaningful section labels, so staged snippets stay cleaner and more reusable. (#61597) Thanks @mbelinky. - Plugins/Lobster: run bundled Lobster workflows in process instead of spawning the external CLI, reducing transport overhead and unblocking native runtime integration. (#61523) Thanks @mbelinky. - Plugins/Lobster: harden managed resume validation so invalid TaskFlow resume calls fail earlier, and memoize embedded runtime loading per runner while keeping failed loads retryable. (#61566) Thanks @mbelinky. +- Agents/bootstrap: add opt-in `agents.defaults.contextInjection: "continuation-skip"` so safe continuation turns can skip workspace bootstrap re-injection, while heartbeat runs and post-compaction retries still rebuild context when needed. Fixes #9157. Thanks @cgdusek. ### Fixes diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 73c097ce6b8..f14166d984c 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -433dc1a6776b3c782524489d6bb22c770015d4915f6886da89bb3538698f0057 config-baseline.json -71414a189b62e3a362443068cb911372b2fe326a0bf43237a36d475533508499 config-baseline.core.json +1c74540dd152c55dbda3e5dee1e37008ee3e6aabb0608e571292832c7a1c012c config-baseline.json +7e30316f2326b7d07b71d7b8a96049a74b81428921299b5c4b5aa3d080e03305 config-baseline.core.json 66edc86a9d16db1b9e9e7dd99b7032e2d9bcfb9ff210256a21f4b4f088cb3dc1 config-baseline.channel.json d6ebc4948499b997c4a3727cf31849d4a598de9f1a4c197417dcc0b0ec1b734f config-baseline.plugin.json diff --git a/src/agents/bootstrap-files.test.ts b/src/agents/bootstrap-files.test.ts index 11e3d0dd50b..ab0204a1669 100644 --- a/src/agents/bootstrap-files.test.ts +++ b/src/agents/bootstrap-files.test.ts @@ -7,7 +7,13 @@ import { type AgentBootstrapHookContext, } from "../hooks/internal-hooks.js"; import { makeTempWorkspace } from "../test-helpers/workspace.js"; -import { resolveBootstrapContextForRun, resolveBootstrapFilesForRun } from "./bootstrap-files.js"; +import { + FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, + hasCompletedBootstrapTurn, + resolveBootstrapContextForRun, + resolveBootstrapFilesForRun, + resolveContextInjectionMode, +} from "./bootstrap-files.js"; import type { WorkspaceBootstrapFile } from "./workspace.js"; function registerExtraBootstrapFileHook() { @@ -127,3 +133,181 @@ describe("resolveBootstrapContextForRun", () => { expect(files).toEqual([]); }); }); + +describe("hasCompletedBootstrapTurn", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(await fs.realpath("/tmp"), "openclaw-bootstrap-turn-")); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("returns false when session file does not exist", async () => { + expect(await hasCompletedBootstrapTurn(path.join(tmpDir, "missing.jsonl"))).toBe(false); + }); + + it("returns false for empty session files", async () => { + const sessionFile = path.join(tmpDir, "empty.jsonl"); + await fs.writeFile(sessionFile, "", "utf8"); + expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false); + }); + + it("returns false for header-only session files", async () => { + const sessionFile = path.join(tmpDir, "header-only.jsonl"); + await fs.writeFile(sessionFile, `${JSON.stringify({ type: "session", id: "s1" })}\n`, "utf8"); + expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false); + }); + + it("returns false when no assistant turn has been flushed yet", async () => { + const sessionFile = path.join(tmpDir, "user-only.jsonl"); + await fs.writeFile( + sessionFile, + [ + JSON.stringify({ type: "session", id: "s1" }), + JSON.stringify({ type: "message", message: { role: "user", content: "hello" } }), + ].join("\n") + "\n", + "utf8", + ); + expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false); + }); + + it("returns false for assistant turns without a recorded full bootstrap marker", async () => { + const sessionFile = path.join(tmpDir, "assistant-no-marker.jsonl"); + await fs.writeFile( + sessionFile, + [ + JSON.stringify({ type: "session", id: "s1" }), + JSON.stringify({ type: "message", message: { role: "user", content: "hello" } }), + JSON.stringify({ type: "message", message: { role: "assistant", content: "hi" } }), + ].join("\n") + "\n", + "utf8", + ); + expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false); + }); + + it("returns true when a full bootstrap completion marker exists", async () => { + const sessionFile = path.join(tmpDir, "full-bootstrap.jsonl"); + await fs.writeFile( + sessionFile, + [ + JSON.stringify({ type: "message", message: { role: "assistant", content: "hi" } }), + JSON.stringify({ + type: "custom", + customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, + data: { timestamp: 1 }, + }), + ].join("\n") + "\n", + "utf8", + ); + expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true); + }); + + it("returns false when compaction happened after the last assistant turn", async () => { + const sessionFile = path.join(tmpDir, "post-compaction.jsonl"); + await fs.writeFile( + sessionFile, + [ + JSON.stringify({ + type: "custom", + customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, + data: { timestamp: 1 }, + }), + JSON.stringify({ type: "compaction", summary: "trimmed" }), + ].join("\n") + "\n", + "utf8", + ); + expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false); + }); + + it("returns true when a later full bootstrap marker happens after compaction", async () => { + const sessionFile = path.join(tmpDir, "assistant-after-compaction.jsonl"); + await fs.writeFile( + sessionFile, + [ + JSON.stringify({ + type: "custom", + customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, + data: { timestamp: 1 }, + }), + JSON.stringify({ type: "compaction", summary: "trimmed" }), + JSON.stringify({ type: "message", message: { role: "user", content: "new ask" } }), + JSON.stringify({ type: "message", message: { role: "assistant", content: "new reply" } }), + JSON.stringify({ + type: "custom", + customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, + data: { timestamp: 2 }, + }), + ].join("\n") + "\n", + "utf8", + ); + expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true); + }); + + it("ignores malformed JSON lines", async () => { + const sessionFile = path.join(tmpDir, "malformed.jsonl"); + await fs.writeFile( + sessionFile, + [ + "{broken", + JSON.stringify({ + type: "custom", + customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, + data: { timestamp: 1 }, + }), + ].join("\n") + "\n", + "utf8", + ); + expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true); + }); + + it("finds a recent full bootstrap marker even when the scan starts mid-file", async () => { + const sessionFile = path.join(tmpDir, "large-prefix.jsonl"); + const hugePrefix = "x".repeat(300 * 1024); + await fs.writeFile( + sessionFile, + [ + JSON.stringify({ type: "message", message: { role: "user", content: hugePrefix } }), + JSON.stringify({ + type: "custom", + customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, + data: { timestamp: 1 }, + }), + ].join("\n") + "\n", + "utf8", + ); + expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true); + }); + + it("returns false for symbolic links", async () => { + const realFile = path.join(tmpDir, "real.jsonl"); + const linkFile = path.join(tmpDir, "link.jsonl"); + await fs.writeFile( + realFile, + `${JSON.stringify({ type: "custom", customType: FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, data: { timestamp: 1 } })}\n`, + "utf8", + ); + await fs.symlink(realFile, linkFile); + expect(await hasCompletedBootstrapTurn(linkFile)).toBe(false); + }); +}); + +describe("resolveContextInjectionMode", () => { + it("defaults to always when config is missing", () => { + expect(resolveContextInjectionMode(undefined)).toBe("always"); + }); + + it("defaults to always when the setting is omitted", () => { + expect(resolveContextInjectionMode({ agents: { defaults: {} } } as never)).toBe("always"); + }); + + it("returns the configured continuation-skip mode", () => { + expect( + resolveContextInjectionMode({ + agents: { defaults: { contextInjection: "continuation-skip" } }, + } as never), + ).toBe("continuation-skip"); + }); +}); diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index ae544ebbacb..0b72de3e3bb 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -1,4 +1,6 @@ +import fs from "node:fs/promises"; import type { OpenClawConfig } from "../config/config.js"; +import type { AgentContextInjection } from "../config/types.agent-defaults.js"; import { getOrLoadBootstrapFiles } from "./bootstrap-cache.js"; import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; @@ -16,6 +18,85 @@ import { export type BootstrapContextMode = "full" | "lightweight"; export type BootstrapContextRunKind = "default" | "heartbeat" | "cron"; +const CONTINUATION_SCAN_MAX_TAIL_BYTES = 256 * 1024; +const CONTINUATION_SCAN_MAX_RECORDS = 500; +export const FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE = "openclaw:bootstrap-context:full"; + +export function resolveContextInjectionMode(config?: OpenClawConfig): AgentContextInjection { + return config?.agents?.defaults?.contextInjection ?? "always"; +} + +export async function hasCompletedBootstrapTurn(sessionFile: string): Promise { + try { + const stat = await fs.lstat(sessionFile); + if (stat.isSymbolicLink()) { + return false; + } + + const fh = await fs.open(sessionFile, "r"); + try { + const bytesToRead = Math.min(stat.size, CONTINUATION_SCAN_MAX_TAIL_BYTES); + if (bytesToRead <= 0) { + return false; + } + const start = stat.size - bytesToRead; + const buffer = Buffer.allocUnsafe(bytesToRead); + const { bytesRead } = await fh.read(buffer, 0, bytesToRead, start); + let text = buffer.toString("utf-8", 0, bytesRead); + if (start > 0) { + const firstNewline = text.indexOf("\n"); + if (firstNewline === -1) { + return false; + } + text = text.slice(firstNewline + 1); + } + + const records = text + .split(/\r?\n/u) + .filter((line) => line.trim().length > 0) + .slice(-CONTINUATION_SCAN_MAX_RECORDS); + let compactedAfterLatestAssistant = false; + + for (let i = records.length - 1; i >= 0; i--) { + const line = records[i]; + if (!line) { + continue; + } + let entry: unknown; + try { + entry = JSON.parse(line); + } catch { + continue; + } + const record = entry as + | { + type?: string; + customType?: string; + message?: { role?: string }; + } + | null + | undefined; + if (record?.type === "compaction") { + compactedAfterLatestAssistant = true; + continue; + } + if ( + record?.type === "custom" && + record.customType === FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE + ) { + return !compactedAfterLatestAssistant; + } + } + + return false; + } finally { + await fh.close(); + } + } catch { + return false; + } +} + export function makeBootstrapWarn(params: { sessionLabel: string; warn?: (message: string) => void; 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 new file mode 100644 index 00000000000..9cacf3e78c4 --- /dev/null +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-injection.test.ts @@ -0,0 +1,133 @@ +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 context injection", () => { + const tempPaths: string[] = []; + + beforeEach(() => { + resetEmbeddedAttemptHarness(); + }); + + afterEach(async () => { + await cleanupTempPaths(tempPaths); + }); + + it("skips bootstrap reinjection on safe continuation turns when configured", async () => { + hoisted.resolveContextInjectionModeMock.mockReturnValue("continuation-skip"); + hoisted.hasCompletedBootstrapTurnMock.mockResolvedValue(true); + + await createContextEngineAttemptRunner({ + contextEngine: { + assemble: async ({ messages }) => ({ messages, estimatedTokens: 1 }), + }, + sessionKey: "agent:main", + tempPaths, + }); + + expect(hoisted.hasCompletedBootstrapTurnMock).toHaveBeenCalled(); + expect(hoisted.resolveBootstrapContextForRunMock).not.toHaveBeenCalled(); + }); + + it("checks continuation state only after taking the session lock", async () => { + hoisted.resolveContextInjectionModeMock.mockReturnValue("continuation-skip"); + hoisted.hasCompletedBootstrapTurnMock.mockResolvedValue(true); + + await createContextEngineAttemptRunner({ + contextEngine: { + assemble: async ({ messages }) => ({ messages, estimatedTokens: 1 }), + }, + sessionKey: "agent:main", + tempPaths, + }); + + expect(hoisted.acquireSessionWriteLockMock).toHaveBeenCalled(); + expect(hoisted.hasCompletedBootstrapTurnMock).toHaveBeenCalled(); + const lockCallOrder = hoisted.acquireSessionWriteLockMock.mock.invocationCallOrder[0]; + const continuationCallOrder = hoisted.hasCompletedBootstrapTurnMock.mock.invocationCallOrder[0]; + expect(lockCallOrder).toBeLessThan(continuationCallOrder); + }); + + it("still resolves bootstrap context when continuation-skip has no completed assistant turn yet", async () => { + hoisted.resolveContextInjectionModeMock.mockReturnValue("continuation-skip"); + hoisted.hasCompletedBootstrapTurnMock.mockResolvedValue(false); + + await createContextEngineAttemptRunner({ + contextEngine: { + assemble: async ({ messages }) => ({ messages, estimatedTokens: 1 }), + }, + sessionKey: "agent:main", + tempPaths, + }); + + expect(hoisted.resolveBootstrapContextForRunMock).toHaveBeenCalledTimes(1); + }); + + it("never skips heartbeat bootstrap filtering", async () => { + hoisted.resolveContextInjectionModeMock.mockReturnValue("continuation-skip"); + hoisted.hasCompletedBootstrapTurnMock.mockResolvedValue(true); + + await createContextEngineAttemptRunner({ + contextEngine: { + assemble: async ({ messages }) => ({ messages, estimatedTokens: 1 }), + }, + attemptOverrides: { + bootstrapContextMode: "lightweight", + bootstrapContextRunKind: "heartbeat", + }, + sessionKey: "agent:main:heartbeat:test", + tempPaths, + }); + + expect(hoisted.hasCompletedBootstrapTurnMock).not.toHaveBeenCalled(); + expect(hoisted.resolveBootstrapContextForRunMock).toHaveBeenCalledWith( + expect.objectContaining({ + contextMode: "lightweight", + runKind: "heartbeat", + }), + ); + }); + + it("records full bootstrap completion after a successful non-heartbeat turn", async () => { + await createContextEngineAttemptRunner({ + contextEngine: { + assemble: async ({ messages }) => ({ messages, estimatedTokens: 1 }), + }, + sessionKey: "agent:main", + tempPaths, + }); + + expect(hoisted.sessionManager.appendCustomEntry).toHaveBeenCalledWith( + "openclaw:bootstrap-context:full", + expect.objectContaining({ + runId: "run-context-engine-forwarding", + sessionId: "embedded-session", + }), + ); + }); + + it("does not record full bootstrap completion for heartbeat runs", async () => { + await createContextEngineAttemptRunner({ + contextEngine: { + assemble: async ({ messages }) => ({ messages, estimatedTokens: 1 }), + }, + attemptOverrides: { + bootstrapContextMode: "lightweight", + bootstrapContextRunKind: "heartbeat", + }, + sessionKey: "agent:main:heartbeat:test", + tempPaths, + }); + + expect(hoisted.sessionManager.appendCustomEntry).not.toHaveBeenCalledWith( + "openclaw:bootstrap-context:full", + expect.anything(), + ); + }); +}); 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 d96fb46944e..c5305b6e2e1 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 @@ -48,6 +48,8 @@ type AttemptSpawnWorkspaceHoisted = { flushPendingToolResultsAfterIdleMock: AsyncUnknownMock; releaseWsSessionMock: UnknownMock; resolveBootstrapContextForRunMock: Mock<() => Promise>; + resolveContextInjectionModeMock: Mock<() => "always" | "continuation-skip">; + hasCompletedBootstrapTurnMock: Mock<() => Promise>; getGlobalHookRunnerMock: Mock<() => unknown>; initializeGlobalHookRunnerMock: UnknownMock; runContextEngineMaintenanceMock: AsyncUnknownMock; @@ -90,6 +92,10 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { bootstrapFiles: [], contextFiles: [], })); + const resolveContextInjectionModeMock = vi.fn<() => "always" | "continuation-skip">( + () => "always", + ); + const hasCompletedBootstrapTurnMock = vi.fn<() => Promise>(async () => false); const getGlobalHookRunnerMock = vi.fn<() => unknown>(() => undefined); const initializeGlobalHookRunnerMock = vi.fn(); const runContextEngineMaintenanceMock = vi.fn(async (_params?: unknown) => undefined); @@ -113,6 +119,8 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { flushPendingToolResultsAfterIdleMock, releaseWsSessionMock, resolveBootstrapContextForRunMock, + resolveContextInjectionModeMock, + hasCompletedBootstrapTurnMock, getGlobalHookRunnerMock, initializeGlobalHookRunnerMock, runContextEngineMaintenanceMock, @@ -177,6 +185,8 @@ vi.mock("../../../infra/net/undici-global-dispatcher.js", () => ({ vi.mock("../../bootstrap-files.js", () => ({ makeBootstrapWarn: () => () => {}, resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock, + resolveContextInjectionMode: hoisted.resolveContextInjectionModeMock, + hasCompletedBootstrapTurn: hoisted.hasCompletedBootstrapTurnMock, })); vi.mock("../../skills.js", () => ({ @@ -585,6 +595,8 @@ export function resetEmbeddedAttemptHarness( bootstrapFiles: [], contextFiles: [], }); + hoisted.resolveContextInjectionModeMock.mockReset().mockReturnValue("always"); + hoisted.hasCompletedBootstrapTurnMock.mockReset().mockResolvedValue(false); hoisted.getGlobalHookRunnerMock.mockReset().mockReturnValue(undefined); hoisted.runContextEngineMaintenanceMock.mockReset().mockResolvedValue(undefined); hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null); @@ -684,6 +696,10 @@ export const cacheTtlEligibleModel = { input: ["text"], } as unknown as Model; +const testAuthStorage = { + getApiKey: async () => undefined, +}; + export async function createContextEngineAttemptRunner(params: { contextEngine: { bootstrap?: (params: { @@ -789,10 +805,7 @@ export async function createContextEngineAttemptRunner(params: { provider: "openai", modelId: "gpt-test", model: testModel, - authStorage: { - getApiKey: async () => undefined, - setRuntimeApiKey: () => {}, - } as never, + authStorage: testAuthStorage as never, modelRegistry: {} as never, thinkLevel: "off", senderIsOwner: true, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index ddeb9987c85..274775e47eb 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -38,7 +38,13 @@ import { buildBootstrapInjectionStats, prependBootstrapPromptWarning, } from "../../bootstrap-budget.js"; -import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js"; +import { + FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, + hasCompletedBootstrapTurn, + makeBootstrapWarn, + resolveBootstrapContextForRun, + resolveContextInjectionMode, +} from "../../bootstrap-files.js"; import { createCacheTrace } from "../../cache-trace.js"; import { listChannelSupportedActions, @@ -366,17 +372,40 @@ export async function runEmbeddedAttempt( agentId: sessionAgentId, }); + const sessionLock = await acquireSessionWriteLock({ + sessionFile: params.sessionFile, + maxHoldMs: resolveSessionLockMaxHoldFromTimeout({ + timeoutMs: resolveRunTimeoutWithCompactionGraceMs({ + runTimeoutMs: params.timeoutMs, + compactionTimeoutMs: resolveCompactionTimeoutMs(params.config), + }), + }), + }); + const sessionLabel = params.sessionKey ?? params.sessionId; - const { bootstrapFiles: hookAdjustedBootstrapFiles, contextFiles } = - await resolveBootstrapContextForRun({ - workspaceDir: effectiveWorkspace, - config: params.config, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }), - contextMode: params.bootstrapContextMode, - runKind: params.bootstrapContextRunKind, - }); + const contextInjectionMode = resolveContextInjectionMode(params.config); + const isContinuationTurn = + contextInjectionMode === "continuation-skip" && + params.bootstrapContextRunKind !== "heartbeat" && + (await hasCompletedBootstrapTurn(params.sessionFile)); + const shouldRecordCompletedBootstrapTurn = + !isContinuationTurn && + params.bootstrapContextMode !== "lightweight" && + params.bootstrapContextRunKind !== "heartbeat"; + const { bootstrapFiles: hookAdjustedBootstrapFiles, contextFiles } = isContinuationTurn + ? { + bootstrapFiles: [], + contextFiles: [], + } + : await resolveBootstrapContextForRun({ + workspaceDir: effectiveWorkspace, + config: params.config, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }), + contextMode: params.bootstrapContextMode, + runKind: params.bootstrapContextRunKind, + }); const bootstrapMaxChars = resolveBootstrapMaxChars(params.config); const bootstrapTotalMaxChars = resolveBootstrapTotalMaxChars(params.config); const bootstrapAnalysis = analyzeBootstrapBudget({ @@ -740,16 +769,6 @@ export async function runEmbeddedAttempt( const systemPromptOverride = createSystemPromptOverride(appendPrompt); let systemPromptText = systemPromptOverride(); - const sessionLock = await acquireSessionWriteLock({ - sessionFile: params.sessionFile, - maxHoldMs: resolveSessionLockMaxHoldFromTimeout({ - timeoutMs: resolveRunTimeoutWithCompactionGraceMs({ - runTimeoutMs: params.timeoutMs, - compactionTimeoutMs: resolveCompactionTimeoutMs(params.config), - }), - }), - }); - let sessionManager: ReturnType | undefined; let session: Awaited>["session"] | undefined; let removeToolResultContextGuard: (() => void) | undefined; @@ -1911,6 +1930,25 @@ export async function runEmbeddedAttempt( }); } + if ( + shouldRecordCompletedBootstrapTurn && + !promptError && + !aborted && + !yieldAborted && + !timedOutDuringCompaction && + !compactionOccurredThisAttempt + ) { + try { + sessionManager.appendCustomEntry(FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, { + timestamp: Date.now(), + runId: params.runId, + sessionId: params.sessionId, + }); + } catch (entryErr) { + log.warn(`failed to persist bootstrap completion entry: ${String(entryErr)}`); + } + } + cacheTrace?.recordStage("session:after", { messages: messagesSnapshot, note: timedOutDuringCompaction diff --git a/src/auto-reply/reply/commands-subagents/shared.ts b/src/auto-reply/reply/commands-subagents/shared.ts index 585f2ba188c..f057150893b 100644 --- a/src/auto-reply/reply/commands-subagents/shared.ts +++ b/src/auto-reply/reply/commands-subagents/shared.ts @@ -38,6 +38,7 @@ import { export { extractAssistantText, stripToolMessages }; export { resolveCommandSurfaceChannel, resolveChannelAccountId }; +export type { ChatMessage }; export const COMMAND = "/subagents"; export const COMMAND_KILL = "/kill"; diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index b95b95d1c2e..7b10cd93d75 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -3172,6 +3172,21 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { skipBootstrap: { type: "boolean", }, + contextInjection: { + anyOf: [ + { + type: "string", + const: "always", + }, + { + type: "string", + const: "continuation-skip", + }, + ], + title: "Context Injection", + description: + 'Controls when workspace bootstrap files are injected into the system prompt: "always" (default) or "continuation-skip" for safe continuation turns after a completed assistant response.', + }, bootstrapMaxChars: { type: "integer", exclusiveMinimum: 0, @@ -23888,6 +23903,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: "Optional repository root shown in the system prompt runtime line (overrides auto-detect).", tags: ["advanced"], }, + "agents.defaults.contextInjection": { + label: "Context Injection", + help: 'Controls when workspace bootstrap files are injected into the system prompt: "always" (default) or "continuation-skip" for safe continuation turns after a completed assistant response.', + tags: ["advanced"], + }, "agents.defaults.bootstrapMaxChars": { label: "Bootstrap Max Chars", help: "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index d99be8deb2c..32e594ce592 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -826,6 +826,8 @@ export const FIELD_HELP: Record = { "Maximum same-provider auth-profile rotations allowed for rate-limit errors before switching to model fallback (default: 1).", "agents.defaults.workspace": "Default workspace path exposed to agent runtime tools for filesystem context and repo-aware behavior. Set this explicitly when running from wrappers so path resolution stays deterministic.", + "agents.defaults.contextInjection": + 'Controls when workspace bootstrap files are injected into the system prompt: "always" (default) or "continuation-skip" for safe continuation turns after a completed assistant response.', "agents.defaults.bootstrapMaxChars": "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", "agents.defaults.bootstrapTotalMaxChars": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 1b4f0f3341b..29c56302cca 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -328,6 +328,7 @@ export const FIELD_LABELS: Record = { "agents.defaults.skills": "Skills", "agents.defaults.workspace": "Workspace", "agents.defaults.repoRoot": "Repo Root", + "agents.defaults.contextInjection": "Context Injection", "agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars", "agents.defaults.bootstrapTotalMaxChars": "Bootstrap Total Max Chars", "agents.defaults.bootstrapPromptTruncationWarning": "Bootstrap Prompt Truncation Warning", diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 1885b36da11..afeb4d3592d 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -8,6 +8,8 @@ import type { } from "./types.base.js"; import type { MemorySearchConfig } from "./types.tools.js"; +export type AgentContextInjection = "always" | "continuation-skip"; + export type AgentModelEntryConfig = { alias?: string; /** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */ @@ -73,6 +75,14 @@ export type AgentDefaultsConfig = { repoRoot?: string; /** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */ skipBootstrap?: boolean; + /** + * Controls when workspace bootstrap files (AGENTS.md, SOUL.md, etc.) are + * injected into the system prompt: + * - always: inject on every turn (default) + * - continuation-skip: skip injection on safe continuation turns once the + * transcript already contains a completed assistant turn + */ + contextInjection?: AgentContextInjection; /** Max chars for injected bootstrap files before truncation (default: 20000). */ bootstrapMaxChars?: number; /** Max total chars across all injected bootstrap files (default: 150000). */ diff --git a/src/config/zod-schema.agent-defaults.test.ts b/src/config/zod-schema.agent-defaults.test.ts index 878dc59a9ed..b58142a73ea 100644 --- a/src/config/zod-schema.agent-defaults.test.ts +++ b/src/config/zod-schema.agent-defaults.test.ts @@ -22,4 +22,18 @@ describe("agent defaults schema", () => { }), ).not.toThrow(); }); + + it("accepts contextInjection: always", () => { + const result = AgentDefaultsSchema.parse({ contextInjection: "always" })!; + expect(result.contextInjection).toBe("always"); + }); + + it("accepts contextInjection: continuation-skip", () => { + const result = AgentDefaultsSchema.parse({ contextInjection: "continuation-skip" })!; + expect(result.contextInjection).toBe("continuation-skip"); + }); + + it("rejects invalid contextInjection values", () => { + expect(() => AgentDefaultsSchema.parse({ contextInjection: "never" })).toThrow(); + }); }); diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 94165de41ac..cf046bcc017 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -43,6 +43,7 @@ export const AgentDefaultsSchema = z skills: z.array(z.string()).optional(), repoRoot: z.string().optional(), skipBootstrap: z.boolean().optional(), + contextInjection: z.union([z.literal("always"), z.literal("continuation-skip")]).optional(), bootstrapMaxChars: z.number().int().positive().optional(), bootstrapTotalMaxChars: z.number().int().positive().optional(), bootstrapPromptTruncationWarning: z