compact-loop-repro.spec.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import LlmService from '@deepseek-ai/dsh-llm'
  4. import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
  5. import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
  6. import SessionStore from '@deepseek-ai/dsh-session'
  7. import { isToolPairingBalanced } from '@deepseek-ai/dsh-session'
  8. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  9. import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
  10. import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
  11. import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
  12. import * as Invariants from '@deepseek-ai/dsh-invariants'
  13. import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
  14. import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
  15. /**
  16. * CBR-001 regression: a compaction checkpoint that the REAL loop lands is a
  17. * free surface boundary (it carries no tool-call/result pair), so it must be a
  18. * valid region edge on BOTH sides. A surface-anchored balance check sees that;
  19. * the abandoned log-position scan did not.
  20. *
  21. * The loop fires the compaction seam mid-flight, so the landed checkpoint
  22. * `user/message{replace}` sits at a HIGH log seq positioned beside the current
  23. * step even though its SURFACE position is the head. A log-position forward scan
  24. * from the checkpoint reaches the step's own later `assistant/message` and
  25. * wrongly reports the checkpoint as mid-step — refusing it as a region end. A
  26. * SECOND compaction that re-summarizes just that head checkpoint (region end ==
  27. * checkpoint) therefore throws and is swallowed, so the surface never
  28. * re-consolidates.
  29. *
  30. * This drives a real auto-compaction through the agent-loop and asserts the
  31. * landed checkpoint balances on both sides AND that re-compacting it (end ==
  32. * checkpoint) succeeds. RED on the log-position predicates; GREEN once alignment
  33. * is decided from surface tool-pairing balance.
  34. */
  35. const TOKENS_PER_BLOCK = 10
  36. class ReproCompactService extends BasicCompactService {
  37. override estimateContentTokens(blocks: readonly ContentBlock[]): number {
  38. return blocks.length * TOKENS_PER_BLOCK
  39. }
  40. override async summarize(): Promise<{ summary: ContentBlock[]; model: string }> {
  41. return { summary: [{ type: 'text', text: 'CHECKPOINT SUMMARY' }], model: 'stub' }
  42. }
  43. }
  44. /** Each call emits one tool-call until exhausted, then a final text answer. */
  45. class StepwiseToolAdapter extends LlmAdapter {
  46. calls = 0
  47. constructor(private toolSteps: number) {
  48. super()
  49. }
  50. async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
  51. const n = this.calls
  52. this.calls += 1
  53. if (n < this.toolSteps) {
  54. const id = CallId(`c${n}`)
  55. const args = `{"i":${n}}`
  56. yield { type: 'block-start', index: 0, blockType: 'text' }
  57. yield { type: 'block-end', index: 0, block: { type: 'text', text: `step ${n}` } }
  58. yield { type: 'block-start', index: 1, blockType: 'tool-call' }
  59. yield { type: 'block-end', index: 1, block: { type: 'tool-call', id, name: 'work', arguments: args } }
  60. yield { type: 'finish', reason: { kind: 'tool-calls' } }
  61. return
  62. }
  63. yield { type: 'block-start', index: 0, blockType: 'text' }
  64. yield { type: 'block-end', index: 0, block: { type: 'text', text: 'all done' } }
  65. yield { type: 'finish', reason: { kind: 'stop' } }
  66. }
  67. }
  68. async function harness(toolSteps: number): Promise<{ ctx: Context; compact: ReproCompactService }> {
  69. const ctx = new Context()
  70. await ctx.plugin(LlmService)
  71. await ctx.plugin(SessionStore)
  72. await ctx.plugin(Invariants)
  73. await ctx.plugin(SystemPrompt)
  74. await ctx.plugin(ToolRegistry)
  75. await ctx.plugin(AgentRegistry)
  76. await ctx.plugin(AgentLoop, { agents: [] })
  77. ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps))
  78. ctx.tools.register(defineTool({
  79. name: 'work',
  80. description: 'does work',
  81. parameters: { i: { type: 'number' } },
  82. async execute() {
  83. return [{ type: 'text', text: 'work result' }]
  84. },
  85. }))
  86. // Tiny window so a couple of tool steps cross the threshold and compaction
  87. // fires within the runaway turn.
  88. const compact = new ReproCompactService(ctx, {
  89. auto: true,
  90. contextWindow: 64,
  91. thresholdRatio: 0.5,
  92. retainTokens: 20,
  93. summarizationModel: '',
  94. maxTokens: 8192,
  95. compactionRetries: 1,
  96. })
  97. return { ctx, compact }
  98. }
  99. function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
  100. return new Promise((resolve) => {
  101. const dispose = ctx.on('agent/status', (subject, status) => {
  102. if (subject === agent && status === 'idle') {
  103. dispose()
  104. resolve()
  105. }
  106. })
  107. })
  108. }
  109. describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => {
  110. it('the head checkpoint the loop lands is a balanced cut on both sides', async () => {
  111. const { ctx } = await harness(8)
  112. try {
  113. const agent = ctx.agentLoop.create(AgentId('repro'), { model: 'mock' })
  114. agent.send([{ type: 'text', text: 'do a long multi-step task' }])
  115. await waitForIdle(ctx, agent)
  116. const events = [...agent.session.events]
  117. // A compaction ran: at least one checkpoint landed on the surface.
  118. const checkpoints = events.filter(
  119. (e): e is SurfaceEvent =>
  120. e.type === 'user/message'
  121. && typeof (e as SurfaceEvent).surfaceOp === 'object',
  122. )
  123. expect(checkpoints.length).toBeGreaterThan(0)
  124. // The loop fired compaction mid-flight, so each landed checkpoint sits at a
  125. // high log seq beside the step it landed in, even though its SURFACE
  126. // position is the head of the range it shadowed. A checkpoint carries no
  127. // tool-call/result pair (only summarized prose), so every checkpoint still
  128. // on the surface must be a balanced cut on BOTH sides — the cut before it
  129. // (region START) and the cut after it (region END). The abandoned
  130. // log-position scan reported the END as mis-aligned because the forward log
  131. // scan reached the neighbouring step's assistant/message.
  132. const nodes = agent.session.surface.nodes
  133. for (const cp of checkpoints) {
  134. const node = nodes.find(n => n.seq === cp.seq)
  135. if (!node) continue // shadowed by a later checkpoint — no longer an edge.
  136. expect(isToolPairingBalanced(nodes, events, node.seq),
  137. `checkpoint seq ${node.seq} must be a balanced region START`).toBe(true)
  138. expect(isToolPairingBalanced(nodes, events, node.next),
  139. `checkpoint seq ${node.seq} must be a balanced region END`).toBe(true)
  140. }
  141. } finally {
  142. await ctx.fiber.dispose()
  143. }
  144. })
  145. })