test_benchmark_config.py 14 KB

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