test_event_kernel.py 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312
  1. from __future__ import annotations
  2. import gc
  3. import json
  4. import warnings
  5. from typing import Any
  6. import pytest
  7. from agent_lab.application.events import (
  8. ConfirmationPolicy,
  9. EventDefinition,
  10. EventExecutionContext,
  11. EventKernel,
  12. EventRegistry,
  13. EventRequest,
  14. ResolvedEventArguments,
  15. EventSource,
  16. EventStatus,
  17. ResultPolicy,
  18. RiskLevel,
  19. )
  20. from agent_lab.application.events.models import EventArgumentResolution
  21. from agent_lab.application.tools import (
  22. ToolDefinition,
  23. ToolExecutionContext,
  24. ToolRegistry,
  25. )
  26. from agent_lab.domain.events import ToolCallEvent
  27. from agent_lab.domain.messages import ChatMessage
  28. def _definition(
  29. name: str = "example.lookup",
  30. **overrides: Any,
  31. ) -> EventDefinition:
  32. values: dict[str, Any] = {
  33. "name": name,
  34. "description": "Look up an example value.",
  35. "parameters": {
  36. "type": "object",
  37. "properties": {"query": {"type": "string"}},
  38. "required": ["query"],
  39. },
  40. "handler": lambda request: {
  41. "event": request.name,
  42. "query": request.arguments["query"],
  43. },
  44. }
  45. values.update(overrides)
  46. return EventDefinition(**values)
  47. def test_registry_registers_flat_definitions_and_filters_enabled_catalog():
  48. registry = EventRegistry(
  49. [_definition("example.lookup"), _definition("device.inspect")]
  50. )
  51. assert [item["name"] for item in registry.catalog()] == [
  52. "example.lookup",
  53. "device.inspect",
  54. ]
  55. assert registry.catalog(["device.inspect"]) == [
  56. {
  57. "name": "device.inspect",
  58. "description": "Look up an example value.",
  59. "parameters": {
  60. "type": "object",
  61. "properties": {"query": {"type": "string"}},
  62. "required": ["query"],
  63. },
  64. }
  65. ]
  66. assert registry.tool_schema("example.lookup")["function"]["name"] == (
  67. "example.lookup"
  68. )
  69. assert registry.tool_schema("missing") is None
  70. def test_registry_rejects_duplicate_definition_names():
  71. with pytest.raises(ValueError, match="duplicate event definition: example.lookup"):
  72. EventRegistry([_definition(), _definition()])
  73. def test_registry_rejects_invalid_draft_2020_12_schema():
  74. definition = _definition(parameters={"type": 42})
  75. with pytest.raises(ValueError, match="invalid event schema for example.lookup"):
  76. EventRegistry([definition])
  77. def test_registry_does_not_expose_mutable_validator_instances():
  78. registry = EventRegistry([_definition()])
  79. assert not hasattr(registry, "validator")
  80. errors = list(
  81. registry.iter_validation_errors(
  82. "example.lookup",
  83. {"query": 42},
  84. )
  85. )
  86. assert errors
  87. errors[0].schema["type"] = "integer"
  88. assert list(
  89. registry.iter_validation_errors(
  90. "example.lookup",
  91. {"query": 42},
  92. )
  93. )
  94. def test_tool_registry_public_api_remains_compatible():
  95. registry = ToolRegistry(
  96. [
  97. ToolDefinition(
  98. name="compat.lookup",
  99. description="Look up compatibility data.",
  100. parameters={
  101. "type": "object",
  102. "properties": {"query": {"type": "string"}},
  103. "required": ["query"],
  104. },
  105. handler=lambda event: {
  106. "tool": event.name,
  107. "query": event.arguments["query"],
  108. },
  109. argument_resolver=lambda event, context: {
  110. "query": context.history[-1].content
  111. },
  112. )
  113. ]
  114. )
  115. event = ToolCallEvent(
  116. id="call-1",
  117. name="compat.lookup",
  118. arguments={"query": "provider"},
  119. raw_arguments='{"query":"provider"}',
  120. )
  121. assert registry.available_tools() == [
  122. {
  123. "name": "compat.lookup",
  124. "description": "Look up compatibility data.",
  125. "parameters": {
  126. "type": "object",
  127. "properties": {"query": {"type": "string"}},
  128. "required": ["query"],
  129. },
  130. }
  131. ]
  132. assert "- compat.lookup: Look up compatibility data." in (
  133. registry.chat_event_system_message(["compat.lookup"])
  134. )
  135. assert registry.tool_schema("compat.lookup")["function"]["name"] == (
  136. "compat.lookup"
  137. )
  138. assert registry.handle(
  139. event,
  140. ToolExecutionContext(history=[ChatMessage(role="user", content="history")]),
  141. ) == {"tool": "compat.lookup", "query": "history"}
  142. assert registry.execute(event) == {"tool": "compat.lookup", "query": "provider"}
  143. @pytest.mark.asyncio
  144. async def test_kernel_executes_complete_deterministic_arguments_without_fallback():
  145. fallback_calls: list[str] = []
  146. async def fallback(*args: Any) -> dict[str, Any]:
  147. fallback_calls.append("called")
  148. return {"query": "fallback"}
  149. registry = EventRegistry(
  150. [_definition(resolver=lambda request, context: {"query": "deterministic"})]
  151. )
  152. result = await EventKernel(registry, argument_fallback=fallback).execute(
  153. EventRequest(id="event-1", name="example.lookup"),
  154. enabled_names=["example.lookup"],
  155. )
  156. assert result.status is EventStatus.SUCCESS
  157. assert result.arguments == {"query": "deterministic"}
  158. assert result.payload == {"event": "example.lookup", "query": "deterministic"}
  159. assert result.used_fallback is False
  160. assert fallback_calls == []
  161. @pytest.mark.asyncio
  162. async def test_kernel_calls_fallback_once_when_required_arguments_are_incomplete():
  163. fallback_calls: list[dict[str, Any]] = []
  164. async def fallback(
  165. definition: EventDefinition,
  166. request: EventRequest,
  167. context: EventExecutionContext,
  168. ) -> ResolvedEventArguments:
  169. fallback_calls.append(dict(request.arguments))
  170. return ResolvedEventArguments(
  171. event_name=definition.name,
  172. arguments={"query": "resolved once"},
  173. raw_arguments='{"query":"resolved once"}',
  174. )
  175. registry = EventRegistry([_definition(resolver=lambda request, context: {})])
  176. result = await EventKernel(registry, argument_fallback=fallback).execute(
  177. EventRequest(id="event-1", name="example.lookup"),
  178. enabled_names=["example.lookup"],
  179. )
  180. assert result.status is EventStatus.SUCCESS
  181. assert result.arguments == {"query": "resolved once"}
  182. assert result.used_fallback is True
  183. assert fallback_calls == [{}]
  184. @pytest.mark.asyncio
  185. @pytest.mark.parametrize(
  186. "arguments",
  187. [
  188. {"query": 42},
  189. {"query": "unsupported"},
  190. {"query": "valid", "unexpected": True},
  191. ],
  192. )
  193. async def test_kernel_does_not_fallback_for_complete_invalid_arguments(
  194. arguments: dict[str, Any],
  195. ):
  196. fallback_calls = 0
  197. async def fallback(*args: Any) -> ResolvedEventArguments:
  198. nonlocal fallback_calls
  199. fallback_calls += 1
  200. return ResolvedEventArguments(
  201. event_name="example.lookup",
  202. arguments={"query": "valid"},
  203. raw_arguments='{"query":"valid"}',
  204. )
  205. definition = _definition(
  206. parameters={
  207. "type": "object",
  208. "properties": {"query": {"type": "string", "enum": ["valid"]}},
  209. "required": ["query"],
  210. "additionalProperties": False,
  211. },
  212. resolver=lambda request, context: arguments,
  213. )
  214. result = await EventKernel(
  215. EventRegistry([definition]), argument_fallback=fallback
  216. ).execute(
  217. EventRequest(id="event-1", name="example.lookup"),
  218. enabled_names=["example.lookup"],
  219. )
  220. assert result.status is EventStatus.INVALID_ARGUMENTS
  221. assert result.used_fallback is False
  222. assert fallback_calls == 0
  223. @pytest.mark.asyncio
  224. @pytest.mark.parametrize("composition", ["anyOf", "oneOf"])
  225. async def test_kernel_falls_back_for_required_missing_inside_composed_schema(
  226. composition: str,
  227. ):
  228. fallback_calls = 0
  229. async def fallback(*args: Any) -> ResolvedEventArguments:
  230. nonlocal fallback_calls
  231. fallback_calls += 1
  232. return ResolvedEventArguments(
  233. event_name="example.lookup",
  234. arguments={"choice": {"mode": "alpha"}},
  235. raw_arguments='{"choice":{"mode":"alpha"}}',
  236. )
  237. definition = _definition(
  238. parameters={
  239. "type": "object",
  240. "properties": {
  241. "choice": {
  242. composition: [
  243. {
  244. "type": "object",
  245. "properties": {"mode": {"const": "alpha"}},
  246. "required": ["mode"],
  247. },
  248. {
  249. "type": "object",
  250. "properties": {"mode": {"const": "beta"}},
  251. "required": ["mode"],
  252. },
  253. ]
  254. }
  255. },
  256. "required": ["choice"],
  257. },
  258. resolver=lambda request, context: {"choice": {}},
  259. handler=lambda request: {"ok": True},
  260. )
  261. result = await EventKernel(
  262. EventRegistry([definition]), argument_fallback=fallback
  263. ).execute(
  264. EventRequest(id="event-1", name="example.lookup"),
  265. enabled_names=["example.lookup"],
  266. )
  267. assert result.status is EventStatus.SUCCESS
  268. assert result.used_fallback is True
  269. assert fallback_calls == 1
  270. @pytest.mark.asyncio
  271. @pytest.mark.parametrize("composition", ["anyOf", "oneOf"])
  272. async def test_kernel_does_not_fallback_for_complete_invalid_composed_schema(
  273. composition: str,
  274. ):
  275. fallback_calls = 0
  276. async def fallback(*args: Any) -> ResolvedEventArguments:
  277. nonlocal fallback_calls
  278. fallback_calls += 1
  279. raise AssertionError("fallback should not run")
  280. definition = _definition(
  281. parameters={
  282. "type": "object",
  283. "properties": {
  284. "choice": {
  285. composition: [
  286. {
  287. "type": "object",
  288. "properties": {"mode": {"const": "alpha"}},
  289. "required": ["mode"],
  290. },
  291. {
  292. "type": "object",
  293. "properties": {"mode": {"const": "beta"}},
  294. "required": ["mode"],
  295. },
  296. ]
  297. }
  298. },
  299. "required": ["choice"],
  300. },
  301. resolver=lambda request, context: {"choice": {"mode": "other"}},
  302. handler=lambda request: {"ok": True},
  303. )
  304. result = await EventKernel(
  305. EventRegistry([definition]), argument_fallback=fallback
  306. ).execute(
  307. EventRequest(id="event-1", name="example.lookup"),
  308. enabled_names=["example.lookup"],
  309. )
  310. assert result.status is EventStatus.INVALID_ARGUMENTS
  311. assert result.used_fallback is False
  312. assert fallback_calls == 0
  313. @pytest.mark.asyncio
  314. async def test_kernel_does_not_fallback_when_definition_disallows_it():
  315. fallback_calls = 0
  316. async def fallback(*args: Any) -> ResolvedEventArguments:
  317. nonlocal fallback_calls
  318. fallback_calls += 1
  319. return ResolvedEventArguments(
  320. event_name="example.lookup",
  321. arguments={"query": "not allowed"},
  322. raw_arguments='{"query":"not allowed"}',
  323. )
  324. registry = EventRegistry(
  325. [
  326. _definition(
  327. resolver=lambda request, context: {},
  328. fallback_allowed=False,
  329. )
  330. ]
  331. )
  332. result = await EventKernel(registry, argument_fallback=fallback).execute(
  333. EventRequest(id="event-1", name="example.lookup"),
  334. enabled_names=["example.lookup"],
  335. )
  336. assert result.status is EventStatus.INVALID_ARGUMENTS
  337. assert result.error == "missing required arguments: query"
  338. assert result.used_fallback is False
  339. assert fallback_calls == 0
  340. @pytest.mark.asyncio
  341. async def test_structured_resolution_can_mark_optional_arguments_incomplete():
  342. fallback_calls = 0
  343. async def fallback(*args: Any) -> ResolvedEventArguments:
  344. nonlocal fallback_calls
  345. fallback_calls += 1
  346. return ResolvedEventArguments(
  347. event_name="example.lookup",
  348. arguments={"query": "resolved"},
  349. raw_arguments='{"query":"resolved"}',
  350. )
  351. definition = _definition(
  352. parameters={
  353. "type": "object",
  354. "properties": {"query": {"type": "string"}},
  355. "additionalProperties": False,
  356. },
  357. resolver=lambda request, context: EventArgumentResolution(
  358. arguments={},
  359. complete=False,
  360. ),
  361. )
  362. result = await EventKernel(
  363. EventRegistry([definition]), argument_fallback=fallback
  364. ).execute(
  365. EventRequest(id="event-1", name="example.lookup"),
  366. enabled_names=["example.lookup"],
  367. )
  368. assert result.status is EventStatus.SUCCESS
  369. assert result.arguments == {"query": "resolved"}
  370. assert result.used_fallback is True
  371. assert fallback_calls == 1
  372. @pytest.mark.asyncio
  373. async def test_structured_incomplete_optional_arguments_fail_without_fallback():
  374. definition = _definition(
  375. parameters={
  376. "type": "object",
  377. "properties": {"query": {"type": "string"}},
  378. },
  379. resolver=lambda request, context: EventArgumentResolution(
  380. arguments={},
  381. complete=False,
  382. ),
  383. handler=lambda request: {"ok": True},
  384. )
  385. result = await EventKernel(EventRegistry([definition])).execute(
  386. EventRequest(id="event-1", name="example.lookup"),
  387. enabled_names=["example.lookup"],
  388. )
  389. assert result.status is EventStatus.INVALID_ARGUMENTS
  390. assert result.error == "event arguments incomplete"
  391. @pytest.mark.asyncio
  392. async def test_plain_dict_resolution_remains_complete_for_optional_schema():
  393. fallback_calls = 0
  394. async def fallback(*args: Any) -> ResolvedEventArguments:
  395. nonlocal fallback_calls
  396. fallback_calls += 1
  397. raise AssertionError("fallback should not run")
  398. definition = _definition(
  399. parameters={
  400. "type": "object",
  401. "properties": {"query": {"type": "string"}},
  402. },
  403. resolver=lambda request, context: {},
  404. handler=lambda request: {"ok": True},
  405. )
  406. result = await EventKernel(
  407. EventRegistry([definition]), argument_fallback=fallback
  408. ).execute(
  409. EventRequest(id="event-1", name="example.lookup"),
  410. enabled_names=["example.lookup"],
  411. )
  412. assert result.status is EventStatus.SUCCESS
  413. assert fallback_calls == 0
  414. def test_sync_kernel_rejects_structured_incomplete_optional_arguments():
  415. handler_calls = 0
  416. def handler(request: EventRequest) -> dict[str, Any]:
  417. nonlocal handler_calls
  418. handler_calls += 1
  419. return {"ok": True}
  420. definition = _definition(
  421. parameters={
  422. "type": "object",
  423. "properties": {"query": {"type": "string"}},
  424. },
  425. resolver=lambda request, context: EventArgumentResolution(
  426. arguments={},
  427. complete=False,
  428. ),
  429. handler=handler,
  430. )
  431. result = EventKernel(EventRegistry([definition])).execute_sync(
  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 == "event arguments incomplete"
  437. assert handler_calls == 0
  438. @pytest.mark.parametrize(
  439. "resolver",
  440. [
  441. lambda request, context: EventArgumentResolution(
  442. arguments={},
  443. complete=True,
  444. ),
  445. lambda request, context: {},
  446. ],
  447. )
  448. def test_sync_kernel_preserves_complete_compatible_resolvers(resolver: Any):
  449. handler_calls = 0
  450. def handler(request: EventRequest) -> dict[str, Any]:
  451. nonlocal handler_calls
  452. handler_calls += 1
  453. return {"ok": True}
  454. definition = _definition(
  455. parameters={
  456. "type": "object",
  457. "properties": {"query": {"type": "string"}},
  458. },
  459. resolver=resolver,
  460. handler=handler,
  461. )
  462. result = EventKernel(EventRegistry([definition])).execute_sync(
  463. EventRequest(id="event-1", name="example.lookup"),
  464. enabled_names=["example.lookup"],
  465. )
  466. assert result.status is EventStatus.SUCCESS
  467. assert result.payload == {"ok": True}
  468. assert handler_calls == 1
  469. @pytest.mark.asyncio
  470. async def test_provider_resolved_arguments_are_not_rewritten_or_fallen_back():
  471. resolver_calls = 0
  472. fallback_calls = 0
  473. def resolver(*args: Any) -> dict[str, Any]:
  474. nonlocal resolver_calls
  475. resolver_calls += 1
  476. return {"query": "rewritten"}
  477. async def fallback(*args: Any) -> ResolvedEventArguments:
  478. nonlocal fallback_calls
  479. fallback_calls += 1
  480. return ResolvedEventArguments(
  481. event_name="example.lookup",
  482. arguments={"query": "fallback"},
  483. raw_arguments='{"query":"fallback"}',
  484. )
  485. registry = EventRegistry([_definition(resolver=resolver)])
  486. result = await EventKernel(registry, argument_fallback=fallback).execute(
  487. EventRequest(
  488. id="event-1",
  489. name="example.lookup",
  490. arguments={"query": "provider value"},
  491. source=EventSource.PROVIDER_RESOLVED,
  492. ),
  493. enabled_names=["example.lookup"],
  494. )
  495. assert result.status is EventStatus.SUCCESS
  496. assert result.arguments == {"query": "provider value"}
  497. assert resolver_calls == 0
  498. assert fallback_calls == 0
  499. @pytest.mark.asyncio
  500. async def test_provider_resolved_missing_arguments_return_invalid_without_fallback():
  501. fallback_calls = 0
  502. async def fallback(*args: Any) -> ResolvedEventArguments:
  503. nonlocal fallback_calls
  504. fallback_calls += 1
  505. return ResolvedEventArguments(
  506. event_name="example.lookup",
  507. arguments={"query": "fallback"},
  508. raw_arguments='{"query":"fallback"}',
  509. )
  510. result = await EventKernel(
  511. EventRegistry([_definition()]), argument_fallback=fallback
  512. ).execute(
  513. EventRequest(
  514. id="event-1",
  515. name="example.lookup",
  516. arguments={},
  517. source=EventSource.PROVIDER_RESOLVED,
  518. ),
  519. enabled_names=["example.lookup"],
  520. )
  521. assert result.status is EventStatus.INVALID_ARGUMENTS
  522. assert result.error == "missing required arguments: query"
  523. assert fallback_calls == 0
  524. @pytest.mark.asyncio
  525. @pytest.mark.parametrize(
  526. ("event_request", "enabled_names", "expected_status", "expected_error"),
  527. [
  528. (
  529. EventRequest(id="event-1", name="missing"),
  530. ["missing"],
  531. EventStatus.UNKNOWN,
  532. "unknown event",
  533. ),
  534. (
  535. EventRequest(id="event-1", name="example.lookup"),
  536. [],
  537. EventStatus.DISABLED,
  538. "event disabled",
  539. ),
  540. (
  541. EventRequest(
  542. id="event-1",
  543. name="example.lookup",
  544. arguments={"query": 42},
  545. source=EventSource.PROVIDER_RESOLVED,
  546. ),
  547. ["example.lookup"],
  548. EventStatus.INVALID_ARGUMENTS,
  549. "invalid argument type for query: expected string",
  550. ),
  551. ],
  552. )
  553. async def test_kernel_normalizes_lookup_and_validation_failures(
  554. event_request: EventRequest,
  555. enabled_names: list[str],
  556. expected_status: EventStatus,
  557. expected_error: str,
  558. ):
  559. result = await EventKernel(EventRegistry([_definition()])).execute(
  560. event_request,
  561. enabled_names=enabled_names,
  562. )
  563. assert result.status is expected_status
  564. assert result.error == expected_error
  565. @pytest.mark.asyncio
  566. async def test_kernel_normalizes_handler_exceptions():
  567. def fail(request: EventRequest) -> dict[str, Any]:
  568. raise RuntimeError("boom")
  569. result = await EventKernel(
  570. EventRegistry([_definition(handler=fail)])
  571. ).execute(
  572. EventRequest(
  573. id="event-1",
  574. name="example.lookup",
  575. arguments={"query": "value"},
  576. source=EventSource.PROVIDER_RESOLVED,
  577. ),
  578. enabled_names=["example.lookup"],
  579. )
  580. assert result.status is EventStatus.HANDLER_ERROR
  581. assert result.error == "event handler failed: boom"
  582. @pytest.mark.asyncio
  583. async def test_kernel_validates_complete_draft_2020_12_schema():
  584. definition = _definition(
  585. parameters={
  586. "type": "object",
  587. "properties": {
  588. "mode": {"enum": ["quick", "deep"]},
  589. "target": {"type": ["string", "null"]},
  590. "filters": {
  591. "type": "array",
  592. "items": {
  593. "type": "object",
  594. "properties": {"score": {"type": "number", "minimum": 0}},
  595. "required": ["score"],
  596. "additionalProperties": False,
  597. },
  598. },
  599. },
  600. "required": ["mode", "target", "filters"],
  601. "additionalProperties": False,
  602. },
  603. handler=lambda request: {"event": request.name},
  604. )
  605. kernel = EventKernel(EventRegistry([definition]))
  606. valid = await kernel.execute(
  607. EventRequest(
  608. id="valid",
  609. name=definition.name,
  610. arguments={
  611. "mode": "deep",
  612. "target": None,
  613. "filters": [{"score": 0.5}],
  614. },
  615. source=EventSource.PROVIDER_RESOLVED,
  616. ),
  617. enabled_names=[definition.name],
  618. )
  619. invalid = await kernel.execute(
  620. EventRequest(
  621. id="invalid",
  622. name=definition.name,
  623. arguments={
  624. "mode": "other",
  625. "target": 7,
  626. "filters": [{"score": -1, "extra": True}],
  627. "unexpected": True,
  628. },
  629. source=EventSource.PROVIDER_RESOLVED,
  630. ),
  631. enabled_names=[definition.name],
  632. )
  633. assert valid.status is EventStatus.SUCCESS
  634. assert invalid.status is EventStatus.INVALID_ARGUMENTS
  635. assert invalid.error.startswith("invalid event arguments:")
  636. @pytest.mark.asyncio
  637. async def test_kernel_normalizes_validator_runtime_exceptions():
  638. definition = _definition(parameters={"$ref": "urn:agent-lab:missing-schema"})
  639. result = await EventKernel(EventRegistry([definition])).execute(
  640. EventRequest(
  641. id="event-1",
  642. name=definition.name,
  643. arguments={},
  644. source=EventSource.PROVIDER_RESOLVED,
  645. ),
  646. enabled_names=[definition.name],
  647. )
  648. assert result.status is EventStatus.DEFINITION_ERROR
  649. assert result.error.startswith("event argument validation failed:")
  650. @pytest.mark.asyncio
  651. async def test_tool_registry_maps_definition_errors_to_tool_compatibility_payload():
  652. registry = ToolRegistry(
  653. [
  654. ToolDefinition(
  655. name="broken.lookup",
  656. description="Broken lookup.",
  657. parameters={"$ref": "urn:agent-lab:missing-schema"},
  658. handler=lambda event: {"tool": event.name},
  659. )
  660. ]
  661. )
  662. payload = await registry.execute_async(
  663. ToolCallEvent(
  664. id="call-1",
  665. name="broken.lookup",
  666. arguments={},
  667. raw_arguments="{}",
  668. )
  669. )
  670. assert payload["tool"] == "broken.lookup"
  671. assert payload["error"].startswith("tool definition validation failed:")
  672. @pytest.mark.asyncio
  673. @pytest.mark.parametrize("boundary", ["resolver", "fallback"])
  674. async def test_kernel_normalizes_resolution_boundary_exceptions(boundary: str):
  675. def resolver(request: EventRequest, context: EventExecutionContext) -> dict[str, Any]:
  676. if boundary == "resolver":
  677. raise RuntimeError("resolver boom")
  678. return {}
  679. async def fallback(*args: Any) -> ResolvedEventArguments:
  680. raise RuntimeError("fallback boom")
  681. result = await EventKernel(
  682. EventRegistry([_definition(resolver=resolver)]),
  683. argument_fallback=fallback,
  684. ).execute(
  685. EventRequest(id="event-1", name="example.lookup"),
  686. enabled_names=["example.lookup"],
  687. )
  688. assert result.status is EventStatus.RESOLUTION_ERROR
  689. assert result.error == f"event argument {boundary} failed: {boundary} boom"
  690. @pytest.mark.asyncio
  691. @pytest.mark.parametrize("boundary", ["resolver", "fallback"])
  692. async def test_kernel_normalizes_invalid_resolution_payloads(boundary: str):
  693. resolver = (
  694. (lambda request, context: None)
  695. if boundary == "resolver"
  696. else (lambda request, context: {})
  697. )
  698. async def fallback(*args: Any) -> Any:
  699. return {"query": "legacy bare mapping"}
  700. result = await EventKernel(
  701. EventRegistry([_definition(resolver=resolver)]),
  702. argument_fallback=fallback,
  703. ).execute(
  704. EventRequest(id="event-1", name="example.lookup"),
  705. enabled_names=["example.lookup"],
  706. )
  707. assert result.status is EventStatus.RESOLUTION_ERROR
  708. assert result.error == f"event argument {boundary} returned invalid payload"
  709. @pytest.mark.asyncio
  710. async def test_kernel_normalizes_non_json_resolver_arguments():
  711. result = await EventKernel(
  712. EventRegistry(
  713. [
  714. _definition(
  715. resolver=lambda request, context: {"query": object()}
  716. )
  717. ]
  718. )
  719. ).execute(
  720. EventRequest(id="event-1", name="example.lookup"),
  721. enabled_names=["example.lookup"],
  722. )
  723. assert result.status is EventStatus.RESOLUTION_ERROR
  724. assert result.error.startswith("event argument resolver failed to serialize:")
  725. @pytest.mark.asyncio
  726. async def test_kernel_normalizes_non_object_fallback_arguments():
  727. async def fallback(*args: Any) -> ResolvedEventArguments:
  728. return ResolvedEventArguments(
  729. event_name="example.lookup",
  730. arguments=["not", "an", "object"], # type: ignore[arg-type]
  731. raw_arguments='["not","an","object"]',
  732. )
  733. result = await EventKernel(
  734. EventRegistry([_definition(resolver=lambda request, context: {})]),
  735. argument_fallback=fallback,
  736. ).execute(
  737. EventRequest(id="event-1", name="example.lookup"),
  738. enabled_names=["example.lookup"],
  739. )
  740. assert result.status is EventStatus.RESOLUTION_ERROR
  741. assert result.error == "event argument fallback returned invalid payload"
  742. @pytest.mark.asyncio
  743. @pytest.mark.parametrize(
  744. ("resolved", "expected_error"),
  745. [
  746. (
  747. ResolvedEventArguments(
  748. event_name="another.event",
  749. arguments={"query": "value"},
  750. raw_arguments='{"query":"value"}',
  751. ),
  752. "fallback returned tool another.event for example.lookup",
  753. ),
  754. (
  755. ResolvedEventArguments(
  756. event_name="example.lookup",
  757. arguments={"query": "value"},
  758. raw_arguments="not-json",
  759. ),
  760. "fallback raw arguments are not valid JSON",
  761. ),
  762. (
  763. ResolvedEventArguments(
  764. event_name="example.lookup",
  765. arguments={"query": "parsed"},
  766. raw_arguments='{"query":"raw"}',
  767. ),
  768. "fallback raw arguments do not match parsed arguments",
  769. ),
  770. ],
  771. )
  772. async def test_kernel_rejects_inconsistent_structured_fallback(
  773. resolved: ResolvedEventArguments,
  774. expected_error: str,
  775. ):
  776. async def fallback(*args: Any) -> ResolvedEventArguments:
  777. return resolved
  778. result = await EventKernel(
  779. EventRegistry([_definition(resolver=lambda request, context: {})]),
  780. argument_fallback=fallback,
  781. ).execute(
  782. EventRequest(id="event-1", name="example.lookup"),
  783. enabled_names=["example.lookup"],
  784. )
  785. assert result.status is EventStatus.RESOLUTION_ERROR
  786. assert result.error == expected_error
  787. @pytest.mark.asyncio
  788. @pytest.mark.parametrize(
  789. ("arguments", "raw_arguments"),
  790. [
  791. ({"query": 1}, '{"query":true}'),
  792. ({"query": 1.0}, '{"query":1}'),
  793. ({"query": {"nested": [1]}}, '{"query":{"nested":[true]}}'),
  794. ({"query": float("nan")}, '{"query":NaN}'),
  795. ({"query": float("inf")}, '{"query":Infinity}'),
  796. ],
  797. )
  798. async def test_kernel_rejects_noncanonical_fallback_json(
  799. arguments: dict[str, Any],
  800. raw_arguments: str,
  801. ):
  802. async def fallback(*args: Any) -> ResolvedEventArguments:
  803. return ResolvedEventArguments(
  804. event_name="example.lookup",
  805. arguments=arguments,
  806. raw_arguments=raw_arguments,
  807. )
  808. result = await EventKernel(
  809. EventRegistry([_definition(resolver=lambda request, context: {})]),
  810. argument_fallback=fallback,
  811. ).execute(
  812. EventRequest(id="event-1", name="example.lookup"),
  813. enabled_names=["example.lookup"],
  814. )
  815. assert result.status is EventStatus.RESOLUTION_ERROR
  816. @pytest.mark.asyncio
  817. async def test_registry_schema_is_isolated_from_caller_mutation():
  818. parameters = {
  819. "type": "object",
  820. "properties": {"query": {"type": "string"}},
  821. "required": ["query"],
  822. "additionalProperties": False,
  823. }
  824. registry = EventRegistry([_definition(parameters=parameters)])
  825. parameters["properties"]["query"]["type"] = "integer"
  826. parameters["required"].clear()
  827. parameters["additionalProperties"] = True
  828. assert registry.catalog()[0]["parameters"] == {
  829. "type": "object",
  830. "properties": {"query": {"type": "string"}},
  831. "required": ["query"],
  832. "additionalProperties": False,
  833. }
  834. registered = registry.definition("example.lookup")
  835. assert registered is not None
  836. with pytest.raises(TypeError):
  837. registered.parameters["additionalProperties"] = True
  838. with pytest.raises(TypeError):
  839. registered.parameters["properties"]["query"]["type"] = "integer"
  840. with pytest.raises(AttributeError):
  841. registered.parameters["required"].append("unexpected")
  842. result = await EventKernel(registry).execute(
  843. EventRequest(
  844. id="event-1",
  845. name="example.lookup",
  846. arguments={"query": 42, "unexpected": True},
  847. source=EventSource.PROVIDER_RESOLVED,
  848. ),
  849. enabled_names=["example.lookup"],
  850. )
  851. assert result.status is EventStatus.INVALID_ARGUMENTS
  852. @pytest.mark.asyncio
  853. async def test_kernel_passes_consistent_fallback_arguments_and_raw_json_to_handler():
  854. captured: list[EventRequest] = []
  855. async def fallback(*args: Any) -> ResolvedEventArguments:
  856. return ResolvedEventArguments(
  857. event_name="example.lookup",
  858. arguments={"query": "resolved"},
  859. raw_arguments='{"query":"resolved"}',
  860. )
  861. result = await EventKernel(
  862. EventRegistry(
  863. [
  864. _definition(
  865. resolver=lambda request, context: {},
  866. handler=lambda request: captured.append(request) or {"ok": True},
  867. )
  868. ]
  869. ),
  870. argument_fallback=fallback,
  871. ).execute(
  872. EventRequest(id="event-1", name="example.lookup"),
  873. enabled_names=["example.lookup"],
  874. )
  875. assert result.status is EventStatus.SUCCESS
  876. assert captured[0].arguments == json.loads(captured[0].raw_arguments)
  877. assert result.raw_arguments == captured[0].raw_arguments
  878. @pytest.mark.asyncio
  879. @pytest.mark.parametrize("execution", ["async", "sync"])
  880. async def test_kernel_isolates_nested_handler_mutation_from_audit_values(
  881. execution: str,
  882. ):
  883. caller_arguments = {"nested": {"items": ["original"]}}
  884. handler_arguments: list[dict[str, Any]] = []
  885. def handler(request: EventRequest) -> dict[str, Any]:
  886. handler_arguments.append(request.arguments)
  887. request.arguments["nested"]["items"].append("handler")
  888. return {"nested": request.arguments["nested"]}
  889. definition = _definition(
  890. parameters={
  891. "type": "object",
  892. "properties": {
  893. "nested": {
  894. "type": "object",
  895. "properties": {
  896. "items": {"type": "array", "items": {"type": "string"}}
  897. },
  898. "required": ["items"],
  899. }
  900. },
  901. "required": ["nested"],
  902. },
  903. handler=handler,
  904. )
  905. request = EventRequest(
  906. id="event-1",
  907. name="example.lookup",
  908. arguments=caller_arguments,
  909. raw_arguments='{"nested":{"items":["original"]}}',
  910. source=EventSource.PROVIDER_RESOLVED,
  911. )
  912. kernel = EventKernel(EventRegistry([definition]))
  913. result = (
  914. await kernel.execute(request, enabled_names=["example.lookup"])
  915. if execution == "async"
  916. else kernel.execute_sync(request, enabled_names=["example.lookup"])
  917. )
  918. assert result.status is EventStatus.SUCCESS
  919. assert caller_arguments == {"nested": {"items": ["original"]}}
  920. assert request.arguments == {"nested": {"items": ["original"]}}
  921. assert result.arguments == {"nested": {"items": ["original"]}}
  922. assert json.loads(result.raw_arguments) == result.arguments
  923. assert result.payload == {"nested": {"items": ["original", "handler"]}}
  924. handler_arguments[0]["nested"]["items"].append("later")
  925. assert result.payload == {"nested": {"items": ["original", "handler"]}}
  926. result.payload["nested"]["items"].append("result")
  927. assert result.arguments == {"nested": {"items": ["original"]}}
  928. assert caller_arguments == {"nested": {"items": ["original"]}}
  929. @pytest.mark.asyncio
  930. @pytest.mark.parametrize("payload", [None, "text", 1, ["item"]])
  931. async def test_kernel_normalizes_invalid_handler_payloads(payload: Any):
  932. result = await EventKernel(
  933. EventRegistry([_definition(handler=lambda request: payload)])
  934. ).execute(
  935. EventRequest(
  936. id="event-1",
  937. name="example.lookup",
  938. arguments={"query": "value"},
  939. source=EventSource.PROVIDER_RESOLVED,
  940. ),
  941. enabled_names=["example.lookup"],
  942. )
  943. assert result.status is EventStatus.HANDLER_ERROR
  944. assert result.error == "event handler returned non-object payload"
  945. @pytest.mark.asyncio
  946. @pytest.mark.parametrize("execution", ["async", "sync"])
  947. @pytest.mark.parametrize(
  948. "invalid_value",
  949. [object(), {"set-item"}, float("nan"), float("inf")],
  950. )
  951. async def test_kernel_rejects_non_json_handler_dictionary_payloads(
  952. execution: str,
  953. invalid_value: Any,
  954. ):
  955. definition = _definition(
  956. handler=lambda request: {"invalid": invalid_value},
  957. )
  958. request = EventRequest(
  959. id="event-1",
  960. name="example.lookup",
  961. arguments={"query": "value"},
  962. raw_arguments='{"query":"value"}',
  963. source=EventSource.PROVIDER_RESOLVED,
  964. )
  965. kernel = EventKernel(EventRegistry([definition]))
  966. result = (
  967. await kernel.execute(request, enabled_names=["example.lookup"])
  968. if execution == "async"
  969. else kernel.execute_sync(request, enabled_names=["example.lookup"])
  970. )
  971. assert result.status is EventStatus.HANDLER_ERROR
  972. assert result.error == "event handler returned non-JSON payload"
  973. @pytest.mark.asyncio
  974. async def test_async_kernel_supports_async_handler():
  975. async def handler(request: EventRequest) -> dict[str, Any]:
  976. return {"query": request.arguments["query"]}
  977. result = await EventKernel(
  978. EventRegistry([_definition(handler=handler)])
  979. ).execute(
  980. EventRequest(
  981. id="event-1",
  982. name="example.lookup",
  983. arguments={"query": "async"},
  984. source=EventSource.PROVIDER_RESOLVED,
  985. ),
  986. enabled_names=["example.lookup"],
  987. )
  988. assert result.status is EventStatus.SUCCESS
  989. assert result.payload == {"query": "async"}
  990. @pytest.mark.asyncio
  991. async def test_tool_registry_async_entry_points_support_async_handler():
  992. async def handler(event: ToolCallEvent) -> dict[str, Any]:
  993. return {"tool": event.name, "query": event.arguments["query"]}
  994. registry = ToolRegistry(
  995. [
  996. ToolDefinition(
  997. name="async.lookup",
  998. description="Async lookup.",
  999. parameters={
  1000. "type": "object",
  1001. "properties": {"query": {"type": "string"}},
  1002. "required": ["query"],
  1003. },
  1004. handler=handler,
  1005. )
  1006. ]
  1007. )
  1008. event = ToolCallEvent(
  1009. id="call-1",
  1010. name="async.lookup",
  1011. arguments={"query": "value"},
  1012. raw_arguments='{"query":"value"}',
  1013. )
  1014. assert await registry.handle_async(event) == {
  1015. "tool": "async.lookup",
  1016. "query": "value",
  1017. }
  1018. assert await registry.execute_async(event) == {
  1019. "tool": "async.lookup",
  1020. "query": "value",
  1021. }
  1022. def test_tool_registry_sync_facade_rejects_async_handler_without_runtime_warning():
  1023. called = False
  1024. async def handler(event: ToolCallEvent) -> dict[str, Any]:
  1025. nonlocal called
  1026. called = True
  1027. return {"tool": event.name}
  1028. registry = ToolRegistry(
  1029. [
  1030. ToolDefinition(
  1031. name="async.lookup",
  1032. description="Async lookup.",
  1033. parameters={"type": "object"},
  1034. handler=handler,
  1035. )
  1036. ]
  1037. )
  1038. event = ToolCallEvent(
  1039. id="call-1",
  1040. name="async.lookup",
  1041. arguments={},
  1042. raw_arguments="{}",
  1043. )
  1044. with warnings.catch_warnings(record=True) as captured:
  1045. warnings.simplefilter("always")
  1046. payload = registry.execute(event)
  1047. gc.collect()
  1048. assert payload == {
  1049. "tool": "async.lookup",
  1050. "error": "tool handler failed: async event handlers require execute_async",
  1051. }
  1052. assert called is False
  1053. assert not [warning for warning in captured if issubclass(warning.category, RuntimeWarning)]
  1054. @pytest.mark.asyncio
  1055. async def test_kernel_rejects_boolean_for_json_number_arguments():
  1056. definition = _definition(
  1057. parameters={
  1058. "type": "object",
  1059. "properties": {"query": {"type": "number"}},
  1060. "required": ["query"],
  1061. }
  1062. )
  1063. result = await EventKernel(EventRegistry([definition])).execute(
  1064. EventRequest(
  1065. id="event-1",
  1066. name="example.lookup",
  1067. arguments={"query": True},
  1068. source=EventSource.PROVIDER_RESOLVED,
  1069. ),
  1070. enabled_names=["example.lookup"],
  1071. )
  1072. assert result.status is EventStatus.INVALID_ARGUMENTS
  1073. assert result.error == "invalid argument type for query: expected number"
  1074. @pytest.mark.asyncio
  1075. async def test_definition_metadata_survives_registration_and_result_creation():
  1076. definition = _definition(
  1077. result_policy=ResultPolicy.TEMPLATE_FOLLOW_UP,
  1078. confirmation_policy=ConfirmationPolicy.REQUIRED,
  1079. risk_level=RiskLevel.HIGH,
  1080. idempotency_key_fields=("session_id", "event_id"),
  1081. concurrency_class="device-write",
  1082. conflict_keys=("device",),
  1083. timeout_seconds=1.5,
  1084. terminal=True,
  1085. )
  1086. registry = EventRegistry([definition])
  1087. result = await EventKernel(registry).execute(
  1088. EventRequest(
  1089. id="event-1",
  1090. name="example.lookup",
  1091. arguments={"query": "value"},
  1092. source=EventSource.PROVIDER_RESOLVED,
  1093. ),
  1094. enabled_names=["example.lookup"],
  1095. )
  1096. registered = registry.definition("example.lookup")
  1097. assert registered is not definition
  1098. assert result.result_policy is ResultPolicy.TEMPLATE_FOLLOW_UP
  1099. assert result.confirmation_policy is ConfirmationPolicy.REQUIRED
  1100. assert result.risk_level is RiskLevel.HIGH
  1101. assert result.idempotency_key_fields == ("session_id", "event_id")
  1102. assert result.concurrency_class == "device-write"
  1103. assert result.conflict_keys == ("device",)
  1104. assert result.timeout_seconds == 1.5
  1105. assert result.terminal is True
  1106. @pytest.mark.asyncio
  1107. @pytest.mark.parametrize("name", ["alpha.one", "beta-two", "任意.事件"])
  1108. async def test_kernel_applies_identical_behavior_to_arbitrary_event_names(name: str):
  1109. definition = _definition(name=name)
  1110. result = await EventKernel(EventRegistry([definition])).execute(
  1111. EventRequest(
  1112. id="event-1",
  1113. name=name,
  1114. arguments={"query": "value"},
  1115. source=EventSource.PROVIDER_RESOLVED,
  1116. ),
  1117. enabled_names=[name],
  1118. )
  1119. assert result.status is EventStatus.SUCCESS
  1120. assert result.payload == {"event": name, "query": "value"}