mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-27 15:13:37 +08:00
fix(compact): stop re-compaction thrash and post-compact 100% context meter
Two context-management fixes (CLI/sidecar side):
1. Re-compaction loop (方案1+2). Auto-compaction previously only circuit-broke
on hard failures; a compaction that succeeded but left context still over
threshold reset the failure counter and re-compacted every turn, burning a
summary API call each time.
- autoCompact.ts: isIneffectiveCompaction() — when truePostCompactTokenCount
is still >= threshold, count it toward the existing circuit breaker instead
of resetting to 0; query.ts now honours that count on the success path.
- shouldThrottleAutoCompact() — suppress proactive autocompaction for a few
turns after a compaction, unless at the hard blocking limit (so we never
risk prompt-too-long by throttling).
2. Post-compact context meter stuck at ~100% (sessionService.ts). The transcript
context estimate counted the entire pre-compact history plus the summarization
call's huge input_tokens, pinning the indicator near full until the next turn.
Now the estimate is scoped to messages after the latest compact_boundary, with
a model fallback for the just-compacted/no-new-turn case. Non-compacted
sessions are unchanged.
Tested:
- bun test src/services/compact/autoCompact.test.ts (12 pass; throttle + ineffective predicates)
- bun test src/server/__tests__/conversations.test.ts -t "context" (6 pass incl. new compact-boundary scoping case)
Not-tested: 4 pre-existing WebSocket runtime-restart integration tests fail in this env (CLI subprocess code 143); unrelated to these changes.
Confidence: high
Scope-risk: moderate
This commit is contained in:
parent
3cbed50ca9
commit
b0f368ef97
@ -19,6 +19,13 @@
|
||||
- **Read 工具空 `pages` 容错**。`pages: ""` 视为读全文件,避免模型传空字符串触发的反复校验报错。
|
||||
- **Fork 化指向**。仓库链接、检查更新源(electron-updater publish + Tauri 端点)改为指向本 fork
|
||||
`706412584/cc-haha`;设置→关于 新增"Fork 维护者"署名(706412584),原作者 NanmiCoder 署名与社交链接保留。
|
||||
- **修复上下文压缩抖动/死循环**。此前自动压缩只对"压缩失败"做熔断,"压缩成功但结果仍超阈值"会被
|
||||
当成成功并清零失败计数,导致每隔几句话就再压一次、空烧摘要 API。现在:(方案1) 压缩后若仍超阈值
|
||||
记为"无效压缩"并计入熔断,连续若干次后停止自动压缩;(方案2) 一次压缩后的若干轮内不再主动压缩
|
||||
(触及硬阻塞上限时除外,避免 prompt-too-long)。
|
||||
- **修复压缩后上下文进度卡 100%**。设置/编辑框的上下文进度在压缩完成后会停在 ~100% 不回落,直到
|
||||
下一轮对话。根因是 transcript 估算路径未切到压缩边界之后(把压缩前全部消息 + 摘要调用的巨大
|
||||
input 都算进去)。现在估算只统计最近一次压缩边界之后的消息,压缩完成即正确回落。
|
||||
|
||||
## 范围
|
||||
|
||||
|
||||
@ -520,11 +520,14 @@ async function* queryLoop(
|
||||
// compact. recompactionInfo (autoCompact.ts:190) already captured the
|
||||
// old values for turnsSincePreviousCompact/previousCompactTurnId before
|
||||
// the call, so this reset doesn't lose those.
|
||||
// consecutiveFailures is normally 0 here, but an "ineffective" compaction
|
||||
// (post-compact context still over threshold) returns a non-zero count so
|
||||
// the circuit breaker can stop a re-compaction loop.
|
||||
tracking = {
|
||||
compacted: true,
|
||||
turnId: deps.uuid(),
|
||||
turnCounter: 0,
|
||||
consecutiveFailures: 0,
|
||||
consecutiveFailures: consecutiveFailures ?? 0,
|
||||
}
|
||||
|
||||
const postCompactMessages = buildPostCompactMessages(compactionResult)
|
||||
|
||||
@ -12,6 +12,7 @@ import * as os from 'os'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { ConversationService, ConversationStartupError, conversationService } from '../services/conversationService.js'
|
||||
import { ORCHESTRATION_PROMPT_MARKER } from '../orchestrationPrompt.js'
|
||||
import { SessionService, sessionService } from '../services/sessionService.js'
|
||||
import { ProviderService } from '../services/providerService.js'
|
||||
|
||||
@ -242,6 +243,20 @@ describe('ConversationService', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should append the orchestration system prompt when coordinator mode is on', () => {
|
||||
const svc = new ConversationService()
|
||||
const args = (svc as any).getRuntimeArgs({ coordinatorMode: true }) as string[]
|
||||
const idx = args.indexOf('--append-system-prompt')
|
||||
expect(idx).toBeGreaterThanOrEqual(0)
|
||||
expect(args[idx + 1]).toContain(ORCHESTRATION_PROMPT_MARKER)
|
||||
})
|
||||
|
||||
it('should not append the orchestration system prompt when coordinator mode is off', () => {
|
||||
const svc = new ConversationService()
|
||||
expect((svc as any).getRuntimeArgs({ coordinatorMode: false })).not.toContain('--append-system-prompt')
|
||||
expect((svc as any).getRuntimeArgs({})).not.toContain('--append-system-prompt')
|
||||
})
|
||||
|
||||
it('should send thinking token controls to active CLI sessions', () => {
|
||||
const svc = new ConversationService() as any
|
||||
const sent: string[] = []
|
||||
@ -684,11 +699,87 @@ describe('ConversationService', () => {
|
||||
await fs.rm(workDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// WebSocket integration tests (with mock CLI using the SDK websocket protocol)
|
||||
// ============================================================================
|
||||
it('scopes the transcript context estimate to messages after the latest compact boundary', async () => {
|
||||
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
const previousNodeEnv = process.env.NODE_ENV
|
||||
const previousUseBedrock = process.env.CLAUDE_CODE_USE_BEDROCK
|
||||
const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-transcript-compact-'))
|
||||
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-workdir-compact-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpConfigDir
|
||||
process.env.NODE_ENV = 'development'
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = '1'
|
||||
|
||||
try {
|
||||
const svc = new SessionService()
|
||||
const { sessionId } = await svc.createSession(workDir)
|
||||
const found = await svc.findSessionFile(sessionId)
|
||||
expect(found).not.toBeNull()
|
||||
|
||||
// Pre-compact turn: a near-full request (would read as ~100%).
|
||||
await fs.appendFile(found!.filePath, JSON.stringify({
|
||||
type: 'assistant',
|
||||
uuid: crypto.randomUUID(),
|
||||
timestamp: '2026-04-27T12:00:00.000Z',
|
||||
cwd: workDir,
|
||||
version: '999.0.0-test',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
model: 'claude-sonnet-4-6',
|
||||
content: [{ type: 'text', text: 'pre-compact answer' }],
|
||||
usage: { input_tokens: 190_000, output_tokens: 50 },
|
||||
},
|
||||
}) + '\n')
|
||||
|
||||
// Compaction boundary — everything above is no longer in live context.
|
||||
await fs.appendFile(found!.filePath, JSON.stringify({
|
||||
type: 'system',
|
||||
subtype: 'compact_boundary',
|
||||
uuid: crypto.randomUUID(),
|
||||
timestamp: '2026-04-27T12:00:01.000Z',
|
||||
cwd: workDir,
|
||||
}) + '\n')
|
||||
|
||||
// Post-compact: just the small summary, no new real turn yet.
|
||||
await fs.appendFile(found!.filePath, JSON.stringify({
|
||||
type: 'user',
|
||||
uuid: crypto.randomUUID(),
|
||||
timestamp: '2026-04-27T12:00:02.000Z',
|
||||
cwd: workDir,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Summary of the prior conversation: we did a few things.' }],
|
||||
},
|
||||
}) + '\n')
|
||||
|
||||
const contextEstimate = await svc.getTranscriptContextEstimate(sessionId)
|
||||
|
||||
expect(contextEstimate?.rawMaxTokens).toBe(200_000)
|
||||
// The pre-compact 190k request must NOT anchor the estimate post-compaction.
|
||||
expect(contextEstimate?.percentage).toBeLessThan(50)
|
||||
expect(contextEstimate?.totalTokens).toBeLessThan(50_000)
|
||||
expect(contextEstimate?.model).toBe('claude-sonnet-4-6')
|
||||
} finally {
|
||||
if (previousConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
|
||||
}
|
||||
if (previousNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV
|
||||
} else {
|
||||
process.env.NODE_ENV = previousNodeEnv
|
||||
}
|
||||
if (previousUseBedrock === undefined) {
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = previousUseBedrock
|
||||
}
|
||||
await fs.rm(tmpConfigDir, { recursive: true, force: true })
|
||||
await fs.rm(workDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebSocket Chat Integration', () => {
|
||||
let server: ReturnType<typeof Bun.serve>
|
||||
|
||||
@ -1382,6 +1382,24 @@ export class SessionService {
|
||||
if (!found) return null
|
||||
|
||||
const entries = await this.readJsonlFile(found.filePath)
|
||||
|
||||
// After a compaction the transcript still holds the full pre-compact
|
||||
// history, but the live context only contains messages after the latest
|
||||
// compact boundary. Scope the estimate to post-boundary entries so the
|
||||
// indicator doesn't report a stale ~100% — both the whole-transcript token
|
||||
// estimate AND the summarization call's huge input_tokens would otherwise
|
||||
// keep it pinned near full until the next real turn lands.
|
||||
let boundaryIndex = -1
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i]!
|
||||
if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
|
||||
boundaryIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
const scopedEntries =
|
||||
boundaryIndex >= 0 ? entries.slice(boundaryIndex + 1) : entries
|
||||
|
||||
let latest: {
|
||||
model: string
|
||||
inputTokens: number
|
||||
@ -1390,7 +1408,7 @@ export class SessionService {
|
||||
cacheCreationInputTokens: number
|
||||
} | null = null
|
||||
|
||||
for (const entry of entries) {
|
||||
for (const entry of scopedEntries) {
|
||||
const usage = entry.message?.usage
|
||||
const model = entry.message?.model
|
||||
if (!usage || typeof model !== 'string') continue
|
||||
@ -1411,11 +1429,31 @@ export class SessionService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!latest) return null
|
||||
// Resolve the model even when the post-compact segment has no usage yet
|
||||
// (compaction just happened, no real turn since): we still want a valid
|
||||
// post-compact estimate rather than null.
|
||||
let model = latest?.model
|
||||
if (!model) {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const candidate = entries[i]?.message?.model
|
||||
if (typeof candidate === 'string') {
|
||||
model = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!model) return null
|
||||
|
||||
const rawMaxTokens = await this.getTranscriptContextWindow(sessionId, latest.model)
|
||||
const promptTokens = latest.inputTokens + latest.cacheReadInputTokens + latest.cacheCreationInputTokens
|
||||
const transcriptMessages = entries.filter(entry =>
|
||||
const usage = latest ?? {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
}
|
||||
|
||||
const rawMaxTokens = await this.getTranscriptContextWindow(sessionId, model)
|
||||
const promptTokens = usage.inputTokens + usage.cacheReadInputTokens + usage.cacheCreationInputTokens
|
||||
const transcriptMessages = scopedEntries.filter(entry =>
|
||||
entry.type === 'user' || entry.type === 'assistant' || entry.type === 'attachment',
|
||||
) as Parameters<typeof roughTokenCountEstimationForMessages>[0]
|
||||
const estimatedTokens =
|
||||
@ -1423,12 +1461,16 @@ export class SessionService {
|
||||
const contextBudget = calculateContextBudget({
|
||||
estimatedTokens,
|
||||
contextWindow: rawMaxTokens,
|
||||
currentUsage: {
|
||||
input_tokens: latest.inputTokens,
|
||||
output_tokens: latest.outputTokens,
|
||||
cache_read_input_tokens: latest.cacheReadInputTokens,
|
||||
cache_creation_input_tokens: latest.cacheCreationInputTokens,
|
||||
},
|
||||
// Without a post-compact API turn yet, there's no trustworthy provider
|
||||
// usage to anchor to — fall back to the pure message-token estimate.
|
||||
currentUsage: latest
|
||||
? {
|
||||
input_tokens: latest.inputTokens,
|
||||
output_tokens: latest.outputTokens,
|
||||
cache_read_input_tokens: latest.cacheReadInputTokens,
|
||||
cache_creation_input_tokens: latest.cacheCreationInputTokens,
|
||||
}
|
||||
: undefined,
|
||||
usageTrust: getProviderUsageTrust({
|
||||
isFirstPartyAnthropic: isFirstPartyAnthropicBaseUrl(),
|
||||
}),
|
||||
@ -1437,10 +1479,10 @@ export class SessionService {
|
||||
const totalTokens = contextBudget.usedTokens
|
||||
const percentage = rawMaxTokens > 0 ? Math.round((totalTokens / rawMaxTokens) * 100) : 0
|
||||
const usageCategories: TranscriptContextEstimate['categories'] = [
|
||||
{ name: 'Input tokens', tokens: latest.inputTokens, color: '#8f3217' },
|
||||
{ name: 'Cache read', tokens: latest.cacheReadInputTokens, color: '#0f5c8f' },
|
||||
{ name: 'Cache write', tokens: latest.cacheCreationInputTokens, color: '#7c3aed' },
|
||||
{ name: 'Output tokens', tokens: latest.outputTokens, color: '#2f7d32' },
|
||||
{ name: 'Input tokens', tokens: usage.inputTokens, color: '#8f3217' },
|
||||
{ name: 'Cache read', tokens: usage.cacheReadInputTokens, color: '#0f5c8f' },
|
||||
{ name: 'Cache write', tokens: usage.cacheCreationInputTokens, color: '#7c3aed' },
|
||||
{ name: 'Output tokens', tokens: usage.outputTokens, color: '#2f7d32' },
|
||||
]
|
||||
const contextCategories: TranscriptContextEstimate['categories'] =
|
||||
contextBudget.ignoredUsageReason === 'low_trust_media_usage'
|
||||
@ -1474,15 +1516,15 @@ export class SessionService {
|
||||
rawMaxTokens,
|
||||
percentage,
|
||||
gridRows,
|
||||
model: latest.model,
|
||||
model,
|
||||
memoryFiles: [],
|
||||
mcpTools: [],
|
||||
agents: [],
|
||||
apiUsage: {
|
||||
input_tokens: latest.inputTokens,
|
||||
output_tokens: latest.outputTokens,
|
||||
cache_creation_input_tokens: latest.cacheCreationInputTokens,
|
||||
cache_read_input_tokens: latest.cacheReadInputTokens,
|
||||
input_tokens: usage.inputTokens,
|
||||
output_tokens: usage.outputTokens,
|
||||
cache_creation_input_tokens: usage.cacheCreationInputTokens,
|
||||
cache_read_input_tokens: usage.cacheReadInputTokens,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
||||
|
||||
import { getAutoCompactThreshold, getEffectiveContextWindowSize } from './autoCompact.js'
|
||||
import {
|
||||
getAutoCompactThreshold,
|
||||
getEffectiveContextWindowSize,
|
||||
isIneffectiveCompaction,
|
||||
shouldThrottleAutoCompact,
|
||||
type AutoCompactTrackingState,
|
||||
} from './autoCompact.js'
|
||||
import type { CompactionResult } from './compact.js'
|
||||
import { getContextWindowForModel } from '../../utils/context.js'
|
||||
import { MODEL_CONTEXT_WINDOWS_ENV_KEY } from '../../utils/model/modelContextWindows.js'
|
||||
|
||||
@ -67,3 +74,65 @@ describe('model context window resolution', () => {
|
||||
expect(getAutoCompactThreshold('MiniMax-M2.7')).toBe(171_800)
|
||||
})
|
||||
})
|
||||
|
||||
function makeTracking(
|
||||
overrides: Partial<AutoCompactTrackingState> = {},
|
||||
): AutoCompactTrackingState {
|
||||
return {
|
||||
compacted: true,
|
||||
turnId: 'turn-1',
|
||||
turnCounter: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeCompactionResult(
|
||||
truePostCompactTokenCount: number | undefined,
|
||||
): CompactionResult {
|
||||
// Only the field the predicate reads matters for these tests.
|
||||
return { truePostCompactTokenCount } as unknown as CompactionResult
|
||||
}
|
||||
|
||||
describe('方案2: shouldThrottleAutoCompact (turn throttle)', () => {
|
||||
test('throttles for the first few turns after a compaction', () => {
|
||||
expect(shouldThrottleAutoCompact(makeTracking({ turnCounter: 0 }), false)).toBe(true)
|
||||
expect(shouldThrottleAutoCompact(makeTracking({ turnCounter: 1 }), false)).toBe(true)
|
||||
expect(shouldThrottleAutoCompact(makeTracking({ turnCounter: 2 }), false)).toBe(true)
|
||||
})
|
||||
|
||||
test('stops throttling once enough turns have elapsed', () => {
|
||||
expect(shouldThrottleAutoCompact(makeTracking({ turnCounter: 3 }), false)).toBe(false)
|
||||
expect(shouldThrottleAutoCompact(makeTracking({ turnCounter: 9 }), false)).toBe(false)
|
||||
})
|
||||
|
||||
test('never throttles at the hard blocking limit, even right after a compaction', () => {
|
||||
expect(shouldThrottleAutoCompact(makeTracking({ turnCounter: 0 }), true)).toBe(false)
|
||||
})
|
||||
|
||||
test('does not throttle when no compaction has happened yet', () => {
|
||||
expect(shouldThrottleAutoCompact(undefined, false)).toBe(false)
|
||||
expect(
|
||||
shouldThrottleAutoCompact(makeTracking({ compacted: false, turnCounter: 0 }), false),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('方案1: isIneffectiveCompaction (recompaction-loop guard)', () => {
|
||||
// deepseek-v4-pro: autocompact threshold is 967_000 (see math tests above).
|
||||
const model = 'deepseek-v4-pro'
|
||||
const threshold = getAutoCompactThreshold(model)
|
||||
|
||||
test('flags a compaction whose result is still at/above the threshold', () => {
|
||||
expect(isIneffectiveCompaction(makeCompactionResult(threshold), model)).toBe(true)
|
||||
expect(isIneffectiveCompaction(makeCompactionResult(threshold + 50_000), model)).toBe(true)
|
||||
})
|
||||
|
||||
test('treats a compaction that got under the threshold as effective', () => {
|
||||
expect(isIneffectiveCompaction(makeCompactionResult(threshold - 1), model)).toBe(false)
|
||||
expect(isIneffectiveCompaction(makeCompactionResult(10_000), model)).toBe(false)
|
||||
})
|
||||
|
||||
test('treats an unknown post-compact size as effective (no false circuit-break)', () => {
|
||||
expect(isIneffectiveCompaction(makeCompactionResult(undefined), model)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@ -69,6 +69,13 @@ export const MANUAL_COMPACT_BUFFER_TOKENS = 3_000
|
||||
// in a single session, wasting ~250K API calls/day globally.
|
||||
const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3
|
||||
|
||||
// 方案2: minimum turns between two automatic compactions. After a successful
|
||||
// autocompaction we reset turnCounter to 0 (query.ts); we then refuse to
|
||||
// proactively compact again until this many turns have elapsed — UNLESS the
|
||||
// context is at the hard blocking limit (where skipping would risk a
|
||||
// prompt-too-long error). Prevents "compact every couple of messages" thrash.
|
||||
const MIN_TURNS_BETWEEN_AUTO_COMPACT = 3
|
||||
|
||||
export function getAutoCompactThreshold(model: string): number {
|
||||
const effectiveContextWindow = getEffectiveContextWindowSize(model)
|
||||
|
||||
@ -238,6 +245,37 @@ export async function shouldAutoCompact(
|
||||
return isAboveAutoCompactThreshold
|
||||
}
|
||||
|
||||
// 方案2 predicate (pure, exported for tests): suppress proactive autocompaction
|
||||
// for the first MIN_TURNS_BETWEEN_AUTO_COMPACT turns after a compaction, but
|
||||
// never when we're already at the hard blocking limit.
|
||||
export function shouldThrottleAutoCompact(
|
||||
tracking: AutoCompactTrackingState | undefined,
|
||||
isAtBlockingLimit: boolean,
|
||||
): boolean {
|
||||
if (isAtBlockingLimit) return false
|
||||
return (
|
||||
tracking?.compacted === true &&
|
||||
tracking.turnCounter < MIN_TURNS_BETWEEN_AUTO_COMPACT
|
||||
)
|
||||
}
|
||||
|
||||
// 方案1: a compaction is "ineffective" when, after it completes, the resulting
|
||||
// context is still at or above the autocompact threshold — i.e. the very next
|
||||
// turn would trigger autocompact again. We feed these into the same circuit
|
||||
// breaker as hard failures so a run of ineffective compactions eventually
|
||||
// stops the loop instead of re-summarizing on every turn forever (each attempt
|
||||
// burns a full summarization API call). truePostCompactTokenCount is an
|
||||
// estimate; when it's absent we conservatively treat the compaction as
|
||||
// effective so we never falsely trip the breaker.
|
||||
export function isIneffectiveCompaction(
|
||||
compactionResult: CompactionResult,
|
||||
model: string,
|
||||
): boolean {
|
||||
const truePost = compactionResult.truePostCompactTokenCount
|
||||
if (truePost === undefined) return false
|
||||
return truePost >= getAutoCompactThreshold(model)
|
||||
}
|
||||
|
||||
export async function autoCompactIfNeeded(
|
||||
messages: Message[],
|
||||
toolUseContext: ToolUseContext,
|
||||
@ -276,6 +314,23 @@ export async function autoCompactIfNeeded(
|
||||
return { wasCompacted: false }
|
||||
}
|
||||
|
||||
// 方案2: turn throttle. If we compacted very recently, don't compact again
|
||||
// for a few turns — give the context a chance to settle instead of
|
||||
// re-summarizing every message. The hard blocking limit overrides this:
|
||||
// when we're that close to the context window we must compact regardless,
|
||||
// otherwise the next request risks a prompt-too-long failure.
|
||||
if (tracking?.compacted === true && tracking.turnCounter < MIN_TURNS_BETWEEN_AUTO_COMPACT) {
|
||||
const tokenCount =
|
||||
tokenCountWithEstimation(messages) - (snipTokensFreed ?? 0)
|
||||
const { isAtBlockingLimit } = calculateTokenWarningState(tokenCount, model)
|
||||
if (shouldThrottleAutoCompact(tracking, isAtBlockingLimit)) {
|
||||
logForDebugging(
|
||||
`autocompact: throttled (turnsSinceCompact=${tracking.turnCounter} < ${MIN_TURNS_BETWEEN_AUTO_COMPACT}, not at blocking limit)`,
|
||||
)
|
||||
return { wasCompacted: false }
|
||||
}
|
||||
}
|
||||
|
||||
const recompactionInfo: RecompactionInfo = {
|
||||
isRecompactionInChain: tracking?.compacted === true,
|
||||
turnsSincePreviousCompact: tracking?.turnCounter ?? -1,
|
||||
@ -306,6 +361,9 @@ export async function autoCompactIfNeeded(
|
||||
return {
|
||||
wasCompacted: true,
|
||||
compactionResult: sessionMemoryResult,
|
||||
consecutiveFailures: isIneffectiveCompaction(sessionMemoryResult, model)
|
||||
? (tracking?.consecutiveFailures ?? 0) + 1
|
||||
: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@ -325,10 +383,27 @@ export async function autoCompactIfNeeded(
|
||||
setLastSummarizedMessageId(undefined)
|
||||
runPostCompactCleanup(querySource)
|
||||
|
||||
// 方案1: if the compaction didn't actually get us under the threshold, count
|
||||
// it toward the circuit breaker instead of resetting to 0. Otherwise a
|
||||
// perpetually-over-threshold session re-compacts every single turn.
|
||||
const ineffective = isIneffectiveCompaction(compactionResult, model)
|
||||
if (ineffective) {
|
||||
const nextFailures = (tracking?.consecutiveFailures ?? 0) + 1
|
||||
logForDebugging(
|
||||
`autocompact: ineffective compaction (postCompact=${compactionResult.truePostCompactTokenCount} >= threshold=${getAutoCompactThreshold(model)}); counting toward circuit breaker (${nextFailures}/${MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES})`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
return {
|
||||
wasCompacted: true,
|
||||
compactionResult,
|
||||
consecutiveFailures: nextFailures,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
wasCompacted: true,
|
||||
compactionResult,
|
||||
// Reset failure count on success
|
||||
// Reset failure count on a successful, effective compaction
|
||||
consecutiveFailures: 0,
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user