fix: skip memory flush on read-only workspace
This commit is contained in:
@@ -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.
|
- 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.
|
- 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: 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: 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: 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.
|
- Auto-reply: allow sender fallback for command authorization when `SenderId` is empty (WhatsApp self-chat). (#755) — thanks @juanpablodlc.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ read_when:
|
|||||||
# Memory
|
# Memory
|
||||||
|
|
||||||
Clawdbot memory is **plain Markdown in the agent workspace**. The files are the
|
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)
|
## 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`.
|
- Decisions, preferences, and durable facts go to `MEMORY.md`.
|
||||||
- Day-to-day notes and running context go to `memory/YYYY-MM-DD.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)
|
## Automatic memory flush (pre-compaction ping)
|
||||||
|
|
||||||
When a session is **close to auto-compaction**, Clawdbot triggers a **silent
|
When a session is **close to auto-compaction**, Clawdbot triggers a **silent
|
||||||
agentic turn** that reminds the model to write durable memory **before** the
|
agentic turn** that reminds the model to write durable memory **before** the
|
||||||
context is compacted. The default prompt encourages the model to respond with
|
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`:
|
This is controlled by `agents.defaults.compaction.memoryFlush`:
|
||||||
|
|
||||||
@@ -61,6 +61,8 @@ Details:
|
|||||||
`contextWindow - reserveTokensFloor - softThresholdTokens`.
|
`contextWindow - reserveTokensFloor - softThresholdTokens`.
|
||||||
- **Silent** by default: prompts include `NO_REPLY` so nothing is delivered.
|
- **Silent** by default: prompts include `NO_REPLY` so nothing is delivered.
|
||||||
- **One flush per compaction cycle** (tracked in `sessions.json`).
|
- **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
|
For the full compaction lifecycle, see
|
||||||
[Session management + compaction](/reference/session-management-compaction).
|
[Session management + compaction](/reference/session-management-compaction).
|
||||||
|
|||||||
@@ -1382,6 +1382,8 @@ Defaults:
|
|||||||
- `memoryFlush.enabled`: `true`
|
- `memoryFlush.enabled`: `true`
|
||||||
- `memoryFlush.softThresholdTokens`: `4000`
|
- `memoryFlush.softThresholdTokens`: `4000`
|
||||||
- `memoryFlush.prompt` / `memoryFlush.systemPrompt`: built-in defaults with `NO_REPLY`
|
- `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):
|
Example (tuned):
|
||||||
```json5
|
```json5
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ Notes:
|
|||||||
- The default prompt/system prompt include a `NO_REPLY` hint to suppress delivery.
|
- 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 once per compaction cycle (tracked in `sessions.json`).
|
||||||
- The flush runs only for embedded Pi sessions (CLI backends skip it).
|
- 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.
|
- 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
|
Pi also exposes a `session_before_compact` hook in the extension API, but Clawdbot’s
|
||||||
|
|||||||
246
src/auto-reply/reply/agent-runner.memory-flush.test.ts
Normal file
246
src/auto-reply/reply/agent-runner.memory-flush.test.ts
Normal file
@@ -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<unknown>;
|
||||||
|
}) => ({
|
||||||
|
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<typeof import("./queue.js")>("./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<string, unknown>;
|
||||||
|
}) {
|
||||||
|
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<string, unknown>;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,10 @@ import {
|
|||||||
isCompactionFailureError,
|
isCompactionFailureError,
|
||||||
isContextOverflowError,
|
isContextOverflowError,
|
||||||
} from "../../agents/pi-embedded-helpers.js";
|
} from "../../agents/pi-embedded-helpers.js";
|
||||||
|
import {
|
||||||
|
resolveSandboxConfigForAgent,
|
||||||
|
resolveSandboxRuntimeStatus,
|
||||||
|
} from "../../agents/sandbox.js";
|
||||||
import { hasNonzeroUsage, type NormalizedUsage } from "../../agents/usage.js";
|
import { hasNonzeroUsage, type NormalizedUsage } from "../../agents/usage.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import {
|
import {
|
||||||
@@ -343,8 +347,16 @@ export async function runReplyAgent(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const memoryFlushSettings = resolveMemoryFlushSettings(cfg);
|
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 =
|
const shouldFlushMemory =
|
||||||
memoryFlushSettings &&
|
memoryFlushSettings &&
|
||||||
|
memoryFlushWritable &&
|
||||||
!isHeartbeat &&
|
!isHeartbeat &&
|
||||||
!isCliProvider(followupRun.run.provider, cfg) &&
|
!isCliProvider(followupRun.run.provider, cfg) &&
|
||||||
shouldRunMemoryFlush({
|
shouldRunMemoryFlush({
|
||||||
|
|||||||
@@ -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", () => {
|
describe("config discord", () => {
|
||||||
let previousHome: string | undefined;
|
let previousHome: string | undefined;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user