mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
fix(agents): record what agents re-read after auto-compaction (#128148)
* feat(agents): record post-compaction re-read facts in guard window summary The post-compaction loop guard observed up to three tool outcomes after auto-compaction but recorded nothing unless an exact args+result loop persisted. Re-reads of content the compaction just summarized — the common quality signal — left no trace at all. Keep a bounded recent-call tail so arming snapshots the pre-compaction baseline, count window observations that repeat pre-compaction signatures, and log one bounded summary line when the window closes without aborting. * test(agents): remove redundant compaction guard cases
This commit is contained in:
committed by
GitHub
parent
2b27b4bcf9
commit
33ed38bbf1
@@ -1,5 +1,18 @@
|
||||
// Coverage for detecting repeated tool loops immediately after compaction.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const logInfo = vi.hoisted(() => vi.fn());
|
||||
const logError = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../logging/subsystem.js", () => ({
|
||||
createSubsystemLogger: vi.fn(() => ({
|
||||
info: logInfo,
|
||||
error: logError,
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
import {
|
||||
createPostCompactionLoopGuard,
|
||||
PostCompactionLoopPersistedError,
|
||||
@@ -120,6 +133,64 @@ describe("createPostCompactionLoopGuard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("post-compaction re-read instrumentation", () => {
|
||||
beforeEach(() => {
|
||||
logInfo.mockClear();
|
||||
logError.mockClear();
|
||||
});
|
||||
|
||||
it("summarizes post-compaction re-reads against the pre-compaction baseline", () => {
|
||||
const guard = createPostCompactionLoopGuard();
|
||||
guard.observe(callOutcome("read", { path: "/report.md" }, "content-v1"));
|
||||
guard.observe(callOutcome("exec", { cmd: "ls" }, "ok"));
|
||||
guard.armPostCompaction();
|
||||
// The model immediately re-reads what compaction summarized away, then moves on.
|
||||
guard.observe(callOutcome("read", { path: "/report.md" }, "content-v2"));
|
||||
guard.observe(callOutcome("read", { path: "/other.md" }, "fresh"));
|
||||
const last = guard.observe(callOutcome("gateway", { action: "probe" }, "r1"));
|
||||
expect(last.shouldAbort).toBe(false);
|
||||
expect(logInfo).toHaveBeenCalledWith(expect.stringContaining("post-compaction window closed"));
|
||||
const summary = logInfo.mock.calls
|
||||
.map((call) => call[0] as string)
|
||||
.find((message) => message.includes("post-compaction window closed"));
|
||||
expect(summary).toContain("toolCalls=3");
|
||||
expect(summary).toContain("preCompactionRepeats=1");
|
||||
expect(summary).toContain("read");
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports zero re-reads when the window introduces only fresh calls", () => {
|
||||
const guard = createPostCompactionLoopGuard();
|
||||
guard.observe(callOutcome("read", { path: "/a.md" }, "v1"));
|
||||
guard.armPostCompaction();
|
||||
guard.observe(callOutcome("read", { path: "/b.md" }, "v1"));
|
||||
guard.observe(callOutcome("read", { path: "/c.md" }, "v1"));
|
||||
guard.observe(callOutcome("exec", { cmd: "ls" }, "ok"));
|
||||
const summary = logInfo.mock.calls
|
||||
.map((call) => call[0] as string)
|
||||
.find((message) => message.includes("post-compaction window closed"));
|
||||
expect(summary).toContain("preCompactionRepeats=0");
|
||||
});
|
||||
|
||||
it("does not count signatures evicted from the bounded baseline", () => {
|
||||
const guard = createPostCompactionLoopGuard();
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
guard.observe(callOutcome("read", { path: `/old-${i}.md` }, "v1"));
|
||||
}
|
||||
guard.armPostCompaction();
|
||||
// Signature from before the baseline window: no longer comparable.
|
||||
guard.observe(callOutcome("read", { path: "/old-0.md" }, "v2"));
|
||||
// Signature still inside the baseline window tail.
|
||||
guard.observe(callOutcome("read", { path: "/old-19.md" }, "v2"));
|
||||
guard.observe(callOutcome("gateway", { action: "probe" }, "r1"));
|
||||
const summary = logInfo.mock.calls
|
||||
.map((call) => call[0] as string)
|
||||
.find((message) => message.includes("post-compaction window closed"));
|
||||
expect(summary).toContain("toolCalls=3");
|
||||
expect(summary).toContain("preCompactionRepeats=1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PostCompactionLoopPersistedError", () => {
|
||||
it("captures the detector, count, toolName, and message", () => {
|
||||
const err = new PostCompactionLoopPersistedError("loop persisted", {
|
||||
|
||||
@@ -13,6 +13,11 @@ const log = createSubsystemLogger("agents/post-compaction-guard");
|
||||
|
||||
const DEFAULT_WINDOW_SIZE = 3;
|
||||
|
||||
// Bounded recent-call tail kept across the whole run so arming can snapshot what the
|
||||
// model was doing right before compaction. Without it, re-reads of summarized content
|
||||
// inside the post-compaction window leave no recorded fact at all.
|
||||
const BASELINE_WINDOW_SIZE = 16;
|
||||
|
||||
type PostCompactionGuardObservation = {
|
||||
toolName: string;
|
||||
argsHash: string;
|
||||
@@ -42,8 +47,16 @@ type GuardState = {
|
||||
windowSize: number;
|
||||
remainingAttempts: number;
|
||||
history: PostCompactionGuardObservation[];
|
||||
recentCalls: PostCompactionGuardObservation[];
|
||||
baselineSignatures: Set<string> | undefined;
|
||||
windowObserved: number;
|
||||
windowRepeats: number;
|
||||
repeatTools: Set<string>;
|
||||
};
|
||||
|
||||
const observationSignature = (call: PostCompactionGuardObservation): string =>
|
||||
`${call.toolName}\0${call.argsHash}`;
|
||||
|
||||
/** Creates a stateful post-compaction loop detector for one embedded run. */
|
||||
export function createPostCompactionLoopGuard(options?: {
|
||||
enabled?: boolean;
|
||||
@@ -53,24 +66,56 @@ export function createPostCompactionLoopGuard(options?: {
|
||||
windowSize: DEFAULT_WINDOW_SIZE,
|
||||
remainingAttempts: 0,
|
||||
history: [],
|
||||
recentCalls: [],
|
||||
baselineSignatures: undefined,
|
||||
windowObserved: 0,
|
||||
windowRepeats: 0,
|
||||
repeatTools: new Set<string>(),
|
||||
};
|
||||
|
||||
const armPostCompaction = (): void => {
|
||||
// Snapshot the pre-compaction call tail before the new window starts. A re-arm
|
||||
// mid-window replaces the unclosed window's counts; compaction success implies
|
||||
// the prior attempt ended, so that loss is accepted.
|
||||
state.baselineSignatures =
|
||||
state.enabled && state.recentCalls.length > 0
|
||||
? new Set(state.recentCalls.map(observationSignature))
|
||||
: undefined;
|
||||
state.remainingAttempts = state.windowSize;
|
||||
state.history = [];
|
||||
state.windowObserved = 0;
|
||||
state.windowRepeats = 0;
|
||||
state.repeatTools = new Set<string>();
|
||||
if (state.enabled) {
|
||||
log.info(`post-compaction guard armed for ${state.windowSize} attempts`);
|
||||
}
|
||||
};
|
||||
|
||||
const logWindowSummary = (): void => {
|
||||
const tools = [...state.repeatTools].toSorted().join(",");
|
||||
log.info(
|
||||
`post-compaction window closed: toolCalls=${state.windowObserved} ` +
|
||||
`preCompactionRepeats=${state.windowRepeats}${tools ? ` tools=${tools}` : ""}`,
|
||||
);
|
||||
};
|
||||
|
||||
const observe = (call: PostCompactionGuardObservation): PostCompactionGuardVerdict => {
|
||||
if (!state.enabled) {
|
||||
return { shouldAbort: false, armed: false, remainingAttempts: 0 };
|
||||
}
|
||||
state.recentCalls.push(call);
|
||||
if (state.recentCalls.length > BASELINE_WINDOW_SIZE) {
|
||||
state.recentCalls.shift();
|
||||
}
|
||||
if (state.remainingAttempts <= 0) {
|
||||
return { shouldAbort: false, armed: false, remainingAttempts: 0 };
|
||||
}
|
||||
state.remainingAttempts -= 1;
|
||||
state.windowObserved += 1;
|
||||
if (state.baselineSignatures?.has(observationSignature(call))) {
|
||||
state.windowRepeats += 1;
|
||||
state.repeatTools.add(call.toolName);
|
||||
}
|
||||
state.history.push(call);
|
||||
const armedAfter = state.remainingAttempts > 0;
|
||||
|
||||
@@ -98,6 +143,14 @@ export function createPostCompactionLoopGuard(options?: {
|
||||
};
|
||||
}
|
||||
|
||||
if (!armedAfter) {
|
||||
logWindowSummary();
|
||||
state.baselineSignatures = undefined;
|
||||
state.windowObserved = 0;
|
||||
state.windowRepeats = 0;
|
||||
state.repeatTools = new Set<string>();
|
||||
}
|
||||
|
||||
return { shouldAbort: false, armed: armedAfter, remainingAttempts: state.remainingAttempts };
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user