test_event_kernel.py 58 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905
  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"}
  1570. @pytest.mark.asyncio
  1571. async def test_post_fallback_normalizer_failure_preserves_used_fallback_flag():
  1572. normalizer_calls = 0
  1573. def normalizer(arguments: dict[str, Any]) -> dict[str, Any]:
  1574. nonlocal normalizer_calls
  1575. normalizer_calls += 1
  1576. if "query" in arguments:
  1577. raise RuntimeError("post-fallback failure")
  1578. return arguments
  1579. async def fallback(*args: Any) -> ResolvedEventArguments:
  1580. return ResolvedEventArguments(
  1581. event_name="example.lookup",
  1582. arguments={"query": "fallback"},
  1583. raw_arguments='{"query":"fallback"}',
  1584. )
  1585. definition = _definition(
  1586. resolver=lambda request, context: {},
  1587. normalizer=normalizer,
  1588. )
  1589. result = await EventKernel(
  1590. EventRegistry([definition]),
  1591. argument_fallback=fallback,
  1592. ).execute(
  1593. EventRequest(id="event-1", name=definition.name),
  1594. enabled_names=[definition.name],
  1595. )
  1596. assert result.status is EventStatus.RESOLUTION_ERROR
  1597. assert result.error == "event argument normalizer failed"
  1598. assert result.used_fallback is True
  1599. assert normalizer_calls == 2
  1600. def test_event_definition_preserves_historical_positional_field_order():
  1601. definition = EventDefinition(
  1602. "compat.event",
  1603. "Compatibility event.",
  1604. {"type": "object"},
  1605. lambda request: {"ok": True},
  1606. None,
  1607. "legacy-schema",
  1608. False,
  1609. )
  1610. assert definition.resolver is None
  1611. assert definition.schema_version == "legacy-schema"
  1612. assert definition.fallback_allowed is False
  1613. assert definition.normalizer is None
  1614. def test_tool_definition_preserves_historical_positional_field_order():
  1615. definition = ToolDefinition(
  1616. "compat.tool",
  1617. "Compatibility tool.",
  1618. {"type": "object"},
  1619. lambda event: {"ok": True},
  1620. None,
  1621. "legacy-schema",
  1622. False,
  1623. )
  1624. assert definition.argument_resolver is None
  1625. assert definition.schema_version == "legacy-schema"
  1626. assert definition.fallback_allowed is False
  1627. assert definition.normalizer is None