mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Compaction: preserve opaque identifiers in summaries (openclaw#25553) thanks @rodrigouroz
Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: rodrigouroz <384037+rodrigouroz@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -291,6 +291,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber.
|
||||
- Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber.
|
||||
- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber.
|
||||
- Agents/Compaction: harden summarization prompts to preserve opaque identifiers verbatim (UUIDs, IDs, tokens, host/IP/port, URLs), reducing post-compaction identifier drift and hallucinated identifier reconstruction.
|
||||
- iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach.
|
||||
- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai.
|
||||
- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd.
|
||||
|
||||
@@ -22,6 +22,7 @@ Compaction **persists** in the session’s JSONL history.
|
||||
## Configuration
|
||||
|
||||
Use the `agents.defaults.compaction` setting in your `openclaw.json` to configure compaction behavior (mode, target tokens, etc.).
|
||||
Compaction summarization preserves opaque identifiers by default (`identifierPolicy: "strict"`). You can override this with `identifierPolicy: "off"` or provide custom text with `identifierPolicy: "custom"` and `identifierInstructions`.
|
||||
|
||||
## Auto-compaction (default on)
|
||||
|
||||
|
||||
@@ -939,6 +939,8 @@ Periodic heartbeat runs.
|
||||
compaction: {
|
||||
mode: "safeguard", // default | safeguard
|
||||
reserveTokensFloor: 24000,
|
||||
identifierPolicy: "strict", // strict | off | custom
|
||||
identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom
|
||||
memoryFlush: {
|
||||
enabled: true,
|
||||
softThresholdTokens: 6000,
|
||||
@@ -952,6 +954,8 @@ Periodic heartbeat runs.
|
||||
```
|
||||
|
||||
- `mode`: `default` or `safeguard` (chunked summarization for long histories). See [Compaction](/concepts/compaction).
|
||||
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
|
||||
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.
|
||||
- `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Skipped when workspace is read-only.
|
||||
|
||||
### `agents.defaults.contextPruning`
|
||||
|
||||
117
src/agents/compaction.identifier-policy.test.ts
Normal file
117
src/agents/compaction.identifier-policy.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import * as piCodingAgent from "@mariozechner/pi-coding-agent";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildCompactionSummarizationInstructions, summarizeInStages } from "./compaction.js";
|
||||
|
||||
vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof piCodingAgent>();
|
||||
return {
|
||||
...actual,
|
||||
generateSummary: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockGenerateSummary = vi.mocked(piCodingAgent.generateSummary);
|
||||
|
||||
function makeMessage(index: number, size = 1200): AgentMessage {
|
||||
return {
|
||||
role: "user",
|
||||
content: `m${index}-${"x".repeat(size)}`,
|
||||
timestamp: index,
|
||||
};
|
||||
}
|
||||
|
||||
describe("compaction identifier policy", () => {
|
||||
const testModel = {
|
||||
provider: "anthropic",
|
||||
model: "claude-3-opus",
|
||||
contextWindow: 200_000,
|
||||
} as unknown as NonNullable<ExtensionContext["model"]>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGenerateSummary.mockReset();
|
||||
mockGenerateSummary.mockResolvedValue("summary");
|
||||
});
|
||||
|
||||
it("defaults to strict identifier preservation", async () => {
|
||||
await summarizeInStages({
|
||||
messages: [makeMessage(1), makeMessage(2)],
|
||||
model: testModel,
|
||||
apiKey: "test-key",
|
||||
signal: new AbortController().signal,
|
||||
reserveTokens: 4000,
|
||||
maxChunkTokens: 8000,
|
||||
contextWindow: 200_000,
|
||||
});
|
||||
|
||||
const firstCall = mockGenerateSummary.mock.calls[0];
|
||||
expect(firstCall?.[5]).toContain("Preserve all opaque identifiers exactly as written");
|
||||
expect(firstCall?.[5]).toContain("UUIDs");
|
||||
});
|
||||
|
||||
it("can disable identifier preservation with off policy", async () => {
|
||||
await summarizeInStages({
|
||||
messages: [makeMessage(1), makeMessage(2)],
|
||||
model: testModel,
|
||||
apiKey: "test-key",
|
||||
signal: new AbortController().signal,
|
||||
reserveTokens: 4000,
|
||||
maxChunkTokens: 8000,
|
||||
contextWindow: 200_000,
|
||||
summarizationInstructions: { identifierPolicy: "off" },
|
||||
});
|
||||
|
||||
const firstCall = mockGenerateSummary.mock.calls[0];
|
||||
expect(firstCall?.[5]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("supports custom identifier instructions", async () => {
|
||||
await summarizeInStages({
|
||||
messages: [makeMessage(1), makeMessage(2)],
|
||||
model: testModel,
|
||||
apiKey: "test-key",
|
||||
signal: new AbortController().signal,
|
||||
reserveTokens: 4000,
|
||||
maxChunkTokens: 8000,
|
||||
contextWindow: 200_000,
|
||||
summarizationInstructions: {
|
||||
identifierPolicy: "custom",
|
||||
identifierInstructions: "Keep ticket IDs unchanged.",
|
||||
},
|
||||
});
|
||||
|
||||
const firstCall = mockGenerateSummary.mock.calls[0];
|
||||
expect(firstCall?.[5]).toContain("Keep ticket IDs unchanged.");
|
||||
expect(firstCall?.[5]).not.toContain("Preserve all opaque identifiers exactly as written");
|
||||
});
|
||||
|
||||
it("falls back to strict text when custom policy is missing instructions", () => {
|
||||
const built = buildCompactionSummarizationInstructions(undefined, {
|
||||
identifierPolicy: "custom",
|
||||
identifierInstructions: " ",
|
||||
});
|
||||
expect(built).toContain("Preserve all opaque identifiers exactly as written");
|
||||
});
|
||||
|
||||
it("avoids duplicate additional-focus headers in split+merge path", async () => {
|
||||
await summarizeInStages({
|
||||
messages: [makeMessage(1), makeMessage(2), makeMessage(3), makeMessage(4)],
|
||||
model: testModel,
|
||||
apiKey: "test-key",
|
||||
signal: new AbortController().signal,
|
||||
reserveTokens: 4000,
|
||||
maxChunkTokens: 1000,
|
||||
contextWindow: 200_000,
|
||||
parts: 2,
|
||||
minMessagesForSplit: 4,
|
||||
customInstructions: "Prioritize customer-visible regressions.",
|
||||
});
|
||||
|
||||
const mergedCall = mockGenerateSummary.mock.calls.at(-1);
|
||||
const instructions = mergedCall?.[5] ?? "";
|
||||
expect(instructions).toContain("Merge these partial summaries into a single cohesive summary.");
|
||||
expect(instructions).toContain("Prioritize customer-visible regressions.");
|
||||
expect((instructions.match(/Additional focus:/g) ?? []).length).toBe(1);
|
||||
});
|
||||
});
|
||||
128
src/agents/compaction.identifier-preservation.test.ts
Normal file
128
src/agents/compaction.identifier-preservation.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import * as piCodingAgent from "@mariozechner/pi-coding-agent";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildCompactionSummarizationInstructions, summarizeInStages } from "./compaction.js";
|
||||
|
||||
vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof piCodingAgent>();
|
||||
return {
|
||||
...actual,
|
||||
generateSummary: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockGenerateSummary = vi.mocked(piCodingAgent.generateSummary);
|
||||
|
||||
function makeMessage(index: number, size = 1200): AgentMessage {
|
||||
return {
|
||||
role: "user",
|
||||
content: `m${index}-${"x".repeat(size)}`,
|
||||
timestamp: index,
|
||||
};
|
||||
}
|
||||
|
||||
describe("compaction identifier-preservation instructions", () => {
|
||||
const testModel = {
|
||||
provider: "anthropic",
|
||||
model: "claude-3-opus",
|
||||
contextWindow: 200_000,
|
||||
} as unknown as NonNullable<ExtensionContext["model"]>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGenerateSummary.mockReset();
|
||||
mockGenerateSummary.mockResolvedValue("summary");
|
||||
});
|
||||
|
||||
it("injects identifier-preservation guidance even without custom instructions", async () => {
|
||||
await summarizeInStages({
|
||||
messages: [makeMessage(1), makeMessage(2)],
|
||||
model: testModel,
|
||||
apiKey: "test-key",
|
||||
signal: new AbortController().signal,
|
||||
reserveTokens: 4000,
|
||||
maxChunkTokens: 8000,
|
||||
contextWindow: 200_000,
|
||||
});
|
||||
|
||||
expect(mockGenerateSummary).toHaveBeenCalled();
|
||||
const firstCall = mockGenerateSummary.mock.calls[0];
|
||||
expect(firstCall?.[5]).toContain("Preserve all opaque identifiers exactly as written");
|
||||
expect(firstCall?.[5]).toContain("UUIDs");
|
||||
expect(firstCall?.[5]).toContain("IPs");
|
||||
expect(firstCall?.[5]).toContain("ports");
|
||||
});
|
||||
|
||||
it("keeps identifier-preservation guidance when custom instructions are provided", async () => {
|
||||
await summarizeInStages({
|
||||
messages: [makeMessage(1), makeMessage(2)],
|
||||
model: testModel,
|
||||
apiKey: "test-key",
|
||||
signal: new AbortController().signal,
|
||||
reserveTokens: 4000,
|
||||
maxChunkTokens: 8000,
|
||||
contextWindow: 200_000,
|
||||
customInstructions: "Focus on release-impacting bugs.",
|
||||
});
|
||||
|
||||
const firstCall = mockGenerateSummary.mock.calls[0];
|
||||
expect(firstCall?.[5]).toContain("Preserve all opaque identifiers exactly as written");
|
||||
expect(firstCall?.[5]).toContain("Additional focus:");
|
||||
expect(firstCall?.[5]).toContain("Focus on release-impacting bugs.");
|
||||
});
|
||||
|
||||
it("applies identifier-preservation guidance on staged split + merge summarization", async () => {
|
||||
await summarizeInStages({
|
||||
messages: [makeMessage(1), makeMessage(2), makeMessage(3), makeMessage(4)],
|
||||
model: testModel,
|
||||
apiKey: "test-key",
|
||||
signal: new AbortController().signal,
|
||||
reserveTokens: 4000,
|
||||
maxChunkTokens: 1000,
|
||||
contextWindow: 200_000,
|
||||
parts: 2,
|
||||
minMessagesForSplit: 4,
|
||||
});
|
||||
|
||||
expect(mockGenerateSummary.mock.calls.length).toBeGreaterThan(1);
|
||||
for (const call of mockGenerateSummary.mock.calls) {
|
||||
expect(call[5]).toContain("Preserve all opaque identifiers exactly as written");
|
||||
}
|
||||
});
|
||||
|
||||
it("avoids duplicate additional-focus headers in split+merge path", async () => {
|
||||
await summarizeInStages({
|
||||
messages: [makeMessage(1), makeMessage(2), makeMessage(3), makeMessage(4)],
|
||||
model: testModel,
|
||||
apiKey: "test-key",
|
||||
signal: new AbortController().signal,
|
||||
reserveTokens: 4000,
|
||||
maxChunkTokens: 1000,
|
||||
contextWindow: 200_000,
|
||||
parts: 2,
|
||||
minMessagesForSplit: 4,
|
||||
customInstructions: "Prioritize customer-visible regressions.",
|
||||
});
|
||||
|
||||
const mergedCall = mockGenerateSummary.mock.calls.at(-1);
|
||||
const instructions = mergedCall?.[5] ?? "";
|
||||
expect(instructions).toContain("Merge these partial summaries into a single cohesive summary.");
|
||||
expect(instructions).toContain("Prioritize customer-visible regressions.");
|
||||
expect((instructions.match(/Additional focus:/g) ?? []).length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCompactionSummarizationInstructions", () => {
|
||||
it("returns base instructions when no custom text is provided", () => {
|
||||
const result = buildCompactionSummarizationInstructions();
|
||||
expect(result).toContain("Preserve all opaque identifiers exactly as written");
|
||||
expect(result).not.toContain("Additional focus:");
|
||||
});
|
||||
|
||||
it("appends custom instructions in a stable format", () => {
|
||||
const result = buildCompactionSummarizationInstructions("Keep deployment details.");
|
||||
expect(result).toContain("Preserve all opaque identifiers exactly as written");
|
||||
expect(result).toContain("Additional focus:");
|
||||
expect(result).toContain("Keep deployment details.");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
|
||||
import type { AgentCompactionIdentifierPolicy } from "../config/types.agent-defaults.js";
|
||||
import { retryAsync } from "../infra/retry.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js";
|
||||
@@ -16,6 +17,46 @@ const DEFAULT_PARTS = 2;
|
||||
const MERGE_SUMMARIES_INSTRUCTIONS =
|
||||
"Merge these partial summaries into a single cohesive summary. Preserve decisions," +
|
||||
" TODOs, open questions, and any constraints.";
|
||||
const IDENTIFIER_PRESERVATION_INSTRUCTIONS =
|
||||
"Preserve all opaque identifiers exactly as written (no shortening or reconstruction), " +
|
||||
"including UUIDs, hashes, IDs, tokens, API keys, hostnames, IPs, ports, URLs, and file names.";
|
||||
|
||||
export type CompactionSummarizationInstructions = {
|
||||
identifierPolicy?: AgentCompactionIdentifierPolicy;
|
||||
identifierInstructions?: string;
|
||||
};
|
||||
|
||||
function resolveIdentifierPreservationInstructions(
|
||||
instructions?: CompactionSummarizationInstructions,
|
||||
): string | undefined {
|
||||
const policy = instructions?.identifierPolicy ?? "strict";
|
||||
if (policy === "off") {
|
||||
return undefined;
|
||||
}
|
||||
if (policy === "custom") {
|
||||
const custom = instructions?.identifierInstructions?.trim();
|
||||
return custom && custom.length > 0 ? custom : IDENTIFIER_PRESERVATION_INSTRUCTIONS;
|
||||
}
|
||||
return IDENTIFIER_PRESERVATION_INSTRUCTIONS;
|
||||
}
|
||||
|
||||
export function buildCompactionSummarizationInstructions(
|
||||
customInstructions?: string,
|
||||
instructions?: CompactionSummarizationInstructions,
|
||||
): string | undefined {
|
||||
const custom = customInstructions?.trim();
|
||||
const identifierPreservation = resolveIdentifierPreservationInstructions(instructions);
|
||||
if (!identifierPreservation && !custom) {
|
||||
return undefined;
|
||||
}
|
||||
if (!custom) {
|
||||
return identifierPreservation;
|
||||
}
|
||||
if (!identifierPreservation) {
|
||||
return `Additional focus:\n${custom}`;
|
||||
}
|
||||
return `${identifierPreservation}\n\nAdditional focus:\n${custom}`;
|
||||
}
|
||||
|
||||
export function estimateMessagesTokens(messages: AgentMessage[]): number {
|
||||
// SECURITY: toolResult.details can contain untrusted/verbose payloads; never include in LLM-facing compaction.
|
||||
@@ -164,6 +205,7 @@ async function summarizeChunks(params: {
|
||||
reserveTokens: number;
|
||||
maxChunkTokens: number;
|
||||
customInstructions?: string;
|
||||
summarizationInstructions?: CompactionSummarizationInstructions;
|
||||
previousSummary?: string;
|
||||
}): Promise<string> {
|
||||
if (params.messages.length === 0) {
|
||||
@@ -174,7 +216,10 @@ async function summarizeChunks(params: {
|
||||
const safeMessages = stripToolResultDetails(params.messages);
|
||||
const chunks = chunkMessagesByMaxTokens(safeMessages, params.maxChunkTokens);
|
||||
let summary = params.previousSummary;
|
||||
|
||||
const effectiveInstructions = buildCompactionSummarizationInstructions(
|
||||
params.customInstructions,
|
||||
params.summarizationInstructions,
|
||||
);
|
||||
for (const chunk of chunks) {
|
||||
summary = await retryAsync(
|
||||
() =>
|
||||
@@ -184,7 +229,7 @@ async function summarizeChunks(params: {
|
||||
params.reserveTokens,
|
||||
params.apiKey,
|
||||
params.signal,
|
||||
params.customInstructions,
|
||||
effectiveInstructions,
|
||||
summary,
|
||||
),
|
||||
{
|
||||
@@ -214,6 +259,7 @@ export async function summarizeWithFallback(params: {
|
||||
maxChunkTokens: number;
|
||||
contextWindow: number;
|
||||
customInstructions?: string;
|
||||
summarizationInstructions?: CompactionSummarizationInstructions;
|
||||
previousSummary?: string;
|
||||
}): Promise<string> {
|
||||
const { messages, contextWindow } = params;
|
||||
@@ -282,6 +328,7 @@ export async function summarizeInStages(params: {
|
||||
maxChunkTokens: number;
|
||||
contextWindow: number;
|
||||
customInstructions?: string;
|
||||
summarizationInstructions?: CompactionSummarizationInstructions;
|
||||
previousSummary?: string;
|
||||
parts?: number;
|
||||
minMessagesForSplit?: number;
|
||||
@@ -325,8 +372,9 @@ export async function summarizeInStages(params: {
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
|
||||
const mergeInstructions = params.customInstructions
|
||||
? `${MERGE_SUMMARIES_INSTRUCTIONS}\n\nAdditional focus:\n${params.customInstructions}`
|
||||
const custom = params.customInstructions?.trim();
|
||||
const mergeInstructions = custom
|
||||
? `${MERGE_SUMMARIES_INSTRUCTIONS}\n\n${custom}`
|
||||
: MERGE_SUMMARIES_INSTRUCTIONS;
|
||||
|
||||
return summarizeWithFallback({
|
||||
|
||||
@@ -81,6 +81,8 @@ export function buildEmbeddedExtensionFactories(params: {
|
||||
setCompactionSafeguardRuntime(params.sessionManager, {
|
||||
maxHistoryShare: compactionCfg?.maxHistoryShare,
|
||||
contextWindowTokens: contextWindowInfo.tokens,
|
||||
identifierPolicy: compactionCfg?.identifierPolicy,
|
||||
identifierInstructions: compactionCfg?.identifierInstructions,
|
||||
model: params.model,
|
||||
});
|
||||
factories.push(compactionSafeguardExtension);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Api, Model } from "@mariozechner/pi-ai";
|
||||
import type { AgentCompactionIdentifierPolicy } from "../../config/types.agent-defaults.js";
|
||||
import { createSessionManagerRuntimeRegistry } from "./session-manager-runtime-registry.js";
|
||||
|
||||
export type CompactionSafeguardRuntimeValue = {
|
||||
maxHistoryShare?: number;
|
||||
contextWindowTokens?: number;
|
||||
identifierPolicy?: AgentCompactionIdentifierPolicy;
|
||||
identifierInstructions?: string;
|
||||
/**
|
||||
* Model to use for compaction summarization.
|
||||
* Passed through runtime because `ctx.model` is undefined in the compact.ts workflow
|
||||
|
||||
@@ -212,6 +212,10 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
// Model resolution: ctx.model is undefined in compact.ts workflow (extensionRunner.initialize() is never called).
|
||||
// Fall back to runtime.model which is explicitly passed when building extension paths.
|
||||
const runtime = getCompactionSafeguardRuntime(ctx.sessionManager);
|
||||
const summarizationInstructions = {
|
||||
identifierPolicy: runtime?.identifierPolicy,
|
||||
identifierInstructions: runtime?.identifierInstructions,
|
||||
};
|
||||
const model = ctx.model ?? runtime?.model;
|
||||
if (!model) {
|
||||
// Log warning once per session when both models are missing (diagnostic for future issues).
|
||||
@@ -295,6 +299,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
maxChunkTokens: droppedMaxChunkTokens,
|
||||
contextWindow: contextWindowTokens,
|
||||
customInstructions,
|
||||
summarizationInstructions,
|
||||
previousSummary: preparation.previousSummary,
|
||||
});
|
||||
} catch (droppedError) {
|
||||
@@ -333,6 +338,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
maxChunkTokens,
|
||||
contextWindow: contextWindowTokens,
|
||||
customInstructions,
|
||||
summarizationInstructions,
|
||||
previousSummary: effectivePreviousSummary,
|
||||
});
|
||||
|
||||
@@ -347,6 +353,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
maxChunkTokens,
|
||||
contextWindow: contextWindowTokens,
|
||||
customInstructions: TURN_PREFIX_INSTRUCTIONS,
|
||||
summarizationInstructions,
|
||||
previousSummary: undefined,
|
||||
});
|
||||
summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`;
|
||||
|
||||
@@ -11,6 +11,8 @@ describe("config compaction settings", () => {
|
||||
compaction: {
|
||||
mode: "safeguard",
|
||||
reserveTokensFloor: 12_345,
|
||||
identifierPolicy: "custom",
|
||||
identifierInstructions: "Keep ticket IDs unchanged.",
|
||||
memoryFlush: {
|
||||
enabled: false,
|
||||
softThresholdTokens: 1234,
|
||||
@@ -28,6 +30,10 @@ describe("config compaction settings", () => {
|
||||
expect(cfg.agents?.defaults?.compaction?.mode).toBe("safeguard");
|
||||
expect(cfg.agents?.defaults?.compaction?.reserveTokens).toBeUndefined();
|
||||
expect(cfg.agents?.defaults?.compaction?.keepRecentTokens).toBeUndefined();
|
||||
expect(cfg.agents?.defaults?.compaction?.identifierPolicy).toBe("custom");
|
||||
expect(cfg.agents?.defaults?.compaction?.identifierInstructions).toBe(
|
||||
"Keep ticket IDs unchanged.",
|
||||
);
|
||||
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.");
|
||||
|
||||
@@ -361,6 +361,8 @@ const TARGET_KEYS = [
|
||||
"agents.defaults.compaction.keepRecentTokens",
|
||||
"agents.defaults.compaction.reserveTokensFloor",
|
||||
"agents.defaults.compaction.maxHistoryShare",
|
||||
"agents.defaults.compaction.identifierPolicy",
|
||||
"agents.defaults.compaction.identifierInstructions",
|
||||
"agents.defaults.compaction.memoryFlush",
|
||||
"agents.defaults.compaction.memoryFlush.enabled",
|
||||
"agents.defaults.compaction.memoryFlush.softThresholdTokens",
|
||||
@@ -415,6 +417,7 @@ const ENUM_EXPECTATIONS: Record<string, string[]> = {
|
||||
"logging.redactSensitive": ['"off"', '"tools"'],
|
||||
"update.channel": ['"stable"', '"beta"', '"dev"'],
|
||||
"agents.defaults.compaction.mode": ['"default"', '"safeguard"'],
|
||||
"agents.defaults.compaction.identifierPolicy": ['"strict"', '"off"', '"custom"'],
|
||||
};
|
||||
|
||||
const TOOLS_HOOKS_TARGET_KEYS = [
|
||||
@@ -777,6 +780,11 @@ describe("config help copy quality", () => {
|
||||
const historyShare = FIELD_HELP["agents.defaults.compaction.maxHistoryShare"];
|
||||
expect(/0\\.1-0\\.9|fraction|share/i.test(historyShare)).toBe(true);
|
||||
|
||||
const identifierPolicy = FIELD_HELP["agents.defaults.compaction.identifierPolicy"];
|
||||
expect(identifierPolicy.includes('"strict"')).toBe(true);
|
||||
expect(identifierPolicy.includes('"off"')).toBe(true);
|
||||
expect(identifierPolicy.includes('"custom"')).toBe(true);
|
||||
|
||||
const flush = FIELD_HELP["agents.defaults.compaction.memoryFlush.enabled"];
|
||||
expect(/pre-compaction|memory flush|token/i.test(flush)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -920,6 +920,10 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"Minimum floor enforced for reserveTokens in Pi compaction paths (0 disables the floor guard). Use a non-zero floor to avoid over-aggressive compression under fluctuating token estimates.",
|
||||
"agents.defaults.compaction.maxHistoryShare":
|
||||
"Maximum fraction of total context budget allowed for retained history after compaction (range 0.1-0.9). Use lower shares for more generation headroom or higher shares for deeper historical continuity.",
|
||||
"agents.defaults.compaction.identifierPolicy":
|
||||
'Identifier-preservation policy for compaction summaries: "strict" prepends built-in opaque-identifier retention guidance (default), "off" disables this prefix, and "custom" uses identifierInstructions. Keep "strict" unless you have a specific compatibility need.',
|
||||
"agents.defaults.compaction.identifierInstructions":
|
||||
'Custom identifier-preservation instruction text used when identifierPolicy="custom". Keep this explicit and safety-focused so compaction summaries do not rewrite opaque IDs, URLs, hosts, or ports.',
|
||||
"agents.defaults.compaction.memoryFlush":
|
||||
"Pre-compaction memory flush settings that run an agentic memory write before heavy compaction. Keep enabled for long sessions so salient context is persisted before aggressive trimming.",
|
||||
"agents.defaults.compaction.memoryFlush.enabled":
|
||||
|
||||
@@ -408,6 +408,8 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"agents.defaults.compaction.keepRecentTokens": "Compaction Keep Recent Tokens",
|
||||
"agents.defaults.compaction.reserveTokensFloor": "Compaction Reserve Token Floor",
|
||||
"agents.defaults.compaction.maxHistoryShare": "Compaction Max History Share",
|
||||
"agents.defaults.compaction.identifierPolicy": "Compaction Identifier Policy",
|
||||
"agents.defaults.compaction.identifierInstructions": "Compaction Identifier Instructions",
|
||||
"agents.defaults.compaction.memoryFlush": "Compaction Memory Flush",
|
||||
"agents.defaults.compaction.memoryFlush.enabled": "Compaction Memory Flush Enabled",
|
||||
"agents.defaults.compaction.memoryFlush.softThresholdTokens":
|
||||
|
||||
@@ -269,6 +269,7 @@ export type AgentDefaultsConfig = {
|
||||
};
|
||||
|
||||
export type AgentCompactionMode = "default" | "safeguard";
|
||||
export type AgentCompactionIdentifierPolicy = "strict" | "off" | "custom";
|
||||
|
||||
export type AgentCompactionConfig = {
|
||||
/** Compaction summarization mode. */
|
||||
@@ -281,6 +282,10 @@ export type AgentCompactionConfig = {
|
||||
reserveTokensFloor?: number;
|
||||
/** Max share of context window for history during safeguard pruning (0.1–0.9, default 0.5). */
|
||||
maxHistoryShare?: number;
|
||||
/** Identifier-preservation instruction policy for compaction summaries. */
|
||||
identifierPolicy?: AgentCompactionIdentifierPolicy;
|
||||
/** Custom identifier-preservation instructions used when identifierPolicy is "custom". */
|
||||
identifierInstructions?: string;
|
||||
/** Pre-compaction memory flush (agentic turn). Default: enabled. */
|
||||
memoryFlush?: AgentCompactionMemoryFlushConfig;
|
||||
};
|
||||
|
||||
@@ -84,6 +84,10 @@ export const AgentDefaultsSchema = z
|
||||
keepRecentTokens: z.number().int().positive().optional(),
|
||||
reserveTokensFloor: z.number().int().nonnegative().optional(),
|
||||
maxHistoryShare: z.number().min(0.1).max(0.9).optional(),
|
||||
identifierPolicy: z
|
||||
.union([z.literal("strict"), z.literal("off"), z.literal("custom")])
|
||||
.optional(),
|
||||
identifierInstructions: z.string().optional(),
|
||||
memoryFlush: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
|
||||
Reference in New Issue
Block a user