agent-continuation.bench.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. /** Baseline budgets for long-history requests, tool continuation, and fork-child discovery. */
  2. import { cp, mkdir, mkdtemp, rm } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  6. import { runBuiltBenchmarkWorker } from '../support/built-worker.ts'
  7. import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts'
  8. import type { ContinuationReport } from './agent-continuation.worker.ts'
  9. import type { CatalogReport } from './child-catalog.worker.ts'
  10. import type { ProfileReport } from './profile-continuation.worker.ts'
  11. import { WORKLOAD } from './workload.ts'
  12. const ATTEMPTS = 5
  13. const WORKER_TIMEOUT_MS = 60_000
  14. /** M4 Pro / Node 24.19 baseline expectations, before shared CI scaling and variance headroom. */
  15. const EXPECTED_MS = { 'profile-continuation': 1_700 } as const
  16. /** Standard two-CPU hosted CI baseline request-history median is 582.304 ms. */
  17. const EXPECTED_BASELINE_REQUEST_CI_MS = 600
  18. const BASELINE_REQUEST_BUDGET_MS = Math.ceil(EXPECTED_BASELINE_REQUEST_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
  19. /** Standard two-CPU hosted CI tool-continuation median is 898.252 ms. */
  20. const EXPECTED_TOOL_CONTINUATION_CI_MS = 900
  21. const TOOL_CONTINUATION_BUDGET_MS = Math.ceil(EXPECTED_TOOL_CONTINUATION_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
  22. /** Standard two-CPU hosted CI catalog median is 858.364 ms; 900 ms is the rounded expectation. */
  23. const EXPECTED_CATALOG_CI_MS = 900
  24. const CATALOG_BUDGET_MS = Math.ceil(EXPECTED_CATALOG_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
  25. /** Two-CPU ubuntu-24.04 / Node 24.20 samples span 182.161–185.042 ms; rounded CI expectation. */
  26. const EXPECTED_REQUEST_HISTORY_CI_MS = 190
  27. const REQUEST_HISTORY_BUDGET_MS = Math.ceil(EXPECTED_REQUEST_HISTORY_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
  28. const EXPECTED_RETAINED_HEAP_MB = 23
  29. const WORKERS = join(import.meta.dirname, '..', '.dsh-build', 'agent-continuation')
  30. type Scenario = 'request-history' | 'catalog' | 'tool-continuation' | keyof typeof EXPECTED_MS
  31. type Report = ContinuationReport | CatalogReport | ProfileReport
  32. function workerName(scenario: Scenario): string {
  33. if (scenario === 'profile-continuation') return 'profile-continuation.worker.js'
  34. return scenario === 'catalog' ? 'child-catalog.worker.js' : 'agent-continuation.worker.js'
  35. }
  36. async function run<Output>(root: string, scenario: Scenario, mode: string): Promise<Output> {
  37. const outcome = await runBuiltBenchmarkWorker<Output>({
  38. worker: join(WORKERS, workerName(scenario)), args: [root, mode],
  39. timeoutMs: WORKER_TIMEOUT_MS, exposeGc: true,
  40. })
  41. if (outcome.timedOut || outcome.signal !== null || outcome.exitCode !== 0 || outcome.report === undefined) {
  42. throw new Error('backend worker failed: ' + JSON.stringify(outcome))
  43. }
  44. return outcome.report
  45. }
  46. function median(values: readonly number[]): number {
  47. return [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)] as number
  48. }
  49. function expectTotalWithinBudget(value: number, budget: number): void {
  50. expect(value).toBeLessThanOrEqual(budget)
  51. }
  52. describe('standard hosted catalog calibration', () => {
  53. it('accepts the recorded two-CPU samples that exceed the historical budget', () => {
  54. const recordedMedian = median([797.373945, 883.157358, 858.363927, 790.568538, 904.5785669999999])
  55. expect(recordedMedian).toBe(858.363927)
  56. expect(() => expectTotalWithinBudget(recordedMedian, 800)).toThrow()
  57. expectTotalWithinBudget(recordedMedian, CATALOG_BUDGET_MS)
  58. expect(CATALOG_BUDGET_MS).toBe(1_125)
  59. })
  60. it('rejects a synthetic material catalog regression', () => {
  61. const regressionMedian = median([1_380, 1_400, 1_420, 1_410, 1_390])
  62. expect(regressionMedian).toBe(1_400)
  63. expect(() => expectTotalWithinBudget(regressionMedian, CATALOG_BUDGET_MS)).toThrow()
  64. })
  65. })
  66. describe('standard hosted tool-continuation calibration', () => {
  67. it('accepts recorded two-CPU samples but rejects a material regression', () => {
  68. const recordedMedian = median([917.006744, 892.091482, 887.838867, 905.6594390000001, 898.2517579999999])
  69. expect(recordedMedian).toBe(898.2517579999999)
  70. expect(() => expectTotalWithinBudget(recordedMedian, 850)).toThrow()
  71. expectTotalWithinBudget(recordedMedian, TOOL_CONTINUATION_BUDGET_MS)
  72. expect(TOOL_CONTINUATION_BUDGET_MS).toBe(1_125)
  73. expect(() => expectTotalWithinBudget(1_400, TOOL_CONTINUATION_BUDGET_MS)).toThrow()
  74. })
  75. })
  76. describe('standard hosted baseline request-history calibration', () => {
  77. it('accepts recorded two-CPU samples but rejects a material regression', () => {
  78. const recordedMedian = median([618.598065, 618.606407, 582.0351149999999, 582.303506, 581.8318300000001])
  79. expect(recordedMedian).toBe(582.303506)
  80. expect(() => expectTotalWithinBudget(recordedMedian, 550)).toThrow()
  81. expectTotalWithinBudget(recordedMedian, BASELINE_REQUEST_BUDGET_MS)
  82. expect(BASELINE_REQUEST_BUDGET_MS).toBe(750)
  83. expect(() => expectTotalWithinBudget(900, BASELINE_REQUEST_BUDGET_MS)).toThrow()
  84. })
  85. })
  86. function assertRequestHistoryBudget(value: number): void {
  87. expect(value).toBeLessThanOrEqual(REQUEST_HISTORY_BUDGET_MS)
  88. }
  89. describe('standard hosted request-history calibration', () => {
  90. it('accepts the recorded two-CPU samples above the historical budget', () => {
  91. const recorded = [183.355397, 184.468253, 185.042397, 182.160790, 182.924728]
  92. const recordedMedian = median(recorded)
  93. expect(recordedMedian).toBe(183.355397)
  94. expect(recordedMedian).toBeGreaterThan(ciTimeBudget(70))
  95. assertRequestHistoryBudget(recordedMedian)
  96. assertRequestHistoryBudget(Math.max(...recorded))
  97. expect(REQUEST_HISTORY_BUDGET_MS).toBe(238)
  98. })
  99. it('rejects a synthetic material request-history regression', () => {
  100. const regressionMedian = median([248, 250, 252, 251, 249])
  101. expect(() => assertRequestHistoryBudget(regressionMedian)).toThrow()
  102. })
  103. it('rejects the recorded original implementation on the M4 reference', () => {
  104. const originalMedian = median([249.050708, 238.275291, 242.172084, 250.093166, 246.130875])
  105. expect(originalMedian).toBe(246.130875)
  106. expect(() => assertRequestHistoryBudget(originalMedian)).toThrow()
  107. })
  108. })
  109. describe('continuing tool-heavy Sessions with large histories', () => {
  110. let scratch: string | undefined
  111. const sources = new Map<Scenario, string>()
  112. beforeAll(async () => {
  113. scratch = await mkdtemp(join(tmpdir(), 'dsh-agent-continuation-bench-'))
  114. for (const scenario of ['request-history', 'catalog'] as const) {
  115. const root = join(scratch, 'source-' + scenario)
  116. await run(root, scenario, 'seed')
  117. sources.set(scenario, root)
  118. }
  119. sources.set('tool-continuation', sources.get('request-history') as string)
  120. })
  121. afterAll(async () => {
  122. if (scratch !== undefined) await rm(scratch, { recursive: true, force: true })
  123. })
  124. for (const scenario of ['request-history', 'tool-continuation', 'catalog', 'profile-continuation'] as const) {
  125. it(scenario, async () => {
  126. const samples: Report[] = []
  127. for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
  128. const root = join(scratch as string, scenario + '-' + String(attempt))
  129. if (scenario === 'profile-continuation') await mkdir(root)
  130. else await cp(sources.get(scenario) as string, root, { recursive: true })
  131. try { samples.push(await run<Report>(root, scenario, scenario)) }
  132. finally { await rm(root, { recursive: true, force: true }) }
  133. }
  134. const totalMs = samples.map(sample => sample.totalMs)
  135. const budgetMs = scenario === 'request-history' ? REQUEST_HISTORY_BUDGET_MS
  136. : scenario === 'catalog' ? CATALOG_BUDGET_MS
  137. : scenario === 'tool-continuation' ? TOOL_CONTINUATION_BUDGET_MS : ciTimeBudget(EXPECTED_MS[scenario])
  138. const retainedHeapBudgetMb = EXPECTED_RETAINED_HEAP_MB * PERFORMANCE_BUDGET_HEADROOM
  139. console.log(JSON.stringify({
  140. benchmark: 'agent-continuation/' + scenario, workload: WORKLOAD,
  141. samples, totalMs: { min: Math.min(...totalMs), median: median(totalMs), max: Math.max(...totalMs) },
  142. budgetMs, ...(scenario === 'tool-continuation' ? { retainedHeapBudgetMb } : {}),
  143. }))
  144. if (scenario === 'request-history') assertRequestHistoryBudget(median(totalMs))
  145. else expectTotalWithinBudget(median(totalMs), budgetMs)
  146. if (scenario === 'tool-continuation') {
  147. expect(median((samples as ContinuationReport[]).map(sample => sample.retainedHeapMb)))
  148. .toBeLessThanOrEqual(retainedHeapBudgetMb)
  149. }
  150. })
  151. }
  152. })