test_event_kernel.py 56 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834
  1. from __future__ import annotations
  2. import gc
  3. import json
  4. import warnings
  5. from dataclasses import FrozenInstanceError
  6. from typing import Any
  7. import pytest
  8. from agent_lab.application.events import (
  9. ConfirmationPolicy,
  10. EventDefinition,
  11. EventExecutionContext,
  12. EventKernel,
  13. EventRegistry,
  14. EventRequest,
  15. ResolvedEventArguments,
  16. EventSource,
  17. EventStatus,
  18. ResultPolicy,
  19. RiskLevel,
  20. )
  21. from agent_lab.application.events.models import EventArgumentResolution
  22. from agent_lab.application.tools import (
  23. ToolDefinition,
  24. ToolExecutionContext,
  25. ToolRegistry,
  26. )
  27. from agent_lab.domain.events import ToolCallEvent
  28. from agent_lab.domain.messages import ChatMessage
  29. def _definition(
  30. name: str = "example.lookup",
  31. **overrides: Any,
  32. ) -> EventDefinition:
  33. values: dict[str, Any] = {
  34. "name": name,
  35. "description": "Look up an example value.",
  36. "parameters": {
  37. "type": "object",
  38. "properties": {"query": {"type": "string"}},
  39. "required": ["query"],
  40. },
  41. "handler": lambda request: {
  42. "event": request.name,
  43. "query": request.arguments["query"],
  44. },
  45. }
  46. values.update(overrides)
  47. return EventDefinition(**values)
  48. def test_registry_registers_flat_definitions_and_filters_enabled_catalog():
  49. registry = EventRegistry(
  50. [_definition("example.lookup"), _definition("device.inspect")]
  51. )
  52. assert [item["name"] for item in registry.catalog()] == [
  53. "example.lookup",
  54. "device.inspect",
  55. ]
  56. assert registry.catalog(["device.inspect"]) == [
  57. {
  58. "name": "device.inspect",
  59. "description": "Look up an example value.",
  60. "parameters": {
  61. "type": "object",
  62. "properties": {"query": {"type": "string"}},
  63. "required": ["query"],
  64. },
  65. }
  66. ]
  67. assert registry.tool_schema("example.lookup")["function"]["name"] == (
  68. "example.lookup"
  69. )
  70. assert registry.tool_schema("missing") is None
  71. def test_registry_rejects_duplicate_definition_names():
  72. with pytest.raises(ValueError, match="duplicate event definition: example.lookup"):
  73. EventRegistry([_definition(), _definition()])
  74. def test_registry_rejects_invalid_draft_2020_12_schema():
  75. definition = _definition(parameters={"type": 42})
  76. with pytest.raises(ValueError, match="invalid event schema for example.lookup"):
  77. EventRegistry([definition])
  78. def test_registry_does_not_expose_mutable_validator_instances():
  79. registry = EventRegistry([_definition()])
  80. assert not hasattr(registry, "validator")
  81. errors = list(
  82. registry.iter_validation_errors(
  83. "example.lookup",
  84. {"query": 42},
  85. )
  86. )
  87. assert errors
  88. assert not hasattr(errors[0], "schema")
  89. assert not hasattr(errors[0], "instance")
  90. with pytest.raises(FrozenInstanceError):
  91. errors[0].message = "mutated" # type: ignore[misc]
  92. assert list(
  93. registry.iter_validation_errors(
  94. "example.lookup",
  95. {"query": 42},
  96. )
  97. )
  98. def test_tool_registry_public_api_remains_compatible():
  99. registry = ToolRegistry(
  100. [
  101. ToolDefinition(
  102. name="compat.lookup",
  103. description="Look up compatibility data.",
  104. parameters={
  105. "type": "object",
  106. "properties": {"query": {"type": "string"}},
  107. "required": ["query"],
  108. },
  109. handler=lambda event: {
  110. "tool": event.name,
  111. "query": event.arguments["query"],
  112. },
  113. argument_resolver=lambda event, context: {
  114. "query": context.history[-1].content
  115. },
  116. )
  117. ]
  118. )
  119. event = ToolCallEvent(
  120. id="call-1",
  121. name="compat.lookup",
  122. arguments={"query": "provider"},
  123. raw_arguments='{"query":"provider"}',
  124. )
  125. assert registry.available_tools() == [
  126. {
  127. "name": "compat.lookup",
  128. "description": "Look up compatibility data.",
  129. "parameters": {
  130. "type": "object",
  131. "properties": {"query": {"type": "string"}},
  132. "required": ["query"],
  133. },
  134. }
  135. ]
  136. assert "- compat.lookup: Look up compatibility data." in (
  137. registry.chat_event_system_message(["compat.lookup"])
  138. )
  139. assert registry.tool_schema("compat.lookup")["function"]["name"] == (
  140. "compat.lookup"
  141. )
  142. assert registry.handle(
  143. event,
  144. ToolExecutionContext(history=[ChatMessage(role="user", content="history")]),
  145. ) == {"tool": "compat.lookup", "query": "history"}
  146. assert registry.execute(event) == {"tool": "compat.lookup", "query": "provider"}
  147. @pytest.mark.asyncio
  148. async def test_kernel_executes_complete_deterministic_arguments_without_fallback():
  149. fallback_calls: list[str] = []
  150. async def fallback(*args: Any) -> dict[str, Any]:
  151. fallback_calls.append("called")
  152. return {"query": "fallback"}
  153. registry = EventRegistry(
  154. [_definition(resolver=lambda request, context: {"query": "deterministic"})]
  155. )
  156. result = await EventKernel(registry, argument_fallback=fallback).execute(
  157. EventRequest(id="event-1", name="example.lookup"),
  158. enabled_names=["example.lookup"],
  159. )
  160. assert result.status is EventStatus.SUCCESS
  161. assert result.arguments == {"query": "deterministic"}
  162. assert result.payload == {"event": "example.lookup", "query": "deterministic"}
  163. assert result.used_fallback is False
  164. assert fallback_calls == []
  165. @pytest.mark.asyncio
  166. async def test_kernel_calls_fallback_once_when_required_arguments_are_incomplete():
  167. fallback_calls: list[dict[str, Any]] = []
  168. async def fallback(
  169. definition: EventDefinition,
  170. request: EventRequest,
  171. context: EventExecutionContext,
  172. ) -> ResolvedEventArguments:
  173. fallback_calls.append(dict(request.arguments))
  174. return ResolvedEventArguments(
  175. event_name=definition.name,
  176. arguments={"query": "resolved once"},
  177. raw_arguments='{"query":"resolved once"}',
  178. )
  179. registry = EventRegistry([_definition(resolver=lambda request, context: {})])
  180. result = await EventKernel(registry, argument_fallback=fallback).execute(
  181. EventRequest(id="event-1", name="example.lookup"),
  182. enabled_names=["example.lookup"],
  183. )
  184. assert result.status is EventStatus.SUCCESS
  185. assert result.arguments == {"query": "resolved once"}
  186. assert result.used_fallback is True
  187. assert fallback_calls == [{}]
  188. @pytest.mark.asyncio
  189. @pytest.mark.parametrize(
  190. "arguments",
  191. [
  192. {"query": 42},
  193. {"query": "unsupported"},
  194. {"query": "valid", "unexpected": True},
  195. ],
  196. )
  197. async def test_kernel_does_not_fallback_for_complete_invalid_arguments(
  198. arguments: dict[str, Any],
  199. ):
  200. fallback_calls = 0
  201. async def fallback(*args: Any) -> ResolvedEventArguments:
  202. nonlocal fallback_calls
  203. fallback_calls += 1
  204. return ResolvedEventArguments(
  205. event_name="example.lookup",
  206. arguments={"query": "valid"},
  207. raw_arguments='{"query":"valid"}',
  208. )
  209. definition = _definition(
  210. parameters={
  211. "type": "object",
  212. "properties": {"query": {"type": "string", "enum": ["valid"]}},
  213. "required": ["query"],
  214. "additionalProperties": False,
  215. },
  216. resolver=lambda request, context: arguments,
  217. )
  218. result = await EventKernel(
  219. EventRegistry([definition]), argument_fallback=fallback
  220. ).execute(
  221. EventRequest(id="event-1", name="example.lookup"),
  222. enabled_names=["example.lookup"],
  223. )
  224. assert result.status is EventStatus.INVALID_ARGUMENTS
  225. assert result.used_fallback is False
  226. assert fallback_calls == 0
  227. @pytest.mark.asyncio
  228. @pytest.mark.parametrize(
  229. ("arguments", "expected_error"),
  230. [
  231. (
  232. {"mode": "invalid"},
  233. "invalid event arguments: 'invalid' is not one of ['valid']",
  234. ),
  235. (
  236. {"mode": "valid", "count": "invalid"},
  237. "invalid argument type for count: expected integer",
  238. ),
  239. (
  240. {"mode": "valid", "unexpected": True},
  241. "invalid event arguments: Additional properties are not allowed "
  242. "('unexpected' was unexpected)",
  243. ),
  244. ],
  245. )
  246. async def test_kernel_does_not_fallback_for_required_plus_substantive_error(
  247. arguments: dict[str, Any],
  248. expected_error: str,
  249. ):
  250. fallback_calls = 0
  251. async def fallback(*args: Any) -> ResolvedEventArguments:
  252. nonlocal fallback_calls
  253. fallback_calls += 1
  254. raise AssertionError("fallback should not run")
  255. definition = _definition(
  256. parameters={
  257. "type": "object",
  258. "properties": {
  259. "query": {"type": "string"},
  260. "mode": {"enum": ["valid"]},
  261. "count": {"type": "integer"},
  262. },
  263. "required": ["query"],
  264. "additionalProperties": False,
  265. },
  266. resolver=lambda request, context: arguments,
  267. )
  268. result = await EventKernel(
  269. EventRegistry([definition]), argument_fallback=fallback
  270. ).execute(
  271. EventRequest(id="event-1", name=definition.name),
  272. enabled_names=[definition.name],
  273. )
  274. assert result.status is EventStatus.INVALID_ARGUMENTS
  275. assert result.error == expected_error
  276. assert result.used_fallback is False
  277. assert fallback_calls == 0
  278. @pytest.mark.asyncio
  279. @pytest.mark.parametrize(
  280. ("arguments", "properties", "expected_error"),
  281. [
  282. (
  283. {"mode": "invalid"},
  284. {"mode": {"enum": ["valid"]}},
  285. "invalid event arguments: 'invalid' is not one of ['valid']",
  286. ),
  287. (
  288. {"count": "invalid"},
  289. {"count": {"type": "integer"}},
  290. "invalid argument type for count: expected integer",
  291. ),
  292. (
  293. {"unexpected": True},
  294. {},
  295. "invalid event arguments: Additional properties are not allowed "
  296. "('unexpected' was unexpected)",
  297. ),
  298. ],
  299. )
  300. async def test_structured_incomplete_does_not_fallback_over_substantive_error(
  301. arguments: dict[str, Any],
  302. properties: dict[str, Any],
  303. expected_error: str,
  304. ):
  305. fallback_calls = 0
  306. async def fallback(*args: Any) -> ResolvedEventArguments:
  307. nonlocal fallback_calls
  308. fallback_calls += 1
  309. raise AssertionError("fallback should not run")
  310. definition = _definition(
  311. parameters={
  312. "type": "object",
  313. "properties": {
  314. "query": {"type": "string"},
  315. **properties,
  316. },
  317. "required": ["query"],
  318. "additionalProperties": False,
  319. },
  320. resolver=lambda request, context: EventArgumentResolution(
  321. arguments=arguments,
  322. complete=False,
  323. ),
  324. )
  325. result = await EventKernel(
  326. EventRegistry([definition]), argument_fallback=fallback
  327. ).execute(
  328. EventRequest(id="event-1", name=definition.name),
  329. enabled_names=[definition.name],
  330. )
  331. assert result.status is EventStatus.INVALID_ARGUMENTS
  332. assert result.error == expected_error
  333. assert result.used_fallback is False
  334. assert fallback_calls == 0
  335. def _discriminated_composed_parameters(composition: str) -> dict[str, Any]:
  336. return {
  337. "type": "object",
  338. "properties": {
  339. "choice": {
  340. composition: [
  341. {
  342. "type": "object",
  343. "properties": {
  344. "kind": {"const": "a"},
  345. "value": {"type": "string"},
  346. },
  347. "required": ["kind", "value"],
  348. },
  349. {
  350. "type": "object",
  351. "properties": {
  352. "kind": {"const": "b"},
  353. "count": {"type": "integer"},
  354. },
  355. "required": ["kind", "count"],
  356. },
  357. ]
  358. }
  359. },
  360. "required": ["choice"],
  361. }
  362. @pytest.mark.asyncio
  363. @pytest.mark.parametrize("composition", ["anyOf", "oneOf"])
  364. async def test_kernel_falls_back_for_matching_composed_branch_missing_required(
  365. composition: str,
  366. ):
  367. fallback_calls = 0
  368. async def fallback(*args: Any) -> ResolvedEventArguments:
  369. nonlocal fallback_calls
  370. fallback_calls += 1
  371. return ResolvedEventArguments(
  372. event_name="example.lookup",
  373. arguments={"choice": {"kind": "a", "value": "resolved"}},
  374. raw_arguments='{"choice":{"kind":"a","value":"resolved"}}',
  375. )
  376. definition = _definition(
  377. parameters=_discriminated_composed_parameters(composition),
  378. resolver=lambda request, context: {"choice": {"kind": "a"}},
  379. handler=lambda request: {"ok": True},
  380. )
  381. result = await EventKernel(
  382. EventRegistry([definition]), argument_fallback=fallback
  383. ).execute(
  384. EventRequest(id="event-1", name="example.lookup"),
  385. enabled_names=["example.lookup"],
  386. )
  387. assert result.status is EventStatus.SUCCESS
  388. assert result.used_fallback is True
  389. assert fallback_calls == 1
  390. @pytest.mark.asyncio
  391. @pytest.mark.parametrize("composition", ["anyOf", "oneOf"])
  392. async def test_kernel_does_not_fallback_when_matching_composed_branch_is_invalid(
  393. composition: str,
  394. ):
  395. fallback_calls = 0
  396. async def fallback(*args: Any) -> ResolvedEventArguments:
  397. nonlocal fallback_calls
  398. fallback_calls += 1
  399. raise AssertionError("fallback should not run")
  400. definition = _definition(
  401. parameters=_discriminated_composed_parameters(composition),
  402. resolver=lambda request, context: {
  403. "choice": {"kind": "a", "value": 42}
  404. },
  405. handler=lambda request: {"ok": True},
  406. )
  407. result = await EventKernel(
  408. EventRegistry([definition]), argument_fallback=fallback
  409. ).execute(
  410. EventRequest(id="event-1", name="example.lookup"),
  411. enabled_names=["example.lookup"],
  412. )
  413. assert result.status is EventStatus.INVALID_ARGUMENTS
  414. assert result.used_fallback is False
  415. assert fallback_calls == 0
  416. @pytest.mark.asyncio
  417. @pytest.mark.parametrize("composition", ["anyOf", "oneOf"])
  418. @pytest.mark.parametrize("choice", [{}, {"kind": "other"}])
  419. async def test_kernel_does_not_fallback_when_composed_branch_is_ambiguous(
  420. composition: str,
  421. choice: dict[str, Any],
  422. ):
  423. fallback_calls = 0
  424. async def fallback(*args: Any) -> ResolvedEventArguments:
  425. nonlocal fallback_calls
  426. fallback_calls += 1
  427. raise AssertionError("fallback should not run")
  428. definition = _definition(
  429. parameters=_discriminated_composed_parameters(composition),
  430. resolver=lambda request, context: {"choice": choice},
  431. handler=lambda request: {"ok": True},
  432. )
  433. result = await EventKernel(
  434. EventRegistry([definition]), argument_fallback=fallback
  435. ).execute(
  436. EventRequest(id="event-1", name="example.lookup"),
  437. enabled_names=["example.lookup"],
  438. )
  439. assert result.status is EventStatus.INVALID_ARGUMENTS
  440. assert result.used_fallback is False
  441. assert fallback_calls == 0
  442. @pytest.mark.asyncio
  443. async def test_kernel_does_not_fallback_for_substantive_and_composed_missing_errors():
  444. fallback_calls = 0
  445. async def fallback(*args: Any) -> ResolvedEventArguments:
  446. nonlocal fallback_calls
  447. fallback_calls += 1
  448. raise AssertionError("fallback should not run")
  449. parameters = _discriminated_composed_parameters("anyOf")
  450. parameters["properties"]["mode"] = {"enum": ["valid"]}
  451. definition = _definition(
  452. parameters=parameters,
  453. resolver=lambda request, context: {
  454. "mode": "invalid",
  455. "choice": {"kind": "a"},
  456. },
  457. handler=lambda request: {"ok": True},
  458. )
  459. result = await EventKernel(
  460. EventRegistry([definition]), argument_fallback=fallback
  461. ).execute(
  462. EventRequest(id="event-1", name=definition.name),
  463. enabled_names=[definition.name],
  464. )
  465. assert result.status is EventStatus.INVALID_ARGUMENTS
  466. assert result.error == "invalid event arguments: 'invalid' is not one of ['valid']"
  467. assert result.used_fallback is False
  468. assert fallback_calls == 0
  469. @pytest.mark.asyncio
  470. async def test_kernel_does_not_fallback_when_definition_disallows_it():
  471. fallback_calls = 0
  472. async def fallback(*args: Any) -> ResolvedEventArguments:
  473. nonlocal fallback_calls
  474. fallback_calls += 1
  475. return ResolvedEventArguments(
  476. event_name="example.lookup",
  477. arguments={"query": "not allowed"},
  478. raw_arguments='{"query":"not allowed"}',
  479. )
  480. registry = EventRegistry(
  481. [
  482. _definition(
  483. resolver=lambda request, context: {},
  484. fallback_allowed=False,
  485. )
  486. ]
  487. )
  488. result = await EventKernel(registry, argument_fallback=fallback).execute(
  489. EventRequest(id="event-1", name="example.lookup"),
  490. enabled_names=["example.lookup"],
  491. )
  492. assert result.status is EventStatus.INVALID_ARGUMENTS
  493. assert result.error == "missing required arguments: query"
  494. assert result.used_fallback is False
  495. assert fallback_calls == 0
  496. @pytest.mark.asyncio
  497. async def test_structured_resolution_can_mark_optional_arguments_incomplete():
  498. fallback_calls = 0
  499. async def fallback(*args: Any) -> ResolvedEventArguments:
  500. nonlocal fallback_calls
  501. fallback_calls += 1
  502. return ResolvedEventArguments(
  503. event_name="example.lookup",
  504. arguments={"query": "resolved"},
  505. raw_arguments='{"query":"resolved"}',
  506. )
  507. definition = _definition(
  508. parameters={
  509. "type": "object",
  510. "properties": {"query": {"type": "string"}},
  511. "additionalProperties": False,
  512. },
  513. resolver=lambda request, context: EventArgumentResolution(
  514. arguments={},
  515. complete=False,
  516. ),
  517. )
  518. result = await EventKernel(
  519. EventRegistry([definition]), argument_fallback=fallback
  520. ).execute(
  521. EventRequest(id="event-1", name="example.lookup"),
  522. enabled_names=["example.lookup"],
  523. )
  524. assert result.status is EventStatus.SUCCESS
  525. assert result.arguments == {"query": "resolved"}
  526. assert result.used_fallback is True
  527. assert fallback_calls == 1
  528. @pytest.mark.asyncio
  529. async def test_structured_incomplete_optional_arguments_fail_without_fallback():
  530. definition = _definition(
  531. parameters={
  532. "type": "object",
  533. "properties": {"query": {"type": "string"}},
  534. },
  535. resolver=lambda request, context: EventArgumentResolution(
  536. arguments={},
  537. complete=False,
  538. ),
  539. handler=lambda request: {"ok": True},
  540. )
  541. result = await EventKernel(EventRegistry([definition])).execute(
  542. EventRequest(id="event-1", name="example.lookup"),
  543. enabled_names=["example.lookup"],
  544. )
  545. assert result.status is EventStatus.INVALID_ARGUMENTS
  546. assert result.error == "event arguments incomplete"
  547. @pytest.mark.asyncio
  548. async def test_plain_dict_resolution_remains_complete_for_optional_schema():
  549. fallback_calls = 0
  550. async def fallback(*args: Any) -> ResolvedEventArguments:
  551. nonlocal fallback_calls
  552. fallback_calls += 1
  553. raise AssertionError("fallback should not run")
  554. definition = _definition(
  555. parameters={
  556. "type": "object",
  557. "properties": {"query": {"type": "string"}},
  558. },
  559. resolver=lambda request, context: {},
  560. handler=lambda request: {"ok": True},
  561. )
  562. result = await EventKernel(
  563. EventRegistry([definition]), argument_fallback=fallback
  564. ).execute(
  565. EventRequest(id="event-1", name="example.lookup"),
  566. enabled_names=["example.lookup"],
  567. )
  568. assert result.status is EventStatus.SUCCESS
  569. assert fallback_calls == 0
  570. def test_sync_kernel_rejects_structured_incomplete_optional_arguments():
  571. handler_calls = 0
  572. def handler(request: EventRequest) -> dict[str, Any]:
  573. nonlocal handler_calls
  574. handler_calls += 1
  575. return {"ok": True}
  576. definition = _definition(
  577. parameters={
  578. "type": "object",
  579. "properties": {"query": {"type": "string"}},
  580. },
  581. resolver=lambda request, context: EventArgumentResolution(
  582. arguments={},
  583. complete=False,
  584. ),
  585. handler=handler,
  586. )
  587. result = EventKernel(EventRegistry([definition])).execute_sync(
  588. EventRequest(id="event-1", name="example.lookup"),
  589. enabled_names=["example.lookup"],
  590. )
  591. assert result.status is EventStatus.INVALID_ARGUMENTS
  592. assert result.error == "event arguments incomplete"
  593. assert handler_calls == 0
  594. @pytest.mark.parametrize(
  595. "resolver",
  596. [
  597. lambda request, context: EventArgumentResolution(
  598. arguments={},
  599. complete=True,
  600. ),
  601. lambda request, context: {},
  602. ],
  603. )
  604. def test_sync_kernel_preserves_complete_compatible_resolvers(resolver: Any):
  605. handler_calls = 0
  606. def handler(request: EventRequest) -> dict[str, Any]:
  607. nonlocal handler_calls
  608. handler_calls += 1
  609. return {"ok": True}
  610. definition = _definition(
  611. parameters={
  612. "type": "object",
  613. "properties": {"query": {"type": "string"}},
  614. },
  615. resolver=resolver,
  616. handler=handler,
  617. )
  618. result = EventKernel(EventRegistry([definition])).execute_sync(
  619. EventRequest(id="event-1", name="example.lookup"),
  620. enabled_names=["example.lookup"],
  621. )
  622. assert result.status is EventStatus.SUCCESS
  623. assert result.payload == {"ok": True}
  624. assert handler_calls == 1
  625. @pytest.mark.asyncio
  626. async def test_provider_resolved_arguments_are_not_rewritten_or_fallen_back():
  627. resolver_calls = 0
  628. fallback_calls = 0
  629. def resolver(*args: Any) -> dict[str, Any]:
  630. nonlocal resolver_calls
  631. resolver_calls += 1
  632. return {"query": "rewritten"}
  633. async def fallback(*args: Any) -> ResolvedEventArguments:
  634. nonlocal fallback_calls
  635. fallback_calls += 1
  636. return ResolvedEventArguments(
  637. event_name="example.lookup",
  638. arguments={"query": "fallback"},
  639. raw_arguments='{"query":"fallback"}',
  640. )
  641. registry = EventRegistry([_definition(resolver=resolver)])
  642. result = await EventKernel(registry, argument_fallback=fallback).execute(
  643. EventRequest(
  644. id="event-1",
  645. name="example.lookup",
  646. arguments={"query": "provider value"},
  647. source=EventSource.PROVIDER_RESOLVED,
  648. ),
  649. enabled_names=["example.lookup"],
  650. )
  651. assert result.status is EventStatus.SUCCESS
  652. assert result.arguments == {"query": "provider value"}
  653. assert resolver_calls == 0
  654. assert fallback_calls == 0
  655. @pytest.mark.asyncio
  656. async def test_provider_resolved_missing_arguments_return_invalid_without_fallback():
  657. fallback_calls = 0
  658. async def fallback(*args: Any) -> ResolvedEventArguments:
  659. nonlocal fallback_calls
  660. fallback_calls += 1
  661. return ResolvedEventArguments(
  662. event_name="example.lookup",
  663. arguments={"query": "fallback"},
  664. raw_arguments='{"query":"fallback"}',
  665. )
  666. result = await EventKernel(
  667. EventRegistry([_definition()]), argument_fallback=fallback
  668. ).execute(
  669. EventRequest(
  670. id="event-1",
  671. name="example.lookup",
  672. arguments={},
  673. source=EventSource.PROVIDER_RESOLVED,
  674. ),
  675. enabled_names=["example.lookup"],
  676. )
  677. assert result.status is EventStatus.INVALID_ARGUMENTS
  678. assert result.error == "missing required arguments: query"
  679. assert fallback_calls == 0
  680. @pytest.mark.asyncio
  681. @pytest.mark.parametrize(
  682. ("event_request", "enabled_names", "expected_status", "expected_error"),
  683. [
  684. (
  685. EventRequest(id="event-1", name="missing"),
  686. ["missing"],
  687. EventStatus.UNKNOWN,
  688. "unknown event",
  689. ),
  690. (
  691. EventRequest(id="event-1", name="example.lookup"),
  692. [],
  693. EventStatus.DISABLED,
  694. "event disabled",
  695. ),
  696. (
  697. EventRequest(
  698. id="event-1",
  699. name="example.lookup",
  700. arguments={"query": 42},
  701. source=EventSource.PROVIDER_RESOLVED,
  702. ),
  703. ["example.lookup"],
  704. EventStatus.INVALID_ARGUMENTS,
  705. "invalid argument type for query: expected string",
  706. ),
  707. ],
  708. )
  709. async def test_kernel_normalizes_lookup_and_validation_failures(
  710. event_request: EventRequest,
  711. enabled_names: list[str],
  712. expected_status: EventStatus,
  713. expected_error: str,
  714. ):
  715. result = await EventKernel(EventRegistry([_definition()])).execute(
  716. event_request,
  717. enabled_names=enabled_names,
  718. )
  719. assert result.status is expected_status
  720. assert result.error == expected_error
  721. @pytest.mark.asyncio
  722. async def test_kernel_normalizes_handler_exceptions():
  723. def fail(request: EventRequest) -> dict[str, Any]:
  724. raise RuntimeError("boom")
  725. result = await EventKernel(
  726. EventRegistry([_definition(handler=fail)])
  727. ).execute(
  728. EventRequest(
  729. id="event-1",
  730. name="example.lookup",
  731. arguments={"query": "value"},
  732. source=EventSource.PROVIDER_RESOLVED,
  733. ),
  734. enabled_names=["example.lookup"],
  735. )
  736. assert result.status is EventStatus.HANDLER_ERROR
  737. assert result.error == "event handler failed: boom"
  738. @pytest.mark.asyncio
  739. async def test_kernel_validates_complete_draft_2020_12_schema():
  740. definition = _definition(
  741. parameters={
  742. "type": "object",
  743. "properties": {
  744. "mode": {"enum": ["quick", "deep"]},
  745. "target": {"type": ["string", "null"]},
  746. "filters": {
  747. "type": "array",
  748. "items": {
  749. "type": "object",
  750. "properties": {"score": {"type": "number", "minimum": 0}},
  751. "required": ["score"],
  752. "additionalProperties": False,
  753. },
  754. },
  755. },
  756. "required": ["mode", "target", "filters"],
  757. "additionalProperties": False,
  758. },
  759. handler=lambda request: {"event": request.name},
  760. )
  761. kernel = EventKernel(EventRegistry([definition]))
  762. valid = await kernel.execute(
  763. EventRequest(
  764. id="valid",
  765. name=definition.name,
  766. arguments={
  767. "mode": "deep",
  768. "target": None,
  769. "filters": [{"score": 0.5}],
  770. },
  771. source=EventSource.PROVIDER_RESOLVED,
  772. ),
  773. enabled_names=[definition.name],
  774. )
  775. invalid = await kernel.execute(
  776. EventRequest(
  777. id="invalid",
  778. name=definition.name,
  779. arguments={
  780. "mode": "other",
  781. "target": 7,
  782. "filters": [{"score": -1, "extra": True}],
  783. "unexpected": True,
  784. },
  785. source=EventSource.PROVIDER_RESOLVED,
  786. ),
  787. enabled_names=[definition.name],
  788. )
  789. assert valid.status is EventStatus.SUCCESS
  790. assert invalid.status is EventStatus.INVALID_ARGUMENTS
  791. assert invalid.error.startswith("invalid event arguments:")
  792. @pytest.mark.asyncio
  793. async def test_kernel_normalizes_validator_runtime_exceptions():
  794. definition = _definition(parameters={"$ref": "urn:agent-lab:missing-schema"})
  795. result = await EventKernel(EventRegistry([definition])).execute(
  796. EventRequest(
  797. id="event-1",
  798. name=definition.name,
  799. arguments={},
  800. source=EventSource.PROVIDER_RESOLVED,
  801. ),
  802. enabled_names=[definition.name],
  803. )
  804. assert result.status is EventStatus.DEFINITION_ERROR
  805. assert result.error.startswith("event argument validation failed:")
  806. @pytest.mark.asyncio
  807. async def test_tool_registry_maps_definition_errors_to_tool_compatibility_payload():
  808. registry = ToolRegistry(
  809. [
  810. ToolDefinition(
  811. name="broken.lookup",
  812. description="Broken lookup.",
  813. parameters={"$ref": "urn:agent-lab:missing-schema"},
  814. handler=lambda event: {"tool": event.name},
  815. )
  816. ]
  817. )
  818. payload = await registry.execute_async(
  819. ToolCallEvent(
  820. id="call-1",
  821. name="broken.lookup",
  822. arguments={},
  823. raw_arguments="{}",
  824. )
  825. )
  826. assert payload["tool"] == "broken.lookup"
  827. assert payload["error"].startswith("tool definition validation failed:")
  828. @pytest.mark.asyncio
  829. @pytest.mark.parametrize("boundary", ["resolver", "fallback"])
  830. async def test_kernel_normalizes_resolution_boundary_exceptions(boundary: str):
  831. def resolver(request: EventRequest, context: EventExecutionContext) -> dict[str, Any]:
  832. if boundary == "resolver":
  833. raise RuntimeError("resolver boom")
  834. return {}
  835. async def fallback(*args: Any) -> ResolvedEventArguments:
  836. raise RuntimeError("fallback boom")
  837. result = await EventKernel(
  838. EventRegistry([_definition(resolver=resolver)]),
  839. argument_fallback=fallback,
  840. ).execute(
  841. EventRequest(id="event-1", name="example.lookup"),
  842. enabled_names=["example.lookup"],
  843. )
  844. assert result.status is EventStatus.RESOLUTION_ERROR
  845. assert result.error == f"event argument {boundary} failed: {boundary} boom"
  846. @pytest.mark.asyncio
  847. @pytest.mark.parametrize("boundary", ["resolver", "fallback"])
  848. async def test_kernel_normalizes_invalid_resolution_payloads(boundary: str):
  849. resolver = (
  850. (lambda request, context: None)
  851. if boundary == "resolver"
  852. else (lambda request, context: {})
  853. )
  854. async def fallback(*args: Any) -> Any:
  855. return {"query": "legacy bare mapping"}
  856. result = await EventKernel(
  857. EventRegistry([_definition(resolver=resolver)]),
  858. argument_fallback=fallback,
  859. ).execute(
  860. EventRequest(id="event-1", name="example.lookup"),
  861. enabled_names=["example.lookup"],
  862. )
  863. assert result.status is EventStatus.RESOLUTION_ERROR
  864. assert result.error == f"event argument {boundary} returned invalid payload"
  865. @pytest.mark.asyncio
  866. async def test_kernel_normalizes_non_json_resolver_arguments():
  867. result = await EventKernel(
  868. EventRegistry(
  869. [
  870. _definition(
  871. resolver=lambda request, context: {"query": object()}
  872. )
  873. ]
  874. )
  875. ).execute(
  876. EventRequest(id="event-1", name="example.lookup"),
  877. enabled_names=["example.lookup"],
  878. )
  879. assert result.status is EventStatus.RESOLUTION_ERROR
  880. assert result.error.startswith("event argument resolver failed to serialize:")
  881. class _ExplodingDeepcopyDict(dict[str, Any]):
  882. def __deepcopy__(self, memo: dict[int, Any]) -> dict[str, Any]:
  883. raise RuntimeError("deepcopy must not be used")
  884. @pytest.mark.asyncio
  885. @pytest.mark.parametrize("execution", ["async", "sync"])
  886. @pytest.mark.parametrize(
  887. ("source", "expected_status", "expected_error"),
  888. [
  889. (
  890. EventSource.TEXT_EVENT,
  891. EventStatus.RESOLUTION_ERROR,
  892. "event argument resolver failed to serialize: "
  893. "JSON round-trip changed payload",
  894. ),
  895. (
  896. EventSource.PROVIDER_RESOLVED,
  897. EventStatus.INVALID_ARGUMENTS,
  898. "provider-resolved event arguments are not valid JSON",
  899. ),
  900. ],
  901. )
  902. async def test_kernel_normalizes_argument_snapshot_failures_without_deepcopy(
  903. execution: str,
  904. source: EventSource,
  905. expected_status: EventStatus,
  906. expected_error: str,
  907. ):
  908. fallback_calls = 0
  909. arguments = {"query": _ExplodingDeepcopyDict({"nested": "value"})}
  910. async def fallback(*args: Any) -> ResolvedEventArguments:
  911. nonlocal fallback_calls
  912. fallback_calls += 1
  913. raise AssertionError("fallback should not run")
  914. definition = _definition(
  915. parameters={
  916. "type": "object",
  917. "properties": {"query": {"type": "object"}},
  918. "required": ["query"],
  919. },
  920. resolver=lambda request, context: arguments,
  921. handler=lambda request: {"ok": True},
  922. )
  923. request = EventRequest(
  924. id="event-1",
  925. name="example.lookup",
  926. arguments=arguments if source is EventSource.PROVIDER_RESOLVED else {},
  927. source=source,
  928. )
  929. kernel = EventKernel(EventRegistry([definition]), argument_fallback=fallback)
  930. result = (
  931. await kernel.execute(request, enabled_names=[definition.name])
  932. if execution == "async"
  933. else kernel.execute_sync(request, enabled_names=[definition.name])
  934. )
  935. assert result.status is expected_status
  936. assert result.status is not EventStatus.DEFINITION_ERROR
  937. assert result.error == expected_error
  938. assert result.used_fallback is False
  939. assert fallback_calls == 0
  940. @pytest.mark.asyncio
  941. async def test_kernel_normalizes_non_object_fallback_arguments():
  942. async def fallback(*args: Any) -> ResolvedEventArguments:
  943. return ResolvedEventArguments(
  944. event_name="example.lookup",
  945. arguments=["not", "an", "object"], # type: ignore[arg-type]
  946. raw_arguments='["not","an","object"]',
  947. )
  948. result = await EventKernel(
  949. EventRegistry([_definition(resolver=lambda request, context: {})]),
  950. argument_fallback=fallback,
  951. ).execute(
  952. EventRequest(id="event-1", name="example.lookup"),
  953. enabled_names=["example.lookup"],
  954. )
  955. assert result.status is EventStatus.RESOLUTION_ERROR
  956. assert result.error == "event argument fallback returned invalid payload"
  957. @pytest.mark.asyncio
  958. @pytest.mark.parametrize(
  959. ("resolved", "expected_error"),
  960. [
  961. (
  962. ResolvedEventArguments(
  963. event_name="another.event",
  964. arguments={"query": "value"},
  965. raw_arguments='{"query":"value"}',
  966. ),
  967. "fallback returned tool another.event for example.lookup",
  968. ),
  969. (
  970. ResolvedEventArguments(
  971. event_name="example.lookup",
  972. arguments={"query": "value"},
  973. raw_arguments="not-json",
  974. ),
  975. "fallback raw arguments are not valid JSON",
  976. ),
  977. (
  978. ResolvedEventArguments(
  979. event_name="example.lookup",
  980. arguments={"query": "parsed"},
  981. raw_arguments='{"query":"raw"}',
  982. ),
  983. "fallback raw arguments do not match parsed arguments",
  984. ),
  985. ],
  986. )
  987. async def test_kernel_rejects_inconsistent_structured_fallback(
  988. resolved: ResolvedEventArguments,
  989. expected_error: str,
  990. ):
  991. async def fallback(*args: Any) -> ResolvedEventArguments:
  992. return resolved
  993. result = await EventKernel(
  994. EventRegistry([_definition(resolver=lambda request, context: {})]),
  995. argument_fallback=fallback,
  996. ).execute(
  997. EventRequest(id="event-1", name="example.lookup"),
  998. enabled_names=["example.lookup"],
  999. )
  1000. assert result.status is EventStatus.RESOLUTION_ERROR
  1001. assert result.error == expected_error
  1002. @pytest.mark.asyncio
  1003. @pytest.mark.parametrize(
  1004. ("arguments", "raw_arguments"),
  1005. [
  1006. ({"query": 1}, '{"query":true}'),
  1007. ({"query": 1.0}, '{"query":1}'),
  1008. ({"query": {"nested": [1]}}, '{"query":{"nested":[true]}}'),
  1009. ({"query": float("nan")}, '{"query":NaN}'),
  1010. ({"query": float("inf")}, '{"query":Infinity}'),
  1011. ],
  1012. )
  1013. async def test_kernel_rejects_noncanonical_fallback_json(
  1014. arguments: dict[str, Any],
  1015. raw_arguments: str,
  1016. ):
  1017. async def fallback(*args: Any) -> ResolvedEventArguments:
  1018. return ResolvedEventArguments(
  1019. event_name="example.lookup",
  1020. arguments=arguments,
  1021. raw_arguments=raw_arguments,
  1022. )
  1023. result = await EventKernel(
  1024. EventRegistry([_definition(resolver=lambda request, context: {})]),
  1025. argument_fallback=fallback,
  1026. ).execute(
  1027. EventRequest(id="event-1", name="example.lookup"),
  1028. enabled_names=["example.lookup"],
  1029. )
  1030. assert result.status is EventStatus.RESOLUTION_ERROR
  1031. @pytest.mark.asyncio
  1032. async def test_registry_schema_is_isolated_from_caller_mutation():
  1033. parameters = {
  1034. "type": "object",
  1035. "properties": {"query": {"type": "string"}},
  1036. "required": ["query"],
  1037. "additionalProperties": False,
  1038. }
  1039. registry = EventRegistry([_definition(parameters=parameters)])
  1040. parameters["properties"]["query"]["type"] = "integer"
  1041. parameters["required"].clear()
  1042. parameters["additionalProperties"] = True
  1043. assert registry.catalog()[0]["parameters"] == {
  1044. "type": "object",
  1045. "properties": {"query": {"type": "string"}},
  1046. "required": ["query"],
  1047. "additionalProperties": False,
  1048. }
  1049. registered = registry.definition("example.lookup")
  1050. assert registered is not None
  1051. with pytest.raises(TypeError):
  1052. registered.parameters["additionalProperties"] = True
  1053. with pytest.raises(TypeError):
  1054. registered.parameters["properties"]["query"]["type"] = "integer"
  1055. with pytest.raises(AttributeError):
  1056. registered.parameters["required"].append("unexpected")
  1057. result = await EventKernel(registry).execute(
  1058. EventRequest(
  1059. id="event-1",
  1060. name="example.lookup",
  1061. arguments={"query": 42, "unexpected": True},
  1062. source=EventSource.PROVIDER_RESOLVED,
  1063. ),
  1064. enabled_names=["example.lookup"],
  1065. )
  1066. assert result.status is EventStatus.INVALID_ARGUMENTS
  1067. @pytest.mark.asyncio
  1068. async def test_kernel_passes_consistent_fallback_arguments_and_raw_json_to_handler():
  1069. captured: list[EventRequest] = []
  1070. async def fallback(*args: Any) -> ResolvedEventArguments:
  1071. return ResolvedEventArguments(
  1072. event_name="example.lookup",
  1073. arguments={"query": "resolved"},
  1074. raw_arguments='{"query":"resolved"}',
  1075. )
  1076. result = await EventKernel(
  1077. EventRegistry(
  1078. [
  1079. _definition(
  1080. resolver=lambda request, context: {},
  1081. handler=lambda request: captured.append(request) or {"ok": True},
  1082. )
  1083. ]
  1084. ),
  1085. argument_fallback=fallback,
  1086. ).execute(
  1087. EventRequest(id="event-1", name="example.lookup"),
  1088. enabled_names=["example.lookup"],
  1089. )
  1090. assert result.status is EventStatus.SUCCESS
  1091. assert captured[0].arguments == json.loads(captured[0].raw_arguments)
  1092. assert result.raw_arguments == captured[0].raw_arguments
  1093. @pytest.mark.asyncio
  1094. @pytest.mark.parametrize("execution", ["async", "sync"])
  1095. async def test_kernel_isolates_nested_handler_mutation_from_audit_values(
  1096. execution: str,
  1097. ):
  1098. caller_arguments = {"nested": {"items": ["original"]}}
  1099. handler_arguments: list[dict[str, Any]] = []
  1100. def handler(request: EventRequest) -> dict[str, Any]:
  1101. handler_arguments.append(request.arguments)
  1102. request.arguments["nested"]["items"].append("handler")
  1103. return {"nested": request.arguments["nested"]}
  1104. definition = _definition(
  1105. parameters={
  1106. "type": "object",
  1107. "properties": {
  1108. "nested": {
  1109. "type": "object",
  1110. "properties": {
  1111. "items": {"type": "array", "items": {"type": "string"}}
  1112. },
  1113. "required": ["items"],
  1114. }
  1115. },
  1116. "required": ["nested"],
  1117. },
  1118. handler=handler,
  1119. )
  1120. request = EventRequest(
  1121. id="event-1",
  1122. name="example.lookup",
  1123. arguments=caller_arguments,
  1124. raw_arguments='{"nested":{"items":["original"]}}',
  1125. source=EventSource.PROVIDER_RESOLVED,
  1126. )
  1127. kernel = EventKernel(EventRegistry([definition]))
  1128. result = (
  1129. await kernel.execute(request, enabled_names=["example.lookup"])
  1130. if execution == "async"
  1131. else kernel.execute_sync(request, enabled_names=["example.lookup"])
  1132. )
  1133. assert result.status is EventStatus.SUCCESS
  1134. assert caller_arguments == {"nested": {"items": ["original"]}}
  1135. assert request.arguments == {"nested": {"items": ["original"]}}
  1136. assert result.arguments == {"nested": {"items": ["original"]}}
  1137. assert json.loads(result.raw_arguments) == result.arguments
  1138. assert result.payload == {"nested": {"items": ["original", "handler"]}}
  1139. handler_arguments[0]["nested"]["items"].append("later")
  1140. assert result.payload == {"nested": {"items": ["original", "handler"]}}
  1141. result.payload["nested"]["items"].append("result")
  1142. assert result.arguments == {"nested": {"items": ["original"]}}
  1143. assert caller_arguments == {"nested": {"items": ["original"]}}
  1144. @pytest.mark.asyncio
  1145. @pytest.mark.parametrize("payload", [None, "text", 1, ["item"]])
  1146. async def test_kernel_normalizes_invalid_handler_payloads(payload: Any):
  1147. result = await EventKernel(
  1148. EventRegistry([_definition(handler=lambda request: payload)])
  1149. ).execute(
  1150. EventRequest(
  1151. id="event-1",
  1152. name="example.lookup",
  1153. arguments={"query": "value"},
  1154. source=EventSource.PROVIDER_RESOLVED,
  1155. ),
  1156. enabled_names=["example.lookup"],
  1157. )
  1158. assert result.status is EventStatus.HANDLER_ERROR
  1159. assert result.error == "event handler returned non-object payload"
  1160. @pytest.mark.asyncio
  1161. @pytest.mark.parametrize("execution", ["async", "sync"])
  1162. @pytest.mark.parametrize(
  1163. "invalid_value",
  1164. [object(), {"set-item"}, float("nan"), float("inf")],
  1165. )
  1166. async def test_kernel_rejects_non_json_handler_dictionary_payloads(
  1167. execution: str,
  1168. invalid_value: Any,
  1169. ):
  1170. definition = _definition(
  1171. handler=lambda request: {"invalid": invalid_value},
  1172. )
  1173. request = EventRequest(
  1174. id="event-1",
  1175. name="example.lookup",
  1176. arguments={"query": "value"},
  1177. raw_arguments='{"query":"value"}',
  1178. source=EventSource.PROVIDER_RESOLVED,
  1179. )
  1180. kernel = EventKernel(EventRegistry([definition]))
  1181. result = (
  1182. await kernel.execute(request, enabled_names=["example.lookup"])
  1183. if execution == "async"
  1184. else kernel.execute_sync(request, enabled_names=["example.lookup"])
  1185. )
  1186. assert result.status is EventStatus.HANDLER_ERROR
  1187. assert result.error == "event handler returned non-JSON payload"
  1188. class _ExplodingItemsDict(dict[str, Any]):
  1189. def items(self):
  1190. raise RuntimeError("payload items failed")
  1191. @pytest.mark.asyncio
  1192. @pytest.mark.parametrize("execution", ["async", "sync"])
  1193. async def test_kernel_normalizes_handler_payload_snapshot_exceptions(
  1194. execution: str,
  1195. ):
  1196. definition = _definition(
  1197. handler=lambda request: _ExplodingItemsDict(ok=True),
  1198. )
  1199. request = EventRequest(
  1200. id="event-1",
  1201. name=definition.name,
  1202. arguments={"query": "value"},
  1203. raw_arguments='{"query":"value"}',
  1204. source=EventSource.PROVIDER_RESOLVED,
  1205. )
  1206. kernel = EventKernel(EventRegistry([definition]))
  1207. result = (
  1208. await kernel.execute(request, enabled_names=[definition.name])
  1209. if execution == "async"
  1210. else kernel.execute_sync(request, enabled_names=[definition.name])
  1211. )
  1212. assert result.status is EventStatus.HANDLER_ERROR
  1213. assert result.error == "event handler returned non-JSON payload"
  1214. @pytest.mark.asyncio
  1215. @pytest.mark.parametrize("execution", ["async", "sync"])
  1216. async def test_kernel_does_not_swallow_base_exception_from_payload_snapshot(
  1217. execution: str,
  1218. ):
  1219. class SnapshotAbort(BaseException):
  1220. pass
  1221. class AbortingItemsDict(dict[str, Any]):
  1222. def items(self):
  1223. raise SnapshotAbort
  1224. definition = _definition(
  1225. handler=lambda request: AbortingItemsDict(ok=True),
  1226. )
  1227. request = EventRequest(
  1228. id="event-1",
  1229. name=definition.name,
  1230. arguments={"query": "value"},
  1231. source=EventSource.PROVIDER_RESOLVED,
  1232. )
  1233. kernel = EventKernel(EventRegistry([definition]))
  1234. with pytest.raises(SnapshotAbort):
  1235. if execution == "async":
  1236. await kernel.execute(request, enabled_names=[definition.name])
  1237. else:
  1238. kernel.execute_sync(request, enabled_names=[definition.name])
  1239. @pytest.mark.asyncio
  1240. async def test_tool_registry_normalizes_handler_payload_snapshot_exceptions():
  1241. registry = ToolRegistry(
  1242. [
  1243. ToolDefinition(
  1244. name="bad_payload",
  1245. description="Return a payload that fails during snapshot.",
  1246. parameters={"type": "object"},
  1247. handler=lambda event: _ExplodingItemsDict(ok=True),
  1248. )
  1249. ]
  1250. )
  1251. event = ToolCallEvent(
  1252. id="call-1",
  1253. name="bad_payload",
  1254. arguments={},
  1255. raw_arguments="{}",
  1256. )
  1257. expected = {
  1258. "tool": "bad_payload",
  1259. "error": "event handler returned non-JSON payload",
  1260. }
  1261. assert registry.execute(event) == expected
  1262. assert await registry.execute_async(event) == expected
  1263. @pytest.mark.asyncio
  1264. async def test_async_kernel_supports_async_handler():
  1265. async def handler(request: EventRequest) -> dict[str, Any]:
  1266. return {"query": request.arguments["query"]}
  1267. result = await EventKernel(
  1268. EventRegistry([_definition(handler=handler)])
  1269. ).execute(
  1270. EventRequest(
  1271. id="event-1",
  1272. name="example.lookup",
  1273. arguments={"query": "async"},
  1274. source=EventSource.PROVIDER_RESOLVED,
  1275. ),
  1276. enabled_names=["example.lookup"],
  1277. )
  1278. assert result.status is EventStatus.SUCCESS
  1279. assert result.payload == {"query": "async"}
  1280. @pytest.mark.asyncio
  1281. async def test_tool_registry_async_entry_points_support_async_handler():
  1282. async def handler(event: ToolCallEvent) -> dict[str, Any]:
  1283. return {"tool": event.name, "query": event.arguments["query"]}
  1284. registry = ToolRegistry(
  1285. [
  1286. ToolDefinition(
  1287. name="async.lookup",
  1288. description="Async lookup.",
  1289. parameters={
  1290. "type": "object",
  1291. "properties": {"query": {"type": "string"}},
  1292. "required": ["query"],
  1293. },
  1294. handler=handler,
  1295. )
  1296. ]
  1297. )
  1298. event = ToolCallEvent(
  1299. id="call-1",
  1300. name="async.lookup",
  1301. arguments={"query": "value"},
  1302. raw_arguments='{"query":"value"}',
  1303. )
  1304. assert await registry.handle_async(event) == {
  1305. "tool": "async.lookup",
  1306. "query": "value",
  1307. }
  1308. assert await registry.execute_async(event) == {
  1309. "tool": "async.lookup",
  1310. "query": "value",
  1311. }
  1312. def test_tool_registry_sync_facade_rejects_async_handler_without_runtime_warning():
  1313. called = False
  1314. async def handler(event: ToolCallEvent) -> dict[str, Any]:
  1315. nonlocal called
  1316. called = True
  1317. return {"tool": event.name}
  1318. registry = ToolRegistry(
  1319. [
  1320. ToolDefinition(
  1321. name="async.lookup",
  1322. description="Async lookup.",
  1323. parameters={"type": "object"},
  1324. handler=handler,
  1325. )
  1326. ]
  1327. )
  1328. event = ToolCallEvent(
  1329. id="call-1",
  1330. name="async.lookup",
  1331. arguments={},
  1332. raw_arguments="{}",
  1333. )
  1334. with warnings.catch_warnings(record=True) as captured:
  1335. warnings.simplefilter("always")
  1336. payload = registry.execute(event)
  1337. gc.collect()
  1338. assert payload == {
  1339. "tool": "async.lookup",
  1340. "error": "tool handler failed: async event handlers require execute_async",
  1341. }
  1342. assert called is False
  1343. assert not [warning for warning in captured if issubclass(warning.category, RuntimeWarning)]
  1344. @pytest.mark.asyncio
  1345. async def test_kernel_rejects_boolean_for_json_number_arguments():
  1346. definition = _definition(
  1347. parameters={
  1348. "type": "object",
  1349. "properties": {"query": {"type": "number"}},
  1350. "required": ["query"],
  1351. }
  1352. )
  1353. result = await EventKernel(EventRegistry([definition])).execute(
  1354. EventRequest(
  1355. id="event-1",
  1356. name="example.lookup",
  1357. arguments={"query": True},
  1358. source=EventSource.PROVIDER_RESOLVED,
  1359. ),
  1360. enabled_names=["example.lookup"],
  1361. )
  1362. assert result.status is EventStatus.INVALID_ARGUMENTS
  1363. assert result.error == "invalid argument type for query: expected number"
  1364. @pytest.mark.asyncio
  1365. async def test_definition_metadata_survives_registration_and_result_creation():
  1366. definition = _definition(
  1367. result_policy=ResultPolicy.TEMPLATE_FOLLOW_UP,
  1368. confirmation_policy=ConfirmationPolicy.REQUIRED,
  1369. risk_level=RiskLevel.HIGH,
  1370. idempotency_key_fields=("session_id", "event_id"),
  1371. concurrency_class="device-write",
  1372. conflict_keys=("device",),
  1373. timeout_seconds=1.5,
  1374. terminal=True,
  1375. )
  1376. registry = EventRegistry([definition])
  1377. result = await EventKernel(registry).execute(
  1378. EventRequest(
  1379. id="event-1",
  1380. name="example.lookup",
  1381. arguments={"query": "value"},
  1382. source=EventSource.PROVIDER_RESOLVED,
  1383. ),
  1384. enabled_names=["example.lookup"],
  1385. )
  1386. registered = registry.definition("example.lookup")
  1387. assert registered is not definition
  1388. assert result.result_policy is ResultPolicy.TEMPLATE_FOLLOW_UP
  1389. assert result.confirmation_policy is ConfirmationPolicy.REQUIRED
  1390. assert result.risk_level is RiskLevel.HIGH
  1391. assert result.idempotency_key_fields == ("session_id", "event_id")
  1392. assert result.concurrency_class == "device-write"
  1393. assert result.conflict_keys == ("device",)
  1394. assert result.timeout_seconds == 1.5
  1395. assert result.terminal is True
  1396. @pytest.mark.asyncio
  1397. @pytest.mark.parametrize("name", ["alpha.one", "beta-two", "任意.事件"])
  1398. async def test_kernel_applies_identical_behavior_to_arbitrary_event_names(name: str):
  1399. definition = _definition(name=name)
  1400. result = await EventKernel(EventRegistry([definition])).execute(
  1401. EventRequest(
  1402. id="event-1",
  1403. name=name,
  1404. arguments={"query": "value"},
  1405. source=EventSource.PROVIDER_RESOLVED,
  1406. ),
  1407. enabled_names=[name],
  1408. )
  1409. assert result.status is EventStatus.SUCCESS
  1410. assert result.payload == {"event": name, "query": "value"}
  1411. @pytest.mark.asyncio
  1412. async def test_kernel_runs_normalizer_after_text_resolver_before_validation():
  1413. captured: list[EventRequest] = []
  1414. definition = _definition(
  1415. resolver=lambda request, context: {"query": " TEXT Value "},
  1416. normalizer=lambda arguments: {"query": arguments["query"].strip().lower()},
  1417. handler=lambda request: captured.append(request) or {"ok": True},
  1418. )
  1419. result = await EventKernel(EventRegistry([definition])).execute(
  1420. EventRequest(id="event-1", name=definition.name),
  1421. enabled_names=[definition.name],
  1422. )
  1423. assert result.status is EventStatus.SUCCESS
  1424. assert result.arguments == {"query": "text value"}
  1425. assert result.raw_arguments == '{"query":"text value"}'
  1426. assert captured[0].arguments == {"query": "text value"}
  1427. assert captured[0].raw_arguments == '{"query":"text value"}'
  1428. @pytest.mark.asyncio
  1429. async def test_kernel_normalizes_provider_arguments_and_preserves_original_raw_json():
  1430. captured: list[EventRequest] = []
  1431. original_raw = '{ "query": " PROVIDER Value " }'
  1432. definition = _definition(
  1433. resolver=lambda request, context: (_ for _ in ()).throw(
  1434. AssertionError("provider must not run resolver")
  1435. ),
  1436. normalizer=lambda arguments: {"query": arguments["query"].strip().lower()},
  1437. handler=lambda request: captured.append(request) or {"ok": True},
  1438. )
  1439. result = await EventKernel(EventRegistry([definition])).execute(
  1440. EventRequest(
  1441. id="event-1",
  1442. name=definition.name,
  1443. arguments={"query": " PROVIDER Value "},
  1444. raw_arguments=original_raw,
  1445. source=EventSource.PROVIDER_RESOLVED,
  1446. ),
  1447. enabled_names=[definition.name],
  1448. )
  1449. assert result.status is EventStatus.SUCCESS
  1450. assert result.arguments == {"query": "provider value"}
  1451. assert result.raw_arguments == original_raw
  1452. assert captured[0].arguments == {"query": "provider value"}
  1453. assert captured[0].raw_arguments == original_raw
  1454. @pytest.mark.asyncio
  1455. async def test_provider_normalization_never_enables_argument_fallback():
  1456. fallback_calls = 0
  1457. async def fallback(*args: Any) -> ResolvedEventArguments:
  1458. nonlocal fallback_calls
  1459. fallback_calls += 1
  1460. return ResolvedEventArguments(
  1461. event_name="example.lookup",
  1462. arguments={"query": "fallback"},
  1463. raw_arguments='{"query":"fallback"}',
  1464. )
  1465. definition = _definition(normalizer=lambda arguments: {})
  1466. original_raw = '{"query":"provider"}'
  1467. result = await EventKernel(
  1468. EventRegistry([definition]), argument_fallback=fallback
  1469. ).execute(
  1470. EventRequest(
  1471. id="event-1",
  1472. name=definition.name,
  1473. arguments={"query": "provider"},
  1474. raw_arguments=original_raw,
  1475. source=EventSource.PROVIDER_RESOLVED,
  1476. ),
  1477. enabled_names=[definition.name],
  1478. )
  1479. assert result.status is EventStatus.INVALID_ARGUMENTS
  1480. assert result.error == "missing required arguments: query"
  1481. assert result.raw_arguments == original_raw
  1482. assert result.used_fallback is False
  1483. assert fallback_calls == 0
  1484. @pytest.mark.asyncio
  1485. @pytest.mark.parametrize("execution", ["async", "sync"])
  1486. @pytest.mark.parametrize(
  1487. ("source", "expected_status", "expected_error"),
  1488. [
  1489. (
  1490. EventSource.TEXT_EVENT,
  1491. EventStatus.RESOLUTION_ERROR,
  1492. "event argument normalizer failed",
  1493. ),
  1494. (
  1495. EventSource.PROVIDER_RESOLVED,
  1496. EventStatus.INVALID_ARGUMENTS,
  1497. "provider-resolved event arguments could not be normalized",
  1498. ),
  1499. ],
  1500. )
  1501. @pytest.mark.parametrize("normalizer_kind", ["raises", "invalid_return"])
  1502. async def test_kernel_normalizes_normalizer_failures_without_leaking_details(
  1503. execution: str,
  1504. source: EventSource,
  1505. expected_status: EventStatus,
  1506. expected_error: str,
  1507. normalizer_kind: str,
  1508. ):
  1509. fallback_calls = 0
  1510. async def fallback(*args: Any) -> ResolvedEventArguments:
  1511. nonlocal fallback_calls
  1512. fallback_calls += 1
  1513. raise AssertionError("fallback must not run")
  1514. def normalizer(arguments: dict[str, Any]):
  1515. if normalizer_kind == "raises":
  1516. raise RuntimeError("secret adapter detail")
  1517. return [arguments]
  1518. definition = _definition(
  1519. resolver=lambda request, context: {"query": "value"},
  1520. normalizer=normalizer,
  1521. )
  1522. request = EventRequest(
  1523. id="event-1",
  1524. name=definition.name,
  1525. arguments={"query": "value"} if source is EventSource.PROVIDER_RESOLVED else {},
  1526. raw_arguments='{"query":"value"}',
  1527. source=source,
  1528. )
  1529. kernel = EventKernel(EventRegistry([definition]), argument_fallback=fallback)
  1530. result = (
  1531. await kernel.execute(request, enabled_names=[definition.name])
  1532. if execution == "async"
  1533. else kernel.execute_sync(request, enabled_names=[definition.name])
  1534. )
  1535. assert result.status is expected_status
  1536. assert result.error == expected_error
  1537. assert "secret" not in (result.error or "")
  1538. assert result.used_fallback is False
  1539. assert fallback_calls == 0
  1540. def test_tool_definition_normalizer_remains_compatible_with_provider_execution():
  1541. registry = ToolRegistry(
  1542. [
  1543. ToolDefinition(
  1544. name="compat.normalize",
  1545. description="Normalize compatibility data.",
  1546. parameters={
  1547. "type": "object",
  1548. "properties": {"query": {"type": "string"}},
  1549. "required": ["query"],
  1550. },
  1551. handler=lambda event: {
  1552. "tool": event.name,
  1553. "query": event.arguments["query"],
  1554. },
  1555. normalizer=lambda arguments: {
  1556. "query": arguments["query"].strip().lower()
  1557. },
  1558. )
  1559. ]
  1560. )
  1561. payload = registry.execute(
  1562. ToolCallEvent(
  1563. id="call-1",
  1564. name="compat.normalize",
  1565. arguments={"query": " PROVIDER "},
  1566. raw_arguments='{ "query": " PROVIDER " }',
  1567. )
  1568. )
  1569. assert payload == {"tool": "compat.normalize", "query": "provider"}