fix(compact): count the logged session prefix toward token pressure

ds-review-bot critical: compactIfNeeded estimated pressure from the
derived history + system prompt only, but every loop-built request also
carries EpochHeader.messagePrefix in front of the history — a
deployment at the window edge would under-estimate by exactly the
prefix, skip compaction, and ship an over-window request.

BasicCompactService now gates on estimatePressure(): the session prefix
read from the log's folded header + the derived history + the system
prompt. The fold is exact from the instance's second request on (and
from a resumed instance's first — the previous instance logged its
prefix); it is absent only before a fresh session's first request,
where the history is a single prompt and compaction is moot. Compaction
itself still shrinks history only — a prefix that alone approaches the
window is a configuration error no compactor fixes, same as the
documented single-unit-overflow stance.
This commit is contained in:
Yichen Jiang
2026-07-08 19:32:42 +08:00
parent 77333c0a19
commit e6bd6cc9be
3 changed files with 55 additions and 7 deletions
+31 -6
View File
@@ -187,7 +187,7 @@ export class BasicCompactService extends CompactService {
try {
const result = await this.compactIfNeeded(agent, fullSystemPrompt, signal)
if (result) {
const after = this.estimateTokens(agent.session.deriveMessages(), fullSystemPrompt)
const after = this.estimatePressure(agent.session, fullSystemPrompt)
ctx.logger.info(
`compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` +
`(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` +
@@ -359,11 +359,19 @@ export class BasicCompactService extends CompactService {
// ---- Core API (implements the abstract contract) ----
/**
* The sole token-pressure gate: estimate the current surface-derived history,
* and if it exceeds the threshold (`contextWindow * thresholdRatio`), compact
* The sole token-pressure gate: estimate the NEXT request's pressure — the
* logged session prefix + the surface-derived history + the system prompt
* ({@link estimatePressure}) — and if it exceeds the threshold
* (`contextWindow * thresholdRatio`), compact
* the oldest surface nodes outside the `retainTokens` budget. The auto-
* compaction listener delegates here rather than pre-checking, so this is the
* only place the decision lives.
* only place the decision lives. The prefix counts because every request
* carries it in front of the history (`EpochHeader.messagePrefix`) even
* though it is not derived history — omitting it would under-estimate by
* exactly the prefix and let a deployment at the window edge skip
* compaction, then ship an over-window request. Compaction itself can only
* shrink HISTORY: a prefix that alone approaches the window is a
* configuration error no compactor fixes.
*
* Retention is a UNIFORM tail→head walk over the whole surface — turn
* boundaries play NO role. Walking node-by-node from the tail and summing
@@ -393,7 +401,7 @@ export class BasicCompactService extends CompactService {
const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio)
let result: CompactionResult | null = null
for (let attempt = 0; attempt <= this.config.compactionRetries; attempt++) {
const totalTokens = this.estimateTokens(session.deriveMessages(), fullSystemPrompt)
const totalTokens = this.estimatePressure(session, fullSystemPrompt)
if (totalTokens < threshold) return result
const range = this._compactableRange(session)
@@ -407,7 +415,7 @@ export class BasicCompactService extends CompactService {
result = await this.compactRegion(session, range.start, range.end, agent, signal)
}
const totalTokens = this.estimateTokens(session.deriveMessages(), fullSystemPrompt)
const totalTokens = this.estimatePressure(session, fullSystemPrompt)
if (totalTokens < threshold) return result
throw new Error(
@@ -416,6 +424,23 @@ export class BasicCompactService extends CompactService {
)
}
/**
* Estimated token pressure of the NEXT request: the logged session prefix
* (`EpochHeader.messagePrefix` from the header fold — request-only messages
* the loop sends in front of the derived history), the derived history, and
* the system prompt. The fold is exact from the loop instance's second
* request on (and from a resumed instance's first — the previous instance
* logged its prefix); it is absent only before a fresh session's first
* request, where the history is a single prompt and compaction is moot.
* @param session - the session whose next request is being estimated.
* @param fullSystemPrompt - the assembled system prompt (counts toward pressure).
* @returns the estimated token total the next request will carry.
*/
estimatePressure(session: Session, fullSystemPrompt: string): number {
const sessionPrefix = session.requestHeader()?.messagePrefix ?? []
return this.estimateTokens([...sessionPrefix, ...session.deriveMessages()], fullSystemPrompt)
}
override async compactRegion(
session: Session,
start: number,