test_event_kernel.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098
  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_tool_registry_public_api_remains_compatible():
  78. registry = ToolRegistry(
  79. [
  80. ToolDefinition(
  81. name="compat.lookup",
  82. description="Look up compatibility data.",
  83. parameters={
  84. "type": "object",
  85. "properties": {"query": {"type": "string"}},
  86. "required": ["query"],
  87. },
  88. handler=lambda event: {
  89. "tool": event.name,
  90. "query": event.arguments["query"],
  91. },
  92. argument_resolver=lambda event, context: {
  93. "query": context.history[-1].content
  94. },
  95. )
  96. ]
  97. )
  98. event = ToolCallEvent(
  99. id="call-1",
  100. name="compat.lookup",
  101. arguments={"query": "provider"},
  102. raw_arguments='{"query":"provider"}',
  103. )
  104. assert registry.available_tools() == [
  105. {
  106. "name": "compat.lookup",
  107. "description": "Look up compatibility data.",
  108. "parameters": {
  109. "type": "object",
  110. "properties": {"query": {"type": "string"}},
  111. "required": ["query"],
  112. },
  113. }
  114. ]
  115. assert "- compat.lookup: Look up compatibility data." in (
  116. registry.chat_event_system_message(["compat.lookup"])
  117. )
  118. assert registry.tool_schema("compat.lookup")["function"]["name"] == (
  119. "compat.lookup"
  120. )
  121. assert registry.handle(
  122. event,
  123. ToolExecutionContext(history=[ChatMessage(role="user", content="history")]),
  124. ) == {"tool": "compat.lookup", "query": "history"}
  125. assert registry.execute(event) == {"tool": "compat.lookup", "query": "provider"}
  126. @pytest.mark.asyncio
  127. async def test_kernel_executes_complete_deterministic_arguments_without_fallback():
  128. fallback_calls: list[str] = []
  129. async def fallback(*args: Any) -> dict[str, Any]:
  130. fallback_calls.append("called")
  131. return {"query": "fallback"}
  132. registry = EventRegistry(
  133. [_definition(resolver=lambda request, context: {"query": "deterministic"})]
  134. )
  135. result = await EventKernel(registry, argument_fallback=fallback).execute(
  136. EventRequest(id="event-1", name="example.lookup"),
  137. enabled_names=["example.lookup"],
  138. )
  139. assert result.status is EventStatus.SUCCESS
  140. assert result.arguments == {"query": "deterministic"}
  141. assert result.payload == {"event": "example.lookup", "query": "deterministic"}
  142. assert result.used_fallback is False
  143. assert fallback_calls == []
  144. @pytest.mark.asyncio
  145. async def test_kernel_calls_fallback_once_when_required_arguments_are_incomplete():
  146. fallback_calls: list[dict[str, Any]] = []
  147. async def fallback(
  148. definition: EventDefinition,
  149. request: EventRequest,
  150. context: EventExecutionContext,
  151. ) -> ResolvedEventArguments:
  152. fallback_calls.append(dict(request.arguments))
  153. return ResolvedEventArguments(
  154. event_name=definition.name,
  155. arguments={"query": "resolved once"},
  156. raw_arguments='{"query":"resolved once"}',
  157. )
  158. registry = EventRegistry([_definition(resolver=lambda request, context: {})])
  159. result = await EventKernel(registry, argument_fallback=fallback).execute(
  160. EventRequest(id="event-1", name="example.lookup"),
  161. enabled_names=["example.lookup"],
  162. )
  163. assert result.status is EventStatus.SUCCESS
  164. assert result.arguments == {"query": "resolved once"}
  165. assert result.used_fallback is True
  166. assert fallback_calls == [{}]
  167. @pytest.mark.asyncio
  168. @pytest.mark.parametrize(
  169. "arguments",
  170. [
  171. {"query": 42},
  172. {"query": "unsupported"},
  173. {"query": "valid", "unexpected": True},
  174. ],
  175. )
  176. async def test_kernel_does_not_fallback_for_complete_invalid_arguments(
  177. arguments: dict[str, Any],
  178. ):
  179. fallback_calls = 0
  180. async def fallback(*args: Any) -> ResolvedEventArguments:
  181. nonlocal fallback_calls
  182. fallback_calls += 1
  183. return ResolvedEventArguments(
  184. event_name="example.lookup",
  185. arguments={"query": "valid"},
  186. raw_arguments='{"query":"valid"}',
  187. )
  188. definition = _definition(
  189. parameters={
  190. "type": "object",
  191. "properties": {"query": {"type": "string", "enum": ["valid"]}},
  192. "required": ["query"],
  193. "additionalProperties": False,
  194. },
  195. resolver=lambda request, context: arguments,
  196. )
  197. result = await EventKernel(
  198. EventRegistry([definition]), argument_fallback=fallback
  199. ).execute(
  200. EventRequest(id="event-1", name="example.lookup"),
  201. enabled_names=["example.lookup"],
  202. )
  203. assert result.status is EventStatus.INVALID_ARGUMENTS
  204. assert result.used_fallback is False
  205. assert fallback_calls == 0
  206. @pytest.mark.asyncio
  207. async def test_kernel_does_not_fallback_when_definition_disallows_it():
  208. fallback_calls = 0
  209. async def fallback(*args: Any) -> ResolvedEventArguments:
  210. nonlocal fallback_calls
  211. fallback_calls += 1
  212. return ResolvedEventArguments(
  213. event_name="example.lookup",
  214. arguments={"query": "not allowed"},
  215. raw_arguments='{"query":"not allowed"}',
  216. )
  217. registry = EventRegistry(
  218. [
  219. _definition(
  220. resolver=lambda request, context: {},
  221. fallback_allowed=False,
  222. )
  223. ]
  224. )
  225. result = await EventKernel(registry, argument_fallback=fallback).execute(
  226. EventRequest(id="event-1", name="example.lookup"),
  227. enabled_names=["example.lookup"],
  228. )
  229. assert result.status is EventStatus.INVALID_ARGUMENTS
  230. assert result.error == "missing required arguments: query"
  231. assert result.used_fallback is False
  232. assert fallback_calls == 0
  233. @pytest.mark.asyncio
  234. async def test_structured_resolution_can_mark_optional_arguments_incomplete():
  235. fallback_calls = 0
  236. async def fallback(*args: Any) -> ResolvedEventArguments:
  237. nonlocal fallback_calls
  238. fallback_calls += 1
  239. return ResolvedEventArguments(
  240. event_name="example.lookup",
  241. arguments={"query": "resolved"},
  242. raw_arguments='{"query":"resolved"}',
  243. )
  244. definition = _definition(
  245. parameters={
  246. "type": "object",
  247. "properties": {"query": {"type": "string"}},
  248. "additionalProperties": False,
  249. },
  250. resolver=lambda request, context: EventArgumentResolution(
  251. arguments={},
  252. complete=False,
  253. ),
  254. )
  255. result = await EventKernel(
  256. EventRegistry([definition]), argument_fallback=fallback
  257. ).execute(
  258. EventRequest(id="event-1", name="example.lookup"),
  259. enabled_names=["example.lookup"],
  260. )
  261. assert result.status is EventStatus.SUCCESS
  262. assert result.arguments == {"query": "resolved"}
  263. assert result.used_fallback is True
  264. assert fallback_calls == 1
  265. @pytest.mark.asyncio
  266. async def test_structured_incomplete_optional_arguments_fail_without_fallback():
  267. definition = _definition(
  268. parameters={
  269. "type": "object",
  270. "properties": {"query": {"type": "string"}},
  271. },
  272. resolver=lambda request, context: EventArgumentResolution(
  273. arguments={},
  274. complete=False,
  275. ),
  276. handler=lambda request: {"ok": True},
  277. )
  278. result = await EventKernel(EventRegistry([definition])).execute(
  279. EventRequest(id="event-1", name="example.lookup"),
  280. enabled_names=["example.lookup"],
  281. )
  282. assert result.status is EventStatus.INVALID_ARGUMENTS
  283. assert result.error == "event arguments incomplete"
  284. @pytest.mark.asyncio
  285. async def test_plain_dict_resolution_remains_complete_for_optional_schema():
  286. fallback_calls = 0
  287. async def fallback(*args: Any) -> ResolvedEventArguments:
  288. nonlocal fallback_calls
  289. fallback_calls += 1
  290. raise AssertionError("fallback should not run")
  291. definition = _definition(
  292. parameters={
  293. "type": "object",
  294. "properties": {"query": {"type": "string"}},
  295. },
  296. resolver=lambda request, context: {},
  297. handler=lambda request: {"ok": True},
  298. )
  299. result = await EventKernel(
  300. EventRegistry([definition]), argument_fallback=fallback
  301. ).execute(
  302. EventRequest(id="event-1", name="example.lookup"),
  303. enabled_names=["example.lookup"],
  304. )
  305. assert result.status is EventStatus.SUCCESS
  306. assert fallback_calls == 0
  307. def test_sync_kernel_rejects_structured_incomplete_optional_arguments():
  308. handler_calls = 0
  309. def handler(request: EventRequest) -> dict[str, Any]:
  310. nonlocal handler_calls
  311. handler_calls += 1
  312. return {"ok": True}
  313. definition = _definition(
  314. parameters={
  315. "type": "object",
  316. "properties": {"query": {"type": "string"}},
  317. },
  318. resolver=lambda request, context: EventArgumentResolution(
  319. arguments={},
  320. complete=False,
  321. ),
  322. handler=handler,
  323. )
  324. result = EventKernel(EventRegistry([definition])).execute_sync(
  325. EventRequest(id="event-1", name="example.lookup"),
  326. enabled_names=["example.lookup"],
  327. )
  328. assert result.status is EventStatus.INVALID_ARGUMENTS
  329. assert result.error == "event arguments incomplete"
  330. assert handler_calls == 0
  331. @pytest.mark.parametrize(
  332. "resolver",
  333. [
  334. lambda request, context: EventArgumentResolution(
  335. arguments={},
  336. complete=True,
  337. ),
  338. lambda request, context: {},
  339. ],
  340. )
  341. def test_sync_kernel_preserves_complete_compatible_resolvers(resolver: Any):
  342. handler_calls = 0
  343. def handler(request: EventRequest) -> dict[str, Any]:
  344. nonlocal handler_calls
  345. handler_calls += 1
  346. return {"ok": True}
  347. definition = _definition(
  348. parameters={
  349. "type": "object",
  350. "properties": {"query": {"type": "string"}},
  351. },
  352. resolver=resolver,
  353. handler=handler,
  354. )
  355. result = EventKernel(EventRegistry([definition])).execute_sync(
  356. EventRequest(id="event-1", name="example.lookup"),
  357. enabled_names=["example.lookup"],
  358. )
  359. assert result.status is EventStatus.SUCCESS
  360. assert result.payload == {"ok": True}
  361. assert handler_calls == 1
  362. @pytest.mark.asyncio
  363. async def test_provider_resolved_arguments_are_not_rewritten_or_fallen_back():
  364. resolver_calls = 0
  365. fallback_calls = 0
  366. def resolver(*args: Any) -> dict[str, Any]:
  367. nonlocal resolver_calls
  368. resolver_calls += 1
  369. return {"query": "rewritten"}
  370. async def fallback(*args: Any) -> ResolvedEventArguments:
  371. nonlocal fallback_calls
  372. fallback_calls += 1
  373. return ResolvedEventArguments(
  374. event_name="example.lookup",
  375. arguments={"query": "fallback"},
  376. raw_arguments='{"query":"fallback"}',
  377. )
  378. registry = EventRegistry([_definition(resolver=resolver)])
  379. result = await EventKernel(registry, argument_fallback=fallback).execute(
  380. EventRequest(
  381. id="event-1",
  382. name="example.lookup",
  383. arguments={"query": "provider value"},
  384. source=EventSource.PROVIDER_RESOLVED,
  385. ),
  386. enabled_names=["example.lookup"],
  387. )
  388. assert result.status is EventStatus.SUCCESS
  389. assert result.arguments == {"query": "provider value"}
  390. assert resolver_calls == 0
  391. assert fallback_calls == 0
  392. @pytest.mark.asyncio
  393. async def test_provider_resolved_missing_arguments_return_invalid_without_fallback():
  394. fallback_calls = 0
  395. async def fallback(*args: Any) -> ResolvedEventArguments:
  396. nonlocal fallback_calls
  397. fallback_calls += 1
  398. return ResolvedEventArguments(
  399. event_name="example.lookup",
  400. arguments={"query": "fallback"},
  401. raw_arguments='{"query":"fallback"}',
  402. )
  403. result = await EventKernel(
  404. EventRegistry([_definition()]), argument_fallback=fallback
  405. ).execute(
  406. EventRequest(
  407. id="event-1",
  408. name="example.lookup",
  409. arguments={},
  410. source=EventSource.PROVIDER_RESOLVED,
  411. ),
  412. enabled_names=["example.lookup"],
  413. )
  414. assert result.status is EventStatus.INVALID_ARGUMENTS
  415. assert result.error == "missing required arguments: query"
  416. assert fallback_calls == 0
  417. @pytest.mark.asyncio
  418. @pytest.mark.parametrize(
  419. ("event_request", "enabled_names", "expected_status", "expected_error"),
  420. [
  421. (
  422. EventRequest(id="event-1", name="missing"),
  423. ["missing"],
  424. EventStatus.UNKNOWN,
  425. "unknown event",
  426. ),
  427. (
  428. EventRequest(id="event-1", name="example.lookup"),
  429. [],
  430. EventStatus.DISABLED,
  431. "event disabled",
  432. ),
  433. (
  434. EventRequest(
  435. id="event-1",
  436. name="example.lookup",
  437. arguments={"query": 42},
  438. source=EventSource.PROVIDER_RESOLVED,
  439. ),
  440. ["example.lookup"],
  441. EventStatus.INVALID_ARGUMENTS,
  442. "invalid argument type for query: expected string",
  443. ),
  444. ],
  445. )
  446. async def test_kernel_normalizes_lookup_and_validation_failures(
  447. event_request: EventRequest,
  448. enabled_names: list[str],
  449. expected_status: EventStatus,
  450. expected_error: str,
  451. ):
  452. result = await EventKernel(EventRegistry([_definition()])).execute(
  453. event_request,
  454. enabled_names=enabled_names,
  455. )
  456. assert result.status is expected_status
  457. assert result.error == expected_error
  458. @pytest.mark.asyncio
  459. async def test_kernel_normalizes_handler_exceptions():
  460. def fail(request: EventRequest) -> dict[str, Any]:
  461. raise RuntimeError("boom")
  462. result = await EventKernel(
  463. EventRegistry([_definition(handler=fail)])
  464. ).execute(
  465. EventRequest(
  466. id="event-1",
  467. name="example.lookup",
  468. arguments={"query": "value"},
  469. source=EventSource.PROVIDER_RESOLVED,
  470. ),
  471. enabled_names=["example.lookup"],
  472. )
  473. assert result.status is EventStatus.HANDLER_ERROR
  474. assert result.error == "event handler failed: boom"
  475. @pytest.mark.asyncio
  476. async def test_kernel_validates_complete_draft_2020_12_schema():
  477. definition = _definition(
  478. parameters={
  479. "type": "object",
  480. "properties": {
  481. "mode": {"enum": ["quick", "deep"]},
  482. "target": {"type": ["string", "null"]},
  483. "filters": {
  484. "type": "array",
  485. "items": {
  486. "type": "object",
  487. "properties": {"score": {"type": "number", "minimum": 0}},
  488. "required": ["score"],
  489. "additionalProperties": False,
  490. },
  491. },
  492. },
  493. "required": ["mode", "target", "filters"],
  494. "additionalProperties": False,
  495. },
  496. handler=lambda request: {"event": request.name},
  497. )
  498. kernel = EventKernel(EventRegistry([definition]))
  499. valid = await kernel.execute(
  500. EventRequest(
  501. id="valid",
  502. name=definition.name,
  503. arguments={
  504. "mode": "deep",
  505. "target": None,
  506. "filters": [{"score": 0.5}],
  507. },
  508. source=EventSource.PROVIDER_RESOLVED,
  509. ),
  510. enabled_names=[definition.name],
  511. )
  512. invalid = await kernel.execute(
  513. EventRequest(
  514. id="invalid",
  515. name=definition.name,
  516. arguments={
  517. "mode": "other",
  518. "target": 7,
  519. "filters": [{"score": -1, "extra": True}],
  520. "unexpected": True,
  521. },
  522. source=EventSource.PROVIDER_RESOLVED,
  523. ),
  524. enabled_names=[definition.name],
  525. )
  526. assert valid.status is EventStatus.SUCCESS
  527. assert invalid.status is EventStatus.INVALID_ARGUMENTS
  528. assert invalid.error.startswith("invalid event arguments:")
  529. @pytest.mark.asyncio
  530. async def test_kernel_normalizes_validator_runtime_exceptions():
  531. definition = _definition(parameters={"$ref": "urn:agent-lab:missing-schema"})
  532. result = await EventKernel(EventRegistry([definition])).execute(
  533. EventRequest(
  534. id="event-1",
  535. name=definition.name,
  536. arguments={},
  537. source=EventSource.PROVIDER_RESOLVED,
  538. ),
  539. enabled_names=[definition.name],
  540. )
  541. assert result.status is EventStatus.DEFINITION_ERROR
  542. assert result.error.startswith("event argument validation failed:")
  543. @pytest.mark.asyncio
  544. async def test_tool_registry_maps_definition_errors_to_tool_compatibility_payload():
  545. registry = ToolRegistry(
  546. [
  547. ToolDefinition(
  548. name="broken.lookup",
  549. description="Broken lookup.",
  550. parameters={"$ref": "urn:agent-lab:missing-schema"},
  551. handler=lambda event: {"tool": event.name},
  552. )
  553. ]
  554. )
  555. payload = await registry.execute_async(
  556. ToolCallEvent(
  557. id="call-1",
  558. name="broken.lookup",
  559. arguments={},
  560. raw_arguments="{}",
  561. )
  562. )
  563. assert payload["tool"] == "broken.lookup"
  564. assert payload["error"].startswith("tool definition validation failed:")
  565. @pytest.mark.asyncio
  566. @pytest.mark.parametrize("boundary", ["resolver", "fallback"])
  567. async def test_kernel_normalizes_resolution_boundary_exceptions(boundary: str):
  568. def resolver(request: EventRequest, context: EventExecutionContext) -> dict[str, Any]:
  569. if boundary == "resolver":
  570. raise RuntimeError("resolver boom")
  571. return {}
  572. async def fallback(*args: Any) -> ResolvedEventArguments:
  573. raise RuntimeError("fallback boom")
  574. result = await EventKernel(
  575. EventRegistry([_definition(resolver=resolver)]),
  576. argument_fallback=fallback,
  577. ).execute(
  578. EventRequest(id="event-1", name="example.lookup"),
  579. enabled_names=["example.lookup"],
  580. )
  581. assert result.status is EventStatus.RESOLUTION_ERROR
  582. assert result.error == f"event argument {boundary} failed: {boundary} boom"
  583. @pytest.mark.asyncio
  584. @pytest.mark.parametrize("boundary", ["resolver", "fallback"])
  585. async def test_kernel_normalizes_invalid_resolution_payloads(boundary: str):
  586. resolver = (
  587. (lambda request, context: None)
  588. if boundary == "resolver"
  589. else (lambda request, context: {})
  590. )
  591. async def fallback(*args: Any) -> Any:
  592. return {"query": "legacy bare mapping"}
  593. result = await EventKernel(
  594. EventRegistry([_definition(resolver=resolver)]),
  595. argument_fallback=fallback,
  596. ).execute(
  597. EventRequest(id="event-1", name="example.lookup"),
  598. enabled_names=["example.lookup"],
  599. )
  600. assert result.status is EventStatus.RESOLUTION_ERROR
  601. assert result.error == f"event argument {boundary} returned invalid payload"
  602. @pytest.mark.asyncio
  603. async def test_kernel_normalizes_non_json_resolver_arguments():
  604. result = await EventKernel(
  605. EventRegistry(
  606. [
  607. _definition(
  608. resolver=lambda request, context: {"query": object()}
  609. )
  610. ]
  611. )
  612. ).execute(
  613. EventRequest(id="event-1", name="example.lookup"),
  614. enabled_names=["example.lookup"],
  615. )
  616. assert result.status is EventStatus.RESOLUTION_ERROR
  617. assert result.error.startswith("event argument resolver failed to serialize:")
  618. @pytest.mark.asyncio
  619. async def test_kernel_normalizes_non_object_fallback_arguments():
  620. async def fallback(*args: Any) -> ResolvedEventArguments:
  621. return ResolvedEventArguments(
  622. event_name="example.lookup",
  623. arguments=["not", "an", "object"], # type: ignore[arg-type]
  624. raw_arguments='["not","an","object"]',
  625. )
  626. result = await EventKernel(
  627. EventRegistry([_definition(resolver=lambda request, context: {})]),
  628. argument_fallback=fallback,
  629. ).execute(
  630. EventRequest(id="event-1", name="example.lookup"),
  631. enabled_names=["example.lookup"],
  632. )
  633. assert result.status is EventStatus.RESOLUTION_ERROR
  634. assert result.error == "event argument fallback returned invalid payload"
  635. @pytest.mark.asyncio
  636. @pytest.mark.parametrize(
  637. ("resolved", "expected_error"),
  638. [
  639. (
  640. ResolvedEventArguments(
  641. event_name="another.event",
  642. arguments={"query": "value"},
  643. raw_arguments='{"query":"value"}',
  644. ),
  645. "fallback returned tool another.event for example.lookup",
  646. ),
  647. (
  648. ResolvedEventArguments(
  649. event_name="example.lookup",
  650. arguments={"query": "value"},
  651. raw_arguments="not-json",
  652. ),
  653. "fallback raw arguments are not valid JSON",
  654. ),
  655. (
  656. ResolvedEventArguments(
  657. event_name="example.lookup",
  658. arguments={"query": "parsed"},
  659. raw_arguments='{"query":"raw"}',
  660. ),
  661. "fallback raw arguments do not match parsed arguments",
  662. ),
  663. ],
  664. )
  665. async def test_kernel_rejects_inconsistent_structured_fallback(
  666. resolved: ResolvedEventArguments,
  667. expected_error: str,
  668. ):
  669. async def fallback(*args: Any) -> ResolvedEventArguments:
  670. return resolved
  671. result = await EventKernel(
  672. EventRegistry([_definition(resolver=lambda request, context: {})]),
  673. argument_fallback=fallback,
  674. ).execute(
  675. EventRequest(id="event-1", name="example.lookup"),
  676. enabled_names=["example.lookup"],
  677. )
  678. assert result.status is EventStatus.RESOLUTION_ERROR
  679. assert result.error == expected_error
  680. @pytest.mark.asyncio
  681. @pytest.mark.parametrize(
  682. ("arguments", "raw_arguments"),
  683. [
  684. ({"query": 1}, '{"query":true}'),
  685. ({"query": 1.0}, '{"query":1}'),
  686. ({"query": {"nested": [1]}}, '{"query":{"nested":[true]}}'),
  687. ({"query": float("nan")}, '{"query":NaN}'),
  688. ({"query": float("inf")}, '{"query":Infinity}'),
  689. ],
  690. )
  691. async def test_kernel_rejects_noncanonical_fallback_json(
  692. arguments: dict[str, Any],
  693. raw_arguments: str,
  694. ):
  695. async def fallback(*args: Any) -> ResolvedEventArguments:
  696. return ResolvedEventArguments(
  697. event_name="example.lookup",
  698. arguments=arguments,
  699. raw_arguments=raw_arguments,
  700. )
  701. result = await EventKernel(
  702. EventRegistry([_definition(resolver=lambda request, context: {})]),
  703. argument_fallback=fallback,
  704. ).execute(
  705. EventRequest(id="event-1", name="example.lookup"),
  706. enabled_names=["example.lookup"],
  707. )
  708. assert result.status is EventStatus.RESOLUTION_ERROR
  709. @pytest.mark.asyncio
  710. async def test_registry_schema_is_isolated_from_caller_mutation():
  711. parameters = {
  712. "type": "object",
  713. "properties": {"query": {"type": "string"}},
  714. "required": ["query"],
  715. "additionalProperties": False,
  716. }
  717. registry = EventRegistry([_definition(parameters=parameters)])
  718. parameters["properties"]["query"]["type"] = "integer"
  719. parameters["required"].clear()
  720. parameters["additionalProperties"] = True
  721. assert registry.catalog()[0]["parameters"] == {
  722. "type": "object",
  723. "properties": {"query": {"type": "string"}},
  724. "required": ["query"],
  725. "additionalProperties": False,
  726. }
  727. registered = registry.definition("example.lookup")
  728. assert registered is not None
  729. with pytest.raises(TypeError):
  730. registered.parameters["additionalProperties"] = True
  731. with pytest.raises(TypeError):
  732. registered.parameters["properties"]["query"]["type"] = "integer"
  733. with pytest.raises(AttributeError):
  734. registered.parameters["required"].append("unexpected")
  735. result = await EventKernel(registry).execute(
  736. EventRequest(
  737. id="event-1",
  738. name="example.lookup",
  739. arguments={"query": 42, "unexpected": True},
  740. source=EventSource.PROVIDER_RESOLVED,
  741. ),
  742. enabled_names=["example.lookup"],
  743. )
  744. assert result.status is EventStatus.INVALID_ARGUMENTS
  745. @pytest.mark.asyncio
  746. async def test_kernel_passes_consistent_fallback_arguments_and_raw_json_to_handler():
  747. captured: list[EventRequest] = []
  748. async def fallback(*args: Any) -> ResolvedEventArguments:
  749. return ResolvedEventArguments(
  750. event_name="example.lookup",
  751. arguments={"query": "resolved"},
  752. raw_arguments='{"query":"resolved"}',
  753. )
  754. result = await EventKernel(
  755. EventRegistry(
  756. [
  757. _definition(
  758. resolver=lambda request, context: {},
  759. handler=lambda request: captured.append(request) or {"ok": True},
  760. )
  761. ]
  762. ),
  763. argument_fallback=fallback,
  764. ).execute(
  765. EventRequest(id="event-1", name="example.lookup"),
  766. enabled_names=["example.lookup"],
  767. )
  768. assert result.status is EventStatus.SUCCESS
  769. assert captured[0].arguments == json.loads(captured[0].raw_arguments)
  770. assert result.raw_arguments == captured[0].raw_arguments
  771. @pytest.mark.asyncio
  772. @pytest.mark.parametrize("payload", [None, "text", 1, ["item"]])
  773. async def test_kernel_normalizes_invalid_handler_payloads(payload: Any):
  774. result = await EventKernel(
  775. EventRegistry([_definition(handler=lambda request: payload)])
  776. ).execute(
  777. EventRequest(
  778. id="event-1",
  779. name="example.lookup",
  780. arguments={"query": "value"},
  781. source=EventSource.PROVIDER_RESOLVED,
  782. ),
  783. enabled_names=["example.lookup"],
  784. )
  785. assert result.status is EventStatus.HANDLER_ERROR
  786. assert result.error == "event handler returned non-object payload"
  787. @pytest.mark.asyncio
  788. async def test_async_kernel_supports_async_handler():
  789. async def handler(request: EventRequest) -> dict[str, Any]:
  790. return {"query": request.arguments["query"]}
  791. result = await EventKernel(
  792. EventRegistry([_definition(handler=handler)])
  793. ).execute(
  794. EventRequest(
  795. id="event-1",
  796. name="example.lookup",
  797. arguments={"query": "async"},
  798. source=EventSource.PROVIDER_RESOLVED,
  799. ),
  800. enabled_names=["example.lookup"],
  801. )
  802. assert result.status is EventStatus.SUCCESS
  803. assert result.payload == {"query": "async"}
  804. @pytest.mark.asyncio
  805. async def test_tool_registry_async_entry_points_support_async_handler():
  806. async def handler(event: ToolCallEvent) -> dict[str, Any]:
  807. return {"tool": event.name, "query": event.arguments["query"]}
  808. registry = ToolRegistry(
  809. [
  810. ToolDefinition(
  811. name="async.lookup",
  812. description="Async lookup.",
  813. parameters={
  814. "type": "object",
  815. "properties": {"query": {"type": "string"}},
  816. "required": ["query"],
  817. },
  818. handler=handler,
  819. )
  820. ]
  821. )
  822. event = ToolCallEvent(
  823. id="call-1",
  824. name="async.lookup",
  825. arguments={"query": "value"},
  826. raw_arguments='{"query":"value"}',
  827. )
  828. assert await registry.handle_async(event) == {
  829. "tool": "async.lookup",
  830. "query": "value",
  831. }
  832. assert await registry.execute_async(event) == {
  833. "tool": "async.lookup",
  834. "query": "value",
  835. }
  836. def test_tool_registry_sync_facade_rejects_async_handler_without_runtime_warning():
  837. called = False
  838. async def handler(event: ToolCallEvent) -> dict[str, Any]:
  839. nonlocal called
  840. called = True
  841. return {"tool": event.name}
  842. registry = ToolRegistry(
  843. [
  844. ToolDefinition(
  845. name="async.lookup",
  846. description="Async lookup.",
  847. parameters={"type": "object"},
  848. handler=handler,
  849. )
  850. ]
  851. )
  852. event = ToolCallEvent(
  853. id="call-1",
  854. name="async.lookup",
  855. arguments={},
  856. raw_arguments="{}",
  857. )
  858. with warnings.catch_warnings(record=True) as captured:
  859. warnings.simplefilter("always")
  860. payload = registry.execute(event)
  861. gc.collect()
  862. assert payload == {
  863. "tool": "async.lookup",
  864. "error": "tool handler failed: async event handlers require execute_async",
  865. }
  866. assert called is False
  867. assert not [warning for warning in captured if issubclass(warning.category, RuntimeWarning)]
  868. @pytest.mark.asyncio
  869. async def test_kernel_rejects_boolean_for_json_number_arguments():
  870. definition = _definition(
  871. parameters={
  872. "type": "object",
  873. "properties": {"query": {"type": "number"}},
  874. "required": ["query"],
  875. }
  876. )
  877. result = await EventKernel(EventRegistry([definition])).execute(
  878. EventRequest(
  879. id="event-1",
  880. name="example.lookup",
  881. arguments={"query": True},
  882. source=EventSource.PROVIDER_RESOLVED,
  883. ),
  884. enabled_names=["example.lookup"],
  885. )
  886. assert result.status is EventStatus.INVALID_ARGUMENTS
  887. assert result.error == "invalid argument type for query: expected number"
  888. @pytest.mark.asyncio
  889. async def test_definition_metadata_survives_registration_and_result_creation():
  890. definition = _definition(
  891. result_policy=ResultPolicy.TEMPLATE_FOLLOW_UP,
  892. confirmation_policy=ConfirmationPolicy.REQUIRED,
  893. risk_level=RiskLevel.HIGH,
  894. idempotency_key_fields=("session_id", "event_id"),
  895. concurrency_class="device-write",
  896. conflict_keys=("device",),
  897. timeout_seconds=1.5,
  898. terminal=True,
  899. )
  900. registry = EventRegistry([definition])
  901. result = await EventKernel(registry).execute(
  902. EventRequest(
  903. id="event-1",
  904. name="example.lookup",
  905. arguments={"query": "value"},
  906. source=EventSource.PROVIDER_RESOLVED,
  907. ),
  908. enabled_names=["example.lookup"],
  909. )
  910. registered = registry.definition("example.lookup")
  911. assert registered is not definition
  912. assert result.result_policy is ResultPolicy.TEMPLATE_FOLLOW_UP
  913. assert result.confirmation_policy is ConfirmationPolicy.REQUIRED
  914. assert result.risk_level is RiskLevel.HIGH
  915. assert result.idempotency_key_fields == ("session_id", "event_id")
  916. assert result.concurrency_class == "device-write"
  917. assert result.conflict_keys == ("device",)
  918. assert result.timeout_seconds == 1.5
  919. assert result.terminal is True
  920. @pytest.mark.asyncio
  921. @pytest.mark.parametrize("name", ["alpha.one", "beta-two", "任意.事件"])
  922. async def test_kernel_applies_identical_behavior_to_arbitrary_event_names(name: str):
  923. definition = _definition(name=name)
  924. result = await EventKernel(EventRegistry([definition])).execute(
  925. EventRequest(
  926. id="event-1",
  927. name=name,
  928. arguments={"query": "value"},
  929. source=EventSource.PROVIDER_RESOLVED,
  930. ),
  931. enabled_names=[name],
  932. )
  933. assert result.status is EventStatus.SUCCESS
  934. assert result.payload == {"event": name, "query": "value"}