| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587 |
- from __future__ import annotations
- import gc
- import json
- import warnings
- from dataclasses import FrozenInstanceError
- from typing import Any
- import pytest
- from agent_lab.application.events import (
- ConfirmationPolicy,
- EventDefinition,
- EventExecutionContext,
- EventKernel,
- EventRegistry,
- EventRequest,
- ResolvedEventArguments,
- EventSource,
- EventStatus,
- ResultPolicy,
- RiskLevel,
- )
- from agent_lab.application.events.models import EventArgumentResolution
- from agent_lab.application.tools import (
- ToolDefinition,
- ToolExecutionContext,
- ToolRegistry,
- )
- from agent_lab.domain.events import ToolCallEvent
- from agent_lab.domain.messages import ChatMessage
- def _definition(
- name: str = "example.lookup",
- **overrides: Any,
- ) -> EventDefinition:
- values: dict[str, Any] = {
- "name": name,
- "description": "Look up an example value.",
- "parameters": {
- "type": "object",
- "properties": {"query": {"type": "string"}},
- "required": ["query"],
- },
- "handler": lambda request: {
- "event": request.name,
- "query": request.arguments["query"],
- },
- }
- values.update(overrides)
- return EventDefinition(**values)
- def test_registry_registers_flat_definitions_and_filters_enabled_catalog():
- registry = EventRegistry(
- [_definition("example.lookup"), _definition("device.inspect")]
- )
- assert [item["name"] for item in registry.catalog()] == [
- "example.lookup",
- "device.inspect",
- ]
- assert registry.catalog(["device.inspect"]) == [
- {
- "name": "device.inspect",
- "description": "Look up an example value.",
- "parameters": {
- "type": "object",
- "properties": {"query": {"type": "string"}},
- "required": ["query"],
- },
- }
- ]
- assert registry.tool_schema("example.lookup")["function"]["name"] == (
- "example.lookup"
- )
- assert registry.tool_schema("missing") is None
- def test_registry_rejects_duplicate_definition_names():
- with pytest.raises(ValueError, match="duplicate event definition: example.lookup"):
- EventRegistry([_definition(), _definition()])
- def test_registry_rejects_invalid_draft_2020_12_schema():
- definition = _definition(parameters={"type": 42})
- with pytest.raises(ValueError, match="invalid event schema for example.lookup"):
- EventRegistry([definition])
- def test_registry_does_not_expose_mutable_validator_instances():
- registry = EventRegistry([_definition()])
- assert not hasattr(registry, "validator")
- errors = list(
- registry.iter_validation_errors(
- "example.lookup",
- {"query": 42},
- )
- )
- assert errors
- assert not hasattr(errors[0], "schema")
- assert not hasattr(errors[0], "instance")
- with pytest.raises(FrozenInstanceError):
- errors[0].message = "mutated" # type: ignore[misc]
- assert list(
- registry.iter_validation_errors(
- "example.lookup",
- {"query": 42},
- )
- )
- def test_tool_registry_public_api_remains_compatible():
- registry = ToolRegistry(
- [
- ToolDefinition(
- name="compat.lookup",
- description="Look up compatibility data.",
- parameters={
- "type": "object",
- "properties": {"query": {"type": "string"}},
- "required": ["query"],
- },
- handler=lambda event: {
- "tool": event.name,
- "query": event.arguments["query"],
- },
- argument_resolver=lambda event, context: {
- "query": context.history[-1].content
- },
- )
- ]
- )
- event = ToolCallEvent(
- id="call-1",
- name="compat.lookup",
- arguments={"query": "provider"},
- raw_arguments='{"query":"provider"}',
- )
- assert registry.available_tools() == [
- {
- "name": "compat.lookup",
- "description": "Look up compatibility data.",
- "parameters": {
- "type": "object",
- "properties": {"query": {"type": "string"}},
- "required": ["query"],
- },
- }
- ]
- assert "- compat.lookup: Look up compatibility data." in (
- registry.chat_event_system_message(["compat.lookup"])
- )
- assert registry.tool_schema("compat.lookup")["function"]["name"] == (
- "compat.lookup"
- )
- assert registry.handle(
- event,
- ToolExecutionContext(history=[ChatMessage(role="user", content="history")]),
- ) == {"tool": "compat.lookup", "query": "history"}
- assert registry.execute(event) == {"tool": "compat.lookup", "query": "provider"}
- @pytest.mark.asyncio
- async def test_kernel_executes_complete_deterministic_arguments_without_fallback():
- fallback_calls: list[str] = []
- async def fallback(*args: Any) -> dict[str, Any]:
- fallback_calls.append("called")
- return {"query": "fallback"}
- registry = EventRegistry(
- [_definition(resolver=lambda request, context: {"query": "deterministic"})]
- )
- result = await EventKernel(registry, argument_fallback=fallback).execute(
- EventRequest(id="event-1", name="example.lookup"),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.SUCCESS
- assert result.arguments == {"query": "deterministic"}
- assert result.payload == {"event": "example.lookup", "query": "deterministic"}
- assert result.used_fallback is False
- assert fallback_calls == []
- @pytest.mark.asyncio
- async def test_kernel_calls_fallback_once_when_required_arguments_are_incomplete():
- fallback_calls: list[dict[str, Any]] = []
- async def fallback(
- definition: EventDefinition,
- request: EventRequest,
- context: EventExecutionContext,
- ) -> ResolvedEventArguments:
- fallback_calls.append(dict(request.arguments))
- return ResolvedEventArguments(
- event_name=definition.name,
- arguments={"query": "resolved once"},
- raw_arguments='{"query":"resolved once"}',
- )
- registry = EventRegistry([_definition(resolver=lambda request, context: {})])
- result = await EventKernel(registry, argument_fallback=fallback).execute(
- EventRequest(id="event-1", name="example.lookup"),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.SUCCESS
- assert result.arguments == {"query": "resolved once"}
- assert result.used_fallback is True
- assert fallback_calls == [{}]
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- "arguments",
- [
- {"query": 42},
- {"query": "unsupported"},
- {"query": "valid", "unexpected": True},
- ],
- )
- async def test_kernel_does_not_fallback_for_complete_invalid_arguments(
- arguments: dict[str, Any],
- ):
- fallback_calls = 0
- async def fallback(*args: Any) -> ResolvedEventArguments:
- nonlocal fallback_calls
- fallback_calls += 1
- return ResolvedEventArguments(
- event_name="example.lookup",
- arguments={"query": "valid"},
- raw_arguments='{"query":"valid"}',
- )
- definition = _definition(
- parameters={
- "type": "object",
- "properties": {"query": {"type": "string", "enum": ["valid"]}},
- "required": ["query"],
- "additionalProperties": False,
- },
- resolver=lambda request, context: arguments,
- )
- result = await EventKernel(
- EventRegistry([definition]), argument_fallback=fallback
- ).execute(
- EventRequest(id="event-1", name="example.lookup"),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.INVALID_ARGUMENTS
- assert result.used_fallback is False
- assert fallback_calls == 0
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- ("arguments", "expected_error"),
- [
- (
- {"mode": "invalid"},
- "invalid event arguments: 'invalid' is not one of ['valid']",
- ),
- (
- {"mode": "valid", "count": "invalid"},
- "invalid argument type for count: expected integer",
- ),
- (
- {"mode": "valid", "unexpected": True},
- "invalid event arguments: Additional properties are not allowed "
- "('unexpected' was unexpected)",
- ),
- ],
- )
- async def test_kernel_does_not_fallback_for_required_plus_substantive_error(
- arguments: dict[str, Any],
- expected_error: str,
- ):
- fallback_calls = 0
- async def fallback(*args: Any) -> ResolvedEventArguments:
- nonlocal fallback_calls
- fallback_calls += 1
- raise AssertionError("fallback should not run")
- definition = _definition(
- parameters={
- "type": "object",
- "properties": {
- "query": {"type": "string"},
- "mode": {"enum": ["valid"]},
- "count": {"type": "integer"},
- },
- "required": ["query"],
- "additionalProperties": False,
- },
- resolver=lambda request, context: arguments,
- )
- result = await EventKernel(
- EventRegistry([definition]), argument_fallback=fallback
- ).execute(
- EventRequest(id="event-1", name=definition.name),
- enabled_names=[definition.name],
- )
- assert result.status is EventStatus.INVALID_ARGUMENTS
- assert result.error == expected_error
- assert result.used_fallback is False
- assert fallback_calls == 0
- def _discriminated_composed_parameters(composition: str) -> dict[str, Any]:
- return {
- "type": "object",
- "properties": {
- "choice": {
- composition: [
- {
- "type": "object",
- "properties": {
- "kind": {"const": "a"},
- "value": {"type": "string"},
- },
- "required": ["kind", "value"],
- },
- {
- "type": "object",
- "properties": {
- "kind": {"const": "b"},
- "count": {"type": "integer"},
- },
- "required": ["kind", "count"],
- },
- ]
- }
- },
- "required": ["choice"],
- }
- @pytest.mark.asyncio
- @pytest.mark.parametrize("composition", ["anyOf", "oneOf"])
- async def test_kernel_falls_back_for_matching_composed_branch_missing_required(
- composition: str,
- ):
- fallback_calls = 0
- async def fallback(*args: Any) -> ResolvedEventArguments:
- nonlocal fallback_calls
- fallback_calls += 1
- return ResolvedEventArguments(
- event_name="example.lookup",
- arguments={"choice": {"kind": "a", "value": "resolved"}},
- raw_arguments='{"choice":{"kind":"a","value":"resolved"}}',
- )
- definition = _definition(
- parameters=_discriminated_composed_parameters(composition),
- resolver=lambda request, context: {"choice": {"kind": "a"}},
- handler=lambda request: {"ok": True},
- )
- result = await EventKernel(
- EventRegistry([definition]), argument_fallback=fallback
- ).execute(
- EventRequest(id="event-1", name="example.lookup"),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.SUCCESS
- assert result.used_fallback is True
- assert fallback_calls == 1
- @pytest.mark.asyncio
- @pytest.mark.parametrize("composition", ["anyOf", "oneOf"])
- async def test_kernel_does_not_fallback_when_matching_composed_branch_is_invalid(
- composition: str,
- ):
- fallback_calls = 0
- async def fallback(*args: Any) -> ResolvedEventArguments:
- nonlocal fallback_calls
- fallback_calls += 1
- raise AssertionError("fallback should not run")
- definition = _definition(
- parameters=_discriminated_composed_parameters(composition),
- resolver=lambda request, context: {
- "choice": {"kind": "a", "value": 42}
- },
- handler=lambda request: {"ok": True},
- )
- result = await EventKernel(
- EventRegistry([definition]), argument_fallback=fallback
- ).execute(
- EventRequest(id="event-1", name="example.lookup"),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.INVALID_ARGUMENTS
- assert result.used_fallback is False
- assert fallback_calls == 0
- @pytest.mark.asyncio
- @pytest.mark.parametrize("composition", ["anyOf", "oneOf"])
- @pytest.mark.parametrize("choice", [{}, {"kind": "other"}])
- async def test_kernel_does_not_fallback_when_composed_branch_is_ambiguous(
- composition: str,
- choice: dict[str, Any],
- ):
- fallback_calls = 0
- async def fallback(*args: Any) -> ResolvedEventArguments:
- nonlocal fallback_calls
- fallback_calls += 1
- raise AssertionError("fallback should not run")
- definition = _definition(
- parameters=_discriminated_composed_parameters(composition),
- resolver=lambda request, context: {"choice": choice},
- handler=lambda request: {"ok": True},
- )
- result = await EventKernel(
- EventRegistry([definition]), argument_fallback=fallback
- ).execute(
- EventRequest(id="event-1", name="example.lookup"),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.INVALID_ARGUMENTS
- assert result.used_fallback is False
- assert fallback_calls == 0
- @pytest.mark.asyncio
- async def test_kernel_does_not_fallback_for_substantive_and_composed_missing_errors():
- fallback_calls = 0
- async def fallback(*args: Any) -> ResolvedEventArguments:
- nonlocal fallback_calls
- fallback_calls += 1
- raise AssertionError("fallback should not run")
- parameters = _discriminated_composed_parameters("anyOf")
- parameters["properties"]["mode"] = {"enum": ["valid"]}
- definition = _definition(
- parameters=parameters,
- resolver=lambda request, context: {
- "mode": "invalid",
- "choice": {"kind": "a"},
- },
- handler=lambda request: {"ok": True},
- )
- result = await EventKernel(
- EventRegistry([definition]), argument_fallback=fallback
- ).execute(
- EventRequest(id="event-1", name=definition.name),
- enabled_names=[definition.name],
- )
- assert result.status is EventStatus.INVALID_ARGUMENTS
- assert result.error == "invalid event arguments: 'invalid' is not one of ['valid']"
- assert result.used_fallback is False
- assert fallback_calls == 0
- @pytest.mark.asyncio
- async def test_kernel_does_not_fallback_when_definition_disallows_it():
- fallback_calls = 0
- async def fallback(*args: Any) -> ResolvedEventArguments:
- nonlocal fallback_calls
- fallback_calls += 1
- return ResolvedEventArguments(
- event_name="example.lookup",
- arguments={"query": "not allowed"},
- raw_arguments='{"query":"not allowed"}',
- )
- registry = EventRegistry(
- [
- _definition(
- resolver=lambda request, context: {},
- fallback_allowed=False,
- )
- ]
- )
- result = await EventKernel(registry, argument_fallback=fallback).execute(
- EventRequest(id="event-1", name="example.lookup"),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.INVALID_ARGUMENTS
- assert result.error == "missing required arguments: query"
- assert result.used_fallback is False
- assert fallback_calls == 0
- @pytest.mark.asyncio
- async def test_structured_resolution_can_mark_optional_arguments_incomplete():
- fallback_calls = 0
- async def fallback(*args: Any) -> ResolvedEventArguments:
- nonlocal fallback_calls
- fallback_calls += 1
- return ResolvedEventArguments(
- event_name="example.lookup",
- arguments={"query": "resolved"},
- raw_arguments='{"query":"resolved"}',
- )
- definition = _definition(
- parameters={
- "type": "object",
- "properties": {"query": {"type": "string"}},
- "additionalProperties": False,
- },
- resolver=lambda request, context: EventArgumentResolution(
- arguments={},
- complete=False,
- ),
- )
- result = await EventKernel(
- EventRegistry([definition]), argument_fallback=fallback
- ).execute(
- EventRequest(id="event-1", name="example.lookup"),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.SUCCESS
- assert result.arguments == {"query": "resolved"}
- assert result.used_fallback is True
- assert fallback_calls == 1
- @pytest.mark.asyncio
- async def test_structured_incomplete_optional_arguments_fail_without_fallback():
- definition = _definition(
- parameters={
- "type": "object",
- "properties": {"query": {"type": "string"}},
- },
- resolver=lambda request, context: EventArgumentResolution(
- arguments={},
- complete=False,
- ),
- handler=lambda request: {"ok": True},
- )
- result = await EventKernel(EventRegistry([definition])).execute(
- EventRequest(id="event-1", name="example.lookup"),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.INVALID_ARGUMENTS
- assert result.error == "event arguments incomplete"
- @pytest.mark.asyncio
- async def test_plain_dict_resolution_remains_complete_for_optional_schema():
- fallback_calls = 0
- async def fallback(*args: Any) -> ResolvedEventArguments:
- nonlocal fallback_calls
- fallback_calls += 1
- raise AssertionError("fallback should not run")
- definition = _definition(
- parameters={
- "type": "object",
- "properties": {"query": {"type": "string"}},
- },
- resolver=lambda request, context: {},
- handler=lambda request: {"ok": True},
- )
- result = await EventKernel(
- EventRegistry([definition]), argument_fallback=fallback
- ).execute(
- EventRequest(id="event-1", name="example.lookup"),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.SUCCESS
- assert fallback_calls == 0
- def test_sync_kernel_rejects_structured_incomplete_optional_arguments():
- handler_calls = 0
- def handler(request: EventRequest) -> dict[str, Any]:
- nonlocal handler_calls
- handler_calls += 1
- return {"ok": True}
- definition = _definition(
- parameters={
- "type": "object",
- "properties": {"query": {"type": "string"}},
- },
- resolver=lambda request, context: EventArgumentResolution(
- arguments={},
- complete=False,
- ),
- handler=handler,
- )
- result = EventKernel(EventRegistry([definition])).execute_sync(
- EventRequest(id="event-1", name="example.lookup"),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.INVALID_ARGUMENTS
- assert result.error == "event arguments incomplete"
- assert handler_calls == 0
- @pytest.mark.parametrize(
- "resolver",
- [
- lambda request, context: EventArgumentResolution(
- arguments={},
- complete=True,
- ),
- lambda request, context: {},
- ],
- )
- def test_sync_kernel_preserves_complete_compatible_resolvers(resolver: Any):
- handler_calls = 0
- def handler(request: EventRequest) -> dict[str, Any]:
- nonlocal handler_calls
- handler_calls += 1
- return {"ok": True}
- definition = _definition(
- parameters={
- "type": "object",
- "properties": {"query": {"type": "string"}},
- },
- resolver=resolver,
- handler=handler,
- )
- result = EventKernel(EventRegistry([definition])).execute_sync(
- EventRequest(id="event-1", name="example.lookup"),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.SUCCESS
- assert result.payload == {"ok": True}
- assert handler_calls == 1
- @pytest.mark.asyncio
- async def test_provider_resolved_arguments_are_not_rewritten_or_fallen_back():
- resolver_calls = 0
- fallback_calls = 0
- def resolver(*args: Any) -> dict[str, Any]:
- nonlocal resolver_calls
- resolver_calls += 1
- return {"query": "rewritten"}
- async def fallback(*args: Any) -> ResolvedEventArguments:
- nonlocal fallback_calls
- fallback_calls += 1
- return ResolvedEventArguments(
- event_name="example.lookup",
- arguments={"query": "fallback"},
- raw_arguments='{"query":"fallback"}',
- )
- registry = EventRegistry([_definition(resolver=resolver)])
- result = await EventKernel(registry, argument_fallback=fallback).execute(
- EventRequest(
- id="event-1",
- name="example.lookup",
- arguments={"query": "provider value"},
- source=EventSource.PROVIDER_RESOLVED,
- ),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.SUCCESS
- assert result.arguments == {"query": "provider value"}
- assert resolver_calls == 0
- assert fallback_calls == 0
- @pytest.mark.asyncio
- async def test_provider_resolved_missing_arguments_return_invalid_without_fallback():
- fallback_calls = 0
- async def fallback(*args: Any) -> ResolvedEventArguments:
- nonlocal fallback_calls
- fallback_calls += 1
- return ResolvedEventArguments(
- event_name="example.lookup",
- arguments={"query": "fallback"},
- raw_arguments='{"query":"fallback"}',
- )
- result = await EventKernel(
- EventRegistry([_definition()]), argument_fallback=fallback
- ).execute(
- EventRequest(
- id="event-1",
- name="example.lookup",
- arguments={},
- source=EventSource.PROVIDER_RESOLVED,
- ),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.INVALID_ARGUMENTS
- assert result.error == "missing required arguments: query"
- assert fallback_calls == 0
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- ("event_request", "enabled_names", "expected_status", "expected_error"),
- [
- (
- EventRequest(id="event-1", name="missing"),
- ["missing"],
- EventStatus.UNKNOWN,
- "unknown event",
- ),
- (
- EventRequest(id="event-1", name="example.lookup"),
- [],
- EventStatus.DISABLED,
- "event disabled",
- ),
- (
- EventRequest(
- id="event-1",
- name="example.lookup",
- arguments={"query": 42},
- source=EventSource.PROVIDER_RESOLVED,
- ),
- ["example.lookup"],
- EventStatus.INVALID_ARGUMENTS,
- "invalid argument type for query: expected string",
- ),
- ],
- )
- async def test_kernel_normalizes_lookup_and_validation_failures(
- event_request: EventRequest,
- enabled_names: list[str],
- expected_status: EventStatus,
- expected_error: str,
- ):
- result = await EventKernel(EventRegistry([_definition()])).execute(
- event_request,
- enabled_names=enabled_names,
- )
- assert result.status is expected_status
- assert result.error == expected_error
- @pytest.mark.asyncio
- async def test_kernel_normalizes_handler_exceptions():
- def fail(request: EventRequest) -> dict[str, Any]:
- raise RuntimeError("boom")
- result = await EventKernel(
- EventRegistry([_definition(handler=fail)])
- ).execute(
- EventRequest(
- id="event-1",
- name="example.lookup",
- arguments={"query": "value"},
- source=EventSource.PROVIDER_RESOLVED,
- ),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.HANDLER_ERROR
- assert result.error == "event handler failed: boom"
- @pytest.mark.asyncio
- async def test_kernel_validates_complete_draft_2020_12_schema():
- definition = _definition(
- parameters={
- "type": "object",
- "properties": {
- "mode": {"enum": ["quick", "deep"]},
- "target": {"type": ["string", "null"]},
- "filters": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {"score": {"type": "number", "minimum": 0}},
- "required": ["score"],
- "additionalProperties": False,
- },
- },
- },
- "required": ["mode", "target", "filters"],
- "additionalProperties": False,
- },
- handler=lambda request: {"event": request.name},
- )
- kernel = EventKernel(EventRegistry([definition]))
- valid = await kernel.execute(
- EventRequest(
- id="valid",
- name=definition.name,
- arguments={
- "mode": "deep",
- "target": None,
- "filters": [{"score": 0.5}],
- },
- source=EventSource.PROVIDER_RESOLVED,
- ),
- enabled_names=[definition.name],
- )
- invalid = await kernel.execute(
- EventRequest(
- id="invalid",
- name=definition.name,
- arguments={
- "mode": "other",
- "target": 7,
- "filters": [{"score": -1, "extra": True}],
- "unexpected": True,
- },
- source=EventSource.PROVIDER_RESOLVED,
- ),
- enabled_names=[definition.name],
- )
- assert valid.status is EventStatus.SUCCESS
- assert invalid.status is EventStatus.INVALID_ARGUMENTS
- assert invalid.error.startswith("invalid event arguments:")
- @pytest.mark.asyncio
- async def test_kernel_normalizes_validator_runtime_exceptions():
- definition = _definition(parameters={"$ref": "urn:agent-lab:missing-schema"})
- result = await EventKernel(EventRegistry([definition])).execute(
- EventRequest(
- id="event-1",
- name=definition.name,
- arguments={},
- source=EventSource.PROVIDER_RESOLVED,
- ),
- enabled_names=[definition.name],
- )
- assert result.status is EventStatus.DEFINITION_ERROR
- assert result.error.startswith("event argument validation failed:")
- @pytest.mark.asyncio
- async def test_tool_registry_maps_definition_errors_to_tool_compatibility_payload():
- registry = ToolRegistry(
- [
- ToolDefinition(
- name="broken.lookup",
- description="Broken lookup.",
- parameters={"$ref": "urn:agent-lab:missing-schema"},
- handler=lambda event: {"tool": event.name},
- )
- ]
- )
- payload = await registry.execute_async(
- ToolCallEvent(
- id="call-1",
- name="broken.lookup",
- arguments={},
- raw_arguments="{}",
- )
- )
- assert payload["tool"] == "broken.lookup"
- assert payload["error"].startswith("tool definition validation failed:")
- @pytest.mark.asyncio
- @pytest.mark.parametrize("boundary", ["resolver", "fallback"])
- async def test_kernel_normalizes_resolution_boundary_exceptions(boundary: str):
- def resolver(request: EventRequest, context: EventExecutionContext) -> dict[str, Any]:
- if boundary == "resolver":
- raise RuntimeError("resolver boom")
- return {}
- async def fallback(*args: Any) -> ResolvedEventArguments:
- raise RuntimeError("fallback boom")
- result = await EventKernel(
- EventRegistry([_definition(resolver=resolver)]),
- argument_fallback=fallback,
- ).execute(
- EventRequest(id="event-1", name="example.lookup"),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.RESOLUTION_ERROR
- assert result.error == f"event argument {boundary} failed: {boundary} boom"
- @pytest.mark.asyncio
- @pytest.mark.parametrize("boundary", ["resolver", "fallback"])
- async def test_kernel_normalizes_invalid_resolution_payloads(boundary: str):
- resolver = (
- (lambda request, context: None)
- if boundary == "resolver"
- else (lambda request, context: {})
- )
- async def fallback(*args: Any) -> Any:
- return {"query": "legacy bare mapping"}
- result = await EventKernel(
- EventRegistry([_definition(resolver=resolver)]),
- argument_fallback=fallback,
- ).execute(
- EventRequest(id="event-1", name="example.lookup"),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.RESOLUTION_ERROR
- assert result.error == f"event argument {boundary} returned invalid payload"
- @pytest.mark.asyncio
- async def test_kernel_normalizes_non_json_resolver_arguments():
- result = await EventKernel(
- EventRegistry(
- [
- _definition(
- resolver=lambda request, context: {"query": object()}
- )
- ]
- )
- ).execute(
- EventRequest(id="event-1", name="example.lookup"),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.RESOLUTION_ERROR
- assert result.error.startswith("event argument resolver failed to serialize:")
- class _ExplodingDeepcopyDict(dict[str, Any]):
- def __deepcopy__(self, memo: dict[int, Any]) -> dict[str, Any]:
- raise RuntimeError("deepcopy must not be used")
- @pytest.mark.asyncio
- @pytest.mark.parametrize("execution", ["async", "sync"])
- @pytest.mark.parametrize(
- ("source", "expected_status", "expected_error"),
- [
- (
- EventSource.TEXT_EVENT,
- EventStatus.RESOLUTION_ERROR,
- "event argument resolver failed to serialize: "
- "JSON round-trip changed payload",
- ),
- (
- EventSource.PROVIDER_RESOLVED,
- EventStatus.INVALID_ARGUMENTS,
- "provider-resolved event arguments are not valid JSON",
- ),
- ],
- )
- async def test_kernel_normalizes_argument_snapshot_failures_without_deepcopy(
- execution: str,
- source: EventSource,
- expected_status: EventStatus,
- expected_error: str,
- ):
- fallback_calls = 0
- arguments = {"query": _ExplodingDeepcopyDict({"nested": "value"})}
- async def fallback(*args: Any) -> ResolvedEventArguments:
- nonlocal fallback_calls
- fallback_calls += 1
- raise AssertionError("fallback should not run")
- definition = _definition(
- parameters={
- "type": "object",
- "properties": {"query": {"type": "object"}},
- "required": ["query"],
- },
- resolver=lambda request, context: arguments,
- handler=lambda request: {"ok": True},
- )
- request = EventRequest(
- id="event-1",
- name="example.lookup",
- arguments=arguments if source is EventSource.PROVIDER_RESOLVED else {},
- source=source,
- )
- kernel = EventKernel(EventRegistry([definition]), argument_fallback=fallback)
- result = (
- await kernel.execute(request, enabled_names=[definition.name])
- if execution == "async"
- else kernel.execute_sync(request, enabled_names=[definition.name])
- )
- assert result.status is expected_status
- assert result.status is not EventStatus.DEFINITION_ERROR
- assert result.error == expected_error
- assert result.used_fallback is False
- assert fallback_calls == 0
- @pytest.mark.asyncio
- async def test_kernel_normalizes_non_object_fallback_arguments():
- async def fallback(*args: Any) -> ResolvedEventArguments:
- return ResolvedEventArguments(
- event_name="example.lookup",
- arguments=["not", "an", "object"], # type: ignore[arg-type]
- raw_arguments='["not","an","object"]',
- )
- result = await EventKernel(
- EventRegistry([_definition(resolver=lambda request, context: {})]),
- argument_fallback=fallback,
- ).execute(
- EventRequest(id="event-1", name="example.lookup"),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.RESOLUTION_ERROR
- assert result.error == "event argument fallback returned invalid payload"
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- ("resolved", "expected_error"),
- [
- (
- ResolvedEventArguments(
- event_name="another.event",
- arguments={"query": "value"},
- raw_arguments='{"query":"value"}',
- ),
- "fallback returned tool another.event for example.lookup",
- ),
- (
- ResolvedEventArguments(
- event_name="example.lookup",
- arguments={"query": "value"},
- raw_arguments="not-json",
- ),
- "fallback raw arguments are not valid JSON",
- ),
- (
- ResolvedEventArguments(
- event_name="example.lookup",
- arguments={"query": "parsed"},
- raw_arguments='{"query":"raw"}',
- ),
- "fallback raw arguments do not match parsed arguments",
- ),
- ],
- )
- async def test_kernel_rejects_inconsistent_structured_fallback(
- resolved: ResolvedEventArguments,
- expected_error: str,
- ):
- async def fallback(*args: Any) -> ResolvedEventArguments:
- return resolved
- result = await EventKernel(
- EventRegistry([_definition(resolver=lambda request, context: {})]),
- argument_fallback=fallback,
- ).execute(
- EventRequest(id="event-1", name="example.lookup"),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.RESOLUTION_ERROR
- assert result.error == expected_error
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- ("arguments", "raw_arguments"),
- [
- ({"query": 1}, '{"query":true}'),
- ({"query": 1.0}, '{"query":1}'),
- ({"query": {"nested": [1]}}, '{"query":{"nested":[true]}}'),
- ({"query": float("nan")}, '{"query":NaN}'),
- ({"query": float("inf")}, '{"query":Infinity}'),
- ],
- )
- async def test_kernel_rejects_noncanonical_fallback_json(
- arguments: dict[str, Any],
- raw_arguments: str,
- ):
- async def fallback(*args: Any) -> ResolvedEventArguments:
- return ResolvedEventArguments(
- event_name="example.lookup",
- arguments=arguments,
- raw_arguments=raw_arguments,
- )
- result = await EventKernel(
- EventRegistry([_definition(resolver=lambda request, context: {})]),
- argument_fallback=fallback,
- ).execute(
- EventRequest(id="event-1", name="example.lookup"),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.RESOLUTION_ERROR
- @pytest.mark.asyncio
- async def test_registry_schema_is_isolated_from_caller_mutation():
- parameters = {
- "type": "object",
- "properties": {"query": {"type": "string"}},
- "required": ["query"],
- "additionalProperties": False,
- }
- registry = EventRegistry([_definition(parameters=parameters)])
- parameters["properties"]["query"]["type"] = "integer"
- parameters["required"].clear()
- parameters["additionalProperties"] = True
- assert registry.catalog()[0]["parameters"] == {
- "type": "object",
- "properties": {"query": {"type": "string"}},
- "required": ["query"],
- "additionalProperties": False,
- }
- registered = registry.definition("example.lookup")
- assert registered is not None
- with pytest.raises(TypeError):
- registered.parameters["additionalProperties"] = True
- with pytest.raises(TypeError):
- registered.parameters["properties"]["query"]["type"] = "integer"
- with pytest.raises(AttributeError):
- registered.parameters["required"].append("unexpected")
- result = await EventKernel(registry).execute(
- EventRequest(
- id="event-1",
- name="example.lookup",
- arguments={"query": 42, "unexpected": True},
- source=EventSource.PROVIDER_RESOLVED,
- ),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.INVALID_ARGUMENTS
- @pytest.mark.asyncio
- async def test_kernel_passes_consistent_fallback_arguments_and_raw_json_to_handler():
- captured: list[EventRequest] = []
- async def fallback(*args: Any) -> ResolvedEventArguments:
- return ResolvedEventArguments(
- event_name="example.lookup",
- arguments={"query": "resolved"},
- raw_arguments='{"query":"resolved"}',
- )
- result = await EventKernel(
- EventRegistry(
- [
- _definition(
- resolver=lambda request, context: {},
- handler=lambda request: captured.append(request) or {"ok": True},
- )
- ]
- ),
- argument_fallback=fallback,
- ).execute(
- EventRequest(id="event-1", name="example.lookup"),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.SUCCESS
- assert captured[0].arguments == json.loads(captured[0].raw_arguments)
- assert result.raw_arguments == captured[0].raw_arguments
- @pytest.mark.asyncio
- @pytest.mark.parametrize("execution", ["async", "sync"])
- async def test_kernel_isolates_nested_handler_mutation_from_audit_values(
- execution: str,
- ):
- caller_arguments = {"nested": {"items": ["original"]}}
- handler_arguments: list[dict[str, Any]] = []
- def handler(request: EventRequest) -> dict[str, Any]:
- handler_arguments.append(request.arguments)
- request.arguments["nested"]["items"].append("handler")
- return {"nested": request.arguments["nested"]}
- definition = _definition(
- parameters={
- "type": "object",
- "properties": {
- "nested": {
- "type": "object",
- "properties": {
- "items": {"type": "array", "items": {"type": "string"}}
- },
- "required": ["items"],
- }
- },
- "required": ["nested"],
- },
- handler=handler,
- )
- request = EventRequest(
- id="event-1",
- name="example.lookup",
- arguments=caller_arguments,
- raw_arguments='{"nested":{"items":["original"]}}',
- source=EventSource.PROVIDER_RESOLVED,
- )
- kernel = EventKernel(EventRegistry([definition]))
- result = (
- await kernel.execute(request, enabled_names=["example.lookup"])
- if execution == "async"
- else kernel.execute_sync(request, enabled_names=["example.lookup"])
- )
- assert result.status is EventStatus.SUCCESS
- assert caller_arguments == {"nested": {"items": ["original"]}}
- assert request.arguments == {"nested": {"items": ["original"]}}
- assert result.arguments == {"nested": {"items": ["original"]}}
- assert json.loads(result.raw_arguments) == result.arguments
- assert result.payload == {"nested": {"items": ["original", "handler"]}}
- handler_arguments[0]["nested"]["items"].append("later")
- assert result.payload == {"nested": {"items": ["original", "handler"]}}
- result.payload["nested"]["items"].append("result")
- assert result.arguments == {"nested": {"items": ["original"]}}
- assert caller_arguments == {"nested": {"items": ["original"]}}
- @pytest.mark.asyncio
- @pytest.mark.parametrize("payload", [None, "text", 1, ["item"]])
- async def test_kernel_normalizes_invalid_handler_payloads(payload: Any):
- result = await EventKernel(
- EventRegistry([_definition(handler=lambda request: payload)])
- ).execute(
- EventRequest(
- id="event-1",
- name="example.lookup",
- arguments={"query": "value"},
- source=EventSource.PROVIDER_RESOLVED,
- ),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.HANDLER_ERROR
- assert result.error == "event handler returned non-object payload"
- @pytest.mark.asyncio
- @pytest.mark.parametrize("execution", ["async", "sync"])
- @pytest.mark.parametrize(
- "invalid_value",
- [object(), {"set-item"}, float("nan"), float("inf")],
- )
- async def test_kernel_rejects_non_json_handler_dictionary_payloads(
- execution: str,
- invalid_value: Any,
- ):
- definition = _definition(
- handler=lambda request: {"invalid": invalid_value},
- )
- request = EventRequest(
- id="event-1",
- name="example.lookup",
- arguments={"query": "value"},
- raw_arguments='{"query":"value"}',
- source=EventSource.PROVIDER_RESOLVED,
- )
- kernel = EventKernel(EventRegistry([definition]))
- result = (
- await kernel.execute(request, enabled_names=["example.lookup"])
- if execution == "async"
- else kernel.execute_sync(request, enabled_names=["example.lookup"])
- )
- assert result.status is EventStatus.HANDLER_ERROR
- assert result.error == "event handler returned non-JSON payload"
- class _ExplodingItemsDict(dict[str, Any]):
- def items(self):
- raise RuntimeError("payload items failed")
- @pytest.mark.asyncio
- @pytest.mark.parametrize("execution", ["async", "sync"])
- async def test_kernel_normalizes_handler_payload_snapshot_exceptions(
- execution: str,
- ):
- definition = _definition(
- handler=lambda request: _ExplodingItemsDict(ok=True),
- )
- request = EventRequest(
- id="event-1",
- name=definition.name,
- arguments={"query": "value"},
- raw_arguments='{"query":"value"}',
- source=EventSource.PROVIDER_RESOLVED,
- )
- kernel = EventKernel(EventRegistry([definition]))
- result = (
- await kernel.execute(request, enabled_names=[definition.name])
- if execution == "async"
- else kernel.execute_sync(request, enabled_names=[definition.name])
- )
- assert result.status is EventStatus.HANDLER_ERROR
- assert result.error == "event handler returned non-JSON payload"
- @pytest.mark.asyncio
- @pytest.mark.parametrize("execution", ["async", "sync"])
- async def test_kernel_does_not_swallow_base_exception_from_payload_snapshot(
- execution: str,
- ):
- class SnapshotAbort(BaseException):
- pass
- class AbortingItemsDict(dict[str, Any]):
- def items(self):
- raise SnapshotAbort
- definition = _definition(
- handler=lambda request: AbortingItemsDict(ok=True),
- )
- request = EventRequest(
- id="event-1",
- name=definition.name,
- arguments={"query": "value"},
- source=EventSource.PROVIDER_RESOLVED,
- )
- kernel = EventKernel(EventRegistry([definition]))
- with pytest.raises(SnapshotAbort):
- if execution == "async":
- await kernel.execute(request, enabled_names=[definition.name])
- else:
- kernel.execute_sync(request, enabled_names=[definition.name])
- @pytest.mark.asyncio
- async def test_tool_registry_normalizes_handler_payload_snapshot_exceptions():
- registry = ToolRegistry(
- [
- ToolDefinition(
- name="bad_payload",
- description="Return a payload that fails during snapshot.",
- parameters={"type": "object"},
- handler=lambda event: _ExplodingItemsDict(ok=True),
- )
- ]
- )
- event = ToolCallEvent(
- id="call-1",
- name="bad_payload",
- arguments={},
- raw_arguments="{}",
- )
- expected = {
- "tool": "bad_payload",
- "error": "event handler returned non-JSON payload",
- }
- assert registry.execute(event) == expected
- assert await registry.execute_async(event) == expected
- @pytest.mark.asyncio
- async def test_async_kernel_supports_async_handler():
- async def handler(request: EventRequest) -> dict[str, Any]:
- return {"query": request.arguments["query"]}
- result = await EventKernel(
- EventRegistry([_definition(handler=handler)])
- ).execute(
- EventRequest(
- id="event-1",
- name="example.lookup",
- arguments={"query": "async"},
- source=EventSource.PROVIDER_RESOLVED,
- ),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.SUCCESS
- assert result.payload == {"query": "async"}
- @pytest.mark.asyncio
- async def test_tool_registry_async_entry_points_support_async_handler():
- async def handler(event: ToolCallEvent) -> dict[str, Any]:
- return {"tool": event.name, "query": event.arguments["query"]}
- registry = ToolRegistry(
- [
- ToolDefinition(
- name="async.lookup",
- description="Async lookup.",
- parameters={
- "type": "object",
- "properties": {"query": {"type": "string"}},
- "required": ["query"],
- },
- handler=handler,
- )
- ]
- )
- event = ToolCallEvent(
- id="call-1",
- name="async.lookup",
- arguments={"query": "value"},
- raw_arguments='{"query":"value"}',
- )
- assert await registry.handle_async(event) == {
- "tool": "async.lookup",
- "query": "value",
- }
- assert await registry.execute_async(event) == {
- "tool": "async.lookup",
- "query": "value",
- }
- def test_tool_registry_sync_facade_rejects_async_handler_without_runtime_warning():
- called = False
- async def handler(event: ToolCallEvent) -> dict[str, Any]:
- nonlocal called
- called = True
- return {"tool": event.name}
- registry = ToolRegistry(
- [
- ToolDefinition(
- name="async.lookup",
- description="Async lookup.",
- parameters={"type": "object"},
- handler=handler,
- )
- ]
- )
- event = ToolCallEvent(
- id="call-1",
- name="async.lookup",
- arguments={},
- raw_arguments="{}",
- )
- with warnings.catch_warnings(record=True) as captured:
- warnings.simplefilter("always")
- payload = registry.execute(event)
- gc.collect()
- assert payload == {
- "tool": "async.lookup",
- "error": "tool handler failed: async event handlers require execute_async",
- }
- assert called is False
- assert not [warning for warning in captured if issubclass(warning.category, RuntimeWarning)]
- @pytest.mark.asyncio
- async def test_kernel_rejects_boolean_for_json_number_arguments():
- definition = _definition(
- parameters={
- "type": "object",
- "properties": {"query": {"type": "number"}},
- "required": ["query"],
- }
- )
- result = await EventKernel(EventRegistry([definition])).execute(
- EventRequest(
- id="event-1",
- name="example.lookup",
- arguments={"query": True},
- source=EventSource.PROVIDER_RESOLVED,
- ),
- enabled_names=["example.lookup"],
- )
- assert result.status is EventStatus.INVALID_ARGUMENTS
- assert result.error == "invalid argument type for query: expected number"
- @pytest.mark.asyncio
- async def test_definition_metadata_survives_registration_and_result_creation():
- definition = _definition(
- result_policy=ResultPolicy.TEMPLATE_FOLLOW_UP,
- confirmation_policy=ConfirmationPolicy.REQUIRED,
- risk_level=RiskLevel.HIGH,
- idempotency_key_fields=("session_id", "event_id"),
- concurrency_class="device-write",
- conflict_keys=("device",),
- timeout_seconds=1.5,
- terminal=True,
- )
- registry = EventRegistry([definition])
- result = await EventKernel(registry).execute(
- EventRequest(
- id="event-1",
- name="example.lookup",
- arguments={"query": "value"},
- source=EventSource.PROVIDER_RESOLVED,
- ),
- enabled_names=["example.lookup"],
- )
- registered = registry.definition("example.lookup")
- assert registered is not definition
- assert result.result_policy is ResultPolicy.TEMPLATE_FOLLOW_UP
- assert result.confirmation_policy is ConfirmationPolicy.REQUIRED
- assert result.risk_level is RiskLevel.HIGH
- assert result.idempotency_key_fields == ("session_id", "event_id")
- assert result.concurrency_class == "device-write"
- assert result.conflict_keys == ("device",)
- assert result.timeout_seconds == 1.5
- assert result.terminal is True
- @pytest.mark.asyncio
- @pytest.mark.parametrize("name", ["alpha.one", "beta-two", "任意.事件"])
- async def test_kernel_applies_identical_behavior_to_arbitrary_event_names(name: str):
- definition = _definition(name=name)
- result = await EventKernel(EventRegistry([definition])).execute(
- EventRequest(
- id="event-1",
- name=name,
- arguments={"query": "value"},
- source=EventSource.PROVIDER_RESOLVED,
- ),
- enabled_names=[name],
- )
- assert result.status is EventStatus.SUCCESS
- assert result.payload == {"event": name, "query": "value"}
|