test_event_kernel.py 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587
  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. def _discriminated_composed_parameters(composition: str) -> dict[str, Any]:
  279. return {
  280. "type": "object",
  281. "properties": {
  282. "choice": {
  283. composition: [
  284. {
  285. "type": "object",
  286. "properties": {
  287. "kind": {"const": "a"},
  288. "value": {"type": "string"},
  289. },
  290. "required": ["kind", "value"],
  291. },
  292. {
  293. "type": "object",
  294. "properties": {
  295. "kind": {"const": "b"},
  296. "count": {"type": "integer"},
  297. },
  298. "required": ["kind", "count"],
  299. },
  300. ]
  301. }
  302. },
  303. "required": ["choice"],
  304. }
  305. @pytest.mark.asyncio
  306. @pytest.mark.parametrize("composition", ["anyOf", "oneOf"])
  307. async def test_kernel_falls_back_for_matching_composed_branch_missing_required(
  308. composition: str,
  309. ):
  310. fallback_calls = 0
  311. async def fallback(*args: Any) -> ResolvedEventArguments:
  312. nonlocal fallback_calls
  313. fallback_calls += 1
  314. return ResolvedEventArguments(
  315. event_name="example.lookup",
  316. arguments={"choice": {"kind": "a", "value": "resolved"}},
  317. raw_arguments='{"choice":{"kind":"a","value":"resolved"}}',
  318. )
  319. definition = _definition(
  320. parameters=_discriminated_composed_parameters(composition),
  321. resolver=lambda request, context: {"choice": {"kind": "a"}},
  322. handler=lambda request: {"ok": True},
  323. )
  324. result = await EventKernel(
  325. EventRegistry([definition]), argument_fallback=fallback
  326. ).execute(
  327. EventRequest(id="event-1", name="example.lookup"),
  328. enabled_names=["example.lookup"],
  329. )
  330. assert result.status is EventStatus.SUCCESS
  331. assert result.used_fallback is True
  332. assert fallback_calls == 1
  333. @pytest.mark.asyncio
  334. @pytest.mark.parametrize("composition", ["anyOf", "oneOf"])
  335. async def test_kernel_does_not_fallback_when_matching_composed_branch_is_invalid(
  336. composition: str,
  337. ):
  338. fallback_calls = 0
  339. async def fallback(*args: Any) -> ResolvedEventArguments:
  340. nonlocal fallback_calls
  341. fallback_calls += 1
  342. raise AssertionError("fallback should not run")
  343. definition = _definition(
  344. parameters=_discriminated_composed_parameters(composition),
  345. resolver=lambda request, context: {
  346. "choice": {"kind": "a", "value": 42}
  347. },
  348. handler=lambda request: {"ok": True},
  349. )
  350. result = await EventKernel(
  351. EventRegistry([definition]), argument_fallback=fallback
  352. ).execute(
  353. EventRequest(id="event-1", name="example.lookup"),
  354. enabled_names=["example.lookup"],
  355. )
  356. assert result.status is EventStatus.INVALID_ARGUMENTS
  357. assert result.used_fallback is False
  358. assert fallback_calls == 0
  359. @pytest.mark.asyncio
  360. @pytest.mark.parametrize("composition", ["anyOf", "oneOf"])
  361. @pytest.mark.parametrize("choice", [{}, {"kind": "other"}])
  362. async def test_kernel_does_not_fallback_when_composed_branch_is_ambiguous(
  363. composition: str,
  364. choice: dict[str, Any],
  365. ):
  366. fallback_calls = 0
  367. async def fallback(*args: Any) -> ResolvedEventArguments:
  368. nonlocal fallback_calls
  369. fallback_calls += 1
  370. raise AssertionError("fallback should not run")
  371. definition = _definition(
  372. parameters=_discriminated_composed_parameters(composition),
  373. resolver=lambda request, context: {"choice": choice},
  374. handler=lambda request: {"ok": True},
  375. )
  376. result = await EventKernel(
  377. EventRegistry([definition]), argument_fallback=fallback
  378. ).execute(
  379. EventRequest(id="event-1", name="example.lookup"),
  380. enabled_names=["example.lookup"],
  381. )
  382. assert result.status is EventStatus.INVALID_ARGUMENTS
  383. assert result.used_fallback is False
  384. assert fallback_calls == 0
  385. @pytest.mark.asyncio
  386. async def test_kernel_does_not_fallback_for_substantive_and_composed_missing_errors():
  387. fallback_calls = 0
  388. async def fallback(*args: Any) -> ResolvedEventArguments:
  389. nonlocal fallback_calls
  390. fallback_calls += 1
  391. raise AssertionError("fallback should not run")
  392. parameters = _discriminated_composed_parameters("anyOf")
  393. parameters["properties"]["mode"] = {"enum": ["valid"]}
  394. definition = _definition(
  395. parameters=parameters,
  396. resolver=lambda request, context: {
  397. "mode": "invalid",
  398. "choice": {"kind": "a"},
  399. },
  400. handler=lambda request: {"ok": True},
  401. )
  402. result = await EventKernel(
  403. EventRegistry([definition]), argument_fallback=fallback
  404. ).execute(
  405. EventRequest(id="event-1", name=definition.name),
  406. enabled_names=[definition.name],
  407. )
  408. assert result.status is EventStatus.INVALID_ARGUMENTS
  409. assert result.error == "invalid event arguments: 'invalid' is not one of ['valid']"
  410. assert result.used_fallback is False
  411. assert fallback_calls == 0
  412. @pytest.mark.asyncio
  413. async def test_kernel_does_not_fallback_when_definition_disallows_it():
  414. fallback_calls = 0
  415. async def fallback(*args: Any) -> ResolvedEventArguments:
  416. nonlocal fallback_calls
  417. fallback_calls += 1
  418. return ResolvedEventArguments(
  419. event_name="example.lookup",
  420. arguments={"query": "not allowed"},
  421. raw_arguments='{"query":"not allowed"}',
  422. )
  423. registry = EventRegistry(
  424. [
  425. _definition(
  426. resolver=lambda request, context: {},
  427. fallback_allowed=False,
  428. )
  429. ]
  430. )
  431. result = await EventKernel(registry, argument_fallback=fallback).execute(
  432. EventRequest(id="event-1", name="example.lookup"),
  433. enabled_names=["example.lookup"],
  434. )
  435. assert result.status is EventStatus.INVALID_ARGUMENTS
  436. assert result.error == "missing required arguments: query"
  437. assert result.used_fallback is False
  438. assert fallback_calls == 0
  439. @pytest.mark.asyncio
  440. async def test_structured_resolution_can_mark_optional_arguments_incomplete():
  441. fallback_calls = 0
  442. async def fallback(*args: Any) -> ResolvedEventArguments:
  443. nonlocal fallback_calls
  444. fallback_calls += 1
  445. return ResolvedEventArguments(
  446. event_name="example.lookup",
  447. arguments={"query": "resolved"},
  448. raw_arguments='{"query":"resolved"}',
  449. )
  450. definition = _definition(
  451. parameters={
  452. "type": "object",
  453. "properties": {"query": {"type": "string"}},
  454. "additionalProperties": False,
  455. },
  456. resolver=lambda request, context: EventArgumentResolution(
  457. arguments={},
  458. complete=False,
  459. ),
  460. )
  461. result = await EventKernel(
  462. EventRegistry([definition]), argument_fallback=fallback
  463. ).execute(
  464. EventRequest(id="event-1", name="example.lookup"),
  465. enabled_names=["example.lookup"],
  466. )
  467. assert result.status is EventStatus.SUCCESS
  468. assert result.arguments == {"query": "resolved"}
  469. assert result.used_fallback is True
  470. assert fallback_calls == 1
  471. @pytest.mark.asyncio
  472. async def test_structured_incomplete_optional_arguments_fail_without_fallback():
  473. definition = _definition(
  474. parameters={
  475. "type": "object",
  476. "properties": {"query": {"type": "string"}},
  477. },
  478. resolver=lambda request, context: EventArgumentResolution(
  479. arguments={},
  480. complete=False,
  481. ),
  482. handler=lambda request: {"ok": True},
  483. )
  484. result = await EventKernel(EventRegistry([definition])).execute(
  485. EventRequest(id="event-1", name="example.lookup"),
  486. enabled_names=["example.lookup"],
  487. )
  488. assert result.status is EventStatus.INVALID_ARGUMENTS
  489. assert result.error == "event arguments incomplete"
  490. @pytest.mark.asyncio
  491. async def test_plain_dict_resolution_remains_complete_for_optional_schema():
  492. fallback_calls = 0
  493. async def fallback(*args: Any) -> ResolvedEventArguments:
  494. nonlocal fallback_calls
  495. fallback_calls += 1
  496. raise AssertionError("fallback should not run")
  497. definition = _definition(
  498. parameters={
  499. "type": "object",
  500. "properties": {"query": {"type": "string"}},
  501. },
  502. resolver=lambda request, context: {},
  503. handler=lambda request: {"ok": True},
  504. )
  505. result = await EventKernel(
  506. EventRegistry([definition]), argument_fallback=fallback
  507. ).execute(
  508. EventRequest(id="event-1", name="example.lookup"),
  509. enabled_names=["example.lookup"],
  510. )
  511. assert result.status is EventStatus.SUCCESS
  512. assert fallback_calls == 0
  513. def test_sync_kernel_rejects_structured_incomplete_optional_arguments():
  514. handler_calls = 0
  515. def handler(request: EventRequest) -> dict[str, Any]:
  516. nonlocal handler_calls
  517. handler_calls += 1
  518. return {"ok": True}
  519. definition = _definition(
  520. parameters={
  521. "type": "object",
  522. "properties": {"query": {"type": "string"}},
  523. },
  524. resolver=lambda request, context: EventArgumentResolution(
  525. arguments={},
  526. complete=False,
  527. ),
  528. handler=handler,
  529. )
  530. result = EventKernel(EventRegistry([definition])).execute_sync(
  531. EventRequest(id="event-1", name="example.lookup"),
  532. enabled_names=["example.lookup"],
  533. )
  534. assert result.status is EventStatus.INVALID_ARGUMENTS
  535. assert result.error == "event arguments incomplete"
  536. assert handler_calls == 0
  537. @pytest.mark.parametrize(
  538. "resolver",
  539. [
  540. lambda request, context: EventArgumentResolution(
  541. arguments={},
  542. complete=True,
  543. ),
  544. lambda request, context: {},
  545. ],
  546. )
  547. def test_sync_kernel_preserves_complete_compatible_resolvers(resolver: Any):
  548. handler_calls = 0
  549. def handler(request: EventRequest) -> dict[str, Any]:
  550. nonlocal handler_calls
  551. handler_calls += 1
  552. return {"ok": True}
  553. definition = _definition(
  554. parameters={
  555. "type": "object",
  556. "properties": {"query": {"type": "string"}},
  557. },
  558. resolver=resolver,
  559. handler=handler,
  560. )
  561. result = EventKernel(EventRegistry([definition])).execute_sync(
  562. EventRequest(id="event-1", name="example.lookup"),
  563. enabled_names=["example.lookup"],
  564. )
  565. assert result.status is EventStatus.SUCCESS
  566. assert result.payload == {"ok": True}
  567. assert handler_calls == 1
  568. @pytest.mark.asyncio
  569. async def test_provider_resolved_arguments_are_not_rewritten_or_fallen_back():
  570. resolver_calls = 0
  571. fallback_calls = 0
  572. def resolver(*args: Any) -> dict[str, Any]:
  573. nonlocal resolver_calls
  574. resolver_calls += 1
  575. return {"query": "rewritten"}
  576. async def fallback(*args: Any) -> ResolvedEventArguments:
  577. nonlocal fallback_calls
  578. fallback_calls += 1
  579. return ResolvedEventArguments(
  580. event_name="example.lookup",
  581. arguments={"query": "fallback"},
  582. raw_arguments='{"query":"fallback"}',
  583. )
  584. registry = EventRegistry([_definition(resolver=resolver)])
  585. result = await EventKernel(registry, argument_fallback=fallback).execute(
  586. EventRequest(
  587. id="event-1",
  588. name="example.lookup",
  589. arguments={"query": "provider value"},
  590. source=EventSource.PROVIDER_RESOLVED,
  591. ),
  592. enabled_names=["example.lookup"],
  593. )
  594. assert result.status is EventStatus.SUCCESS
  595. assert result.arguments == {"query": "provider value"}
  596. assert resolver_calls == 0
  597. assert fallback_calls == 0
  598. @pytest.mark.asyncio
  599. async def test_provider_resolved_missing_arguments_return_invalid_without_fallback():
  600. fallback_calls = 0
  601. async def fallback(*args: Any) -> ResolvedEventArguments:
  602. nonlocal fallback_calls
  603. fallback_calls += 1
  604. return ResolvedEventArguments(
  605. event_name="example.lookup",
  606. arguments={"query": "fallback"},
  607. raw_arguments='{"query":"fallback"}',
  608. )
  609. result = await EventKernel(
  610. EventRegistry([_definition()]), argument_fallback=fallback
  611. ).execute(
  612. EventRequest(
  613. id="event-1",
  614. name="example.lookup",
  615. arguments={},
  616. source=EventSource.PROVIDER_RESOLVED,
  617. ),
  618. enabled_names=["example.lookup"],
  619. )
  620. assert result.status is EventStatus.INVALID_ARGUMENTS
  621. assert result.error == "missing required arguments: query"
  622. assert fallback_calls == 0
  623. @pytest.mark.asyncio
  624. @pytest.mark.parametrize(
  625. ("event_request", "enabled_names", "expected_status", "expected_error"),
  626. [
  627. (
  628. EventRequest(id="event-1", name="missing"),
  629. ["missing"],
  630. EventStatus.UNKNOWN,
  631. "unknown event",
  632. ),
  633. (
  634. EventRequest(id="event-1", name="example.lookup"),
  635. [],
  636. EventStatus.DISABLED,
  637. "event disabled",
  638. ),
  639. (
  640. EventRequest(
  641. id="event-1",
  642. name="example.lookup",
  643. arguments={"query": 42},
  644. source=EventSource.PROVIDER_RESOLVED,
  645. ),
  646. ["example.lookup"],
  647. EventStatus.INVALID_ARGUMENTS,
  648. "invalid argument type for query: expected string",
  649. ),
  650. ],
  651. )
  652. async def test_kernel_normalizes_lookup_and_validation_failures(
  653. event_request: EventRequest,
  654. enabled_names: list[str],
  655. expected_status: EventStatus,
  656. expected_error: str,
  657. ):
  658. result = await EventKernel(EventRegistry([_definition()])).execute(
  659. event_request,
  660. enabled_names=enabled_names,
  661. )
  662. assert result.status is expected_status
  663. assert result.error == expected_error
  664. @pytest.mark.asyncio
  665. async def test_kernel_normalizes_handler_exceptions():
  666. def fail(request: EventRequest) -> dict[str, Any]:
  667. raise RuntimeError("boom")
  668. result = await EventKernel(
  669. EventRegistry([_definition(handler=fail)])
  670. ).execute(
  671. EventRequest(
  672. id="event-1",
  673. name="example.lookup",
  674. arguments={"query": "value"},
  675. source=EventSource.PROVIDER_RESOLVED,
  676. ),
  677. enabled_names=["example.lookup"],
  678. )
  679. assert result.status is EventStatus.HANDLER_ERROR
  680. assert result.error == "event handler failed: boom"
  681. @pytest.mark.asyncio
  682. async def test_kernel_validates_complete_draft_2020_12_schema():
  683. definition = _definition(
  684. parameters={
  685. "type": "object",
  686. "properties": {
  687. "mode": {"enum": ["quick", "deep"]},
  688. "target": {"type": ["string", "null"]},
  689. "filters": {
  690. "type": "array",
  691. "items": {
  692. "type": "object",
  693. "properties": {"score": {"type": "number", "minimum": 0}},
  694. "required": ["score"],
  695. "additionalProperties": False,
  696. },
  697. },
  698. },
  699. "required": ["mode", "target", "filters"],
  700. "additionalProperties": False,
  701. },
  702. handler=lambda request: {"event": request.name},
  703. )
  704. kernel = EventKernel(EventRegistry([definition]))
  705. valid = await kernel.execute(
  706. EventRequest(
  707. id="valid",
  708. name=definition.name,
  709. arguments={
  710. "mode": "deep",
  711. "target": None,
  712. "filters": [{"score": 0.5}],
  713. },
  714. source=EventSource.PROVIDER_RESOLVED,
  715. ),
  716. enabled_names=[definition.name],
  717. )
  718. invalid = await kernel.execute(
  719. EventRequest(
  720. id="invalid",
  721. name=definition.name,
  722. arguments={
  723. "mode": "other",
  724. "target": 7,
  725. "filters": [{"score": -1, "extra": True}],
  726. "unexpected": True,
  727. },
  728. source=EventSource.PROVIDER_RESOLVED,
  729. ),
  730. enabled_names=[definition.name],
  731. )
  732. assert valid.status is EventStatus.SUCCESS
  733. assert invalid.status is EventStatus.INVALID_ARGUMENTS
  734. assert invalid.error.startswith("invalid event arguments:")
  735. @pytest.mark.asyncio
  736. async def test_kernel_normalizes_validator_runtime_exceptions():
  737. definition = _definition(parameters={"$ref": "urn:agent-lab:missing-schema"})
  738. result = await EventKernel(EventRegistry([definition])).execute(
  739. EventRequest(
  740. id="event-1",
  741. name=definition.name,
  742. arguments={},
  743. source=EventSource.PROVIDER_RESOLVED,
  744. ),
  745. enabled_names=[definition.name],
  746. )
  747. assert result.status is EventStatus.DEFINITION_ERROR
  748. assert result.error.startswith("event argument validation failed:")
  749. @pytest.mark.asyncio
  750. async def test_tool_registry_maps_definition_errors_to_tool_compatibility_payload():
  751. registry = ToolRegistry(
  752. [
  753. ToolDefinition(
  754. name="broken.lookup",
  755. description="Broken lookup.",
  756. parameters={"$ref": "urn:agent-lab:missing-schema"},
  757. handler=lambda event: {"tool": event.name},
  758. )
  759. ]
  760. )
  761. payload = await registry.execute_async(
  762. ToolCallEvent(
  763. id="call-1",
  764. name="broken.lookup",
  765. arguments={},
  766. raw_arguments="{}",
  767. )
  768. )
  769. assert payload["tool"] == "broken.lookup"
  770. assert payload["error"].startswith("tool definition validation failed:")
  771. @pytest.mark.asyncio
  772. @pytest.mark.parametrize("boundary", ["resolver", "fallback"])
  773. async def test_kernel_normalizes_resolution_boundary_exceptions(boundary: str):
  774. def resolver(request: EventRequest, context: EventExecutionContext) -> dict[str, Any]:
  775. if boundary == "resolver":
  776. raise RuntimeError("resolver boom")
  777. return {}
  778. async def fallback(*args: Any) -> ResolvedEventArguments:
  779. raise RuntimeError("fallback boom")
  780. result = await EventKernel(
  781. EventRegistry([_definition(resolver=resolver)]),
  782. argument_fallback=fallback,
  783. ).execute(
  784. EventRequest(id="event-1", name="example.lookup"),
  785. enabled_names=["example.lookup"],
  786. )
  787. assert result.status is EventStatus.RESOLUTION_ERROR
  788. assert result.error == f"event argument {boundary} failed: {boundary} boom"
  789. @pytest.mark.asyncio
  790. @pytest.mark.parametrize("boundary", ["resolver", "fallback"])
  791. async def test_kernel_normalizes_invalid_resolution_payloads(boundary: str):
  792. resolver = (
  793. (lambda request, context: None)
  794. if boundary == "resolver"
  795. else (lambda request, context: {})
  796. )
  797. async def fallback(*args: Any) -> Any:
  798. return {"query": "legacy bare mapping"}
  799. result = await EventKernel(
  800. EventRegistry([_definition(resolver=resolver)]),
  801. argument_fallback=fallback,
  802. ).execute(
  803. EventRequest(id="event-1", name="example.lookup"),
  804. enabled_names=["example.lookup"],
  805. )
  806. assert result.status is EventStatus.RESOLUTION_ERROR
  807. assert result.error == f"event argument {boundary} returned invalid payload"
  808. @pytest.mark.asyncio
  809. async def test_kernel_normalizes_non_json_resolver_arguments():
  810. result = await EventKernel(
  811. EventRegistry(
  812. [
  813. _definition(
  814. resolver=lambda request, context: {"query": object()}
  815. )
  816. ]
  817. )
  818. ).execute(
  819. EventRequest(id="event-1", name="example.lookup"),
  820. enabled_names=["example.lookup"],
  821. )
  822. assert result.status is EventStatus.RESOLUTION_ERROR
  823. assert result.error.startswith("event argument resolver failed to serialize:")
  824. class _ExplodingDeepcopyDict(dict[str, Any]):
  825. def __deepcopy__(self, memo: dict[int, Any]) -> dict[str, Any]:
  826. raise RuntimeError("deepcopy must not be used")
  827. @pytest.mark.asyncio
  828. @pytest.mark.parametrize("execution", ["async", "sync"])
  829. @pytest.mark.parametrize(
  830. ("source", "expected_status", "expected_error"),
  831. [
  832. (
  833. EventSource.TEXT_EVENT,
  834. EventStatus.RESOLUTION_ERROR,
  835. "event argument resolver failed to serialize: "
  836. "JSON round-trip changed payload",
  837. ),
  838. (
  839. EventSource.PROVIDER_RESOLVED,
  840. EventStatus.INVALID_ARGUMENTS,
  841. "provider-resolved event arguments are not valid JSON",
  842. ),
  843. ],
  844. )
  845. async def test_kernel_normalizes_argument_snapshot_failures_without_deepcopy(
  846. execution: str,
  847. source: EventSource,
  848. expected_status: EventStatus,
  849. expected_error: str,
  850. ):
  851. fallback_calls = 0
  852. arguments = {"query": _ExplodingDeepcopyDict({"nested": "value"})}
  853. async def fallback(*args: Any) -> ResolvedEventArguments:
  854. nonlocal fallback_calls
  855. fallback_calls += 1
  856. raise AssertionError("fallback should not run")
  857. definition = _definition(
  858. parameters={
  859. "type": "object",
  860. "properties": {"query": {"type": "object"}},
  861. "required": ["query"],
  862. },
  863. resolver=lambda request, context: arguments,
  864. handler=lambda request: {"ok": True},
  865. )
  866. request = EventRequest(
  867. id="event-1",
  868. name="example.lookup",
  869. arguments=arguments if source is EventSource.PROVIDER_RESOLVED else {},
  870. source=source,
  871. )
  872. kernel = EventKernel(EventRegistry([definition]), argument_fallback=fallback)
  873. result = (
  874. await kernel.execute(request, enabled_names=[definition.name])
  875. if execution == "async"
  876. else kernel.execute_sync(request, enabled_names=[definition.name])
  877. )
  878. assert result.status is expected_status
  879. assert result.status is not EventStatus.DEFINITION_ERROR
  880. assert result.error == expected_error
  881. assert result.used_fallback is False
  882. assert fallback_calls == 0
  883. @pytest.mark.asyncio
  884. async def test_kernel_normalizes_non_object_fallback_arguments():
  885. async def fallback(*args: Any) -> ResolvedEventArguments:
  886. return ResolvedEventArguments(
  887. event_name="example.lookup",
  888. arguments=["not", "an", "object"], # type: ignore[arg-type]
  889. raw_arguments='["not","an","object"]',
  890. )
  891. result = await EventKernel(
  892. EventRegistry([_definition(resolver=lambda request, context: {})]),
  893. argument_fallback=fallback,
  894. ).execute(
  895. EventRequest(id="event-1", name="example.lookup"),
  896. enabled_names=["example.lookup"],
  897. )
  898. assert result.status is EventStatus.RESOLUTION_ERROR
  899. assert result.error == "event argument fallback returned invalid payload"
  900. @pytest.mark.asyncio
  901. @pytest.mark.parametrize(
  902. ("resolved", "expected_error"),
  903. [
  904. (
  905. ResolvedEventArguments(
  906. event_name="another.event",
  907. arguments={"query": "value"},
  908. raw_arguments='{"query":"value"}',
  909. ),
  910. "fallback returned tool another.event for example.lookup",
  911. ),
  912. (
  913. ResolvedEventArguments(
  914. event_name="example.lookup",
  915. arguments={"query": "value"},
  916. raw_arguments="not-json",
  917. ),
  918. "fallback raw arguments are not valid JSON",
  919. ),
  920. (
  921. ResolvedEventArguments(
  922. event_name="example.lookup",
  923. arguments={"query": "parsed"},
  924. raw_arguments='{"query":"raw"}',
  925. ),
  926. "fallback raw arguments do not match parsed arguments",
  927. ),
  928. ],
  929. )
  930. async def test_kernel_rejects_inconsistent_structured_fallback(
  931. resolved: ResolvedEventArguments,
  932. expected_error: str,
  933. ):
  934. async def fallback(*args: Any) -> ResolvedEventArguments:
  935. return resolved
  936. result = await EventKernel(
  937. EventRegistry([_definition(resolver=lambda request, context: {})]),
  938. argument_fallback=fallback,
  939. ).execute(
  940. EventRequest(id="event-1", name="example.lookup"),
  941. enabled_names=["example.lookup"],
  942. )
  943. assert result.status is EventStatus.RESOLUTION_ERROR
  944. assert result.error == expected_error
  945. @pytest.mark.asyncio
  946. @pytest.mark.parametrize(
  947. ("arguments", "raw_arguments"),
  948. [
  949. ({"query": 1}, '{"query":true}'),
  950. ({"query": 1.0}, '{"query":1}'),
  951. ({"query": {"nested": [1]}}, '{"query":{"nested":[true]}}'),
  952. ({"query": float("nan")}, '{"query":NaN}'),
  953. ({"query": float("inf")}, '{"query":Infinity}'),
  954. ],
  955. )
  956. async def test_kernel_rejects_noncanonical_fallback_json(
  957. arguments: dict[str, Any],
  958. raw_arguments: str,
  959. ):
  960. async def fallback(*args: Any) -> ResolvedEventArguments:
  961. return ResolvedEventArguments(
  962. event_name="example.lookup",
  963. arguments=arguments,
  964. raw_arguments=raw_arguments,
  965. )
  966. result = await EventKernel(
  967. EventRegistry([_definition(resolver=lambda request, context: {})]),
  968. argument_fallback=fallback,
  969. ).execute(
  970. EventRequest(id="event-1", name="example.lookup"),
  971. enabled_names=["example.lookup"],
  972. )
  973. assert result.status is EventStatus.RESOLUTION_ERROR
  974. @pytest.mark.asyncio
  975. async def test_registry_schema_is_isolated_from_caller_mutation():
  976. parameters = {
  977. "type": "object",
  978. "properties": {"query": {"type": "string"}},
  979. "required": ["query"],
  980. "additionalProperties": False,
  981. }
  982. registry = EventRegistry([_definition(parameters=parameters)])
  983. parameters["properties"]["query"]["type"] = "integer"
  984. parameters["required"].clear()
  985. parameters["additionalProperties"] = True
  986. assert registry.catalog()[0]["parameters"] == {
  987. "type": "object",
  988. "properties": {"query": {"type": "string"}},
  989. "required": ["query"],
  990. "additionalProperties": False,
  991. }
  992. registered = registry.definition("example.lookup")
  993. assert registered is not None
  994. with pytest.raises(TypeError):
  995. registered.parameters["additionalProperties"] = True
  996. with pytest.raises(TypeError):
  997. registered.parameters["properties"]["query"]["type"] = "integer"
  998. with pytest.raises(AttributeError):
  999. registered.parameters["required"].append("unexpected")
  1000. result = await EventKernel(registry).execute(
  1001. EventRequest(
  1002. id="event-1",
  1003. name="example.lookup",
  1004. arguments={"query": 42, "unexpected": True},
  1005. source=EventSource.PROVIDER_RESOLVED,
  1006. ),
  1007. enabled_names=["example.lookup"],
  1008. )
  1009. assert result.status is EventStatus.INVALID_ARGUMENTS
  1010. @pytest.mark.asyncio
  1011. async def test_kernel_passes_consistent_fallback_arguments_and_raw_json_to_handler():
  1012. captured: list[EventRequest] = []
  1013. async def fallback(*args: Any) -> ResolvedEventArguments:
  1014. return ResolvedEventArguments(
  1015. event_name="example.lookup",
  1016. arguments={"query": "resolved"},
  1017. raw_arguments='{"query":"resolved"}',
  1018. )
  1019. result = await EventKernel(
  1020. EventRegistry(
  1021. [
  1022. _definition(
  1023. resolver=lambda request, context: {},
  1024. handler=lambda request: captured.append(request) or {"ok": True},
  1025. )
  1026. ]
  1027. ),
  1028. argument_fallback=fallback,
  1029. ).execute(
  1030. EventRequest(id="event-1", name="example.lookup"),
  1031. enabled_names=["example.lookup"],
  1032. )
  1033. assert result.status is EventStatus.SUCCESS
  1034. assert captured[0].arguments == json.loads(captured[0].raw_arguments)
  1035. assert result.raw_arguments == captured[0].raw_arguments
  1036. @pytest.mark.asyncio
  1037. @pytest.mark.parametrize("execution", ["async", "sync"])
  1038. async def test_kernel_isolates_nested_handler_mutation_from_audit_values(
  1039. execution: str,
  1040. ):
  1041. caller_arguments = {"nested": {"items": ["original"]}}
  1042. handler_arguments: list[dict[str, Any]] = []
  1043. def handler(request: EventRequest) -> dict[str, Any]:
  1044. handler_arguments.append(request.arguments)
  1045. request.arguments["nested"]["items"].append("handler")
  1046. return {"nested": request.arguments["nested"]}
  1047. definition = _definition(
  1048. parameters={
  1049. "type": "object",
  1050. "properties": {
  1051. "nested": {
  1052. "type": "object",
  1053. "properties": {
  1054. "items": {"type": "array", "items": {"type": "string"}}
  1055. },
  1056. "required": ["items"],
  1057. }
  1058. },
  1059. "required": ["nested"],
  1060. },
  1061. handler=handler,
  1062. )
  1063. request = EventRequest(
  1064. id="event-1",
  1065. name="example.lookup",
  1066. arguments=caller_arguments,
  1067. raw_arguments='{"nested":{"items":["original"]}}',
  1068. source=EventSource.PROVIDER_RESOLVED,
  1069. )
  1070. kernel = EventKernel(EventRegistry([definition]))
  1071. result = (
  1072. await kernel.execute(request, enabled_names=["example.lookup"])
  1073. if execution == "async"
  1074. else kernel.execute_sync(request, enabled_names=["example.lookup"])
  1075. )
  1076. assert result.status is EventStatus.SUCCESS
  1077. assert caller_arguments == {"nested": {"items": ["original"]}}
  1078. assert request.arguments == {"nested": {"items": ["original"]}}
  1079. assert result.arguments == {"nested": {"items": ["original"]}}
  1080. assert json.loads(result.raw_arguments) == result.arguments
  1081. assert result.payload == {"nested": {"items": ["original", "handler"]}}
  1082. handler_arguments[0]["nested"]["items"].append("later")
  1083. assert result.payload == {"nested": {"items": ["original", "handler"]}}
  1084. result.payload["nested"]["items"].append("result")
  1085. assert result.arguments == {"nested": {"items": ["original"]}}
  1086. assert caller_arguments == {"nested": {"items": ["original"]}}
  1087. @pytest.mark.asyncio
  1088. @pytest.mark.parametrize("payload", [None, "text", 1, ["item"]])
  1089. async def test_kernel_normalizes_invalid_handler_payloads(payload: Any):
  1090. result = await EventKernel(
  1091. EventRegistry([_definition(handler=lambda request: payload)])
  1092. ).execute(
  1093. EventRequest(
  1094. id="event-1",
  1095. name="example.lookup",
  1096. arguments={"query": "value"},
  1097. source=EventSource.PROVIDER_RESOLVED,
  1098. ),
  1099. enabled_names=["example.lookup"],
  1100. )
  1101. assert result.status is EventStatus.HANDLER_ERROR
  1102. assert result.error == "event handler returned non-object payload"
  1103. @pytest.mark.asyncio
  1104. @pytest.mark.parametrize("execution", ["async", "sync"])
  1105. @pytest.mark.parametrize(
  1106. "invalid_value",
  1107. [object(), {"set-item"}, float("nan"), float("inf")],
  1108. )
  1109. async def test_kernel_rejects_non_json_handler_dictionary_payloads(
  1110. execution: str,
  1111. invalid_value: Any,
  1112. ):
  1113. definition = _definition(
  1114. handler=lambda request: {"invalid": invalid_value},
  1115. )
  1116. request = EventRequest(
  1117. id="event-1",
  1118. name="example.lookup",
  1119. arguments={"query": "value"},
  1120. raw_arguments='{"query":"value"}',
  1121. source=EventSource.PROVIDER_RESOLVED,
  1122. )
  1123. kernel = EventKernel(EventRegistry([definition]))
  1124. result = (
  1125. await kernel.execute(request, enabled_names=["example.lookup"])
  1126. if execution == "async"
  1127. else kernel.execute_sync(request, enabled_names=["example.lookup"])
  1128. )
  1129. assert result.status is EventStatus.HANDLER_ERROR
  1130. assert result.error == "event handler returned non-JSON payload"
  1131. class _ExplodingItemsDict(dict[str, Any]):
  1132. def items(self):
  1133. raise RuntimeError("payload items failed")
  1134. @pytest.mark.asyncio
  1135. @pytest.mark.parametrize("execution", ["async", "sync"])
  1136. async def test_kernel_normalizes_handler_payload_snapshot_exceptions(
  1137. execution: str,
  1138. ):
  1139. definition = _definition(
  1140. handler=lambda request: _ExplodingItemsDict(ok=True),
  1141. )
  1142. request = EventRequest(
  1143. id="event-1",
  1144. name=definition.name,
  1145. arguments={"query": "value"},
  1146. raw_arguments='{"query":"value"}',
  1147. source=EventSource.PROVIDER_RESOLVED,
  1148. )
  1149. kernel = EventKernel(EventRegistry([definition]))
  1150. result = (
  1151. await kernel.execute(request, enabled_names=[definition.name])
  1152. if execution == "async"
  1153. else kernel.execute_sync(request, enabled_names=[definition.name])
  1154. )
  1155. assert result.status is EventStatus.HANDLER_ERROR
  1156. assert result.error == "event handler returned non-JSON payload"
  1157. @pytest.mark.asyncio
  1158. @pytest.mark.parametrize("execution", ["async", "sync"])
  1159. async def test_kernel_does_not_swallow_base_exception_from_payload_snapshot(
  1160. execution: str,
  1161. ):
  1162. class SnapshotAbort(BaseException):
  1163. pass
  1164. class AbortingItemsDict(dict[str, Any]):
  1165. def items(self):
  1166. raise SnapshotAbort
  1167. definition = _definition(
  1168. handler=lambda request: AbortingItemsDict(ok=True),
  1169. )
  1170. request = EventRequest(
  1171. id="event-1",
  1172. name=definition.name,
  1173. arguments={"query": "value"},
  1174. source=EventSource.PROVIDER_RESOLVED,
  1175. )
  1176. kernel = EventKernel(EventRegistry([definition]))
  1177. with pytest.raises(SnapshotAbort):
  1178. if execution == "async":
  1179. await kernel.execute(request, enabled_names=[definition.name])
  1180. else:
  1181. kernel.execute_sync(request, enabled_names=[definition.name])
  1182. @pytest.mark.asyncio
  1183. async def test_tool_registry_normalizes_handler_payload_snapshot_exceptions():
  1184. registry = ToolRegistry(
  1185. [
  1186. ToolDefinition(
  1187. name="bad_payload",
  1188. description="Return a payload that fails during snapshot.",
  1189. parameters={"type": "object"},
  1190. handler=lambda event: _ExplodingItemsDict(ok=True),
  1191. )
  1192. ]
  1193. )
  1194. event = ToolCallEvent(
  1195. id="call-1",
  1196. name="bad_payload",
  1197. arguments={},
  1198. raw_arguments="{}",
  1199. )
  1200. expected = {
  1201. "tool": "bad_payload",
  1202. "error": "event handler returned non-JSON payload",
  1203. }
  1204. assert registry.execute(event) == expected
  1205. assert await registry.execute_async(event) == expected
  1206. @pytest.mark.asyncio
  1207. async def test_async_kernel_supports_async_handler():
  1208. async def handler(request: EventRequest) -> dict[str, Any]:
  1209. return {"query": request.arguments["query"]}
  1210. result = await EventKernel(
  1211. EventRegistry([_definition(handler=handler)])
  1212. ).execute(
  1213. EventRequest(
  1214. id="event-1",
  1215. name="example.lookup",
  1216. arguments={"query": "async"},
  1217. source=EventSource.PROVIDER_RESOLVED,
  1218. ),
  1219. enabled_names=["example.lookup"],
  1220. )
  1221. assert result.status is EventStatus.SUCCESS
  1222. assert result.payload == {"query": "async"}
  1223. @pytest.mark.asyncio
  1224. async def test_tool_registry_async_entry_points_support_async_handler():
  1225. async def handler(event: ToolCallEvent) -> dict[str, Any]:
  1226. return {"tool": event.name, "query": event.arguments["query"]}
  1227. registry = ToolRegistry(
  1228. [
  1229. ToolDefinition(
  1230. name="async.lookup",
  1231. description="Async lookup.",
  1232. parameters={
  1233. "type": "object",
  1234. "properties": {"query": {"type": "string"}},
  1235. "required": ["query"],
  1236. },
  1237. handler=handler,
  1238. )
  1239. ]
  1240. )
  1241. event = ToolCallEvent(
  1242. id="call-1",
  1243. name="async.lookup",
  1244. arguments={"query": "value"},
  1245. raw_arguments='{"query":"value"}',
  1246. )
  1247. assert await registry.handle_async(event) == {
  1248. "tool": "async.lookup",
  1249. "query": "value",
  1250. }
  1251. assert await registry.execute_async(event) == {
  1252. "tool": "async.lookup",
  1253. "query": "value",
  1254. }
  1255. def test_tool_registry_sync_facade_rejects_async_handler_without_runtime_warning():
  1256. called = False
  1257. async def handler(event: ToolCallEvent) -> dict[str, Any]:
  1258. nonlocal called
  1259. called = True
  1260. return {"tool": event.name}
  1261. registry = ToolRegistry(
  1262. [
  1263. ToolDefinition(
  1264. name="async.lookup",
  1265. description="Async lookup.",
  1266. parameters={"type": "object"},
  1267. handler=handler,
  1268. )
  1269. ]
  1270. )
  1271. event = ToolCallEvent(
  1272. id="call-1",
  1273. name="async.lookup",
  1274. arguments={},
  1275. raw_arguments="{}",
  1276. )
  1277. with warnings.catch_warnings(record=True) as captured:
  1278. warnings.simplefilter("always")
  1279. payload = registry.execute(event)
  1280. gc.collect()
  1281. assert payload == {
  1282. "tool": "async.lookup",
  1283. "error": "tool handler failed: async event handlers require execute_async",
  1284. }
  1285. assert called is False
  1286. assert not [warning for warning in captured if issubclass(warning.category, RuntimeWarning)]
  1287. @pytest.mark.asyncio
  1288. async def test_kernel_rejects_boolean_for_json_number_arguments():
  1289. definition = _definition(
  1290. parameters={
  1291. "type": "object",
  1292. "properties": {"query": {"type": "number"}},
  1293. "required": ["query"],
  1294. }
  1295. )
  1296. result = await EventKernel(EventRegistry([definition])).execute(
  1297. EventRequest(
  1298. id="event-1",
  1299. name="example.lookup",
  1300. arguments={"query": True},
  1301. source=EventSource.PROVIDER_RESOLVED,
  1302. ),
  1303. enabled_names=["example.lookup"],
  1304. )
  1305. assert result.status is EventStatus.INVALID_ARGUMENTS
  1306. assert result.error == "invalid argument type for query: expected number"
  1307. @pytest.mark.asyncio
  1308. async def test_definition_metadata_survives_registration_and_result_creation():
  1309. definition = _definition(
  1310. result_policy=ResultPolicy.TEMPLATE_FOLLOW_UP,
  1311. confirmation_policy=ConfirmationPolicy.REQUIRED,
  1312. risk_level=RiskLevel.HIGH,
  1313. idempotency_key_fields=("session_id", "event_id"),
  1314. concurrency_class="device-write",
  1315. conflict_keys=("device",),
  1316. timeout_seconds=1.5,
  1317. terminal=True,
  1318. )
  1319. registry = EventRegistry([definition])
  1320. result = await EventKernel(registry).execute(
  1321. EventRequest(
  1322. id="event-1",
  1323. name="example.lookup",
  1324. arguments={"query": "value"},
  1325. source=EventSource.PROVIDER_RESOLVED,
  1326. ),
  1327. enabled_names=["example.lookup"],
  1328. )
  1329. registered = registry.definition("example.lookup")
  1330. assert registered is not definition
  1331. assert result.result_policy is ResultPolicy.TEMPLATE_FOLLOW_UP
  1332. assert result.confirmation_policy is ConfirmationPolicy.REQUIRED
  1333. assert result.risk_level is RiskLevel.HIGH
  1334. assert result.idempotency_key_fields == ("session_id", "event_id")
  1335. assert result.concurrency_class == "device-write"
  1336. assert result.conflict_keys == ("device",)
  1337. assert result.timeout_seconds == 1.5
  1338. assert result.terminal is True
  1339. @pytest.mark.asyncio
  1340. @pytest.mark.parametrize("name", ["alpha.one", "beta-two", "任意.事件"])
  1341. async def test_kernel_applies_identical_behavior_to_arbitrary_event_names(name: str):
  1342. definition = _definition(name=name)
  1343. result = await EventKernel(EventRegistry([definition])).execute(
  1344. EventRequest(
  1345. id="event-1",
  1346. name=name,
  1347. arguments={"query": "value"},
  1348. source=EventSource.PROVIDER_RESOLVED,
  1349. ),
  1350. enabled_names=[name],
  1351. )
  1352. assert result.status is EventStatus.SUCCESS
  1353. assert result.payload == {"event": name, "query": "value"}