Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ravendb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,7 @@
TimeSeriesStatistics,
)
from ravendb.exceptions.raven_exceptions import (
AiAgentParameterCollisionException,
AiException,
BadResponseException,
ClientVersionMismatchException,
Expand All @@ -687,6 +688,7 @@
TooManyRequestsException,
TooManyTokensException,
UnsuccessfulAiRequestException,
UnsupportedAiQueryParameterException,
)
from ravendb.http.misc import (
AggressiveCacheMode,
Expand Down
4 changes: 4 additions & 0 deletions ravendb/exceptions/exception_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -27,6 +28,7 @@
TooManyRequestsException,
TooManyTokensException,
UnsuccessfulAiRequestException,
UnsupportedAiQueryParameterException,
)

# Maps the simple C# class name to the Python exception class.
Expand All @@ -50,6 +52,8 @@
"TooManyTokensException": TooManyTokensException,
"MissingAiAgentParameterException": MissingAiAgentParameterException,
"QueryToolFailedException": QueryToolFailedException,
"UnsupportedAiQueryParameterException": UnsupportedAiQueryParameterException,
"AiAgentParameterCollisionException": AiAgentParameterCollisionException,
# documents
"DocumentConflictException": DocumentConflictException,
"DocumentDoesNotExistException": DocumentDoesNotExistException,
Expand Down
12 changes: 12 additions & 0 deletions ravendb/exceptions/raven_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 30 additions & 2 deletions ravendb/tests/ai_agent_tests/test_ai_agent_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
103 changes: 103 additions & 0 deletions ravendb/tests/ai_agent_tests/test_ravenDB_27289.py
Original file line number Diff line number Diff line change
@@ -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))
2 changes: 2 additions & 0 deletions ravendb/tests/test_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading