test_event_kernel.py 31 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031
  1. from __future__ import annotations
  2. import gc
  3. import json
  4. import warnings
  5. from typing import Any
  6. import pytest
  7. from agent_lab.application.events import (
  8. ConfirmationPolicy,
  9. EventDefinition,
  10. EventExecutionContext,
  11. EventKernel,
  12. EventRegistry,
  13. EventRequest,
  14. ResolvedEventArguments,
  15. EventSource,
  16. EventStatus,
  17. ResultPolicy,
  18. RiskLevel,
  19. )
  20. from agent_lab.application.events.models import EventArgumentResolution
  21. from agent_lab.application.tools import (
  22. ToolDefinition,
  23. ToolExecutionContext,
  24. ToolRegistry,
  25. )
  26. from agent_lab.domain.events import ToolCallEvent
  27. from agent_lab.domain.messages import ChatMessage
  28. def _definition(
  29. name: str = "example.lookup",
  30. **overrides: Any,
  31. ) -> EventDefinition:
  32. values: dict[str, Any] = {
  33. "name": name,
  34. "description": "Look up an example value.",
  35. "parameters": {
  36. "type": "object",
  37. "properties": {"query": {"type": "string"}},
  38. "required": ["query"],
  39. },
  40. "handler": lambda request: {
  41. "event": request.name,
  42. "query": request.arguments["query"],
  43. },
  44. }
  45. values.update(overrides)
  46. return EventDefinition(**values)
  47. def test_registry_registers_flat_definitions_and_filters_enabled_catalog():
  48. registry = EventRegistry(
  49. [_definition("example.lookup"), _definition("device.inspect")]
  50. )
  51. assert [item["name"] for item in registry.catalog()] == [
  52. "example.lookup",
  53. "device.inspect",
  54. ]
  55. assert registry.catalog(["device.inspect"]) == [
  56. {
  57. "name": "device.inspect",
  58. "description": "Look up an example value.",
  59. "parameters": {
  60. "type": "object",
  61. "properties": {"query": {"type": "string"}},
  62. "required": ["query"],
  63. },
  64. }
  65. ]
  66. assert registry.tool_schema("example.lookup")["function"]["name"] == (
  67. "example.lookup"
  68. )
  69. assert registry.tool_schema("missing") is None
  70. def test_registry_rejects_duplicate_definition_names():
  71. with pytest.raises(ValueError, match="duplicate event definition: example.lookup"):
  72. EventRegistry([_definition(), _definition()])
  73. def test_registry_rejects_invalid_draft_2020_12_schema():
  74. definition = _definition(parameters={"type": 42})
  75. with pytest.raises(ValueError, match="invalid event schema for example.lookup"):
  76. EventRegistry([definition])
  77. def test_tool_registry_public_api_remains_compatible():
  78. registry = ToolRegistry(
  79. [
  80. ToolDefinition(
  81. name="compat.lookup",
  82. description="Look up compatibility data.",
  83. parameters={
  84. "type": "object",
  85. "properties": {"query": {"type": "string"}},
  86. "required": ["query"],
  87. },
  88. handler=lambda event: {
  89. "tool": event.name,
  90. "query": event.arguments["query"],
  91. },
  92. argument_resolver=lambda event, context: {
  93. "query": context.history[-1].content
  94. },
  95. )
  96. ]
  97. )
  98. event = ToolCallEvent(
  99. id="call-1",
  100. name="compat.lookup",
  101. arguments={"query": "provider"},
  102. raw_arguments='{"query":"provider"}',
  103. )
  104. assert registry.available_tools() == [
  105. {
  106. "name": "compat.lookup",
  107. "description": "Look up compatibility data.",
  108. "parameters": {
  109. "type": "object",
  110. "properties": {"query": {"type": "string"}},
  111. "required": ["query"],
  112. },
  113. }
  114. ]
  115. assert "- compat.lookup: Look up compatibility data." in (
  116. registry.chat_event_system_message(["compat.lookup"])
  117. )
  118. assert registry.tool_schema("compat.lookup")["function"]["name"] == (
  119. "compat.lookup"
  120. )
  121. assert registry.handle(
  122. event,
  123. ToolExecutionContext(history=[ChatMessage(role="user", content="history")]),
  124. ) == {"tool": "compat.lookup", "query": "history"}
  125. assert registry.execute(event) == {"tool": "compat.lookup", "query": "provider"}
  126. @pytest.mark.asyncio
  127. async def test_kernel_executes_complete_deterministic_arguments_without_fallback():
  128. fallback_calls: list[str] = []
  129. async def fallback(*args: Any) -> dict[str, Any]:
  130. fallback_calls.append("called")
  131. return {"query": "fallback"}
  132. registry = EventRegistry(
  133. [_definition(resolver=lambda request, context: {"query": "deterministic"})]
  134. )
  135. result = await EventKernel(registry, argument_fallback=fallback).execute(
  136. EventRequest(id="event-1", name="example.lookup"),
  137. enabled_names=["example.lookup"],
  138. )
  139. assert result.status is EventStatus.SUCCESS
  140. assert result.arguments == {"query": "deterministic"}
  141. assert result.payload == {"event": "example.lookup", "query": "deterministic"}
  142. assert result.used_fallback is False
  143. assert fallback_calls == []
  144. @pytest.mark.asyncio
  145. async def test_kernel_calls_fallback_once_when_required_arguments_are_incomplete():
  146. fallback_calls: list[dict[str, Any]] = []
  147. async def fallback(
  148. definition: EventDefinition,
  149. request: EventRequest,
  150. context: EventExecutionContext,
  151. ) -> ResolvedEventArguments:
  152. fallback_calls.append(dict(request.arguments))
  153. return ResolvedEventArguments(
  154. event_name=definition.name,
  155. arguments={"query": "resolved once"},
  156. raw_arguments='{"query":"resolved once"}',
  157. )
  158. registry = EventRegistry([_definition(resolver=lambda request, context: {})])
  159. result = await EventKernel(registry, argument_fallback=fallback).execute(
  160. EventRequest(id="event-1", name="example.lookup"),
  161. enabled_names=["example.lookup"],
  162. )
  163. assert result.status is EventStatus.SUCCESS
  164. assert result.arguments == {"query": "resolved once"}
  165. assert result.used_fallback is True
  166. assert fallback_calls == [{}]
  167. @pytest.mark.asyncio
  168. @pytest.mark.parametrize(
  169. "arguments",
  170. [
  171. {"query": 42},
  172. {"query": "unsupported"},
  173. {"query": "valid", "unexpected": True},
  174. ],
  175. )
  176. async def test_kernel_does_not_fallback_for_complete_invalid_arguments(
  177. arguments: dict[str, Any],
  178. ):
  179. fallback_calls = 0
  180. async def fallback(*args: Any) -> ResolvedEventArguments:
  181. nonlocal fallback_calls
  182. fallback_calls += 1
  183. return ResolvedEventArguments(
  184. event_name="example.lookup",
  185. arguments={"query": "valid"},
  186. raw_arguments='{"query":"valid"}',
  187. )
  188. definition = _definition(
  189. parameters={
  190. "type": "object",
  191. "properties": {"query": {"type": "string", "enum": ["valid"]}},
  192. "required": ["query"],
  193. "additionalProperties": False,
  194. },
  195. resolver=lambda request, context: arguments,
  196. )
  197. result = await EventKernel(
  198. EventRegistry([definition]), argument_fallback=fallback
  199. ).execute(
  200. EventRequest(id="event-1", name="example.lookup"),
  201. enabled_names=["example.lookup"],
  202. )
  203. assert result.status is EventStatus.INVALID_ARGUMENTS
  204. assert result.used_fallback is False
  205. assert fallback_calls == 0
  206. @pytest.mark.asyncio
  207. async def test_kernel_does_not_fallback_when_definition_disallows_it():
  208. fallback_calls = 0
  209. async def fallback(*args: Any) -> ResolvedEventArguments:
  210. nonlocal fallback_calls
  211. fallback_calls += 1
  212. return ResolvedEventArguments(
  213. event_name="example.lookup",
  214. arguments={"query": "not allowed"},
  215. raw_arguments='{"query":"not allowed"}',
  216. )
  217. registry = EventRegistry(
  218. [
  219. _definition(
  220. resolver=lambda request, context: {},
  221. fallback_allowed=False,
  222. )
  223. ]
  224. )
  225. result = await EventKernel(registry, argument_fallback=fallback).execute(
  226. EventRequest(id="event-1", name="example.lookup"),
  227. enabled_names=["example.lookup"],
  228. )
  229. assert result.status is EventStatus.INVALID_ARGUMENTS
  230. assert result.error == "missing required arguments: query"
  231. assert result.used_fallback is False
  232. assert fallback_calls == 0
  233. @pytest.mark.asyncio
  234. async def test_structured_resolution_can_mark_optional_arguments_incomplete():
  235. fallback_calls = 0
  236. async def fallback(*args: Any) -> ResolvedEventArguments:
  237. nonlocal fallback_calls
  238. fallback_calls += 1
  239. return ResolvedEventArguments(
  240. event_name="example.lookup",
  241. arguments={"query": "resolved"},
  242. raw_arguments='{"query":"resolved"}',
  243. )
  244. definition = _definition(
  245. parameters={
  246. "type": "object",
  247. "properties": {"query": {"type": "string"}},
  248. "additionalProperties": False,
  249. },
  250. resolver=lambda request, context: EventArgumentResolution(
  251. arguments={},
  252. complete=False,
  253. ),
  254. )
  255. result = await EventKernel(
  256. EventRegistry([definition]), argument_fallback=fallback
  257. ).execute(
  258. EventRequest(id="event-1", name="example.lookup"),
  259. enabled_names=["example.lookup"],
  260. )
  261. assert result.status is EventStatus.SUCCESS
  262. assert result.arguments == {"query": "resolved"}
  263. assert result.used_fallback is True
  264. assert fallback_calls == 1
  265. @pytest.mark.asyncio
  266. async def test_structured_incomplete_optional_arguments_fail_without_fallback():
  267. definition = _definition(
  268. parameters={
  269. "type": "object",
  270. "properties": {"query": {"type": "string"}},
  271. },
  272. resolver=lambda request, context: EventArgumentResolution(
  273. arguments={},
  274. complete=False,
  275. ),
  276. handler=lambda request: {"ok": True},
  277. )
  278. result = await EventKernel(EventRegistry([definition])).execute(
  279. EventRequest(id="event-1", name="example.lookup"),
  280. enabled_names=["example.lookup"],
  281. )
  282. assert result.status is EventStatus.INVALID_ARGUMENTS
  283. assert result.error == "event arguments incomplete"
  284. @pytest.mark.asyncio
  285. async def test_plain_dict_resolution_remains_complete_for_optional_schema():
  286. fallback_calls = 0
  287. async def fallback(*args: Any) -> ResolvedEventArguments:
  288. nonlocal fallback_calls
  289. fallback_calls += 1
  290. raise AssertionError("fallback should not run")
  291. definition = _definition(
  292. parameters={
  293. "type": "object",
  294. "properties": {"query": {"type": "string"}},
  295. },
  296. resolver=lambda request, context: {},
  297. handler=lambda request: {"ok": True},
  298. )
  299. result = await EventKernel(
  300. EventRegistry([definition]), argument_fallback=fallback
  301. ).execute(
  302. EventRequest(id="event-1", name="example.lookup"),
  303. enabled_names=["example.lookup"],
  304. )
  305. assert result.status is EventStatus.SUCCESS
  306. assert fallback_calls == 0
  307. @pytest.mark.asyncio
  308. async def test_provider_resolved_arguments_are_not_rewritten_or_fallen_back():
  309. resolver_calls = 0
  310. fallback_calls = 0
  311. def resolver(*args: Any) -> dict[str, Any]:
  312. nonlocal resolver_calls
  313. resolver_calls += 1
  314. return {"query": "rewritten"}
  315. async def fallback(*args: Any) -> ResolvedEventArguments:
  316. nonlocal fallback_calls
  317. fallback_calls += 1
  318. return ResolvedEventArguments(
  319. event_name="example.lookup",
  320. arguments={"query": "fallback"},
  321. raw_arguments='{"query":"fallback"}',
  322. )
  323. registry = EventRegistry([_definition(resolver=resolver)])
  324. result = await EventKernel(registry, argument_fallback=fallback).execute(
  325. EventRequest(
  326. id="event-1",
  327. name="example.lookup",
  328. arguments={"query": "provider value"},
  329. source=EventSource.PROVIDER_RESOLVED,
  330. ),
  331. enabled_names=["example.lookup"],
  332. )
  333. assert result.status is EventStatus.SUCCESS
  334. assert result.arguments == {"query": "provider value"}
  335. assert resolver_calls == 0
  336. assert fallback_calls == 0
  337. @pytest.mark.asyncio
  338. async def test_provider_resolved_missing_arguments_return_invalid_without_fallback():
  339. fallback_calls = 0
  340. async def fallback(*args: Any) -> ResolvedEventArguments:
  341. nonlocal fallback_calls
  342. fallback_calls += 1
  343. return ResolvedEventArguments(
  344. event_name="example.lookup",
  345. arguments={"query": "fallback"},
  346. raw_arguments='{"query":"fallback"}',
  347. )
  348. result = await EventKernel(
  349. EventRegistry([_definition()]), argument_fallback=fallback
  350. ).execute(
  351. EventRequest(
  352. id="event-1",
  353. name="example.lookup",
  354. arguments={},
  355. source=EventSource.PROVIDER_RESOLVED,
  356. ),
  357. enabled_names=["example.lookup"],
  358. )
  359. assert result.status is EventStatus.INVALID_ARGUMENTS
  360. assert result.error == "missing required arguments: query"
  361. assert fallback_calls == 0
  362. @pytest.mark.asyncio
  363. @pytest.mark.parametrize(
  364. ("event_request", "enabled_names", "expected_status", "expected_error"),
  365. [
  366. (
  367. EventRequest(id="event-1", name="missing"),
  368. ["missing"],
  369. EventStatus.UNKNOWN,
  370. "unknown event",
  371. ),
  372. (
  373. EventRequest(id="event-1", name="example.lookup"),
  374. [],
  375. EventStatus.DISABLED,
  376. "event disabled",
  377. ),
  378. (
  379. EventRequest(
  380. id="event-1",
  381. name="example.lookup",
  382. arguments={"query": 42},
  383. source=EventSource.PROVIDER_RESOLVED,
  384. ),
  385. ["example.lookup"],
  386. EventStatus.INVALID_ARGUMENTS,
  387. "invalid argument type for query: expected string",
  388. ),
  389. ],
  390. )
  391. async def test_kernel_normalizes_lookup_and_validation_failures(
  392. event_request: EventRequest,
  393. enabled_names: list[str],
  394. expected_status: EventStatus,
  395. expected_error: str,
  396. ):
  397. result = await EventKernel(EventRegistry([_definition()])).execute(
  398. event_request,
  399. enabled_names=enabled_names,
  400. )
  401. assert result.status is expected_status
  402. assert result.error == expected_error
  403. @pytest.mark.asyncio
  404. async def test_kernel_normalizes_handler_exceptions():
  405. def fail(request: EventRequest) -> dict[str, Any]:
  406. raise RuntimeError("boom")
  407. result = await EventKernel(
  408. EventRegistry([_definition(handler=fail)])
  409. ).execute(
  410. EventRequest(
  411. id="event-1",
  412. name="example.lookup",
  413. arguments={"query": "value"},
  414. source=EventSource.PROVIDER_RESOLVED,
  415. ),
  416. enabled_names=["example.lookup"],
  417. )
  418. assert result.status is EventStatus.HANDLER_ERROR
  419. assert result.error == "event handler failed: boom"
  420. @pytest.mark.asyncio
  421. async def test_kernel_validates_complete_draft_2020_12_schema():
  422. definition = _definition(
  423. parameters={
  424. "type": "object",
  425. "properties": {
  426. "mode": {"enum": ["quick", "deep"]},
  427. "target": {"type": ["string", "null"]},
  428. "filters": {
  429. "type": "array",
  430. "items": {
  431. "type": "object",
  432. "properties": {"score": {"type": "number", "minimum": 0}},
  433. "required": ["score"],
  434. "additionalProperties": False,
  435. },
  436. },
  437. },
  438. "required": ["mode", "target", "filters"],
  439. "additionalProperties": False,
  440. },
  441. handler=lambda request: {"event": request.name},
  442. )
  443. kernel = EventKernel(EventRegistry([definition]))
  444. valid = await kernel.execute(
  445. EventRequest(
  446. id="valid",
  447. name=definition.name,
  448. arguments={
  449. "mode": "deep",
  450. "target": None,
  451. "filters": [{"score": 0.5}],
  452. },
  453. source=EventSource.PROVIDER_RESOLVED,
  454. ),
  455. enabled_names=[definition.name],
  456. )
  457. invalid = await kernel.execute(
  458. EventRequest(
  459. id="invalid",
  460. name=definition.name,
  461. arguments={
  462. "mode": "other",
  463. "target": 7,
  464. "filters": [{"score": -1, "extra": True}],
  465. "unexpected": True,
  466. },
  467. source=EventSource.PROVIDER_RESOLVED,
  468. ),
  469. enabled_names=[definition.name],
  470. )
  471. assert valid.status is EventStatus.SUCCESS
  472. assert invalid.status is EventStatus.INVALID_ARGUMENTS
  473. assert invalid.error.startswith("invalid event arguments:")
  474. @pytest.mark.asyncio
  475. async def test_kernel_normalizes_validator_runtime_exceptions():
  476. definition = _definition(parameters={"$ref": "urn:agent-lab:missing-schema"})
  477. result = await EventKernel(EventRegistry([definition])).execute(
  478. EventRequest(
  479. id="event-1",
  480. name=definition.name,
  481. arguments={},
  482. source=EventSource.PROVIDER_RESOLVED,
  483. ),
  484. enabled_names=[definition.name],
  485. )
  486. assert result.status is EventStatus.DEFINITION_ERROR
  487. assert result.error.startswith("event argument validation failed:")
  488. @pytest.mark.asyncio
  489. async def test_tool_registry_maps_definition_errors_to_tool_compatibility_payload():
  490. registry = ToolRegistry(
  491. [
  492. ToolDefinition(
  493. name="broken.lookup",
  494. description="Broken lookup.",
  495. parameters={"$ref": "urn:agent-lab:missing-schema"},
  496. handler=lambda event: {"tool": event.name},
  497. )
  498. ]
  499. )
  500. payload = await registry.execute_async(
  501. ToolCallEvent(
  502. id="call-1",
  503. name="broken.lookup",
  504. arguments={},
  505. raw_arguments="{}",
  506. )
  507. )
  508. assert payload["tool"] == "broken.lookup"
  509. assert payload["error"].startswith("tool definition validation failed:")
  510. @pytest.mark.asyncio
  511. @pytest.mark.parametrize("boundary", ["resolver", "fallback"])
  512. async def test_kernel_normalizes_resolution_boundary_exceptions(boundary: str):
  513. def resolver(request: EventRequest, context: EventExecutionContext) -> dict[str, Any]:
  514. if boundary == "resolver":
  515. raise RuntimeError("resolver boom")
  516. return {}
  517. async def fallback(*args: Any) -> ResolvedEventArguments:
  518. raise RuntimeError("fallback boom")
  519. result = await EventKernel(
  520. EventRegistry([_definition(resolver=resolver)]),
  521. argument_fallback=fallback,
  522. ).execute(
  523. EventRequest(id="event-1", name="example.lookup"),
  524. enabled_names=["example.lookup"],
  525. )
  526. assert result.status is EventStatus.RESOLUTION_ERROR
  527. assert result.error == f"event argument {boundary} failed: {boundary} boom"
  528. @pytest.mark.asyncio
  529. @pytest.mark.parametrize("boundary", ["resolver", "fallback"])
  530. async def test_kernel_normalizes_invalid_resolution_payloads(boundary: str):
  531. resolver = (
  532. (lambda request, context: None)
  533. if boundary == "resolver"
  534. else (lambda request, context: {})
  535. )
  536. async def fallback(*args: Any) -> Any:
  537. return {"query": "legacy bare mapping"}
  538. result = await EventKernel(
  539. EventRegistry([_definition(resolver=resolver)]),
  540. argument_fallback=fallback,
  541. ).execute(
  542. EventRequest(id="event-1", name="example.lookup"),
  543. enabled_names=["example.lookup"],
  544. )
  545. assert result.status is EventStatus.RESOLUTION_ERROR
  546. assert result.error == f"event argument {boundary} returned invalid payload"
  547. @pytest.mark.asyncio
  548. async def test_kernel_normalizes_non_json_resolver_arguments():
  549. result = await EventKernel(
  550. EventRegistry(
  551. [
  552. _definition(
  553. resolver=lambda request, context: {"query": object()}
  554. )
  555. ]
  556. )
  557. ).execute(
  558. EventRequest(id="event-1", name="example.lookup"),
  559. enabled_names=["example.lookup"],
  560. )
  561. assert result.status is EventStatus.RESOLUTION_ERROR
  562. assert result.error.startswith("event argument resolver failed to serialize:")
  563. @pytest.mark.asyncio
  564. async def test_kernel_normalizes_non_object_fallback_arguments():
  565. async def fallback(*args: Any) -> ResolvedEventArguments:
  566. return ResolvedEventArguments(
  567. event_name="example.lookup",
  568. arguments=["not", "an", "object"], # type: ignore[arg-type]
  569. raw_arguments='["not","an","object"]',
  570. )
  571. result = await EventKernel(
  572. EventRegistry([_definition(resolver=lambda request, context: {})]),
  573. argument_fallback=fallback,
  574. ).execute(
  575. EventRequest(id="event-1", name="example.lookup"),
  576. enabled_names=["example.lookup"],
  577. )
  578. assert result.status is EventStatus.RESOLUTION_ERROR
  579. assert result.error == "event argument fallback returned invalid payload"
  580. @pytest.mark.asyncio
  581. @pytest.mark.parametrize(
  582. ("resolved", "expected_error"),
  583. [
  584. (
  585. ResolvedEventArguments(
  586. event_name="another.event",
  587. arguments={"query": "value"},
  588. raw_arguments='{"query":"value"}',
  589. ),
  590. "fallback returned tool another.event for example.lookup",
  591. ),
  592. (
  593. ResolvedEventArguments(
  594. event_name="example.lookup",
  595. arguments={"query": "value"},
  596. raw_arguments="not-json",
  597. ),
  598. "fallback raw arguments are not valid JSON",
  599. ),
  600. (
  601. ResolvedEventArguments(
  602. event_name="example.lookup",
  603. arguments={"query": "parsed"},
  604. raw_arguments='{"query":"raw"}',
  605. ),
  606. "fallback raw arguments do not match parsed arguments",
  607. ),
  608. ],
  609. )
  610. async def test_kernel_rejects_inconsistent_structured_fallback(
  611. resolved: ResolvedEventArguments,
  612. expected_error: str,
  613. ):
  614. async def fallback(*args: Any) -> ResolvedEventArguments:
  615. return resolved
  616. result = await EventKernel(
  617. EventRegistry([_definition(resolver=lambda request, context: {})]),
  618. argument_fallback=fallback,
  619. ).execute(
  620. EventRequest(id="event-1", name="example.lookup"),
  621. enabled_names=["example.lookup"],
  622. )
  623. assert result.status is EventStatus.RESOLUTION_ERROR
  624. assert result.error == expected_error
  625. @pytest.mark.asyncio
  626. @pytest.mark.parametrize(
  627. ("arguments", "raw_arguments"),
  628. [
  629. ({"query": 1}, '{"query":true}'),
  630. ({"query": 1.0}, '{"query":1}'),
  631. ({"query": {"nested": [1]}}, '{"query":{"nested":[true]}}'),
  632. ({"query": float("nan")}, '{"query":NaN}'),
  633. ({"query": float("inf")}, '{"query":Infinity}'),
  634. ],
  635. )
  636. async def test_kernel_rejects_noncanonical_fallback_json(
  637. arguments: dict[str, Any],
  638. raw_arguments: str,
  639. ):
  640. async def fallback(*args: Any) -> ResolvedEventArguments:
  641. return ResolvedEventArguments(
  642. event_name="example.lookup",
  643. arguments=arguments,
  644. raw_arguments=raw_arguments,
  645. )
  646. result = await EventKernel(
  647. EventRegistry([_definition(resolver=lambda request, context: {})]),
  648. argument_fallback=fallback,
  649. ).execute(
  650. EventRequest(id="event-1", name="example.lookup"),
  651. enabled_names=["example.lookup"],
  652. )
  653. assert result.status is EventStatus.RESOLUTION_ERROR
  654. @pytest.mark.asyncio
  655. async def test_registry_schema_is_isolated_from_caller_mutation():
  656. parameters = {
  657. "type": "object",
  658. "properties": {"query": {"type": "string"}},
  659. "required": ["query"],
  660. "additionalProperties": False,
  661. }
  662. registry = EventRegistry([_definition(parameters=parameters)])
  663. parameters["properties"]["query"]["type"] = "integer"
  664. parameters["required"].clear()
  665. parameters["additionalProperties"] = True
  666. assert registry.catalog()[0]["parameters"] == {
  667. "type": "object",
  668. "properties": {"query": {"type": "string"}},
  669. "required": ["query"],
  670. "additionalProperties": False,
  671. }
  672. registered = registry.definition("example.lookup")
  673. assert registered is not None
  674. with pytest.raises(TypeError):
  675. registered.parameters["additionalProperties"] = True
  676. with pytest.raises(TypeError):
  677. registered.parameters["properties"]["query"]["type"] = "integer"
  678. with pytest.raises(AttributeError):
  679. registered.parameters["required"].append("unexpected")
  680. result = await EventKernel(registry).execute(
  681. EventRequest(
  682. id="event-1",
  683. name="example.lookup",
  684. arguments={"query": 42, "unexpected": True},
  685. source=EventSource.PROVIDER_RESOLVED,
  686. ),
  687. enabled_names=["example.lookup"],
  688. )
  689. assert result.status is EventStatus.INVALID_ARGUMENTS
  690. @pytest.mark.asyncio
  691. async def test_kernel_passes_consistent_fallback_arguments_and_raw_json_to_handler():
  692. captured: list[EventRequest] = []
  693. async def fallback(*args: Any) -> ResolvedEventArguments:
  694. return ResolvedEventArguments(
  695. event_name="example.lookup",
  696. arguments={"query": "resolved"},
  697. raw_arguments='{"query":"resolved"}',
  698. )
  699. result = await EventKernel(
  700. EventRegistry(
  701. [
  702. _definition(
  703. resolver=lambda request, context: {},
  704. handler=lambda request: captured.append(request) or {"ok": True},
  705. )
  706. ]
  707. ),
  708. argument_fallback=fallback,
  709. ).execute(
  710. EventRequest(id="event-1", name="example.lookup"),
  711. enabled_names=["example.lookup"],
  712. )
  713. assert result.status is EventStatus.SUCCESS
  714. assert captured[0].arguments == json.loads(captured[0].raw_arguments)
  715. assert result.raw_arguments == captured[0].raw_arguments
  716. @pytest.mark.asyncio
  717. @pytest.mark.parametrize("payload", [None, "text", 1, ["item"]])
  718. async def test_kernel_normalizes_invalid_handler_payloads(payload: Any):
  719. result = await EventKernel(
  720. EventRegistry([_definition(handler=lambda request: payload)])
  721. ).execute(
  722. EventRequest(
  723. id="event-1",
  724. name="example.lookup",
  725. arguments={"query": "value"},
  726. source=EventSource.PROVIDER_RESOLVED,
  727. ),
  728. enabled_names=["example.lookup"],
  729. )
  730. assert result.status is EventStatus.HANDLER_ERROR
  731. assert result.error == "event handler returned non-object payload"
  732. @pytest.mark.asyncio
  733. async def test_async_kernel_supports_async_handler():
  734. async def handler(request: EventRequest) -> dict[str, Any]:
  735. return {"query": request.arguments["query"]}
  736. result = await EventKernel(
  737. EventRegistry([_definition(handler=handler)])
  738. ).execute(
  739. EventRequest(
  740. id="event-1",
  741. name="example.lookup",
  742. arguments={"query": "async"},
  743. source=EventSource.PROVIDER_RESOLVED,
  744. ),
  745. enabled_names=["example.lookup"],
  746. )
  747. assert result.status is EventStatus.SUCCESS
  748. assert result.payload == {"query": "async"}
  749. @pytest.mark.asyncio
  750. async def test_tool_registry_async_entry_points_support_async_handler():
  751. async def handler(event: ToolCallEvent) -> dict[str, Any]:
  752. return {"tool": event.name, "query": event.arguments["query"]}
  753. registry = ToolRegistry(
  754. [
  755. ToolDefinition(
  756. name="async.lookup",
  757. description="Async lookup.",
  758. parameters={
  759. "type": "object",
  760. "properties": {"query": {"type": "string"}},
  761. "required": ["query"],
  762. },
  763. handler=handler,
  764. )
  765. ]
  766. )
  767. event = ToolCallEvent(
  768. id="call-1",
  769. name="async.lookup",
  770. arguments={"query": "value"},
  771. raw_arguments='{"query":"value"}',
  772. )
  773. assert await registry.handle_async(event) == {
  774. "tool": "async.lookup",
  775. "query": "value",
  776. }
  777. assert await registry.execute_async(event) == {
  778. "tool": "async.lookup",
  779. "query": "value",
  780. }
  781. def test_tool_registry_sync_facade_rejects_async_handler_without_runtime_warning():
  782. called = False
  783. async def handler(event: ToolCallEvent) -> dict[str, Any]:
  784. nonlocal called
  785. called = True
  786. return {"tool": event.name}
  787. registry = ToolRegistry(
  788. [
  789. ToolDefinition(
  790. name="async.lookup",
  791. description="Async lookup.",
  792. parameters={"type": "object"},
  793. handler=handler,
  794. )
  795. ]
  796. )
  797. event = ToolCallEvent(
  798. id="call-1",
  799. name="async.lookup",
  800. arguments={},
  801. raw_arguments="{}",
  802. )
  803. with warnings.catch_warnings(record=True) as captured:
  804. warnings.simplefilter("always")
  805. payload = registry.execute(event)
  806. gc.collect()
  807. assert payload == {
  808. "tool": "async.lookup",
  809. "error": "tool handler failed: async event handlers require execute_async",
  810. }
  811. assert called is False
  812. assert not [warning for warning in captured if issubclass(warning.category, RuntimeWarning)]
  813. @pytest.mark.asyncio
  814. async def test_kernel_rejects_boolean_for_json_number_arguments():
  815. definition = _definition(
  816. parameters={
  817. "type": "object",
  818. "properties": {"query": {"type": "number"}},
  819. "required": ["query"],
  820. }
  821. )
  822. result = await EventKernel(EventRegistry([definition])).execute(
  823. EventRequest(
  824. id="event-1",
  825. name="example.lookup",
  826. arguments={"query": True},
  827. source=EventSource.PROVIDER_RESOLVED,
  828. ),
  829. enabled_names=["example.lookup"],
  830. )
  831. assert result.status is EventStatus.INVALID_ARGUMENTS
  832. assert result.error == "invalid argument type for query: expected number"
  833. @pytest.mark.asyncio
  834. async def test_definition_metadata_survives_registration_and_result_creation():
  835. definition = _definition(
  836. result_policy=ResultPolicy.TEMPLATE_FOLLOW_UP,
  837. confirmation_policy=ConfirmationPolicy.REQUIRED,
  838. risk_level=RiskLevel.HIGH,
  839. idempotency_key_fields=("session_id", "event_id"),
  840. concurrency_class="device-write",
  841. conflict_keys=("device",),
  842. timeout_seconds=1.5,
  843. terminal=True,
  844. )
  845. registry = EventRegistry([definition])
  846. result = await EventKernel(registry).execute(
  847. EventRequest(
  848. id="event-1",
  849. name="example.lookup",
  850. arguments={"query": "value"},
  851. source=EventSource.PROVIDER_RESOLVED,
  852. ),
  853. enabled_names=["example.lookup"],
  854. )
  855. registered = registry.definition("example.lookup")
  856. assert registered is not definition
  857. assert result.result_policy is ResultPolicy.TEMPLATE_FOLLOW_UP
  858. assert result.confirmation_policy is ConfirmationPolicy.REQUIRED
  859. assert result.risk_level is RiskLevel.HIGH
  860. assert result.idempotency_key_fields == ("session_id", "event_id")
  861. assert result.concurrency_class == "device-write"
  862. assert result.conflict_keys == ("device",)
  863. assert result.timeout_seconds == 1.5
  864. assert result.terminal is True
  865. @pytest.mark.asyncio
  866. @pytest.mark.parametrize("name", ["alpha.one", "beta-two", "任意.事件"])
  867. async def test_kernel_applies_identical_behavior_to_arbitrary_event_names(name: str):
  868. definition = _definition(name=name)
  869. result = await EventKernel(EventRegistry([definition])).execute(
  870. EventRequest(
  871. id="event-1",
  872. name=name,
  873. arguments={"query": "value"},
  874. source=EventSource.PROVIDER_RESOLVED,
  875. ),
  876. enabled_names=[name],
  877. )
  878. assert result.status is EventStatus.SUCCESS
  879. assert result.payload == {"event": name, "query": "value"}