diff --git a/src/agents/embedded-agent-runner/compact-reasons.ts b/src/agents/embedded-agent-runner/compact-reasons.ts index 2ed42218aa84..da6486514d51 100644 --- a/src/agents/embedded-agent-runner/compact-reasons.ts +++ b/src/agents/embedded-agent-runner/compact-reasons.ts @@ -26,7 +26,7 @@ export function classifyCompactionReason(reason?: string): string { if (!text) { return "unknown"; } - if (text.includes("nothing to compact")) { + if (text.includes("nothing to compact") || text.includes("no real conversation messages")) { return "no_compactable_entries"; } // Backends use both phrases for the same harmless state: the transcript is diff --git a/src/agents/embedded-agent-runner/compact.hooks.test.ts b/src/agents/embedded-agent-runner/compact.hooks.test.ts index cc8cc5493717..746cc6e32171 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.test.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.test.ts @@ -2164,6 +2164,46 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { expect(hookRunner.runAfterCompaction).not.toHaveBeenCalled(); }); + it("forces engine-owned compaction for preflight-required budget compaction", async () => { + const result = await compactEmbeddedAgentSession( + wrappedCompactionArgs({ + trigger: "budget", + forcePreflight: true, + preflightRequired: true, + preflightCompactionTrigger: "transcript_bytes", + }), + ); + + expect(result.ok).toBe(true); + const compactArg = mockCallArg(contextEngineCompactMock) as { + runtimeContext?: Record; + }; + expectRecordFields(compactArg, { + compactionTarget: "budget", + force: true, + }); + expectRecordFields(compactArg.runtimeContext, { + forceReason: "preflight_required", + preflightCompactionTrigger: "transcript_bytes", + }); + }); + + it("continues forcing engine-owned manual compaction with manual force reason", async () => { + const result = await compactEmbeddedAgentSession(wrappedCompactionArgs({ trigger: "manual" })); + + expect(result.ok).toBe(true); + const compactArg = mockCallArg(contextEngineCompactMock) as { + runtimeContext?: Record; + }; + expectRecordFields(compactArg, { + compactionTarget: "threshold", + force: true, + }); + expectRecordFields(compactArg.runtimeContext, { + forceReason: "manual", + }); + }); + it("threads the caller abort signal into the engine compact() call", async () => { const controller = new AbortController(); diff --git a/src/agents/embedded-agent-runner/compact.queued.ts b/src/agents/embedded-agent-runner/compact.queued.ts index 025f5b8cffc3..467db144cb79 100644 --- a/src/agents/embedded-agent-runner/compact.queued.ts +++ b/src/agents/embedded-agent-runner/compact.queued.ts @@ -363,8 +363,21 @@ export async function compactEmbeddedAgentSession( currentTokenCount: params.currentTokenCount, compactionTarget: params.trigger === "manual" ? "threshold" : "budget", customInstructions: params.customInstructions, - force: params.trigger === "manual", - runtimeContext, + force: + params.force === true || + params.forcePreflight === true || + params.preflightRequired === true || + params.trigger === "manual", + runtimeContext: { + ...runtimeContext, + forceReason: + params.forcePreflight === true || params.preflightRequired === true + ? "preflight_required" + : params.trigger === "manual" + ? "manual" + : undefined, + preflightCompactionTrigger: params.preflightCompactionTrigger, + }, }, resolveCompactionTimeoutMs(params.config), params.abortSignal, diff --git a/src/agents/embedded-agent-runner/compact.types.ts b/src/agents/embedded-agent-runner/compact.types.ts index eb6df209b026..f161cd8ad908 100644 --- a/src/agents/embedded-agent-runner/compact.types.ts +++ b/src/agents/embedded-agent-runner/compact.types.ts @@ -66,6 +66,12 @@ export type CompactEmbeddedAgentSessionParams = { customInstructions?: string; tokenBudget?: number; force?: boolean; + /** Force compaction because the caller already determined this turn must compact before prompt submission. */ + forcePreflight?: boolean; + /** Alias for forcePreflight used by preflight budget gates. */ + preflightRequired?: boolean; + /** Diagnostic trigger that made preflight compaction mandatory. */ + preflightCompactionTrigger?: "tokens" | "transcript_bytes"; trigger?: "budget" | "overflow" | "manual"; /** * Preflight callers can allow native/current-session harness compaction but diff --git a/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts b/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts index ad6b5e7af9fb..79c912bef881 100644 --- a/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts +++ b/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts @@ -306,6 +306,29 @@ describe("runReplyAgent runtime config", () => { expect(metadata?.deliverDespiteSourceReplySuppression).toBe(true); }); + it("surfaces preflight compaction failures before the agent starts", async () => { + const { replyParams } = createDirectRuntimeReplyParams({ + shouldFollowup: false, + isActive: false, + }); + runPreflightCompactionIfNeededMock.mockRejectedValue( + new Error("Preflight compaction required but failed: auth profile mismatch"), + ); + runMemoryFlushIfNeededMock.mockResolvedValue(undefined); + + const result = await runReplyAgent(replyParams); + + if (!result || Array.isArray(result)) { + throw new Error("expected a single preflight compaction failure reply payload"); + } + expect(result.text).toContain("Context is too large"); + expect(result.text).toContain("auto-compaction could not recover"); + expect(result.text).toContain("/compact"); + expect(result.text).toContain("/new"); + const metadata = getReplyPayloadMetadata(result); + expect(metadata?.deliverDespiteSourceReplySuppression).toBe(true); + }); + it("does not resolve secrets before the enqueue-followup queue path", async () => { const { followupRun, resolvedQueue, replyParams } = createDirectRuntimeReplyParams({ shouldFollowup: true, diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 1f0290a88bb6..1735ff333037 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -625,6 +625,7 @@ function collapseRepeatedFailureDetail(message: string): string { const SAFE_MISSING_API_KEY_PROVIDERS = new Set(["anthropic", "google", "openai"]); const EXTERNAL_RUN_FAILURE_DETAIL_MAX_CHARS = 900; const AGENT_FAILED_BEFORE_REPLY_TEXT = "Agent failed before reply:"; +const PREFLIGHT_COMPACTION_FAILURE_PREFIX = "Preflight compaction required but failed:"; type ExternalRunFailureReply = { text: string; @@ -692,6 +693,27 @@ function buildCodexAppServerFailureText(message: string): string | null { return null; } +export function buildPreflightCompactionFailureText( + message: string, + options?: { includeDetails?: boolean }, +): string | null { + const normalizedMessage = collapseRepeatedFailureDetail(message); + if (!normalizedMessage.startsWith(PREFLIGHT_COMPACTION_FAILURE_PREFIX)) { + return null; + } + const reason = sanitizeUserFacingText( + normalizedMessage.slice(PREFLIGHT_COMPACTION_FAILURE_PREFIX.length), + { errorContext: true }, + ) + .trim() + .replace(/\s+/gu, " "); + const reasonSuffix = options?.includeDetails && reason ? ` Reason: ${reason}.` : ""; + return ( + "⚠️ Context is too large and auto-compaction could not recover this turn." + + `${reasonSuffix} Try again, use /compact, or use /new to start a fresh session.` + ); +} + function buildCliBackendTimeoutFailureText(message: string): string | null { const normalizedMessage = collapseRepeatedFailureDetail(message); const stall = normalizedMessage.match(CLI_BACKEND_NO_OUTPUT_STALL_RE); @@ -820,6 +842,20 @@ export function buildKnownAgentRunFailureReplyPayload(params: { }); } + const preflightCompactionFailureText = buildPreflightCompactionFailureText(message, { + includeDetails: isVerboseFailureDetailEnabled(params.resolvedVerboseLevel), + }); + if (preflightCompactionFailureText) { + return markAgentRunFailureReplyPayload({ + text: resolveExternalRunFailureTextForConversation({ + text: preflightCompactionFailureText, + sessionCtx: params.sessionCtx, + isGenericRunnerFailure: false, + cfg: params.cfg, + }), + }); + } + const isPureTransientSummary = isFallbackSummary ? isPureTransientRateLimitSummary(params.err) : false; diff --git a/src/auto-reply/reply/agent-runner-memory.test.ts b/src/auto-reply/reply/agent-runner-memory.test.ts index f1794b422858..82c105c27a03 100644 --- a/src/auto-reply/reply/agent-runner-memory.test.ts +++ b/src/auto-reply/reply/agent-runner-memory.test.ts @@ -106,6 +106,10 @@ type CompactEmbeddedAgentSessionParams = { sandboxSessionKey?: string; currentTokenCount?: number; cwd?: string; + force?: boolean; + forcePreflight?: boolean; + preflightRequired?: boolean; + preflightCompactionTrigger?: string; sessionFile?: string; sessionId?: string; trigger?: string; @@ -999,6 +1003,10 @@ describe("runMemoryFlushIfNeeded", () => { expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1); expect(requireCompactEmbeddedAgentSessionCall()).toMatchObject({ trigger: "budget", + force: true, + forcePreflight: true, + preflightRequired: true, + preflightCompactionTrigger: "tokens", deferOwningContextEngineCompaction: false, contextTokenBudget: 100, }); @@ -1112,7 +1120,7 @@ describe("runMemoryFlushIfNeeded", () => { ["stale_thread_binding", "thread not found: "], ["missing_thread_binding", "no thread binding for session"], ])( - "continues after recoverable native harness %s failure during preflight compaction", + "fails required preflight compaction after native harness %s failure", async (failureReason, reason) => { const sessionFile = path.join(rootDir, "session.jsonl"); await fs.writeFile( @@ -1143,30 +1151,31 @@ describe("runMemoryFlushIfNeeded", () => { }; const sessionStore = { "agent:main:telegram:group:redacted": sessionEntry }; - const entry = await runPreflightCompactionIfNeeded({ - cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } }, - followupRun: createTestFollowupRun({ - sessionId: "session", - sessionFile, + await expect( + runPreflightCompactionIfNeeded({ + cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } }, + followupRun: createTestFollowupRun({ + sessionId: "session", + sessionFile, + sessionKey: "agent:main:telegram:group:redacted", + }), + defaultModel: "anthropic/claude-opus-4-6", + agentCfgContextTokens: 100, + sessionEntry, + sessionStore, sessionKey: "agent:main:telegram:group:redacted", + storePath: path.join(rootDir, "sessions.json"), + isHeartbeat: false, + replyOperation: createReplyOperation(), }), - defaultModel: "anthropic/claude-opus-4-6", - agentCfgContextTokens: 100, - sessionEntry, - sessionStore, - sessionKey: "agent:main:telegram:group:redacted", - storePath: path.join(rootDir, "sessions.json"), - isHeartbeat: false, - replyOperation: createReplyOperation(), - }); + ).rejects.toThrow(`Preflight compaction required but failed: ${reason}`); - expect(entry).toBe(sessionEntry); expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1); expect(incrementCompactionCountMock).not.toHaveBeenCalled(); }, ); - it("continues after an unstructured thread-not-found preflight compaction failure", async () => { + it("fails required preflight compaction after an unstructured thread-not-found failure", async () => { const sessionFile = path.join(rootDir, "session.jsonl"); await fs.writeFile( sessionFile, @@ -1195,24 +1204,27 @@ describe("runMemoryFlushIfNeeded", () => { }; const sessionStore = { "agent:main:telegram:group:redacted": sessionEntry }; - const entry = await runPreflightCompactionIfNeeded({ - cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } }, - followupRun: createTestFollowupRun({ - sessionId: "session", - sessionFile, + await expect( + runPreflightCompactionIfNeeded({ + cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } }, + followupRun: createTestFollowupRun({ + sessionId: "session", + sessionFile, + sessionKey: "agent:main:telegram:group:redacted", + }), + defaultModel: "anthropic/claude-opus-4-6", + agentCfgContextTokens: 100, + sessionEntry, + sessionStore, sessionKey: "agent:main:telegram:group:redacted", + storePath: path.join(rootDir, "sessions.json"), + isHeartbeat: false, + replyOperation: createReplyOperation(), }), - defaultModel: "anthropic/claude-opus-4-6", - agentCfgContextTokens: 100, - sessionEntry, - sessionStore, - sessionKey: "agent:main:telegram:group:redacted", - storePath: path.join(rootDir, "sessions.json"), - isHeartbeat: false, - replyOperation: createReplyOperation(), - }); + ).rejects.toThrow( + "Preflight compaction required but failed: thread not found: ", + ); - expect(entry).toBe(sessionEntry); expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1); expect(incrementCompactionCountMock).not.toHaveBeenCalled(); }); @@ -1469,7 +1481,7 @@ describe("runMemoryFlushIfNeeded", () => { expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); }); - it("continues when preflight compaction returns a successful no-op", async () => { + it("fails when required preflight compaction returns an unknown successful no-op", async () => { compactEmbeddedAgentSessionMock.mockResolvedValueOnce({ ok: true, compacted: false, @@ -1485,23 +1497,24 @@ describe("runMemoryFlushIfNeeded", () => { const sessionStore = { main: sessionEntry }; const replyOperation = createReplyOperation(); - const entry = await runPreflightCompactionIfNeeded({ - cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } }, - followupRun: createTestFollowupRun({ - sessionId: "session", + await expect( + runPreflightCompactionIfNeeded({ + cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } }, + followupRun: createTestFollowupRun({ + sessionId: "session", + sessionKey: "main", + }), + defaultModel: "anthropic/claude-opus-4-6", + agentCfgContextTokens: 200_000, + sessionEntry, + sessionStore, sessionKey: "main", + storePath: path.join(rootDir, "sessions.json"), + isHeartbeat: false, + replyOperation, }), - defaultModel: "anthropic/claude-opus-4-6", - agentCfgContextTokens: 200_000, - sessionEntry, - sessionStore, - sessionKey: "main", - storePath: path.join(rootDir, "sessions.json"), - isHeartbeat: false, - replyOperation, - }); + ).rejects.toThrow("Preflight compaction required but failed: plugin already stored this turn"); - expect(entry).toBe(sessionEntry); expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledTimes(1); const compactCall = requireCompactEmbeddedAgentSessionCall(); expect(compactCall.contextTokenBudget).toBe(200_000); diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index a04b783e8a07..f9dfd4c146d7 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -7,11 +7,7 @@ import { } from "@openclaw/normalization-core/string-coerce"; import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js"; import { estimateMessagesTokens } from "../../agents/compaction.js"; -import { - classifyCompactionReason, - DEFERRED_CONTEXT_ENGINE_COMPACTION_REASON, -} from "../../agents/embedded-agent-runner/compact-reasons.js"; -import { isRecoverableNativeHarnessBindingFailure } from "../../agents/harness/compaction-recovery.js"; +import { classifyCompactionReason } from "../../agents/embedded-agent-runner/compact-reasons.js"; import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js"; import { ensureSelectedAgentHarnessPlugin } from "../../agents/harness/runtime-plugin.js"; import { runWithModelFallback } from "../../agents/model-fallback.js"; @@ -189,10 +185,6 @@ function isPreflightCompactionSkipReason(reason?: string): boolean { ); } -function isDeferredPreflightCompactionReason(reason?: string): boolean { - return normalizeOptionalString(reason) === DEFERRED_CONTEXT_ENGINE_COMPACTION_REASON; -} - function resolveMemoryFlushModelFallbackOptions( run: FollowupRun["run"], model?: string, @@ -893,6 +885,10 @@ export async function runPreflightCompactionIfNeeded(params: { thinkLevel: params.followupRun.run.thinkLevel, bashElevated: params.followupRun.run.bashElevated, trigger: "budget", + force: true, + forcePreflight: true, + preflightRequired: true, + preflightCompactionTrigger: compactionTrigger, deferOwningContextEngineCompaction: false, contextTokenBudget: contextWindowTokens, currentTokenCount: tokenCountForCompaction ?? freshPersistedTokens, @@ -907,25 +903,17 @@ export async function runPreflightCompactionIfNeeded(params: { return entry ?? params.sessionEntry; } logVerbose(`preflightCompaction failed: sessionKey=${params.sessionKey} reason=${reason}`); - if (isRecoverableNativeHarnessBindingFailure(result)) { - logVerbose( - `preflightCompaction continuing after recoverable native harness binding failure: sessionKey=${params.sessionKey} reason=${reason}`, - ); - return entry ?? params.sessionEntry; - } throw new Error(`Preflight compaction required but failed: ${reason}`); } if (!result.compacted) { - const reason = normalizeOptionalString(result.reason); - if (isDeferredPreflightCompactionReason(reason)) { - logVerbose(`preflightCompaction failed: sessionKey=${params.sessionKey} reason=${reason}`); - throw new Error(`Preflight compaction required but failed: ${reason}`); + const reason = normalizeOptionalString(result.reason) ?? "not_compacted"; + if (isPreflightCompactionSkipReason(reason)) { + logVerbose(`preflightCompaction skipped: sessionKey=${params.sessionKey} reason=${reason}`); + return entry ?? params.sessionEntry; } - logVerbose( - `preflightCompaction skipped: sessionKey=${params.sessionKey} reason=${reason ?? "not_compacted"}`, - ); - return entry ?? params.sessionEntry; + logVerbose(`preflightCompaction failed: sessionKey=${params.sessionKey} reason=${reason}`); + throw new Error(`Preflight compaction required but failed: ${reason}`); } await deps.incrementCompactionCount({ diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 19eac81eb462..7fddff99c539 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -744,6 +744,77 @@ describe("createFollowupRunner reply-lane admission", () => { ); realAgentEvents.resetAgentRunContextForTest(); }); + + it("routes preflight compaction failures before starting queued followup runs", async () => { + runPreflightCompactionIfNeededMock.mockRejectedValueOnce( + new Error("Preflight compaction required but failed: auth profile mismatch"), + ); + const runner = createFollowupRunner({ + typing: createMockTypingController(), + typingMode: "instant", + sessionKey: "main", + defaultModel: "anthropic/claude", + }); + + await runner( + createQueuedRun({ + originatingChannel: "discord", + originatingTo: "channel:C1", + originatingAccountId: "acct-1", + originatingThreadId: "thread-1", + originatingChatType: "group", + run: { + messageProvider: "discord", + provider: "anthropic", + model: "claude", + verboseLevel: "off", + sessionKey: "main", + }, + }), + ); + + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); + expect(routeReplyMock).toHaveBeenCalledOnce(); + expect(routeReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "discord", + to: "channel:C1", + accountId: "acct-1", + threadId: "thread-1", + payload: expect.objectContaining({ + text: expect.stringContaining("auto-compaction could not recover"), + }), + }), + ); + }); + + it("preserves non-compaction preflight failures for queued followup runs", async () => { + runPreflightCompactionIfNeededMock.mockRejectedValueOnce(new Error("session load failed")); + const runner = createFollowupRunner({ + typing: createMockTypingController(), + typingMode: "instant", + sessionKey: "main", + defaultModel: "anthropic/claude", + }); + + await expect( + runner( + createQueuedRun({ + originatingChannel: "discord", + originatingTo: "channel:C1", + run: { + messageProvider: "discord", + provider: "anthropic", + model: "claude", + sessionKey: "main", + }, + }), + ), + ).rejects.toThrow("session load failed"); + + expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); + expect(routeReplyMock).not.toHaveBeenCalled(); + }); }); async function normalizeComparablePath(filePath: string): Promise { diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 1d527bcd66c4..e6197d160054 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -28,6 +28,7 @@ import { formatErrorMessage } from "../../infra/errors.js"; import { defaultRuntime } from "../../runtime.js"; import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js"; +import { markReplyPayloadForSourceSuppressionDelivery } from "../reply-payload.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; import { clearDroppedCliSessionBinding, @@ -35,6 +36,7 @@ import { runCliAgentWithLifecycle, } from "./agent-runner-cli-dispatch.js"; import { + buildPreflightCompactionFailureText, resolveRunAfterAutoFallbackPrimaryProbeRecheck, resolveSessionRuntimeOverrideForProvider, } from "./agent-runner-execution.js"; @@ -546,19 +548,40 @@ export function createFollowupRunner(params: { let runResult: Awaited>; let fallbackProvider = run.provider; let fallbackModel = run.model; - activeSessionEntry = await runPreflightCompactionIfNeeded({ - cfg: runtimeConfig, - followupRun: effectiveQueued, - promptForEstimate: queued.prompt, - defaultModel, - agentCfgContextTokens, - sessionEntry: activeSessionEntry, - sessionStore, - sessionKey: replySessionKey, - storePath, - isHeartbeat: opts?.isHeartbeat === true, - replyOperation, - }); + try { + activeSessionEntry = await runPreflightCompactionIfNeeded({ + cfg: runtimeConfig, + followupRun: effectiveQueued, + promptForEstimate: queued.prompt, + defaultModel, + agentCfgContextTokens, + sessionEntry: activeSessionEntry, + sessionStore, + sessionKey: replySessionKey, + storePath, + isHeartbeat: opts?.isHeartbeat === true, + replyOperation, + }); + } catch (err) { + const message = formatErrorMessage(err); + replyOperation.fail("run_failed", err); + const preflightCompactionFailureText = buildPreflightCompactionFailureText(message, { + includeDetails: run.verboseLevel === "on" || run.verboseLevel === "full", + }); + if (preflightCompactionFailureText) { + await sendFollowupPayloads( + [ + markReplyPayloadForSourceSuppressionDelivery({ + text: preflightCompactionFailureText, + }), + ], + effectiveQueued, + { provider: fallbackProvider, modelId: fallbackModel }, + ); + return; + } + throw err; + } if (run.sessionKey) { const owningSessionId = activeSessionEntry?.sessionId === run.sessionId