diff --git a/CHANGELOG.md b/CHANGELOG.md index 30b08625751..46cc1106846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4036,6 +4036,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic - Gateway/Daemon/Doctor: atomic config writes; repair gateway service entrypoint + install switches; non-interactive legacy migrations; systemd unit alignment + KillMode=process; node bridge keepalive/pings; Launch at Login persistence; bundle MoltbotKit resources + Swift 6.2 compat dylib; relay version check + remove smoke test; regen Swift GatewayModels + keep agent provider string; cron jobId alias + channel alias migration + main session key normalization; heartbeat Telegram accountId resolution; avoid WhatsApp fallback for internal runs; gateway listener error wording; serveBaseUrl param; honor gateway --dev; fix wide-area discovery updates; align agents.defaults schema; provider account metadata in daemon status; refresh Carbon patch for gateway fixes; restore doctor prompter initialValue handling. - Control UI/TUI: persist per-session verbose off + hide tool cards; logs tab opens at bottom; relative asset paths + landing cleanup; session labels lookup/persistence; stop pinning main session in recents; start logs at bottom; TUI status bar refresh + timeout handling + hide reasoning label when off. - Onboarding/Configure: QuickStart single-select provider picker; avoid Codex CLI false-expiry warnings; clarify WhatsApp owner prompt; fix Minimax hosted onboarding (agents.defaults + msteams heartbeat target); remove configure Control UI prompt; honor gateway --dev flag. +- Agent loop: guard overflow compaction throws and restore compaction hooks for engine-owned context engines. (#41361) — thanks @davidrudduck ### Maintenance diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.fixture.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.fixture.ts index 8c7afc834d2..8c320f765be 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.fixture.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.fixture.ts @@ -9,16 +9,18 @@ export function makeOverflowError(message: string = DEFAULT_OVERFLOW_ERROR_MESSA export function makeCompactionSuccess(params: { summary: string; - firstKeptEntryId: string; - tokensBefore: number; + firstKeptEntryId?: string; + tokensBefore?: number; + tokensAfter?: number; }) { return { ok: true as const, compacted: true as const, result: { summary: params.summary, - firstKeptEntryId: params.firstKeptEntryId, - tokensBefore: params.tokensBefore, + ...(params.firstKeptEntryId ? { firstKeptEntryId: params.firstKeptEntryId } : {}), + ...(params.tokensBefore !== undefined ? { tokensBefore: params.tokensBefore } : {}), + ...(params.tokensAfter !== undefined ? { tokensAfter: params.tokensAfter } : {}), }, }; } @@ -55,8 +57,9 @@ type MockCompactDirect = { compacted: true; result: { summary: string; - firstKeptEntryId: string; - tokensBefore: number; + firstKeptEntryId?: string; + tokensBefore?: number; + tokensAfter?: number; }; }) => unknown; }; diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts index 5980170be62..7a2550ba1e9 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts @@ -2,9 +2,13 @@ import "./run.overflow-compaction.mocks.shared.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { isCompactionFailureError, isLikelyContextOverflowError } from "../pi-embedded-helpers.js"; -vi.mock("../../utils.js", () => ({ - resolveUserPath: vi.fn((p: string) => p), -})); +vi.mock(import("../../utils.js"), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveUserPath: vi.fn((p: string) => p), + }; +}); import { log } from "./logger.js"; import { runEmbeddedPiAgent } from "./run.js"; @@ -16,6 +20,7 @@ import { queueOverflowAttemptWithOversizedToolOutput, } from "./run.overflow-compaction.fixture.js"; import { + mockedContextEngine, mockedCompactDirect, mockedRunEmbeddedAttempt, mockedSessionLikelyHasOversizedToolResults, @@ -30,6 +35,11 @@ const mockedIsLikelyContextOverflowError = vi.mocked(isLikelyContextOverflowErro describe("overflow compaction in run loop", () => { beforeEach(() => { vi.clearAllMocks(); + mockedRunEmbeddedAttempt.mockReset(); + mockedCompactDirect.mockReset(); + mockedSessionLikelyHasOversizedToolResults.mockReset(); + mockedTruncateOversizedToolResultsInSession.mockReset(); + mockedContextEngine.info.ownsCompaction = false; mockedIsCompactionFailureError.mockImplementation((msg?: string) => { if (!msg) { return false; @@ -72,7 +82,9 @@ describe("overflow compaction in run loop", () => { expect(mockedCompactDirect).toHaveBeenCalledTimes(1); expect(mockedCompactDirect).toHaveBeenCalledWith( - expect.objectContaining({ authProfileId: "test-profile" }), + expect.objectContaining({ + runtimeContext: expect.objectContaining({ authProfileId: "test-profile" }), + }), ); expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); expect(log.warn).toHaveBeenCalledWith( diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts index 22dee7b49cd..42d88a4dc6a 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts @@ -26,12 +26,35 @@ export const mockedGlobalHookRunner = { _ctx: PluginHookAgentContext, ): Promise => undefined, ), + runBeforeCompaction: vi.fn(async () => undefined), + runAfterCompaction: vi.fn(async () => undefined), }; +export const mockedContextEngine = { + info: { ownsCompaction: false }, + compact: vi.fn(async () => ({ + ok: false as const, + compacted: false as const, + reason: "nothing to compact", + })), +}; + +export const mockedContextEngineCompact = mockedContextEngine.compact; +export const mockedEnsureRuntimePluginsLoaded = vi.fn(); + vi.mock("../../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: vi.fn(() => mockedGlobalHookRunner), })); +vi.mock("../../context-engine/index.js", () => ({ + ensureContextEnginesInitialized: vi.fn(), + resolveContextEngine: vi.fn(async () => mockedContextEngine), +})); + +vi.mock("../runtime-plugins.js", () => ({ + ensureRuntimePluginsLoaded: mockedEnsureRuntimePluginsLoaded, +})); + vi.mock("../auth-profiles.js", () => ({ isProfileInCooldown: vi.fn(() => false), markAuthProfileFailure: vi.fn(async () => {}), @@ -141,9 +164,13 @@ vi.mock("../../process/command-queue.js", () => ({ enqueueCommandInLane: vi.fn((_lane: string, task: () => unknown) => task()), })); -vi.mock("../../utils/message-channel.js", () => ({ - isMarkdownCapableMessageChannel: vi.fn(() => true), -})); +vi.mock(import("../../utils/message-channel.js"), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isMarkdownCapableMessageChannel: vi.fn(() => true), + }; +}); vi.mock("../agent-paths.js", () => ({ resolveOpenClawAgentDir: vi.fn(() => "/tmp/agent-dir"), diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.shared-test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.shared-test.ts index 45bab82e1b8..c697ac9526a 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.shared-test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.shared-test.ts @@ -1,5 +1,8 @@ import { vi } from "vitest"; -import { compactEmbeddedPiSessionDirect } from "./compact.js"; +import { + mockedContextEngine, + mockedContextEngineCompact, +} from "./run.overflow-compaction.mocks.shared.js"; import { runEmbeddedAttempt } from "./run/attempt.js"; import { sessionLikelyHasOversizedToolResults, @@ -7,13 +10,14 @@ import { } from "./tool-result-truncation.js"; export const mockedRunEmbeddedAttempt = vi.mocked(runEmbeddedAttempt); -export const mockedCompactDirect = vi.mocked(compactEmbeddedPiSessionDirect); +export const mockedCompactDirect = mockedContextEngineCompact; export const mockedSessionLikelyHasOversizedToolResults = vi.mocked( sessionLikelyHasOversizedToolResults, ); export const mockedTruncateOversizedToolResultsInSession = vi.mocked( truncateOversizedToolResultsInSession, ); +export { mockedContextEngine }; export const overflowBaseRunParams = { sessionId: "test-session", diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts index 19b4a81d279..b29394eedfd 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts @@ -11,6 +11,7 @@ import { } from "./run.overflow-compaction.fixture.js"; import { mockedGlobalHookRunner } from "./run.overflow-compaction.mocks.shared.js"; import { + mockedContextEngine, mockedCompactDirect, mockedRunEmbeddedAttempt, mockedSessionLikelyHasOversizedToolResults, @@ -22,6 +23,25 @@ const mockedPickFallbackThinkingLevel = vi.mocked(pickFallbackThinkingLevel); describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { beforeEach(() => { vi.clearAllMocks(); + mockedRunEmbeddedAttempt.mockReset(); + mockedCompactDirect.mockReset(); + mockedSessionLikelyHasOversizedToolResults.mockReset(); + mockedTruncateOversizedToolResultsInSession.mockReset(); + mockedGlobalHookRunner.runBeforeAgentStart.mockReset(); + mockedGlobalHookRunner.runBeforeCompaction.mockReset(); + mockedGlobalHookRunner.runAfterCompaction.mockReset(); + mockedContextEngine.info.ownsCompaction = false; + mockedCompactDirect.mockResolvedValue({ + ok: false, + compacted: false, + reason: "nothing to compact", + }); + mockedSessionLikelyHasOversizedToolResults.mockReturnValue(false); + mockedTruncateOversizedToolResultsInSession.mockResolvedValue({ + truncated: false, + truncatedCount: 0, + reason: "no oversized tool results", + }); mockedGlobalHookRunner.hasHooks.mockImplementation(() => false); }); @@ -81,8 +101,12 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { expect(mockedCompactDirect).toHaveBeenCalledTimes(1); expect(mockedCompactDirect).toHaveBeenCalledWith( expect.objectContaining({ - trigger: "overflow", - authProfileId: "test-profile", + sessionId: "test-session", + sessionFile: "/tmp/session.json", + runtimeContext: expect.objectContaining({ + trigger: "overflow", + authProfileId: "test-profile", + }), }), ); }); @@ -132,6 +156,63 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => { expect(result.meta.error?.kind).toBe("context_overflow"); }); + it("fires compaction hooks during overflow recovery for ownsCompaction engines", async () => { + mockedContextEngine.info.ownsCompaction = true; + mockedGlobalHookRunner.hasHooks.mockImplementation( + (hookName) => hookName === "before_compaction" || hookName === "after_compaction", + ); + mockedRunEmbeddedAttempt + .mockResolvedValueOnce(makeAttemptResult({ promptError: makeOverflowError() })) + .mockResolvedValueOnce(makeAttemptResult({ promptError: null })); + mockedCompactDirect.mockResolvedValueOnce({ + ok: true, + compacted: true, + result: { + summary: "engine-owned compaction", + tokensAfter: 50, + }, + }); + + await runEmbeddedPiAgent(overflowBaseRunParams); + + expect(mockedGlobalHookRunner.runBeforeCompaction).toHaveBeenCalledWith( + { messageCount: -1, sessionFile: "/tmp/session.json" }, + expect.objectContaining({ + sessionKey: "test-key", + }), + ); + expect(mockedGlobalHookRunner.runAfterCompaction).toHaveBeenCalledWith( + { + messageCount: -1, + compactedCount: -1, + tokenCount: 50, + sessionFile: "/tmp/session.json", + }, + expect.objectContaining({ + sessionKey: "test-key", + }), + ); + }); + + it("guards thrown engine-owned overflow compaction attempts", async () => { + mockedContextEngine.info.ownsCompaction = true; + mockedGlobalHookRunner.hasHooks.mockImplementation( + (hookName) => hookName === "before_compaction" || hookName === "after_compaction", + ); + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ promptError: makeOverflowError() }), + ); + mockedCompactDirect.mockRejectedValueOnce(new Error("engine boom")); + + const result = await runEmbeddedPiAgent(overflowBaseRunParams); + + expect(mockedCompactDirect).toHaveBeenCalledTimes(1); + expect(mockedGlobalHookRunner.runBeforeCompaction).toHaveBeenCalledTimes(1); + expect(mockedGlobalHookRunner.runAfterCompaction).not.toHaveBeenCalled(); + expect(result.meta.error?.kind).toBe("context_overflow"); + expect(result.payloads?.[0]?.isError).toBe(true); + }); + it("returns retry_limit when repeated retries never converge", async () => { mockedRunEmbeddedAttempt.mockClear(); mockedCompactDirect.mockClear();