diff --git a/CHANGELOG.md b/CHANGELOG.md index a3c80b965ce..f13c4e4bbdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai - Gateway/hooks: route non-delivered hook completion and error summaries to the target agent's main session instead of the default agent session, preserving multi-agent hook isolation. Fixes #24693; carries forward #68667. Thanks @abersonFAC and @bluesky6868. - Discord: own the Carbon interaction listener and hand off Discord slash/component handling asynchronously, so compaction or long session locks no longer trip `InteractionEventListener` listener timeouts. Fixes #73204. Thanks @slideshow-dingo. +- Compaction/diagnostics: keep unknown compaction failure classifications stable while logging sanitized detail for unclassified provider errors such as missing Ollama provider adapters. Thanks @gzsiang. - Gateway/startup: keep value-option foreground starts on the gateway fast path and skip proxy bootstrap unless proxy env is configured, reducing normal gateway startup RSS and avoiding full CLI graph loading. Thanks @vincentkoc. - Heartbeat/models: show heartbeat model bleed guidance on context-overflow resets when the last runtime model matches configured `heartbeat.model`, so smaller local heartbeat models point users to `isolatedSession` or `lightContext` instead of only compaction-buffer tuning. Fixes #67314. Thanks @Knightmare6890. - Subagents/models: persist `sessions_spawn.model` and configured subagent models as child-session model overrides before the first turn, so spawned subagents actually run on the requested provider/model instead of reverting to the target agent default. Fixes #73180. Thanks @danielzinhu99. diff --git a/src/agents/pi-embedded-runner/compact-reasons.test.ts b/src/agents/pi-embedded-runner/compact-reasons.test.ts index 85f8101cb58..6018b371a0d 100644 --- a/src/agents/pi-embedded-runner/compact-reasons.test.ts +++ b/src/agents/pi-embedded-runner/compact-reasons.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { classifyCompactionReason, resolveCompactionFailureReason } from "./compact-reasons.js"; +import { + classifyCompactionReason, + formatUnknownCompactionReasonDetail, + resolveCompactionFailureReason, +} from "./compact-reasons.js"; describe("resolveCompactionFailureReason", () => { it("replaces generic compaction cancellation with the safeguard reason", () => { @@ -37,4 +41,30 @@ describe("classifyCompactionReason", () => { ), ).toBe("guard_blocked"); }); + + it("keeps unclassified provider errors in the stable unknown bucket", () => { + expect(classifyCompactionReason("No API provider registered for api: ollama")).toBe("unknown"); + }); +}); + +describe("formatUnknownCompactionReasonDetail", () => { + it("formats unknown reasons as single-token diagnostic detail", () => { + expect(formatUnknownCompactionReasonDetail("No API provider registered for api: ollama")).toBe( + "No_API_provider_registered_for_api:_ollama", + ); + }); + + it("strips terminal escapes and log separators from unknown reasons", () => { + expect( + formatUnknownCompactionReasonDetail("\u001b[31mNo API\u001b[0m provider = ollama\nnext"), + ).toBe("No_API_provider_ollama_next"); + }); + + it("omits empty unknown reason detail", () => { + expect(formatUnknownCompactionReasonDetail(" \n\t ")).toBeUndefined(); + }); + + it("limits unknown reason detail length", () => { + expect(formatUnknownCompactionReasonDetail("x".repeat(120))).toHaveLength(100); + }); }); diff --git a/src/agents/pi-embedded-runner/compact-reasons.ts b/src/agents/pi-embedded-runner/compact-reasons.ts index 642889cbc4d..5e98181435e 100644 --- a/src/agents/pi-embedded-runner/compact-reasons.ts +++ b/src/agents/pi-embedded-runner/compact-reasons.ts @@ -1,4 +1,7 @@ import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; +import { sanitizeForLog } from "../../terminal/ansi.js"; + +const MAX_COMPACTION_REASON_DETAIL_CHARS = 100; function isGenericCompactionCancelledReason(reason: string): boolean { const normalized = normalizeLowercaseStringOrEmpty(reason); @@ -59,3 +62,15 @@ export function classifyCompactionReason(reason?: string): string { } return "unknown"; } + +export function formatUnknownCompactionReasonDetail(reason?: string): string | undefined { + const sanitized = sanitizeForLog((reason ?? "").replace(/\s+/g, " ")) + .trim() + .replace(/[^A-Za-z0-9._:@/+~-]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, ""); + if (!sanitized) { + return undefined; + } + return sanitized.slice(0, MAX_COMPACTION_REASON_DETAIL_CHARS); +} diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 5d666f68d02..b173d4501bc 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -97,7 +97,11 @@ import { resolveSkillsPromptForRun, } from "../skills.js"; import { resolveSystemPromptOverride } from "../system-prompt-override.js"; -import { classifyCompactionReason, resolveCompactionFailureReason } from "./compact-reasons.js"; +import { + classifyCompactionReason, + formatUnknownCompactionReasonDetail, + resolveCompactionFailureReason, +} from "./compact-reasons.js"; import type { CompactEmbeddedPiSessionParams, CompactionMessageMetrics } from "./compact.types.js"; import { dedupeDuplicateUserMessagesForCompaction } from "./compaction-duplicate-user-messages.js"; import { @@ -351,10 +355,14 @@ export async function compactEmbeddedPiSessionDirect( let thinkLevel: ThinkLevel = params.thinkLevel ?? "off"; const attemptedThinking = new Set(); const fail = (reason: string): EmbeddedPiCompactResult => { + const failureReason = classifyCompactionReason(reason); + const detail = + failureReason === "unknown" ? formatUnknownCompactionReasonDetail(reason) : undefined; + const detailSuffix = detail ? ` detail=${detail}` : ""; log.warn( `[compaction-diag] end runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` + `diagId=${diagId} trigger=${trigger} provider=${provider}/${modelId} ` + - `attempt=${attempt} maxAttempts=${maxAttempts} outcome=failed reason=${classifyCompactionReason(reason)} ` + + `attempt=${attempt} maxAttempts=${maxAttempts} outcome=failed reason=${failureReason}${detailSuffix} ` + `durationMs=${Date.now() - startedAt}`, ); return {