diff --git a/.github/workflows/RavenClient.yml b/.github/workflows/RavenClient.yml index 38ef72c4..4dd37a3b 100644 --- a/.github/workflows/RavenClient.yml +++ b/.github/workflows/RavenClient.yml @@ -2,9 +2,9 @@ name: tests/python on: push: - branches: [v7.0] + branches: [v7.1] pull_request: - branches: [v7.0] + branches: [v7.1] schedule: - cron: '0 10 * * *' workflow_dispatch: @@ -30,7 +30,7 @@ jobs: strategy: matrix: python-version: [ '3.9', '3.10' ,'3.11', '3.12'] - serverVersion: [ '7.0', '7.1' ] + serverVersion: [ '7.1' ] fail-fast: false steps: diff --git a/AI_AGENT_README.md b/AI_AGENT_README.md new file mode 100644 index 00000000..98b6fe9d --- /dev/null +++ b/AI_AGENT_README.md @@ -0,0 +1,741 @@ +# RavenDB AI Agent Python Client + +A comprehensive Python client for RavenDB's AI Agent functionality, enabling intelligent database interactions through conversational AI interfaces. + +## Overview + +The RavenDB AI Agent Python client allows you to create intelligent agents that can: + +- **Query databases** using natural language through AI-powered tools +- **Execute actions** and handle complex workflows +- **Maintain conversation state** across multiple interactions +- **Integrate with various AI providers** (OpenAI, Azure OpenAI, etc.) +- **Handle tool usage** for database queries and external actions +- **Manage conversation persistence** and token optimization + +### Key Features + +- 🤖 **Conversational AI**: Natural language database interactions +- 🔧 **Tool Integration**: Query and action tools for database operations +- 💬 **Conversation Management**: Stateful conversations with continuation support +- 🎯 **Type Safety**: Full type hints and dataclass support +- 🐍 **Pythonic API**: Context managers, properties, and idiomatic Python patterns +- 🔒 **Enterprise Ready**: Production-ready with comprehensive error handling + +## Installation & Setup + +### Prerequisites + +1. **RavenDB Server** (v7.1+) with AI Agent support +2. **Python** 3.8 or higher +3. **AI Connection String** configured in RavenDB + +### Install RavenDB Python Client + +```bash +pip install ravendb +``` + +### Configure AI Connection String + +In RavenDB Studio, configure your AI connection string (e.g., "openai-gpt4"): + +```json +{ + "Name": "openai-gpt4", + "ModelType": "Chat", + "OpenAiSettings": { + "ApiKey": "your-openai-api-key", + "Model": "gpt-4", + "Endpoint": "https://api.openai.com/v1" + } +} +``` + +## Quick Start Guide + +Here's a simple example to get you started: + +```python +from ravendb import DocumentStore, AiAgentConfiguration + +# Initialize document store +with DocumentStore(["http://localhost:8080"], "YourDatabase") as store: + store.initialize() + + # Create a simple AI agent + config = AiAgentConfiguration( + name="DatabaseAssistant", + connection_string_name="openai-gpt4", + system_prompt="You are a helpful database assistant." + ) + config.sample_object = '{"response": "Your helpful response"}' + + # Add the agent + result = store.ai.add_or_update_agent(config) + agent_id = result.identifier + + # Start a conversation + with store.ai.conversation(agent_id) as conversation: + conversation.set_user_prompt("Hello! Can you help me?") + result = conversation.run() + print(f"AI Response: {result.response}") + + # Cleanup + store.ai.delete_agent(agent_id) +``` + +## Detailed Usage Examples + +### Creating and Configuring AI Agents + +#### Basic Agent Configuration + +```python +from ravendb import ( + AiAgentConfiguration, + AiAgentToolQuery, + AiAgentPersistenceConfiguration, + AiAgentChatTrimmingConfiguration, + AiAgentSummarizationByTokens +) + +# Create comprehensive agent configuration +config = AiAgentConfiguration( + name="ComprehensiveAgent", + connection_string_name="openai-gpt4", + system_prompt="""You are a database expert assistant. + +You can query the database using available tools. When users ask for data, +use the appropriate tools to retrieve information and explain the results.""" +) + +# Set output format +config.sample_object = '{"response": "Your response", "action_taken": "What you did"}' + +# Add parameters +config.parameters.add("user_id") +config.parameters.add("max_results") + +# Add query tool +categories_query = AiAgentToolQuery( + name="GetCategories", + description="Retrieves category information from the database", + query="from Categories select Name, Description limit $limit" +) +categories_query.parameters_sample_object = '{"limit": 10}' +config.queries.append(categories_query) + +# Configure persistence +config.persistence = AiAgentPersistenceConfiguration( + persist_conversation=True, + persist_user_prompts=True, + persist_ai_responses=True +) + +# Configure chat trimming +trimming = AiAgentChatTrimmingConfiguration() +trimming.tokens = AiAgentSummarizationByTokens() +trimming.tokens.max_tokens_before_summarization = 16000 +trimming.tokens.max_tokens_after_summarization = 4000 +config.chat_trimming = trimming + +# Create the agent +result = store.ai.add_or_update_agent(config) +agent_id = result.identifier +``` + +#### Adding Action Tools + +```python +from ravendb import AiAgentToolAction + +# Create action tool for external operations +email_action = AiAgentToolAction( + name="SendEmail", + description="Sends an email notification to users" +) +email_action.parameters_sample_object = '{"to": "user@example.com", "subject": "Subject", "body": "Message"}' +config.actions.append(email_action) +``` + +### Starting Conversations and Handling Responses + +#### Basic Conversation + +```python +# Start a new conversation +conversation = store.ai.conversation(agent_id, { + "user_id": "user123", + "max_results": 10 +}) + +conversation.set_user_prompt("Show me some product categories") +result = conversation.run() + +print(f"Response: {result.response}") +print(f"Conversation ID: {result.conversation_id}") +print(f"Token Usage: {result.usage.total_tokens if result.usage else 'N/A'}") +``` + +#### Handling Action Requests + +```python +conversation.set_user_prompt("Send me an email with the category list") +result = conversation.run() + +# Check if AI requested actions +if result.has_action_requests: + print("AI requested actions:") + for action in result.action_requests: + print(f"- Tool: {action.name}") + print(f"- Arguments: {action.arguments}") + + # Handle the action (implement your logic here) + if action.name == "SendEmail": + # Process email sending + email_result = send_email_implementation(action.arguments) + + # Provide response back to AI + conversation.add_action_response(action.tool_id, email_result) + + # Continue conversation with action results + result = conversation.run() + print(f"Final response: {result.response}") +``` + +### Conversation Continuation and State Management + +#### Continuing Existing Conversations + +```python +# Continue an existing conversation +existing_conversation = store.ai.conversation_with_id( + conversation_id="conversations/12345-A", + change_vector="A:123-xyz" +) + +existing_conversation.set_user_prompt("What else can you tell me?") +result = existing_conversation.run() + +print(f"Continued response: {result.response}") +print(f"Updated change vector: {result.change_vector}") +``` + +#### Using Context Managers for Automatic Cleanup + +```python +# Context manager automatically handles cleanup +with store.ai.conversation(agent_id) as conversation: + conversation.set_user_prompt("Hello!") + result = conversation.run() + + # Check required actions as property + if conversation.required_actions: + print(f"Actions needed: {len(conversation.required_actions)}") + + # Conversation is automatically cleaned up on exit +``` + +### Error Handling and Best Practices + +#### Comprehensive Error Handling + +```python +from ravendb.exceptions import RavenException + +try: + # Create conversation + conversation = store.ai.conversation(agent_id) + + # Validate input + user_input = input("Enter your question: ").strip() + if not user_input: + raise ValueError("Question cannot be empty") + + conversation.set_user_prompt(user_input) + result = conversation.run() + + # Handle response + if result.response: + print(f"AI: {result.response}") + else: + print("No response received from AI") + +except ValueError as e: + print(f"Input error: {e}") +except RavenException as e: + print(f"RavenDB error: {e}") +except Exception as e: + print(f"Unexpected error: {e}") +``` + +#### Production Best Practices + +```python +import logging +from typing import Optional + +class ProductionAiAgent: + def __init__(self, store: DocumentStore, agent_id: str): + self.store = store + self.agent_id = agent_id + self.logger = logging.getLogger(__name__) + + def safe_conversation(self, prompt: str, parameters: Optional[dict] = None) -> Optional[dict]: + """Safely execute a conversation with comprehensive error handling.""" + try: + with self.store.ai.conversation(self.agent_id, parameters) as conversation: + conversation.set_user_prompt(prompt) + result = conversation.run() + + # Log usage for monitoring + if result.usage: + self.logger.info(f"Token usage: {result.usage.total_tokens}") + + return { + "response": result.response, + "conversation_id": result.conversation_id, + "success": True + } + + except Exception as e: + self.logger.error(f"Conversation failed: {e}") + return { + "error": str(e), + "success": False + } + +# Usage +agent = ProductionAiAgent(store, agent_id) +result = agent.safe_conversation("What categories do we have?") +if result["success"]: + print(result["response"]) +else: + print(f"Error: {result['error']}") +``` + +## API Reference + +### Core Classes + +#### `AiOperations` + +Main entry point for AI agent operations. + +```python +# Access via document store +ai_ops = store.ai + +# Methods +ai_ops.add_or_update_agent(configuration, schema_type=None) -> AiAgentConfigurationResult +ai_ops.delete_agent(identifier) -> AiAgentConfigurationResult +ai_ops.get_agents(agent_id=None) -> GetAiAgentsResponse +ai_ops.conversation(agent_id, parameters=None) -> AiConversation +ai_ops.conversation_with_id(conversation_id, change_vector=None) -> AiConversation +``` + +#### `AiConversation` + +Manages individual conversations with AI agents. + +```python +# Properties +conversation.required_actions -> List[AiAgentActionRequest] # Read-only property + +# Methods +conversation.set_user_prompt(prompt: str) -> None +conversation.add_action_response(action_id: str, response: Union[str, Any]) -> None +conversation.run() -> AiConversationResult + +# Context manager support +with store.ai.conversation(agent_id) as conversation: + # Automatic cleanup on exit + pass +``` + +#### `AiConversationResult` + +Contains the result of a conversation turn. + +```python +# Properties +result.conversation_id: Optional[str] +result.change_vector: Optional[str] +result.response: Optional[Any] +result.usage: Optional[AiUsage] +result.action_requests: List[AiAgentActionRequest] + +# Methods +result.has_action_requests -> bool # Property +result.get_action_request_by_id(action_id: str) -> Optional[AiAgentActionRequest] + +# String representations +str(result) # Brief summary +repr(result) # Detailed representation +``` + +#### `AiAgentConfiguration` + +Defines agent configuration and capabilities. + +```python +config = AiAgentConfiguration( + name="AgentName", + connection_string_name="ai-connection", + system_prompt="Your system prompt" +) + +# Properties +config.identifier: Optional[str] +config.sample_object: Optional[str] +config.output_schema: Optional[str] +config.queries: List[AiAgentToolQuery] +config.actions: List[AiAgentToolAction] +config.parameters: Set[str] +config.persistence: Optional[AiAgentPersistenceConfiguration] +config.chat_trimming: Optional[AiAgentChatTrimmingConfiguration] +config.max_model_iterations_per_call: Optional[int] +``` + +### Data Classes + +#### `AiUsage` (Dataclass) + +Token usage statistics. + +```python +@dataclass +class AiUsage: + prompt_tokens: int = 0 + completion_tokens: int = 0 + total_tokens: int = 0 + cached_tokens: int = 0 +``` + +#### `AiAgentActionRequest` (Dataclass) + +Represents an action request from the AI. + +```python +@dataclass +class AiAgentActionRequest: + name: Optional[str] = None + tool_id: Optional[str] = None + arguments: Optional[str] = None +``` + +#### `AiAgentActionResponse` (Dataclass) + +Response to an AI action request. + +```python +@dataclass +class AiAgentActionResponse: + tool_id: Optional[str] = None + content: Optional[str] = None +``` + +## Configuration Options + +### Agent Configuration + +#### Basic Settings + +```python +config = AiAgentConfiguration( + name="MyAgent", # Agent display name + connection_string_name="ai-provider", # AI connection string name + system_prompt="Your system prompt" # Instructions for the AI +) +``` + +#### Output Configuration + +```python +# JSON sample for response format +config.sample_object = '{"response": "text", "confidence": 0.95}' + +# JSON schema for strict typing +config.output_schema = ''' +{ + "type": "object", + "properties": { + "response": {"type": "string"}, + "confidence": {"type": "number"} + } +} +''' +``` + +#### Tool Configuration + +```python +# Query tools for database operations +query_tool = AiAgentToolQuery( + name="GetProducts", + description="Retrieves product information", + query="from Products where Category == $category select Name, Price" +) +query_tool.parameters_sample_object = '{"category": "Electronics"}' +config.queries.append(query_tool) + +# Action tools for external operations +action_tool = AiAgentToolAction( + name="SendNotification", + description="Sends notifications to users" +) +action_tool.parameters_sample_object = '{"message": "text", "recipients": ["email"]}' +config.actions.append(action_tool) +``` + +#### Persistence Configuration + +```python +from ravendb import AiAgentPersistenceConfiguration + +config.persistence = AiAgentPersistenceConfiguration( + persist_conversation=True, # Save conversation history + persist_user_prompts=True, # Save user inputs + persist_ai_responses=True # Save AI responses +) +``` + +#### Chat Trimming Configuration + +```python +from ravendb import ( + AiAgentChatTrimmingConfiguration, + AiAgentSummarizationByTokens, + AiAgentTruncateChat, + AiAgentHistoryConfiguration +) + +# Token-based summarization +trimming = AiAgentChatTrimmingConfiguration() +trimming.tokens = AiAgentSummarizationByTokens() +trimming.tokens.max_tokens_before_summarization = 32000 +trimming.tokens.max_tokens_after_summarization = 8000 +trimming.tokens.summarization_task_beginning_prompt = "Summarize the conversation:" + +# Simple truncation +trimming.truncate = AiAgentTruncateChat() +trimming.truncate.max_messages = 50 + +# History-based trimming +trimming.history = AiAgentHistoryConfiguration() +trimming.history.max_age_in_minutes = 1440 # 24 hours + +config.chat_trimming = trimming +``` + +## Best Practices + +### 1. Agent Design + +#### Clear System Prompts + +```python +# Good: Specific and actionable +system_prompt = """You are a customer service assistant for an e-commerce platform. + +You can: +- Query product information using GetProducts tool +- Check order status using GetOrders tool +- Send notifications using SendEmail tool + +Always be helpful and provide specific information. When using tools, +explain what you're doing and what the results mean.""" + +# Avoid: Vague or overly broad prompts +system_prompt = "You are a helpful assistant." +``` + +#### Structured Output Formats + +```python +# Good: Structured response format +config.sample_object = ''' +{ + "response": "Your helpful response to the user", + "action_taken": "Description of what you did", + "confidence": 0.95, + "follow_up_needed": false +} +''' + +# This helps ensure consistent, parseable responses +``` + +### 2. Conversation Management + +#### Use Context Managers + +```python +# Good: Automatic cleanup +with store.ai.conversation(agent_id) as conversation: + conversation.set_user_prompt(user_input) + result = conversation.run() + return result.response + +# Avoid: Manual resource management +conversation = store.ai.conversation(agent_id) +# ... (easy to forget cleanup) +``` + +#### Handle Long Conversations + +```python +# Configure appropriate chat trimming +trimming = AiAgentChatTrimmingConfiguration() +trimming.tokens = AiAgentSummarizationByTokens() +trimming.tokens.max_tokens_before_summarization = 16000 # Adjust based on model +trimming.tokens.max_tokens_after_summarization = 4000 + +config.chat_trimming = trimming +``` + +### 3. Error Handling + +#### Validate Inputs + +```python +def safe_prompt(conversation, user_input: str): + # Validate input + if not user_input or user_input.isspace(): + raise ValueError("Input cannot be empty") + + if len(user_input) > 10000: # Reasonable limit + raise ValueError("Input too long") + + conversation.set_user_prompt(user_input) + return conversation.run() +``` + +#### Handle Action Failures + +```python +def handle_actions(conversation, result): + for action in result.action_requests: + try: + # Implement your action logic + action_result = execute_action(action) + conversation.add_action_response(action.tool_id, action_result) + except Exception as e: + # Provide error feedback to AI + error_response = f"Action failed: {str(e)}" + conversation.add_action_response(action.tool_id, error_response) + + return conversation.run() +``` + +### 4. Production Deployment + +#### Monitor Token Usage + +```python +def monitor_conversation(conversation, prompt): + result = conversation.run() + + if result.usage: + # Log for monitoring + logger.info(f"Tokens used: {result.usage.total_tokens}") + + # Alert on high usage + if result.usage.total_tokens > 10000: + logger.warning(f"High token usage: {result.usage.total_tokens}") + + return result +``` + +#### Implement Rate Limiting + +```python +from time import time +from collections import defaultdict + +class RateLimiter: + def __init__(self, max_requests_per_minute=60): + self.max_requests = max_requests_per_minute + self.requests = defaultdict(list) + + def allow_request(self, user_id: str) -> bool: + now = time() + minute_ago = now - 60 + + # Clean old requests + self.requests[user_id] = [ + req_time for req_time in self.requests[user_id] + if req_time > minute_ago + ] + + # Check limit + if len(self.requests[user_id]) >= self.max_requests: + return False + + self.requests[user_id].append(now) + return True + +# Usage +rate_limiter = RateLimiter(max_requests_per_minute=30) + +if not rate_limiter.allow_request(user_id): + raise Exception("Rate limit exceeded") +``` + +#### Secure Configuration + +```python +# Store sensitive configuration securely +import os + +config = AiAgentConfiguration( + name="ProductionAgent", + connection_string_name=os.getenv("AI_CONNECTION_NAME"), + system_prompt=load_prompt_from_secure_storage() +) + +# Don't hardcode API keys or sensitive prompts in code +``` + +### 5. Testing + +#### Unit Testing + +```python +import unittest +from unittest.mock import Mock, patch + +class TestAiAgent(unittest.TestCase): + def setUp(self): + self.store = Mock() + self.agent_id = "test-agent" + + def test_conversation_creation(self): + # Mock the conversation + mock_conversation = Mock() + self.store.ai.conversation.return_value = mock_conversation + + # Test conversation creation + conversation = self.store.ai.conversation(self.agent_id) + self.store.ai.conversation.assert_called_once_with(self.agent_id) + + @patch('your_module.store') + def test_agent_response(self, mock_store): + # Test actual agent responses + # ... implementation + pass +``` + +## Troubleshooting + +### Common Issues + +1. **"Agent ID is required"**: Ensure you're providing a valid agent ID when creating conversations +2. **"User prompt cannot be empty"**: Validate user input before setting prompts +3. **Token limit exceeded**: Configure appropriate chat trimming settings +4. **Action tool not found**: Verify tool names match exactly between configuration and usage +5. **Connection string not found**: Ensure AI connection string is properly configured in RavenDB + + +For more information, visit the [RavenDB Documentation](https://docs.ravendb.net) or check the [Python Client GitHub Repository](https://github.com/ravendb/ravendb-python-client). diff --git a/ravendb/__init__.py b/ravendb/__init__.py index 60a4b423..ca90f736 100644 --- a/ravendb/__init__.py +++ b/ravendb/__init__.py @@ -76,6 +76,37 @@ from ravendb.documents.operations.configuration.definitions import StudioConfiguration, StudioEnvironment from ravendb.documents.operations.connection_strings import ConnectionString + +# AI Operations +from ravendb.documents.ai import ( + AiOperations, + IAiConversationOperations, + AiConversation, + AiConversationResult, + AiAgentParametersBuilder, + IAiAgentParametersBuilder, +) +from ravendb.documents.operations.ai.agents import ( + AiAgentConfiguration, + AiAgentConfigurationResult, + AiAgentToolAction, + AiAgentToolQuery, + AiAgentPersistenceConfiguration, + AiAgentChatTrimmingConfiguration, + AiAgentSummarizationByTokens, + AiAgentTruncateChat, + AiAgentHistoryConfiguration, + RunConversationOperation, + ConversationResult, + AiAgentActionRequest, + AiAgentActionResponse, + AiUsage, + GetAiAgentOperation, + GetAiAgentsResponse, + AddOrUpdateAiAgentOperation, + DeleteAiAgentOperation, +) + from ravendb.documents.operations.etl.configuration import EtlConfiguration, RavenEtlConfiguration from ravendb.documents.operations.etl.olap import OlapEtlConfiguration from ravendb.documents.operations.etl.sql import SqlEtlConfiguration diff --git a/ravendb/documents/ai/__init__.py b/ravendb/documents/ai/__init__.py new file mode 100644 index 00000000..391aec81 --- /dev/null +++ b/ravendb/documents/ai/__init__.py @@ -0,0 +1,14 @@ +from .ai_operations import AiOperations +from .ai_conversation_operations import IAiConversationOperations +from .ai_conversation import AiConversation +from .ai_conversation_result import AiConversationResult +from .ai_agent_parameters_builder import AiAgentParametersBuilder, IAiAgentParametersBuilder + +__all__ = [ + "AiOperations", + "IAiConversationOperations", + "AiConversation", + "AiConversationResult", + "AiAgentParametersBuilder", + "IAiAgentParametersBuilder", +] diff --git a/ravendb/documents/ai/ai_agent_parameters_builder.py b/ravendb/documents/ai/ai_agent_parameters_builder.py new file mode 100644 index 00000000..68476ebf --- /dev/null +++ b/ravendb/documents/ai/ai_agent_parameters_builder.py @@ -0,0 +1,77 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from typing import Dict, Any, TypeVar, Generic, TYPE_CHECKING + +if TYPE_CHECKING: + from ravendb.documents.ai.ai_conversation_operations import IAiConversationOperations + +TResponse = TypeVar("TResponse") + + +class IAiAgentParametersBuilder(ABC, Generic[TResponse]): + """ + Interface for building parameters for AI agent conversations. + """ + + @abstractmethod + def with_parameter(self, name: str, value: Any) -> IAiAgentParametersBuilder[TResponse]: + """ + Adds a parameter to the conversation. + + Args: + name: The parameter name + value: The parameter value + + Returns: + The builder instance for method chaining + """ + pass + + @abstractmethod + def build(self) -> IAiConversationOperations[TResponse]: + """ + Builds and returns the conversation operations instance. + + Returns: + The conversation operations interface + """ + pass + + +class AiAgentParametersBuilder(IAiAgentParametersBuilder[TResponse]): + """ + Builder for constructing AI agent conversation parameters. + """ + + def __init__(self, conversation_factory): + """ + Initializes the parameters builder. + + Args: + conversation_factory: A callable that creates the conversation with the built parameters + """ + self._parameters: Dict[str, Any] = {} + self._conversation_factory = conversation_factory + + def with_parameter(self, name: str, value: Any) -> IAiAgentParametersBuilder[TResponse]: + """ + Adds a parameter to the conversation. + + Args: + name: The parameter name + value: The parameter value + + Returns: + The builder instance for method chaining + """ + self._parameters[name] = value + return self + + def build(self) -> IAiConversationOperations[TResponse]: + """ + Builds and returns the conversation operations instance. + + Returns: + The conversation operations interface + """ + return self._conversation_factory(self._parameters) diff --git a/ravendb/documents/ai/ai_conversation.py b/ravendb/documents/ai/ai_conversation.py new file mode 100644 index 00000000..769cdaf4 --- /dev/null +++ b/ravendb/documents/ai/ai_conversation.py @@ -0,0 +1,184 @@ +from __future__ import annotations +import json +from typing import List, Dict, Any, Optional, Union, TypeVar, TYPE_CHECKING + +from ravendb.documents.ai.ai_conversation_operations import IAiConversationOperations +from ravendb.documents.ai.ai_conversation_result import AiConversationResult + +if TYPE_CHECKING: + from ravendb.documents.store.definition import DocumentStore + from ravendb.documents.operations.ai.agents import ( + AiAgentActionRequest, + AiAgentActionResponse, + ConversationResult, + ) + +TResponse = TypeVar("TResponse") + + +class AiConversation(IAiConversationOperations[TResponse]): + """ + Implementation of AI conversation operations for managing conversations with AI agents. + + Can be used as a context manager for automatic cleanup: + with store.ai.conversation(agent_id) as conversation: + conversation.set_user_prompt("Hello!") + result = conversation.run() + """ + + def __init__( + self, + store: DocumentStore, + agent_id: str = None, + parameters: Dict[str, Any] = None, + conversation_id: str = None, + change_vector: str = None, + ): + self._store = store + self._agent_id = agent_id + self._parameters = parameters or {} + self._conversation_id = conversation_id + self._change_vector = change_vector + self._user_prompt: Optional[str] = None + self._action_responses: List[AiAgentActionResponse] = [] + self._last_result: Optional[ConversationResult[TResponse]] = None + + @classmethod + def with_conversation_id( + cls, store: DocumentStore, conversation_id: str, change_vector: str = None + ) -> AiConversation[TResponse]: + """ + Creates a conversation instance for continuing an existing conversation. + + Args: + store: The document store + conversation_id: The ID of the existing conversation + change_vector: Optional change vector for optimistic concurrency + + Returns: + A new conversation instance + """ + return cls( + store=store, + conversation_id=conversation_id, + change_vector=change_vector, + ) + + @property + def required_actions(self) -> List[AiAgentActionRequest]: + """ + Gets the list of action requests that need to be fulfilled before + the conversation can continue. + """ + if self._last_result and self._last_result.action_requests: + return self._last_result.action_requests + return [] + + def add_action_response(self, action_id: str, action_response: Union[str, TResponse]) -> None: + """ + Adds a response for a given action request. + + Args: + action_id: The ID of the action to respond to + action_response: The response content (string or typed response object) + """ + from ravendb.documents.operations.ai.agents import AiAgentActionResponse + + response = AiAgentActionResponse(tool_id=action_id) + + if isinstance(action_response, str): + response.content = action_response + else: + # More robust JSON serialization + try: + response.content = json.dumps( + action_response.__dict__ if hasattr(action_response, "__dict__") else action_response, default=str + ) + except (TypeError, ValueError) as e: + response.content = str(action_response) + + self._action_responses.append(response) + + def run(self) -> AiConversationResult[TResponse]: + """ + Executes one "turn" of the conversation: + sends the current prompt, processes any required actions, + and awaits the agent's reply. + """ + from ravendb.documents.operations.ai.agents import RunConversationOperation + + if self._conversation_id: + # Continue existing conversation + if not self._agent_id: + raise ValueError("Agent ID is required for conversation continuation") + + operation = RunConversationOperation( + self._conversation_id, + self._user_prompt, + self._action_responses, + self._change_vector, + ) + # Set agent ID for conversation continuation + operation._agent_id = self._agent_id + else: + # Start new conversation + if not self._agent_id: + raise ValueError("Agent ID is required for new conversations") + + operation = RunConversationOperation( + self._agent_id, + self._user_prompt, + self._parameters, + ) + + # Execute the operation + result = self._store.maintenance.send(operation) + self._last_result = result + + # Update conversation state for future calls + if result.conversation_id: + self._conversation_id = result.conversation_id + if result.change_vector: + self._change_vector = result.change_vector + + # Preserve agent ID for future conversation turns + if not self._agent_id and hasattr(operation, "_agent_id"): + self._agent_id = operation._agent_id + + # Clear processed data for next turn + self._user_prompt = None + self._action_responses.clear() + + # Convert to AiConversationResult + conversation_result = AiConversationResult[TResponse]() + conversation_result.conversation_id = result.conversation_id + conversation_result.change_vector = result.change_vector + conversation_result.response = result.response + conversation_result.usage = result.usage + conversation_result.action_requests = result.action_requests or [] + + return conversation_result + + def set_user_prompt(self, user_prompt: str) -> None: + """ + Sets the next user prompt to send to the AI agent. + + Args: + user_prompt: The prompt text to send to the agent + + Raises: + ValueError: If user_prompt is empty or whitespace-only + """ + if not user_prompt or user_prompt.isspace(): + raise ValueError("User prompt cannot be empty or whitespace-only") + self._user_prompt = user_prompt + + def __enter__(self) -> AiConversation[TResponse]: + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + """Context manager exit - cleanup resources.""" + # Clear any pending data + self._user_prompt = None + self._action_responses.clear() diff --git a/ravendb/documents/ai/ai_conversation_operations.py b/ravendb/documents/ai/ai_conversation_operations.py new file mode 100644 index 00000000..4f1d6d86 --- /dev/null +++ b/ravendb/documents/ai/ai_conversation_operations.py @@ -0,0 +1,62 @@ +from __future__ import annotations +from abc import ABC, abstractmethod +from typing import List, TypeVar, Generic, Union, TYPE_CHECKING + +if TYPE_CHECKING: + from ravendb.documents.operations.ai.agents import AiAgentActionRequest + from ravendb.documents.ai.ai_conversation_result import AiConversationResult + +TResponse = TypeVar("TResponse") + + +class IAiConversationOperations(ABC, Generic[TResponse]): + """ + Interface for AI conversation operations, providing methods to manage + conversations with AI agents including sending prompts, handling actions, + and running conversation turns. + """ + + @property + @abstractmethod + def required_actions(self) -> List[AiAgentActionRequest]: + """ + Gets the list of action requests that need to be fulfilled before + the conversation can continue. + + Returns: + List of action requests that require responses + """ + pass + + @abstractmethod + def add_action_response(self, action_id: str, action_response: Union[str, TResponse]) -> None: + """ + Adds a response for a given action request. + + Args: + action_id: The ID of the action to respond to + action_response: The response content (string or typed response object) + """ + pass + + @abstractmethod + def run(self) -> AiConversationResult[TResponse]: + """ + Executes one "turn" of the conversation: + sends the current prompt, processes any required actions, + and awaits the agent's reply. + + Returns: + The result of the conversation turn + """ + pass + + @abstractmethod + def set_user_prompt(self, user_prompt: str) -> None: + """ + Sets the next user prompt to send to the AI agent. + + Args: + user_prompt: The prompt text to send to the agent + """ + pass diff --git a/ravendb/documents/ai/ai_conversation_result.py b/ravendb/documents/ai/ai_conversation_result.py new file mode 100644 index 00000000..a554429a --- /dev/null +++ b/ravendb/documents/ai/ai_conversation_result.py @@ -0,0 +1,58 @@ +from __future__ import annotations +from typing import Optional, List, TypeVar, Generic, TYPE_CHECKING + +if TYPE_CHECKING: + from ravendb.documents.operations.ai.agents import AiAgentActionRequest, AiUsage + +TResponse = TypeVar("TResponse") + + +class AiConversationResult(Generic[TResponse]): + """ + Represents the result of an AI conversation turn, containing the agent's response, + usage statistics, and any action requests that need to be fulfilled. + """ + + def __init__(self): + self.conversation_id: Optional[str] = None + self.change_vector: Optional[str] = None + self.response: Optional[TResponse] = None + self.usage: Optional[AiUsage] = None + self.action_requests: List[AiAgentActionRequest] = [] + + def __str__(self) -> str: + """String representation for debugging.""" + return ( + f"AiConversationResult(conversation_id={self.conversation_id!r}, " + f"has_response={self.response is not None}, " + f"action_requests={len(self.action_requests)})" + ) + + def __repr__(self) -> str: + """Detailed representation for debugging.""" + return ( + f"AiConversationResult(conversation_id={self.conversation_id!r}, " + f"change_vector={self.change_vector!r}, " + f"response={self.response!r}, " + f"usage={self.usage!r}, " + f"action_requests={self.action_requests!r})" + ) + + @property + def has_action_requests(self) -> bool: + """ + Returns True if there are action requests that need to be fulfilled. + """ + return bool(self.action_requests) + + def get_action_request_by_id(self, action_id: str) -> Optional[AiAgentActionRequest]: + """ + Gets an action request by its tool ID. + + Args: + action_id: The tool ID of the action request + + Returns: + The action request if found, None otherwise + """ + return next((request for request in self.action_requests if request.tool_id == action_id), None) diff --git a/ravendb/documents/ai/ai_operations.py b/ravendb/documents/ai/ai_operations.py new file mode 100644 index 00000000..8c02ff01 --- /dev/null +++ b/ravendb/documents/ai/ai_operations.py @@ -0,0 +1,98 @@ +from __future__ import annotations +from typing import TYPE_CHECKING, Optional, Dict, Any, Type + +if TYPE_CHECKING: + from ravendb.documents.store.definition import DocumentStore + from ravendb.documents.operations.ai.agents import ( + AiAgentConfiguration, + AiAgentConfigurationResult, + GetAiAgentsResponse, + ) + from ravendb.documents.ai.ai_conversation_operations import IAiConversationOperations + + +class AiOperations: + """ + AI operations for the document store, providing access to AI agent management and conversation operations. + """ + + def __init__(self, store: DocumentStore): + self._store = store + + def add_or_update_agent( + self, configuration: AiAgentConfiguration, schema_type: Type = None + ) -> AiAgentConfigurationResult: + """ + Adds or updates an AI agent configuration. + + Args: + configuration: The AI agent configuration to add or update + schema_type: Optional type to use for generating sample schema + + Returns: + Result containing the agent identifier and raft command index + """ + from ravendb.documents.operations.ai.agents import AddOrUpdateAiAgentOperation + + operation = AddOrUpdateAiAgentOperation(configuration, schema_type) + return self._store.maintenance.send(operation) + + def delete_agent(self, identifier: str) -> AiAgentConfigurationResult: + """ + Deletes an AI agent configuration. + + Args: + identifier: The identifier of the agent to delete + + Returns: + Result containing the raft command index + """ + from ravendb.documents.operations.ai.agents import DeleteAiAgentOperation + + operation = DeleteAiAgentOperation(identifier) + return self._store.maintenance.send(operation) + + def get_agents(self, agent_id: str = None) -> GetAiAgentsResponse: + """ + Gets AI agent configurations. + + Args: + agent_id: Optional specific agent ID to retrieve. If None, returns all agents. + + Returns: + Response containing the list of AI agent configurations + """ + from ravendb.documents.operations.ai.agents import GetAiAgentOperation + + operation = GetAiAgentOperation(agent_id) + return self._store.maintenance.send(operation) + + def conversation(self, agent_id: str, parameters: Dict[str, Any] = None) -> IAiConversationOperations: + """ + Creates a new conversation with the specified AI agent. + + Args: + agent_id: The identifier of the AI agent to start a conversation with + parameters: Optional parameters to pass to the agent + + Returns: + Conversation operations interface for managing the conversation + """ + from ravendb.documents.ai.ai_conversation import AiConversation + + return AiConversation(self._store, agent_id, parameters) + + def conversation_with_id(self, conversation_id: str, change_vector: str = None) -> IAiConversationOperations: + """ + Continues an existing conversation by its ID. + + Args: + conversation_id: The ID of the existing conversation + change_vector: Optional change vector for optimistic concurrency + + Returns: + Conversation operations interface for managing the conversation + """ + from ravendb.documents.ai.ai_conversation import AiConversation + + return AiConversation.with_conversation_id(self._store, conversation_id, change_vector) diff --git a/ravendb/documents/operations/ai/agents/__init__.py b/ravendb/documents/operations/ai/agents/__init__.py new file mode 100644 index 00000000..d6048a5d --- /dev/null +++ b/ravendb/documents/operations/ai/agents/__init__.py @@ -0,0 +1,51 @@ +from .ai_agent_configuration import ( + AiAgentConfiguration, + AiAgentToolAction, + AiAgentToolQuery, + AiAgentPersistenceConfiguration, + AiAgentChatTrimmingConfiguration, + AiAgentSummarizationByTokens, + AiAgentTruncateChat, + AiAgentHistoryConfiguration, +) + +from .add_or_update_ai_agent_operation import ( + AddOrUpdateAiAgentOperation, + AiAgentConfigurationResult, +) + +from .delete_ai_agent_operation import DeleteAiAgentOperation + +from .get_ai_agent_operation import ( + GetAiAgentOperation, + GetAiAgentsResponse, +) + +from .run_conversation_operation import ( + RunConversationOperation, + ConversationResult, + AiAgentActionRequest, + AiAgentActionResponse, + AiUsage, +) + +__all__ = [ + "AiAgentConfiguration", + "AiAgentConfigurationResult", + "AiAgentToolAction", + "AiAgentToolQuery", + "AiAgentPersistenceConfiguration", + "AiAgentChatTrimmingConfiguration", + "AiAgentSummarizationByTokens", + "AiAgentTruncateChat", + "AiAgentHistoryConfiguration", + "RunConversationOperation", + "ConversationResult", + "AiAgentActionRequest", + "AiAgentActionResponse", + "AiUsage", + "GetAiAgentOperation", + "GetAiAgentsResponse", + "AddOrUpdateAiAgentOperation", + "DeleteAiAgentOperation", +] diff --git a/ravendb/documents/operations/ai/agents/add_or_update_ai_agent_operation.py b/ravendb/documents/operations/ai/agents/add_or_update_ai_agent_operation.py new file mode 100644 index 00000000..be99b7f2 --- /dev/null +++ b/ravendb/documents/operations/ai/agents/add_or_update_ai_agent_operation.py @@ -0,0 +1,96 @@ +from __future__ import annotations +import json +from typing import Optional, Type, Dict, Any, TYPE_CHECKING + +from ravendb.documents.operations.definitions import MaintenanceOperation +from ravendb.documents.conventions import DocumentConventions +from ravendb.http.raven_command import RavenCommand +from ravendb.http.server_node import ServerNode +import requests + + +if TYPE_CHECKING: + from ravendb.documents.operations.ai.agents.ai_agent_configuration import AiAgentConfiguration + + +class AiAgentConfigurationResult: + def __init__(self): + self.identifier: Optional[str] = None + self.raft_command_index: Optional[int] = None + + @classmethod + def from_json(cls, json_dict: Dict[str, Any]) -> AiAgentConfigurationResult: + result = cls() + result.identifier = json_dict.get("Identifier") + result.raft_command_index = json_dict.get("RaftCommandIndex") + return result + + +class AddOrUpdateAiAgentOperation(MaintenanceOperation[AiAgentConfigurationResult]): + def __init__(self, configuration: AiAgentConfiguration, schema_type: Type = None): + if configuration is None: + raise ValueError("configuration cannot be None") + + if not configuration.output_schema and not configuration.sample_object and schema_type is None: + raise ValueError( + "Please provide a non-empty value for either output_schema or sample_object or schema_type" + ) + + self._configuration = configuration + self._sample_schema = schema_type() if schema_type else None + + def get_command(self, conventions: DocumentConventions) -> RavenCommand[AiAgentConfigurationResult]: + return AddOrUpdateAiAgentCommand(self._configuration, self._sample_schema, conventions) + + +class AddOrUpdateAiAgentCommand(RavenCommand[AiAgentConfigurationResult]): + def __init__( + self, + configuration: AiAgentConfiguration, + sample_schema: Any, + conventions: DocumentConventions, + ): + super().__init__(AiAgentConfigurationResult) + self._configuration = configuration + self._sample_schema = sample_schema + self._conventions = conventions + + def is_read_request(self) -> bool: + return False + + def create_request(self, node: ServerNode) -> requests.Request: + url = f"{node.url}/databases/{node.database}/admin/ai/agent" + + # Create a copy of the configuration to avoid modifying the original + config_to_send = self._configuration + + # Set sample object if not provided but we have a schema type + if not config_to_send.sample_object and self._sample_schema: + if hasattr(self._sample_schema, "__dict__"): + config_to_send.sample_object = json.dumps(self._sample_schema.__dict__) + else: + config_to_send.sample_object = json.dumps(self._sample_schema) + + # Convert configuration to JSON + body_json = config_to_send.to_json() + + body = json.dumps(body_json) + + request = requests.Request("PUT", url) + request.headers = {"Content-Type": "application/json"} + request.data = body + return request + + def set_response(self, response: str, from_cache: bool) -> None: + if response is None: + self.result = AiAgentConfigurationResult() + return + + response_json = json.loads(response) + self.result = AiAgentConfigurationResult.from_json(response_json) + + def get_raft_unique_request_id(self) -> str: + # Generate a unique ID for Raft operations + import uuid + + return str(uuid.uuid4()) diff --git a/ravendb/documents/operations/ai/agents/ai_agent_configuration.py b/ravendb/documents/operations/ai/agents/ai_agent_configuration.py new file mode 100644 index 00000000..cc45cb71 --- /dev/null +++ b/ravendb/documents/operations/ai/agents/ai_agent_configuration.py @@ -0,0 +1,299 @@ +from __future__ import annotations +from typing import List, Set, Optional, Dict, Any + + +class AiAgentToolQuery: + """ + Represents a query tool that can be invoked by an AI agent. + The tool includes a name, description, query string, and parameter schema or sample object. + When invoked by the AI model, the query is expected to be executed by the server (database), + and its results provided back to the model. + """ + + def __init__(self, name: str = None, description: str = None, query: str = None): + self.name = name + self.description = description + self.query = query + self.parameters_sample_object: Optional[str] = None + self.parameters_schema: Optional[str] = None + + def to_json(self) -> Dict[str, Any]: + return { + "Name": self.name, + "Description": self.description, + "Query": self.query, + "ParametersSampleObject": self.parameters_sample_object, + "ParametersSchema": self.parameters_schema, + } + + @classmethod + def from_json(cls, json_dict: Dict[str, Any]) -> AiAgentToolQuery: + instance = cls() + instance.name = json_dict.get("name") or json_dict.get("Name") + instance.description = json_dict.get("description") or json_dict.get("Description") + instance.query = json_dict.get("query") or json_dict.get("Query") + instance.parameters_sample_object = json_dict.get("parametersSampleObject") or json_dict.get( + "ParametersSampleObject" + ) + instance.parameters_schema = json_dict.get("parametersSchema") or json_dict.get("ParametersSchema") + return instance + + +class AiAgentToolAction: + """ + Represents a tool action that can be invoked by an AI agent. + Includes metadata such as name, description, and optional parameters schema or sample. + Tool actions represent external functions whose results are provided by the user + """ + + def __init__(self, name: str = None, description: str = None): + self.name = name + self.description = description + self.parameters_sample_object: Optional[str] = None + self.parameters_schema: Optional[str] = None + + def to_json(self) -> Dict[str, Any]: + return { + "Name": self.name, + "Description": self.description, + "ParametersSampleObject": self.parameters_sample_object, + "ParametersSchema": self.parameters_schema, + } + + @classmethod + def from_json(cls, json_dict: Dict[str, Any]) -> AiAgentToolAction: + instance = cls() + instance.name = json_dict.get("name") or json_dict.get("Name") + instance.description = json_dict.get("description") or json_dict.get("Description") + instance.parameters_sample_object = json_dict.get("parametersSampleObject") or json_dict.get( + "ParametersSampleObject" + ) + instance.parameters_schema = json_dict.get("parametersSchema") or json_dict.get("ParametersSchema") + return instance + + +class AiAgentPersistenceConfiguration: + """ + Configuration for persisting chat history in RavenDB. + Defines where chat sessions should be stored and optionally how long they should be retained (expiration). + """ + + def __init__(self, conversation_id_prefix: str = None, expires: int = None): + self.conversation_id_prefix = conversation_id_prefix + self.conversation_expiration_in_sec: Optional[int] = expires + + def to_json(self) -> Dict[str, Any]: + return { + "ConversationIdPrefix": self.conversation_id_prefix, + "ConversationExpirationInSec": self.conversation_expiration_in_sec, + } + + @classmethod + def from_json(cls, json_dict: Dict[str, Any]) -> AiAgentPersistenceConfiguration: + instance = cls() + instance.conversation_id_prefix = json_dict.get("conversationIdPrefix") or json_dict.get("ConversationIdPrefix") + instance.conversation_expiration_in_sec = json_dict.get("conversationExpirationInSec") or json_dict.get( + "ConversationExpirationInSec" + ) + return instance + + +class AiAgentSummarizationByTokens: + """ + Configuration settings for AI agent conversation summarization. + """ + + DEFAULT_MAX_TOKENS_BEFORE_SUMMARIZATION = 32 * 1024 + + def __init__(self): + self.summarization_task_beginning_prompt: Optional[str] = None + self.summarization_task_end_prompt: Optional[str] = None + self.result_prefix: Optional[str] = None + self.max_tokens_before_summarization: int = self.DEFAULT_MAX_TOKENS_BEFORE_SUMMARIZATION + self.max_tokens_after_summarization: int = 1024 + + def to_json(self) -> Dict[str, Any]: + return { + "SummarizationTaskBeginningPrompt": self.summarization_task_beginning_prompt, + "SummarizationTaskEndPrompt": self.summarization_task_end_prompt, + "ResultPrefix": self.result_prefix, + "MaxTokensBeforeSummarization": self.max_tokens_before_summarization, + "MaxTokensAfterSummarization": self.max_tokens_after_summarization, + } + + @classmethod + def from_json(cls, json_dict: Dict[str, Any]) -> AiAgentSummarizationByTokens: + instance = cls() + instance.summarization_task_beginning_prompt = json_dict.get("SummarizationTaskBeginningPrompt") + instance.summarization_task_end_prompt = json_dict.get("SummarizationTaskEndPrompt") + instance.result_prefix = json_dict.get("ResultPrefix") + instance.max_tokens_before_summarization = json_dict.get( + "MaxTokensBeforeSummarization", cls.DEFAULT_MAX_TOKENS_BEFORE_SUMMARIZATION + ) + instance.max_tokens_after_summarization = json_dict.get("MaxTokensAfterSummarization", 1024) + return instance + + +class AiAgentTruncateChat: + """ + Configuration for truncating the AI chat history based on message count. + """ + + DEFAULT_MESSAGES_LENGTH_BEFORE_TRUNCATE = 500 + + def __init__(self): + self.messages_length_before_truncate: int = self.DEFAULT_MESSAGES_LENGTH_BEFORE_TRUNCATE + self.messages_length_after_truncate: int = self.DEFAULT_MESSAGES_LENGTH_BEFORE_TRUNCATE // 2 + + def to_json(self) -> Dict[str, Any]: + return { + "MessagesLengthBeforeTruncate": self.messages_length_before_truncate, + "MessagesLengthAfterTruncate": self.messages_length_after_truncate, + } + + @classmethod + def from_json(cls, json_dict: Dict[str, Any]) -> AiAgentTruncateChat: + instance = cls() + instance.messages_length_before_truncate = json_dict.get( + "MessagesLengthBeforeTruncate", cls.DEFAULT_MESSAGES_LENGTH_BEFORE_TRUNCATE + ) + instance.messages_length_after_truncate = json_dict.get( + "MessagesLengthAfterTruncate", cls.DEFAULT_MESSAGES_LENGTH_BEFORE_TRUNCATE // 2 + ) + return instance + + +class AiAgentHistoryConfiguration: + """ + Defines the configuration for retention and expiration of AI agent chat history documents. + """ + + def __init__(self): + self.history_expiration_in_sec: Optional[int] = None + + def to_json(self) -> Dict[str, Any]: + return { + "HistoryExpirationInSec": self.history_expiration_in_sec, + } + + @classmethod + def from_json(cls, json_dict: Dict[str, Any]) -> AiAgentHistoryConfiguration: + instance = cls() + instance.history_expiration_in_sec = json_dict.get("HistoryExpirationInSec") + return instance + + +class AiAgentChatTrimmingConfiguration: + """ + Defines configuration options for reducing the size of the AI agent's chat history. + """ + + def __init__( + self, + tokens_config: AiAgentSummarizationByTokens = None, + truncate_config: AiAgentTruncateChat = None, + history_config: AiAgentHistoryConfiguration = None, + ): + self.tokens: Optional[AiAgentSummarizationByTokens] = tokens_config + self.truncate: Optional[AiAgentTruncateChat] = truncate_config + self.history: Optional[AiAgentHistoryConfiguration] = history_config + + def to_json(self) -> Dict[str, Any]: + return { + "Tokens": self.tokens.to_json() if self.tokens else None, + "Truncate": self.truncate.to_json() if self.truncate else None, + "History": self.history.to_json() if self.history else None, + } + + @classmethod + def from_json(cls, json_dict: Dict[str, Any]) -> AiAgentChatTrimmingConfiguration: + instance = cls() + if json_dict.get("Tokens"): + instance.tokens = AiAgentSummarizationByTokens.from_json(json_dict["Tokens"]) + if json_dict.get("Truncate"): + instance.truncate = AiAgentTruncateChat.from_json(json_dict["Truncate"]) + if json_dict.get("History"): + instance.history = AiAgentHistoryConfiguration.from_json(json_dict["History"]) + return instance + + +class AiAgentConfiguration: + """ + Defines the configuration for an AI agent in RavenDB, including the system prompt, + tools (queries/actions), output schema, persistence settings, and connection string. + """ + + def __init__(self, name: str = None, connection_string_name: str = None, system_prompt: str = None): + self.identifier: Optional[str] = None + self.name = name + self.connection_string_name = connection_string_name + self.system_prompt = system_prompt + self.sample_object: Optional[str] = None + self.output_schema: Optional[str] = None + self.queries: List[AiAgentToolQuery] = [] + self.actions: List[AiAgentToolAction] = [] + self.persistence: Optional[AiAgentPersistenceConfiguration] = None + self.parameters: Set[str] = set() + self.chat_trimming: Optional[AiAgentChatTrimmingConfiguration] = None + self.max_model_iterations_per_call: Optional[int] = None + + def to_json(self) -> Dict[str, Any]: + # Convert parameters set to list of parameter objects using list comprehension + parameters_list = [{"Name": param_name, "Description": None} for param_name in self.parameters] + + return { + "Identifier": self.identifier, + "Name": self.name, + "ConnectionStringName": self.connection_string_name, + "SystemPrompt": self.system_prompt, + "SampleObject": self.sample_object, + "OutputSchema": self.output_schema, + "Queries": [q.to_json() for q in self.queries], + "Actions": [a.to_json() for a in self.actions], + "Persistence": self.persistence.to_json() if self.persistence else None, + "Parameters": parameters_list, + "ChatTrimming": self.chat_trimming.to_json() if self.chat_trimming else None, + "MaxModelIterationsPerCall": self.max_model_iterations_per_call, + } + + @classmethod + def from_json(cls, json_dict: Dict[str, Any]) -> AiAgentConfiguration: + instance = cls() + # Handle both camelCase and PascalCase for compatibility + instance.identifier = json_dict.get("identifier") or json_dict.get("Identifier") + instance.name = json_dict.get("name") or json_dict.get("Name") + instance.connection_string_name = json_dict.get("connectionStringName") or json_dict.get("ConnectionStringName") + instance.system_prompt = json_dict.get("systemPrompt") or json_dict.get("SystemPrompt") + instance.sample_object = json_dict.get("sampleObject") or json_dict.get("SampleObject") + instance.output_schema = json_dict.get("outputSchema") or json_dict.get("OutputSchema") + + queries_data = json_dict.get("queries") or json_dict.get("Queries") + if queries_data: + instance.queries = [AiAgentToolQuery.from_json(q) for q in queries_data] + + actions_data = json_dict.get("actions") or json_dict.get("Actions") + if actions_data: + instance.actions = [AiAgentToolAction.from_json(a) for a in actions_data] + + persistence_data = json_dict.get("persistence") or json_dict.get("Persistence") + if persistence_data: + instance.persistence = AiAgentPersistenceConfiguration.from_json(persistence_data) + + params_data = json_dict.get("parameters") or json_dict.get("Parameters") + if params_data: + # Handle both string list and object list formats + if params_data and isinstance(params_data[0], dict): + # New format: list of objects with name property + instance.parameters = set(param.get("name") or param.get("Name") for param in params_data) + else: + # Old format: list of strings + instance.parameters = set(params_data) + + trimming_data = json_dict.get("chatTrimming") or json_dict.get("ChatTrimming") + if trimming_data: + instance.chat_trimming = AiAgentChatTrimmingConfiguration.from_json(trimming_data) + + instance.max_model_iterations_per_call = json_dict.get("maxModelIterationsPerCall") or json_dict.get( + "MaxModelIterationsPerCall" + ) + return instance diff --git a/ravendb/documents/operations/ai/agents/delete_ai_agent_operation.py b/ravendb/documents/operations/ai/agents/delete_ai_agent_operation.py new file mode 100644 index 00000000..d242b121 --- /dev/null +++ b/ravendb/documents/operations/ai/agents/delete_ai_agent_operation.py @@ -0,0 +1,50 @@ +from __future__ import annotations +import json +from typing import Optional, Dict, Any +from urllib.parse import quote + +from ravendb.documents.operations.definitions import MaintenanceOperation +from ravendb.documents.conventions import DocumentConventions +from ravendb.http.raven_command import RavenCommand +from ravendb.http.server_node import ServerNode +import requests +from ravendb.documents.operations.ai.agents.add_or_update_ai_agent_operation import AiAgentConfigurationResult + + +class DeleteAiAgentOperation(MaintenanceOperation[AiAgentConfigurationResult]): + def __init__(self, identifier: str): + if not identifier or identifier.isspace(): + raise ValueError("identifier cannot be None or empty") + self._identifier = identifier + + def get_command(self, conventions: DocumentConventions) -> RavenCommand[AiAgentConfigurationResult]: + return DeleteAiAgentCommand(self._identifier) + + +class DeleteAiAgentCommand(RavenCommand[AiAgentConfigurationResult]): + def __init__(self, identifier: str): + super().__init__(AiAgentConfigurationResult) + self._identifier = identifier + + def is_read_request(self) -> bool: + return False + + def create_request(self, node: ServerNode) -> requests.Request: + url = f"{node.url}/databases/{node.database}/admin/ai/agent?agentId={quote(self._identifier)}" + + request = requests.Request("DELETE", url) + return request + + def set_response(self, response: str, from_cache: bool) -> None: + if response is None: + self.result = AiAgentConfigurationResult() + return + + response_json = json.loads(response) + self.result = AiAgentConfigurationResult.from_json(response_json) + + def get_raft_unique_request_id(self) -> str: + # Generate a unique ID for Raft operations + import uuid + + return str(uuid.uuid4()) diff --git a/ravendb/documents/operations/ai/agents/get_ai_agent_operation.py b/ravendb/documents/operations/ai/agents/get_ai_agent_operation.py new file mode 100644 index 00000000..a1a3cd04 --- /dev/null +++ b/ravendb/documents/operations/ai/agents/get_ai_agent_operation.py @@ -0,0 +1,63 @@ +from __future__ import annotations +import json +from typing import Optional, List, Dict, Any, TYPE_CHECKING +from urllib.parse import quote + +from ravendb.documents.operations.definitions import MaintenanceOperation +from ravendb.documents.conventions import DocumentConventions +from ravendb.http.raven_command import RavenCommand +from ravendb.http.server_node import ServerNode +import requests + +if TYPE_CHECKING: + from ravendb.documents.operations.ai.agents.ai_agent_configuration import AiAgentConfiguration + + +class GetAiAgentsResponse: + def __init__(self): + self.ai_agents: List[AiAgentConfiguration] = [] + + @classmethod + def from_json(cls, json_dict: Dict[str, Any]) -> GetAiAgentsResponse: + from ravendb.documents.operations.ai.agents.ai_agent_configuration import AiAgentConfiguration + + response = cls() + if json_dict.get("AiAgents"): + response.ai_agents = [AiAgentConfiguration.from_json(agent_json) for agent_json in json_dict["AiAgents"]] + return response + + +class GetAiAgentOperation(MaintenanceOperation[GetAiAgentsResponse]): + def __init__(self, agent_id: str = None): + if agent_id is not None and (not agent_id or agent_id.isspace()): + raise ValueError("agent_id cannot be empty") + self._agent_id = agent_id + + def get_command(self, conventions: DocumentConventions) -> RavenCommand[GetAiAgentsResponse]: + return GetAiAgentCommand(self._agent_id) + + +class GetAiAgentCommand(RavenCommand[GetAiAgentsResponse]): + def __init__(self, agent_id: str = None): + super().__init__(GetAiAgentsResponse) + self._agent_id = agent_id + + def is_read_request(self) -> bool: + return True + + def create_request(self, node: ServerNode) -> requests.Request: + url = f"{node.url}/databases/{node.database}/admin/ai/agent" + + if self._agent_id: + url += f"?agentId={quote(self._agent_id)}" + + request = requests.Request("GET", url) + return request + + def set_response(self, response: str, from_cache: bool) -> None: + if response is None: + self.result = GetAiAgentsResponse() + return + + response_json = json.loads(response) + self.result = GetAiAgentsResponse.from_json(response_json) diff --git a/ravendb/documents/operations/ai/agents/run_conversation_operation.py b/ravendb/documents/operations/ai/agents/run_conversation_operation.py new file mode 100644 index 00000000..4c83e3db --- /dev/null +++ b/ravendb/documents/operations/ai/agents/run_conversation_operation.py @@ -0,0 +1,289 @@ +from __future__ import annotations +import json +from dataclasses import dataclass +from typing import Optional, List, Dict, Any, TypeVar, Generic + +from ravendb.documents.operations.definitions import MaintenanceOperation +from ravendb.documents.conventions import DocumentConventions +from ravendb.http.raven_command import RavenCommand +from ravendb.http.server_node import ServerNode +import requests + +TSchema = TypeVar("TSchema") + + +@dataclass +class AiAgentActionRequest: + """Represents an action request from an AI agent.""" + + name: Optional[str] = None + tool_id: Optional[str] = None + arguments: Optional[str] = None + + @classmethod + def from_json(cls, json_dict: Dict[str, Any]) -> AiAgentActionRequest: + return cls(name=json_dict.get("Name"), tool_id=json_dict.get("ToolId"), arguments=json_dict.get("Arguments")) + + def to_json(self) -> Dict[str, Any]: + return { + "Name": self.name, + "ToolId": self.tool_id, + "Arguments": self.arguments, + } + + +@dataclass +class AiAgentActionResponse: + """Represents a response to an AI agent action request.""" + + tool_id: Optional[str] = None + content: Optional[str] = None + + @classmethod + def from_json(cls, json_dict: Dict[str, Any]) -> AiAgentActionResponse: + return cls(tool_id=json_dict.get("ToolId"), content=json_dict.get("Content")) + + def to_json(self) -> Dict[str, Any]: + return { + "ToolId": self.tool_id, + "Content": self.content, + } + + +@dataclass +class AiUsage: + """Represents AI token usage statistics.""" + + prompt_tokens: int = 0 + completion_tokens: int = 0 + total_tokens: int = 0 + cached_tokens: int = 0 + + @classmethod + def from_json(cls, json_dict: Dict[str, Any]) -> AiUsage: + return cls( + prompt_tokens=json_dict.get("PromptTokens", 0), + completion_tokens=json_dict.get("CompletionTokens", 0), + total_tokens=json_dict.get("TotalTokens", 0), + cached_tokens=json_dict.get("CachedTokens", 0), + ) + + def to_json(self) -> Dict[str, Any]: + return { + "PromptTokens": self.prompt_tokens, + "CompletionTokens": self.completion_tokens, + "TotalTokens": self.total_tokens, + "CachedTokens": self.cached_tokens, + } + + +class ConversationResult(Generic[TSchema]): + def __init__(self): + self.conversation_id: Optional[str] = None + self.change_vector: Optional[str] = None + self.response: Optional[TSchema] = None + self.usage: Optional[AiUsage] = None + self.action_requests: List[AiAgentActionRequest] = [] + + @classmethod + def from_json(cls, json_dict: Dict[str, Any]) -> ConversationResult: + result = cls() + result.conversation_id = json_dict.get("ConversationId") + result.change_vector = json_dict.get("ChangeVector") + result.response = json_dict.get("Response") + + if json_dict.get("Usage"): + result.usage = AiUsage.from_json(json_dict["Usage"]) + + if json_dict.get("ActionRequests"): + result.action_requests = [AiAgentActionRequest.from_json(req) for req in json_dict["ActionRequests"]] + + return result + + +class AiConversationCreationOptions: + """ + Options for creating AI agent conversations, including parameters and expiration settings. + """ + + def __init__(self): + self.expiration_in_sec: Optional[int] = None + self.parameters: Optional[Dict[str, Any]] = None + + def to_json(self) -> Dict[str, Any]: + """ + Converts the creation options to a JSON-serializable dictionary. + + Returns: + Dictionary representation of the creation options + """ + return {"ExpirationInSec": self.expiration_in_sec, "Parameters": self.parameters} + + +class ConversationRequestBody: + """ + Request body for AI agent conversation operations, containing user prompts, + action responses, and creation options. + """ + + def __init__(self): + self.action_responses: Optional[List[AiAgentActionResponse]] = None + self.user_prompt: Optional[str] = None + self.creation_options: Optional[AiConversationCreationOptions] = None + + def to_json(self) -> Dict[str, Any]: + """ + Converts the request body to a JSON-serializable dictionary. + + Returns: + Dictionary representation of the request body + """ + # Build dictionary with only non-None values + result = {} + + if self.action_responses is not None: + result["ActionResponses"] = [resp.to_json() for resp in self.action_responses] + + if self.user_prompt is not None: + result["UserPrompt"] = self.user_prompt + + if self.creation_options is not None: + result["CreationOptions"] = self.creation_options.to_json() + + return result + + +class RunConversationOperation(MaintenanceOperation[ConversationResult[TSchema]]): + def __init__( + self, + agent_id_or_conversation_id: str, + user_prompt: str = None, + parameters_or_action_responses: Any = None, + change_vector: str = None, + ): + # Reset all fields first + self._conversation_id = None + self._agent_id = None + self._user_prompt = None + self._parameters = None + self._action_responses = None + self._change_vector = None + + if change_vector is not None or isinstance(parameters_or_action_responses, list): + # Constructor overload: conversationId-based + if not agent_id_or_conversation_id or agent_id_or_conversation_id.isspace(): + raise ValueError("conversation_id cannot be None or empty") + + self._conversation_id = agent_id_or_conversation_id + self._user_prompt = user_prompt + self._action_responses = ( + parameters_or_action_responses if isinstance(parameters_or_action_responses, list) else None + ) + self._change_vector = change_vector + else: + # Constructor overload: agentId-based + if not agent_id_or_conversation_id or agent_id_or_conversation_id.isspace(): + raise ValueError("agent_id cannot be None or empty") + if user_prompt is not None and (not user_prompt or user_prompt.isspace()): + raise ValueError("user_prompt cannot be empty") + + self._agent_id = agent_id_or_conversation_id + self._user_prompt = user_prompt + self._parameters = ( + parameters_or_action_responses if isinstance(parameters_or_action_responses, dict) else None + ) + + def get_command(self, conventions: DocumentConventions) -> RavenCommand[ConversationResult[TSchema]]: + return RunConversationCommand( + conversation_id=self._conversation_id, + agent_id=self._agent_id, + prompt=self._user_prompt, + parameters=self._parameters, + action_responses=self._action_responses, + change_vector=self._change_vector, + conventions=conventions, + ) + + +class RunConversationCommand(RavenCommand[ConversationResult[TSchema]]): + def __init__( + self, + conversation_id: str = None, + agent_id: str = None, + prompt: str = None, + parameters: Dict[str, Any] = None, + action_responses: List[AiAgentActionResponse] = None, + change_vector: str = None, + conventions: DocumentConventions = None, + ): + super().__init__(ConversationResult) + self._conversation_id = conversation_id + self._agent_id = agent_id + self._prompt = prompt + self._parameters = parameters + self._action_responses = action_responses + self._change_vector = change_vector + self._conventions = conventions + + def is_read_request(self) -> bool: + return False + + def create_request(self, node: ServerNode) -> requests.Request: + url = f"{node.url}/databases/{node.database}/ai/agent" + + # Add query parameters - server requires BOTH agentId and conversationId + query_params = [] + from urllib.parse import quote + + if self._conversation_id and self._agent_id: + # Continuing conversation - we have both + query_params.append(f"conversationId={quote(self._conversation_id)}") + query_params.append(f"agentId={quote(self._agent_id)}") + elif self._conversation_id: + # We only have conversation ID - this might fail, but let's try + query_params.append(f"conversationId={quote(self._conversation_id)}") + elif self._agent_id: + # New conversation - use conversation prefix as per RavenDB documentation + # The server will generate the full conversation ID from the prefix + conversation_prefix = "conversations/" + query_params.append(f"conversationId={quote(conversation_prefix)}") + query_params.append(f"agentId={quote(self._agent_id)}") + + if query_params: + url += "?" + "&".join(query_params) + + # Build request body with correct structure to match .NET client + request_body = ConversationRequestBody() + request_body.action_responses = self._action_responses + request_body.user_prompt = self._prompt + + # Always include CreationOptions to match .NET client structure + creation_options = AiConversationCreationOptions() + creation_options.parameters = self._parameters + request_body.creation_options = creation_options + + body = json.dumps(request_body.to_json()) + + # Create request + request = requests.Request("POST", url) + request.headers = {"Content-Type": "application/json"} + + if self._change_vector: + request.headers["If-Match"] = self._change_vector + + request.data = body + return request + + def set_response(self, response: str, from_cache: bool) -> None: + if response is None: + self.result = ConversationResult() + return + + response_json = json.loads(response) + self.result = ConversationResult.from_json(response_json) + + def get_raft_unique_request_id(self) -> str: + # Generate a unique ID for Raft operations + import uuid + + return str(uuid.uuid4()) diff --git a/ravendb/documents/store/definition.py b/ravendb/documents/store/definition.py index 7e836c95..d1bbd7e8 100644 --- a/ravendb/documents/store/definition.py +++ b/ravendb/documents/store/definition.py @@ -324,6 +324,7 @@ def __init__(self, urls: Union[str, List[str]] = None, database: Optional[str] = self.__after_close: List[Callable[[], None]] = [] self.__before_close: List[Callable[[], None]] = [] self.__time_series_operation: Optional[TimeSeriesOperations] = None + self.__ai_operations = None def __enter__(self): return self @@ -560,3 +561,12 @@ def time_series(self) -> TimeSeriesOperations: self.__time_series_operation = TimeSeriesOperations(self) return self.__time_series_operation + + @property + def ai(self): + if self.__ai_operations is None: + from ravendb.documents.ai import AiOperations + + self.__ai_operations = AiOperations(self) + + return self.__ai_operations diff --git a/ravendb/http/request_executor.py b/ravendb/http/request_executor.py index 744b312e..6f1717fd 100644 --- a/ravendb/http/request_executor.py +++ b/ravendb/http/request_executor.py @@ -48,7 +48,7 @@ class RequestExecutor: __INITIAL_TOPOLOGY_ETAG = -2 __GLOBAL_APPLICATION_IDENTIFIER = uuid.uuid4() - CLIENT_VERSION = "7.0.2" + CLIENT_VERSION = "7.1.0" logger = logging.getLogger("request_executor") # todo: initializer should take also cryptography certificates diff --git a/ravendb/tests/ai_agent_tests/__init__.py b/ravendb/tests/ai_agent_tests/__init__.py new file mode 100644 index 00000000..dd69bc72 --- /dev/null +++ b/ravendb/tests/ai_agent_tests/__init__.py @@ -0,0 +1 @@ +# AI Agent Tests Module diff --git a/setup.py b/setup.py index 58040024..be89dd25 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name="ravendb", packages=find_packages(exclude=["*.tests.*", "tests", "*.tests", "tests.*"]), - version="7.0.2", + version="7.1.0", long_description_content_type="text/markdown", long_description=open("README_pypi.md").read(), description="Python client for RavenDB NoSQL Database",