Skip to content

Commit 8cf713e

Browse files
authored
feat(config): add experimental config for gating unstable features (#110)
* feat(anthropic-auto-compact): add aggressive truncation and empty message recovery Add truncateUntilTargetTokens method, empty content recovery mechanism, and emptyContentAttemptBySession tracking for robust message handling. 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(session-recovery): add auto-resume and recovery callbacks Implement ResumeConfig, resumeSession() method, and callback support for enhanced session recovery and resume functionality. 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) * feat(config): add experimental config schema for gating unstable features This adds a new 'experimental' config field to the OhMyOpenCode schema that enables fine-grained control over unstable/experimental features: - aggressive_truncation: Enables aggressive token truncation in anthropic-auto-compact hook for more aggressive token limit handling - empty_message_recovery: Enables empty message recovery mechanism in anthropic-auto-compact hook for fixing truncation-induced empty message errors - auto_resume: Enables automatic session resume after recovery in session-recovery hook for seamless recovery experience The experimental config is optional and all experimental features are disabled by default, ensuring backward compatibility while allowing early adopters to opt-in to cutting-edge features. 🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
1 parent 7fe6423 commit 8cf713e

File tree

11 files changed

+422
-33
lines changed

11 files changed

+422
-33
lines changed

src/config/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export {
66
AgentNameSchema,
77
HookNameSchema,
88
OmoAgentConfigSchema,
9+
ExperimentalConfigSchema,
910
} from "./schema"
1011

1112
export type {
@@ -16,4 +17,5 @@ export type {
1617
AgentName,
1718
HookName,
1819
OmoAgentConfig,
20+
ExperimentalConfig,
1921
} from "./schema"

src/config/schema.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@ export const OmoAgentConfigSchema = z.object({
106106
disabled: z.boolean().optional(),
107107
})
108108

109+
export const ExperimentalConfigSchema = z.object({
110+
aggressive_truncation: z.boolean().optional(),
111+
empty_message_recovery: z.boolean().optional(),
112+
auto_resume: z.boolean().optional(),
113+
})
114+
109115
export const OhMyOpenCodeConfigSchema = z.object({
110116
$schema: z.string().optional(),
111117
disabled_mcps: z.array(McpNameSchema).optional(),
@@ -115,6 +121,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
115121
claude_code: ClaudeCodeConfigSchema.optional(),
116122
google_auth: z.boolean().optional(),
117123
omo_agent: OmoAgentConfigSchema.optional(),
124+
experimental: ExperimentalConfigSchema.optional(),
118125
})
119126

120127
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>
@@ -123,5 +130,6 @@ export type AgentOverrides = z.infer<typeof AgentOverridesSchema>
123130
export type AgentName = z.infer<typeof AgentNameSchema>
124131
export type HookName = z.infer<typeof HookNameSchema>
125132
export type OmoAgentConfig = z.infer<typeof OmoAgentConfigSchema>
133+
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>
126134

127135
export { McpNameSchema, type McpName } from "../mcp/types"

src/hooks/anthropic-auto-compact/executor.ts

Lines changed: 214 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { AutoCompactState, FallbackState, RetryState, TruncateState } from "./types"
2+
import type { ExperimentalConfig } from "../../config"
23
import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types"
3-
import { findLargestToolResult, truncateToolResult } from "./storage"
4+
import { findLargestToolResult, truncateToolResult, truncateUntilTargetTokens } from "./storage"
5+
import { findEmptyMessages, injectTextPart } from "../session-recovery/storage"
6+
import { log } from "../../shared/logger"
47

58
type Client = {
69
session: {
@@ -151,24 +154,151 @@ function clearSessionState(autoCompactState: AutoCompactState, sessionID: string
151154
autoCompactState.retryStateBySession.delete(sessionID)
152155
autoCompactState.fallbackStateBySession.delete(sessionID)
153156
autoCompactState.truncateStateBySession.delete(sessionID)
157+
autoCompactState.emptyContentAttemptBySession.delete(sessionID)
154158
autoCompactState.compactionInProgress.delete(sessionID)
155159
}
156160

161+
function getOrCreateEmptyContentAttempt(
162+
autoCompactState: AutoCompactState,
163+
sessionID: string
164+
): number {
165+
return autoCompactState.emptyContentAttemptBySession.get(sessionID) ?? 0
166+
}
167+
168+
async function fixEmptyMessages(
169+
sessionID: string,
170+
autoCompactState: AutoCompactState,
171+
client: Client
172+
): Promise<boolean> {
173+
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID)
174+
autoCompactState.emptyContentAttemptBySession.set(sessionID, attempt + 1)
175+
176+
const emptyMessageIds = findEmptyMessages(sessionID)
177+
if (emptyMessageIds.length === 0) {
178+
await client.tui
179+
.showToast({
180+
body: {
181+
title: "Empty Content Error",
182+
message: "No empty messages found in storage. Cannot auto-recover.",
183+
variant: "error",
184+
duration: 5000,
185+
},
186+
})
187+
.catch(() => {})
188+
return false
189+
}
190+
191+
let fixed = false
192+
for (const messageID of emptyMessageIds) {
193+
const success = injectTextPart(sessionID, messageID, "[user interrupted]")
194+
if (success) fixed = true
195+
}
196+
197+
if (fixed) {
198+
await client.tui
199+
.showToast({
200+
body: {
201+
title: "Session Recovery",
202+
message: `Fixed ${emptyMessageIds.length} empty messages. Retrying...`,
203+
variant: "warning",
204+
duration: 3000,
205+
},
206+
})
207+
.catch(() => {})
208+
}
209+
210+
return fixed
211+
}
212+
157213
export async function executeCompact(
158214
sessionID: string,
159215
msg: Record<string, unknown>,
160216
autoCompactState: AutoCompactState,
161217
// eslint-disable-next-line @typescript-eslint/no-explicit-any
162218
client: any,
163-
directory: string
219+
directory: string,
220+
experimental?: ExperimentalConfig
164221
): Promise<void> {
165222
if (autoCompactState.compactionInProgress.has(sessionID)) {
166223
return
167224
}
168225
autoCompactState.compactionInProgress.add(sessionID)
169226

227+
const errorData = autoCompactState.errorDataBySession.get(sessionID)
170228
const truncateState = getOrCreateTruncateState(autoCompactState, sessionID)
171229

230+
if (
231+
experimental?.aggressive_truncation &&
232+
errorData?.currentTokens &&
233+
errorData?.maxTokens &&
234+
errorData.currentTokens > errorData.maxTokens &&
235+
truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts
236+
) {
237+
log("[auto-compact] aggressive truncation triggered (experimental)", {
238+
currentTokens: errorData.currentTokens,
239+
maxTokens: errorData.maxTokens,
240+
targetRatio: TRUNCATE_CONFIG.targetTokenRatio,
241+
})
242+
243+
const aggressiveResult = truncateUntilTargetTokens(
244+
sessionID,
245+
errorData.currentTokens,
246+
errorData.maxTokens,
247+
TRUNCATE_CONFIG.targetTokenRatio,
248+
TRUNCATE_CONFIG.charsPerToken
249+
)
250+
251+
if (aggressiveResult.truncatedCount > 0) {
252+
truncateState.truncateAttempt += aggressiveResult.truncatedCount
253+
254+
const toolNames = aggressiveResult.truncatedTools.map((t) => t.toolName).join(", ")
255+
const statusMsg = aggressiveResult.sufficient
256+
? `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)})`
257+
: `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) but need ${formatBytes(aggressiveResult.targetBytesToRemove)}. Falling back to summarize/revert...`
258+
259+
await (client as Client).tui
260+
.showToast({
261+
body: {
262+
title: aggressiveResult.sufficient ? "Aggressive Truncation" : "Partial Truncation",
263+
message: `${statusMsg}: ${toolNames}`,
264+
variant: "warning",
265+
duration: 4000,
266+
},
267+
})
268+
.catch(() => {})
269+
270+
log("[auto-compact] aggressive truncation completed", aggressiveResult)
271+
272+
if (aggressiveResult.sufficient) {
273+
autoCompactState.compactionInProgress.delete(sessionID)
274+
275+
setTimeout(async () => {
276+
try {
277+
await (client as Client).session.prompt_async({
278+
path: { sessionID },
279+
body: { parts: [{ type: "text", text: "Continue" }] },
280+
query: { directory },
281+
})
282+
} catch {}
283+
}, 500)
284+
return
285+
}
286+
} else {
287+
await (client as Client).tui
288+
.showToast({
289+
body: {
290+
title: "Truncation Skipped",
291+
message: "No tool outputs found to truncate.",
292+
variant: "warning",
293+
duration: 3000,
294+
},
295+
})
296+
.catch(() => {})
297+
}
298+
}
299+
300+
let skipSummarize = false
301+
172302
if (truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts) {
173303
const largest = findLargestToolResult(sessionID)
174304

@@ -203,12 +333,68 @@ export async function executeCompact(
203333
}, 500)
204334
return
205335
}
336+
} else if (errorData?.currentTokens && errorData?.maxTokens && errorData.currentTokens > errorData.maxTokens) {
337+
skipSummarize = true
338+
await (client as Client).tui
339+
.showToast({
340+
body: {
341+
title: "Summarize Skipped",
342+
message: `Over token limit (${errorData.currentTokens}/${errorData.maxTokens}) with nothing to truncate. Going to revert...`,
343+
variant: "warning",
344+
duration: 3000,
345+
},
346+
})
347+
.catch(() => {})
348+
} else if (!errorData?.currentTokens) {
349+
await (client as Client).tui
350+
.showToast({
351+
body: {
352+
title: "Truncation Skipped",
353+
message: "No large tool outputs found.",
354+
variant: "warning",
355+
duration: 3000,
356+
},
357+
})
358+
.catch(() => {})
206359
}
207360
}
208361

209362
const retryState = getOrCreateRetryState(autoCompactState, sessionID)
210363

211-
if (retryState.attempt < RETRY_CONFIG.maxAttempts) {
364+
if (experimental?.empty_message_recovery && errorData?.errorType?.includes("non-empty content")) {
365+
const attempt = getOrCreateEmptyContentAttempt(autoCompactState, sessionID)
366+
if (attempt < 3) {
367+
const fixed = await fixEmptyMessages(sessionID, autoCompactState, client as Client)
368+
if (fixed) {
369+
autoCompactState.compactionInProgress.delete(sessionID)
370+
setTimeout(() => {
371+
executeCompact(sessionID, msg, autoCompactState, client, directory, experimental)
372+
}, 500)
373+
return
374+
}
375+
} else {
376+
await (client as Client).tui
377+
.showToast({
378+
body: {
379+
title: "Recovery Failed",
380+
message: "Max recovery attempts (3) reached for empty content error. Please start a new session.",
381+
variant: "error",
382+
duration: 10000,
383+
},
384+
})
385+
.catch(() => {})
386+
autoCompactState.compactionInProgress.delete(sessionID)
387+
return
388+
}
389+
}
390+
391+
if (Date.now() - retryState.lastAttemptTime > 300000) {
392+
retryState.attempt = 0
393+
autoCompactState.fallbackStateBySession.delete(sessionID)
394+
autoCompactState.truncateStateBySession.delete(sessionID)
395+
}
396+
397+
if (!skipSummarize && retryState.attempt < RETRY_CONFIG.maxAttempts) {
212398
retryState.attempt++
213399
retryState.lastAttemptTime = Date.now()
214400

@@ -234,7 +420,7 @@ export async function executeCompact(
234420
query: { directory },
235421
})
236422

237-
clearSessionState(autoCompactState, sessionID)
423+
autoCompactState.compactionInProgress.delete(sessionID)
238424

239425
setTimeout(async () => {
240426
try {
@@ -253,10 +439,21 @@ export async function executeCompact(
253439
const cappedDelay = Math.min(delay, RETRY_CONFIG.maxDelayMs)
254440

255441
setTimeout(() => {
256-
executeCompact(sessionID, msg, autoCompactState, client, directory)
442+
executeCompact(sessionID, msg, autoCompactState, client, directory, experimental)
257443
}, cappedDelay)
258444
return
259445
}
446+
} else {
447+
await (client as Client).tui
448+
.showToast({
449+
body: {
450+
title: "Summarize Skipped",
451+
message: "Missing providerID or modelID. Skipping to revert...",
452+
variant: "warning",
453+
duration: 3000,
454+
},
455+
})
456+
.catch(() => {})
260457
}
261458
}
262459

@@ -301,10 +498,21 @@ export async function executeCompact(
301498
autoCompactState.compactionInProgress.delete(sessionID)
302499

303500
setTimeout(() => {
304-
executeCompact(sessionID, msg, autoCompactState, client, directory)
501+
executeCompact(sessionID, msg, autoCompactState, client, directory, experimental)
305502
}, 1000)
306503
return
307504
} catch {}
505+
} else {
506+
await (client as Client).tui
507+
.showToast({
508+
body: {
509+
title: "Revert Skipped",
510+
message: "Could not find last message pair to revert.",
511+
variant: "warning",
512+
duration: 3000,
513+
},
514+
})
515+
.catch(() => {})
308516
}
309517
}
310518

0 commit comments

Comments
 (0)