Просмотр исходного кода

Merge pull request #283 from deepseek-harness/codex/simp-compaction-surface

refactor: narrow compaction surface
Tianyi Cui 2 месяцев назад
Родитель
Сommit
e8ae22d547

+ 1 - 1
docs/cordis-catalog/services.md

@@ -105,7 +105,7 @@ Abstract compaction service. Implementations own trigger policy, retention, and
 
 ```ts cordis-catalog
 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)

+ 2 - 2
docs/core-data-structures/compaction.md

@@ -1,6 +1,6 @@
 # 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)
 
@@ -20,7 +20,7 @@ These variants are merged inside a `declare module '@deepseek-ai/dsh-session'` b
 
 ## `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
 interface CompactionResult {

+ 2 - 2
docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md

@@ -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 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.
 
@@ -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.
 
-`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
 

+ 2 - 2
packages/compact/compact-basic/README.md

@@ -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.
 - **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.
-- **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.
 
-`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`)
 

+ 5 - 11
packages/compact/compact-basic/src/index.ts

@@ -21,7 +21,6 @@ import type {
   ResolvedConfig,
 } from './types.ts'
 
-export { resolveConfig } from './config.ts'
 export type {
   BasicCompactConfig,
   ResolvedConfig,
@@ -96,7 +95,7 @@ export class BasicCompactService extends CompactService {
    * @param signal - optional cancellation forwarded to the adapter.
    * @returns safe text summary blocks and exact auxiliary-call provenance.
    */
-  async summarize(
+  protected async summarize(
     text: string,
     agent: Agent,
     signal?: AbortSignal,
@@ -137,7 +136,7 @@ export class BasicCompactService extends CompactService {
         /* v8 ignore next -- paired with the defensive post-success branch above. */
         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)
       if (measurement.totalTokens < threshold) return result
     }
@@ -149,10 +148,8 @@ export class BasicCompactService extends CompactService {
   }
 
   /**
-   * Compact one inclusive positional surface range using the effective
-   * token meter for all retention and shrink pricing. Reject an agent that does
-   * not own the exact target before any mutation.
-   * @param session - session whose surface is mutated; must equal `agent.session`.
+   * Compact one inclusive positional range from the agent-owned surface using
+   * the effective token meter for all retention and shrink pricing.
    * @param start - inclusive first surface-node seq.
    * @param end - inclusive last surface-node seq.
    * @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.
    */
   override async compactRegion(
-    session: Session,
     start: number,
     end: number,
     agent: Agent,
     signal?: AbortSignal,
   ): Promise<CompactionResult> {
-    if (session !== agent.session) {
-      throw new Error('compactRegion: agent.session must be the exact target session')
-    }
+    const session = agent.session
     return compactSurfaceRegion({
       meter: this.ctx.tokenMeter,
       summarize: (text, owner, abort) => this.summarize(text, owner, abort),

+ 20 - 43
packages/compact/compact-basic/tests/compact-basic.spec.ts

@@ -1,8 +1,9 @@
 import { describe, expect, it, vi } from 'vitest'
 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 { 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 LlmService, { CallId, LlmAdapter } 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', () => {
-  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 () => {
     const compact = service()
     const session = conversation(3)
     const before = session.surface.nodes
     const result = await compact.compactRegion(
-      session,
       before[0]!,
       before[3]!,
       agent(session, MODEL),
@@ -410,7 +390,6 @@ describe('compaction region transaction', () => {
     const session = conversation(2)
     const nodes = session.surface.nodes
     await expect(compact.compactRegion(
-      session,
       startOverride ?? nodes[0]!,
       endOverride ?? nodes[1]!,
       agent(session, MODEL),
@@ -422,7 +401,6 @@ describe('compaction region transaction', () => {
     const plain = conversation(2)
     const nodes = plain.surface.nodes
     await expect(compact.compactRegion(
-      plain,
       nodes[2]!,
       nodes[1]!,
       agent(plain, MODEL),
@@ -431,13 +409,11 @@ describe('compaction region transaction', () => {
     const tools = toolConversation()
     const toolNodes = tools.surface.nodes
     await expect(compact.compactRegion(
-      tools,
       toolNodes[2]!,
       toolNodes[4]!,
       agent(tools, MODEL),
     )).rejects.toThrow(/start seq .* not a balanced boundary/)
     await expect(compact.compactRegion(
-      tools,
       toolNodes[0]!,
       toolNodes[1]!,
       agent(tools, MODEL),
@@ -450,7 +426,6 @@ describe('compaction region transaction', () => {
     closed.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
     const nodes = closed.surface.nodes
     await expect(compact.compactRegion(
-      closed,
       nodes[0]!,
       nodes[1]!,
       agent(closed, MODEL),
@@ -460,7 +435,6 @@ describe('compaction region transaction', () => {
     locked.append('compact/start', { turn: 2 })
     const lockedNodes = locked.surface.nodes
     await expect(compact.compactRegion(
-      locked,
       lockedNodes[0]!,
       lockedNodes[1]!,
       agent(locked, MODEL),
@@ -477,7 +451,6 @@ describe('compaction region transaction', () => {
     const node = session.surface.nodes[0]!
 
     await expect(compact.compactRegion(
-      session,
       node,
       node,
       agent(session, MODEL),
@@ -497,7 +470,6 @@ describe('compaction region transaction', () => {
     const nodes = session.surface.nodes
 
     await expect(compact.compactRegion(
-      session,
       nodes[0]!,
       nodes[2]!,
       agent(session, MODEL),
@@ -511,7 +483,6 @@ describe('compaction region transaction', () => {
     const before = session.surface.nodes
 
     await expect(compact.compactRegion(
-      session,
       before[0]!,
       before[2]!,
       agent(session, MODEL),
@@ -527,7 +498,6 @@ describe('compaction region transaction', () => {
     const session = conversation(2)
     const nodes = session.surface.nodes
     await expect(compact.compactRegion(
-      session,
       nodes[0]!,
       nodes[2]!,
       agent(session, MODEL),
@@ -548,7 +518,6 @@ describe('compaction region transaction', () => {
     const nodes = session.surface.nodes
 
     await expect(compact.compactRegion(
-      session,
       nodes[0]!,
       nodes[2]!,
       agent(session, MODEL),
@@ -566,7 +535,6 @@ describe('compaction region transaction', () => {
     const nodes = session.surface.nodes
 
     await expect(compact.compactRegion(
-      session,
       nodes[0]!,
       nodes[2]!,
       agent(session, MODEL),
@@ -579,7 +547,6 @@ describe('compaction region transaction', () => {
     const session = conversation(1)
     const nodes = session.surface.nodes
     await expect(compact.compactRegion(
-      session,
       nodes[0]!,
       nodes[1]!,
       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(
   blocks: readonly ContentBlock[],
   finish?: (StreamChunk & { type: 'finish' })['reason'],
   model = MODEL,
   config: BasicCompactConfig = { auto: false },
-): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: BasicCompactService }> {
+): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: ExposedCompactService }> {
   const ctx = new Context()
   await ctx.plugin(LlmService)
   void new TokenMeterService(ctx, { contextWindow: 1_000 })
   const adapter = new ScriptedAdapter(blocks, finish)
   ctx.llm.registerAdapter([model], adapter)
-  const compact = new BasicCompactService(ctx, config)
+  const compact = new ExposedCompactService(ctx, config)
   return { ctx, adapter, compact }
 }
 
@@ -641,7 +618,7 @@ describe('default one-shot summarizer', () => {
       maxTokens: 321,
     })
     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({
       summary: [{ type: 'text', text: 'public summary' }],
@@ -666,7 +643,7 @@ describe('default one-shot summarizer', () => {
       header: { config: { provider: 'routed', model: 'routed' } },
       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.model).toBe('routed')
     expect(adapter.lastOptions?.provider).toBe('routed')
@@ -677,8 +654,8 @@ describe('default one-shot summarizer', () => {
     const ctx = new Context()
     await ctx.plugin(LlmService)
     void new TokenMeterService(ctx)
-    const compact = new BasicCompactService(ctx, { auto: false })
-    await expect(compact.summarize('history', agent(new Session(SessionId('model-less')))))
+    const compact = new ExposedCompactService(ctx, { auto: false })
+    await expect(compact.runSummarize('history', agent(new Session(SessionId('model-less')))))
       .rejects.toThrow(/no provider\/model available for summarization/)
   })
 
@@ -693,7 +670,7 @@ describe('default one-shot summarizer', () => {
       const { compact } = await summarizerHarness([], finish)
       let thrown: unknown
       try {
-        await compact.summarize('history', agent(conversation(1), MODEL))
+        await compact.runSummarize('history', agent(conversation(1), MODEL))
       } catch (error: unknown) {
         thrown = error
       }
@@ -705,7 +682,7 @@ describe('default one-shot summarizer', () => {
 
   it('rejects empty or reasoning-only successful output', async () => {
     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/)
   })
 })

+ 3 - 1
packages/compact/compact/README.md

@@ -19,7 +19,9 @@ Both methods are **abstract** — the backend owns trigger policy, retention, ev
 | 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`. |
-| `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.
 

+ 5 - 9
packages/compact/compact/src/index.ts

@@ -67,23 +67,19 @@ export abstract class CompactService extends Service {
    * `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.
+   * backed implementation forwards cancellation and rejects active, missing,
+   * reversed, or unbalanced ranges. The target session is `agent.session`.
    * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}
    * for the edge checks.
    *
-   * @param session - session to mutate; must be identical to `agent.session`.
    * @param start - first 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.
-   * @throws when the agent does not own `session`, compaction is active, or the range is missing, reversed, or unbalanced.
-   * @returns the replaced range and summary.
+   * @throws when compaction is active or the range is missing, reversed, or unbalanced.
+   * @returns the appended event seqs, summary, replaced range, and token accounting.
    */
   abstract compactRegion(
-    session: Session,
     start: number,
     end: number,
     agent: CompactAgentContext,

+ 11 - 6
packages/compact/compact/tests/compact.spec.ts

@@ -27,17 +27,18 @@ class StubCompactService extends CompactService {
   }
 
   override async compactRegion(
-    session: Session,
     start: number,
     end: number,
-    _agent: CompactAgentContext,
+    agent: CompactAgentContext,
     signal?: AbortSignal,
   ): Promise<CompactionResult> {
     this.lastSignal = signal
+    const session = agent.session
+    const summary = [{ type: 'text' as const, text: 'stub' }]
     // Minimal stub honoring the lock + log-only event contract.
     const startEvent = session.append('compact/start', { turn: 0 })
     const summaryEvent = session.append('compact/summary', {
-      summary: [{ type: 'text', text: 'stub' }],
+      summary,
       shadowedRange: { start, end },
       shadowedSeqs: [],
       shadowedTokenCount: 0,
@@ -49,7 +50,7 @@ class StubCompactService extends CompactService {
       startSeq: startEvent.seq,
       summarySeq: summaryEvent.seq,
       endSeq: endEvent.seq,
-      summary: [{ type: 'text', text: 'stub' }],
+      summary,
       shadowedRange: { start, end },
       shadowedSeqs: [],
       shadowedTokenCount: 0,
@@ -89,7 +90,7 @@ describe('CompactService seam', () => {
     const svc = new StubCompactService(ctx)
     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')
     expect(startEvent).toBeDefined()
@@ -97,8 +98,12 @@ describe('CompactService seam', () => {
     // verify the runtime value is absent.
     const raw = startEvent as unknown as { surfaceOp?: unknown }
     expect(raw.surfaceOp).toBeUndefined()
+    expect(result.summary).toEqual([{ type: 'text', text: 'stub' }])
     expect(result.summarySeq).toBeGreaterThan(result.startSeq)
     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 () => {
@@ -107,7 +112,7 @@ describe('CompactService seam', () => {
     const session = new Session(SessionId('s'))
     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)
 
     await svc.compactIfNeeded(stubAgent(session), '', [], controller.signal)

+ 1 - 1
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -112,7 +112,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
     summary: 'Abstract compaction service.',
     methods: [
       '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>',
     ],
   },
   {

+ 6 - 7
website/zh-CN/api/harness/compact.md

@@ -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)
 
-### ctx.compact.compactRegion(session, start, end, agent, signal?)
+### ctx.compact.compactRegion(start, end, agent, signal?)
 
 ```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.
 - `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.
 
-**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)