test_benchmark_config.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. import json
  2. from enum import StrEnum
  3. import pytest
  4. from pydantic import ValidationError
  5. from agent_lab.application.benchmark import (
  6. BENCHMARK_CASE_CATALOG,
  7. BENCHMARK_CASE_IDS,
  8. BENCHMARK_MODES,
  9. BenchmarkCaseId,
  10. BenchmarkConfig,
  11. BenchmarkMode,
  12. BenchmarkTarget,
  13. build_benchmark_request,
  14. build_mock_rounds,
  15. )
  16. VALID_CONFIG = {
  17. "schema_version": 1,
  18. "base_url": "https://provider.example/v1",
  19. "model": "benchmark-model",
  20. "runs_per_case": 1,
  21. "modes": ["dual_agent", "chat_agent_tools"],
  22. "cases": [
  23. "ordinary_chat",
  24. "device_volume_silent",
  25. "calendar_schedule_template",
  26. "web_search_two_answers",
  27. "session_terminate",
  28. "parallel_volume_schedule",
  29. ],
  30. }
  31. def test_benchmark_ids_are_ordered_string_compatible_enums():
  32. assert isinstance(BenchmarkMode, type)
  33. assert issubclass(BenchmarkMode, StrEnum)
  34. assert isinstance(BenchmarkCaseId, type)
  35. assert issubclass(BenchmarkCaseId, StrEnum)
  36. assert tuple(BenchmarkMode) == BENCHMARK_MODES
  37. assert tuple(BenchmarkCaseId) == BENCHMARK_CASE_IDS
  38. assert tuple(str(mode) for mode in BENCHMARK_MODES) == (
  39. "dual_agent",
  40. "chat_agent_tools",
  41. )
  42. assert tuple(str(case_id) for case_id in BENCHMARK_CASE_IDS) == (
  43. "ordinary_chat",
  44. "device_volume_silent",
  45. "calendar_schedule_template",
  46. "web_search_two_answers",
  47. "session_terminate",
  48. "parallel_volume_schedule",
  49. )
  50. def test_benchmark_config_accepts_the_versioned_single_target_schema():
  51. config = BenchmarkConfig.model_validate_json(json.dumps(VALID_CONFIG))
  52. python_config = BenchmarkConfig.model_validate(VALID_CONFIG)
  53. assert config.schema_version == 1
  54. assert config.base_url == "https://provider.example/v1"
  55. assert config.model == "benchmark-model"
  56. assert config.runs_per_case == 1
  57. assert tuple(config.modes) == BENCHMARK_MODES
  58. assert tuple(config.cases) == BENCHMARK_CASE_IDS
  59. assert all(isinstance(mode, StrEnum) for mode in config.modes)
  60. assert all(isinstance(case_id, StrEnum) for case_id in config.cases)
  61. assert python_config.modes == config.modes
  62. assert python_config.cases == config.cases
  63. assert config.model_dump(mode="json")["modes"] == VALID_CONFIG["modes"]
  64. assert config.model_dump(mode="json")["cases"] == VALID_CONFIG["cases"]
  65. def test_benchmark_target_uses_the_same_strict_url_and_model_validation():
  66. target = BenchmarkTarget(
  67. base_url="http://localhost:8000/v1",
  68. model="local-model",
  69. )
  70. assert target.model_dump() == {
  71. "base_url": "http://localhost:8000/v1",
  72. "model": "local-model",
  73. }
  74. @pytest.mark.parametrize("schema_version", [0, 2, "1", True, 1.0])
  75. def test_benchmark_config_rejects_unknown_or_coerced_schema_versions(
  76. schema_version: object,
  77. ):
  78. payload = {**VALID_CONFIG, "schema_version": schema_version}
  79. with pytest.raises(ValidationError, match="schema_version"):
  80. BenchmarkConfig.model_validate(payload)
  81. @pytest.mark.parametrize(
  82. "base_url",
  83. [
  84. "ftp://provider.example/v1",
  85. "provider.example/v1",
  86. "https:///v1",
  87. "https://user:pass@provider.example/v1",
  88. "https://provider.example/v1?debug=true",
  89. "https://provider.example/v1#fragment",
  90. ],
  91. )
  92. def test_benchmark_config_rejects_unsafe_or_non_http_base_urls(base_url: str):
  93. payload = {**VALID_CONFIG, "base_url": base_url}
  94. with pytest.raises(ValidationError, match="base_url"):
  95. BenchmarkConfig.model_validate(payload)
  96. @pytest.mark.parametrize(
  97. "base_url",
  98. [
  99. pytest.param("https://🚀.example/v1", id="invalid-idna-host"),
  100. pytest.param("https://xn--.example/v1", id="malformed-a-label"),
  101. pytest.param(
  102. "https://provider.example:not-a-port/v1",
  103. id="invalid-port",
  104. ),
  105. pytest.param(
  106. "https://provider.example:65536/v1",
  107. id="out-of-range-port",
  108. ),
  109. pytest.param("https://[v1.invalid]/v1", id="invalid-ip-literal"),
  110. ],
  111. )
  112. def test_benchmark_target_rejects_urls_incompatible_with_httpx(base_url: str):
  113. with pytest.raises(ValidationError, match="base_url"):
  114. BenchmarkTarget(base_url=base_url, model="benchmark-model")
  115. @pytest.mark.parametrize(
  116. "character",
  117. [
  118. pytest.param(chr(0), id="nul-cc"),
  119. pytest.param("\r\n", id="crlf-cc"),
  120. pytest.param("\t", id="tab-cc"),
  121. pytest.param(chr(0x200B), id="zero-width-space-cf"),
  122. pytest.param(chr(0x202E), id="right-to-left-override-cf"),
  123. pytest.param(chr(0xE000), id="private-use-co"),
  124. pytest.param(chr(0xD800), id="surrogate-cs"),
  125. pytest.param(chr(0x0378), id="unassigned-cn"),
  126. ],
  127. )
  128. def test_benchmark_config_rejects_unicode_category_c_in_base_url(character: str):
  129. payload = {
  130. **VALID_CONFIG,
  131. "base_url": f"https://provider.example/v1{character}segment",
  132. }
  133. with pytest.raises(ValidationError, match="base_url"):
  134. BenchmarkConfig.model_validate(payload)
  135. def test_benchmark_config_accepts_printable_internationalized_base_url():
  136. base_url = "https://例え.テスト/路径/模型-🚀"
  137. config = BenchmarkConfig.model_validate({**VALID_CONFIG, "base_url": base_url})
  138. assert config.base_url == base_url
  139. @pytest.mark.parametrize("model", ["", " "])
  140. def test_benchmark_config_rejects_empty_models(model: str):
  141. payload = {**VALID_CONFIG, "model": model}
  142. with pytest.raises(ValidationError, match="model"):
  143. BenchmarkConfig.model_validate(payload)
  144. def test_benchmark_config_rejects_non_positive_runs_per_case():
  145. payload = {**VALID_CONFIG, "runs_per_case": 0}
  146. with pytest.raises(ValidationError, match="runs_per_case"):
  147. BenchmarkConfig.model_validate(payload)
  148. @pytest.mark.parametrize(
  149. "modes",
  150. [[], ["dual_agent", "dual_agent"], ["unsupported"]],
  151. )
  152. def test_benchmark_config_rejects_empty_duplicate_or_unknown_modes(
  153. modes: list[str],
  154. ):
  155. payload = {**VALID_CONFIG, "modes": modes}
  156. with pytest.raises(ValidationError, match="modes"):
  157. BenchmarkConfig.model_validate(payload)
  158. @pytest.mark.parametrize(
  159. "cases",
  160. [[], ["ordinary_chat", "ordinary_chat"], ["custom_case"]],
  161. )
  162. def test_benchmark_config_rejects_empty_duplicate_or_unknown_cases(
  163. cases: list[str],
  164. ):
  165. payload = {**VALID_CONFIG, "cases": cases}
  166. with pytest.raises(ValidationError, match="cases"):
  167. BenchmarkConfig.model_validate(payload)
  168. @pytest.mark.parametrize("field", ["api_key", "token", "unknown_field"])
  169. def test_benchmark_config_rejects_secrets_and_unknown_json_fields(field: str):
  170. secret_value = "must-not-appear-in-validation-errors"
  171. payload = {**VALID_CONFIG, field: secret_value}
  172. with pytest.raises(ValidationError) as exc_info:
  173. BenchmarkConfig.model_validate_json(json.dumps(payload))
  174. assert field in str(exc_info.value)
  175. assert secret_value not in str(exc_info.value)
  176. def test_builtin_case_catalog_is_complete_and_immutable():
  177. assert all(isinstance(case_id, StrEnum) for case_id in BENCHMARK_CASE_CATALOG)
  178. assert tuple(BENCHMARK_CASE_CATALOG) == BENCHMARK_CASE_IDS
  179. assert set(BENCHMARK_CASE_CATALOG) == set(BenchmarkCaseId)
  180. assert tuple(
  181. case.case_id for case in BENCHMARK_CASE_CATALOG.values()
  182. ) == BENCHMARK_CASE_IDS
  183. with pytest.raises(TypeError):
  184. BENCHMARK_CASE_CATALOG["ordinary_chat"] = BENCHMARK_CASE_CATALOG[
  185. "ordinary_chat"
  186. ]
  187. with pytest.raises(ValidationError, match="frozen"):
  188. BENCHMARK_CASE_CATALOG["ordinary_chat"].user_message = "changed"
  189. first_event = BENCHMARK_CASE_CATALOG["device_volume_silent"].mock_events[0]
  190. with pytest.raises(TypeError):
  191. first_event.arguments[0] = ("mode", "relative")
  192. def test_builtin_cases_own_semantics_and_deterministic_mock_data():
  193. actual = {
  194. case_id: {
  195. "user_message": case.user_message,
  196. "enabled_tools": case.enabled_tools,
  197. "events": case.expectation.event_names,
  198. "answer_count": case.expectation.answer_count,
  199. "terminal": case.expectation.terminal,
  200. "first_reply_before_tool": case.expectation.first_reply_before_tool,
  201. "expected_visible": case.expectation.visible_messages,
  202. "mock_visible": case.mock_visible_messages,
  203. "mock_events": tuple(
  204. (event.id, event.name, dict(event.arguments))
  205. for event in case.mock_events
  206. ),
  207. }
  208. for case_id, case in BENCHMARK_CASE_CATALOG.items()
  209. }
  210. assert actual == {
  211. "ordinary_chat": {
  212. "user_message": "Hello.",
  213. "enabled_tools": (
  214. "session.terminate",
  215. "device.volume.adjust",
  216. "calendar.schedule.create",
  217. "knowledge.web.search",
  218. ),
  219. "events": (),
  220. "answer_count": 1,
  221. "terminal": False,
  222. "first_reply_before_tool": False,
  223. "expected_visible": ("Ordinary answer.",),
  224. "mock_visible": ("Ordinary answer.",),
  225. "mock_events": (),
  226. },
  227. "device_volume_silent": {
  228. "user_message": "set volume to 30",
  229. "enabled_tools": ("device.volume.adjust",),
  230. "events": ("device.volume.adjust",),
  231. "answer_count": 1,
  232. "terminal": False,
  233. "first_reply_before_tool": True,
  234. "expected_visible": ("I will set the volume to 30.",),
  235. "mock_visible": ("I will set the volume to 30.",),
  236. "mock_events": (
  237. (
  238. "volume-1",
  239. "device.volume.adjust",
  240. {"mode": "absolute", "value": 30},
  241. ),
  242. ),
  243. },
  244. "calendar_schedule_template": {
  245. "user_message": (
  246. 'schedule "Design review" at 2026-07-14T09:30:00+08:00 '
  247. "timezone Asia/Shanghai"
  248. ),
  249. "enabled_tools": ("calendar.schedule.create",),
  250. "events": ("calendar.schedule.create",),
  251. "answer_count": 2,
  252. "terminal": False,
  253. "first_reply_before_tool": True,
  254. "expected_visible": (
  255. "I will schedule that.",
  256. "Scheduled Design review for 2026-07-14T09:30:00+08:00 "
  257. "(Asia/Shanghai).",
  258. ),
  259. "mock_visible": ("I will schedule that.",),
  260. "mock_events": (
  261. (
  262. "schedule-1",
  263. "calendar.schedule.create",
  264. {
  265. "title": "Design review",
  266. "start_at": "2026-07-14T09:30:00+08:00",
  267. "timezone": "Asia/Shanghai",
  268. },
  269. ),
  270. ),
  271. },
  272. "web_search_two_answers": {
  273. "user_message": "event batch safety",
  274. "enabled_tools": ("knowledge.web.search",),
  275. "events": ("knowledge.web.search",),
  276. "answer_count": 2,
  277. "terminal": False,
  278. "first_reply_before_tool": True,
  279. "expected_visible": ("I will check.", "Grounded search update."),
  280. "mock_visible": ("I will check.", "Grounded search update."),
  281. "mock_events": (
  282. (
  283. "search-1",
  284. "knowledge.web.search",
  285. {"query": "event batch safety"},
  286. ),
  287. ),
  288. },
  289. "session_terminate": {
  290. "user_message": "end this session",
  291. "enabled_tools": ("session.terminate",),
  292. "events": ("session.terminate",),
  293. "answer_count": 1,
  294. "terminal": True,
  295. "first_reply_before_tool": True,
  296. "expected_visible": ("Goodbye.",),
  297. "mock_visible": ("Goodbye.",),
  298. "mock_events": (("terminate-1", "session.terminate", {}),),
  299. },
  300. "parallel_volume_schedule": {
  301. "user_message": (
  302. 'set volume to 30 and schedule "Design review" at '
  303. "2026-07-14T09:30:00+08:00 timezone Asia/Shanghai"
  304. ),
  305. "enabled_tools": (
  306. "device.volume.adjust",
  307. "calendar.schedule.create",
  308. ),
  309. "events": (
  310. "device.volume.adjust",
  311. "calendar.schedule.create",
  312. ),
  313. "answer_count": 2,
  314. "terminal": False,
  315. "first_reply_before_tool": True,
  316. "expected_visible": (
  317. "I will set the volume to 30 and schedule that.",
  318. "Scheduled Design review for 2026-07-14T09:30:00+08:00 "
  319. "(Asia/Shanghai).",
  320. ),
  321. "mock_visible": (
  322. "I will set the volume to 30 and schedule that.",
  323. ),
  324. "mock_events": (
  325. (
  326. "parallel-volume",
  327. "device.volume.adjust",
  328. {"mode": "absolute", "value": 30},
  329. ),
  330. (
  331. "parallel-schedule",
  332. "calendar.schedule.create",
  333. {
  334. "title": "Design review",
  335. "start_at": "2026-07-14T09:30:00+08:00",
  336. "timezone": "Asia/Shanghai",
  337. },
  338. ),
  339. ),
  340. },
  341. }
  342. @pytest.mark.parametrize("case_id", BENCHMARK_CASE_IDS)
  343. def test_build_benchmark_request_uses_fixed_fairness_controls(
  344. case_id: BenchmarkCaseId,
  345. ):
  346. case = BENCHMARK_CASE_CATALOG[case_id]
  347. requests = {
  348. mode: build_benchmark_request(case, mode, "comparison-model")
  349. for mode in BENCHMARK_MODES
  350. }
  351. dual = requests["dual_agent"]
  352. tools = requests["chat_agent_tools"]
  353. assert dual.model_dump(exclude={"tool_invocation_mode"}) == tools.model_dump(
  354. exclude={"tool_invocation_mode"}
  355. )
  356. assert dual.user_message == case.user_message
  357. assert dual.system_prompts == ["Exercise the requested business scenario."]
  358. assert dual.pre_messages == []
  359. assert dual.chat_agent.model == "comparison-model"
  360. assert dual.chat_agent.temperature == 0.0
  361. assert dual.chat_agent.max_tokens == 128
  362. assert dual.event_agent.model == "comparison-model"
  363. assert dual.event_agent.temperature == 0.0
  364. assert dual.event_agent.max_tokens == 128
  365. assert tuple(dual.event_agent.enabled_tools) == case.enabled_tools
  366. assert dual.event_agent.max_event_loops == 1
  367. assert dual.event_agent.max_parallel_events == 2
  368. assert dual.event_agent.batch_timeout_seconds == 1.0
  369. assert dual.tool_invocation_mode == "dual_agent"
  370. assert tools.tool_invocation_mode == "chat_agent_tools"
  371. @pytest.mark.parametrize("mode", BENCHMARK_MODES)
  372. def test_benchmark_helpers_accept_enum_and_string_modes(mode: BenchmarkMode):
  373. assert isinstance(mode, StrEnum)
  374. case = BENCHMARK_CASE_CATALOG["ordinary_chat"]
  375. enum_request = build_benchmark_request(case, mode, "comparison-model")
  376. string_request = build_benchmark_request(case, mode.value, "comparison-model")
  377. assert enum_request == string_request
  378. assert enum_request.tool_invocation_mode == mode.value
  379. assert type(enum_request.tool_invocation_mode) is str
  380. assert build_mock_rounds(case, mode) == build_mock_rounds(case, mode.value)
  381. def test_build_benchmark_request_rejects_an_empty_model():
  382. case = BENCHMARK_CASE_CATALOG["ordinary_chat"]
  383. with pytest.raises(ValueError, match="model"):
  384. build_benchmark_request(case, "dual_agent", " ")
  385. @pytest.mark.parametrize("mode", BENCHMARK_MODES)
  386. @pytest.mark.parametrize("case_id", BENCHMARK_CASE_IDS)
  387. def test_mock_rounds_are_mode_correct_and_derived_from_catalog(
  388. mode: BenchmarkMode,
  389. case_id: BenchmarkCaseId,
  390. ):
  391. case = BENCHMARK_CASE_CATALOG[case_id]
  392. rounds = build_mock_rounds(case, mode)
  393. items = tuple(item for round_items in rounds for item in round_items)
  394. message_items = tuple(item for item in items if item.kind == "message_delta")
  395. event_items = tuple(item for item in items if item.event is not None)
  396. assert tuple(item.content for item in message_items) == case.mock_visible_messages
  397. assert tuple(item.event.name for item in event_items) == case.expectation.event_names
  398. assert tuple(item.event.id for item in event_items) == tuple(
  399. event.id for event in case.mock_events
  400. )
  401. assert tuple(item.event.arguments for item in event_items) == tuple(
  402. dict(event.arguments) for event in case.mock_events
  403. )
  404. expected_kind = "text_event" if mode == "dual_agent" else "provider_tool_call"
  405. assert {item.kind for item in event_items} <= {expected_kind}
  406. assert len(rounds) == max(1, len(case.mock_visible_messages))