From d3ca81534760b541dcec3c202fb786bc396de928 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz <25389585+poissoncorp@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:11:23 +0200 Subject: [PATCH] RDBC-1161 Add exceptions for unbindable and colliding agent parameters --- ravendb/__init__.py | 2 + ravendb/exceptions/exception_dispatcher.py | 4 + ravendb/exceptions/raven_exceptions.py | 12 ++ .../test_ai_agent_configuration.py | 32 +++++- .../ai_agent_tests/test_ravenDB_27289.py | 103 ++++++++++++++++++ ravendb/tests/test_imports.py | 2 + 6 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 ravendb/tests/ai_agent_tests/test_ravenDB_27289.py diff --git a/ravendb/__init__.py b/ravendb/__init__.py index b4e6c79b..12149a73 100644 --- a/ravendb/__init__.py +++ b/ravendb/__init__.py @@ -670,6 +670,7 @@ TimeSeriesStatistics, ) from ravendb.exceptions.raven_exceptions import ( + AiAgentParameterCollisionException, AiException, BadResponseException, ClientVersionMismatchException, @@ -687,6 +688,7 @@ TooManyRequestsException, TooManyTokensException, UnsuccessfulAiRequestException, + UnsupportedAiQueryParameterException, ) from ravendb.http.misc import ( AggressiveCacheMode, diff --git a/ravendb/exceptions/exception_dispatcher.py b/ravendb/exceptions/exception_dispatcher.py index 6fbf1a39..5da9b818 100644 --- a/ravendb/exceptions/exception_dispatcher.py +++ b/ravendb/exceptions/exception_dispatcher.py @@ -10,6 +10,7 @@ from ravendb.exceptions.documents.bulkinsert import BulkInsertAbortedException, BulkInsertProtocolViolationException from ravendb.exceptions.documents.indexes import IndexDoesNotExistException from ravendb.exceptions.raven_exceptions import ( + AiAgentParameterCollisionException, AiException, BadResponseException, ClientVersionMismatchException, @@ -27,6 +28,7 @@ TooManyRequestsException, TooManyTokensException, UnsuccessfulAiRequestException, + UnsupportedAiQueryParameterException, ) # Maps the simple C# class name to the Python exception class. @@ -50,6 +52,8 @@ "TooManyTokensException": TooManyTokensException, "MissingAiAgentParameterException": MissingAiAgentParameterException, "QueryToolFailedException": QueryToolFailedException, + "UnsupportedAiQueryParameterException": UnsupportedAiQueryParameterException, + "AiAgentParameterCollisionException": AiAgentParameterCollisionException, # documents "DocumentConflictException": DocumentConflictException, "DocumentDoesNotExistException": DocumentDoesNotExistException, diff --git a/ravendb/exceptions/raven_exceptions.py b/ravendb/exceptions/raven_exceptions.py index 12f48cad..4f6d2808 100644 --- a/ravendb/exceptions/raven_exceptions.py +++ b/ravendb/exceptions/raven_exceptions.py @@ -88,6 +88,18 @@ class QueryToolFailedException(AiException): pass +class UnsupportedAiQueryParameterException(AiException): + """Raised when a query tool references a parameter whose value is not a scalar, so the query cannot bind it.""" + + pass + + +class AiAgentParameterCollisionException(AiException): + """Raised when a name is both an agent parameter and an argument of a query tool.""" + + pass + + class MissingAiAgentParameterException(RavenException): pass diff --git a/ravendb/tests/ai_agent_tests/test_ai_agent_configuration.py b/ravendb/tests/ai_agent_tests/test_ai_agent_configuration.py index b8d8933b..62e69feb 100644 --- a/ravendb/tests/ai_agent_tests/test_ai_agent_configuration.py +++ b/ravendb/tests/ai_agent_tests/test_ai_agent_configuration.py @@ -19,8 +19,13 @@ ) from ravendb.documents.ai.content_part import AiMessagePromptFields from ravendb.documents.operations.ai.azure_open_ai_settings import AzureOpenAiSettings -from ravendb.exceptions.exception_dispatcher import _EXCEPTION_MAP -from ravendb.exceptions.raven_exceptions import MissingAiAgentParameterException +from ravendb.exceptions.exception_dispatcher import _EXCEPTION_MAP, ExceptionDispatcher +from ravendb.exceptions.raven_exceptions import ( + AiAgentParameterCollisionException, + AiException, + MissingAiAgentParameterException, + UnsupportedAiQueryParameterException, +) class TestAiAgentParameterPolicyAndType(unittest.TestCase): @@ -103,6 +108,29 @@ def test_is_raven_exception(self): self.assertTrue(issubclass(MissingAiAgentParameterException, RavenException)) +class TestAiAgentParameterExceptions(unittest.TestCase): + def test_dispatcher_registers_short_names(self): + self.assertIs(UnsupportedAiQueryParameterException, _EXCEPTION_MAP["UnsupportedAiQueryParameterException"]) + self.assertIs(AiAgentParameterCollisionException, _EXCEPTION_MAP["AiAgentParameterCollisionException"]) + + def test_are_ai_exceptions(self): + self.assertTrue(issubclass(UnsupportedAiQueryParameterException, AiException)) + self.assertTrue(issubclass(AiAgentParameterCollisionException, AiException)) + + def test_server_error_is_raised_as_its_own_type(self): + schema = ExceptionDispatcher.ExceptionSchema( + "http://localhost:8080", + "Raven.Client.Exceptions.AiAgentParameterCollisionException", + "Parameter TelegramUsername is defined on both the agent level and the query level for MyTickets", + "Parameter TelegramUsername is defined on both the agent level and the query level for MyTickets", + ) + + exception = ExceptionDispatcher.get(schema, 500) + + self.assertIsInstance(exception, AiAgentParameterCollisionException) + self.assertIn("TelegramUsername", exception.args[0]) + + class TestAiMessagePromptFieldsImage(unittest.TestCase): def test_image_constant(self): self.assertEqual("image", AiMessagePromptFields.IMAGE) diff --git a/ravendb/tests/ai_agent_tests/test_ravenDB_27289.py b/ravendb/tests/ai_agent_tests/test_ravenDB_27289.py new file mode 100644 index 00000000..b8f20534 --- /dev/null +++ b/ravendb/tests/ai_agent_tests/test_ravenDB_27289.py @@ -0,0 +1,103 @@ +import os +import unittest + +from ravendb import AiAgentConfiguration, AiAgentParameter, AiAgentToolQuery, AiConversationCreationOptions +from ravendb.documents.operations.ai import AiConnectionString, AiModelType +from ravendb.documents.operations.ai.agents import AddOrUpdateAiAgentOperation, DeleteAiAgentOperation +from ravendb.documents.operations.ai.open_ai_settings import OpenAiSettings +from ravendb.documents.operations.connection_string.put_connection_string_operation import PutConnectionStringOperation +from ravendb.exceptions.raven_exceptions import ( + AiAgentParameterCollisionException, + RavenException, + UnsupportedAiQueryParameterException, +) +from ravendb.tests.test_base import TestBase + +_OPTIONAL_SCOPING_PARAMETER = ( + '{"type":"object","properties":{"TelegramUsername":{"type":"string"}},"required":[],"additionalProperties":false}' +) + + +@unittest.skipIf(os.environ.get("RAVENDB_LICENSE") is None, "Insufficient license permissions. Skipping on CI/CD.") +class TestRavenDB27289(TestBase): + CONNECTION_STRING_NAME = "fake-connection" + AGENT_IDENTIFIER = "support-assistant" + + def setUp(self): + super().setUp() + # The refusals happen before the provider is contacted, so nothing has to answer at this address. + self.store.maintenance.send( + PutConnectionStringOperation( + AiConnectionString( + name=self.CONNECTION_STRING_NAME, + identifier=self.CONNECTION_STRING_NAME, + model_type=AiModelType.CHAT, + openai_settings=OpenAiSettings( + api_key="fake-key", endpoint="http://localhost:1/", model="fake-model" + ), + ) + ) + ) + + def tearDown(self): + try: + self.store.maintenance.send(DeleteAiAgentOperation(self.AGENT_IDENTIFIER)) + except Exception: + pass + super().tearDown() + + def _create_agent( + self, send_to_model: bool, parameters_schema: str = None, sample_object: str = "{}" + ) -> AiAgentConfiguration: + return AiAgentConfiguration( + name="support assistant", + identifier=self.AGENT_IDENTIFIER, + connection_string_name=self.CONNECTION_STRING_NAME, + system_prompt="You help users with their own support tickets.", + sample_object='{"Answer":"The answer to the query"}', + parameters=[AiAgentParameter("TelegramUsername", "the handle of the sender", send_to_model)], + queries=[ + AiAgentToolQuery( + "MyTickets", + "Get the tickets of the current user", + "from SupportTickets where TelegramHandle = $TelegramUsername limit 5", + parameters_sample_object=sample_object if parameters_schema is None else None, + parameters_schema=parameters_schema, + ) + ], + ) + + def test_the_refusal_reaches_the_client_as_its_own_type(self): + agent_id = self.store.maintenance.send( + AddOrUpdateAiAgentOperation(self._create_agent(send_to_model=False)) + ).identifier + + chat = self.store.ai.conversation( + agent_id, + "chats/", + AiConversationCreationOptions().add_parameter("TelegramUsername", {"Handle": "abc123"}), + ) + chat.set_user_prompt("show my tickets") + + with self.assertRaises(UnsupportedAiQueryParameterException) as context: + chat.run() + + self.assertIn("$TelegramUsername", context.exception.args[0]) + + def test_tool_schema_declaring_the_scoping_parameter_as_optional_is_rejected(self): + # The parameter is listed in 'properties' with an empty 'required', so the server would otherwise + # advertise the scoping parameter's name to the model. + agent = self._create_agent(send_to_model=False, parameters_schema=_OPTIONAL_SCOPING_PARAMETER) + + with self.assertRaises(AiAgentParameterCollisionException) as context: + self.store.maintenance.send(AddOrUpdateAiAgentOperation(agent)) + + self.assertIn("TelegramUsername", context.exception.args[0]) + + def test_tool_sample_object_declaring_the_scoping_parameter_is_rejected(self): + # A schema generated from a sample object lists every property under 'required', so this collision + # is caught by the older rule, which raises a plain server error. + agent = self._create_agent(send_to_model=False, sample_object='{"TelegramUsername":"abc"}') + + with self.assertRaises(RavenException): + self.store.maintenance.send(AddOrUpdateAiAgentOperation(agent)) diff --git a/ravendb/tests/test_imports.py b/ravendb/tests/test_imports.py index 32b3e7a2..4724c3cd 100644 --- a/ravendb/tests/test_imports.py +++ b/ravendb/tests/test_imports.py @@ -603,6 +603,7 @@ def test_the_rest_of_the_public_surface_imports(self): from ravendb import AiAgentConfigurationResult from ravendb import AiAgentHistoryConfiguration from ravendb import AiAgentParameter + from ravendb import AiAgentParameterCollisionException from ravendb import AiAgentParameterPolicy from ravendb import AiAgentParameterValueType from ravendb import AiAgentPersistenceConfiguration @@ -830,6 +831,7 @@ def test_the_rest_of_the_public_surface_imports(self): from ravendb import TooManyTokensException from ravendb import TopologyChange from ravendb import UnsuccessfulAiRequestException + from ravendb import UnsupportedAiQueryParameterException from ravendb import UpdateCdcSinkOperation from ravendb import UpdateCdcSinkOperationResult from ravendb import UpdateEmbeddingsGenerationOperation