33import json
44import traceback
55from typing import List , Dict , Any , Optional , TypeVar , TYPE_CHECKING , Callable
6+ from datetime import timedelta
67
7- from ravendb .documents .ai .ai_conversation_result import AiConversationResult
8+ from ravendb .documents .ai .ai_answer import AiAnswer , AiConversationStatus
9+ from ravendb .documents .operations .ai .agents import (
10+ AiAgentActionRequest ,
11+ AiAgentActionResponse ,
12+ AiConversationCreationOptions ,
13+ )
814
915if TYPE_CHECKING :
1016 from ravendb .documents .store .definition import DocumentStore
11- from ravendb .documents .operations .ai .agents import (
12- AiAgentActionRequest ,
13- AiAgentActionResponse ,
14- ConversationResult ,
15- )
1617
1718TResponse = TypeVar ("TResponse" )
1819
@@ -32,24 +33,26 @@ class AiConversation:
3233 result = conversation.run()
3334 """
3435
35- _invocations : Dict [str , Callable [[AiAgentActionRequest ], None ]] = {}
36-
3736 def __init__ (
3837 self ,
3938 store : DocumentStore ,
4039 agent_id : str = None ,
41- parameters : Dict [ str , Any ] = None ,
40+ options : AiConversationCreationOptions = None ,
4241 conversation_id : str = None ,
4342 change_vector : str = None ,
4443 ):
4544 self ._store = store
4645 self ._agent_id = agent_id
47- self ._parameters = parameters or {}
46+ self ._options = options or AiConversationCreationOptions ()
4847 self ._conversation_id = conversation_id
4948 self ._change_vector = change_vector
50- self ._user_prompt : Optional [str ] = None
49+
50+ self ._prompt_parts : List [str ] = []
5151 self ._action_responses : List [AiAgentActionResponse ] = []
52- self ._last_result : Optional [ConversationResult ] = None
52+ self ._action_requests : Optional [List [AiAgentActionRequest ]] = None
53+
54+ # Action handlers
55+ self ._invocations : Dict [str , Callable [[AiAgentActionRequest ], None ]] = {}
5356
5457 def __enter__ (self ) -> AiConversation :
5558 """Context manager entry."""
@@ -85,10 +88,13 @@ def required_actions(self) -> List[AiAgentActionRequest]:
8588 """
8689 Gets the list of action requests that need to be fulfilled before
8790 the conversation can continue.
91+
92+ Raises:
93+ RuntimeError: If run() hasn't been called yet
8894 """
89- if self ._last_result and self . _last_result . action_requests :
90- return self . _last_result . action_requests
91- return []
95+ if self ._action_requests is None :
96+ raise RuntimeError ( "You have to call run() first" )
97+ return self . _action_requests
9298
9399 def add_action_response (self , action_id : str , action_response : str ) -> None :
94100 """
@@ -107,69 +113,133 @@ def add_action_response(self, action_id: str, action_response: str) -> None:
107113
108114 self ._action_responses .append (response )
109115
110- def run (self ) -> AiConversationResult :
116+ def run (self , answer_type : type = dict ) -> AiAnswer :
117+ """
118+ Executes the conversation loop, automatically handling action requests
119+ until the conversation is complete or no handlers are available.
120+
121+ Args:
122+ answer_type: The expected type of the answer (default: dict)
123+
124+ Returns:
125+ AiAnswer with the final response, status, usage, and elapsed time
111126 """
112- Executes one "turn" of the conversation:
113- sends the current prompt or replies to any required actions,
114- and awaits the agent's reply.
127+ while True :
128+ r = self ._run_internal (answer_type )
129+ if self ._handle_server_reply (r ):
130+ return r
131+
132+ def _run_internal (self , answer_type : type = dict ) -> AiAnswer :
115133 """
116- from ravendb . documents . operations . ai . agents import RunConversationOperation
134+ Internal method that executes a single server call.
117135
118- if self ._conversation_id :
119- # Continue existing conversation
120- if not self ._agent_id :
121- raise ValueError ("Agent ID is required for conversation continuation" )
136+ Args:
137+ answer_type: The expected type of the answer
122138
123- operation = RunConversationOperation (
124- self . _conversation_id ,
125- self . _user_prompt ,
126- self . _action_responses ,
127- self . _change_vector ,
128- )
129- # Set agent ID for conversation continuation
130- operation . _agent_id = self . _agent_id
131- else :
132- # Start new conversation
133- if not self ._agent_id :
134- raise ValueError ( "Agent ID is required for new conversations" )
135-
136- operation = RunConversationOperation (
137- self . _agent_id ,
138- self . _user_prompt ,
139- self . _parameters ,
139+ Returns:
140+ AiAnswer from this single turn
141+ """
142+ from ravendb . documents . operations . ai . agents import RunConversationOperation
143+ import time
144+
145+ # If we already went to the server and have nothing new to tell it, we're done
146+ if (
147+ self . _action_requests is not None
148+ and len ( self . _prompt_parts ) == 0
149+ and len ( self ._action_responses ) == 0
150+ ):
151+ return AiAnswer (
152+ answer = None ,
153+ status = AiConversationStatus . DONE ,
154+ usage = None ,
155+ elapsed = None ,
140156 )
141157
142- # Execute the operation
143- result = self ._store .maintenance .send (operation )
144- self ._last_result = result
158+ # Build the operation
159+ if not self ._agent_id :
160+ raise ValueError ("Agent ID is required" )
161+
162+ # If we don't have a conversation ID yet, generate one with the prefix
163+ # The server will complete it with a unique ID
164+ if not self ._conversation_id :
165+ self ._conversation_id = "conversations/"
166+
167+ # Create operation with all required parameters
168+ operation = RunConversationOperation (
169+ agent_id = self ._agent_id ,
170+ conversation_id = self ._conversation_id ,
171+ prompt_parts = self ._prompt_parts , # Always send list, even if empty
172+ action_responses = self ._action_responses , # Always send list, even if empty
173+ options = self ._options ,
174+ change_vector = self ._change_vector ,
175+ )
145176
146- # Update conversation state for future calls
147- if result .conversation_id :
148- self ._conversation_id = result .conversation_id
149- if result .change_vector :
177+ try :
178+ # Track elapsed time
179+ start_time = time .time ()
180+ result = self ._store .maintenance .send (operation )
181+ elapsed = timedelta (seconds = time .time () - start_time )
182+
183+ # Update conversation state
150184 self ._change_vector = result .change_vector
185+ self ._conversation_id = result .conversation_id
186+ self ._action_requests = result .action_requests or []
187+
188+ # Build AiAnswer
189+ return AiAnswer (
190+ answer = result .response ,
191+ status = AiConversationStatus .ACTION_REQUIRED if len (self ._action_requests ) > 0 else AiConversationStatus .DONE ,
192+ usage = result .usage ,
193+ elapsed = elapsed ,
194+ )
195+ # except ConcurrencyException as e:
196+ # self._change_vector = e.actual_change_vector
197+ # raise
198+ finally :
199+ # Clear the user prompt and tool responses after running the conversation
200+ self ._prompt_parts .clear ()
201+ self ._action_responses .clear ()
202+
203+ def _handle_server_reply (self , answer : AiAnswer ) -> bool :
204+ """
205+ Handles the server reply by invoking registered action handlers.
151206
152- # Preserve agent ID for future conversation turns
153- if not self ._agent_id and hasattr (operation , "_agent_id" ):
154- self ._agent_id = operation ._agent_id
207+ Args:
208+ answer: The answer from the server
155209
156- # Clear processed data for next turn
157- self ._user_prompt = None
158- self ._action_responses .clear ()
210+ Returns:
211+ True if the conversation is done, False if it should continue
212+ """
213+ if answer .status == AiConversationStatus .DONE :
214+ return True
159215
160- # Convert to AiConversationResult
161- conversation_result = AiConversationResult ()
162- conversation_result .conversation_id = result .conversation_id
163- conversation_result .change_vector = result .change_vector
164- conversation_result .response = result .response
165- conversation_result .usage = result .usage
166- conversation_result .action_requests = result .action_requests or []
216+ if len (self ._action_requests ) == 0 :
217+ raise RuntimeError (
218+ f"There are no action requests to process, but Status was { answer .status } , should not be possible."
219+ )
167220
168- return conversation_result
221+ # Process each action request
222+ for action in self ._action_requests :
223+ if action .name in self ._invocations :
224+ # Invoke the registered handler
225+ # Error handling is done by the invocation based on the error strategy
226+ self ._invocations [action .name ](action )
227+ else :
228+ # No handler registered for this action
229+ raise RuntimeError (
230+ f"There is no action defined for action '{ action .name } ' on agent '{ self ._agent_id } ' "
231+ f"({ self ._conversation_id } ), but it was invoked by the model with: { action .arguments } . "
232+ f"Did you forget to call receive() or handle()?"
233+ )
234+
235+ # If we have nothing to tell the server (no action responses), we're done
236+ # Otherwise, continue the loop to send the responses
237+ return len (self ._action_responses ) == 0
169238
170239 def set_user_prompt (self , user_prompt : str ) -> None :
171240 """
172- Sets the next user prompt to send to the AI agent.
241+ Sets the user prompt to send to the AI agent.
242+ Clears any existing prompt parts and adds the new prompt.
173243
174244 Args:
175245 user_prompt: The prompt text to send to the agent
@@ -179,7 +249,23 @@ def set_user_prompt(self, user_prompt: str) -> None:
179249 """
180250 if not user_prompt or user_prompt .isspace ():
181251 raise ValueError ("User prompt cannot be empty or whitespace-only" )
182- self ._user_prompt = user_prompt
252+ self ._prompt_parts .clear ()
253+ self ._prompt_parts .append (user_prompt )
254+
255+ def add_user_prompt (self , * prompts : str ) -> None :
256+ """
257+ Adds one or more user prompts to the conversation.
258+
259+ Args:
260+ *prompts: One or more prompt strings to add
261+
262+ Raises:
263+ ValueError: If any prompt is empty or whitespace-only
264+ """
265+ for prompt in prompts :
266+ if not prompt or prompt .isspace ():
267+ raise ValueError ("User prompt cannot be empty or whitespace-only" )
268+ self ._prompt_parts .append (prompt )
183269
184270 def handle (
185271 self ,
0 commit comments