|
|
@@ -30,7 +30,7 @@
|
|
|
*/
|
|
|
|
|
|
import { Context } from 'cordis'
|
|
|
-import { CompactService } from '@deepseek-ai/dsh-compact'
|
|
|
+import { CompactService, renderTranscript } from '@deepseek-ai/dsh-compact'
|
|
|
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
|
|
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
|
|
|
import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
|
|
@@ -183,11 +183,11 @@ export class BasicCompactService extends CompactService {
|
|
|
// log-only `compact/*` records and the replacement node cleanly outside a
|
|
|
// step, so a crash mid-compaction leaves an inert orphan the turn-repair
|
|
|
// closes — never a half-open step.
|
|
|
- ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, fullSystemPrompt: string, signal: AbortSignal) => {
|
|
|
+ ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal) => {
|
|
|
try {
|
|
|
- const result = await this.compactIfNeeded(agent, fullSystemPrompt, signal)
|
|
|
+ const result = await this.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)
|
|
|
if (result) {
|
|
|
- const after = this.estimateTokens(agent.session.deriveMessages(), fullSystemPrompt)
|
|
|
+ const after = this.estimatePressure(agent.session, fullSystemPrompt, sessionPrefix)
|
|
|
ctx.logger.info(
|
|
|
`compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` +
|
|
|
`(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` +
|
|
|
@@ -359,11 +359,23 @@ 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
|
|
|
+ * 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. The loop composes the
|
|
|
+ * prefix BEFORE the pre-step seam and hands it through, so the gate sees
|
|
|
+ * this instance's actual prefix (never a previous instance's logged one —
|
|
|
+ * a resumed/forked instance whose contributor grew is gated on the grown
|
|
|
+ * value from its very first step). 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
|
|
|
@@ -387,13 +399,14 @@ export class BasicCompactService extends CompactService {
|
|
|
override async compactIfNeeded(
|
|
|
agent: Agent,
|
|
|
fullSystemPrompt: string,
|
|
|
+ sessionPrefix: readonly Message[],
|
|
|
signal: AbortSignal,
|
|
|
): Promise<CompactionResult | null> {
|
|
|
const session = agent.session
|
|
|
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, sessionPrefix)
|
|
|
if (totalTokens < threshold) return result
|
|
|
|
|
|
const range = this._compactableRange(session)
|
|
|
@@ -407,7 +420,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, sessionPrefix)
|
|
|
if (totalTokens < threshold) return result
|
|
|
|
|
|
throw new Error(
|
|
|
@@ -416,6 +429,20 @@ export class BasicCompactService extends CompactService {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Estimated token pressure of the NEXT request: the session prefix
|
|
|
+ * (`EpochHeader.messagePrefix` — request-only messages the loop sends in
|
|
|
+ * front of the derived history, composed before the pre-step seam and
|
|
|
+ * handed to the gate), the derived history, and the system prompt.
|
|
|
+ * @param session - the session whose next request is being estimated.
|
|
|
+ * @param fullSystemPrompt - the assembled system prompt (counts toward pressure).
|
|
|
+ * @param sessionPrefix - the instance's composed session prefix (counts toward pressure).
|
|
|
+ * @returns the estimated token total the next request will carry.
|
|
|
+ */
|
|
|
+ estimatePressure(session: Session, fullSystemPrompt: string, sessionPrefix: readonly Message[]): number {
|
|
|
+ return this.estimateTokens([...sessionPrefix, ...session.deriveMessages()], fullSystemPrompt)
|
|
|
+ }
|
|
|
+
|
|
|
override async compactRegion(
|
|
|
session: Session,
|
|
|
start: number,
|
|
|
@@ -483,7 +510,7 @@ export class BasicCompactService extends CompactService {
|
|
|
|
|
|
try {
|
|
|
// --- Extract text and summarize ---
|
|
|
- const text = this._extractText(session, shadowedSeqs)
|
|
|
+ const text = renderTranscript(session.events, shadowedSeqs)
|
|
|
const { summary, model, maxTokens } = await this.summarize(text, agent, signal)
|
|
|
|
|
|
// Estimate token count of the shadowed content for provenance.
|
|
|
@@ -679,101 +706,6 @@ export class BasicCompactService extends CompactService {
|
|
|
}
|
|
|
return null
|
|
|
}
|
|
|
-
|
|
|
- /**
|
|
|
- * Extract plain-text conversation from a set of surface node seqs, for
|
|
|
- * feeding into the summarization model. Walks the seqs in the order given
|
|
|
- * (surface order, as `compactRegion` slices the surface-node list) so the
|
|
|
- * summary follows the conversation as the model sees it — which, after a
|
|
|
- * `replace`, is NOT ascending log-seq order (a high-seq summary node heads the
|
|
|
- * surface before older retained lower-seq nodes).
|
|
|
- */
|
|
|
- private _extractText(session: Session, seqs: number[]): string {
|
|
|
- const lines: string[] = []
|
|
|
-
|
|
|
- // Walk seqs in the order given (surface order, as compactRegion slices the
|
|
|
- // surface-node list) — NOT ascending log-seq order. After a replace the
|
|
|
- // summary node carries a fresh high seq while sitting at the head of the
|
|
|
- // surface before older retained lower-seq nodes, so a log-order scan would
|
|
|
- // feed the transcript out of order and break the checkpoint-merge prompt.
|
|
|
- for (const seq of seqs) {
|
|
|
- const event = session.events[seq]
|
|
|
- /* v8 ignore next -- seq is a surface-node seq, always a valid log index by construction */
|
|
|
- if (!event) continue
|
|
|
-
|
|
|
- switch (event.type) {
|
|
|
- case 'user/message': {
|
|
|
- const text = this._blocksToText(event.data.content)
|
|
|
- if (text) lines.push(`User: ${text}`)
|
|
|
- break
|
|
|
- }
|
|
|
- case 'assistant/message': {
|
|
|
- const text = this._blocksToText(event.data.content)
|
|
|
- if (text) lines.push(`Assistant: ${text}`)
|
|
|
- break
|
|
|
- }
|
|
|
- case 'tool/result': {
|
|
|
- const text = this._blocksToText(event.data.content)
|
|
|
- const label = event.data.isError ? 'Tool error' : 'Tool result'
|
|
|
- if (text) lines.push(`${label} (call ${event.data.callId}): ${text}`)
|
|
|
- break
|
|
|
- }
|
|
|
- case 'context/message': {
|
|
|
- const text = this._blocksToText(event.data.content)
|
|
|
- if (text) lines.push(`[Context: ${text}]`)
|
|
|
- break
|
|
|
- }
|
|
|
- case 'steering/message': {
|
|
|
- const text = this._blocksToText(event.data.content)
|
|
|
- if (text) lines.push(`[Steering: ${text}]`)
|
|
|
- break
|
|
|
- }
|
|
|
- // SessionEventMap is merge-extensible — unknown types are
|
|
|
- // non-message events that carry no extractable text.
|
|
|
- /* v8 ignore next 2 -- seqs only name surface nodes, always one of the 5 handled SurfaceEventTypes; unreachable */
|
|
|
- default:
|
|
|
- break
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- return lines.join('\n\n')
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * Render content blocks to a single plain-text string for the summarization
|
|
|
- * prompt. Text and reasoning contribute their text; every other block type
|
|
|
- * contributes a type-tagged placeholder (`[tool-call: name(args)]`,
|
|
|
- * `[tool-result: …]`, …) so the summarizer is told what non-text content
|
|
|
- * existed in the region rather than silently losing it. Blocks join with
|
|
|
- * newlines; empty-text blocks contribute nothing.
|
|
|
- */
|
|
|
- private _blocksToText(blocks: readonly ContentBlock[]): string {
|
|
|
- const parts: string[] = []
|
|
|
- for (const block of blocks) {
|
|
|
- switch (block.type) {
|
|
|
- case 'text':
|
|
|
- if (block.text) parts.push(block.text)
|
|
|
- break
|
|
|
- case 'reasoning':
|
|
|
- if (block.text) parts.push(`[reasoning: ${block.text}]`)
|
|
|
- break
|
|
|
- case 'tool-call':
|
|
|
- parts.push(`[tool-call: ${block.name}(${block.arguments})]`)
|
|
|
- break
|
|
|
- case 'tool-result': {
|
|
|
- const inner = this._blocksToText(block.content)
|
|
|
- parts.push(inner ? `[tool-result: ${inner}]` : '[tool-result]')
|
|
|
- break
|
|
|
- }
|
|
|
- // ContentBlockMap is merge-extensible — render an unknown block as a
|
|
|
- // bare type-tagged placeholder so a plugin-added block type is still
|
|
|
- // signalled to the summarizer rather than dropped.
|
|
|
- default:
|
|
|
- parts.push(`[${(block as ContentBlock).type}]`)
|
|
|
- }
|
|
|
- }
|
|
|
- return parts.join('\n')
|
|
|
- }
|
|
|
}
|
|
|
|
|
|
export default BasicCompactService
|