compact-loop-repro.spec.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  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<ContentBlock[]> {
  41. return [{ type: 'text', text: 'CHECKPOINT SUMMARY' }]
  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. })
  94. return { ctx, compact }
  95. }
  96. function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
  97. return new Promise((resolve) => {
  98. const dispose = ctx.on('agent/status', (subject, status) => {
  99. if (subject === agent && status === 'idle') {
  100. dispose()
  101. resolve()
  102. }
  103. })
  104. })
  105. }
  106. describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => {
  107. it('the head checkpoint the loop lands is a balanced cut on both sides', async () => {
  108. const { ctx } = await harness(8)
  109. try {
  110. const agent = ctx.agentLoop.create(AgentId('repro'), { model: 'mock' })
  111. agent.send([{ type: 'text', text: 'do a long multi-step task' }])
  112. await waitForIdle(ctx, agent)
  113. const events = [...agent.session.events]
  114. // A compaction ran: at least one checkpoint landed on the surface.
  115. const checkpoints = events.filter(
  116. (e): e is SurfaceEvent =>
  117. e.type === 'user/message'
  118. && typeof (e as SurfaceEvent).surfaceOp === 'object',
  119. )
  120. expect(checkpoints.length).toBeGreaterThan(0)
  121. // The loop fired compaction mid-flight, so each landed checkpoint sits at a
  122. // high log seq beside the step it landed in, even though its SURFACE
  123. // position is the head of the range it shadowed. A checkpoint carries no
  124. // tool-call/result pair (only summarized prose), so every checkpoint still
  125. // on the surface must be a balanced cut on BOTH sides — the cut before it
  126. // (region START) and the cut after it (region END). The abandoned
  127. // log-position scan reported the END as mis-aligned because the forward log
  128. // scan reached the neighbouring step's assistant/message.
  129. const nodes = agent.session.surface.nodes
  130. for (const cp of checkpoints) {
  131. const node = nodes.find(n => n.seq === cp.seq)
  132. if (!node) continue // shadowed by a later checkpoint — no longer an edge.
  133. expect(isToolPairingBalanced(nodes, events, node.seq),
  134. `checkpoint seq ${node.seq} must be a balanced region START`).toBe(true)
  135. expect(isToolPairingBalanced(nodes, events, node.next),
  136. `checkpoint seq ${node.seq} must be a balanced region END`).toBe(true)
  137. }
  138. } finally {
  139. await ctx.fiber.dispose()
  140. }
  141. })
  142. })