22
33import json
44import traceback
5- from typing import List , Dict , Any , Optional , TypeVar , TYPE_CHECKING , Callable
5+ from typing import List , Dict , Any , IO , Optional , TypeVar , TYPE_CHECKING , Callable , Union
66from datetime import timedelta
77
88from ravendb .documents .ai .ai_answer import AiAnswer , AiConversationStatus
@@ -33,14 +33,7 @@ def __init__(self, sender: AiConversation, action: AiAgentActionRequest):
3333
3434
3535class AiConversation :
36- """
37- Implementation of AI conversation operations for managing conversations with AI agents.
38-
39- Can be used as a context manager for automatic cleanup:
40- with store.ai.conversation(agent_id) as conversation:
41- conversation.set_user_prompt("Hello!")
42- result = conversation.run()
43- """
36+ # Usable as a context manager: `with store.ai.conversation(agent_id) as c:`.
4437
4538 def __init__ (
4639 self ,
@@ -60,35 +53,43 @@ def __init__(
6053 self ._action_responses : Dict [str , AiAgentActionResponse ] = {}
6154 self ._artificial_actions : List [AiAgentArtificialActionResponse ] = []
6255 self ._action_requests : Optional [List [AiAgentActionRequest ]] = None
56+ self ._attachments_commands : List = []
6357
64- # Action handlers
6558 self ._invocations : Dict [str , Callable [[AiAgentActionRequest ], None ]] = {}
66-
6759 self .on_unhandled_action : Optional [Callable [[UnhandledActionEventArgs ], None ]] = None
6860
61+ def add_attachment (self , name : str , stream : Union [bytes , IO [bytes ]], content_type : str ) -> None :
62+ # `stream` is raw bytes or any binary file-like; each stream may only
63+ # be used once per turn (SingleNodeBatchCommand enforces uniqueness).
64+ if stream is None :
65+ raise ValueError ("stream cannot be None" )
66+ from ravendb .documents .commands .batches import PutAttachmentCommandData
67+
68+ self ._attachments_commands .append (
69+ PutAttachmentCommandData ("__this__" , name , stream , content_type , change_vector = None )
70+ )
71+
72+ def copy_attachment_from (self , source_document_id : str , file_name : str ) -> None :
73+ if not source_document_id or (isinstance (source_document_id , str ) and source_document_id .isspace ()):
74+ raise ValueError ("source_document_id cannot be None or empty" )
75+ if not file_name or (isinstance (file_name , str ) and file_name .isspace ()):
76+ raise ValueError ("file_name cannot be None or empty" )
77+ from ravendb .documents .commands .batches import CopyAttachmentCommandData
78+
79+ self ._attachments_commands .append (
80+ CopyAttachmentCommandData (source_document_id , file_name , "__this__" , file_name , change_vector = None )
81+ )
82+
6983 def __enter__ (self ) -> AiConversation :
70- """Context manager entry."""
7184 return self
7285
7386 def __exit__ (self , exc_type , exc_val , exc_tb ) -> None :
74- """Context manager exit - cleanup resources."""
7587 pass
7688
7789 @classmethod
7890 def with_conversation_id (
7991 cls , store : DocumentStore , conversation_id : str , change_vector : str = None
8092 ) -> AiConversation :
81- """
82- Creates a conversation instance for continuing an existing conversation.
83-
84- Args:
85- store: The document store
86- conversation_id: The ID of the existing conversation
87- change_vector: Optional change vector for optimistic concurrency
88-
89- Returns:
90- A new conversation instance
91- """
9293 return cls (
9394 store = store ,
9495 conversation_id = conversation_id ,
@@ -97,28 +98,11 @@ def with_conversation_id(
9798
9899 @property
99100 def required_actions (self ) -> List [AiAgentActionRequest ]:
100- """
101- Gets the list of action requests that need to be fulfilled before
102- the conversation can continue.
103-
104- Raises:
105- RuntimeError: If run() hasn't been called yet
106- """
107101 if self ._action_requests is None :
108102 raise RuntimeError ("You have to call run() first" )
109103 return self ._action_requests
110104
111105 def add_action_response (self , action_id : str , action_response : str ) -> None :
112- """
113- Adds a response for a given action request.
114-
115- Args:
116- action_id: The ID of the action to respond to
117- action_response: The response content
118-
119- Raises:
120- InvalidOperationException: If a response for the given tool-id was already added
121- """
122106 from ravendb .documents .operations .ai .agents import AiAgentActionResponse
123107
124108 if action_id in self ._action_responses :
@@ -136,16 +120,8 @@ def add_action_response(self, action_id: str, action_response: str) -> None:
136120 self ._action_responses [action_id ] = response
137121
138122 def add_artificial_action_with_response (self , tool_id : str , action_response ) -> None :
139- """
140- Injects an artificial action (tool call) and a response into the model's conversation context.
141- This is an advanced mechanism to programmatically prompt the agent, causing it to "believe"
142- it successfully executed a tool and received the specified action_response.
143-
144- Args:
145- tool_id: The name of the tool to simulate the agent called.
146- action_response: The response to supply to the agent as the result of the simulated action.
147- Can be a string or any object that will be serialized to JSON.
148- """
123+ # Injects a synthetic tool-call + response so the agent "believes" it
124+ # already executed `tool_id` and got `action_response` back.
149125 if not tool_id or (isinstance (tool_id , str ) and tool_id .isspace ()):
150126 raise ValueError ("tool_id cannot be None or empty" )
151127 if action_response is None :
@@ -159,22 +135,12 @@ def add_artificial_action_with_response(self, tool_id: str, action_response) ->
159135 self ._artificial_actions .append (AiAgentArtificialActionResponse (tool_id = tool_id , content = content ))
160136
161137 def run (self ) -> AiAnswer :
162- """
163- Executes the conversation loop, automatically handling action requests
164- until the conversation is complete or no handlers are available.
165-
166- Returns:
167- AiAnswer with the final response, status, usage, and elapsed time
168- """
169138 while True :
170139 r = self ._run_internal ()
171140 if self ._handle_server_reply (r ):
172141 return r
173142
174143 def stream (self , stream_property_path : str = None , on_chunk : Optional [Callable [[str ], None ]] = None ) -> AiAnswer :
175- """
176- Stream the LLM response for the given property and return the final AiAnswer when done.
177- """
178144 while True :
179145 r = self ._run_internal (stream_property_path = stream_property_path , streamed_chunks_callback = on_chunk )
180146 if self ._handle_server_reply (r ):
@@ -185,21 +151,16 @@ def _run_internal(
185151 stream_property_path : Optional [str ] = None ,
186152 streamed_chunks_callback : Optional [Callable [[str ], None ]] = None ,
187153 ) -> AiAnswer :
188- """
189- Internal method that executes a single server call.
190-
191- Returns:
192- AiAnswer from this single turn
193- """
194154 from ravendb .documents .operations .ai .agents import RunConversationOperation
195155 import time
196156
197- # If we already went to the server and have nothing new to tell it, we're done
157+ # Already round-tripped and nothing new to send.
198158 if (
199159 self ._action_requests is not None
200160 and len (self ._prompt_parts ) == 0
201161 and len (self ._action_responses ) == 0
202162 and len (self ._artificial_actions ) == 0
163+ and len (self ._attachments_commands ) == 0
203164 ):
204165 return AiAnswer (
205166 answer = None ,
@@ -208,40 +169,35 @@ def _run_internal(
208169 elapsed = None ,
209170 )
210171
211- # Build the operation
212172 if not self ._agent_id :
213173 raise ValueError ("Agent ID is required" )
214174
215- # If we don't have a conversation ID yet, generate one with the prefix
216- # The server will complete it with a unique ID
175+ # Trailing "/" tells the server to assign a unique id.
217176 if not self ._conversation_id :
218177 self ._conversation_id = "conversations/"
219178
220- # Create operation with all required parameters
221179 operation = RunConversationOperation (
222180 agent_id = self ._agent_id ,
223181 conversation_id = self ._conversation_id ,
224- prompt_parts = self ._prompt_parts , # Always send list, even if empty
225- action_responses = list (self ._action_responses .values ()), # Always send list, even if empty
226- artificial_actions = self ._artificial_actions , # Always send list, even if empty
182+ prompt_parts = self ._prompt_parts ,
183+ action_responses = list (self ._action_responses .values ()),
184+ artificial_actions = self ._artificial_actions ,
227185 options = self ._options ,
228186 change_vector = self ._change_vector ,
229187 stream_property_path = stream_property_path ,
230188 streamed_chunks_callback = streamed_chunks_callback ,
189+ attachments_commands = self ._attachments_commands ,
231190 )
232191
233192 try :
234- # Track elapsed time
235193 start_time = time .time ()
236194 result = self ._store .maintenance .send (operation )
237195 elapsed = timedelta (seconds = time .time () - start_time )
238196
239- # Update conversation state
240197 self ._change_vector = result .change_vector
241198 self ._conversation_id = result .conversation_id
242199 self ._action_requests = result .action_requests or []
243200
244- # Build AiAnswer
245201 return AiAnswer (
246202 answer = result .response ,
247203 status = (
@@ -252,25 +208,14 @@ def _run_internal(
252208 usage = result .usage ,
253209 elapsed = elapsed ,
254210 )
255- # except ConcurrencyException as e:
256- # self._change_vector = e.actual_change_vector
257- # raise
258211 finally :
259- # Clear the user prompt and tool responses after running the conversation
260212 self ._prompt_parts .clear ()
261213 self ._action_responses .clear ()
262214 self ._artificial_actions .clear ()
215+ self ._attachments_commands .clear ()
263216
264217 def _handle_server_reply (self , answer : AiAnswer ) -> bool :
265- """
266- Handles the server reply by invoking registered action handlers.
267-
268- Args:
269- answer: The answer from the server
270-
271- Returns:
272- True if the conversation is done, False if it should continue
273- """
218+ # Returns True when the conversation is done.
274219 if answer .status == AiConversationStatus .DONE :
275220 return True
276221
@@ -279,52 +224,28 @@ def _handle_server_reply(self, answer: AiAnswer) -> bool:
279224 f"There are no action requests to process, but Status was { answer .status } , should not be possible."
280225 )
281226
282- # Process each action request
283227 for action in self ._action_requests :
284228 if action .name in self ._invocations :
285- # Invoke the registered handler
286- # Error handling is done by the invocation based on the error strategy
287229 self ._invocations [action .name ](action )
288230 elif self .on_unhandled_action is not None :
289231 self .on_unhandled_action (UnhandledActionEventArgs (self , action ))
290232 else :
291- # No handler registered for this action
292233 raise RuntimeError (
293234 f"There is no action defined for action '{ action .name } ' on agent '{ self ._agent_id } ' "
294235 f"({ self ._conversation_id } ), but it was invoked by the model with: { action .arguments } . "
295236 f"Did you forget to call { self .receive .__name__ } () or { self .handle .__name__ } ()? You can also handle unexpected action invocations using the 'on_unhandled_action' event."
296237 )
297238
298- # If we have nothing to tell the server (no action responses), we're done
299- # Otherwise, continue the loop to send the responses
239+ # No responses to deliver => nothing more to tell the server.
300240 return len (self ._action_responses ) == 0
301241
302242 def set_user_prompt (self , user_prompt : str ) -> None :
303- """
304- Sets the user prompt to send to the AI agent.
305- Clears any existing prompt parts and adds the new prompt.
306-
307- Args:
308- user_prompt: The prompt text to send to the agent
309-
310- Raises:
311- ValueError: If user_prompt is empty or whitespace-only
312- """
313243 if not user_prompt or user_prompt .isspace ():
314244 raise ValueError ("User prompt cannot be empty or whitespace-only" )
315245 self ._prompt_parts .clear ()
316246 self .add_user_prompt (user_prompt )
317247
318248 def add_user_prompt (self , * prompts : str ) -> None :
319- """
320- Adds one or more user prompts to the conversation.
321-
322- Args:
323- *prompts: One or more prompt strings to add
324-
325- Raises:
326- ValueError: If any prompt is empty or whitespace-only
327- """
328249 for prompt in prompts :
329250 if not prompt or prompt .isspace ():
330251 raise ValueError ("User prompt cannot be empty or whitespace-only" )
0 commit comments