diff --git a/CHANGELOG.md b/CHANGELOG.md index 94852b1a1..d7a257366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ - Plugins: treat `plugins.load.paths` directory entries as package roots when they contain `package.json` + `clawdbot.extensions`; load plugin packages from config dirs; extract archives without system tar. - Config: expand `~` in `CLAWDBOT_CONFIG_PATH` and common path-like config fields (including `plugins.load.paths`); guard invalid `$include` paths. (#731) — thanks @pasogott. - Agents: stop pre-creating session transcripts so first user messages persist in JSONL history. +- Agents: skip pre-compaction memory flush when the session workspace is read-only. - Auto-reply: align `/think` default display with model reasoning defaults. (#751) — thanks @gabriel-trigo. - Auto-reply: flush block reply buffers on tool boundaries. (#750) — thanks @sebslight. - Auto-reply: allow sender fallback for command authorization when `SenderId` is empty (WhatsApp self-chat). (#755) — thanks @juanpablodlc. diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md index 4300d49f2..1af220920 100644 --- a/docs/concepts/memory.md +++ b/docs/concepts/memory.md @@ -7,7 +7,7 @@ read_when: # Memory Clawdbot memory is **plain Markdown in the agent workspace**. The files are the -source of truth; the model only “remembers” what gets written to disk. +source of truth; the model only "remembers" what gets written to disk. ## Memory files (Markdown) @@ -27,14 +27,14 @@ These files live under the workspace (`agents.defaults.workspace`, default - Decisions, preferences, and durable facts go to `MEMORY.md`. - Day-to-day notes and running context go to `memory/YYYY-MM-DD.md`. -- If someone says “remember this,” write it down (don’t keep it in RAM). +- If someone says "remember this," write it down (do not keep it in RAM). ## Automatic memory flush (pre-compaction ping) When a session is **close to auto-compaction**, Clawdbot triggers a **silent agentic turn** that reminds the model to write durable memory **before** the context is compacted. The default prompt encourages the model to respond with -`NO_REPLY` when there’s nothing to store, so the user never sees this turn. +`NO_REPLY` when there's nothing to store, so the user never sees this turn. This is controlled by `agents.defaults.compaction.memoryFlush`: @@ -61,6 +61,8 @@ Details: `contextWindow - reserveTokensFloor - softThresholdTokens`. - **Silent** by default: prompts include `NO_REPLY` so nothing is delivered. - **One flush per compaction cycle** (tracked in `sessions.json`). +- **Workspace must be writable**: if the session runs sandboxed with + `workspaceAccess: "ro"` or `"none"`, the flush is skipped. For the full compaction lifecycle, see [Session management + compaction](/reference/session-management-compaction). diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index f346e17af..c59a591c0 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1382,6 +1382,8 @@ Defaults: - `memoryFlush.enabled`: `true` - `memoryFlush.softThresholdTokens`: `4000` - `memoryFlush.prompt` / `memoryFlush.systemPrompt`: built-in defaults with `NO_REPLY` +- Note: memory flush is skipped when the session workspace is read-only + (`agents.defaults.sandbox.workspaceAccess: "ro"` or `"none"`). Example (tuned): ```json5 diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index e807d3a21..d81a1ea44 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -251,6 +251,7 @@ Notes: - The default prompt/system prompt include a `NO_REPLY` hint to suppress delivery. - The flush runs once per compaction cycle (tracked in `sessions.json`). - The flush runs only for embedded Pi sessions (CLI backends skip it). +- The flush is skipped when the session workspace is read-only (`workspaceAccess: "ro"` or `"none"`). - See [Memory](/concepts/memory) for the workspace file layout and write patterns. Pi also exposes a `session_before_compact` hook in the extension API, but Clawdbot’s diff --git a/src/auto-reply/reply/agent-runner.memory-flush.test.ts b/src/auto-reply/reply/agent-runner.memory-flush.test.ts new file mode 100644 index 000000000..9d1b86178 --- /dev/null +++ b/src/auto-reply/reply/agent-runner.memory-flush.test.ts @@ -0,0 +1,246 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import type { TemplateContext } from "../templating.js"; +import { DEFAULT_MEMORY_FLUSH_PROMPT } from "./memory-flush.js"; +import type { FollowupRun, QueueSettings } from "./queue.js"; +import { createMockTypingController } from "./test-helpers.js"; + +const runEmbeddedPiAgentMock = vi.fn(); + +type EmbeddedRunParams = { + prompt?: string; +}; + +vi.mock("../../agents/model-fallback.js", () => ({ + runWithModelFallback: async ({ + provider, + model, + run, + }: { + provider: string; + model: string; + run: (provider: string, model: string) => Promise; + }) => ({ + result: await run(provider, model), + provider, + model, + }), +})); + +vi.mock("../../agents/pi-embedded.js", () => ({ + queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), + runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params), +})); + +vi.mock("./queue.js", async () => { + const actual = + await vi.importActual("./queue.js"); + return { + ...actual, + enqueueFollowupRun: vi.fn(), + scheduleFollowupDrain: vi.fn(), + }; +}); + +import { runReplyAgent } from "./agent-runner.js"; + +async function seedSessionStore(params: { + storePath: string; + sessionKey: string; + entry: Record; +}) { + await fs.mkdir(path.dirname(params.storePath), { recursive: true }); + await fs.writeFile( + params.storePath, + JSON.stringify({ [params.sessionKey]: params.entry }, null, 2), + "utf-8", + ); +} + +function createBaseRun(params: { + storePath: string; + sessionEntry: Record; + config?: Record; +}) { + const typing = createMockTypingController(); + const sessionCtx = { + Provider: "whatsapp", + OriginatingTo: "+15550001111", + AccountId: "primary", + MessageSid: "msg", + } as unknown as TemplateContext; + const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings; + const followupRun = { + prompt: "hello", + summaryLine: "hello", + enqueuedAt: Date.now(), + run: { + sessionId: "session", + sessionKey: "main", + messageProvider: "whatsapp", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + config: params.config ?? {}, + skillsSnapshot: {}, + provider: "anthropic", + model: "claude", + thinkLevel: "low", + verboseLevel: "off", + elevatedLevel: "off", + bashElevated: { + enabled: false, + allowed: false, + defaultLevel: "off", + }, + timeoutMs: 1_000, + blockReplyBreak: "message_end", + }, + } as unknown as FollowupRun; + + return { + typing, + sessionCtx, + resolvedQueue, + followupRun, + }; +} + +describe("runReplyAgent memory flush", () => { + it("runs a memory flush turn and updates session metadata", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-flush-")); + const storePath = path.join(tmp, "sessions.json"); + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + const calls: Array<{ prompt?: string }> = []; + runEmbeddedPiAgentMock.mockImplementation( + async (params: EmbeddedRunParams) => { + calls.push({ prompt: params.prompt }); + if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) { + return { payloads: [], meta: {} }; + } + return { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + }; + }, + ); + + const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({ + storePath, + sessionEntry, + }); + + await runReplyAgent({ + commandBody: "hello", + followupRun, + queueKey: "main", + resolvedQueue, + shouldSteer: false, + shouldFollowup: false, + isActive: false, + isStreaming: false, + typing, + sessionCtx, + sessionEntry, + sessionStore: { [sessionKey]: sessionEntry }, + sessionKey, + storePath, + defaultModel: "anthropic/claude-opus-4-5", + agentCfgContextTokens: 100_000, + resolvedVerboseLevel: "off", + isNewSession: false, + blockStreamingEnabled: false, + resolvedBlockStreamingBreak: "message_end", + shouldInjectGroupIntro: false, + typingMode: "instant", + }); + + expect(calls.map((call) => call.prompt)).toEqual([ + DEFAULT_MEMORY_FLUSH_PROMPT, + "hello", + ]); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].memoryFlushAt).toBeTypeOf("number"); + expect(stored[sessionKey].memoryFlushCompactionCount).toBe(1); + }); + + it("skips memory flush when the sandbox workspace is read-only", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-flush-")); + const storePath = path.join(tmp, "sessions.json"); + const sessionKey = "main"; + const sessionEntry = { + sessionId: "session", + updatedAt: Date.now(), + totalTokens: 80_000, + compactionCount: 1, + }; + + await seedSessionStore({ storePath, sessionKey, entry: sessionEntry }); + + const calls: Array<{ prompt?: string }> = []; + runEmbeddedPiAgentMock.mockImplementation( + async (params: EmbeddedRunParams) => { + calls.push({ prompt: params.prompt }); + return { + payloads: [{ text: "ok" }], + meta: { agentMeta: { usage: { input: 1, output: 1 } } }, + }; + }, + ); + + const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({ + storePath, + sessionEntry, + config: { + agents: { + defaults: { + sandbox: { mode: "all", workspaceAccess: "ro" }, + }, + }, + }, + }); + + await runReplyAgent({ + commandBody: "hello", + followupRun, + queueKey: "main", + resolvedQueue, + shouldSteer: false, + shouldFollowup: false, + isActive: false, + isStreaming: false, + typing, + sessionCtx, + sessionEntry, + sessionStore: { [sessionKey]: sessionEntry }, + sessionKey, + storePath, + defaultModel: "anthropic/claude-opus-4-5", + agentCfgContextTokens: 100_000, + resolvedVerboseLevel: "off", + isNewSession: false, + blockStreamingEnabled: false, + resolvedBlockStreamingBreak: "message_end", + shouldInjectGroupIntro: false, + typingMode: "instant", + }); + + expect(calls.map((call) => call.prompt)).toEqual(["hello"]); + + const stored = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(stored[sessionKey].memoryFlushAt).toBeUndefined(); + }); +}); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index c42d45b11..660d80af5 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -15,6 +15,10 @@ import { isCompactionFailureError, isContextOverflowError, } from "../../agents/pi-embedded-helpers.js"; +import { + resolveSandboxConfigForAgent, + resolveSandboxRuntimeStatus, +} from "../../agents/sandbox.js"; import { hasNonzeroUsage, type NormalizedUsage } from "../../agents/usage.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { @@ -343,8 +347,16 @@ export async function runReplyAgent(params: { } const memoryFlushSettings = resolveMemoryFlushSettings(cfg); + const memoryFlushWritable = (() => { + if (!sessionKey) return true; + const runtime = resolveSandboxRuntimeStatus({ cfg, sessionKey }); + if (!runtime.sandboxed) return true; + const sandboxCfg = resolveSandboxConfigForAgent(cfg, runtime.agentId); + return sandboxCfg.workspaceAccess === "rw"; + })(); const shouldFlushMemory = memoryFlushSettings && + memoryFlushWritable && !isHeartbeat && !isCliProvider(followupRun.run.provider, cfg) && shouldRunMemoryFlush({ diff --git a/src/config/config.test.ts b/src/config/config.test.ts index a2d91d47a..78703a28a 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -510,6 +510,56 @@ describe("config pruning defaults", () => { }); }); +describe("config compaction settings", () => { + it("preserves memory flush config values", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".clawdbot"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "clawdbot.json"), + JSON.stringify( + { + agents: { + defaults: { + compaction: { + reserveTokensFloor: 12_345, + memoryFlush: { + enabled: false, + softThresholdTokens: 1234, + prompt: "Write notes.", + systemPrompt: "Flush memory now.", + }, + }, + }, + }, + }, + null, + 2, + ), + "utf-8", + ); + + vi.resetModules(); + const { loadConfig } = await import("./config.js"); + const cfg = loadConfig(); + + expect(cfg.agents?.defaults?.compaction?.reserveTokensFloor).toBe(12_345); + expect(cfg.agents?.defaults?.compaction?.memoryFlush?.enabled).toBe( + false, + ); + expect( + cfg.agents?.defaults?.compaction?.memoryFlush?.softThresholdTokens, + ).toBe(1234); + expect(cfg.agents?.defaults?.compaction?.memoryFlush?.prompt).toBe( + "Write notes.", + ); + expect(cfg.agents?.defaults?.compaction?.memoryFlush?.systemPrompt).toBe( + "Flush memory now.", + ); + }); + }); +}); + describe("config discord", () => { let previousHome: string | undefined;