test_benchmark_config.py 13 KB

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