test_event_kernel.py 59 KB

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