test_builtin_event_plugins.py 30 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010
  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. EventKernel,
  9. EventRequest,
  10. EventStatus,
  11. ResolvedEventArguments,
  12. ResultPolicy,
  13. RiskLevel,
  14. )
  15. from agent_lab.application.tools import build_default_tool_registry
  16. from agent_lab.domain.events import ToolCallEvent
  17. from agent_lab.domain.messages import ChatMessage
  18. BUILTIN_EVENT_NAMES = [
  19. "session.terminate",
  20. "device.volume.adjust",
  21. "calendar.schedule.create",
  22. "knowledge.web.search",
  23. ]
  24. def _event(event_id: str, name: str, arguments: dict):
  25. return ToolCallEvent(
  26. id=event_id,
  27. name=name,
  28. arguments=arguments,
  29. raw_arguments=json.dumps(arguments, ensure_ascii=False),
  30. )
  31. def test_default_registry_preserves_legacy_tools_and_adds_builtin_event_plugins():
  32. registry = build_default_tool_registry()
  33. assert [item["name"] for item in registry.available_tools()] == [
  34. "handoff_note",
  35. "mock_search",
  36. "mock_ticket",
  37. *BUILTIN_EVENT_NAMES,
  38. ]
  39. @pytest.mark.parametrize(
  40. (
  41. "name",
  42. "result_policy",
  43. "risk_level",
  44. "fallback_allowed",
  45. "idempotency_key_fields",
  46. "concurrency_class",
  47. "conflict_keys",
  48. "timeout_seconds",
  49. "terminal",
  50. ),
  51. [
  52. (
  53. "session.terminate",
  54. ResultPolicy.TERMINATE,
  55. RiskLevel.HIGH,
  56. False,
  57. ("event_id",),
  58. "session-lifecycle",
  59. ("session",),
  60. 5.0,
  61. True,
  62. ),
  63. (
  64. "device.volume.adjust",
  65. ResultPolicy.SILENT_SUCCESS,
  66. RiskLevel.MEDIUM,
  67. True,
  68. ("event_id",),
  69. "device-volume",
  70. ("device.volume",),
  71. 5.0,
  72. False,
  73. ),
  74. (
  75. "calendar.schedule.create",
  76. ResultPolicy.TEMPLATE_FOLLOW_UP,
  77. RiskLevel.MEDIUM,
  78. True,
  79. ("event_id",),
  80. "schedule-write",
  81. ("calendar.schedule",),
  82. 10.0,
  83. False,
  84. ),
  85. (
  86. "knowledge.web.search",
  87. ResultPolicy.LLM_FOLLOW_UP,
  88. RiskLevel.LOW,
  89. False,
  90. (),
  91. "read-only",
  92. (),
  93. 10.0,
  94. False,
  95. ),
  96. ],
  97. )
  98. def test_builtin_definition_metadata_is_owned_by_each_flat_plugin(
  99. name,
  100. result_policy,
  101. risk_level,
  102. fallback_allowed,
  103. idempotency_key_fields,
  104. concurrency_class,
  105. conflict_keys,
  106. timeout_seconds,
  107. terminal,
  108. ):
  109. definition = build_default_tool_registry().event_registry.definition(name)
  110. assert definition is not None
  111. assert definition.result_policy is result_policy
  112. assert definition.risk_level is risk_level
  113. assert definition.fallback_allowed is fallback_allowed
  114. assert definition.idempotency_key_fields == idempotency_key_fields
  115. assert definition.concurrency_class == concurrency_class
  116. assert definition.conflict_keys == conflict_keys
  117. assert definition.timeout_seconds == timeout_seconds
  118. assert definition.terminal is terminal
  119. def test_session_terminate_schema_accepts_optional_reason_only():
  120. registry = build_default_tool_registry().event_registry
  121. assert registry.iter_validation_errors("session.terminate", {}) == ()
  122. assert registry.iter_validation_errors(
  123. "session.terminate", {"reason": "user requested"}
  124. ) == ()
  125. assert registry.iter_validation_errors(
  126. "session.terminate", {"reason": 1}
  127. )
  128. assert registry.iter_validation_errors(
  129. "session.terminate", {"unexpected": True}
  130. )
  131. @pytest.mark.parametrize(
  132. "arguments",
  133. [
  134. {"mode": "absolute", "value": 0},
  135. {"mode": "absolute", "value": 100},
  136. {"mode": "relative", "delta": -100},
  137. {"mode": "relative", "delta": 100},
  138. {"mode": "mute"},
  139. {"mode": "unmute"},
  140. ],
  141. )
  142. def test_volume_schema_accepts_mode_specific_valid_arguments(arguments):
  143. registry = build_default_tool_registry().event_registry
  144. assert registry.iter_validation_errors("device.volume.adjust", arguments) == ()
  145. @pytest.mark.parametrize(
  146. "arguments",
  147. [
  148. {"mode": "absolute"},
  149. {"mode": "absolute", "value": -1},
  150. {"mode": "absolute", "value": 101},
  151. {"mode": "absolute", "value": 20, "delta": 5},
  152. {"mode": "relative"},
  153. {"mode": "relative", "delta": 0},
  154. {"mode": "relative", "delta": 101},
  155. {"mode": "relative", "delta": 5, "value": 20},
  156. {"mode": "mute", "value": 0},
  157. {"mode": "unmute", "delta": 1},
  158. ],
  159. )
  160. def test_volume_schema_rejects_invalid_or_cross_mode_arguments(arguments):
  161. registry = build_default_tool_registry().event_registry
  162. assert registry.iter_validation_errors("device.volume.adjust", arguments)
  163. @pytest.mark.parametrize(
  164. ("content", "expected"),
  165. [
  166. ("把音量调到 35", {"mode": "absolute", "value": 35}),
  167. ("set volume to 42", {"mode": "absolute", "value": 42}),
  168. ("音量增加 15", {"mode": "relative", "delta": 15}),
  169. ("decrease volume by 20", {"mode": "relative", "delta": -20}),
  170. ("请静音", {"mode": "mute"}),
  171. ("please unmute", {"mode": "unmute"}),
  172. ],
  173. )
  174. def test_volume_resolver_recognizes_clear_chinese_and_english(content, expected):
  175. resolved = _resolve_text_event("device.volume.adjust", content)
  176. assert resolved == expected
  177. @pytest.mark.parametrize("content", ["把音量调高", "静音还是取消静音"])
  178. def test_volume_resolver_marks_incomplete_or_ambiguous_requests_for_fallback(content):
  179. resolved = _resolve_text_event("device.volume.adjust", content)
  180. assert isinstance(resolved, EventArgumentResolution)
  181. assert resolved.complete is False
  182. def test_schedule_schema_requires_explicit_rfc3339_and_timezone():
  183. registry = build_default_tool_registry().event_registry
  184. valid = {
  185. "title": "Design review",
  186. "start_at": "2026-07-14T09:30:00+08:00",
  187. "timezone": "Asia/Shanghai",
  188. "recurrence": "FREQ=WEEKLY",
  189. "reminder_minutes": 15,
  190. }
  191. assert registry.iter_validation_errors("calendar.schedule.create", valid) == ()
  192. assert registry.iter_validation_errors(
  193. "calendar.schedule.create",
  194. {**valid, "start_at": "tomorrow at nine"},
  195. )
  196. assert registry.iter_validation_errors(
  197. "calendar.schedule.create",
  198. {**valid, "timezone": "Shanghai"},
  199. )
  200. assert registry.iter_validation_errors(
  201. "calendar.schedule.create",
  202. {**valid, "reminder_minutes": -1},
  203. )
  204. @pytest.mark.parametrize(
  205. ("content", "expected"),
  206. [
  207. (
  208. 'Schedule "Design review" at 2026-07-14T09:30:00+08:00 '
  209. "timezone Asia/Shanghai",
  210. {
  211. "title": "Design review",
  212. "start_at": "2026-07-14T09:30:00+08:00",
  213. "timezone": "Asia/Shanghai",
  214. },
  215. ),
  216. (
  217. "标题:项目评审;开始时间:2026-07-14T09:30:00+08:00;"
  218. "时区:Asia/Shanghai",
  219. {
  220. "title": "项目评审",
  221. "start_at": "2026-07-14T09:30:00+08:00",
  222. "timezone": "Asia/Shanghai",
  223. },
  224. ),
  225. ],
  226. )
  227. def test_schedule_resolver_accepts_only_explicit_datetime_details(content, expected):
  228. resolved = _resolve_text_event("calendar.schedule.create", content)
  229. assert resolved == expected
  230. def test_schedule_resolver_does_not_guess_relative_time():
  231. resolved = _resolve_text_event(
  232. "calendar.schedule.create", "Schedule standup tomorrow at nine"
  233. )
  234. assert isinstance(resolved, EventArgumentResolution)
  235. assert resolved.complete is False
  236. def test_web_search_resolver_uses_latest_relevant_user_request():
  237. registry = build_default_tool_registry()
  238. definition = registry.event_registry.definition("knowledge.web.search")
  239. assert definition is not None
  240. assert definition.resolver is not None
  241. resolved = definition.resolver(
  242. EventRequest(id="search-1", name="knowledge.web.search"),
  243. EventExecutionContext(
  244. history=(
  245. ChatMessage(role="user", content="old query"),
  246. ChatMessage(role="assistant", content="assistant summary"),
  247. ChatMessage(role="user", content="latest focused query"),
  248. ChatMessage(role="assistant", content="I will search"),
  249. )
  250. ),
  251. )
  252. assert resolved == {"query": "latest focused query"}
  253. def test_web_search_schema_rejects_empty_query_and_out_of_range_result_limit():
  254. registry = build_default_tool_registry().event_registry
  255. assert registry.iter_validation_errors(
  256. "knowledge.web.search", {"query": "focused", "max_results": 1}
  257. ) == ()
  258. assert registry.iter_validation_errors(
  259. "knowledge.web.search", {"query": "focused", "max_results": 5}
  260. ) == ()
  261. assert registry.iter_validation_errors(
  262. "knowledge.web.search", {"query": ""}
  263. )
  264. assert registry.iter_validation_errors(
  265. "knowledge.web.search", {"query": "focused", "max_results": 0}
  266. )
  267. assert registry.iter_validation_errors(
  268. "knowledge.web.search", {"query": "focused", "max_results": 6}
  269. )
  270. def test_catalog_and_direct_provider_modes_share_the_same_builtin_schemas():
  271. registry = build_default_tool_registry()
  272. catalog = {item["name"]: item for item in registry.available_tools()}
  273. direct = {
  274. item["function"]["name"]: item["function"]
  275. for item in registry.provider_tool_schemas(BUILTIN_EVENT_NAMES)
  276. }
  277. for name in BUILTIN_EVENT_NAMES:
  278. assert direct[name]["description"] == catalog[name]["description"]
  279. assert direct[name]["parameters"] == catalog[name]["parameters"]
  280. class RecordingSessionPort:
  281. def __init__(self):
  282. self.calls = []
  283. def terminate(self, event_id, *, reason=None):
  284. self.calls.append((event_id, reason))
  285. return {"port": "session", "event_id": event_id, "reason": reason}
  286. class RecordingVolumePort:
  287. def __init__(self):
  288. self.calls = []
  289. def adjust(self, event_id, *, mode, value=None, delta=None):
  290. self.calls.append((event_id, mode, value, delta))
  291. return {
  292. "port": "volume",
  293. "event_id": event_id,
  294. "mode": mode,
  295. "value": value,
  296. "delta": delta,
  297. }
  298. class RecordingCalendarPort:
  299. def __init__(self):
  300. self.calls = []
  301. def create(
  302. self,
  303. event_id,
  304. *,
  305. title,
  306. start_at,
  307. timezone,
  308. recurrence=None,
  309. reminder_minutes=None,
  310. ):
  311. self.calls.append(
  312. (
  313. event_id,
  314. title,
  315. start_at,
  316. timezone,
  317. recurrence,
  318. reminder_minutes,
  319. )
  320. )
  321. return {"port": "calendar", "event_id": event_id, "title": title}
  322. class RecordingSearchPort:
  323. def __init__(self):
  324. self.calls = []
  325. def search(self, event_id, *, query, max_results=3):
  326. self.calls.append((event_id, query, max_results))
  327. return {"port": "search", "query": query, "max_results": max_results}
  328. def test_injected_sync_ports_receive_validated_arguments_and_event_ids():
  329. session = RecordingSessionPort()
  330. volume = RecordingVolumePort()
  331. calendar = RecordingCalendarPort()
  332. search = RecordingSearchPort()
  333. registry = build_default_tool_registry(
  334. session_termination_port=session,
  335. device_volume_port=volume,
  336. calendar_schedule_port=calendar,
  337. web_search_port=search,
  338. )
  339. assert registry.execute(
  340. _event("session-1", "session.terminate", {"reason": "done"})
  341. ) == {"port": "session", "event_id": "session-1", "reason": "done"}
  342. assert registry.execute(
  343. _event("volume-1", "device.volume.adjust", {"mode": "absolute", "value": 30})
  344. )["port"] == "volume"
  345. assert registry.execute(
  346. _event(
  347. "calendar-1",
  348. "calendar.schedule.create",
  349. {
  350. "title": "Review",
  351. "start_at": "2026-07-14T09:30:00+08:00",
  352. "timezone": "Asia/Shanghai",
  353. "reminder_minutes": 10,
  354. },
  355. )
  356. )["port"] == "calendar"
  357. assert registry.execute(
  358. _event(
  359. "search-1",
  360. "knowledge.web.search",
  361. {"query": "agent kernels", "max_results": 2},
  362. )
  363. ) == {"port": "search", "query": "agent kernels", "max_results": 2}
  364. assert session.calls == [("session-1", "done")]
  365. assert volume.calls == [("volume-1", "absolute", 30, None)]
  366. assert calendar.calls == [
  367. (
  368. "calendar-1",
  369. "Review",
  370. "2026-07-14T09:30:00+08:00",
  371. "Asia/Shanghai",
  372. None,
  373. 10,
  374. )
  375. ]
  376. assert search.calls == [("search-1", "agent kernels", 2)]
  377. @pytest.mark.asyncio
  378. async def test_injected_async_ports_are_awaited_for_all_builtin_plugins():
  379. class AsyncSessionPort(RecordingSessionPort):
  380. async def terminate(self, event_id, *, reason=None):
  381. return super().terminate(event_id, reason=reason)
  382. class AsyncVolumePort(RecordingVolumePort):
  383. async def adjust(self, event_id, *, mode, value=None, delta=None):
  384. return super().adjust(
  385. event_id, mode=mode, value=value, delta=delta
  386. )
  387. class AsyncCalendarPort(RecordingCalendarPort):
  388. async def create(self, event_id, **arguments):
  389. return super().create(event_id, **arguments)
  390. class AsyncSearchPort(RecordingSearchPort):
  391. async def search(self, event_id, *, query, max_results=3):
  392. return super().search(
  393. event_id, query=query, max_results=max_results
  394. )
  395. registry = build_default_tool_registry(
  396. session_termination_port=AsyncSessionPort(),
  397. device_volume_port=AsyncVolumePort(),
  398. calendar_schedule_port=AsyncCalendarPort(),
  399. web_search_port=AsyncSearchPort(),
  400. )
  401. events = [
  402. _event("session-1", "session.terminate", {}),
  403. _event(
  404. "volume-1",
  405. "device.volume.adjust",
  406. {"mode": "relative", "delta": -10},
  407. ),
  408. _event(
  409. "calendar-1",
  410. "calendar.schedule.create",
  411. {
  412. "title": "Review",
  413. "start_at": "2026-07-14T09:30:00+08:00",
  414. "timezone": "Asia/Shanghai",
  415. },
  416. ),
  417. _event("search-1", "knowledge.web.search", {"query": "agent kernels"}),
  418. ]
  419. payloads = [await registry.execute_async(event) for event in events]
  420. assert [payload["port"] for payload in payloads] == [
  421. "session",
  422. "volume",
  423. "calendar",
  424. "search",
  425. ]
  426. @pytest.mark.parametrize(
  427. "arguments",
  428. [
  429. {"mode": "absolute", "value": 101},
  430. {"mode": "relative", "delta": 0},
  431. {"mode": "mute", "value": 1},
  432. ],
  433. )
  434. def test_invalid_volume_arguments_never_call_the_port(arguments):
  435. volume = RecordingVolumePort()
  436. registry = build_default_tool_registry(device_volume_port=volume)
  437. payload = registry.execute(_event("volume-invalid", "device.volume.adjust", arguments))
  438. assert payload["tool"] == "device.volume.adjust"
  439. assert "invalid" in payload["error"]
  440. assert volume.calls == []
  441. def test_ambiguous_volume_request_without_fallback_never_calls_the_port():
  442. volume = RecordingVolumePort()
  443. registry = build_default_tool_registry(device_volume_port=volume)
  444. payload = registry.handle(
  445. _event("volume-ambiguous", "device.volume.adjust", {}),
  446. EventExecutionContext(
  447. history=(ChatMessage(role="user", content="静音还是取消静音"),)
  448. ),
  449. )
  450. assert payload == {
  451. "tool": "device.volume.adjust",
  452. "error": "missing required arguments: mode",
  453. }
  454. assert volume.calls == []
  455. @pytest.mark.parametrize(
  456. ("port_name", "event"),
  457. [
  458. ("session_termination_port", _event("s", "session.terminate", {})),
  459. (
  460. "device_volume_port",
  461. _event("v", "device.volume.adjust", {"mode": "mute"}),
  462. ),
  463. (
  464. "calendar_schedule_port",
  465. _event(
  466. "c",
  467. "calendar.schedule.create",
  468. {
  469. "title": "Review",
  470. "start_at": "2026-07-14T09:30:00+08:00",
  471. "timezone": "Asia/Shanghai",
  472. },
  473. ),
  474. ),
  475. (
  476. "web_search_port",
  477. _event("w", "knowledge.web.search", {"query": "agent kernels"}),
  478. ),
  479. ],
  480. )
  481. def test_port_failures_are_normalized_by_the_generic_kernel(port_name, event):
  482. class FailingPort:
  483. def terminate(self, *args, **kwargs):
  484. raise RuntimeError("port unavailable")
  485. adjust = terminate
  486. create = terminate
  487. search = terminate
  488. registry = build_default_tool_registry(**{port_name: FailingPort()})
  489. payload = registry.execute(event)
  490. assert payload == {
  491. "tool": event.name,
  492. "error": "tool handler failed: port unavailable",
  493. }
  494. def test_default_stateful_adapters_cache_by_event_id_and_canonical_arguments():
  495. registry = build_default_tool_registry()
  496. first_session = registry.execute(
  497. _event("same-session", "session.terminate", {"reason": "first"})
  498. )
  499. replayed_session = registry.execute(
  500. _event("same-session", "session.terminate", {"reason": "first"})
  501. )
  502. changed_session = registry.execute(
  503. _event("same-session", "session.terminate", {"reason": "second"})
  504. )
  505. first_volume = registry.execute(
  506. _event(
  507. "same-volume",
  508. "device.volume.adjust",
  509. {"mode": " ReLaTiVe ", "delta": 20},
  510. )
  511. )
  512. replayed_volume = registry.execute(
  513. _event(
  514. "same-volume",
  515. "device.volume.adjust",
  516. {"mode": "relative", "delta": 20},
  517. )
  518. )
  519. changed_volume = registry.execute(
  520. _event(
  521. "same-volume",
  522. "device.volume.adjust",
  523. {"mode": "relative", "delta": -10},
  524. )
  525. )
  526. first_schedule = registry.execute(
  527. _event(
  528. "same-schedule",
  529. "calendar.schedule.create",
  530. {
  531. "title": "First",
  532. "start_at": "2026-07-14T09:30:00+08:00",
  533. "timezone": "Asia/Shanghai",
  534. },
  535. )
  536. )
  537. replayed_schedule = registry.execute(
  538. _event(
  539. "same-schedule",
  540. "calendar.schedule.create",
  541. {
  542. "title": "First",
  543. "start_at": "2026-07-14T09:30:00+08:00",
  544. "timezone": "Asia/Shanghai",
  545. },
  546. )
  547. )
  548. changed_schedule = registry.execute(
  549. _event(
  550. "same-schedule",
  551. "calendar.schedule.create",
  552. {
  553. "title": "Second",
  554. "start_at": "2026-07-15T09:30:00+08:00",
  555. "timezone": "Asia/Shanghai",
  556. },
  557. )
  558. )
  559. assert replayed_session == first_session
  560. assert first_session["reason"] == "first"
  561. assert changed_session["reason"] == "second"
  562. assert replayed_volume == first_volume
  563. assert first_volume["delta"] == 20
  564. assert changed_volume["delta"] == -10
  565. assert replayed_schedule == first_schedule
  566. assert first_schedule["schedule"]["title"] == "First"
  567. assert changed_schedule["schedule"]["title"] == "Second"
  568. def test_default_web_search_is_deterministic_compact_and_uses_injected_clock():
  569. registry = build_default_tool_registry(
  570. clock=lambda: datetime(2030, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
  571. )
  572. payload = registry.execute(
  573. _event(
  574. "search-1",
  575. "knowledge.web.search",
  576. {"query": "agent kernels", "max_results": 2},
  577. )
  578. )
  579. assert payload["tool"] == "knowledge.web.search"
  580. assert payload["query"] == "agent kernels"
  581. assert payload["retrieved_at"] == "2030-01-02T03:04:05Z"
  582. assert len(payload["sources"]) == 2
  583. assert all(source["url"].startswith("https://example.invalid/") for source in payload["sources"])
  584. assert all(set(source) == {"title", "url", "snippet"} for source in payload["sources"])
  585. def test_builtin_port_protocols_and_default_adapter_types_are_public():
  586. assert builtin_plugins.SessionTerminationPort
  587. assert builtin_plugins.DeviceVolumePort
  588. assert builtin_plugins.CalendarSchedulePort
  589. assert builtin_plugins.WebSearchPort
  590. assert builtin_plugins.InMemorySessionTerminationAdapter
  591. assert builtin_plugins.InMemoryDeviceVolumeAdapter
  592. assert builtin_plugins.InMemoryCalendarScheduleAdapter
  593. assert builtin_plugins.InMemoryWebSearchAdapter
  594. def _resolve_text_event(name: str, content: str):
  595. registry = build_default_tool_registry()
  596. definition = registry.event_registry.definition(name)
  597. assert definition is not None
  598. assert definition.resolver is not None
  599. return definition.resolver(
  600. EventRequest(id="event-1", name=name),
  601. EventExecutionContext(history=(ChatMessage(role="user", content=content),)),
  602. )
  603. def test_volume_structured_arguments_are_canonical_in_text_and_provider_modes():
  604. volume = RecordingVolumePort()
  605. registry = build_default_tool_registry(device_volume_port=volume)
  606. arguments = {"mode": " AbSoLuTe ", "value": 40}
  607. text_payload = registry.handle(
  608. _event("text-volume", "device.volume.adjust", arguments)
  609. )
  610. provider_payload = registry.execute(
  611. _event("provider-volume", "device.volume.adjust", arguments)
  612. )
  613. assert text_payload["port"] == "volume"
  614. assert provider_payload["port"] == "volume"
  615. assert volume.calls == [
  616. ("text-volume", "absolute", 40, None),
  617. ("provider-volume", "absolute", 40, None),
  618. ]
  619. @pytest.mark.asyncio
  620. @pytest.mark.parametrize(
  621. "content",
  622. [
  623. "不静音",
  624. "不用静音",
  625. "无需静音",
  626. "no mute",
  627. "don't mute",
  628. "do not mute",
  629. ],
  630. )
  631. async def test_negated_volume_requests_are_substantive_invalid_without_fallback(
  632. content,
  633. ):
  634. volume = RecordingVolumePort()
  635. registry = build_default_tool_registry(device_volume_port=volume)
  636. fallback_calls = 0
  637. async def fallback(*args):
  638. nonlocal fallback_calls
  639. fallback_calls += 1
  640. return ResolvedEventArguments(
  641. event_name="device.volume.adjust",
  642. arguments={"mode": "mute"},
  643. raw_arguments='{"mode":"mute"}',
  644. )
  645. result = await EventKernel(
  646. registry.event_registry,
  647. argument_fallback=fallback,
  648. ).execute(
  649. registry.event_request(_event("negated-volume", "device.volume.adjust", {})),
  650. enabled_names=["device.volume.adjust"],
  651. context=EventExecutionContext(
  652. history=(ChatMessage(role="user", content=content),)
  653. ),
  654. )
  655. assert result.status is EventStatus.INVALID_ARGUMENTS
  656. assert result.arguments == {"mode": "unsupported"}
  657. assert result.used_fallback is False
  658. assert fallback_calls == 0
  659. assert volume.calls == []
  660. @pytest.mark.asyncio
  661. @pytest.mark.parametrize(
  662. ("content", "expected_arguments"),
  663. [
  664. ("set volume to 40.5", {"mode": "absolute", "value": "40.5"}),
  665. ("increase volume by 10.5", {"mode": "relative", "delta": "10.5"}),
  666. ("set volume to 1,000", {"mode": "absolute", "value": "1,000"}),
  667. ("音量增加 1,000", {"mode": "relative", "delta": "1,000"}),
  668. ],
  669. )
  670. async def test_unsupported_volume_numeric_tokens_are_not_truncated_or_fallback(
  671. content,
  672. expected_arguments,
  673. ):
  674. volume = RecordingVolumePort()
  675. registry = build_default_tool_registry(device_volume_port=volume)
  676. fallback_calls = 0
  677. async def fallback(*args):
  678. nonlocal fallback_calls
  679. fallback_calls += 1
  680. raise AssertionError("unsupported numeric tokens must not fallback")
  681. result = await EventKernel(
  682. registry.event_registry,
  683. argument_fallback=fallback,
  684. ).execute(
  685. registry.event_request(_event("invalid-number", "device.volume.adjust", {})),
  686. enabled_names=["device.volume.adjust"],
  687. context=EventExecutionContext(
  688. history=(ChatMessage(role="user", content=content),)
  689. ),
  690. )
  691. assert result.status is EventStatus.INVALID_ARGUMENTS
  692. assert result.arguments == expected_arguments
  693. assert result.used_fallback is False
  694. assert fallback_calls == 0
  695. assert volume.calls == []
  696. @pytest.mark.parametrize("content", ["set volume to 1000", "音量增加 1000"])
  697. def test_volume_resolver_captures_full_oversized_number_and_rejects_it(content):
  698. volume = RecordingVolumePort()
  699. registry = build_default_tool_registry(device_volume_port=volume)
  700. payload = registry.handle(
  701. _event("oversized-volume", "device.volume.adjust", {}),
  702. EventExecutionContext(history=(ChatMessage(role="user", content=content),)),
  703. )
  704. assert payload["tool"] == "device.volume.adjust"
  705. assert "invalid" in payload["error"]
  706. assert volume.calls == []
  707. def test_provider_search_query_is_trimmed_before_port_execution():
  708. search = RecordingSearchPort()
  709. registry = build_default_tool_registry(web_search_port=search)
  710. payload = registry.execute(
  711. _event(
  712. "provider-search",
  713. "knowledge.web.search",
  714. {"query": " focused query ", "max_results": 2},
  715. )
  716. )
  717. assert payload == {"port": "search", "query": "focused query", "max_results": 2}
  718. assert search.calls == [("provider-search", "focused query", 2)]
  719. def test_whitespace_only_provider_search_query_never_calls_the_port():
  720. search = RecordingSearchPort()
  721. registry = build_default_tool_registry(web_search_port=search)
  722. payload = registry.execute(
  723. _event("blank-search", "knowledge.web.search", {"query": " "})
  724. )
  725. assert payload["tool"] == "knowledge.web.search"
  726. assert "invalid" in payload["error"]
  727. assert search.calls == []
  728. def test_schedule_catalog_uses_standard_datetime_and_iana_timezone_formats():
  729. registry = build_default_tool_registry()
  730. schema = registry.event_registry.definition(
  731. "calendar.schedule.create"
  732. ).parameters
  733. assert schema["properties"]["start_at"]["format"] == "date-time"
  734. assert schema["properties"]["timezone"]["format"] == "iana-timezone"
  735. def test_schedule_standard_datetime_accepts_lowercase_tz_and_rejects_bad_date():
  736. registry = build_default_tool_registry().event_registry
  737. base = {
  738. "title": "Review",
  739. "timezone": "Asia/Shanghai",
  740. }
  741. assert registry.iter_validation_errors(
  742. "calendar.schedule.create",
  743. {**base, "start_at": "2026-07-14t09:30:00z"},
  744. ) == ()
  745. assert registry.iter_validation_errors(
  746. "calendar.schedule.create",
  747. {**base, "start_at": "2026-02-31T09:30:00Z"},
  748. )
  749. @pytest.mark.parametrize(
  750. "arguments",
  751. [
  752. {
  753. "title": "Impossible date",
  754. "start_at": "2026-02-30T09:30:00+08:00",
  755. "timezone": "Asia/Shanghai",
  756. },
  757. {
  758. "title": "Unknown zone",
  759. "start_at": "2026-07-14T09:30:00+08:00",
  760. "timezone": "Mars/Olympus",
  761. },
  762. {
  763. "title": "Unsafe zone",
  764. "start_at": "2026-07-14T09:30:00+08:00",
  765. "timezone": "../etc/passwd",
  766. },
  767. {
  768. "title": " ",
  769. "start_at": "2026-07-14T09:30:00+08:00",
  770. "timezone": "Asia/Shanghai",
  771. },
  772. ],
  773. )
  774. def test_invalid_schedule_boundaries_never_call_the_port(arguments):
  775. calendar = RecordingCalendarPort()
  776. registry = build_default_tool_registry(calendar_schedule_port=calendar)
  777. payload = registry.execute(
  778. _event("invalid-schedule", "calendar.schedule.create", arguments)
  779. )
  780. assert payload["tool"] == "calendar.schedule.create"
  781. assert "invalid" in payload["error"]
  782. assert calendar.calls == []
  783. def test_integral_float_numeric_arguments_reach_ports_as_ints():
  784. volume = RecordingVolumePort()
  785. calendar = RecordingCalendarPort()
  786. search = RecordingSearchPort()
  787. registry = build_default_tool_registry(
  788. device_volume_port=volume,
  789. calendar_schedule_port=calendar,
  790. web_search_port=search,
  791. )
  792. registry.execute(
  793. _event(
  794. "absolute-float",
  795. "device.volume.adjust",
  796. {"mode": "absolute", "value": 40.0},
  797. )
  798. )
  799. registry.execute(
  800. _event(
  801. "relative-float",
  802. "device.volume.adjust",
  803. {"mode": "relative", "delta": -10.0},
  804. )
  805. )
  806. registry.execute(
  807. _event(
  808. "schedule-float",
  809. "calendar.schedule.create",
  810. {
  811. "title": "Review",
  812. "start_at": "2026-07-14T09:30:00+08:00",
  813. "timezone": "Asia/Shanghai",
  814. "reminder_minutes": 15.0,
  815. },
  816. )
  817. )
  818. registry.execute(
  819. _event(
  820. "search-float",
  821. "knowledge.web.search",
  822. {"query": "focused", "max_results": 2.0},
  823. )
  824. )
  825. assert volume.calls == [
  826. ("absolute-float", "absolute", 40, None),
  827. ("relative-float", "relative", None, -10),
  828. ]
  829. assert type(volume.calls[0][2]) is int
  830. assert type(volume.calls[1][3]) is int
  831. assert calendar.calls[0][-1] == 15
  832. assert type(calendar.calls[0][-1]) is int
  833. assert search.calls == [("search-float", "focused", 2)]
  834. assert type(search.calls[0][-1]) is int
  835. def test_integral_float_and_int_share_the_same_volume_cache_key():
  836. adapter = builtin_plugins.InMemoryDeviceVolumeAdapter()
  837. registry = build_default_tool_registry(device_volume_port=adapter)
  838. first = registry.execute(
  839. _event(
  840. "same-number",
  841. "device.volume.adjust",
  842. {"mode": "absolute", "value": 40},
  843. )
  844. )
  845. replay = registry.execute(
  846. _event(
  847. "same-number",
  848. "device.volume.adjust",
  849. {"mode": "absolute", "value": 40.0},
  850. )
  851. )
  852. assert replay == first
  853. assert len(adapter._results) == 1
  854. @pytest.mark.parametrize(
  855. "arguments",
  856. [
  857. {"mode": "absolute", "value": True},
  858. {"mode": "relative", "delta": False},
  859. ],
  860. )
  861. def test_volume_normalizer_does_not_treat_bool_as_integral_number(arguments):
  862. volume = RecordingVolumePort()
  863. registry = build_default_tool_registry(device_volume_port=volume)
  864. payload = registry.execute(_event("bool-volume", "device.volume.adjust", arguments))
  865. assert payload["tool"] == "device.volume.adjust"
  866. assert "invalid" in payload["error"]
  867. assert volume.calls == []