diff --git a/CHANGELOG.md b/CHANGELOG.md index dde430663cf..069a532eec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -170,6 +170,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Memory: close temp SQLite handles before failed atomic reindex cleanup and retry Windows EBUSY/EPERM/EACCES temp file removals, so `memory index --force` does not abort or leave temp sidecars on locked filesystems. Fixes #79708. Thanks @LobsterFarmerAmp and @hclsys. +- Agents/CLI: add an explicit `reseedFromRawTranscriptWhenUncompacted` backend opt-in so safe invalidated CLI sessions can reseed from a bounded raw OpenClaw transcript tail before compaction while auth-boundary resets remain no-raw. Fixes #79713. (#79764) Thanks @hclsys. - Agents/CLI: handle resumed CLI JSONL output and bound supervisor output buffering so resumed runs stay readable without letting noisy child output grow unbounded. - Codex app-server: honor per-call `timeoutMs`, configured `image_generate` timeouts, and media image-understanding timeouts for dynamic tool calls, capped at 600000 ms, so slow image generation and image analysis no longer fail at the 30s bridge default. Fixes #79810. Thanks @omarshahine. - Agents/sandbox: include the container workspace path hint in sandbox-root escape errors while preserving shortened host workspace roots. Fixes #79712. Thanks @haumanto and @hclsys. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 973370aa321..ca2284baab5 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -bb53a92a54a804d217baf466a4731924653d769db37122c38400cc3b97720c23 config-baseline.json -3b632b0f038846722e2a5012a5eeec2a29048b6e385b591d7bd9122aa0981a20 config-baseline.core.json +335083781741da50b280496b954794bdecba7c1150ce777d37534ccc1ec2c10a config-baseline.json +b629f3b6ec6389eb0709e6f9149d7c3ab50431bb22124019541710873dc52cbb config-baseline.core.json 9edc62ae7dfedabc645470dd03102b813fc780b9108caf675fd661104714206f config-baseline.channel.json 1da42cb10427fb08510f29732493d24851ab915a424f91556569febdd450d9c3 config-baseline.plugin.json diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index 9b7056a7afa..a927755f190 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -136,6 +136,9 @@ The provider id becomes the left side of your model ref: systemPromptWhen: "first", imageArg: "--image", imageMode: "repeat", + // Opt in only if this backend may reseed safe invalidated sessions + // from bounded raw OpenClaw transcript history before compaction. + reseedFromRawTranscriptWhenUncompacted: true, serialize: true, }, }, @@ -231,6 +234,13 @@ binary is not already on `PATH`. - Stored CLI sessions are provider-owned continuity. The implicit daily session reset does not cut them; `/reset` and explicit `session.reset` policies still do. +- Fresh CLI sessions normally reseed only from OpenClaw's compaction summary + plus post-compaction tail. To recover short sessions that are invalidated + before compaction, a backend can opt in with + `reseedFromRawTranscriptWhenUncompacted: true`. OpenClaw still keeps raw + transcript reseed bounded and limits it to safe invalidations such as missing + CLI transcripts, system-prompt/MCP changes, or session-expired retry; auth + profile or credential-epoch changes never reseed raw transcript history. Serialization notes: diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 6bc884f1382..5e31908319c 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -484,6 +484,10 @@ Optional CLI backends for text-only fallback runs (no tool calls). Useful as a b - CLI backends are text-first; tools are always disabled. - Sessions supported when `sessionArg` is set. - Image pass-through supported when `imageArg` accepts file paths. +- `reseedFromRawTranscriptWhenUncompacted: true` lets a backend recover safe + invalidated sessions from a bounded raw OpenClaw transcript tail before the + first compaction summary exists. Auth profile or credential-epoch changes + still never raw-reseed. ### `agents.defaults.systemPromptOverride` diff --git a/src/agents/cli-runner.reliability.test.ts b/src/agents/cli-runner.reliability.test.ts index 5570819ef9e..58f5b2c2752 100644 --- a/src/agents/cli-runner.reliability.test.ts +++ b/src/agents/cli-runner.reliability.test.ts @@ -839,27 +839,32 @@ describe("runCliAgent reliability", () => { ); try { - await expect( - runPreparedCliAgent({ + const result = await runPreparedCliAgent({ + ...buildPreparedContext({ + sessionKey: "agent:main:main", + runId: "run-retry-success", + cliSessionId: "thread-123", + openClawHistoryPrompt: + "Continue this conversation using the OpenClaw transcript below.\n\nUser: recovered history\n\n\nhi\n", + }), + params: { ...buildPreparedContext({ sessionKey: "agent:main:main", runId: "run-retry-success", cliSessionId: "thread-123", - }), - params: { - ...buildPreparedContext({ - sessionKey: "agent:main:main", - runId: "run-retry-success", - cliSessionId: "thread-123", - }).params, - agentId: "main", - sessionFile, - workspaceDir: dir, - }, - }), - ).resolves.toMatchObject({ + openClawHistoryPrompt: + "Continue this conversation using the OpenClaw transcript below.\n\nUser: recovered history\n\n\nhi\n", + }).params, + agentId: "main", + sessionFile, + workspaceDir: dir, + }, + }); + + expect(result).toMatchObject({ payloads: [{ text: "recovered output" }], }); + expect(result.meta.finalPromptText).toContain("User: recovered history"); await vi.waitFor(() => { expect(hookRunner.runLlmInput).toHaveBeenCalledTimes(1); diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 19327663c6b..56d86447329 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -80,7 +80,11 @@ async function createTestMcpLoopbackServer(port = 0) { } function createCliBackendConfig( - params: { systemPromptOverride?: string | null; bundleMcp?: boolean } = {}, + params: { + systemPromptOverride?: string | null; + bundleMcp?: boolean; + reseedFromRawTranscriptWhenUncompacted?: boolean; + } = {}, ): OpenClawConfig { return { agents: { @@ -97,6 +101,9 @@ function createCliBackendConfig( sessionMode: "existing", output: "text", input: "arg", + ...(params.reseedFromRawTranscriptWhenUncompacted + ? { reseedFromRawTranscriptWhenUncompacted: true } + : {}), ...(params.bundleMcp ? { bundleMcp: true, bundleMcpMode: "claude-config-file" as const } : {}), @@ -561,6 +568,89 @@ describe("shouldSkipLocalCliCredentialEpoch", () => { } }); + it("prepares raw-tail history for safe invalidations only when the backend opts in", async () => { + const { dir, sessionFile } = createSessionFile(); + appendTranscriptEntry(sessionFile, { + id: "msg-1", + parentId: null, + timestamp: new Date(1).toISOString(), + message: { + role: "user", + content: "prior no-compaction ask", + timestamp: 1, + }, + }); + + try { + const context = await prepareCliRunContext({ + sessionId: "session-test", + sessionFile, + workspaceDir: dir, + prompt: "latest ask", + provider: "test-cli", + model: "test-model", + timeoutMs: 1_000, + runId: "run-test-raw-reseed-opt-in", + extraSystemPrompt: "changed stable prompt", + extraSystemPromptStatic: "changed stable prompt", + cliSessionBinding: { + sessionId: "cli-session", + extraSystemPromptHash: hashCliSessionText("old stable prompt"), + }, + config: createCliBackendConfig({ + systemPromptOverride: null, + reseedFromRawTranscriptWhenUncompacted: true, + }), + }); + + expect(context.reusableCliSession).toEqual({ invalidatedReason: "system-prompt" }); + expect(context.openClawHistoryPrompt).toContain("prior no-compaction ask"); + expect(context.openClawHistoryPrompt).toContain("latest ask"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("prepares opted-in raw-tail history for session-expired retry without disabling native resume", async () => { + const { dir, sessionFile } = createSessionFile(); + appendTranscriptEntry(sessionFile, { + id: "msg-1", + parentId: null, + timestamp: new Date(1).toISOString(), + message: { + role: "user", + content: "prior resumable ask", + timestamp: 1, + }, + }); + + try { + const context = await prepareCliRunContext({ + sessionId: "session-test", + sessionFile, + workspaceDir: dir, + prompt: "latest ask", + provider: "test-cli", + model: "test-model", + timeoutMs: 1_000, + runId: "run-test-session-expired-reseed-opt-in", + cliSessionBinding: { + sessionId: "cli-session", + }, + config: createCliBackendConfig({ + systemPromptOverride: null, + reseedFromRawTranscriptWhenUncompacted: true, + }), + }); + + expect(context.reusableCliSession).toEqual({ sessionId: "cli-session" }); + expect(context.openClawHistoryPrompt).toContain("prior resumable ask"); + expect(context.openClawHistoryPrompt).toContain("latest ask"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("applies direct-run prepend system context helpers on the CLI path", async () => { const { dir, sessionFile } = createSessionFile(); try { diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 9a5671c13c3..f8a83a1a47f 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -406,18 +406,27 @@ export async function prepareCliRunContext( cliBackendLog.warn(`cli prompt-build hook preparation failed: ${String(error)}`); } preparedPrompt = annotateInterSessionPromptText(preparedPrompt, params.inputProvenance); - const openClawHistoryPrompt = reusableCliSession.sessionId - ? undefined - : buildCliSessionHistoryPrompt({ + const allowRawTranscriptReseed = + backendResolved.config.reseedFromRawTranscriptWhenUncompacted === true; + const rawTranscriptReseedReason = reusableCliSession.sessionId + ? "session-expired" + : reusableCliSession.invalidatedReason; + const shouldPrepareOpenClawHistoryPrompt = + !reusableCliSession.sessionId || allowRawTranscriptReseed; + const openClawHistoryPrompt = shouldPrepareOpenClawHistoryPrompt + ? buildCliSessionHistoryPrompt({ messages: await loadCliSessionReseedMessages({ sessionId: params.sessionId, sessionFile: params.sessionFile, sessionKey: params.sessionKey, agentId: params.agentId, config: params.config, + allowRawTranscriptReseed, + rawTranscriptReseedReason, }), prompt: preparedPrompt, - }); + }) + : undefined; systemPrompt = appendModelIdentitySystemPrompt({ systemPrompt: applyPluginTextReplacements(systemPrompt, backendResolved.textTransforms?.input), model: modelDisplay, diff --git a/src/agents/cli-runner/session-history.test.ts b/src/agents/cli-runner/session-history.test.ts index f72f018ed8a..416a970c5a5 100644 --- a/src/agents/cli-runner/session-history.test.ts +++ b/src/agents/cli-runner/session-history.test.ts @@ -249,6 +249,76 @@ describe("loadCliSessionReseedMessages", () => { } }); + it("reseeds safe invalidated sessions from a bounded raw message tail when explicitly opted in", async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-")); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + const sessionFile = createSessionTranscript({ + rootDir: stateDir, + sessionId: "session-opt-in-raw-tail", + messages: Array.from( + { length: MAX_CLI_SESSION_HISTORY_MESSAGES + 25 }, + (_, index) => `raw-${index}`, + ), + }); + + try { + const reseed = await loadCliSessionReseedMessages({ + sessionId: "session-opt-in-raw-tail", + sessionFile, + sessionKey: "agent:main:main", + agentId: "main", + allowRawTranscriptReseed: true, + rawTranscriptReseedReason: "missing-transcript", + }); + expect(reseed).toHaveLength(MAX_CLI_SESSION_HISTORY_MESSAGES); + expect(reseed[0]).toMatchObject({ role: "user", content: "raw-25" }); + expect(reseed.at(-1)).toMatchObject({ + role: "user", + content: `raw-${MAX_CLI_SESSION_HISTORY_MESSAGES + 24}`, + }); + expect(buildCliSessionHistoryPrompt({ messages: reseed, prompt: "next" })).toContain( + "raw-25", + ); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it("does not raw-reseed auth-boundary invalidations even when opted in", async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-")); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + const sessionFile = createSessionTranscript({ + rootDir: stateDir, + sessionId: "session-auth-boundary", + messages: ["previous account context"], + }); + + try { + await expect( + loadCliSessionReseedMessages({ + sessionId: "session-auth-boundary", + sessionFile, + sessionKey: "agent:main:main", + agentId: "main", + allowRawTranscriptReseed: true, + rawTranscriptReseedReason: "auth-profile", + }), + ).resolves.toStrictEqual([]); + await expect( + loadCliSessionReseedMessages({ + sessionId: "session-auth-boundary", + sessionFile, + sessionKey: "agent:main:main", + agentId: "main", + allowRawTranscriptReseed: true, + rawTranscriptReseedReason: "auth-epoch", + }), + ).resolves.toStrictEqual([]); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + it("reseeds fresh CLI sessions from the latest compaction summary and post-compaction tail", async () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-")); vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); diff --git a/src/agents/cli-runner/session-history.ts b/src/agents/cli-runner/session-history.ts index dca82e48b6b..402d67ebbc7 100644 --- a/src/agents/cli-runner/session-history.ts +++ b/src/agents/cli-runner/session-history.ts @@ -28,6 +28,21 @@ type HistoryEntry = { summary?: unknown; }; +type RawTranscriptReseedReason = + | "auth-profile" + | "auth-epoch" + | "system-prompt" + | "mcp" + | "missing-transcript" + | "session-expired"; + +const RAW_TRANSCRIPT_RESEED_ALLOWED_REASONS = new Set([ + "missing-transcript", + "system-prompt", + "mcp", + "session-expired", +]); + function coerceHistoryText(content: unknown): string { if (typeof content === "string") { return content.trim(); @@ -190,20 +205,36 @@ export async function loadCliSessionReseedMessages(params: { sessionKey?: string; agentId?: string; config?: OpenClawConfig; + allowRawTranscriptReseed?: boolean; + rawTranscriptReseedReason?: RawTranscriptReseedReason; }): Promise { const entries = await loadCliSessionEntries(params); + const loadRawTail = () => { + if ( + params.allowRawTranscriptReseed !== true || + !params.rawTranscriptReseedReason || + !RAW_TRANSCRIPT_RESEED_ALLOWED_REASONS.has(params.rawTranscriptReseedReason) + ) { + return []; + } + const rawTail = entries.flatMap((entry) => { + const candidate = entry as HistoryEntry; + return candidate.type === "message" ? [candidate.message] : []; + }); + return limitAgentHookHistoryMessages(rawTail, MAX_CLI_SESSION_HISTORY_MESSAGES); + }; const latestCompactionIndex = entries.findLastIndex((entry) => { const candidate = entry as HistoryEntry; return candidate.type === "compaction" && typeof candidate.summary === "string"; }); if (latestCompactionIndex < 0) { - return []; + return loadRawTail(); } const compaction = entries[latestCompactionIndex] as HistoryEntry; const summary = typeof compaction.summary === "string" ? compaction.summary.trim() : ""; if (!summary) { - return []; + return loadRawTail(); } const tailMessages = entries.slice(latestCompactionIndex + 1).flatMap((entry) => { diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index c45e49f9438..f6f7a51c725 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -150,6 +150,8 @@ export type CliBackendConfig = { imagePathScope?: "temp" | "workspace"; /** Serialize runs for this CLI. */ serialize?: boolean; + /** Opt in to bounded raw transcript reseed before compaction for safe session resets. */ + reseedFromRawTranscriptWhenUncompacted?: boolean; /** Runtime reliability tuning for this backend's process lifecycle. */ reliability?: { /** Live-session output caps for CLIs that stream JSONL through a long-lived process. */ diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index ca651f15735..06e86dd56ef 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -642,6 +642,7 @@ export const CliBackendSchema = z imageMode: z.union([z.literal("repeat"), z.literal("list")]).optional(), imagePathScope: z.union([z.literal("temp"), z.literal("workspace")]).optional(), serialize: z.boolean().optional(), + reseedFromRawTranscriptWhenUncompacted: z.boolean().optional(), reliability: z .object({ outputLimits: CliBackendOutputLimitsSchema,