test_event_kernel.py 61 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998
  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. @pytest.mark.parametrize("handler_kind", ["async", "sync", "failure"])
  741. async def test_kernel_measures_only_actual_handler_latency(handler_kind: str):
  742. clock_values = iter([10.0, 10.025])
  743. async def async_handler(request: EventRequest) -> dict[str, Any]:
  744. return {"query": request.arguments["query"]}
  745. def sync_handler(request: EventRequest) -> dict[str, Any]:
  746. return {"query": request.arguments["query"]}
  747. def failing_handler(request: EventRequest) -> dict[str, Any]:
  748. raise RuntimeError("boom")
  749. handler = {
  750. "async": async_handler,
  751. "sync": sync_handler,
  752. "failure": failing_handler,
  753. }[handler_kind]
  754. result = await EventKernel(
  755. EventRegistry([_definition(handler=handler)]),
  756. monotonic_clock=lambda: next(clock_values),
  757. ).execute(
  758. EventRequest(
  759. id="event-1",
  760. name="example.lookup",
  761. arguments={"query": "value"},
  762. source=EventSource.PROVIDER_RESOLVED,
  763. )
  764. )
  765. assert result.tool_latency_ms == 25
  766. if handler_kind == "failure":
  767. assert result.status is EventStatus.HANDLER_ERROR
  768. else:
  769. assert result.status is EventStatus.SUCCESS
  770. @pytest.mark.asyncio
  771. async def test_kernel_handler_latency_excludes_argument_fallback_time():
  772. clock_values = iter([20.0, 20.012])
  773. async def fallback(*args: Any) -> ResolvedEventArguments:
  774. return ResolvedEventArguments(
  775. event_name="example.lookup",
  776. arguments={"query": "fallback"},
  777. raw_arguments='{"query":"fallback"}',
  778. )
  779. result = await EventKernel(
  780. EventRegistry([_definition()]),
  781. argument_fallback=fallback,
  782. monotonic_clock=lambda: next(clock_values),
  783. ).execute(
  784. EventRequest(
  785. id="event-1",
  786. name="example.lookup",
  787. arguments={},
  788. raw_arguments="{}",
  789. )
  790. )
  791. assert result.used_fallback is True
  792. assert result.tool_latency_ms == 12
  793. @pytest.mark.asyncio
  794. async def test_kernel_validates_complete_draft_2020_12_schema():
  795. definition = _definition(
  796. parameters={
  797. "type": "object",
  798. "properties": {
  799. "mode": {"enum": ["quick", "deep"]},
  800. "target": {"type": ["string", "null"]},
  801. "filters": {
  802. "type": "array",
  803. "items": {
  804. "type": "object",
  805. "properties": {"score": {"type": "number", "minimum": 0}},
  806. "required": ["score"],
  807. "additionalProperties": False,
  808. },
  809. },
  810. },
  811. "required": ["mode", "target", "filters"],
  812. "additionalProperties": False,
  813. },
  814. handler=lambda request: {"event": request.name},
  815. )
  816. kernel = EventKernel(EventRegistry([definition]))
  817. valid = await kernel.execute(
  818. EventRequest(
  819. id="valid",
  820. name=definition.name,
  821. arguments={
  822. "mode": "deep",
  823. "target": None,
  824. "filters": [{"score": 0.5}],
  825. },
  826. source=EventSource.PROVIDER_RESOLVED,
  827. ),
  828. enabled_names=[definition.name],
  829. )
  830. invalid = await kernel.execute(
  831. EventRequest(
  832. id="invalid",
  833. name=definition.name,
  834. arguments={
  835. "mode": "other",
  836. "target": 7,
  837. "filters": [{"score": -1, "extra": True}],
  838. "unexpected": True,
  839. },
  840. source=EventSource.PROVIDER_RESOLVED,
  841. ),
  842. enabled_names=[definition.name],
  843. )
  844. assert valid.status is EventStatus.SUCCESS
  845. assert invalid.status is EventStatus.INVALID_ARGUMENTS
  846. assert invalid.error.startswith("invalid event arguments:")
  847. @pytest.mark.asyncio
  848. async def test_kernel_normalizes_validator_runtime_exceptions():
  849. definition = _definition(parameters={"$ref": "urn:agent-lab:missing-schema"})
  850. result = await EventKernel(EventRegistry([definition])).execute(
  851. EventRequest(
  852. id="event-1",
  853. name=definition.name,
  854. arguments={},
  855. source=EventSource.PROVIDER_RESOLVED,
  856. ),
  857. enabled_names=[definition.name],
  858. )
  859. assert result.status is EventStatus.DEFINITION_ERROR
  860. assert result.error.startswith("event argument validation failed:")
  861. @pytest.mark.asyncio
  862. async def test_tool_registry_maps_definition_errors_to_tool_compatibility_payload():
  863. registry = ToolRegistry(
  864. [
  865. ToolDefinition(
  866. name="broken.lookup",
  867. description="Broken lookup.",
  868. parameters={"$ref": "urn:agent-lab:missing-schema"},
  869. handler=lambda event: {"tool": event.name},
  870. )
  871. ]
  872. )
  873. payload = await registry.execute_async(
  874. ToolCallEvent(
  875. id="call-1",
  876. name="broken.lookup",
  877. arguments={},
  878. raw_arguments="{}",
  879. )
  880. )
  881. assert payload["tool"] == "broken.lookup"
  882. assert payload["error"].startswith("tool definition validation failed:")
  883. @pytest.mark.asyncio
  884. @pytest.mark.parametrize("boundary", ["resolver", "fallback"])
  885. async def test_kernel_normalizes_resolution_boundary_exceptions(boundary: str):
  886. def resolver(request: EventRequest, context: EventExecutionContext) -> dict[str, Any]:
  887. if boundary == "resolver":
  888. raise RuntimeError("resolver boom")
  889. return {}
  890. async def fallback(*args: Any) -> ResolvedEventArguments:
  891. raise RuntimeError("fallback boom")
  892. result = await EventKernel(
  893. EventRegistry([_definition(resolver=resolver)]),
  894. argument_fallback=fallback,
  895. ).execute(
  896. EventRequest(id="event-1", name="example.lookup"),
  897. enabled_names=["example.lookup"],
  898. )
  899. assert result.status is EventStatus.RESOLUTION_ERROR
  900. assert result.error == f"event argument {boundary} failed: {boundary} boom"
  901. @pytest.mark.asyncio
  902. @pytest.mark.parametrize("boundary", ["resolver", "fallback"])
  903. async def test_kernel_normalizes_invalid_resolution_payloads(boundary: str):
  904. resolver = (
  905. (lambda request, context: None)
  906. if boundary == "resolver"
  907. else (lambda request, context: {})
  908. )
  909. async def fallback(*args: Any) -> Any:
  910. return {"query": "legacy bare mapping"}
  911. result = await EventKernel(
  912. EventRegistry([_definition(resolver=resolver)]),
  913. argument_fallback=fallback,
  914. ).execute(
  915. EventRequest(id="event-1", name="example.lookup"),
  916. enabled_names=["example.lookup"],
  917. )
  918. assert result.status is EventStatus.RESOLUTION_ERROR
  919. assert result.error == f"event argument {boundary} returned invalid payload"
  920. @pytest.mark.asyncio
  921. async def test_kernel_normalizes_non_json_resolver_arguments():
  922. result = await EventKernel(
  923. EventRegistry(
  924. [
  925. _definition(
  926. resolver=lambda request, context: {"query": object()}
  927. )
  928. ]
  929. )
  930. ).execute(
  931. EventRequest(id="event-1", name="example.lookup"),
  932. enabled_names=["example.lookup"],
  933. )
  934. assert result.status is EventStatus.RESOLUTION_ERROR
  935. assert result.error.startswith("event argument resolver failed to serialize:")
  936. class _ExplodingDeepcopyDict(dict[str, Any]):
  937. def __deepcopy__(self, memo: dict[int, Any]) -> dict[str, Any]:
  938. raise RuntimeError("deepcopy must not be used")
  939. @pytest.mark.asyncio
  940. @pytest.mark.parametrize("execution", ["async", "sync"])
  941. @pytest.mark.parametrize(
  942. ("source", "expected_status", "expected_error"),
  943. [
  944. (
  945. EventSource.TEXT_EVENT,
  946. EventStatus.RESOLUTION_ERROR,
  947. "event argument resolver failed to serialize: "
  948. "JSON round-trip changed payload",
  949. ),
  950. (
  951. EventSource.PROVIDER_RESOLVED,
  952. EventStatus.INVALID_ARGUMENTS,
  953. "provider-resolved event arguments are not valid JSON",
  954. ),
  955. ],
  956. )
  957. async def test_kernel_normalizes_argument_snapshot_failures_without_deepcopy(
  958. execution: str,
  959. source: EventSource,
  960. expected_status: EventStatus,
  961. expected_error: str,
  962. ):
  963. fallback_calls = 0
  964. arguments = {"query": _ExplodingDeepcopyDict({"nested": "value"})}
  965. async def fallback(*args: Any) -> ResolvedEventArguments:
  966. nonlocal fallback_calls
  967. fallback_calls += 1
  968. raise AssertionError("fallback should not run")
  969. definition = _definition(
  970. parameters={
  971. "type": "object",
  972. "properties": {"query": {"type": "object"}},
  973. "required": ["query"],
  974. },
  975. resolver=lambda request, context: arguments,
  976. handler=lambda request: {"ok": True},
  977. )
  978. request = EventRequest(
  979. id="event-1",
  980. name="example.lookup",
  981. arguments=arguments if source is EventSource.PROVIDER_RESOLVED else {},
  982. source=source,
  983. )
  984. kernel = EventKernel(EventRegistry([definition]), argument_fallback=fallback)
  985. result = (
  986. await kernel.execute(request, enabled_names=[definition.name])
  987. if execution == "async"
  988. else kernel.execute_sync(request, enabled_names=[definition.name])
  989. )
  990. assert result.status is expected_status
  991. assert result.status is not EventStatus.DEFINITION_ERROR
  992. assert result.error == expected_error
  993. assert result.used_fallback is False
  994. assert fallback_calls == 0
  995. @pytest.mark.asyncio
  996. async def test_kernel_normalizes_non_object_fallback_arguments():
  997. async def fallback(*args: Any) -> ResolvedEventArguments:
  998. return ResolvedEventArguments(
  999. event_name="example.lookup",
  1000. arguments=["not", "an", "object"], # type: ignore[arg-type]
  1001. raw_arguments='["not","an","object"]',
  1002. )
  1003. result = await EventKernel(
  1004. EventRegistry([_definition(resolver=lambda request, context: {})]),
  1005. argument_fallback=fallback,
  1006. ).execute(
  1007. EventRequest(id="event-1", name="example.lookup"),
  1008. enabled_names=["example.lookup"],
  1009. )
  1010. assert result.status is EventStatus.RESOLUTION_ERROR
  1011. assert result.error == "event argument fallback returned invalid payload"
  1012. @pytest.mark.asyncio
  1013. @pytest.mark.parametrize(
  1014. ("resolved", "expected_error"),
  1015. [
  1016. (
  1017. ResolvedEventArguments(
  1018. event_name="another.event",
  1019. arguments={"query": "value"},
  1020. raw_arguments='{"query":"value"}',
  1021. ),
  1022. "fallback returned tool another.event for example.lookup",
  1023. ),
  1024. (
  1025. ResolvedEventArguments(
  1026. event_name="example.lookup",
  1027. arguments={"query": "value"},
  1028. raw_arguments="not-json",
  1029. ),
  1030. "fallback raw arguments are not valid JSON",
  1031. ),
  1032. (
  1033. ResolvedEventArguments(
  1034. event_name="example.lookup",
  1035. arguments={"query": "parsed"},
  1036. raw_arguments='{"query":"raw"}',
  1037. ),
  1038. "fallback raw arguments do not match parsed arguments",
  1039. ),
  1040. ],
  1041. )
  1042. async def test_kernel_rejects_inconsistent_structured_fallback(
  1043. resolved: ResolvedEventArguments,
  1044. expected_error: str,
  1045. ):
  1046. async def fallback(*args: Any) -> ResolvedEventArguments:
  1047. return resolved
  1048. result = await EventKernel(
  1049. EventRegistry([_definition(resolver=lambda request, context: {})]),
  1050. argument_fallback=fallback,
  1051. ).execute(
  1052. EventRequest(id="event-1", name="example.lookup"),
  1053. enabled_names=["example.lookup"],
  1054. )
  1055. assert result.status is EventStatus.RESOLUTION_ERROR
  1056. assert result.error == expected_error
  1057. @pytest.mark.asyncio
  1058. @pytest.mark.parametrize(
  1059. ("arguments", "raw_arguments"),
  1060. [
  1061. ({"query": 1}, '{"query":true}'),
  1062. ({"query": 1.0}, '{"query":1}'),
  1063. ({"query": {"nested": [1]}}, '{"query":{"nested":[true]}}'),
  1064. ({"query": float("nan")}, '{"query":NaN}'),
  1065. ({"query": float("inf")}, '{"query":Infinity}'),
  1066. ],
  1067. )
  1068. async def test_kernel_rejects_noncanonical_fallback_json(
  1069. arguments: dict[str, Any],
  1070. raw_arguments: str,
  1071. ):
  1072. async def fallback(*args: Any) -> ResolvedEventArguments:
  1073. return ResolvedEventArguments(
  1074. event_name="example.lookup",
  1075. arguments=arguments,
  1076. raw_arguments=raw_arguments,
  1077. )
  1078. result = await EventKernel(
  1079. EventRegistry([_definition(resolver=lambda request, context: {})]),
  1080. argument_fallback=fallback,
  1081. ).execute(
  1082. EventRequest(id="event-1", name="example.lookup"),
  1083. enabled_names=["example.lookup"],
  1084. )
  1085. assert result.status is EventStatus.RESOLUTION_ERROR
  1086. @pytest.mark.asyncio
  1087. async def test_registry_schema_is_isolated_from_caller_mutation():
  1088. parameters = {
  1089. "type": "object",
  1090. "properties": {"query": {"type": "string"}},
  1091. "required": ["query"],
  1092. "additionalProperties": False,
  1093. }
  1094. registry = EventRegistry([_definition(parameters=parameters)])
  1095. parameters["properties"]["query"]["type"] = "integer"
  1096. parameters["required"].clear()
  1097. parameters["additionalProperties"] = True
  1098. assert registry.catalog()[0]["parameters"] == {
  1099. "type": "object",
  1100. "properties": {"query": {"type": "string"}},
  1101. "required": ["query"],
  1102. "additionalProperties": False,
  1103. }
  1104. registered = registry.definition("example.lookup")
  1105. assert registered is not None
  1106. with pytest.raises(TypeError):
  1107. registered.parameters["additionalProperties"] = True
  1108. with pytest.raises(TypeError):
  1109. registered.parameters["properties"]["query"]["type"] = "integer"
  1110. with pytest.raises(AttributeError):
  1111. registered.parameters["required"].append("unexpected")
  1112. result = await EventKernel(registry).execute(
  1113. EventRequest(
  1114. id="event-1",
  1115. name="example.lookup",
  1116. arguments={"query": 42, "unexpected": True},
  1117. source=EventSource.PROVIDER_RESOLVED,
  1118. ),
  1119. enabled_names=["example.lookup"],
  1120. )
  1121. assert result.status is EventStatus.INVALID_ARGUMENTS
  1122. @pytest.mark.asyncio
  1123. async def test_kernel_passes_consistent_fallback_arguments_and_raw_json_to_handler():
  1124. captured: list[EventRequest] = []
  1125. async def fallback(*args: Any) -> ResolvedEventArguments:
  1126. return ResolvedEventArguments(
  1127. event_name="example.lookup",
  1128. arguments={"query": "resolved"},
  1129. raw_arguments='{"query":"resolved"}',
  1130. )
  1131. result = await EventKernel(
  1132. EventRegistry(
  1133. [
  1134. _definition(
  1135. resolver=lambda request, context: {},
  1136. handler=lambda request: captured.append(request) or {"ok": True},
  1137. )
  1138. ]
  1139. ),
  1140. argument_fallback=fallback,
  1141. ).execute(
  1142. EventRequest(id="event-1", name="example.lookup"),
  1143. enabled_names=["example.lookup"],
  1144. )
  1145. assert result.status is EventStatus.SUCCESS
  1146. assert captured[0].arguments == json.loads(captured[0].raw_arguments)
  1147. assert result.raw_arguments == captured[0].raw_arguments
  1148. @pytest.mark.asyncio
  1149. @pytest.mark.parametrize("execution", ["async", "sync"])
  1150. async def test_kernel_isolates_nested_handler_mutation_from_audit_values(
  1151. execution: str,
  1152. ):
  1153. caller_arguments = {"nested": {"items": ["original"]}}
  1154. handler_arguments: list[dict[str, Any]] = []
  1155. def handler(request: EventRequest) -> dict[str, Any]:
  1156. handler_arguments.append(request.arguments)
  1157. request.arguments["nested"]["items"].append("handler")
  1158. return {"nested": request.arguments["nested"]}
  1159. definition = _definition(
  1160. parameters={
  1161. "type": "object",
  1162. "properties": {
  1163. "nested": {
  1164. "type": "object",
  1165. "properties": {
  1166. "items": {"type": "array", "items": {"type": "string"}}
  1167. },
  1168. "required": ["items"],
  1169. }
  1170. },
  1171. "required": ["nested"],
  1172. },
  1173. handler=handler,
  1174. )
  1175. request = EventRequest(
  1176. id="event-1",
  1177. name="example.lookup",
  1178. arguments=caller_arguments,
  1179. raw_arguments='{"nested":{"items":["original"]}}',
  1180. source=EventSource.PROVIDER_RESOLVED,
  1181. )
  1182. kernel = EventKernel(EventRegistry([definition]))
  1183. result = (
  1184. await kernel.execute(request, enabled_names=["example.lookup"])
  1185. if execution == "async"
  1186. else kernel.execute_sync(request, enabled_names=["example.lookup"])
  1187. )
  1188. assert result.status is EventStatus.SUCCESS
  1189. assert caller_arguments == {"nested": {"items": ["original"]}}
  1190. assert request.arguments == {"nested": {"items": ["original"]}}
  1191. assert result.arguments == {"nested": {"items": ["original"]}}
  1192. assert json.loads(result.raw_arguments) == result.arguments
  1193. assert result.payload == {"nested": {"items": ["original", "handler"]}}
  1194. handler_arguments[0]["nested"]["items"].append("later")
  1195. assert result.payload == {"nested": {"items": ["original", "handler"]}}
  1196. result.payload["nested"]["items"].append("result")
  1197. assert result.arguments == {"nested": {"items": ["original"]}}
  1198. assert caller_arguments == {"nested": {"items": ["original"]}}
  1199. @pytest.mark.asyncio
  1200. @pytest.mark.parametrize("payload", [None, "text", 1, ["item"]])
  1201. async def test_kernel_normalizes_invalid_handler_payloads(payload: Any):
  1202. result = await EventKernel(
  1203. EventRegistry([_definition(handler=lambda request: payload)])
  1204. ).execute(
  1205. EventRequest(
  1206. id="event-1",
  1207. name="example.lookup",
  1208. arguments={"query": "value"},
  1209. source=EventSource.PROVIDER_RESOLVED,
  1210. ),
  1211. enabled_names=["example.lookup"],
  1212. )
  1213. assert result.status is EventStatus.HANDLER_ERROR
  1214. assert result.error == "event handler returned non-object payload"
  1215. @pytest.mark.asyncio
  1216. @pytest.mark.parametrize("execution", ["async", "sync"])
  1217. @pytest.mark.parametrize(
  1218. "invalid_value",
  1219. [object(), {"set-item"}, float("nan"), float("inf")],
  1220. )
  1221. async def test_kernel_rejects_non_json_handler_dictionary_payloads(
  1222. execution: str,
  1223. invalid_value: Any,
  1224. ):
  1225. definition = _definition(
  1226. handler=lambda request: {"invalid": invalid_value},
  1227. )
  1228. request = EventRequest(
  1229. id="event-1",
  1230. name="example.lookup",
  1231. arguments={"query": "value"},
  1232. raw_arguments='{"query":"value"}',
  1233. source=EventSource.PROVIDER_RESOLVED,
  1234. )
  1235. kernel = EventKernel(EventRegistry([definition]))
  1236. result = (
  1237. await kernel.execute(request, enabled_names=["example.lookup"])
  1238. if execution == "async"
  1239. else kernel.execute_sync(request, enabled_names=["example.lookup"])
  1240. )
  1241. assert result.status is EventStatus.HANDLER_ERROR
  1242. assert result.error == "event handler returned non-JSON payload"
  1243. class _ExplodingItemsDict(dict[str, Any]):
  1244. def items(self):
  1245. raise RuntimeError("payload items failed")
  1246. @pytest.mark.asyncio
  1247. @pytest.mark.parametrize("execution", ["async", "sync"])
  1248. async def test_kernel_normalizes_handler_payload_snapshot_exceptions(
  1249. execution: str,
  1250. ):
  1251. definition = _definition(
  1252. handler=lambda request: _ExplodingItemsDict(ok=True),
  1253. )
  1254. request = EventRequest(
  1255. id="event-1",
  1256. name=definition.name,
  1257. arguments={"query": "value"},
  1258. raw_arguments='{"query":"value"}',
  1259. source=EventSource.PROVIDER_RESOLVED,
  1260. )
  1261. kernel = EventKernel(EventRegistry([definition]))
  1262. result = (
  1263. await kernel.execute(request, enabled_names=[definition.name])
  1264. if execution == "async"
  1265. else kernel.execute_sync(request, enabled_names=[definition.name])
  1266. )
  1267. assert result.status is EventStatus.HANDLER_ERROR
  1268. assert result.error == "event handler returned non-JSON payload"
  1269. @pytest.mark.asyncio
  1270. @pytest.mark.parametrize("execution", ["async", "sync"])
  1271. async def test_kernel_does_not_swallow_base_exception_from_payload_snapshot(
  1272. execution: str,
  1273. ):
  1274. class SnapshotAbort(BaseException):
  1275. pass
  1276. class AbortingItemsDict(dict[str, Any]):
  1277. def items(self):
  1278. raise SnapshotAbort
  1279. definition = _definition(
  1280. handler=lambda request: AbortingItemsDict(ok=True),
  1281. )
  1282. request = EventRequest(
  1283. id="event-1",
  1284. name=definition.name,
  1285. arguments={"query": "value"},
  1286. source=EventSource.PROVIDER_RESOLVED,
  1287. )
  1288. kernel = EventKernel(EventRegistry([definition]))
  1289. with pytest.raises(SnapshotAbort):
  1290. if execution == "async":
  1291. await kernel.execute(request, enabled_names=[definition.name])
  1292. else:
  1293. kernel.execute_sync(request, enabled_names=[definition.name])
  1294. @pytest.mark.asyncio
  1295. async def test_tool_registry_normalizes_handler_payload_snapshot_exceptions():
  1296. registry = ToolRegistry(
  1297. [
  1298. ToolDefinition(
  1299. name="bad_payload",
  1300. description="Return a payload that fails during snapshot.",
  1301. parameters={"type": "object"},
  1302. handler=lambda event: _ExplodingItemsDict(ok=True),
  1303. )
  1304. ]
  1305. )
  1306. event = ToolCallEvent(
  1307. id="call-1",
  1308. name="bad_payload",
  1309. arguments={},
  1310. raw_arguments="{}",
  1311. )
  1312. expected = {
  1313. "tool": "bad_payload",
  1314. "error": "event handler returned non-JSON payload",
  1315. }
  1316. assert registry.execute(event) == expected
  1317. assert await registry.execute_async(event) == expected
  1318. @pytest.mark.asyncio
  1319. async def test_async_kernel_supports_async_handler():
  1320. async def handler(request: EventRequest) -> dict[str, Any]:
  1321. return {"query": request.arguments["query"]}
  1322. result = await EventKernel(
  1323. EventRegistry([_definition(handler=handler)])
  1324. ).execute(
  1325. EventRequest(
  1326. id="event-1",
  1327. name="example.lookup",
  1328. arguments={"query": "async"},
  1329. source=EventSource.PROVIDER_RESOLVED,
  1330. ),
  1331. enabled_names=["example.lookup"],
  1332. )
  1333. assert result.status is EventStatus.SUCCESS
  1334. assert result.payload == {"query": "async"}
  1335. @pytest.mark.asyncio
  1336. async def test_async_kernel_offloads_sync_handler_but_execute_sync_stays_inline():
  1337. caller_thread = threading.get_ident()
  1338. handler_threads: list[int] = []
  1339. def handler(request: EventRequest) -> dict[str, Any]:
  1340. handler_threads.append(threading.get_ident())
  1341. return {"query": request.arguments["query"]}
  1342. kernel = EventKernel(EventRegistry([_definition(handler=handler)]))
  1343. request = EventRequest(
  1344. id="threaded",
  1345. name="example.lookup",
  1346. arguments={"query": "threaded"},
  1347. raw_arguments='{"query":"threaded"}',
  1348. )
  1349. async_result = await kernel.execute(request)
  1350. sync_result = kernel.execute_sync(request)
  1351. assert async_result.status is EventStatus.SUCCESS
  1352. assert sync_result.status is EventStatus.SUCCESS
  1353. assert handler_threads[0] != caller_thread
  1354. assert handler_threads[1] == caller_thread
  1355. @pytest.mark.asyncio
  1356. async def test_tool_registry_async_entry_points_support_async_handler():
  1357. async def handler(event: ToolCallEvent) -> dict[str, Any]:
  1358. return {"tool": event.name, "query": event.arguments["query"]}
  1359. registry = ToolRegistry(
  1360. [
  1361. ToolDefinition(
  1362. name="async.lookup",
  1363. description="Async lookup.",
  1364. parameters={
  1365. "type": "object",
  1366. "properties": {"query": {"type": "string"}},
  1367. "required": ["query"],
  1368. },
  1369. handler=handler,
  1370. )
  1371. ]
  1372. )
  1373. event = ToolCallEvent(
  1374. id="call-1",
  1375. name="async.lookup",
  1376. arguments={"query": "value"},
  1377. raw_arguments='{"query":"value"}',
  1378. )
  1379. assert await registry.handle_async(event) == {
  1380. "tool": "async.lookup",
  1381. "query": "value",
  1382. }
  1383. assert await registry.execute_async(event) == {
  1384. "tool": "async.lookup",
  1385. "query": "value",
  1386. }
  1387. def test_tool_registry_sync_facade_rejects_async_handler_without_runtime_warning():
  1388. called = False
  1389. async def handler(event: ToolCallEvent) -> dict[str, Any]:
  1390. nonlocal called
  1391. called = True
  1392. return {"tool": event.name}
  1393. registry = ToolRegistry(
  1394. [
  1395. ToolDefinition(
  1396. name="async.lookup",
  1397. description="Async lookup.",
  1398. parameters={"type": "object"},
  1399. handler=handler,
  1400. )
  1401. ]
  1402. )
  1403. event = ToolCallEvent(
  1404. id="call-1",
  1405. name="async.lookup",
  1406. arguments={},
  1407. raw_arguments="{}",
  1408. )
  1409. with warnings.catch_warnings(record=True) as captured:
  1410. warnings.simplefilter("always")
  1411. payload = registry.execute(event)
  1412. gc.collect()
  1413. assert payload == {
  1414. "tool": "async.lookup",
  1415. "error": "tool handler failed: async event handlers require execute_async",
  1416. }
  1417. assert called is False
  1418. assert not [warning for warning in captured if issubclass(warning.category, RuntimeWarning)]
  1419. @pytest.mark.asyncio
  1420. async def test_kernel_rejects_boolean_for_json_number_arguments():
  1421. definition = _definition(
  1422. parameters={
  1423. "type": "object",
  1424. "properties": {"query": {"type": "number"}},
  1425. "required": ["query"],
  1426. }
  1427. )
  1428. result = await EventKernel(EventRegistry([definition])).execute(
  1429. EventRequest(
  1430. id="event-1",
  1431. name="example.lookup",
  1432. arguments={"query": True},
  1433. source=EventSource.PROVIDER_RESOLVED,
  1434. ),
  1435. enabled_names=["example.lookup"],
  1436. )
  1437. assert result.status is EventStatus.INVALID_ARGUMENTS
  1438. assert result.error == "invalid argument type for query: expected number"
  1439. @pytest.mark.asyncio
  1440. async def test_definition_metadata_survives_registration_and_result_creation():
  1441. definition = _definition(
  1442. result_policy=ResultPolicy.TEMPLATE_FOLLOW_UP,
  1443. confirmation_policy=ConfirmationPolicy.REQUIRED,
  1444. risk_level=RiskLevel.HIGH,
  1445. idempotency_key_fields=("session_id", "event_id"),
  1446. concurrency_class="device-write",
  1447. conflict_keys=("device",),
  1448. timeout_seconds=1.5,
  1449. terminal=True,
  1450. )
  1451. registry = EventRegistry([definition])
  1452. result = await EventKernel(registry).execute(
  1453. EventRequest(
  1454. id="event-1",
  1455. name="example.lookup",
  1456. arguments={"query": "value"},
  1457. source=EventSource.PROVIDER_RESOLVED,
  1458. ),
  1459. enabled_names=["example.lookup"],
  1460. )
  1461. registered = registry.definition("example.lookup")
  1462. assert registered is not definition
  1463. assert result.result_policy is ResultPolicy.TEMPLATE_FOLLOW_UP
  1464. assert result.confirmation_policy is ConfirmationPolicy.REQUIRED
  1465. assert result.risk_level is RiskLevel.HIGH
  1466. assert result.idempotency_key_fields == ("session_id", "event_id")
  1467. assert result.concurrency_class == "device-write"
  1468. assert result.conflict_keys == ("device",)
  1469. assert result.timeout_seconds == 1.5
  1470. assert result.terminal is True
  1471. @pytest.mark.asyncio
  1472. @pytest.mark.parametrize("name", ["alpha.one", "beta-two", "任意.事件"])
  1473. async def test_kernel_applies_identical_behavior_to_arbitrary_event_names(name: str):
  1474. definition = _definition(name=name)
  1475. result = await EventKernel(EventRegistry([definition])).execute(
  1476. EventRequest(
  1477. id="event-1",
  1478. name=name,
  1479. arguments={"query": "value"},
  1480. source=EventSource.PROVIDER_RESOLVED,
  1481. ),
  1482. enabled_names=[name],
  1483. )
  1484. assert result.status is EventStatus.SUCCESS
  1485. assert result.payload == {"event": name, "query": "value"}
  1486. @pytest.mark.asyncio
  1487. async def test_kernel_runs_normalizer_after_text_resolver_before_validation():
  1488. captured: list[EventRequest] = []
  1489. definition = _definition(
  1490. resolver=lambda request, context: {"query": " TEXT Value "},
  1491. normalizer=lambda arguments: {"query": arguments["query"].strip().lower()},
  1492. handler=lambda request: captured.append(request) or {"ok": True},
  1493. )
  1494. result = await EventKernel(EventRegistry([definition])).execute(
  1495. EventRequest(id="event-1", name=definition.name),
  1496. enabled_names=[definition.name],
  1497. )
  1498. assert result.status is EventStatus.SUCCESS
  1499. assert result.arguments == {"query": "text value"}
  1500. assert result.raw_arguments == '{"query":"text value"}'
  1501. assert captured[0].arguments == {"query": "text value"}
  1502. assert captured[0].raw_arguments == '{"query":"text value"}'
  1503. @pytest.mark.asyncio
  1504. async def test_kernel_normalizes_provider_arguments_and_preserves_original_raw_json():
  1505. captured: list[EventRequest] = []
  1506. original_raw = '{ "query": " PROVIDER Value " }'
  1507. definition = _definition(
  1508. resolver=lambda request, context: (_ for _ in ()).throw(
  1509. AssertionError("provider must not run resolver")
  1510. ),
  1511. normalizer=lambda arguments: {"query": arguments["query"].strip().lower()},
  1512. handler=lambda request: captured.append(request) or {"ok": True},
  1513. )
  1514. result = await EventKernel(EventRegistry([definition])).execute(
  1515. EventRequest(
  1516. id="event-1",
  1517. name=definition.name,
  1518. arguments={"query": " PROVIDER Value "},
  1519. raw_arguments=original_raw,
  1520. source=EventSource.PROVIDER_RESOLVED,
  1521. ),
  1522. enabled_names=[definition.name],
  1523. )
  1524. assert result.status is EventStatus.SUCCESS
  1525. assert result.arguments == {"query": "provider value"}
  1526. assert result.raw_arguments == original_raw
  1527. assert captured[0].arguments == {"query": "provider value"}
  1528. assert captured[0].raw_arguments == original_raw
  1529. @pytest.mark.asyncio
  1530. async def test_provider_normalization_never_enables_argument_fallback():
  1531. fallback_calls = 0
  1532. async def fallback(*args: Any) -> ResolvedEventArguments:
  1533. nonlocal fallback_calls
  1534. fallback_calls += 1
  1535. return ResolvedEventArguments(
  1536. event_name="example.lookup",
  1537. arguments={"query": "fallback"},
  1538. raw_arguments='{"query":"fallback"}',
  1539. )
  1540. definition = _definition(normalizer=lambda arguments: {})
  1541. original_raw = '{"query":"provider"}'
  1542. result = await EventKernel(
  1543. EventRegistry([definition]), argument_fallback=fallback
  1544. ).execute(
  1545. EventRequest(
  1546. id="event-1",
  1547. name=definition.name,
  1548. arguments={"query": "provider"},
  1549. raw_arguments=original_raw,
  1550. source=EventSource.PROVIDER_RESOLVED,
  1551. ),
  1552. enabled_names=[definition.name],
  1553. )
  1554. assert result.status is EventStatus.INVALID_ARGUMENTS
  1555. assert result.error == "missing required arguments: query"
  1556. assert result.raw_arguments == original_raw
  1557. assert result.used_fallback is False
  1558. assert fallback_calls == 0
  1559. @pytest.mark.asyncio
  1560. @pytest.mark.parametrize("execution", ["async", "sync"])
  1561. @pytest.mark.parametrize(
  1562. ("source", "expected_status", "expected_error"),
  1563. [
  1564. (
  1565. EventSource.TEXT_EVENT,
  1566. EventStatus.RESOLUTION_ERROR,
  1567. "event argument normalizer failed",
  1568. ),
  1569. (
  1570. EventSource.PROVIDER_RESOLVED,
  1571. EventStatus.INVALID_ARGUMENTS,
  1572. "provider-resolved event arguments could not be normalized",
  1573. ),
  1574. ],
  1575. )
  1576. @pytest.mark.parametrize("normalizer_kind", ["raises", "invalid_return"])
  1577. async def test_kernel_normalizes_normalizer_failures_without_leaking_details(
  1578. execution: str,
  1579. source: EventSource,
  1580. expected_status: EventStatus,
  1581. expected_error: str,
  1582. normalizer_kind: str,
  1583. ):
  1584. fallback_calls = 0
  1585. async def fallback(*args: Any) -> ResolvedEventArguments:
  1586. nonlocal fallback_calls
  1587. fallback_calls += 1
  1588. raise AssertionError("fallback must not run")
  1589. def normalizer(arguments: dict[str, Any]):
  1590. if normalizer_kind == "raises":
  1591. raise RuntimeError("secret adapter detail")
  1592. return [arguments]
  1593. definition = _definition(
  1594. resolver=lambda request, context: {"query": "value"},
  1595. normalizer=normalizer,
  1596. )
  1597. request = EventRequest(
  1598. id="event-1",
  1599. name=definition.name,
  1600. arguments={"query": "value"} if source is EventSource.PROVIDER_RESOLVED else {},
  1601. raw_arguments='{"query":"value"}',
  1602. source=source,
  1603. )
  1604. kernel = EventKernel(EventRegistry([definition]), argument_fallback=fallback)
  1605. result = (
  1606. await kernel.execute(request, enabled_names=[definition.name])
  1607. if execution == "async"
  1608. else kernel.execute_sync(request, enabled_names=[definition.name])
  1609. )
  1610. assert result.status is expected_status
  1611. assert result.error == expected_error
  1612. assert "secret" not in (result.error or "")
  1613. assert result.used_fallback is False
  1614. assert fallback_calls == 0
  1615. def test_tool_definition_normalizer_remains_compatible_with_provider_execution():
  1616. registry = ToolRegistry(
  1617. [
  1618. ToolDefinition(
  1619. name="compat.normalize",
  1620. description="Normalize compatibility data.",
  1621. parameters={
  1622. "type": "object",
  1623. "properties": {"query": {"type": "string"}},
  1624. "required": ["query"],
  1625. },
  1626. handler=lambda event: {
  1627. "tool": event.name,
  1628. "query": event.arguments["query"],
  1629. },
  1630. normalizer=lambda arguments: {
  1631. "query": arguments["query"].strip().lower()
  1632. },
  1633. )
  1634. ]
  1635. )
  1636. payload = registry.execute(
  1637. ToolCallEvent(
  1638. id="call-1",
  1639. name="compat.normalize",
  1640. arguments={"query": " PROVIDER "},
  1641. raw_arguments='{ "query": " PROVIDER " }',
  1642. )
  1643. )
  1644. assert payload == {"tool": "compat.normalize", "query": "provider"}
  1645. @pytest.mark.asyncio
  1646. async def test_post_fallback_normalizer_failure_preserves_used_fallback_flag():
  1647. normalizer_calls = 0
  1648. def normalizer(arguments: dict[str, Any]) -> dict[str, Any]:
  1649. nonlocal normalizer_calls
  1650. normalizer_calls += 1
  1651. if "query" in arguments:
  1652. raise RuntimeError("post-fallback failure")
  1653. return arguments
  1654. async def fallback(*args: Any) -> ResolvedEventArguments:
  1655. return ResolvedEventArguments(
  1656. event_name="example.lookup",
  1657. arguments={"query": "fallback"},
  1658. raw_arguments='{"query":"fallback"}',
  1659. )
  1660. definition = _definition(
  1661. resolver=lambda request, context: {},
  1662. normalizer=normalizer,
  1663. )
  1664. result = await EventKernel(
  1665. EventRegistry([definition]),
  1666. argument_fallback=fallback,
  1667. ).execute(
  1668. EventRequest(id="event-1", name=definition.name),
  1669. enabled_names=[definition.name],
  1670. )
  1671. assert result.status is EventStatus.RESOLUTION_ERROR
  1672. assert result.error == "event argument normalizer failed"
  1673. assert result.used_fallback is True
  1674. assert normalizer_calls == 2
  1675. def test_event_definition_preserves_historical_positional_field_order():
  1676. definition = EventDefinition(
  1677. "compat.event",
  1678. "Compatibility event.",
  1679. {"type": "object"},
  1680. lambda request: {"ok": True},
  1681. None,
  1682. "legacy-schema",
  1683. False,
  1684. )
  1685. assert definition.resolver is None
  1686. assert definition.schema_version == "legacy-schema"
  1687. assert definition.fallback_allowed is False
  1688. assert definition.normalizer is None
  1689. def test_tool_definition_preserves_historical_positional_field_order():
  1690. definition = ToolDefinition(
  1691. "compat.tool",
  1692. "Compatibility tool.",
  1693. {"type": "object"},
  1694. lambda event: {"ok": True},
  1695. None,
  1696. "legacy-schema",
  1697. False,
  1698. )
  1699. assert definition.argument_resolver is None
  1700. assert definition.schema_version == "legacy-schema"
  1701. assert definition.fallback_allowed is False
  1702. assert definition.normalizer is None