Explorar o código

Merge pull request #225 from deepseek-harness/recall-renderer-shared

refactor(compact): extract the shared transcript renderer into dsh-compact
Tianyi Cui hai 2 meses
pai
achega
b472dbcfb1

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

@@ -102,7 +102,7 @@ abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string,
 abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>
 abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>
 ```
 ```
 
 
-Source: [`packages/compact/compact/src/index.ts:63`](../../packages/compact/compact/src/index.ts)
+Source: [`packages/compact/compact/src/index.ts:64`](../../packages/compact/compact/src/index.ts)
 
 
 ## `ctx.fs` — `FileSystem` (abstract seam)
 ## `ctx.fs` — `FileSystem` (abstract seam)
 
 

+ 2 - 97
packages/compact/compact-basic/src/index.ts

@@ -30,7 +30,7 @@
  */
  */
 
 
 import { Context } from 'cordis'
 import { Context } from 'cordis'
-import { CompactService } from '@deepseek-ai/dsh-compact'
+import { CompactService, renderTranscript } from '@deepseek-ai/dsh-compact'
 import type { CompactionResult } from '@deepseek-ai/dsh-compact'
 import type { CompactionResult } from '@deepseek-ai/dsh-compact'
 import { BlockAssembler } from '@deepseek-ai/dsh-llm'
 import { BlockAssembler } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
@@ -483,7 +483,7 @@ export class BasicCompactService extends CompactService {
 
 
     try {
     try {
       // --- Extract text and summarize ---
       // --- Extract text and summarize ---
-      const text = this._extractText(session, shadowedSeqs)
+      const text = renderTranscript(session.events, shadowedSeqs)
       const { summary, model, maxTokens } = await this.summarize(text, agent, signal)
       const { summary, model, maxTokens } = await this.summarize(text, agent, signal)
 
 
       // Estimate token count of the shadowed content for provenance.
       // Estimate token count of the shadowed content for provenance.
@@ -679,101 +679,6 @@ export class BasicCompactService extends CompactService {
     }
     }
     return null
     return null
   }
   }
-
-  /**
-   * Extract plain-text conversation from a set of surface node seqs, for
-   * feeding into the summarization model. Walks the seqs in the order given
-   * (surface order, as `compactRegion` slices the surface-node list) so the
-   * summary follows the conversation as the model sees it — which, after a
-   * `replace`, is NOT ascending log-seq order (a high-seq summary node heads the
-   * surface before older retained lower-seq nodes).
-   */
-  private _extractText(session: Session, seqs: number[]): string {
-    const lines: string[] = []
-
-    // Walk seqs in the order given (surface order, as compactRegion slices the
-    // surface-node list) — NOT ascending log-seq order. After a replace the
-    // summary node carries a fresh high seq while sitting at the head of the
-    // surface before older retained lower-seq nodes, so a log-order scan would
-    // feed the transcript out of order and break the checkpoint-merge prompt.
-    for (const seq of seqs) {
-      const event = session.events[seq]
-      /* v8 ignore next -- seq is a surface-node seq, always a valid log index by construction */
-      if (!event) continue
-
-      switch (event.type) {
-        case 'user/message': {
-          const text = this._blocksToText(event.data.content)
-          if (text) lines.push(`User: ${text}`)
-          break
-        }
-        case 'assistant/message': {
-          const text = this._blocksToText(event.data.content)
-          if (text) lines.push(`Assistant: ${text}`)
-          break
-        }
-        case 'tool/result': {
-          const text = this._blocksToText(event.data.content)
-          const label = event.data.isError ? 'Tool error' : 'Tool result'
-          if (text) lines.push(`${label} (call ${event.data.callId}): ${text}`)
-          break
-        }
-        case 'context/message': {
-          const text = this._blocksToText(event.data.content)
-          if (text) lines.push(`[Context: ${text}]`)
-          break
-        }
-        case 'steering/message': {
-          const text = this._blocksToText(event.data.content)
-          if (text) lines.push(`[Steering: ${text}]`)
-          break
-        }
-        // SessionEventMap is merge-extensible — unknown types are
-        // non-message events that carry no extractable text.
-        /* v8 ignore next 2 -- seqs only name surface nodes, always one of the 5 handled SurfaceEventTypes; unreachable */
-        default:
-          break
-      }
-    }
-
-    return lines.join('\n\n')
-  }
-
-  /**
-   * Render content blocks to a single plain-text string for the summarization
-   * prompt. Text and reasoning contribute their text; every other block type
-   * contributes a type-tagged placeholder (`[tool-call: name(args)]`,
-   * `[tool-result: …]`, …) so the summarizer is told what non-text content
-   * existed in the region rather than silently losing it. Blocks join with
-   * newlines; empty-text blocks contribute nothing.
-   */
-  private _blocksToText(blocks: readonly ContentBlock[]): string {
-    const parts: string[] = []
-    for (const block of blocks) {
-      switch (block.type) {
-        case 'text':
-          if (block.text) parts.push(block.text)
-          break
-        case 'reasoning':
-          if (block.text) parts.push(`[reasoning: ${block.text}]`)
-          break
-        case 'tool-call':
-          parts.push(`[tool-call: ${block.name}(${block.arguments})]`)
-          break
-        case 'tool-result': {
-          const inner = this._blocksToText(block.content)
-          parts.push(inner ? `[tool-result: ${inner}]` : '[tool-result]')
-          break
-        }
-        // ContentBlockMap is merge-extensible — render an unknown block as a
-        // bare type-tagged placeholder so a plugin-added block type is still
-        // signalled to the summarizer rather than dropped.
-        default:
-          parts.push(`[${(block as ContentBlock).type}]`)
-      }
-    }
-    return parts.join('\n')
-  }
 }
 }
 
 
 export default BasicCompactService
 export default BasicCompactService

+ 1 - 1
packages/compact/compact-basic/tests/compact-basic.spec.ts

@@ -1278,7 +1278,7 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
   })
   })
 })
 })
 
 
-describe('BasicCompactService._extractText branches', () => {
+describe('BasicCompactService transcript rendering (delegated to dsh-compact)', () => {
   it('renders reasoning, context, and steering messages', async () => {
   it('renders reasoning, context, and steering messages', async () => {
     const svc = createTestService()
     const svc = createTestService()
     const s = new Session(SessionId('rich'))
     const s = new Session(SessionId('rich'))

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

@@ -6,7 +6,7 @@ This package is the interface tier of the compaction capability, split so each c
 
 
 | Package | Role |
 | Package | Role |
 |---|---|
 |---|---|
-| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` |
+| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) |
 | `@deepseek-ai/dsh-compact-basic` (deferred) | a backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization |
 | `@deepseek-ai/dsh-compact-basic` (deferred) | a backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization |
 | `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
 | `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
 
 

+ 1 - 0
packages/compact/compact/src/index.ts

@@ -26,6 +26,7 @@ import type { Session } from '@deepseek-ai/dsh-session'
 import type { CompactionResult } from './types.ts'
 import type { CompactionResult } from './types.ts'
 
 
 export type { CompactionResult } from './types.ts'
 export type { CompactionResult } from './types.ts'
+export { renderContentBlocks, renderTranscript } from './render.ts'
 
 
 /** Minimal agent context compaction needs without depending on the agent package. */
 /** Minimal agent context compaction needs without depending on the agent package. */
 export interface CompactAgentContext {
 export interface CompactAgentContext {

+ 118 - 0
packages/compact/compact/src/render.ts

@@ -0,0 +1,118 @@
+/**
+ * Plain-text transcript rendering over session events: the shared projection
+ * used wherever a compaction-class consumer needs "what a model once saw" as
+ * readable text — a summarizer's input, or a recall tool's output.
+ *
+ * Extracted from the basic backend's private helpers so the summarize path and
+ * the recall read path render one span identically (two renderers would drift,
+ * and a recall reader would then see a different transcript than the one the
+ * summary was written from). Both functions are pure over their arguments: no
+ * session access beyond the provided events, no clock, no randomness — a
+ * rendered span is a pure function of the log, so replay reproduces it
+ * byte-identically.
+ *
+ * @module @deepseek-ai/dsh-compact/render
+ */
+
+import type { ContentBlock } from '@deepseek-ai/dsh-llm'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+
+/**
+ * Render content blocks to a single plain-text string. Text and reasoning
+ * contribute their text (reasoning wrapped as `[reasoning: …]`); every other
+ * block type contributes a type-tagged placeholder (`[tool-call: name(args)]`,
+ * `[tool-result: …]`, …) so the reader is told what non-text content existed
+ * rather than silently losing it. A `tool-result` block recurses into its
+ * nested content (`[tool-result: <inner rendering>]`), falling back to a bare
+ * `[tool-result]` when the nested content renders to nothing. Blocks join
+ * with newlines; empty-text blocks contribute nothing.
+ *
+ * @param blocks - the content blocks to render.
+ * @returns the newline-joined plain-text rendering; empty string when nothing renders.
+ */
+export function renderContentBlocks(blocks: readonly ContentBlock[]): string {
+  const parts: string[] = []
+  for (const block of blocks) {
+    switch (block.type) {
+      case 'text':
+        if (block.text) parts.push(block.text)
+        break
+      case 'reasoning':
+        if (block.text) parts.push(`[reasoning: ${block.text}]`)
+        break
+      case 'tool-call':
+        parts.push(`[tool-call: ${block.name}(${block.arguments})]`)
+        break
+      case 'tool-result': {
+        const inner = renderContentBlocks(block.content)
+        parts.push(inner ? `[tool-result: ${inner}]` : '[tool-result]')
+        break
+      }
+      // ContentBlockMap is merge-extensible — render an unknown block as a
+      // bare type-tagged placeholder so a plugin-added block type is still
+      // signalled to the reader rather than dropped.
+      default:
+        parts.push(`[${(block as ContentBlock).type}]`)
+    }
+  }
+  return parts.join('\n')
+}
+
+/**
+ * Render a set of surface-node seqs as a `User:`/`Assistant:`/`Tool result:`
+ * transcript. Walks `seqs` in the order given — callers pass surface order
+ * (e.g. a `compactRegion` slice of the surface-node list), which after a
+ * `replace` is NOT ascending log-seq order (a high-seq summary node can sit at
+ * the head of the surface before older retained lower-seq nodes); a log-order
+ * scan would render the transcript out of order.
+ *
+ * Only the five surface (message-producing) event types render; a seq naming
+ * any other event type contributes nothing. `SessionEventMap` is
+ * merge-extensible, so unknown types are simply non-message events with no
+ * renderable text.
+ *
+ * @param events - the session log the seqs index into (`session.events`).
+ * @param seqs - the surface-node seqs to render, in surface order.
+ * @returns the transcript, entries joined by blank lines; empty string when nothing renders.
+ */
+export function renderTranscript(events: readonly SessionEvent[], seqs: readonly number[]): string {
+  const lines: string[] = []
+
+  for (const seq of seqs) {
+    const event = events[seq]
+    if (!event) continue
+
+    switch (event.type) {
+      case 'user/message': {
+        const text = renderContentBlocks(event.data.content)
+        if (text) lines.push(`User: ${text}`)
+        break
+      }
+      case 'assistant/message': {
+        const text = renderContentBlocks(event.data.content)
+        if (text) lines.push(`Assistant: ${text}`)
+        break
+      }
+      case 'tool/result': {
+        const text = renderContentBlocks(event.data.content)
+        const label = event.data.isError ? 'Tool error' : 'Tool result'
+        if (text) lines.push(`${label} (call ${event.data.callId}): ${text}`)
+        break
+      }
+      case 'context/message': {
+        const text = renderContentBlocks(event.data.content)
+        if (text) lines.push(`[Context: ${text}]`)
+        break
+      }
+      case 'steering/message': {
+        const text = renderContentBlocks(event.data.content)
+        if (text) lines.push(`[Steering: ${text}]`)
+        break
+      }
+      default:
+        break
+    }
+  }
+
+  return lines.join('\n\n')
+}

+ 138 - 0
packages/compact/compact/tests/render.spec.ts

@@ -0,0 +1,138 @@
+import { describe, expect, it } from 'vitest'
+import { renderContentBlocks, renderTranscript } from '@deepseek-ai/dsh-compact'
+import { Session, SessionId } from '@deepseek-ai/dsh-session'
+import type { ContentBlock } from '@deepseek-ai/dsh-llm'
+import { CallId } from '@deepseek-ai/dsh-llm'
+
+function session(): Session {
+  return new Session(SessionId('render-spec'))
+}
+
+describe('renderContentBlocks', () => {
+  it('renders text blocks verbatim and skips empty ones', () => {
+    expect(renderContentBlocks([
+      { type: 'text', text: 'hello' },
+      { type: 'text', text: '' },
+      { type: 'text', text: 'world' },
+    ])).toBe('hello\nworld')
+  })
+
+  it('wraps reasoning, skipping empty reasoning', () => {
+    expect(renderContentBlocks([
+      { type: 'reasoning', text: 'think' },
+      { type: 'reasoning', text: '' },
+    ])).toBe('[reasoning: think]')
+  })
+
+  it('renders tool-call as a name(args) placeholder', () => {
+    expect(renderContentBlocks([
+      { type: 'tool-call', id: CallId('c1'), name: 'read', arguments: '{"filePath":"a"}' },
+    ])).toBe('[tool-call: read({"filePath":"a"})]')
+  })
+
+  it('renders tool-result with nested content, and bare when empty', () => {
+    expect(renderContentBlocks([
+      { type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'ok' }] },
+      { type: 'tool-result', toolCallId: CallId('c2'), content: [] },
+    ])).toBe('[tool-result: ok]\n[tool-result]')
+  })
+
+  it('renders an unknown (merge-extended) block type as a bare type tag', () => {
+    const unknown = { type: 'image', data: 'zzz' } as unknown as ContentBlock
+    expect(renderContentBlocks([unknown])).toBe('[image]')
+  })
+
+  it('returns the empty string for no blocks', () => {
+    expect(renderContentBlocks([])).toBe('')
+  })
+})
+
+describe('renderTranscript', () => {
+  it('renders each surface event type with its label, in the seq order given', () => {
+    const s = session()
+    const user = s.append('user/message', {
+      content: [{ type: 'text', text: 'fix the bug' }],
+      source: { kind: 'user' },
+    }, { surfaceOp: 'append' })
+    const assistant = s.append('assistant/message', {
+      turn: 0, step: 0,
+      content: [{ type: 'text', text: 'looking' }],
+    }, { surfaceOp: 'append' })
+    const result = s.append('tool/result', {
+      turn: 0, step: 0, callId: CallId('c1'),
+      content: [{ type: 'text', text: 'exit 0' }],
+      isError: false,
+    }, { surfaceOp: 'append' })
+    const context = s.append('context/message', {
+      content: [{ type: 'text', text: 'file changed' }],
+      source: { kind: 'plugin', plugin: 'fs' },
+    }, { surfaceOp: 'append' })
+    const steering = s.append('steering/message', {
+      turn: 0,
+      content: [{ type: 'text', text: 'stop that' }],
+      source: { kind: 'user' },
+    }, { surfaceOp: 'append' })
+
+    expect(renderTranscript(s.events, [user.seq, assistant.seq, result.seq, context.seq, steering.seq])).toBe([
+      'User: fix the bug',
+      'Assistant: looking',
+      'Tool result (call c1): exit 0',
+      '[Context: file changed]',
+      '[Steering: stop that]',
+    ].join('\n\n'))
+  })
+
+  it('labels an error tool result "Tool error"', () => {
+    const s = session()
+    const result = s.append('tool/result', {
+      turn: 0, step: 0, callId: CallId('c9'),
+      content: [{ type: 'text', text: 'boom' }],
+      isError: true,
+    }, { surfaceOp: 'append' })
+    expect(renderTranscript(s.events, [result.seq])).toBe('Tool error (call c9): boom')
+  })
+
+  it('renders NON-log-order seqs in the order given (surface order after a replace)', () => {
+    const s = session()
+    const first = s.append('user/message', {
+      content: [{ type: 'text', text: 'first' }],
+      source: { kind: 'user' },
+    }, { surfaceOp: 'append' })
+    const second = s.append('user/message', {
+      content: [{ type: 'text', text: 'second' }],
+      source: { kind: 'user' },
+    }, { surfaceOp: 'append' })
+    expect(renderTranscript(s.events, [second.seq, first.seq])).toBe('User: second\n\nUser: first')
+  })
+
+  it('skips events that render to nothing, non-message events, and seqs with no event', () => {
+    const s = session()
+    const empty = s.append('user/message', {
+      content: [{ type: 'text', text: '' }],
+      source: { kind: 'user' },
+    }, { surfaceOp: 'append' })
+    const emptyAssistant = s.append('assistant/message', {
+      turn: 0, step: 0,
+      content: [{ type: 'text', text: '' }],
+    }, { surfaceOp: 'append' })
+    const emptyResult = s.append('tool/result', {
+      turn: 0, step: 0, callId: CallId('c3'),
+      content: [{ type: 'text', text: '' }],
+      isError: false,
+    }, { surfaceOp: 'append' })
+    const emptyContext = s.append('context/message', {
+      content: [{ type: 'text', text: '' }],
+      source: { kind: 'plugin', plugin: 'fs' },
+    }, { surfaceOp: 'append' })
+    const emptySteering = s.append('steering/message', {
+      turn: 0,
+      content: [{ type: 'text', text: '' }],
+      source: { kind: 'user' },
+    }, { surfaceOp: 'append' })
+    // A log-only (non-surface) event type: contributes nothing to a transcript.
+    const lock = s.append('compact/start', { turn: 0 })
+    expect(renderTranscript(s.events, [
+      empty.seq, emptyAssistant.seq, emptyResult.seq, emptyContext.seq, emptySteering.seq, lock.seq, 9999,
+    ])).toBe('')
+  })
+})