test_builtin_event_plugins.py 31 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027
  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. ("volume to 40.5", {"mode": "absolute", "value": "40.5"}),
  669. ("volume at 1,000", {"mode": "absolute", "value": "1,000"}),
  670. ("volume=40.5", {"mode": "absolute", "value": "40.5"}),
  671. ],
  672. )
  673. async def test_unsupported_volume_numeric_tokens_are_not_truncated_or_fallback(
  674. content,
  675. expected_arguments,
  676. ):
  677. volume = RecordingVolumePort()
  678. registry = build_default_tool_registry(device_volume_port=volume)
  679. fallback_calls = 0
  680. async def fallback(*args):
  681. nonlocal fallback_calls
  682. fallback_calls += 1
  683. raise AssertionError("unsupported numeric tokens must not fallback")
  684. result = await EventKernel(
  685. registry.event_registry,
  686. argument_fallback=fallback,
  687. ).execute(
  688. registry.event_request(_event("invalid-number", "device.volume.adjust", {})),
  689. enabled_names=["device.volume.adjust"],
  690. context=EventExecutionContext(
  691. history=(ChatMessage(role="user", content=content),)
  692. ),
  693. )
  694. assert result.status is EventStatus.INVALID_ARGUMENTS
  695. assert result.arguments == expected_arguments
  696. assert result.used_fallback is False
  697. assert fallback_calls == 0
  698. assert volume.calls == []
  699. @pytest.mark.parametrize("content", ["volume to 40", "volume at 40", "volume=40"])
  700. def test_bare_volume_integer_forms_remain_valid(content):
  701. volume = RecordingVolumePort()
  702. registry = build_default_tool_registry(device_volume_port=volume)
  703. payload = registry.handle(
  704. _event("bare-integer", "device.volume.adjust", {}),
  705. EventExecutionContext(history=(ChatMessage(role="user", content=content),)),
  706. )
  707. assert payload["port"] == "volume"
  708. assert volume.calls == [("bare-integer", "absolute", 40, None)]
  709. @pytest.mark.parametrize("content", ["set volume to 1000", "音量增加 1000"])
  710. def test_volume_resolver_captures_full_oversized_number_and_rejects_it(content):
  711. volume = RecordingVolumePort()
  712. registry = build_default_tool_registry(device_volume_port=volume)
  713. payload = registry.handle(
  714. _event("oversized-volume", "device.volume.adjust", {}),
  715. EventExecutionContext(history=(ChatMessage(role="user", content=content),)),
  716. )
  717. assert payload["tool"] == "device.volume.adjust"
  718. assert "invalid" in payload["error"]
  719. assert volume.calls == []
  720. def test_provider_search_query_is_trimmed_before_port_execution():
  721. search = RecordingSearchPort()
  722. registry = build_default_tool_registry(web_search_port=search)
  723. payload = registry.execute(
  724. _event(
  725. "provider-search",
  726. "knowledge.web.search",
  727. {"query": " focused query ", "max_results": 2},
  728. )
  729. )
  730. assert payload == {"port": "search", "query": "focused query", "max_results": 2}
  731. assert search.calls == [("provider-search", "focused query", 2)]
  732. def test_whitespace_only_provider_search_query_never_calls_the_port():
  733. search = RecordingSearchPort()
  734. registry = build_default_tool_registry(web_search_port=search)
  735. payload = registry.execute(
  736. _event("blank-search", "knowledge.web.search", {"query": " "})
  737. )
  738. assert payload["tool"] == "knowledge.web.search"
  739. assert "invalid" in payload["error"]
  740. assert search.calls == []
  741. def test_schedule_catalog_uses_standard_datetime_and_iana_timezone_formats():
  742. registry = build_default_tool_registry()
  743. schema = registry.event_registry.definition(
  744. "calendar.schedule.create"
  745. ).parameters
  746. assert schema["properties"]["start_at"]["format"] == "date-time"
  747. assert schema["properties"]["timezone"]["format"] == "iana-timezone"
  748. def test_schedule_standard_datetime_accepts_lowercase_tz_and_rejects_bad_date():
  749. registry = build_default_tool_registry().event_registry
  750. base = {
  751. "title": "Review",
  752. "timezone": "Asia/Shanghai",
  753. }
  754. assert registry.iter_validation_errors(
  755. "calendar.schedule.create",
  756. {**base, "start_at": "2026-07-14t09:30:00z"},
  757. ) == ()
  758. assert registry.iter_validation_errors(
  759. "calendar.schedule.create",
  760. {**base, "start_at": "2026-02-31T09:30:00Z"},
  761. )
  762. @pytest.mark.parametrize(
  763. "arguments",
  764. [
  765. {
  766. "title": "Impossible date",
  767. "start_at": "2026-02-30T09:30:00+08:00",
  768. "timezone": "Asia/Shanghai",
  769. },
  770. {
  771. "title": "Unknown zone",
  772. "start_at": "2026-07-14T09:30:00+08:00",
  773. "timezone": "Mars/Olympus",
  774. },
  775. {
  776. "title": "Unsafe zone",
  777. "start_at": "2026-07-14T09:30:00+08:00",
  778. "timezone": "../etc/passwd",
  779. },
  780. {
  781. "title": " ",
  782. "start_at": "2026-07-14T09:30:00+08:00",
  783. "timezone": "Asia/Shanghai",
  784. },
  785. ],
  786. )
  787. def test_invalid_schedule_boundaries_never_call_the_port(arguments):
  788. calendar = RecordingCalendarPort()
  789. registry = build_default_tool_registry(calendar_schedule_port=calendar)
  790. payload = registry.execute(
  791. _event("invalid-schedule", "calendar.schedule.create", arguments)
  792. )
  793. assert payload["tool"] == "calendar.schedule.create"
  794. assert "invalid" in payload["error"]
  795. assert calendar.calls == []
  796. def test_integral_float_numeric_arguments_reach_ports_as_ints():
  797. volume = RecordingVolumePort()
  798. calendar = RecordingCalendarPort()
  799. search = RecordingSearchPort()
  800. registry = build_default_tool_registry(
  801. device_volume_port=volume,
  802. calendar_schedule_port=calendar,
  803. web_search_port=search,
  804. )
  805. registry.execute(
  806. _event(
  807. "absolute-float",
  808. "device.volume.adjust",
  809. {"mode": "absolute", "value": 40.0},
  810. )
  811. )
  812. registry.execute(
  813. _event(
  814. "relative-float",
  815. "device.volume.adjust",
  816. {"mode": "relative", "delta": -10.0},
  817. )
  818. )
  819. registry.execute(
  820. _event(
  821. "schedule-float",
  822. "calendar.schedule.create",
  823. {
  824. "title": "Review",
  825. "start_at": "2026-07-14T09:30:00+08:00",
  826. "timezone": "Asia/Shanghai",
  827. "reminder_minutes": 15.0,
  828. },
  829. )
  830. )
  831. registry.execute(
  832. _event(
  833. "search-float",
  834. "knowledge.web.search",
  835. {"query": "focused", "max_results": 2.0},
  836. )
  837. )
  838. assert volume.calls == [
  839. ("absolute-float", "absolute", 40, None),
  840. ("relative-float", "relative", None, -10),
  841. ]
  842. assert type(volume.calls[0][2]) is int
  843. assert type(volume.calls[1][3]) is int
  844. assert calendar.calls[0][-1] == 15
  845. assert type(calendar.calls[0][-1]) is int
  846. assert search.calls == [("search-float", "focused", 2)]
  847. assert type(search.calls[0][-1]) is int
  848. def test_integral_float_and_int_share_the_same_volume_cache_key():
  849. adapter = builtin_plugins.InMemoryDeviceVolumeAdapter()
  850. registry = build_default_tool_registry(device_volume_port=adapter)
  851. first = registry.execute(
  852. _event(
  853. "same-number",
  854. "device.volume.adjust",
  855. {"mode": "absolute", "value": 40},
  856. )
  857. )
  858. replay = registry.execute(
  859. _event(
  860. "same-number",
  861. "device.volume.adjust",
  862. {"mode": "absolute", "value": 40.0},
  863. )
  864. )
  865. assert replay == first
  866. assert len(adapter._results) == 1
  867. @pytest.mark.parametrize(
  868. "arguments",
  869. [
  870. {"mode": "absolute", "value": True},
  871. {"mode": "relative", "delta": False},
  872. ],
  873. )
  874. def test_volume_normalizer_does_not_treat_bool_as_integral_number(arguments):
  875. volume = RecordingVolumePort()
  876. registry = build_default_tool_registry(device_volume_port=volume)
  877. payload = registry.execute(_event("bool-volume", "device.volume.adjust", arguments))
  878. assert payload["tool"] == "device.volume.adjust"
  879. assert "invalid" in payload["error"]
  880. assert volume.calls == []