mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
docs: trim generated prose
This commit is contained in:
@@ -1,31 +1,6 @@
|
||||
/**
|
||||
* `BasicCompactService`: the first implementation of the
|
||||
* `@deepseek-ai/dsh-compact` seam. It owns the entire compaction strategy:
|
||||
*
|
||||
* - **Token estimation** — chars/`charsPerToken` heuristic (config, default 4)
|
||||
* with per-block structural overhead.
|
||||
* - **Retention policy** — walk surface nodes tail→head, keep recent nodes up
|
||||
* to a token budget, compact everything older. The cutoff is snapped forward
|
||||
* to the next balanced tool-pairing boundary so a compacted region never
|
||||
* splits a step's tool-call/result pair (an open tail step is never crossed —
|
||||
* compaction declines and retries once it closes).
|
||||
* - **Summarization** — a direct one-shot `ctx.llm.stream()` call assembled
|
||||
* via `BlockAssembler` with a fixed condense-the-history system prompt;
|
||||
* NOT a loop step, so `agent/request` never fires — interception happens
|
||||
* at `llm/stream` like any other direct call.
|
||||
* - **Surface mutation** — a single `user/message` replace node carries the
|
||||
* summary; `compact/*` events are log-only lock + provenance records.
|
||||
* - **Auto-compaction** — an `agent/pre-step` listener delegates to
|
||||
* {@link BasicCompactService.compactIfNeeded} before EVERY step (so a
|
||||
* tool-heavy turn that grows the surface mid-turn still compacts); it owns the
|
||||
* sole token-pressure check.
|
||||
*
|
||||
* A different backend (real tokenizer, template summarizer, turn-count
|
||||
* retention) either subclasses this and overrides the {@link
|
||||
* BasicCompactService.estimateContentTokens} / {@link
|
||||
* BasicCompactService.summarize} hooks, or implements the abstract
|
||||
* {@link CompactService} from scratch.
|
||||
*
|
||||
* `BasicCompactService`: the first implementation of the `@deepseek-ai/dsh-compact` seam. It
|
||||
* owns the entire compaction strategy.
|
||||
* @module @deepseek-ai/dsh-compact-basic
|
||||
*/
|
||||
|
||||
@@ -54,15 +29,8 @@ const SUMMARY_OPEN_TAG = '<compacted-summary>'
|
||||
const SUMMARY_CLOSE_TAG = '</compacted-summary>'
|
||||
|
||||
/**
|
||||
* The summarization system prompt: instructs the model to condense the
|
||||
* conversation into a fixed, fully-populated structure rather than freeform
|
||||
* bullets. The fixed structure guarantees coverage of the things a resuming
|
||||
* model needs (original intent, pending work, the next step, critical context)
|
||||
* and is stable across compaction cycles, so a prior checkpoint can be merged
|
||||
* in place. The final rule keys off {@link SUMMARY_OPEN_TAG}: when the
|
||||
* transcript already contains a prior checkpoint, the model consolidates rather
|
||||
* than re-summarizing it verbatim (a cheap incremental-merge that needs no
|
||||
* extra log/event machinery — the tag travels on the summary surface node).
|
||||
* The summarization system prompt: instructs the model to condense the conversation into a
|
||||
* fixed, fully-populated structure rather than freeform bullets.
|
||||
*/
|
||||
const SUMMARIZE_SYSTEM_PROMPT = [
|
||||
'You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.',
|
||||
@@ -100,29 +68,13 @@ const SUMMARIZE_SYSTEM_PROMPT = [
|
||||
`- If the transcript already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`,
|
||||
].join('\n')
|
||||
|
||||
/**
|
||||
* Framing prepended to the landed summary so a resuming model reads it as a
|
||||
* checkpoint rather than a fresh user request, and continues the task from it.
|
||||
* It summarizes an earlier span of the conversation; the messages that follow
|
||||
* are the continuation. Because region compaction can be invoked manually, a
|
||||
* surface may hold several checkpoints, so the framing does NOT claim that
|
||||
* everything after it is recent or verbatim — only that the captured context
|
||||
* should be built on, not restated.
|
||||
*/
|
||||
/** Framing that makes a landed summary established context rather than a new request. */
|
||||
const CHECKPOINT_PREAMBLE =
|
||||
'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.'
|
||||
|
||||
/**
|
||||
* Map a terminal `FinishReason` to the error a SUMMARIZATION must throw, or
|
||||
* `undefined` for an acceptable finish. `FinishReason` is merge-extensible.
|
||||
*
|
||||
* Compaction fails CLOSED on a truncated summary: `error`, `aborted`, AND
|
||||
* `max-tokens` all raise. Unlike an ordinary agent turn — where `max-tokens` is
|
||||
* a normal "the model hit its budget" outcome the loop keeps — a summary cut off
|
||||
* at the token cap is an INCOMPLETE checkpoint, and committing it would shadow
|
||||
* (discard) the real history it summarizes. Raising here keeps the original
|
||||
* surface intact (the caller appends `compact/end` with the error and the auto
|
||||
* path proceeds with full history). `stop`/future kinds are accepted.
|
||||
* Map a terminal `FinishReason` to the error a SUMMARIZATION must throw, or `undefined` for an
|
||||
* acceptable finish. `FinishReason` is merge-extensible.
|
||||
*/
|
||||
function finishError(finish: FinishReason): Error | undefined {
|
||||
switch (finish.kind) {
|
||||
@@ -164,25 +116,7 @@ export class BasicCompactService extends CompactService {
|
||||
this.config = resolveConfig(config)
|
||||
|
||||
if (this.config.auto) {
|
||||
// Auto-compaction: delegate to compactIfNeeded before EVERY step. This is
|
||||
// LOAD-BEARING for runaway-turn survival: a tool-heavy ReAct turn appends
|
||||
// an assistant/message and a tool/result per step, so the surface (and the
|
||||
// derived token count) grows WITHIN a turn. The only moment to rescue a
|
||||
// turn that alone approaches the window is the next step's pre-step
|
||||
// checkpoint; gating to a turn's first step would let a runaway turn
|
||||
// overflow before the next turn's check. The listener owns NO threshold
|
||||
// logic — compactIfNeeded is the single place that decides whether to
|
||||
// compact, and its in-progress lock serializes concurrent attempts.
|
||||
//
|
||||
// It runs on `agent/pre-step` (a serial surface-mutation checkpoint fired
|
||||
// AFTER turn/start but BEFORE step/start), NOT `agent/request`: compaction
|
||||
// mutates the session surface, and the loop derives the request `messages`
|
||||
// AFTER this fires — so a single derive already reflects the compaction,
|
||||
// with no double-derive and no need to rewrite an already-assembled
|
||||
// `messages` array. Firing pre-step (outside any open step) keeps the
|
||||
// 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.
|
||||
// Auto-compaction: delegate to compactIfNeeded before every step.
|
||||
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, sessionPrefix, signal)
|
||||
@@ -289,27 +223,8 @@ export class BasicCompactService extends CompactService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize conversation text into content blocks via `ctx.llm.stream()`
|
||||
* assembled through a `BlockAssembler`. A direct one-shot model call, NOT a
|
||||
* loop step: it does not run the `agent/request` waterfall (that seam shapes
|
||||
* the loop's conversation requests); per-call
|
||||
* interception happens at `llm/stream` like any other direct call. The model
|
||||
* comes from `BasicCompactConfig.summarizationModel`, falling back to the
|
||||
* agent's own model.
|
||||
* Override in a subclass for a template or remote summarizer.
|
||||
*
|
||||
* Honors the adapter failure contract: an adapter may report a model failure
|
||||
* by throwing from `stream()` (propagated here) OR by ending the stream with
|
||||
* a `finish {kind:'error'|'aborted'}` chunk — the latter is re-thrown so a
|
||||
* provider error never yields an empty summary.
|
||||
*
|
||||
* Forwards `signal` into `GenerateOptions.signal` so an abort/dispose tears
|
||||
* down the in-flight summarization rather than orphaning the model call.
|
||||
*
|
||||
* Returns the summary blocks TOGETHER with the call envelope it actually
|
||||
* used (`model`, `maxTokens`) — the caller logs the envelope on the
|
||||
* `compact/summary` provenance event, so an overriding subclass (template
|
||||
* or remote summarizer) reports its own envelope honestly.
|
||||
* Summarize conversation text into content blocks via `ctx.llm.stream()` assembled through a
|
||||
* `BlockAssembler`.
|
||||
*
|
||||
* @param text - plain-text rendering of the conversation region to condense.
|
||||
* @param agent - supplies the fallback model and the session id stamped on
|
||||
@@ -359,42 +274,10 @@ export class BasicCompactService extends CompactService {
|
||||
// ---- Core API (implements the abstract contract) ----
|
||||
|
||||
/**
|
||||
* 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. 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
|
||||
* token estimates, once the retained total reaches `retainTokens` the cutoff
|
||||
* is rounded to a balanced tool-pairing boundary: if the cut before the
|
||||
* retained node is unbalanced (an unanswered tool-call sits before it — i.e.
|
||||
* it is mid-step), the walk continues head-ward until the cut is balanced so
|
||||
* the whole step is retained (never splitting a step's tool-calls from their
|
||||
* results); if it stopped on a free node (a node belonging to no step), that
|
||||
* cut is already balanced. This always rounds toward retaining MORE (retained
|
||||
* ≥ `retainTokens`) and is boundary-safe by construction — no separate snap
|
||||
* pass.
|
||||
*
|
||||
* The compacted range is always anchored at the surface HEAD (`nodes[0]`):
|
||||
* auto-compaction re-consolidates any prior head checkpoint into one fresh
|
||||
* checkpoint. Declines (`null`) when nothing is over threshold, when the whole
|
||||
* surface fits the retain budget, or when no balanced cutoff exists in the
|
||||
* compactable range (its only content is an open tail step — retry once it
|
||||
* closes).
|
||||
* 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.
|
||||
*/
|
||||
override async compactIfNeeded(
|
||||
agent: Agent,
|
||||
@@ -450,13 +333,7 @@ export class BasicCompactService extends CompactService {
|
||||
agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompactionResult> {
|
||||
// Resolve the range by surface POSITION, not numeric seq interval. A prior
|
||||
// replace lands a fresh high-seq summary node AT the shadowed range's
|
||||
// position, so the surface order (head→tail) no longer tracks seq order —
|
||||
// `[newSummarySeq, olderRetainedSeq, …]` is normal. Indexing into the
|
||||
// ordered node list and slicing it is the only correct way to read a range;
|
||||
// a `node.seq >= start && node.seq <= end` interval test would mis-collect
|
||||
// nodes (and `start > end` would falsely reject) once that happens.
|
||||
// Resolve the range by surface POSITION, not numeric seq interval.
|
||||
const nodes = session.surface.nodes
|
||||
const startIdx = nodes.findIndex(n => n.seq === start)
|
||||
const endIdx = nodes.findIndex(n => n.seq === end)
|
||||
@@ -466,14 +343,8 @@ export class BasicCompactService extends CompactService {
|
||||
throw new Error(`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`)
|
||||
}
|
||||
|
||||
// The region must never split a step's assistant-message tool-calls from
|
||||
// their tool/results (which would orphan one side and produce a transcript
|
||||
// every provider rejects). A region is safe iff BOTH its edges are balanced
|
||||
// cuts: the cut before `start`, and the cut after `end`. A node that belongs
|
||||
// to no step (pre-step user message, inter-step steering, injection context)
|
||||
// is a balanced (free) boundary; an `end` inside an open (unclosed) tail step
|
||||
// leaves the cut after it unbalanced (the open tool-call has no result yet),
|
||||
// so it is rejected. See dsh-session's tool-pairing balance check.
|
||||
// The region must never split a step's assistant-message tool-calls from their tool/results
|
||||
// (which would orphan one side and produce a transcript every provider rejects).
|
||||
const events = session.events
|
||||
if (!isToolPairingBalanced(nodes, events, start)) {
|
||||
throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
|
||||
@@ -490,13 +361,8 @@ export class BasicCompactService extends CompactService {
|
||||
throw new Error('compaction already in progress')
|
||||
}
|
||||
|
||||
// Compaction's events (compact/* and the replacement user/message) must be
|
||||
// turn-enclosed: the session-log contract rejects any plugin event appended
|
||||
// outside an open turn. Auto-compaction satisfies this — it runs on the
|
||||
// `agent/pre-step` seam, after `turn/start` and before `step/start`, so
|
||||
// strictly inside the open turn (but outside any step). A manual call on a
|
||||
// fully-closed session has no turn to enclose the events, so reject rather
|
||||
// than emit an un-enclosed run.
|
||||
// Compaction's events (compact/* and the replacement user/message) must be turn-enclosed:
|
||||
// the session-log contract rejects any plugin event appended outside an open turn.
|
||||
const openTurn = this._openTurn(session)
|
||||
if (openTurn === null) {
|
||||
throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn')
|
||||
@@ -537,13 +403,8 @@ export class BasicCompactService extends CompactService {
|
||||
...maxTokens !== undefined ? { maxTokens } : {},
|
||||
})
|
||||
|
||||
// --- Surface replacement ---
|
||||
// The user/message directly shadows all compacted surface nodes with a
|
||||
// single replace op. It is the ONLY surface event in the compaction
|
||||
// sequence — compact/start, compact/summary, and compact/end are log-only
|
||||
// (surfaceOp is rejected by the compiler for non-SurfaceEventType).
|
||||
// The landed content is FRAMED (checkpoint preamble + tag-wrapped summary);
|
||||
// the compact/summary provenance event above holds the raw model output.
|
||||
// --- Surface replacement --- The user/message directly shadows all compacted surface
|
||||
// nodes with a single replace op.
|
||||
session.append('user/message', {
|
||||
content: framedSummary,
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
@@ -597,17 +458,8 @@ export class BasicCompactService extends CompactService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a compaction is currently in progress for `session` — an unmatched
|
||||
* `compact/start` (no later `compact/end`) WITHIN the current turn.
|
||||
*
|
||||
* The scan is scoped to the current turn: walking back from the tail it stops
|
||||
* at the first `turn/end` (the boundary closing the prior turn). A
|
||||
* `compact/start` left orphaned by a crash mid-compaction lives in a turn that
|
||||
* persistence repair then closes with a synthetic `turn/end`; scoping here so
|
||||
* that a stale orphan from a PAST turn cannot wedge compaction forever (it sits
|
||||
* before the nearest `turn/end`, so the scan never reaches it). An in-progress
|
||||
* compaction's `compact/start` is always in the still-open current turn,
|
||||
* before any `turn/end`, so it is still detected.
|
||||
* Whether a compaction is currently in progress for `session` — an unmatched `compact/start`
|
||||
* (no later `compact/end`) WITHIN the current turn.
|
||||
*/
|
||||
private _isCompactionInProgress(session: Session): boolean {
|
||||
const events = session.events
|
||||
@@ -650,14 +502,10 @@ export class BasicCompactService extends CompactService {
|
||||
// The whole surface fits the retain budget — nothing to compact.
|
||||
if (keepFromIdx === 0) return null
|
||||
|
||||
// Round the cutoff to a tool-pairing boundary: if the cut before
|
||||
// `nodes[keepFromIdx]` is unbalanced (an unanswered tool-call sits before
|
||||
// it — i.e. it is mid-step), extend the retained side head-ward until the
|
||||
// cut is balanced, so the compacted range ends without splitting an
|
||||
// assistant↔result pair. A node that belongs to no step is already a
|
||||
// balanced (free) boundary. Decline if no balanced cut exists at or below
|
||||
// `keepFromIdx` (the compactable range is only an un-splittable open tail
|
||||
// step — retry once it closes).
|
||||
// Round the cutoff to a tool-pairing boundary: if the cut before `nodes[keepFromIdx]` is
|
||||
// unbalanced (an unanswered tool-call sits before it — i.e. it is mid-step), extend the
|
||||
// retained side head-ward until the cut is balanced, so the compacted range ends without
|
||||
// splitting an assistant↔result pair.
|
||||
while (keepFromIdx > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!.seq)) break
|
||||
@@ -673,17 +521,7 @@ export class BasicCompactService extends CompactService {
|
||||
return { start: firstSeq, end: cutoffSeq }
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep ONLY text blocks from the model-produced summary before storing it.
|
||||
*
|
||||
* The summary lands on the surface as a synthesized `user/message` (see
|
||||
* {@link _frameSummary}), so the only block type that is both useful and safe
|
||||
* there is `text`. A model assistant message can otherwise carry `reasoning`
|
||||
* (private chain-of-thought, must not leak into the durable checkpoint) and
|
||||
* `tool-call` blocks — and a surviving `tool-call` in a user message would be
|
||||
* an orphaned call with no matching `tool-result`, exactly the tool-pairing
|
||||
* breakage compaction works to avoid. Filtering to text drops both.
|
||||
*/
|
||||
/** Keep only text; checkpoints cannot contain reasoning or orphan tool calls. */
|
||||
private _textOnly(blocks: readonly ContentBlock[]): ContentBlock[] {
|
||||
return blocks.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
|
||||
}
|
||||
|
||||
@@ -48,13 +48,6 @@ export type ResolvedConfig = Required<BasicCompactConfig>
|
||||
/**
|
||||
* Default `auto`/`charsPerToken` when unset and reject nonsensical numeric knobs.
|
||||
*
|
||||
* Convergence is not a static config invariant: provider generation caps can be
|
||||
* spent on hidden or surfaced reasoning tokens, and the model may emit a summary
|
||||
* of unpredictable size. The backend instead enforces convergence dynamically:
|
||||
* each committed summary must be smaller than the content it shadows, and
|
||||
* `compactIfNeeded` may re-compact up to `compactionRetries` extra times before
|
||||
* throwing if the surface still exceeds the threshold.
|
||||
*
|
||||
* @param config - the raw, unresolved backend config.
|
||||
* @returns the validated config with `auto` and `charsPerToken` defaulted.
|
||||
*/
|
||||
|
||||
@@ -211,12 +211,7 @@ function expectNoOrphanToolResults(messages: Message[]): void {
|
||||
|
||||
describe('BasicCompactService step-alignment (never split a tool-call/result pair)', () => {
|
||||
it('compactIfNeeded rounds the retained boundary head-ward to keep a whole step (no orphaned tool-result)', async () => {
|
||||
// 3 turns, each one step = { assistant(tool-call), tool/result }. Surface
|
||||
// (9 nodes): user1, asst1, res1, user2, asst2, res2, user3, asst3, res3 —
|
||||
// 10/20/10 tokens. The tail→head walk retains by whole units; the compacted
|
||||
// region always ends on a step boundary, so no step's tool-call is split
|
||||
// from its result. retainTokens=55 keeps the recent tail; the older steps
|
||||
// compact intact.
|
||||
// 3 turns, each one step = { assistant(tool-call), tool/result }.
|
||||
const svc = createTestService({ contextWindow: 280, thresholdRatio: 0.5, retainTokens: 55 })
|
||||
const session = toolTurnSession(3)
|
||||
|
||||
@@ -231,12 +226,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai
|
||||
})
|
||||
|
||||
it('compactIfNeeded returns null when the only compactable region is an un-splittable single step', async () => {
|
||||
// The surface is exactly ONE step: [assistant(tool-call), tool/result]. Over
|
||||
// threshold (by the derived role overhead), the tail→head walk stops with the
|
||||
// retained boundary at the tool/result — which is NOT a step-aligned start (its
|
||||
// issuing assistant precedes it in the same step). Rounding head-ward to find a
|
||||
// clean boundary reaches index 0, so there is no step-aligned cutoff in the
|
||||
// compactable range: compactIfNeeded declines rather than splitting the step.
|
||||
// The surface is exactly one step: [assistant(tool-call), tool/result].
|
||||
const s = new Session(SessionId('one-step'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
@@ -605,27 +595,14 @@ describe('BasicCompactService.compactIfNeeded', () => {
|
||||
})
|
||||
|
||||
it('returns null when the whole surface fits the retain budget (over threshold by role/system overhead)', async () => {
|
||||
// threshold = floor(480*0.1) = 48. The 4 surface nodes weigh 10 each (raw 40
|
||||
// for the retention walk), but the derived estimate adds 4 role tokens per
|
||||
// message → 56 ≥ 48, so the threshold check passes and the walk runs. The
|
||||
// walk accumulates all 40 < retainTokens (45) without crossing the budget,
|
||||
// so keepFromIdx reaches 0 and compaction declines.
|
||||
// threshold = floor(480*0.1) = 48.
|
||||
const svc = createTestService({ contextWindow: 480, thresholdRatio: 0.1, retainTokens: 45 })
|
||||
const session = multiTurnSession(2, 1)
|
||||
expect(await compactIfNeeded(svc, session, '', 'm', SIGNAL)).toBeNull()
|
||||
})
|
||||
|
||||
it('compacts a runaway turn: its early CLOSED steps summarize while recent steps stay verbatim', async () => {
|
||||
// The REGRESSION that motivated dropping turn-protection. A single in-flight
|
||||
// (open) turn has grown past the threshold on its own: several CLOSED steps,
|
||||
// each [assistant(tool-call), tool/result]. Retention is turn-agnostic, so
|
||||
// the turn's OWN early closed steps are eligible — they compact while the
|
||||
// recent tail stays verbatim, and the harness survives.
|
||||
//
|
||||
// On the OLD layer-2 code this test FAILS: the entire open turn was retained
|
||||
// verbatim (protectedIdx = first open-turn node = 0), so compactIfNeeded
|
||||
// returned null and shadowedSeqs would be empty — the runaway turn could
|
||||
// never compact and the next model call would overflow the window.
|
||||
// The Regression that motivated dropping turn-protection.
|
||||
const svc = createTestService({ contextWindow: 800, thresholdRatio: 0.1, retainTokens: 25 })
|
||||
const s = new Session(SessionId('runaway'))
|
||||
// ONE open turn with 5 closed steps; each step is [asst(tool-call), result].
|
||||
@@ -665,12 +642,7 @@ describe('BasicCompactService.compactIfNeeded', () => {
|
||||
})
|
||||
|
||||
it('compacts again after a prior summary node heads the surface (the summary stays eligible)', async () => {
|
||||
// After the first compaction lands a replacement summary node at the head,
|
||||
// a second compaction (still over threshold) re-consolidates it with newer
|
||||
// context — head-anchoring means the prior checkpoint is always re-included,
|
||||
// never stranded. retainTokens=25 leaves a couple of retained nodes after
|
||||
// the first compaction (so the surface is [summary, …retained], not just
|
||||
// [summary]).
|
||||
// Head-anchored recompaction must include the previous summary and retained context.
|
||||
const svc = createTestService({ contextWindow: 800, thresholdRatio: 0.1, retainTokens: 25 })
|
||||
const s = multiTurnSession(4, 1) // turns 1-4 closed, turn 5 open (no surface yet)
|
||||
|
||||
@@ -776,10 +748,8 @@ describe('BasicCompactService blocking (compaction in progress)', () => {
|
||||
})
|
||||
|
||||
it('is not wedged by an orphaned compact/start from a prior (now-closed) turn', async () => {
|
||||
// A crash mid-compaction left a compact/start with no compact/end; the turn
|
||||
// it lived in was later closed (persistence repair appends turn/end). A
|
||||
// whole-log scan would treat that stale start as an active lock forever. The
|
||||
// scan is scoped to the current turn, so a NEW turn compacts normally.
|
||||
// A crash mid-compaction left a compact/start with no compact/end; the turn it lived in was
|
||||
// later closed (persistence repair appends turn/end).
|
||||
const svc = createTestService()
|
||||
const s = new Session(SessionId('stale-lock'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
@@ -860,11 +830,8 @@ describe('BasicCompactService HMR safety', () => {
|
||||
})
|
||||
|
||||
it('disposing the plugin fiber unregisters ctx.compact', async () => {
|
||||
// Mount through the real plugin fiber (the Loader path), then dispose it and
|
||||
// confirm the service registration is torn down. LlmService is mounted first
|
||||
// so the service's `inject: ['llm']` resolves and the fiber activates. (The
|
||||
// sibling-fiber ctx.llm resolution this same setup also exercises is covered
|
||||
// under the "llm inject (real plugin-load path)" suite.)
|
||||
// Mount through the real plugin fiber (the Loader path), then dispose it and confirm the
|
||||
// service registration is torn down.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const fiber = await ctx.plugin(BasicCompactService, cfg({ auto: false }))
|
||||
@@ -1259,11 +1226,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
|
||||
|
||||
it('summarization is interceptable at llm/stream (model routing for direct calls)', async () => {
|
||||
const { ctx, adapter } = await ctxWithModel('ROUTED SUMMARY', 'routed-model')
|
||||
// The summarize call is a direct one-shot model call, not a loop step: it
|
||||
// does not run agent/request (that seam shapes the loop's conversation
|
||||
// requests). llm/stream is its interception surface, and a hand-built
|
||||
// request is not frozen, so mutate-then-next model routing works — the
|
||||
// adapter resolves AFTER the waterfall, so the rewrite picks the adapter.
|
||||
// One-shot summaries use llm/stream, not the loop's agent/request seam.
|
||||
ctx.on('llm/stream', (options, next) => {
|
||||
options.model = 'routed-model'
|
||||
return next()
|
||||
@@ -1526,10 +1489,7 @@ describe('BasicCompactService edge cases', () => {
|
||||
s.append('context/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
// Step 2: a tool exchange whose tool/result has empty content → empty
|
||||
// extraction → skipped. The assistant carries the matching tool-call so the
|
||||
// surface stays tool-pairing balanced; its text extracts to the tool-call
|
||||
// placeholder (the one surviving line).
|
||||
// Step 2: a tool exchange whose tool/result has empty content → empty extraction → skipped.
|
||||
s.append('step/start', { turn: 1, step: 2 })
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 2,
|
||||
@@ -1594,11 +1554,8 @@ describe('BasicCompactService edge cases', () => {
|
||||
|
||||
describe('BasicCompactService positional range (surface seqs are not monotonic after a replace)', () => {
|
||||
it('compacts a second region after the first replace lands a high-seq summary at the head position', async () => {
|
||||
// A replace inserts the new summary node (a high seq) AT the shadowed
|
||||
// range's surface position, so the surface becomes
|
||||
// [highSeqSummary, …olderRetainedLowerSeqs]. A second compaction over a
|
||||
// range whose start node has a HIGHER seq than its end node must still
|
||||
// succeed — the range is positional, not a numeric seq interval.
|
||||
// A replace inserts the new summary node (a high seq) AT the shadowed range's surface
|
||||
// position, so the surface becomes [highSeqSummary, …olderRetainedLowerSeqs].
|
||||
const svc = createTestService({ auto: false })
|
||||
const session = multiTurnSession(4, 1)
|
||||
|
||||
@@ -1606,19 +1563,13 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a
|
||||
const nodes0 = session.surface.nodes
|
||||
const first = await compactRegion(svc, session, nodes0[0]!.seq, nodes0[1]!.seq, 'm')
|
||||
|
||||
// The summary node now sits at the head with a seq HIGHER than the
|
||||
// retained older nodes that follow it — the non-monotonic surface. (The
|
||||
// head is the user/message replace node, appended after the compact/summary
|
||||
// provenance event, so its seq is at least first.summarySeq.)
|
||||
// The summary node now sits at the head with a seq HIGHER than the retained older nodes
|
||||
// that follow it — the non-monotonic surface.
|
||||
const nodes1 = session.surface.nodes
|
||||
expect(nodes1[0]!.seq).toBeGreaterThanOrEqual(first.summarySeq)
|
||||
expect(nodes1[0]!.seq).toBeGreaterThan(nodes1[1]!.seq)
|
||||
|
||||
// Second compaction: shadow [summary(head) … turn-2's step end]. The start
|
||||
// seq (the head summary node) is GREATER than the end seq (an older retained
|
||||
// node), so the range is a SURFACE-POSITION span, not a numeric seq interval.
|
||||
// The end must land on a step boundary (turn-2's assistant message closes
|
||||
// its step).
|
||||
// Second compaction: shadow [summary(head) … turn-2's step end].
|
||||
const startSeq = nodes1[0]!.seq
|
||||
const endSeq = nodes1[2]!.seq
|
||||
expect(startSeq).toBeGreaterThan(endSeq)
|
||||
@@ -1661,10 +1612,8 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a
|
||||
|
||||
describe('BasicCompactService llm inject (real plugin-load path)', () => {
|
||||
it('declares llm in static inject so a sibling fiber can resolve ctx.llm', () => {
|
||||
// summarize() reads ctx.llm; the inject lets the cordis ctx proxy resolve a
|
||||
// sibling LlmService when this service is mounted as its own plugin fiber.
|
||||
// Asserting the declaration (and exercising the real mount below) guards the
|
||||
// resolution that root-ctx unit tests cannot, since they share one fiber.
|
||||
// summarize() reads ctx.llm; the inject lets the cordis ctx proxy resolve a sibling
|
||||
// LlmService when this service is mounted as its own plugin fiber.
|
||||
expect(BasicCompactService.inject).toContain('llm')
|
||||
})
|
||||
|
||||
|
||||
@@ -14,24 +14,9 @@ import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
||||
import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* CBR-001 regression: a compaction checkpoint that the REAL loop lands is a
|
||||
* free surface boundary (it carries no tool-call/result pair), so it must be a
|
||||
* valid region edge on BOTH sides. A surface-anchored balance check sees that;
|
||||
* the abandoned log-position scan did not.
|
||||
*
|
||||
* The loop fires the compaction seam mid-flight, so the landed checkpoint
|
||||
* `user/message{replace}` sits at a HIGH log seq positioned beside the current
|
||||
* step even though its SURFACE position is the head. A log-position forward scan
|
||||
* from the checkpoint reaches the step's own later `assistant/message` and
|
||||
* wrongly reports the checkpoint as mid-step — refusing it as a region end. A
|
||||
* SECOND compaction that re-summarizes just that head checkpoint (region end ==
|
||||
* checkpoint) therefore throws and is swallowed, so the surface never
|
||||
* re-consolidates.
|
||||
*
|
||||
* This drives a real auto-compaction through the agent-loop and asserts the
|
||||
* landed checkpoint balances on both sides AND that re-compacting it (end ==
|
||||
* checkpoint) succeeds. RED on the log-position predicates; GREEN once alignment
|
||||
* is decided from surface tool-pairing balance.
|
||||
* CBR-001 regression: a compaction checkpoint that the real loop lands is a free surface
|
||||
* boundary (it carries no tool-call/result pair), so it must be a valid region edge on BOTH
|
||||
* sides.
|
||||
*/
|
||||
|
||||
const TOKENS_PER_BLOCK = 10
|
||||
@@ -132,14 +117,9 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
|
||||
)
|
||||
expect(checkpoints.length).toBeGreaterThan(0)
|
||||
|
||||
// The loop fired compaction mid-flight, so each landed checkpoint sits at a
|
||||
// high log seq beside the step it landed in, even though its SURFACE
|
||||
// position is the head of the range it shadowed. A checkpoint carries no
|
||||
// tool-call/result pair (only summarized prose), so every checkpoint still
|
||||
// on the surface must be a balanced cut on BOTH sides — the cut before it
|
||||
// (region START) and the cut after it (region END). The abandoned
|
||||
// log-position scan reported the END as mis-aligned because the forward log
|
||||
// scan reached the neighbouring step's assistant/message.
|
||||
// The loop fired compaction mid-flight, so each landed checkpoint sits at a high log seq
|
||||
// beside the step it landed in, even though its surface position is the head of the range
|
||||
// it shadowed.
|
||||
const nodes = agent.session.surface.nodes
|
||||
for (const cp of checkpoints) {
|
||||
const node = nodes.find(n => n.seq === cp.seq)
|
||||
|
||||
Reference in New Issue
Block a user