Merge pull request #700 from clawdbot/shadow/compaction
Agents: safeguard compaction summarization
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
### Changes
|
||||
- Cron: accept ISO timestamps for one-shot schedules (UTC) and allow optional delete-after-run; wired into CLI + macOS editor.
|
||||
- Gateway: add Tailscale binary discovery, custom bind mode, and probe auth retry for password changes. (#740 — thanks @jeffersonwarrior)
|
||||
- Agents: add compaction mode config with optional safeguard summarization for long histories. (#700 — thanks @thewilloftheshadow)
|
||||
|
||||
### Fixes
|
||||
- Gateway: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides when checking or restarting the daemon.
|
||||
|
||||
40
docs/compaction.md
Normal file
40
docs/compaction.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
summary: "Compaction modes and configuration"
|
||||
read_when:
|
||||
- You want to configure compaction summarization behavior
|
||||
- You are tuning compaction settings in clawdbot.json
|
||||
---
|
||||
# Compaction
|
||||
|
||||
Compaction summarizes older session history so the conversation stays within the model context window. The summary is stored in the session JSONL history and combined with the most recent messages.
|
||||
|
||||
## Modes
|
||||
|
||||
`agents.defaults.compaction.mode` controls how summaries are generated.
|
||||
|
||||
- `default` (default): use the built-in compaction summarizer.
|
||||
- `safeguard`: uses a chunked summarization pass to avoid context overflow for very long histories. If chunked summarization fails, Clawdbot falls back to a minimal summary plus file-operation metadata.
|
||||
|
||||
## Configuration
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
mode: "safeguard",
|
||||
reserveTokensFloor: 20000,
|
||||
memoryFlush: {
|
||||
enabled: true,
|
||||
softThresholdTokens: 4000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Related docs
|
||||
|
||||
- [Context window + compaction behavior](/concepts/compaction)
|
||||
- [Gateway configuration reference](/gateway/configuration)
|
||||
@@ -15,6 +15,9 @@ Compaction **summarizes older conversation** into a compact summary entry and ke
|
||||
|
||||
Compaction **persists** in the session’s JSONL history.
|
||||
|
||||
## Configuration
|
||||
See [Compaction config & modes](/compaction) for the `agents.defaults.compaction` settings.
|
||||
|
||||
## Auto-compaction (default on)
|
||||
When a session nears or exceeds the model’s context window, Clawdbot triggers auto-compaction and may retry the original request using the compacted context.
|
||||
|
||||
|
||||
@@ -1392,6 +1392,8 @@ See [/concepts/session-pruning](/concepts/session-pruning) for behavior details.
|
||||
|
||||
#### `agents.defaults.compaction` (reserve headroom + memory flush)
|
||||
|
||||
`agents.defaults.compaction.mode` selects the compaction summarization strategy. Defaults to `default`; set `safeguard` to enable chunked summarization for very long histories. See [/compaction](/compaction).
|
||||
|
||||
`agents.defaults.compaction.reserveTokensFloor` enforces a minimum `reserveTokens`
|
||||
value for Pi compaction (default: `20000`). Set it to `0` to disable the floor.
|
||||
|
||||
@@ -1413,6 +1415,7 @@ Example (tuned):
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
mode: "safeguard",
|
||||
reserveTokensFloor: 24000,
|
||||
memoryFlush: {
|
||||
enabled: true,
|
||||
|
||||
@@ -321,6 +321,12 @@ function buildContextPruningExtension(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCompactionMode(cfg?: ClawdbotConfig): "default" | "safeguard" {
|
||||
return cfg?.agents?.defaults?.compaction?.mode === "safeguard"
|
||||
? "safeguard"
|
||||
: "default";
|
||||
}
|
||||
|
||||
function buildEmbeddedExtensionPaths(params: {
|
||||
cfg: ClawdbotConfig | undefined;
|
||||
sessionManager: SessionManager;
|
||||
@@ -329,6 +335,9 @@ function buildEmbeddedExtensionPaths(params: {
|
||||
model: Model<Api> | undefined;
|
||||
}): string[] {
|
||||
const paths = [resolvePiExtensionPath("transcript-sanitize")];
|
||||
if (resolveCompactionMode(params.cfg) === "safeguard") {
|
||||
paths.push(resolvePiExtensionPath("compaction-safeguard"));
|
||||
}
|
||||
const pruning = buildContextPruningExtension(params);
|
||||
if (pruning.additionalExtensionPaths) {
|
||||
paths.push(...pruning.additionalExtensionPaths);
|
||||
|
||||
212
src/agents/pi-extensions/compaction-safeguard.ts
Normal file
212
src/agents/pi-extensions/compaction-safeguard.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
FileOperations,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
||||
|
||||
const MAX_CHUNK_RATIO = 0.4;
|
||||
const FALLBACK_SUMMARY =
|
||||
"Summary unavailable due to context limits. Older messages were truncated.";
|
||||
const TURN_PREFIX_INSTRUCTIONS =
|
||||
"This summary covers the prefix of a split turn. Focus on the original request," +
|
||||
" early progress, and any details needed to understand the retained suffix.";
|
||||
|
||||
function computeFileLists(fileOps: FileOperations): {
|
||||
readFiles: string[];
|
||||
modifiedFiles: string[];
|
||||
} {
|
||||
const modified = new Set([...fileOps.edited, ...fileOps.written]);
|
||||
const readFiles = [...fileOps.read].filter((f) => !modified.has(f)).sort();
|
||||
const modifiedFiles = [...modified].sort();
|
||||
return { readFiles, modifiedFiles };
|
||||
}
|
||||
|
||||
function formatFileOperations(
|
||||
readFiles: string[],
|
||||
modifiedFiles: string[],
|
||||
): string {
|
||||
const sections: string[] = [];
|
||||
if (readFiles.length > 0) {
|
||||
sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
|
||||
}
|
||||
if (modifiedFiles.length > 0) {
|
||||
sections.push(
|
||||
`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`,
|
||||
);
|
||||
}
|
||||
if (sections.length === 0) return "";
|
||||
return `\n\n${sections.join("\n\n")}`;
|
||||
}
|
||||
|
||||
function chunkMessages(
|
||||
messages: AgentMessage[],
|
||||
maxTokens: number,
|
||||
): AgentMessage[][] {
|
||||
if (messages.length === 0) return [];
|
||||
|
||||
const chunks: AgentMessage[][] = [];
|
||||
let currentChunk: AgentMessage[] = [];
|
||||
let currentTokens = 0;
|
||||
|
||||
for (const message of messages) {
|
||||
const messageTokens = estimateTokens(message);
|
||||
if (currentChunk.length > 0 && currentTokens + messageTokens > maxTokens) {
|
||||
chunks.push(currentChunk);
|
||||
currentChunk = [];
|
||||
currentTokens = 0;
|
||||
}
|
||||
|
||||
currentChunk.push(message);
|
||||
currentTokens += messageTokens;
|
||||
|
||||
if (messageTokens > maxTokens) {
|
||||
// Split oversized messages to avoid unbounded chunk growth.
|
||||
chunks.push(currentChunk);
|
||||
currentChunk = [];
|
||||
currentTokens = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentChunk.length > 0) {
|
||||
chunks.push(currentChunk);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
async function summarizeChunks(params: {
|
||||
messages: AgentMessage[];
|
||||
model: NonNullable<ExtensionContext["model"]>;
|
||||
apiKey: string;
|
||||
signal: AbortSignal;
|
||||
reserveTokens: number;
|
||||
maxChunkTokens: number;
|
||||
customInstructions?: string;
|
||||
previousSummary?: string;
|
||||
}): Promise<string> {
|
||||
if (params.messages.length === 0) {
|
||||
return params.previousSummary ?? "No prior history.";
|
||||
}
|
||||
|
||||
const chunks = chunkMessages(params.messages, params.maxChunkTokens);
|
||||
let summary = params.previousSummary;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
summary = await generateSummary(
|
||||
chunk,
|
||||
params.model,
|
||||
params.reserveTokens,
|
||||
params.apiKey,
|
||||
params.signal,
|
||||
params.customInstructions,
|
||||
summary,
|
||||
);
|
||||
}
|
||||
|
||||
return summary ?? "No prior history.";
|
||||
}
|
||||
|
||||
export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
api.on("session_before_compact", async (event, ctx) => {
|
||||
const { preparation, customInstructions, signal } = event;
|
||||
const { readFiles, modifiedFiles } = computeFileLists(preparation.fileOps);
|
||||
const fileOpsSummary = formatFileOperations(readFiles, modifiedFiles);
|
||||
const fallbackSummary = `${FALLBACK_SUMMARY}${fileOpsSummary}`;
|
||||
|
||||
const model = ctx.model;
|
||||
if (!model) {
|
||||
return {
|
||||
compaction: {
|
||||
summary: fallbackSummary,
|
||||
firstKeptEntryId: preparation.firstKeptEntryId,
|
||||
tokensBefore: preparation.tokensBefore,
|
||||
details: { readFiles, modifiedFiles },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const apiKey = await ctx.modelRegistry.getApiKey(model);
|
||||
if (!apiKey) {
|
||||
return {
|
||||
compaction: {
|
||||
summary: fallbackSummary,
|
||||
firstKeptEntryId: preparation.firstKeptEntryId,
|
||||
tokensBefore: preparation.tokensBefore,
|
||||
details: { readFiles, modifiedFiles },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const contextWindowTokens = Math.max(
|
||||
1,
|
||||
Math.floor(model.contextWindow ?? DEFAULT_CONTEXT_TOKENS),
|
||||
);
|
||||
const maxChunkTokens = Math.max(
|
||||
1,
|
||||
Math.floor(contextWindowTokens * MAX_CHUNK_RATIO),
|
||||
);
|
||||
const reserveTokens = Math.max(
|
||||
1,
|
||||
Math.floor(preparation.settings.reserveTokens),
|
||||
);
|
||||
|
||||
const historySummary = await summarizeChunks({
|
||||
messages: preparation.messagesToSummarize,
|
||||
model,
|
||||
apiKey,
|
||||
signal,
|
||||
reserveTokens,
|
||||
maxChunkTokens,
|
||||
customInstructions,
|
||||
previousSummary: preparation.previousSummary,
|
||||
});
|
||||
|
||||
let summary = historySummary;
|
||||
if (
|
||||
preparation.isSplitTurn &&
|
||||
preparation.turnPrefixMessages.length > 0
|
||||
) {
|
||||
const prefixSummary = await summarizeChunks({
|
||||
messages: preparation.turnPrefixMessages,
|
||||
model,
|
||||
apiKey,
|
||||
signal,
|
||||
reserveTokens,
|
||||
maxChunkTokens,
|
||||
customInstructions: TURN_PREFIX_INSTRUCTIONS,
|
||||
});
|
||||
summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`;
|
||||
}
|
||||
|
||||
summary += fileOpsSummary;
|
||||
|
||||
return {
|
||||
compaction: {
|
||||
summary,
|
||||
firstKeptEntryId: preparation.firstKeptEntryId,
|
||||
tokensBefore: preparation.tokensBefore,
|
||||
details: { readFiles, modifiedFiles },
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Compaction summarization failed; truncating history: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return {
|
||||
compaction: {
|
||||
summary: fallbackSummary,
|
||||
firstKeptEntryId: preparation.firstKeptEntryId,
|
||||
tokensBefore: preparation.tokensBefore,
|
||||
details: { readFiles, modifiedFiles },
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -522,6 +522,7 @@ describe("config compaction settings", () => {
|
||||
agents: {
|
||||
defaults: {
|
||||
compaction: {
|
||||
mode: "safeguard",
|
||||
reserveTokensFloor: 12_345,
|
||||
memoryFlush: {
|
||||
enabled: false,
|
||||
@@ -544,6 +545,7 @@ describe("config compaction settings", () => {
|
||||
const cfg = loadConfig();
|
||||
|
||||
expect(cfg.agents?.defaults?.compaction?.reserveTokensFloor).toBe(12_345);
|
||||
expect(cfg.agents?.defaults?.compaction?.mode).toBe("safeguard");
|
||||
expect(cfg.agents?.defaults?.compaction?.memoryFlush?.enabled).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -1716,7 +1716,11 @@ export type AgentDefaultsConfig = {
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentCompactionMode = "default" | "safeguard";
|
||||
|
||||
export type AgentCompactionConfig = {
|
||||
/** Compaction summarization mode. */
|
||||
mode?: AgentCompactionMode;
|
||||
/** Minimum reserve tokens enforced for Pi compaction (0 disables the floor). */
|
||||
reserveTokensFloor?: number;
|
||||
/** Pre-compaction memory flush (agentic turn). Default: enabled. */
|
||||
|
||||
@@ -1210,6 +1210,9 @@ const AgentDefaultsSchema = z
|
||||
.optional(),
|
||||
compaction: z
|
||||
.object({
|
||||
mode: z
|
||||
.union([z.literal("default"), z.literal("safeguard")])
|
||||
.optional(),
|
||||
reserveTokensFloor: z.number().int().nonnegative().optional(),
|
||||
memoryFlush: z
|
||||
.object({
|
||||
|
||||
Reference in New Issue
Block a user