Merge pull request #283 from deepseek-harness/codex/simp-compaction-surface
refactor: narrow compaction surface
This commit is contained in:
@@ -105,7 +105,7 @@ Abstract compaction service. Implementations own trigger policy, retention, and
|
|||||||
|
|
||||||
```ts cordis-catalog
|
```ts cordis-catalog
|
||||||
abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise<CompactionResult | null>
|
abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise<CompactionResult | null>
|
||||||
abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>
|
abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>
|
||||||
```
|
```
|
||||||
|
|
||||||
Types: [Message](../core-data-structures/core.md)
|
Types: [Message](../core-data-structures/core.md)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Compaction
|
# Compaction
|
||||||
|
|
||||||
The compaction seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) split like bash: interface ([dsh-compact](../../packages/compact/compact), `ctx.compact`), implementation (a backend such as [dsh-compact-basic](../../packages/compact/compact-basic)), and consumer (a `/compact` tool, deferred). Compaction is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A tokenizer- or template-based backend is a sibling package implementing the same interface. Unlike bash, the interface necessarily depends on `dsh-session` and `dsh-llm`: its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary (see the [compaction capability-seam RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)).
|
The compaction seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) split like bash: interface ([dsh-compact](../../packages/compact/compact), `ctx.compact`), implementation (a backend such as [dsh-compact-basic](../../packages/compact/compact-basic)), and consumer (a `/compact` tool, deferred). Compaction is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A tokenizer- or template-based backend is a sibling package implementing the same interface. Unlike bash, the interface necessarily depends on `dsh-session` and `dsh-llm`: its verbs act on an agent-owned `Session`, and its durable summary event uses the `ContentBlock` vocabulary (see the [compaction capability-seam RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)).
|
||||||
|
|
||||||
Source: [`packages/compact/compact/src/types.ts`](../../packages/compact/compact/src/types.ts)
|
Source: [`packages/compact/compact/src/types.ts`](../../packages/compact/compact/src/types.ts)
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ These variants are merged inside a `declare module '@deepseek-ai/dsh-session'` b
|
|||||||
|
|
||||||
## `CompactionResult`
|
## `CompactionResult`
|
||||||
|
|
||||||
What a successful compaction returns to its caller: the seqs of the three appended `compact/*` events, the summary blocks, and the shadowed range/seqs plus the estimated token count.
|
What a successful compaction returns to its caller: the bookkeeping-event seqs, raw summary, shadowed range and seqs, and estimated token count.
|
||||||
|
|
||||||
```ts type-equiv
|
```ts type-equiv
|
||||||
interface CompactionResult {
|
interface CompactionResult {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ Per the [capability-seams RFC](../../implemented/architecture/2026-06-13-capabil
|
|||||||
|
|
||||||
### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation
|
### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation
|
||||||
|
|
||||||
The capability-seams RFC states the interface package "depends only on cordis" (true of `dsh-bash`, whose vocabulary is self-contained). Compaction **cannot** honor that: its verbs are defined *over* a `Session` (`compactRegion(session, start, end)`) and its output *is* the content vocabulary (`CompactionResult.summary: ContentBlock[]`). There is no way to express the contract without naming `Session`/`SessionEvent` (from `dsh-session`) and `ContentBlock` (from `dsh-llm`).
|
The capability-seams RFC states the interface package "depends only on cordis" (true of `dsh-bash`, whose vocabulary is self-contained). Compaction **cannot** honor that: its verbs act on an agent-owned `Session` (`compactRegion(start, end, agent)`) and its output uses the content vocabulary (`CompactionResult.summary: ContentBlock[]`). There is no way to express the contract without naming `Session`/`SessionEvent` (from `dsh-session`) and `ContentBlock` (from `dsh-llm`).
|
||||||
|
|
||||||
This is not a coupling smell — it is the contract's domain. The "only cordis" guidance was always shorthand for "the interface depends only on what the contract genuinely names, and never on an implementation." `dsh-session` and `dsh-llm` are themselves interface/vocabulary packages, not implementations; `dsh-compact` still imports no backend. The seam's real invariant — *consumers and implementations evolve independently behind an abstract service* — holds intact.
|
This is not a coupling smell — it is the contract's domain. The "only cordis" guidance was always shorthand for "the interface depends only on what the contract genuinely names, and never on an implementation." `dsh-session` and `dsh-llm` are themselves interface/vocabulary packages, not implementations; `dsh-compact` still imports no backend. The seam's real invariant — *consumers and implementations evolve independently behind an abstract service* — holds intact.
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ This is not a coupling smell — it is the contract's domain. The "only cordis"
|
|||||||
|
|
||||||
An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface. That recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend and keeps the interface a statement of *what*. Token measurement is not a compaction hook at all; the singleton service lets multiple consumers share one per-session replay fold.
|
An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface. That recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend and keeps the interface a statement of *what*. Token measurement is not a compaction hook at all; the singleton service lets multiple consumers share one per-session replay fold.
|
||||||
|
|
||||||
`compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` takes required pressure inputs and cancellation. The session comes from the agent. `compactRegion(session, start, end, agent, signal?)` keeps an optional signal for manual callers and requires `session === agent.session`; implementations reject mismatch before model resolution, lock acquisition, summarization, or log mutation. The pre-step integration resolves a provisional model from the latest logged request header, then `AgentOptions.model`; a model-less router-only first step skips pressure because `agent/request` can route later. The default summarizer resolves its model from explicit config, the latest logged routed model, then agent options.
|
`compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` takes required pressure inputs and cancellation. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for manual callers. The pre-step integration resolves a provisional provider/model pair from the latest logged request header, then `AgentOptions`; a model-less router-only first step skips pressure because `agent/request` can route later. The default summarizer resolves its target from explicit config, the latest logged routed target, then agent options.
|
||||||
|
|
||||||
### Auto-compaction runs on `agent/pre-step`, a dedicated surface-mutation seam
|
### Auto-compaction runs on `agent/pre-step`, a dedicated surface-mutation seam
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ This backend owns the compaction policy:
|
|||||||
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
|
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
|
||||||
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
|
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
|
||||||
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
|
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
|
||||||
- **Lifecycle** — `compactRegion()` requires its agent to own the exact target session and rejects mismatch before resolution or mutation; a valid call records its start, summary, replacement, and end. The serial `agent/pre-step` listener checks pressure before every step, outside an open step, so a tool-heavy turn remains compactable and the loop derives history once after mutation.
|
- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. The serial `agent/pre-step` listener checks pressure before every step, outside an open step, so a tool-heavy turn remains compactable and the loop derives history once after mutation.
|
||||||
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Recoverable failure records an error end and leaves the surface unchanged.
|
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Recoverable failure records an error end and leaves the surface unchanged.
|
||||||
|
|
||||||
`summarize()` is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`.
|
The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`.
|
||||||
|
|
||||||
## Config (`BasicCompactConfig`)
|
## Config (`BasicCompactConfig`)
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import type {
|
|||||||
ResolvedConfig,
|
ResolvedConfig,
|
||||||
} from './types.ts'
|
} from './types.ts'
|
||||||
|
|
||||||
export { resolveConfig } from './config.ts'
|
|
||||||
export type {
|
export type {
|
||||||
BasicCompactConfig,
|
BasicCompactConfig,
|
||||||
ResolvedConfig,
|
ResolvedConfig,
|
||||||
@@ -96,7 +95,7 @@ export class BasicCompactService extends CompactService {
|
|||||||
* @param signal - optional cancellation forwarded to the adapter.
|
* @param signal - optional cancellation forwarded to the adapter.
|
||||||
* @returns safe text summary blocks and exact auxiliary-call provenance.
|
* @returns safe text summary blocks and exact auxiliary-call provenance.
|
||||||
*/
|
*/
|
||||||
async summarize(
|
protected async summarize(
|
||||||
text: string,
|
text: string,
|
||||||
agent: Agent,
|
agent: Agent,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
@@ -137,7 +136,7 @@ export class BasicCompactService extends CompactService {
|
|||||||
/* v8 ignore next -- paired with the defensive post-success branch above. */
|
/* v8 ignore next -- paired with the defensive post-success branch above. */
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
result = await this.compactRegion(agent.session, range.start, range.end, agent, signal)
|
result = await this.compactRegion(range.start, range.end, agent, signal)
|
||||||
measurement = meter.measure(agent.session, requestHeader)
|
measurement = meter.measure(agent.session, requestHeader)
|
||||||
if (measurement.totalTokens < threshold) return result
|
if (measurement.totalTokens < threshold) return result
|
||||||
}
|
}
|
||||||
@@ -149,10 +148,8 @@ export class BasicCompactService extends CompactService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact one inclusive positional surface range using the effective
|
* Compact one inclusive positional range from the agent-owned surface using
|
||||||
* token meter for all retention and shrink pricing. Reject an agent that does
|
* the effective token meter for all retention and shrink pricing.
|
||||||
* not own the exact target before any mutation.
|
|
||||||
* @param session - session whose surface is mutated; must equal `agent.session`.
|
|
||||||
* @param start - inclusive first surface-node seq.
|
* @param start - inclusive first surface-node seq.
|
||||||
* @param end - inclusive last surface-node seq.
|
* @param end - inclusive last surface-node seq.
|
||||||
* @param agent - owner of the target session, used by the summarizer.
|
* @param agent - owner of the target session, used by the summarizer.
|
||||||
@@ -160,15 +157,12 @@ export class BasicCompactService extends CompactService {
|
|||||||
* @returns the successful durable compaction result.
|
* @returns the successful durable compaction result.
|
||||||
*/
|
*/
|
||||||
override async compactRegion(
|
override async compactRegion(
|
||||||
session: Session,
|
|
||||||
start: number,
|
start: number,
|
||||||
end: number,
|
end: number,
|
||||||
agent: Agent,
|
agent: Agent,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<CompactionResult> {
|
): Promise<CompactionResult> {
|
||||||
if (session !== agent.session) {
|
const session = agent.session
|
||||||
throw new Error('compactRegion: agent.session must be the exact target session')
|
|
||||||
}
|
|
||||||
return compactSurfaceRegion({
|
return compactSurfaceRegion({
|
||||||
meter: this.ctx.tokenMeter,
|
meter: this.ctx.tokenMeter,
|
||||||
summarize: (text, owner, abort) => this.summarize(text, owner, abort),
|
summarize: (text, owner, abort) => this.summarize(text, owner, abort),
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import BasicCompactService, { resolveConfig } from '@deepseek-ai/dsh-compact-basic'
|
import BasicCompactService from '@deepseek-ai/dsh-compact-basic'
|
||||||
import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic'
|
import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic'
|
||||||
import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts'
|
import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts'
|
||||||
|
import { resolveConfig } from '@deepseek-ai/dsh-compact-basic/src/config.ts'
|
||||||
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||||
import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||||
import type { ContentBlock, GenerateOptions, Message, StreamChunk } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock, GenerateOptions, Message, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||||
@@ -349,32 +350,11 @@ describe('pressure measurement and retention', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('compaction region transaction', () => {
|
describe('compaction region transaction', () => {
|
||||||
it('rejects an agent that does not own the exact target session before mutation', async () => {
|
|
||||||
const compact = service()
|
|
||||||
const target = conversation(2)
|
|
||||||
const owner = conversation(1)
|
|
||||||
const targetEvents = [...target.events]
|
|
||||||
const ownerEvents = [...owner.events]
|
|
||||||
const nodes = target.surface.nodes
|
|
||||||
|
|
||||||
await expect(compact.compactRegion(
|
|
||||||
target,
|
|
||||||
nodes[0]!,
|
|
||||||
nodes[1]!,
|
|
||||||
agent(owner),
|
|
||||||
)).rejects.toThrow('compactRegion: agent.session must be the exact target session')
|
|
||||||
|
|
||||||
expect(target.events).toEqual(targetEvents)
|
|
||||||
expect(owner.events).toEqual(ownerEvents)
|
|
||||||
expect(compact.calls).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('lands a framed, replayable checkpoint with exact pricing provenance', async () => {
|
it('lands a framed, replayable checkpoint with exact pricing provenance', async () => {
|
||||||
const compact = service()
|
const compact = service()
|
||||||
const session = conversation(3)
|
const session = conversation(3)
|
||||||
const before = session.surface.nodes
|
const before = session.surface.nodes
|
||||||
const result = await compact.compactRegion(
|
const result = await compact.compactRegion(
|
||||||
session,
|
|
||||||
before[0]!,
|
before[0]!,
|
||||||
before[3]!,
|
before[3]!,
|
||||||
agent(session, MODEL),
|
agent(session, MODEL),
|
||||||
@@ -410,7 +390,6 @@ describe('compaction region transaction', () => {
|
|||||||
const session = conversation(2)
|
const session = conversation(2)
|
||||||
const nodes = session.surface.nodes
|
const nodes = session.surface.nodes
|
||||||
await expect(compact.compactRegion(
|
await expect(compact.compactRegion(
|
||||||
session,
|
|
||||||
startOverride ?? nodes[0]!,
|
startOverride ?? nodes[0]!,
|
||||||
endOverride ?? nodes[1]!,
|
endOverride ?? nodes[1]!,
|
||||||
agent(session, MODEL),
|
agent(session, MODEL),
|
||||||
@@ -422,7 +401,6 @@ describe('compaction region transaction', () => {
|
|||||||
const plain = conversation(2)
|
const plain = conversation(2)
|
||||||
const nodes = plain.surface.nodes
|
const nodes = plain.surface.nodes
|
||||||
await expect(compact.compactRegion(
|
await expect(compact.compactRegion(
|
||||||
plain,
|
|
||||||
nodes[2]!,
|
nodes[2]!,
|
||||||
nodes[1]!,
|
nodes[1]!,
|
||||||
agent(plain, MODEL),
|
agent(plain, MODEL),
|
||||||
@@ -431,13 +409,11 @@ describe('compaction region transaction', () => {
|
|||||||
const tools = toolConversation()
|
const tools = toolConversation()
|
||||||
const toolNodes = tools.surface.nodes
|
const toolNodes = tools.surface.nodes
|
||||||
await expect(compact.compactRegion(
|
await expect(compact.compactRegion(
|
||||||
tools,
|
|
||||||
toolNodes[2]!,
|
toolNodes[2]!,
|
||||||
toolNodes[4]!,
|
toolNodes[4]!,
|
||||||
agent(tools, MODEL),
|
agent(tools, MODEL),
|
||||||
)).rejects.toThrow(/start seq .* not a balanced boundary/)
|
)).rejects.toThrow(/start seq .* not a balanced boundary/)
|
||||||
await expect(compact.compactRegion(
|
await expect(compact.compactRegion(
|
||||||
tools,
|
|
||||||
toolNodes[0]!,
|
toolNodes[0]!,
|
||||||
toolNodes[1]!,
|
toolNodes[1]!,
|
||||||
agent(tools, MODEL),
|
agent(tools, MODEL),
|
||||||
@@ -450,7 +426,6 @@ describe('compaction region transaction', () => {
|
|||||||
closed.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
closed.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||||
const nodes = closed.surface.nodes
|
const nodes = closed.surface.nodes
|
||||||
await expect(compact.compactRegion(
|
await expect(compact.compactRegion(
|
||||||
closed,
|
|
||||||
nodes[0]!,
|
nodes[0]!,
|
||||||
nodes[1]!,
|
nodes[1]!,
|
||||||
agent(closed, MODEL),
|
agent(closed, MODEL),
|
||||||
@@ -460,7 +435,6 @@ describe('compaction region transaction', () => {
|
|||||||
locked.append('compact/start', { turn: 2 })
|
locked.append('compact/start', { turn: 2 })
|
||||||
const lockedNodes = locked.surface.nodes
|
const lockedNodes = locked.surface.nodes
|
||||||
await expect(compact.compactRegion(
|
await expect(compact.compactRegion(
|
||||||
locked,
|
|
||||||
lockedNodes[0]!,
|
lockedNodes[0]!,
|
||||||
lockedNodes[1]!,
|
lockedNodes[1]!,
|
||||||
agent(locked, MODEL),
|
agent(locked, MODEL),
|
||||||
@@ -477,7 +451,6 @@ describe('compaction region transaction', () => {
|
|||||||
const node = session.surface.nodes[0]!
|
const node = session.surface.nodes[0]!
|
||||||
|
|
||||||
await expect(compact.compactRegion(
|
await expect(compact.compactRegion(
|
||||||
session,
|
|
||||||
node,
|
node,
|
||||||
node,
|
node,
|
||||||
agent(session, MODEL),
|
agent(session, MODEL),
|
||||||
@@ -497,7 +470,6 @@ describe('compaction region transaction', () => {
|
|||||||
const nodes = session.surface.nodes
|
const nodes = session.surface.nodes
|
||||||
|
|
||||||
await expect(compact.compactRegion(
|
await expect(compact.compactRegion(
|
||||||
session,
|
|
||||||
nodes[0]!,
|
nodes[0]!,
|
||||||
nodes[2]!,
|
nodes[2]!,
|
||||||
agent(session, MODEL),
|
agent(session, MODEL),
|
||||||
@@ -511,7 +483,6 @@ describe('compaction region transaction', () => {
|
|||||||
const before = session.surface.nodes
|
const before = session.surface.nodes
|
||||||
|
|
||||||
await expect(compact.compactRegion(
|
await expect(compact.compactRegion(
|
||||||
session,
|
|
||||||
before[0]!,
|
before[0]!,
|
||||||
before[2]!,
|
before[2]!,
|
||||||
agent(session, MODEL),
|
agent(session, MODEL),
|
||||||
@@ -527,7 +498,6 @@ describe('compaction region transaction', () => {
|
|||||||
const session = conversation(2)
|
const session = conversation(2)
|
||||||
const nodes = session.surface.nodes
|
const nodes = session.surface.nodes
|
||||||
await expect(compact.compactRegion(
|
await expect(compact.compactRegion(
|
||||||
session,
|
|
||||||
nodes[0]!,
|
nodes[0]!,
|
||||||
nodes[2]!,
|
nodes[2]!,
|
||||||
agent(session, MODEL),
|
agent(session, MODEL),
|
||||||
@@ -548,7 +518,6 @@ describe('compaction region transaction', () => {
|
|||||||
const nodes = session.surface.nodes
|
const nodes = session.surface.nodes
|
||||||
|
|
||||||
await expect(compact.compactRegion(
|
await expect(compact.compactRegion(
|
||||||
session,
|
|
||||||
nodes[0]!,
|
nodes[0]!,
|
||||||
nodes[2]!,
|
nodes[2]!,
|
||||||
agent(session, MODEL),
|
agent(session, MODEL),
|
||||||
@@ -566,7 +535,6 @@ describe('compaction region transaction', () => {
|
|||||||
const nodes = session.surface.nodes
|
const nodes = session.surface.nodes
|
||||||
|
|
||||||
await expect(compact.compactRegion(
|
await expect(compact.compactRegion(
|
||||||
session,
|
|
||||||
nodes[0]!,
|
nodes[0]!,
|
||||||
nodes[2]!,
|
nodes[2]!,
|
||||||
agent(session, MODEL),
|
agent(session, MODEL),
|
||||||
@@ -579,7 +547,6 @@ describe('compaction region transaction', () => {
|
|||||||
const session = conversation(1)
|
const session = conversation(1)
|
||||||
const nodes = session.surface.nodes
|
const nodes = session.surface.nodes
|
||||||
await expect(compact.compactRegion(
|
await expect(compact.compactRegion(
|
||||||
session,
|
|
||||||
nodes[0]!,
|
nodes[0]!,
|
||||||
nodes[1]!,
|
nodes[1]!,
|
||||||
agent(session),
|
agent(session),
|
||||||
@@ -613,18 +580,28 @@ class ScriptedAdapter extends LlmAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ExposedCompactService extends BasicCompactService {
|
||||||
|
runSummarize(
|
||||||
|
text: string,
|
||||||
|
owner: Agent,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
|
||||||
|
return this.summarize(text, owner, signal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function summarizerHarness(
|
async function summarizerHarness(
|
||||||
blocks: readonly ContentBlock[],
|
blocks: readonly ContentBlock[],
|
||||||
finish?: (StreamChunk & { type: 'finish' })['reason'],
|
finish?: (StreamChunk & { type: 'finish' })['reason'],
|
||||||
model = MODEL,
|
model = MODEL,
|
||||||
config: BasicCompactConfig = { auto: false },
|
config: BasicCompactConfig = { auto: false },
|
||||||
): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: BasicCompactService }> {
|
): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: ExposedCompactService }> {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(LlmService)
|
await ctx.plugin(LlmService)
|
||||||
void new TokenMeterService(ctx, { contextWindow: 1_000 })
|
void new TokenMeterService(ctx, { contextWindow: 1_000 })
|
||||||
const adapter = new ScriptedAdapter(blocks, finish)
|
const adapter = new ScriptedAdapter(blocks, finish)
|
||||||
ctx.llm.registerAdapter([model], adapter)
|
ctx.llm.registerAdapter([model], adapter)
|
||||||
const compact = new BasicCompactService(ctx, config)
|
const compact = new ExposedCompactService(ctx, config)
|
||||||
return { ctx, adapter, compact }
|
return { ctx, adapter, compact }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,7 +618,7 @@ describe('default one-shot summarizer', () => {
|
|||||||
maxTokens: 321,
|
maxTokens: 321,
|
||||||
})
|
})
|
||||||
const session = conversation(1)
|
const session = conversation(1)
|
||||||
const output = await compact.summarize('transcript', agent(session, 'fallback'), SIGNAL)
|
const output = await compact.runSummarize('transcript', agent(session, 'fallback'), SIGNAL)
|
||||||
|
|
||||||
expect(output).toEqual({
|
expect(output).toEqual({
|
||||||
summary: [{ type: 'text', text: 'public summary' }],
|
summary: [{ type: 'text', text: 'public summary' }],
|
||||||
@@ -666,7 +643,7 @@ describe('default one-shot summarizer', () => {
|
|||||||
header: { config: { provider: 'routed', model: 'routed' } },
|
header: { config: { provider: 'routed', model: 'routed' } },
|
||||||
reason: 'initial',
|
reason: 'initial',
|
||||||
})
|
})
|
||||||
const output = await compact.summarize('history', agent(session, 'fallback'))
|
const output = await compact.runSummarize('history', agent(session, 'fallback'))
|
||||||
expect(output.provider).toBe('routed')
|
expect(output.provider).toBe('routed')
|
||||||
expect(output.model).toBe('routed')
|
expect(output.model).toBe('routed')
|
||||||
expect(adapter.lastOptions?.provider).toBe('routed')
|
expect(adapter.lastOptions?.provider).toBe('routed')
|
||||||
@@ -677,8 +654,8 @@ describe('default one-shot summarizer', () => {
|
|||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(LlmService)
|
await ctx.plugin(LlmService)
|
||||||
void new TokenMeterService(ctx)
|
void new TokenMeterService(ctx)
|
||||||
const compact = new BasicCompactService(ctx, { auto: false })
|
const compact = new ExposedCompactService(ctx, { auto: false })
|
||||||
await expect(compact.summarize('history', agent(new Session(SessionId('model-less')))))
|
await expect(compact.runSummarize('history', agent(new Session(SessionId('model-less')))))
|
||||||
.rejects.toThrow(/no provider\/model available for summarization/)
|
.rejects.toThrow(/no provider\/model available for summarization/)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -693,7 +670,7 @@ describe('default one-shot summarizer', () => {
|
|||||||
const { compact } = await summarizerHarness([], finish)
|
const { compact } = await summarizerHarness([], finish)
|
||||||
let thrown: unknown
|
let thrown: unknown
|
||||||
try {
|
try {
|
||||||
await compact.summarize('history', agent(conversation(1), MODEL))
|
await compact.runSummarize('history', agent(conversation(1), MODEL))
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
thrown = error
|
thrown = error
|
||||||
}
|
}
|
||||||
@@ -705,7 +682,7 @@ describe('default one-shot summarizer', () => {
|
|||||||
|
|
||||||
it('rejects empty or reasoning-only successful output', async () => {
|
it('rejects empty or reasoning-only successful output', async () => {
|
||||||
const { compact } = await summarizerHarness([{ type: 'reasoning', text: 'private' }])
|
const { compact } = await summarizerHarness([{ type: 'reasoning', text: 'private' }])
|
||||||
await expect(compact.summarize('history', agent(conversation(1), MODEL)))
|
await expect(compact.runSummarize('history', agent(conversation(1), MODEL)))
|
||||||
.rejects.toThrow(/no text summary content/)
|
.rejects.toThrow(/no text summary content/)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ Both methods are **abstract** — the backend owns trigger policy, retention, ev
|
|||||||
| Member | Semantics |
|
| Member | Semantics |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-step` checkpoint supplies the agent, assembled `fullSystemPrompt`, composed `sessionPrefix` (request-only messages every request carries but the derived history omits — the pressure estimate must count them), and turn `signal`. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. |
|
| `compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-step` checkpoint supplies the agent, assembled `fullSystemPrompt`, composed `sessionPrefix` (request-only messages every request carries but the derived history omits — the pressure estimate must count them), and turn `signal`. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. |
|
||||||
| `compactRegion(session, start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. The agent must own the exact target (`session === agent.session`); a backend rejects mismatch before model resolution, lock acquisition, summarization, or log mutation. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. |
|
| `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. |
|
||||||
|
|
||||||
|
`CompactionResult` keeps the raw summary and bookkeeping-event seqs available to callers alongside the shadowed range and token accounting; its drift-checked shape lives in the [compaction data-structure reference](../../../docs/core-data-structures/compaction.md#compactionresult).
|
||||||
|
|
||||||
`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is recoverable from the owned session's log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value.
|
`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is recoverable from the owned session's log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value.
|
||||||
|
|
||||||
|
|||||||
@@ -67,23 +67,19 @@ export abstract class CompactService extends Service {
|
|||||||
* `start` and `end` name an inclusive span by surface position, not numeric seq
|
* `start` and `end` name an inclusive span by surface position, not numeric seq
|
||||||
* order; replacements can make visible seqs non-monotonic. Both edges must be
|
* order; replacements can make visible seqs non-monotonic. Both edges must be
|
||||||
* balanced so assistant tool calls remain paired with their results. A model-
|
* balanced so assistant tool calls remain paired with their results. A model-
|
||||||
* backed implementation forwards cancellation. The agent must own the exact
|
* backed implementation forwards cancellation and rejects active, missing,
|
||||||
* target session object; implementations reject an ownership mismatch before
|
* reversed, or unbalanced ranges. The target session is `agent.session`.
|
||||||
* model resolution, lock acquisition, summarization, or log mutation, and
|
|
||||||
* reject active, missing, reversed, or unbalanced ranges.
|
|
||||||
* Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}
|
* Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}
|
||||||
* for the edge checks.
|
* for the edge checks.
|
||||||
*
|
*
|
||||||
* @param session - session to mutate; must be identical to `agent.session`.
|
|
||||||
* @param start - first surface seq, inclusive.
|
* @param start - first surface seq, inclusive.
|
||||||
* @param end - last surface seq, inclusive.
|
* @param end - last surface seq, inclusive.
|
||||||
* @param agent - owner of the target session and summarizer context.
|
* @param agent - context whose session is mutated and whose routing options guide summarization.
|
||||||
* @param signal - optional cancellation; model-backed implementations must forward it.
|
* @param signal - optional cancellation; model-backed implementations must forward it.
|
||||||
* @throws when the agent does not own `session`, compaction is active, or the range is missing, reversed, or unbalanced.
|
* @throws when compaction is active or the range is missing, reversed, or unbalanced.
|
||||||
* @returns the replaced range and summary.
|
* @returns the appended event seqs, summary, replaced range, and token accounting.
|
||||||
*/
|
*/
|
||||||
abstract compactRegion(
|
abstract compactRegion(
|
||||||
session: Session,
|
|
||||||
start: number,
|
start: number,
|
||||||
end: number,
|
end: number,
|
||||||
agent: CompactAgentContext,
|
agent: CompactAgentContext,
|
||||||
|
|||||||
@@ -27,17 +27,18 @@ class StubCompactService extends CompactService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override async compactRegion(
|
override async compactRegion(
|
||||||
session: Session,
|
|
||||||
start: number,
|
start: number,
|
||||||
end: number,
|
end: number,
|
||||||
_agent: CompactAgentContext,
|
agent: CompactAgentContext,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<CompactionResult> {
|
): Promise<CompactionResult> {
|
||||||
this.lastSignal = signal
|
this.lastSignal = signal
|
||||||
|
const session = agent.session
|
||||||
|
const summary = [{ type: 'text' as const, text: 'stub' }]
|
||||||
// Minimal stub honoring the lock + log-only event contract.
|
// Minimal stub honoring the lock + log-only event contract.
|
||||||
const startEvent = session.append('compact/start', { turn: 0 })
|
const startEvent = session.append('compact/start', { turn: 0 })
|
||||||
const summaryEvent = session.append('compact/summary', {
|
const summaryEvent = session.append('compact/summary', {
|
||||||
summary: [{ type: 'text', text: 'stub' }],
|
summary,
|
||||||
shadowedRange: { start, end },
|
shadowedRange: { start, end },
|
||||||
shadowedSeqs: [],
|
shadowedSeqs: [],
|
||||||
shadowedTokenCount: 0,
|
shadowedTokenCount: 0,
|
||||||
@@ -49,7 +50,7 @@ class StubCompactService extends CompactService {
|
|||||||
startSeq: startEvent.seq,
|
startSeq: startEvent.seq,
|
||||||
summarySeq: summaryEvent.seq,
|
summarySeq: summaryEvent.seq,
|
||||||
endSeq: endEvent.seq,
|
endSeq: endEvent.seq,
|
||||||
summary: [{ type: 'text', text: 'stub' }],
|
summary,
|
||||||
shadowedRange: { start, end },
|
shadowedRange: { start, end },
|
||||||
shadowedSeqs: [],
|
shadowedSeqs: [],
|
||||||
shadowedTokenCount: 0,
|
shadowedTokenCount: 0,
|
||||||
@@ -89,7 +90,7 @@ describe('CompactService seam', () => {
|
|||||||
const svc = new StubCompactService(ctx)
|
const svc = new StubCompactService(ctx)
|
||||||
const session = new Session(SessionId('s'))
|
const session = new Session(SessionId('s'))
|
||||||
|
|
||||||
const result = await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'))
|
const result = await svc.compactRegion(0, 0, stubAgent(session, 'm'))
|
||||||
|
|
||||||
const startEvent = session.events.find(e => e.type === 'compact/start')
|
const startEvent = session.events.find(e => e.type === 'compact/start')
|
||||||
expect(startEvent).toBeDefined()
|
expect(startEvent).toBeDefined()
|
||||||
@@ -97,8 +98,12 @@ describe('CompactService seam', () => {
|
|||||||
// verify the runtime value is absent.
|
// verify the runtime value is absent.
|
||||||
const raw = startEvent as unknown as { surfaceOp?: unknown }
|
const raw = startEvent as unknown as { surfaceOp?: unknown }
|
||||||
expect(raw.surfaceOp).toBeUndefined()
|
expect(raw.surfaceOp).toBeUndefined()
|
||||||
|
expect(result.summary).toEqual([{ type: 'text', text: 'stub' }])
|
||||||
expect(result.summarySeq).toBeGreaterThan(result.startSeq)
|
expect(result.summarySeq).toBeGreaterThan(result.startSeq)
|
||||||
expect(result.endSeq).toBeGreaterThan(result.summarySeq)
|
expect(result.endSeq).toBeGreaterThan(result.summarySeq)
|
||||||
|
expect(result.shadowedRange).toEqual({ start: 0, end: 0 })
|
||||||
|
expect(session.events.filter(e => e.type.startsWith('compact/')).map(e => e.type))
|
||||||
|
.toEqual(['compact/start', 'compact/summary', 'compact/end'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('threads the cancellation signal through to the backend', async () => {
|
it('threads the cancellation signal through to the backend', async () => {
|
||||||
@@ -107,7 +112,7 @@ describe('CompactService seam', () => {
|
|||||||
const session = new Session(SessionId('s'))
|
const session = new Session(SessionId('s'))
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
|
||||||
await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'), controller.signal)
|
await svc.compactRegion(0, 0, stubAgent(session, 'm'), controller.signal)
|
||||||
expect(svc.lastSignal).toBe(controller.signal)
|
expect(svc.lastSignal).toBe(controller.signal)
|
||||||
|
|
||||||
await svc.compactIfNeeded(stubAgent(session), '', [], controller.signal)
|
await svc.compactIfNeeded(stubAgent(session), '', [], controller.signal)
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
|||||||
summary: 'Abstract compaction service.',
|
summary: 'Abstract compaction service.',
|
||||||
methods: [
|
methods: [
|
||||||
'abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise<CompactionResult | null>',
|
'abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise<CompactionResult | null>',
|
||||||
'abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>',
|
'abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,20 +25,19 @@ Check token pressure and compact if the conversation is too large. Estimate the
|
|||||||
|
|
||||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L58)
|
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L58)
|
||||||
|
|
||||||
### ctx.compact.compactRegion(session, start, end, agent, signal?)
|
### ctx.compact.compactRegion(start, end, agent, signal?)
|
||||||
|
|
||||||
```ts website-api
|
```ts website-api
|
||||||
abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>
|
abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>
|
||||||
```
|
```
|
||||||
|
|
||||||
Forcibly compact a range of surface nodes into a single summary node. `start` and `end` name an inclusive span by surface position, not numeric seq order; replacements can make visible seqs non-monotonic. Both edges must be balanced so assistant tool calls remain paired with their results. A model- backed implementation forwards cancellation. The agent must own the exact target session object; implementations reject an ownership mismatch before model resolution, lock acquisition, summarization, or log mutation, and reject active, missing, reversed, or unbalanced ranges. Use toolPairingBalancedBefore and toolPairingBalancedAfter for the edge checks.
|
Forcibly compact a range of surface nodes into a single summary node. `start` and `end` name an inclusive span by surface position, not numeric seq order; replacements can make visible seqs non-monotonic. Both edges must be balanced so assistant tool calls remain paired with their results. A model- backed implementation forwards cancellation and rejects active, missing, reversed, or unbalanced ranges. The target session is `agent.session`. Use toolPairingBalancedBefore and toolPairingBalancedAfter for the edge checks.
|
||||||
|
|
||||||
- `session` — session to mutate; must be identical to `agent.session`.
|
|
||||||
- `start` — first surface seq, inclusive.
|
- `start` — first surface seq, inclusive.
|
||||||
- `end` — last surface seq, inclusive.
|
- `end` — last surface seq, inclusive.
|
||||||
- `agent` — owner of the target session and summarizer context.
|
- `agent` — context whose session is mutated and whose routing options guide summarization.
|
||||||
- `signal` — optional cancellation; model-backed implementations must forward it.
|
- `signal` — optional cancellation; model-backed implementations must forward it.
|
||||||
|
|
||||||
**Returns** the replaced range and summary.
|
**Returns** the appended event seqs, summary, replaced range, and token accounting.
|
||||||
|
|
||||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L85)
|
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L82)
|
||||||
|
|||||||
Reference in New Issue
Block a user