test_builtin_event_plugins.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670
  1. import json
  2. from datetime import datetime, timezone
  3. import pytest
  4. import agent_lab.application.events.builtin_plugins as builtin_plugins
  5. from agent_lab.application.events import (
  6. EventArgumentResolution,
  7. EventExecutionContext,
  8. EventRequest,
  9. ResultPolicy,
  10. RiskLevel,
  11. )
  12. from agent_lab.application.tools import build_default_tool_registry
  13. from agent_lab.domain.events import ToolCallEvent
  14. from agent_lab.domain.messages import ChatMessage
  15. BUILTIN_EVENT_NAMES = [
  16. "session.terminate",
  17. "device.volume.adjust",
  18. "calendar.schedule.create",
  19. "knowledge.web.search",
  20. ]
  21. def _event(event_id: str, name: str, arguments: dict):
  22. return ToolCallEvent(
  23. id=event_id,
  24. name=name,
  25. arguments=arguments,
  26. raw_arguments=json.dumps(arguments, ensure_ascii=False),
  27. )
  28. def test_default_registry_preserves_legacy_tools_and_adds_builtin_event_plugins():
  29. registry = build_default_tool_registry()
  30. assert [item["name"] for item in registry.available_tools()] == [
  31. "handoff_note",
  32. "mock_search",
  33. "mock_ticket",
  34. *BUILTIN_EVENT_NAMES,
  35. ]
  36. @pytest.mark.parametrize(
  37. (
  38. "name",
  39. "result_policy",
  40. "risk_level",
  41. "fallback_allowed",
  42. "idempotency_key_fields",
  43. "concurrency_class",
  44. "conflict_keys",
  45. "timeout_seconds",
  46. "terminal",
  47. ),
  48. [
  49. (
  50. "session.terminate",
  51. ResultPolicy.TERMINATE,
  52. RiskLevel.HIGH,
  53. False,
  54. ("event_id",),
  55. "session-lifecycle",
  56. ("session",),
  57. 5.0,
  58. True,
  59. ),
  60. (
  61. "device.volume.adjust",
  62. ResultPolicy.SILENT_SUCCESS,
  63. RiskLevel.MEDIUM,
  64. True,
  65. ("event_id",),
  66. "device-volume",
  67. ("device.volume",),
  68. 5.0,
  69. False,
  70. ),
  71. (
  72. "calendar.schedule.create",
  73. ResultPolicy.TEMPLATE_FOLLOW_UP,
  74. RiskLevel.MEDIUM,
  75. True,
  76. ("event_id",),
  77. "schedule-write",
  78. ("calendar.schedule",),
  79. 10.0,
  80. False,
  81. ),
  82. (
  83. "knowledge.web.search",
  84. ResultPolicy.LLM_FOLLOW_UP,
  85. RiskLevel.LOW,
  86. False,
  87. (),
  88. "read-only",
  89. (),
  90. 10.0,
  91. False,
  92. ),
  93. ],
  94. )
  95. def test_builtin_definition_metadata_is_owned_by_each_flat_plugin(
  96. name,
  97. result_policy,
  98. risk_level,
  99. fallback_allowed,
  100. idempotency_key_fields,
  101. concurrency_class,
  102. conflict_keys,
  103. timeout_seconds,
  104. terminal,
  105. ):
  106. definition = build_default_tool_registry().event_registry.definition(name)
  107. assert definition is not None
  108. assert definition.result_policy is result_policy
  109. assert definition.risk_level is risk_level
  110. assert definition.fallback_allowed is fallback_allowed
  111. assert definition.idempotency_key_fields == idempotency_key_fields
  112. assert definition.concurrency_class == concurrency_class
  113. assert definition.conflict_keys == conflict_keys
  114. assert definition.timeout_seconds == timeout_seconds
  115. assert definition.terminal is terminal
  116. def test_session_terminate_schema_accepts_optional_reason_only():
  117. registry = build_default_tool_registry().event_registry
  118. assert registry.iter_validation_errors("session.terminate", {}) == ()
  119. assert registry.iter_validation_errors(
  120. "session.terminate", {"reason": "user requested"}
  121. ) == ()
  122. assert registry.iter_validation_errors(
  123. "session.terminate", {"reason": 1}
  124. )
  125. assert registry.iter_validation_errors(
  126. "session.terminate", {"unexpected": True}
  127. )
  128. @pytest.mark.parametrize(
  129. "arguments",
  130. [
  131. {"mode": "absolute", "value": 0},
  132. {"mode": "absolute", "value": 100},
  133. {"mode": "relative", "delta": -100},
  134. {"mode": "relative", "delta": 100},
  135. {"mode": "mute"},
  136. {"mode": "unmute"},
  137. ],
  138. )
  139. def test_volume_schema_accepts_mode_specific_valid_arguments(arguments):
  140. registry = build_default_tool_registry().event_registry
  141. assert registry.iter_validation_errors("device.volume.adjust", arguments) == ()
  142. @pytest.mark.parametrize(
  143. "arguments",
  144. [
  145. {"mode": "absolute"},
  146. {"mode": "absolute", "value": -1},
  147. {"mode": "absolute", "value": 101},
  148. {"mode": "absolute", "value": 20, "delta": 5},
  149. {"mode": "relative"},
  150. {"mode": "relative", "delta": 0},
  151. {"mode": "relative", "delta": 101},
  152. {"mode": "relative", "delta": 5, "value": 20},
  153. {"mode": "mute", "value": 0},
  154. {"mode": "unmute", "delta": 1},
  155. ],
  156. )
  157. def test_volume_schema_rejects_invalid_or_cross_mode_arguments(arguments):
  158. registry = build_default_tool_registry().event_registry
  159. assert registry.iter_validation_errors("device.volume.adjust", arguments)
  160. @pytest.mark.parametrize(
  161. ("content", "expected"),
  162. [
  163. ("把音量调到 35", {"mode": "absolute", "value": 35}),
  164. ("set volume to 42", {"mode": "absolute", "value": 42}),
  165. ("音量增加 15", {"mode": "relative", "delta": 15}),
  166. ("decrease volume by 20", {"mode": "relative", "delta": -20}),
  167. ("请静音", {"mode": "mute"}),
  168. ("please unmute", {"mode": "unmute"}),
  169. ],
  170. )
  171. def test_volume_resolver_recognizes_clear_chinese_and_english(content, expected):
  172. resolved = _resolve_text_event("device.volume.adjust", content)
  173. assert resolved == expected
  174. @pytest.mark.parametrize("content", ["把音量调高", "静音还是取消静音"])
  175. def test_volume_resolver_marks_incomplete_or_ambiguous_requests_for_fallback(content):
  176. resolved = _resolve_text_event("device.volume.adjust", content)
  177. assert isinstance(resolved, EventArgumentResolution)
  178. assert resolved.complete is False
  179. def test_schedule_schema_requires_explicit_rfc3339_and_timezone():
  180. registry = build_default_tool_registry().event_registry
  181. valid = {
  182. "title": "Design review",
  183. "start_at": "2026-07-14T09:30:00+08:00",
  184. "timezone": "Asia/Shanghai",
  185. "recurrence": "FREQ=WEEKLY",
  186. "reminder_minutes": 15,
  187. }
  188. assert registry.iter_validation_errors("calendar.schedule.create", valid) == ()
  189. assert registry.iter_validation_errors(
  190. "calendar.schedule.create",
  191. {**valid, "start_at": "tomorrow at nine"},
  192. )
  193. assert registry.iter_validation_errors(
  194. "calendar.schedule.create",
  195. {**valid, "timezone": "Shanghai"},
  196. )
  197. assert registry.iter_validation_errors(
  198. "calendar.schedule.create",
  199. {**valid, "reminder_minutes": -1},
  200. )
  201. @pytest.mark.parametrize(
  202. ("content", "expected"),
  203. [
  204. (
  205. 'Schedule "Design review" at 2026-07-14T09:30:00+08:00 '
  206. "timezone Asia/Shanghai",
  207. {
  208. "title": "Design review",
  209. "start_at": "2026-07-14T09:30:00+08:00",
  210. "timezone": "Asia/Shanghai",
  211. },
  212. ),
  213. (
  214. "标题:项目评审;开始时间:2026-07-14T09:30:00+08:00;"
  215. "时区:Asia/Shanghai",
  216. {
  217. "title": "项目评审",
  218. "start_at": "2026-07-14T09:30:00+08:00",
  219. "timezone": "Asia/Shanghai",
  220. },
  221. ),
  222. ],
  223. )
  224. def test_schedule_resolver_accepts_only_explicit_datetime_details(content, expected):
  225. resolved = _resolve_text_event("calendar.schedule.create", content)
  226. assert resolved == expected
  227. def test_schedule_resolver_does_not_guess_relative_time():
  228. resolved = _resolve_text_event(
  229. "calendar.schedule.create", "Schedule standup tomorrow at nine"
  230. )
  231. assert isinstance(resolved, EventArgumentResolution)
  232. assert resolved.complete is False
  233. def test_web_search_resolver_uses_latest_relevant_user_request():
  234. registry = build_default_tool_registry()
  235. definition = registry.event_registry.definition("knowledge.web.search")
  236. assert definition is not None
  237. assert definition.resolver is not None
  238. resolved = definition.resolver(
  239. EventRequest(id="search-1", name="knowledge.web.search"),
  240. EventExecutionContext(
  241. history=(
  242. ChatMessage(role="user", content="old query"),
  243. ChatMessage(role="assistant", content="assistant summary"),
  244. ChatMessage(role="user", content="latest focused query"),
  245. ChatMessage(role="assistant", content="I will search"),
  246. )
  247. ),
  248. )
  249. assert resolved == {"query": "latest focused query"}
  250. def test_web_search_schema_rejects_empty_query_and_out_of_range_result_limit():
  251. registry = build_default_tool_registry().event_registry
  252. assert registry.iter_validation_errors(
  253. "knowledge.web.search", {"query": "focused", "max_results": 1}
  254. ) == ()
  255. assert registry.iter_validation_errors(
  256. "knowledge.web.search", {"query": "focused", "max_results": 5}
  257. ) == ()
  258. assert registry.iter_validation_errors(
  259. "knowledge.web.search", {"query": ""}
  260. )
  261. assert registry.iter_validation_errors(
  262. "knowledge.web.search", {"query": "focused", "max_results": 0}
  263. )
  264. assert registry.iter_validation_errors(
  265. "knowledge.web.search", {"query": "focused", "max_results": 6}
  266. )
  267. def test_catalog_and_direct_provider_modes_share_the_same_builtin_schemas():
  268. registry = build_default_tool_registry()
  269. catalog = {item["name"]: item for item in registry.available_tools()}
  270. direct = {
  271. item["function"]["name"]: item["function"]
  272. for item in registry.provider_tool_schemas(BUILTIN_EVENT_NAMES)
  273. }
  274. for name in BUILTIN_EVENT_NAMES:
  275. assert direct[name]["description"] == catalog[name]["description"]
  276. assert direct[name]["parameters"] == catalog[name]["parameters"]
  277. class RecordingSessionPort:
  278. def __init__(self):
  279. self.calls = []
  280. def terminate(self, event_id, *, reason=None):
  281. self.calls.append((event_id, reason))
  282. return {"port": "session", "event_id": event_id, "reason": reason}
  283. class RecordingVolumePort:
  284. def __init__(self):
  285. self.calls = []
  286. def adjust(self, event_id, *, mode, value=None, delta=None):
  287. self.calls.append((event_id, mode, value, delta))
  288. return {
  289. "port": "volume",
  290. "event_id": event_id,
  291. "mode": mode,
  292. "value": value,
  293. "delta": delta,
  294. }
  295. class RecordingCalendarPort:
  296. def __init__(self):
  297. self.calls = []
  298. def create(
  299. self,
  300. event_id,
  301. *,
  302. title,
  303. start_at,
  304. timezone,
  305. recurrence=None,
  306. reminder_minutes=None,
  307. ):
  308. self.calls.append(
  309. (
  310. event_id,
  311. title,
  312. start_at,
  313. timezone,
  314. recurrence,
  315. reminder_minutes,
  316. )
  317. )
  318. return {"port": "calendar", "event_id": event_id, "title": title}
  319. class RecordingSearchPort:
  320. def __init__(self):
  321. self.calls = []
  322. def search(self, event_id, *, query, max_results=3):
  323. self.calls.append((event_id, query, max_results))
  324. return {"port": "search", "query": query, "max_results": max_results}
  325. def test_injected_sync_ports_receive_validated_arguments_and_event_ids():
  326. session = RecordingSessionPort()
  327. volume = RecordingVolumePort()
  328. calendar = RecordingCalendarPort()
  329. search = RecordingSearchPort()
  330. registry = build_default_tool_registry(
  331. session_termination_port=session,
  332. device_volume_port=volume,
  333. calendar_schedule_port=calendar,
  334. web_search_port=search,
  335. )
  336. assert registry.execute(
  337. _event("session-1", "session.terminate", {"reason": "done"})
  338. ) == {"port": "session", "event_id": "session-1", "reason": "done"}
  339. assert registry.execute(
  340. _event("volume-1", "device.volume.adjust", {"mode": "absolute", "value": 30})
  341. )["port"] == "volume"
  342. assert registry.execute(
  343. _event(
  344. "calendar-1",
  345. "calendar.schedule.create",
  346. {
  347. "title": "Review",
  348. "start_at": "2026-07-14T09:30:00+08:00",
  349. "timezone": "Asia/Shanghai",
  350. "reminder_minutes": 10,
  351. },
  352. )
  353. )["port"] == "calendar"
  354. assert registry.execute(
  355. _event(
  356. "search-1",
  357. "knowledge.web.search",
  358. {"query": "agent kernels", "max_results": 2},
  359. )
  360. ) == {"port": "search", "query": "agent kernels", "max_results": 2}
  361. assert session.calls == [("session-1", "done")]
  362. assert volume.calls == [("volume-1", "absolute", 30, None)]
  363. assert calendar.calls == [
  364. (
  365. "calendar-1",
  366. "Review",
  367. "2026-07-14T09:30:00+08:00",
  368. "Asia/Shanghai",
  369. None,
  370. 10,
  371. )
  372. ]
  373. assert search.calls == [("search-1", "agent kernels", 2)]
  374. @pytest.mark.asyncio
  375. async def test_injected_async_ports_are_awaited_for_all_builtin_plugins():
  376. class AsyncSessionPort(RecordingSessionPort):
  377. async def terminate(self, event_id, *, reason=None):
  378. return super().terminate(event_id, reason=reason)
  379. class AsyncVolumePort(RecordingVolumePort):
  380. async def adjust(self, event_id, *, mode, value=None, delta=None):
  381. return super().adjust(
  382. event_id, mode=mode, value=value, delta=delta
  383. )
  384. class AsyncCalendarPort(RecordingCalendarPort):
  385. async def create(self, event_id, **arguments):
  386. return super().create(event_id, **arguments)
  387. class AsyncSearchPort(RecordingSearchPort):
  388. async def search(self, event_id, *, query, max_results=3):
  389. return super().search(
  390. event_id, query=query, max_results=max_results
  391. )
  392. registry = build_default_tool_registry(
  393. session_termination_port=AsyncSessionPort(),
  394. device_volume_port=AsyncVolumePort(),
  395. calendar_schedule_port=AsyncCalendarPort(),
  396. web_search_port=AsyncSearchPort(),
  397. )
  398. events = [
  399. _event("session-1", "session.terminate", {}),
  400. _event(
  401. "volume-1",
  402. "device.volume.adjust",
  403. {"mode": "relative", "delta": -10},
  404. ),
  405. _event(
  406. "calendar-1",
  407. "calendar.schedule.create",
  408. {
  409. "title": "Review",
  410. "start_at": "2026-07-14T09:30:00+08:00",
  411. "timezone": "Asia/Shanghai",
  412. },
  413. ),
  414. _event("search-1", "knowledge.web.search", {"query": "agent kernels"}),
  415. ]
  416. payloads = [await registry.execute_async(event) for event in events]
  417. assert [payload["port"] for payload in payloads] == [
  418. "session",
  419. "volume",
  420. "calendar",
  421. "search",
  422. ]
  423. @pytest.mark.parametrize(
  424. "arguments",
  425. [
  426. {"mode": "absolute", "value": 101},
  427. {"mode": "relative", "delta": 0},
  428. {"mode": "mute", "value": 1},
  429. ],
  430. )
  431. def test_invalid_volume_arguments_never_call_the_port(arguments):
  432. volume = RecordingVolumePort()
  433. registry = build_default_tool_registry(device_volume_port=volume)
  434. payload = registry.execute(_event("volume-invalid", "device.volume.adjust", arguments))
  435. assert payload["tool"] == "device.volume.adjust"
  436. assert "invalid" in payload["error"]
  437. assert volume.calls == []
  438. def test_ambiguous_volume_request_without_fallback_never_calls_the_port():
  439. volume = RecordingVolumePort()
  440. registry = build_default_tool_registry(device_volume_port=volume)
  441. payload = registry.handle(
  442. _event("volume-ambiguous", "device.volume.adjust", {}),
  443. EventExecutionContext(
  444. history=(ChatMessage(role="user", content="静音还是取消静音"),)
  445. ),
  446. )
  447. assert payload == {
  448. "tool": "device.volume.adjust",
  449. "error": "missing required arguments: mode",
  450. }
  451. assert volume.calls == []
  452. @pytest.mark.parametrize(
  453. ("port_name", "event"),
  454. [
  455. ("session_termination_port", _event("s", "session.terminate", {})),
  456. (
  457. "device_volume_port",
  458. _event("v", "device.volume.adjust", {"mode": "mute"}),
  459. ),
  460. (
  461. "calendar_schedule_port",
  462. _event(
  463. "c",
  464. "calendar.schedule.create",
  465. {
  466. "title": "Review",
  467. "start_at": "2026-07-14T09:30:00+08:00",
  468. "timezone": "Asia/Shanghai",
  469. },
  470. ),
  471. ),
  472. (
  473. "web_search_port",
  474. _event("w", "knowledge.web.search", {"query": "agent kernels"}),
  475. ),
  476. ],
  477. )
  478. def test_port_failures_are_normalized_by_the_generic_kernel(port_name, event):
  479. class FailingPort:
  480. def terminate(self, *args, **kwargs):
  481. raise RuntimeError("port unavailable")
  482. adjust = terminate
  483. create = terminate
  484. search = terminate
  485. registry = build_default_tool_registry(**{port_name: FailingPort()})
  486. payload = registry.execute(event)
  487. assert payload == {
  488. "tool": event.name,
  489. "error": "tool handler failed: port unavailable",
  490. }
  491. def test_default_stateful_adapters_are_idempotent_by_event_id():
  492. registry = build_default_tool_registry()
  493. first_session = registry.execute(
  494. _event("same-session", "session.terminate", {"reason": "first"})
  495. )
  496. second_session = registry.execute(
  497. _event("same-session", "session.terminate", {"reason": "second"})
  498. )
  499. first_volume = registry.execute(
  500. _event(
  501. "same-volume",
  502. "device.volume.adjust",
  503. {"mode": "absolute", "value": 20},
  504. )
  505. )
  506. second_volume = registry.execute(
  507. _event(
  508. "same-volume",
  509. "device.volume.adjust",
  510. {"mode": "absolute", "value": 80},
  511. )
  512. )
  513. first_schedule = registry.execute(
  514. _event(
  515. "same-schedule",
  516. "calendar.schedule.create",
  517. {
  518. "title": "First",
  519. "start_at": "2026-07-14T09:30:00+08:00",
  520. "timezone": "Asia/Shanghai",
  521. },
  522. )
  523. )
  524. second_schedule = registry.execute(
  525. _event(
  526. "same-schedule",
  527. "calendar.schedule.create",
  528. {
  529. "title": "Second",
  530. "start_at": "2026-07-15T09:30:00+08:00",
  531. "timezone": "Asia/Shanghai",
  532. },
  533. )
  534. )
  535. assert second_session == first_session
  536. assert first_session["reason"] == "first"
  537. assert second_volume == first_volume
  538. assert first_volume["value"] == 20
  539. assert second_schedule == first_schedule
  540. assert first_schedule["schedule"]["title"] == "First"
  541. def test_default_web_search_is_deterministic_compact_and_uses_injected_clock():
  542. registry = build_default_tool_registry(
  543. clock=lambda: datetime(2030, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
  544. )
  545. payload = registry.execute(
  546. _event(
  547. "search-1",
  548. "knowledge.web.search",
  549. {"query": "agent kernels", "max_results": 2},
  550. )
  551. )
  552. assert payload["tool"] == "knowledge.web.search"
  553. assert payload["query"] == "agent kernels"
  554. assert payload["retrieved_at"] == "2030-01-02T03:04:05Z"
  555. assert len(payload["sources"]) == 2
  556. assert all(source["url"].startswith("https://example.invalid/") for source in payload["sources"])
  557. assert all(set(source) == {"title", "url", "snippet"} for source in payload["sources"])
  558. def test_builtin_port_protocols_and_default_adapter_types_are_public():
  559. assert builtin_plugins.SessionTerminationPort
  560. assert builtin_plugins.DeviceVolumePort
  561. assert builtin_plugins.CalendarSchedulePort
  562. assert builtin_plugins.WebSearchPort
  563. assert builtin_plugins.InMemorySessionTerminationAdapter
  564. assert builtin_plugins.InMemoryDeviceVolumeAdapter
  565. assert builtin_plugins.InMemoryCalendarScheduleAdapter
  566. assert builtin_plugins.InMemoryWebSearchAdapter
  567. def _resolve_text_event(name: str, content: str):
  568. registry = build_default_tool_registry()
  569. definition = registry.event_registry.definition(name)
  570. assert definition is not None
  571. assert definition.resolver is not None
  572. return definition.resolver(
  573. EventRequest(id="event-1", name=name),
  574. EventExecutionContext(history=(ChatMessage(role="user", content=content),)),
  575. )