test_benchmark_config.py 14 KB

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