benchmark.py 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243
  1. import asyncio
  2. import json
  3. import time
  4. import unicodedata
  5. from collections.abc import AsyncIterator, Callable, Mapping
  6. from enum import StrEnum
  7. from types import MappingProxyType
  8. from typing import Annotated, Any, Literal, TypeAlias
  9. from urllib.parse import urlsplit
  10. from uuid import uuid4
  11. import httpx
  12. from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
  13. from agent_lab.application.contracts import (
  14. AgentParams,
  15. DebugRunRequest,
  16. EventAgentParams,
  17. )
  18. from agent_lab.application.events import (
  19. InMemoryCalendarScheduleAdapter,
  20. InMemoryDeviceVolumeAdapter,
  21. )
  22. from agent_lab.application.runtime import DebugRuntime
  23. from agent_lab.application.queues import RuntimeQueues
  24. from agent_lab.application.tools import build_default_tool_registry
  25. from agent_lab.domain.events import ToolCallEvent
  26. from agent_lab.domain.messages import ChatMessage, StreamItem, TokenUsage
  27. from agent_lab.infrastructure.chat_client import OpenAICompatibleChatClient
  28. from agent_lab.infrastructure.sqlite_store import SQLiteSessionStore
  29. class BenchmarkMode(StrEnum):
  30. DUAL_AGENT = "dual_agent"
  31. CHAT_AGENT_TOOLS = "chat_agent_tools"
  32. class BenchmarkCaseId(StrEnum):
  33. ORDINARY_CHAT = "ordinary_chat"
  34. DEVICE_VOLUME_SILENT = "device_volume_silent"
  35. CALENDAR_SCHEDULE_TEMPLATE = "calendar_schedule_template"
  36. WEB_SEARCH_TWO_ANSWERS = "web_search_two_answers"
  37. SESSION_TERMINATE = "session_terminate"
  38. PARALLEL_VOLUME_SCHEDULE = "parallel_volume_schedule"
  39. JsonScalar: TypeAlias = str | int | float | bool | None
  40. BENCHMARK_MODES: tuple[BenchmarkMode, ...] = tuple(BenchmarkMode)
  41. BENCHMARK_CASE_IDS: tuple[BenchmarkCaseId, ...] = tuple(BenchmarkCaseId)
  42. BENCHMARK_SYSTEM_PROMPT = "Exercise the requested business scenario."
  43. BENCHMARK_MAX_TOKENS = 128
  44. BENCHMARK_MAX_EVENT_LOOPS = 1
  45. BENCHMARK_MAX_PARALLEL_EVENTS = 2
  46. BENCHMARK_BATCH_TIMEOUT_SECONDS = 1.0
  47. BENCHMARK_PARALLEL_BARRIER_TIMEOUT_SECONDS = 0.05
  48. class _StrictBenchmarkModel(BaseModel):
  49. model_config = ConfigDict(
  50. extra="forbid",
  51. strict=True,
  52. hide_input_in_errors=True,
  53. )
  54. class _FrozenStrictBenchmarkModel(BaseModel):
  55. model_config = ConfigDict(
  56. extra="forbid",
  57. strict=True,
  58. hide_input_in_errors=True,
  59. frozen=True,
  60. )
  61. def _validate_base_url(value: str) -> str:
  62. if not value or value != value.strip() or any(char.isspace() for char in value):
  63. raise ValueError("base_url must be a non-empty HTTP(S) URL")
  64. if any(unicodedata.category(char).startswith("C") for char in value):
  65. raise ValueError("base_url must not contain Unicode category C characters")
  66. try:
  67. parsed = urlsplit(value)
  68. hostname = parsed.hostname
  69. except ValueError as exc:
  70. raise ValueError("base_url must be a valid HTTP(S) URL") from exc
  71. if parsed.scheme not in {"http", "https"} or not parsed.netloc or not hostname:
  72. raise ValueError("base_url must use http or https and include a host")
  73. if parsed.username is not None or parsed.password is not None or "@" in parsed.netloc:
  74. raise ValueError("base_url must not contain userinfo")
  75. if "?" in value:
  76. raise ValueError("base_url must not contain a query")
  77. if "#" in value:
  78. raise ValueError("base_url must not contain a fragment")
  79. try:
  80. transport_url = httpx.URL(value)
  81. _ = (transport_url.host, transport_url.port, parsed.port)
  82. except (httpx.InvalidURL, ValueError) as exc:
  83. raise ValueError("base_url must be a valid HTTP(S) URL") from exc
  84. return value
  85. def _validate_model(value: str) -> str:
  86. stripped = value.strip()
  87. if not stripped:
  88. raise ValueError("model must not be empty")
  89. return stripped
  90. class BenchmarkTarget(_StrictBenchmarkModel):
  91. base_url: str
  92. model: str
  93. _base_url_validator = field_validator("base_url")(_validate_base_url)
  94. _model_validator = field_validator("model")(_validate_model)
  95. class BenchmarkConfig(BenchmarkTarget):
  96. schema_version: Literal[1]
  97. runs_per_case: int = Field(default=1, ge=1)
  98. modes: list[Annotated[BenchmarkMode, Field(strict=False)]] = Field(
  99. default_factory=lambda: list(BENCHMARK_MODES),
  100. min_length=1,
  101. )
  102. cases: list[Annotated[BenchmarkCaseId, Field(strict=False)]] = Field(
  103. default_factory=lambda: list(BENCHMARK_CASE_IDS),
  104. min_length=1,
  105. )
  106. @field_validator("schema_version", mode="before")
  107. @classmethod
  108. def require_integer_schema_version(cls, value: object) -> object:
  109. if type(value) is not int or value != 1:
  110. raise ValueError("schema_version must be the integer 1")
  111. return value
  112. @field_validator("modes", "cases")
  113. @classmethod
  114. def reject_duplicates(
  115. cls,
  116. values: list[BenchmarkMode | BenchmarkCaseId],
  117. ) -> list[BenchmarkMode | BenchmarkCaseId]:
  118. if len(values) != len(set(values)):
  119. raise ValueError("values must not contain duplicates")
  120. return values
  121. class BenchmarkModelCallTiming(_StrictBenchmarkModel):
  122. call_index: int = Field(ge=1)
  123. call_kind: Literal["chat_completion", "argument_fallback"]
  124. first_item_kind: str | None
  125. provider_ttft_ms: int | None = Field(ge=0)
  126. visible_ttft_ms: int | None = Field(ge=0)
  127. elapsed_ms: int | None = Field(ge=0)
  128. usage: TokenUsage | None
  129. class BenchmarkRunResult(_StrictBenchmarkModel):
  130. case_id: BenchmarkCaseId
  131. mode: BenchmarkMode
  132. iteration: int = Field(ge=1)
  133. status: Literal["passed", "failed"]
  134. initial_provider_ttft_ms: int | None = Field(ge=0)
  135. visible_ttft_ms: int | None = Field(ge=0)
  136. turn_wall_time_ms: int | None = Field(ge=0)
  137. prompt_tokens: int | None = Field(ge=0)
  138. completion_tokens: int | None = Field(ge=0)
  139. total_tokens: int | None = Field(ge=0)
  140. cached_tokens: int | None = Field(ge=0)
  141. model_call_count: int | None = Field(ge=0)
  142. fallback_count: int | None = Field(ge=0)
  143. tool_count: int | None = Field(ge=0)
  144. event_names: list[str]
  145. batch_event_names: list[str]
  146. tool_event_names: list[str]
  147. event_sources: list[str]
  148. tool_statuses: list[str]
  149. tool_latencies_ms: list[int | None]
  150. semantic_failures: list[str]
  151. error: str | None
  152. model_calls: list[BenchmarkModelCallTiming]
  153. parallel_events_overlapped: bool | None = None
  154. class BenchmarkParallelEventProbe:
  155. _EXPECTED_EVENTS = frozenset(
  156. {"device.volume.adjust", "calendar.schedule.create"}
  157. )
  158. def __init__(
  159. self,
  160. *,
  161. barrier_timeout_seconds: float = BENCHMARK_PARALLEL_BARRIER_TIMEOUT_SECONDS,
  162. ) -> None:
  163. self.barrier_timeout_seconds = barrier_timeout_seconds
  164. self._volume = InMemoryDeviceVolumeAdapter()
  165. self._calendar = InMemoryCalendarScheduleAdapter()
  166. self._active: set[str] = set()
  167. self._release = asyncio.Event()
  168. self._lock = asyncio.Lock()
  169. self.overlapped = False
  170. async def adjust(
  171. self,
  172. event_id: str,
  173. *,
  174. mode: str,
  175. value: int | None = None,
  176. delta: int | None = None,
  177. ) -> dict[str, Any]:
  178. await self._await_overlap("device.volume.adjust")
  179. return self._volume.adjust(
  180. event_id,
  181. mode=mode,
  182. value=value,
  183. delta=delta,
  184. )
  185. async def create(
  186. self,
  187. event_id: str,
  188. *,
  189. title: str,
  190. start_at: str,
  191. timezone: str,
  192. recurrence: str | None = None,
  193. reminder_minutes: int | None = None,
  194. ) -> dict[str, Any]:
  195. await self._await_overlap("calendar.schedule.create")
  196. return self._calendar.create(
  197. event_id,
  198. title=title,
  199. start_at=start_at,
  200. timezone=timezone,
  201. recurrence=recurrence,
  202. reminder_minutes=reminder_minutes,
  203. )
  204. async def _await_overlap(self, event_name: str) -> None:
  205. async with self._lock:
  206. self._active.add(event_name)
  207. if self._EXPECTED_EVENTS.issubset(self._active):
  208. self.overlapped = True
  209. self._release.set()
  210. try:
  211. await asyncio.wait_for(
  212. self._release.wait(),
  213. timeout=self.barrier_timeout_seconds,
  214. )
  215. except TimeoutError:
  216. pass
  217. finally:
  218. async with self._lock:
  219. self._active.discard(event_name)
  220. class TimingChatClient:
  221. def __init__(
  222. self,
  223. inner: object,
  224. *,
  225. clock: Callable[[], float] = time.perf_counter,
  226. ) -> None:
  227. self.inner = inner
  228. self.clock = clock
  229. self.timings: list[BenchmarkModelCallTiming] = []
  230. self._next_call_index = 0
  231. async def stream_chat(
  232. self,
  233. messages: list[ChatMessage],
  234. tools: list[dict[str, Any]],
  235. params: AgentParams,
  236. tool_choice: dict[str, Any] | None = None,
  237. ) -> AsyncIterator[StreamItem]:
  238. self._next_call_index += 1
  239. call_index = self._next_call_index
  240. started_at = self.clock()
  241. first_item_at: float | None = None
  242. first_item_kind: str | None = None
  243. first_message_delta_at: float | None = None
  244. usage: TokenUsage | None = None
  245. provider_elapsed_seconds = 0.0
  246. iterator: AsyncIterator[StreamItem] | None = None
  247. provider_exception_active = False
  248. try:
  249. stream_chat = getattr(self.inner, "stream_chat")
  250. iterator = stream_chat(
  251. messages=messages,
  252. tools=tools,
  253. params=params,
  254. tool_choice=tool_choice,
  255. ).__aiter__()
  256. while True:
  257. wait_started_at = self.clock()
  258. try:
  259. item = await iterator.__anext__()
  260. except StopAsyncIteration:
  261. provider_elapsed_seconds += max(
  262. 0.0,
  263. self.clock() - wait_started_at,
  264. )
  265. break
  266. except BaseException:
  267. provider_exception_active = True
  268. provider_elapsed_seconds += max(
  269. 0.0,
  270. self.clock() - wait_started_at,
  271. )
  272. raise
  273. provider_elapsed_seconds += max(
  274. 0.0,
  275. self.clock() - wait_started_at,
  276. )
  277. item_at = self.clock()
  278. if first_item_at is None:
  279. first_item_at = item_at
  280. first_item_kind = item.kind
  281. if (
  282. item.kind == "message_delta"
  283. and (item.content or "").strip()
  284. and first_message_delta_at is None
  285. ):
  286. first_message_delta_at = item_at
  287. if item.kind == "usage" and item.usage is not None:
  288. usage = item.usage
  289. yield item
  290. finally:
  291. try:
  292. close = getattr(iterator, "aclose", None)
  293. if close is not None:
  294. try:
  295. await close()
  296. except BaseException:
  297. if not provider_exception_active:
  298. raise
  299. finally:
  300. timing = BenchmarkModelCallTiming(
  301. call_index=call_index,
  302. call_kind=(
  303. "argument_fallback"
  304. if tool_choice is not None
  305. else "chat_completion"
  306. ),
  307. first_item_kind=first_item_kind,
  308. provider_ttft_ms=self._elapsed_ms(started_at, first_item_at),
  309. visible_ttft_ms=self._elapsed_ms(
  310. started_at,
  311. first_message_delta_at,
  312. ),
  313. elapsed_ms=max(0, round(provider_elapsed_seconds * 1000)),
  314. usage=usage,
  315. )
  316. self.timings.append(timing)
  317. self.timings.sort(key=lambda item: item.call_index)
  318. async def aclose(self) -> None:
  319. close = getattr(self.inner, "aclose", None)
  320. if close is not None:
  321. await close()
  322. @staticmethod
  323. def _elapsed_ms(started_at: float, ended_at: float | None) -> int | None:
  324. if ended_at is None:
  325. return None
  326. return max(0, round((ended_at - started_at) * 1000))
  327. class BenchmarkExpectation(_FrozenStrictBenchmarkModel):
  328. visible_messages: tuple[str, ...]
  329. event_names: tuple[str, ...]
  330. answer_count: int = Field(ge=1)
  331. expected_model_call_count: int = Field(ge=1)
  332. expected_fallback_count: int = Field(ge=0)
  333. terminal: bool
  334. first_reply_before_tool: bool
  335. @model_validator(mode="after")
  336. def match_answer_count(self) -> "BenchmarkExpectation":
  337. if self.answer_count != len(self.visible_messages):
  338. raise ValueError("answer_count must match visible_messages")
  339. return self
  340. class BenchmarkMockEvent(_FrozenStrictBenchmarkModel):
  341. id: str
  342. name: str
  343. arguments: tuple[tuple[str, JsonScalar], ...]
  344. @model_validator(mode="after")
  345. def validate_event(self) -> "BenchmarkMockEvent":
  346. if not self.id.strip():
  347. raise ValueError("mock event id must not be empty")
  348. if not self.name.strip():
  349. raise ValueError("mock event name must not be empty")
  350. keys = tuple(key for key, _ in self.arguments)
  351. if any(not key.strip() for key in keys):
  352. raise ValueError("mock event argument names must not be empty")
  353. if len(keys) != len(set(keys)):
  354. raise ValueError("mock event arguments must not contain duplicate names")
  355. return self
  356. class BenchmarkCase(_FrozenStrictBenchmarkModel):
  357. case_id: BenchmarkCaseId
  358. user_message: str
  359. enabled_tools: tuple[str, ...]
  360. expectation: BenchmarkExpectation
  361. mock_visible_messages: tuple[str, ...]
  362. mock_events: tuple[BenchmarkMockEvent, ...]
  363. @model_validator(mode="after")
  364. def validate_case_semantics(self) -> "BenchmarkCase":
  365. if not self.user_message.strip():
  366. raise ValueError("user_message must not be empty")
  367. if len(self.enabled_tools) != len(set(self.enabled_tools)):
  368. raise ValueError("enabled_tools must not contain duplicates")
  369. mock_event_names = tuple(event.name for event in self.mock_events)
  370. if mock_event_names != self.expectation.event_names:
  371. raise ValueError("mock event order must match expected event order")
  372. if self.expectation.first_reply_before_tool:
  373. if not self.mock_events or not self.mock_visible_messages:
  374. raise ValueError(
  375. "first_reply_before_tool requires mock text and at least one event"
  376. )
  377. return self
  378. def _mock_event(
  379. event_id: str,
  380. name: str,
  381. arguments: tuple[tuple[str, JsonScalar], ...] = (),
  382. ) -> BenchmarkMockEvent:
  383. return BenchmarkMockEvent(id=event_id, name=name, arguments=arguments)
  384. _SCHEDULE_ARGUMENTS: tuple[tuple[str, JsonScalar], ...] = (
  385. ("title", "Design review"),
  386. ("start_at", "2026-07-14T09:30:00+08:00"),
  387. ("timezone", "Asia/Shanghai"),
  388. )
  389. _SCHEDULE_CONFIRMATION = (
  390. "Scheduled Design review for 2026-07-14T09:30:00+08:00 "
  391. "(Asia/Shanghai)."
  392. )
  393. BENCHMARK_CASE_CATALOG: Mapping[BenchmarkCaseId, BenchmarkCase] = MappingProxyType(
  394. {
  395. BenchmarkCaseId.ORDINARY_CHAT: BenchmarkCase(
  396. case_id=BenchmarkCaseId.ORDINARY_CHAT,
  397. user_message="Hello.",
  398. enabled_tools=(
  399. "session.terminate",
  400. "device.volume.adjust",
  401. "calendar.schedule.create",
  402. "knowledge.web.search",
  403. ),
  404. expectation=BenchmarkExpectation(
  405. visible_messages=("Ordinary answer.",),
  406. event_names=(),
  407. answer_count=1,
  408. expected_model_call_count=1,
  409. expected_fallback_count=0,
  410. terminal=False,
  411. first_reply_before_tool=False,
  412. ),
  413. mock_visible_messages=("Ordinary answer.",),
  414. mock_events=(),
  415. ),
  416. BenchmarkCaseId.DEVICE_VOLUME_SILENT: BenchmarkCase(
  417. case_id=BenchmarkCaseId.DEVICE_VOLUME_SILENT,
  418. user_message="set volume to 30",
  419. enabled_tools=("device.volume.adjust",),
  420. expectation=BenchmarkExpectation(
  421. visible_messages=("I will set the volume to 30.",),
  422. event_names=("device.volume.adjust",),
  423. answer_count=1,
  424. expected_model_call_count=1,
  425. expected_fallback_count=0,
  426. terminal=False,
  427. first_reply_before_tool=True,
  428. ),
  429. mock_visible_messages=("I will set the volume to 30.",),
  430. mock_events=(
  431. _mock_event(
  432. "volume-1",
  433. "device.volume.adjust",
  434. (("mode", "absolute"), ("value", 30)),
  435. ),
  436. ),
  437. ),
  438. BenchmarkCaseId.CALENDAR_SCHEDULE_TEMPLATE: BenchmarkCase(
  439. case_id=BenchmarkCaseId.CALENDAR_SCHEDULE_TEMPLATE,
  440. user_message=(
  441. 'schedule "Design review" at 2026-07-14T09:30:00+08:00 '
  442. "timezone Asia/Shanghai"
  443. ),
  444. enabled_tools=("calendar.schedule.create",),
  445. expectation=BenchmarkExpectation(
  446. visible_messages=("I will schedule that.", _SCHEDULE_CONFIRMATION),
  447. event_names=("calendar.schedule.create",),
  448. answer_count=2,
  449. expected_model_call_count=1,
  450. expected_fallback_count=0,
  451. terminal=False,
  452. first_reply_before_tool=True,
  453. ),
  454. mock_visible_messages=("I will schedule that.",),
  455. mock_events=(
  456. _mock_event(
  457. "schedule-1",
  458. "calendar.schedule.create",
  459. _SCHEDULE_ARGUMENTS,
  460. ),
  461. ),
  462. ),
  463. BenchmarkCaseId.WEB_SEARCH_TWO_ANSWERS: BenchmarkCase(
  464. case_id=BenchmarkCaseId.WEB_SEARCH_TWO_ANSWERS,
  465. user_message="event batch safety",
  466. enabled_tools=("knowledge.web.search",),
  467. expectation=BenchmarkExpectation(
  468. visible_messages=("I will check.", "Grounded search update."),
  469. event_names=("knowledge.web.search",),
  470. answer_count=2,
  471. expected_model_call_count=2,
  472. expected_fallback_count=0,
  473. terminal=False,
  474. first_reply_before_tool=True,
  475. ),
  476. mock_visible_messages=("I will check.", "Grounded search update."),
  477. mock_events=(
  478. _mock_event(
  479. "search-1",
  480. "knowledge.web.search",
  481. (("query", "event batch safety"),),
  482. ),
  483. ),
  484. ),
  485. BenchmarkCaseId.SESSION_TERMINATE: BenchmarkCase(
  486. case_id=BenchmarkCaseId.SESSION_TERMINATE,
  487. user_message="end this session",
  488. enabled_tools=("session.terminate",),
  489. expectation=BenchmarkExpectation(
  490. visible_messages=("Goodbye.",),
  491. event_names=("session.terminate",),
  492. answer_count=1,
  493. expected_model_call_count=1,
  494. expected_fallback_count=0,
  495. terminal=True,
  496. first_reply_before_tool=True,
  497. ),
  498. mock_visible_messages=("Goodbye.",),
  499. mock_events=(_mock_event("terminate-1", "session.terminate"),),
  500. ),
  501. BenchmarkCaseId.PARALLEL_VOLUME_SCHEDULE: BenchmarkCase(
  502. case_id=BenchmarkCaseId.PARALLEL_VOLUME_SCHEDULE,
  503. user_message=(
  504. 'set volume to 30 and schedule "Design review" at '
  505. "2026-07-14T09:30:00+08:00 timezone Asia/Shanghai"
  506. ),
  507. enabled_tools=(
  508. "device.volume.adjust",
  509. "calendar.schedule.create",
  510. ),
  511. expectation=BenchmarkExpectation(
  512. visible_messages=(
  513. "I will set the volume to 30 and schedule that.",
  514. _SCHEDULE_CONFIRMATION,
  515. ),
  516. event_names=(
  517. "device.volume.adjust",
  518. "calendar.schedule.create",
  519. ),
  520. answer_count=2,
  521. expected_model_call_count=1,
  522. expected_fallback_count=0,
  523. terminal=False,
  524. first_reply_before_tool=True,
  525. ),
  526. mock_visible_messages=(
  527. "I will set the volume to 30 and schedule that.",
  528. ),
  529. mock_events=(
  530. _mock_event(
  531. "parallel-volume",
  532. "device.volume.adjust",
  533. (("mode", "absolute"), ("value", 30)),
  534. ),
  535. _mock_event(
  536. "parallel-schedule",
  537. "calendar.schedule.create",
  538. _SCHEDULE_ARGUMENTS,
  539. ),
  540. ),
  541. ),
  542. }
  543. )
  544. def _normalize_benchmark_mode(mode: BenchmarkMode | str) -> BenchmarkMode:
  545. try:
  546. return BenchmarkMode(mode)
  547. except ValueError as exc:
  548. raise ValueError(f"unsupported benchmark mode: {mode}") from exc
  549. def build_benchmark_request(
  550. case: BenchmarkCase,
  551. mode: BenchmarkMode | str,
  552. model: str,
  553. ) -> DebugRunRequest:
  554. normalized_mode = _normalize_benchmark_mode(mode)
  555. validated_model = _validate_model(model)
  556. return DebugRunRequest(
  557. user_message=case.user_message,
  558. system_prompts=[BENCHMARK_SYSTEM_PROMPT],
  559. pre_messages=[],
  560. chat_agent=AgentParams(
  561. model=validated_model,
  562. temperature=0.0,
  563. max_tokens=BENCHMARK_MAX_TOKENS,
  564. ),
  565. event_agent=EventAgentParams(
  566. model=validated_model,
  567. temperature=0.0,
  568. max_tokens=BENCHMARK_MAX_TOKENS,
  569. enabled_tools=list(case.enabled_tools),
  570. max_event_loops=BENCHMARK_MAX_EVENT_LOOPS,
  571. max_parallel_events=BENCHMARK_MAX_PARALLEL_EVENTS,
  572. batch_timeout_seconds=BENCHMARK_BATCH_TIMEOUT_SECONDS,
  573. ),
  574. tool_invocation_mode=normalized_mode.value,
  575. )
  576. def build_mock_rounds(
  577. case: BenchmarkCase,
  578. mode: BenchmarkMode | str,
  579. ) -> list[list[StreamItem]]:
  580. normalized_mode = _normalize_benchmark_mode(mode)
  581. first_round: list[StreamItem] = []
  582. if case.mock_visible_messages:
  583. first_round.append(StreamItem.message_delta(case.mock_visible_messages[0]))
  584. for mock_event in case.mock_events:
  585. arguments = dict(mock_event.arguments)
  586. event = ToolCallEvent(
  587. id=mock_event.id,
  588. name=mock_event.name,
  589. arguments=arguments,
  590. raw_arguments=json.dumps(
  591. arguments,
  592. sort_keys=True,
  593. separators=(",", ":"),
  594. ),
  595. )
  596. event_item = (
  597. StreamItem.text_event(event)
  598. if normalized_mode is BenchmarkMode.DUAL_AGENT
  599. else StreamItem.provider_tool_call(event)
  600. )
  601. first_round.append(event_item)
  602. rounds = [first_round]
  603. rounds.extend(
  604. [StreamItem.message_delta(message)]
  605. for message in case.mock_visible_messages[1:]
  606. )
  607. return rounds
  608. class MockBenchmarkChatClient:
  609. def __init__(
  610. self,
  611. case: BenchmarkCase,
  612. mode: BenchmarkMode | str,
  613. ) -> None:
  614. self.case = case
  615. self.mode = _normalize_benchmark_mode(mode)
  616. self.rounds = build_mock_rounds(case, self.mode)
  617. self.calls = 0
  618. async def stream_chat(
  619. self,
  620. messages: list[ChatMessage],
  621. tools: list[dict[str, Any]],
  622. params: AgentParams,
  623. tool_choice: dict[str, Any] | None = None,
  624. ) -> AsyncIterator[StreamItem]:
  625. del messages, tools, params, tool_choice
  626. call_index = self.calls
  627. if call_index >= len(self.rounds):
  628. raise RuntimeError(
  629. f"mock benchmark stream exhausted: {self.case.case_id}"
  630. )
  631. self.calls += 1
  632. yield StreamItem.raw_response_chunk(
  633. {
  634. "mock": True,
  635. "case_id": self.case.case_id.value,
  636. "mode": self.mode.value,
  637. "call_index": self.calls,
  638. }
  639. )
  640. for item in self.rounds[call_index]:
  641. yield item
  642. yield StreamItem.usage_item(
  643. TokenUsage(
  644. prompt_tokens=10,
  645. completion_tokens=5,
  646. total_tokens=15,
  647. cached_tokens=2,
  648. )
  649. )
  650. async def aclose(self) -> None:
  651. return None
  652. BenchmarkClientFactory: TypeAlias = Callable[[BenchmarkConfig, str], object]
  653. BenchmarkParallelProbeFactory: TypeAlias = Callable[[], BenchmarkParallelEventProbe]
  654. class _BenchmarkOutputQueue(asyncio.Queue[dict[str, Any]]):
  655. def __init__(self, clock: Callable[[], float]) -> None:
  656. super().__init__()
  657. self.clock = clock
  658. self.first_message_delta_at: float | None = None
  659. async def put(self, item: dict[str, Any]) -> None:
  660. content = item.get("content")
  661. if (
  662. item.get("type") == "message_delta"
  663. and isinstance(content, str)
  664. and content.strip()
  665. and self.first_message_delta_at is None
  666. ):
  667. self.first_message_delta_at = self.clock()
  668. await super().put(item)
  669. class BenchmarkRunner:
  670. def __init__(
  671. self,
  672. config: BenchmarkConfig,
  673. api_key: str | None,
  674. mock: bool = False,
  675. client_factory: BenchmarkClientFactory | None = None,
  676. parallel_probe_factory: BenchmarkParallelProbeFactory | None = None,
  677. clock: Callable[[], float] = time.perf_counter,
  678. ) -> None:
  679. if not mock and not (api_key or "").strip():
  680. raise ValueError("api_key is required for live benchmark runs")
  681. self.config = config
  682. self.api_key = (api_key or "").strip()
  683. self.mock = mock
  684. self.client_factory = client_factory or self._default_client_factory
  685. self.parallel_probe_factory = (
  686. parallel_probe_factory or BenchmarkParallelEventProbe
  687. )
  688. self.clock = clock
  689. async def run(self) -> list[BenchmarkRunResult]:
  690. results: list[BenchmarkRunResult] = []
  691. for case_id in self.config.cases:
  692. case = BENCHMARK_CASE_CATALOG[case_id]
  693. for mode in self.config.modes:
  694. for iteration in range(1, self.config.runs_per_case + 1):
  695. results.append(await self._run_one(case, mode, iteration))
  696. return results
  697. async def _run_one(
  698. self,
  699. case: BenchmarkCase,
  700. mode: BenchmarkMode,
  701. iteration: int,
  702. ) -> BenchmarkRunResult:
  703. store = SQLiteSessionStore(":memory:")
  704. runtime: DebugRuntime | None = None
  705. timing_client: TimingChatClient | None = None
  706. parallel_probe: BenchmarkParallelEventProbe | None = None
  707. result: BenchmarkRunResult | None = None
  708. close_error: Exception | None = None
  709. try:
  710. inner_client = (
  711. MockBenchmarkChatClient(case, mode)
  712. if self.mock
  713. else self.client_factory(self.config, self.api_key)
  714. )
  715. timing_client = TimingChatClient(inner_client, clock=self.clock)
  716. session_id = f"benchmark-{uuid4().hex}"
  717. request = build_benchmark_request(
  718. case,
  719. mode,
  720. self.config.model,
  721. ).model_copy(update={"session_id": session_id})
  722. output_queue = _BenchmarkOutputQueue(self.clock)
  723. if case.case_id is BenchmarkCaseId.PARALLEL_VOLUME_SCHEDULE:
  724. parallel_probe = self.parallel_probe_factory()
  725. registry = build_default_tool_registry(
  726. device_volume_port=parallel_probe,
  727. calendar_schedule_port=parallel_probe,
  728. )
  729. else:
  730. registry = build_default_tool_registry()
  731. runtime = DebugRuntime(
  732. timing_client,
  733. queues=RuntimeQueues(output=output_queue),
  734. registry=registry,
  735. session_store=store,
  736. clock=self.clock,
  737. )
  738. turn_started_at = self.clock()
  739. outputs, visible_ttft_ms, runtime_error = await self._consume_turn(
  740. runtime,
  741. request,
  742. turn_started_at,
  743. output_queue,
  744. )
  745. audits = store.list_audit_logs(session_id)
  746. usage = store.usage_summary(session_id)
  747. result = self._build_result(
  748. case=case,
  749. mode=mode,
  750. iteration=iteration,
  751. outputs=outputs,
  752. audits=audits,
  753. usage=usage,
  754. timings=timing_client.timings,
  755. visible_ttft_ms=visible_ttft_ms,
  756. runtime_error=runtime_error,
  757. parallel_events_overlapped=(
  758. parallel_probe.overlapped
  759. if parallel_probe is not None
  760. else None
  761. ),
  762. )
  763. except Exception as exc:
  764. result = self._failed_result(
  765. case.case_id,
  766. mode,
  767. iteration,
  768. error=str(exc),
  769. timings=timing_client.timings if timing_client is not None else [],
  770. )
  771. finally:
  772. if runtime is not None:
  773. try:
  774. await runtime.aclose()
  775. except Exception as exc:
  776. close_error = exc
  777. if timing_client is not None:
  778. try:
  779. await timing_client.aclose()
  780. except Exception as exc:
  781. close_error = close_error or exc
  782. assert result is not None
  783. if close_error is not None:
  784. error = str(close_error)
  785. if result.error:
  786. error = f"{result.error}; close failed: {error}"
  787. result = result.model_copy(update={"status": "failed", "error": error})
  788. return result
  789. async def _consume_turn(
  790. self,
  791. runtime: DebugRuntime,
  792. request: DebugRunRequest,
  793. turn_started_at: float,
  794. output_queue: _BenchmarkOutputQueue,
  795. ) -> tuple[list[dict[str, Any]], int | None, str | None]:
  796. queues = runtime.start_session(request)
  797. outputs: list[dict[str, Any]] = []
  798. visible_ttft_ms: int | None = None
  799. runtime_error: str | None = None
  800. while True:
  801. message = await queues.output.get()
  802. outputs.append(message)
  803. message_type = message.get("type")
  804. if message_type == "error":
  805. runtime_error = str(message.get("message") or "runtime error")
  806. break
  807. if message_type == "turn_completed":
  808. break
  809. if output_queue.first_message_delta_at is not None:
  810. visible_ttft_ms = max(
  811. 0,
  812. round(
  813. (output_queue.first_message_delta_at - turn_started_at) * 1000
  814. ),
  815. )
  816. return outputs, visible_ttft_ms, runtime_error
  817. def _build_result(
  818. self,
  819. *,
  820. case: BenchmarkCase,
  821. mode: BenchmarkMode,
  822. iteration: int,
  823. outputs: list[dict[str, Any]],
  824. audits: list[dict[str, Any]],
  825. usage: dict[str, Any],
  826. timings: list[BenchmarkModelCallTiming],
  827. visible_ttft_ms: int | None,
  828. runtime_error: str | None,
  829. parallel_events_overlapped: bool | None = None,
  830. ) -> BenchmarkRunResult:
  831. session_usage = usage["session"]
  832. calls = usage["calls"]
  833. event_audits = [
  834. audit for audit in audits if audit["event"] == "chat_event_detected"
  835. ]
  836. batch_audits = [
  837. audit for audit in audits if audit["event"] == "event_batch_results"
  838. ]
  839. batch_results = [
  840. item
  841. for audit in batch_audits
  842. for item in audit["details"].get("results", [])
  843. ]
  844. event_names = [audit["details"]["event_name"] for audit in event_audits]
  845. batch_event_names = [
  846. str(item.get("event_name") or "") for item in batch_results
  847. ]
  848. event_sources = [audit["details"]["event_source"] for audit in event_audits]
  849. tool_statuses = [str(item["status"]) for item in batch_results]
  850. tool_calls = [call for call in calls if call["call_kind"] == "tool_execution"]
  851. tool_event_names = [
  852. str(call.get("event_name") or "") for call in tool_calls
  853. ]
  854. ledger_model_call_count = sum(
  855. call["call_kind"] == "chat_completion" for call in calls
  856. )
  857. ledger_fallback_count = int(session_usage["fallback_count"])
  858. timing_model_call_count = sum(
  859. timing.call_kind == "chat_completion" for timing in timings
  860. )
  861. timing_fallback_count = sum(
  862. timing.call_kind == "argument_fallback" for timing in timings
  863. )
  864. token_totals, missing_usage_count = self._token_totals(timings)
  865. tool_count = int(session_usage["tool_count"])
  866. semantic_failures = self._semantic_failures(
  867. case=case,
  868. mode=mode,
  869. outputs=outputs,
  870. audits=audits,
  871. event_names=event_names,
  872. batch_event_names=batch_event_names,
  873. tool_event_names=tool_event_names,
  874. event_sources=event_sources,
  875. tool_statuses=tool_statuses,
  876. model_call_count=ledger_model_call_count,
  877. fallback_count=ledger_fallback_count,
  878. tool_count=tool_count,
  879. timings=timings,
  880. check_mock_text=self.mock,
  881. missing_usage_count=missing_usage_count,
  882. parallel_events_overlapped=parallel_events_overlapped,
  883. )
  884. initial_call = next(
  885. (timing for timing in timings if timing.call_kind == "chat_completion"),
  886. None,
  887. )
  888. status = "failed" if runtime_error or semantic_failures else "passed"
  889. return BenchmarkRunResult(
  890. case_id=case.case_id,
  891. mode=mode,
  892. iteration=iteration,
  893. status=status,
  894. initial_provider_ttft_ms=(
  895. initial_call.provider_ttft_ms if initial_call is not None else None
  896. ),
  897. visible_ttft_ms=visible_ttft_ms,
  898. turn_wall_time_ms=session_usage["turn_wall_time_ms"],
  899. prompt_tokens=token_totals[0],
  900. completion_tokens=token_totals[1],
  901. total_tokens=token_totals[2],
  902. cached_tokens=token_totals[3],
  903. model_call_count=timing_model_call_count,
  904. fallback_count=timing_fallback_count,
  905. tool_count=tool_count,
  906. event_names=event_names,
  907. batch_event_names=batch_event_names,
  908. tool_event_names=tool_event_names,
  909. event_sources=event_sources,
  910. tool_statuses=tool_statuses,
  911. tool_latencies_ms=[call["tool_latency_ms"] for call in tool_calls],
  912. semantic_failures=semantic_failures,
  913. error=runtime_error,
  914. model_calls=list(timings),
  915. parallel_events_overlapped=parallel_events_overlapped,
  916. )
  917. def _semantic_failures(
  918. self,
  919. *,
  920. case: BenchmarkCase,
  921. mode: BenchmarkMode,
  922. outputs: list[dict[str, Any]],
  923. audits: list[dict[str, Any]],
  924. event_names: list[str],
  925. batch_event_names: list[str],
  926. tool_event_names: list[str],
  927. event_sources: list[str],
  928. tool_statuses: list[str],
  929. model_call_count: int,
  930. fallback_count: int,
  931. tool_count: int,
  932. timings: list[BenchmarkModelCallTiming],
  933. check_mock_text: bool,
  934. missing_usage_count: int = 0,
  935. parallel_events_overlapped: bool | None = None,
  936. ) -> list[str]:
  937. failures: list[str] = []
  938. answers, first_answer_index, first_tool_index = self._visible_answers(outputs)
  939. expected = case.expectation
  940. if len(answers) != expected.answer_count:
  941. failures.append(
  942. f"answer_count expected {expected.answer_count}, got {len(answers)}"
  943. )
  944. if any(not answer.strip() for answer in answers):
  945. failures.append("visible answers must be non-empty")
  946. if check_mock_text and tuple(answers) != expected.visible_messages:
  947. failures.append(
  948. f"mock visible messages expected {expected.visible_messages!r}, "
  949. f"got {tuple(answers)!r}"
  950. )
  951. if expected.first_reply_before_tool and not (
  952. first_answer_index is not None
  953. and first_tool_index is not None
  954. and first_answer_index < first_tool_index
  955. ):
  956. failures.append("first visible answer must precede tool results")
  957. if tuple(event_names) != expected.event_names:
  958. failures.append(
  959. f"event names expected {expected.event_names!r}, got {tuple(event_names)!r}"
  960. )
  961. if tuple(batch_event_names) != expected.event_names:
  962. failures.append(
  963. "batch event names expected "
  964. f"{expected.event_names!r}, got {tuple(batch_event_names)!r}"
  965. )
  966. if tuple(tool_event_names) != expected.event_names:
  967. failures.append(
  968. "tool event names expected "
  969. f"{expected.event_names!r}, got {tuple(tool_event_names)!r}"
  970. )
  971. expected_source = (
  972. "text_event"
  973. if mode is BenchmarkMode.DUAL_AGENT
  974. else "provider_resolved"
  975. )
  976. expected_sources = (expected_source,) * len(expected.event_names)
  977. if tuple(event_sources) != expected_sources:
  978. failures.append(
  979. f"event sources expected {expected_sources!r}, "
  980. f"got {tuple(event_sources)!r}"
  981. )
  982. expected_statuses = tuple(
  983. (event_name, "success") for event_name in expected.event_names
  984. )
  985. actual_statuses = tuple(
  986. (
  987. batch_event_names[index]
  988. if index < len(batch_event_names)
  989. else None,
  990. tool_statuses[index] if index < len(tool_statuses) else None,
  991. )
  992. for index in range(max(len(batch_event_names), len(tool_statuses)))
  993. )
  994. if actual_statuses != expected_statuses:
  995. failures.append(
  996. f"event batch statuses expected {expected_statuses!r}, "
  997. f"got {actual_statuses!r}"
  998. )
  999. if tool_count != len(expected.event_names):
  1000. failures.append(
  1001. f"tool_count expected {len(expected.event_names)}, got {tool_count}"
  1002. )
  1003. timing_chat_count = sum(
  1004. timing.call_kind == "chat_completion" for timing in timings
  1005. )
  1006. timing_fallback_count = sum(
  1007. timing.call_kind == "argument_fallback" for timing in timings
  1008. )
  1009. if model_call_count != expected.expected_model_call_count:
  1010. failures.append(
  1011. "model_call_count expected "
  1012. f"{expected.expected_model_call_count}, ledger got {model_call_count}"
  1013. )
  1014. if timing_chat_count != expected.expected_model_call_count:
  1015. failures.append(
  1016. "model_call_count expected "
  1017. f"{expected.expected_model_call_count}, timing got {timing_chat_count}"
  1018. )
  1019. if fallback_count != expected.expected_fallback_count:
  1020. failures.append(
  1021. "fallback_count expected "
  1022. f"{expected.expected_fallback_count}, ledger got {fallback_count}"
  1023. )
  1024. if timing_fallback_count != expected.expected_fallback_count:
  1025. failures.append(
  1026. "fallback_count expected "
  1027. f"{expected.expected_fallback_count}, timing got "
  1028. f"{timing_fallback_count}"
  1029. )
  1030. if model_call_count != timing_chat_count:
  1031. failures.append(
  1032. f"model_call_count ledger={model_call_count}, timing={timing_chat_count}"
  1033. )
  1034. if fallback_count != timing_fallback_count:
  1035. failures.append(
  1036. f"fallback_count ledger={fallback_count}, timing={timing_fallback_count}"
  1037. )
  1038. if missing_usage_count:
  1039. failures.append(
  1040. f"model usage missing for {missing_usage_count} call(s)"
  1041. )
  1042. if (
  1043. case.case_id is BenchmarkCaseId.PARALLEL_VOLUME_SCHEDULE
  1044. and parallel_events_overlapped is not True
  1045. ):
  1046. failures.append("parallel event handlers did not overlap")
  1047. terminal = any(audit["event"] == "terminal_completed" for audit in audits)
  1048. if terminal is not expected.terminal:
  1049. failures.append(
  1050. f"terminal expected {expected.terminal}, got {terminal}"
  1051. )
  1052. return failures
  1053. @staticmethod
  1054. def _visible_answers(
  1055. outputs: list[dict[str, Any]],
  1056. ) -> tuple[list[str], int | None, int | None]:
  1057. answers: list[str] = []
  1058. current: list[str] = []
  1059. first_answer_index: int | None = None
  1060. first_tool_index: int | None = None
  1061. for index, message in enumerate(outputs):
  1062. message_type = message.get("type")
  1063. if message_type == "message_delta":
  1064. content = str(message.get("content") or "")
  1065. if first_answer_index is None and content.strip():
  1066. first_answer_index = index
  1067. current.append(content)
  1068. continue
  1069. if message_type == "tool_result":
  1070. if first_tool_index is None:
  1071. first_tool_index = index
  1072. answer = "".join(current).strip()
  1073. if answer:
  1074. answers.append(answer)
  1075. current = []
  1076. answer = "".join(current).strip()
  1077. if answer:
  1078. answers.append(answer)
  1079. return answers, first_answer_index, first_tool_index
  1080. @staticmethod
  1081. def _failed_result(
  1082. case_id: BenchmarkCaseId,
  1083. mode: BenchmarkMode,
  1084. iteration: int,
  1085. *,
  1086. error: str,
  1087. timings: list[BenchmarkModelCallTiming],
  1088. ) -> BenchmarkRunResult:
  1089. initial_call = next(
  1090. (timing for timing in timings if timing.call_kind == "chat_completion"),
  1091. None,
  1092. )
  1093. model_call_count = sum(
  1094. timing.call_kind == "chat_completion" for timing in timings
  1095. )
  1096. fallback_count = sum(
  1097. timing.call_kind == "argument_fallback" for timing in timings
  1098. )
  1099. token_totals, missing_usage_count = BenchmarkRunner._token_totals(timings)
  1100. return BenchmarkRunResult(
  1101. case_id=case_id,
  1102. mode=mode,
  1103. iteration=iteration,
  1104. status="failed",
  1105. initial_provider_ttft_ms=(
  1106. initial_call.provider_ttft_ms if initial_call is not None else None
  1107. ),
  1108. visible_ttft_ms=None,
  1109. turn_wall_time_ms=None,
  1110. prompt_tokens=token_totals[0],
  1111. completion_tokens=token_totals[1],
  1112. total_tokens=token_totals[2],
  1113. cached_tokens=token_totals[3],
  1114. model_call_count=model_call_count,
  1115. fallback_count=fallback_count,
  1116. tool_count=None,
  1117. event_names=[],
  1118. batch_event_names=[],
  1119. tool_event_names=[],
  1120. event_sources=[],
  1121. tool_statuses=[],
  1122. tool_latencies_ms=[],
  1123. semantic_failures=(
  1124. [f"model usage missing for {missing_usage_count} call(s)"]
  1125. if missing_usage_count
  1126. else []
  1127. ),
  1128. error=error,
  1129. model_calls=list(timings),
  1130. parallel_events_overlapped=None,
  1131. )
  1132. @staticmethod
  1133. def _token_totals(
  1134. timings: list[BenchmarkModelCallTiming],
  1135. ) -> tuple[tuple[int | None, int | None, int | None, int | None], int]:
  1136. if not timings:
  1137. return (None, None, None, None), 0
  1138. missing_usage_count = sum(timing.usage is None for timing in timings)
  1139. if missing_usage_count:
  1140. return (None, None, None, None), missing_usage_count
  1141. usages = [timing.usage for timing in timings]
  1142. assert all(usage is not None for usage in usages)
  1143. complete_usages = [usage for usage in usages if usage is not None]
  1144. return (
  1145. sum(usage.prompt_tokens for usage in complete_usages),
  1146. sum(usage.completion_tokens for usage in complete_usages),
  1147. sum(usage.total_tokens for usage in complete_usages),
  1148. sum(usage.cached_tokens for usage in complete_usages),
  1149. ), 0
  1150. @staticmethod
  1151. def _default_client_factory(
  1152. config: BenchmarkConfig,
  1153. api_key: str,
  1154. ) -> OpenAICompatibleChatClient:
  1155. return OpenAICompatibleChatClient(
  1156. api_key=api_key,
  1157. base_url=config.base_url,
  1158. default_model=config.model,
  1159. request_timeout_seconds=60.0,
  1160. )