Skip to content

Commit 154a07b

Browse files
committed
RDBC-934 Sync-up Python AI Agents .run() implementation with C#
1 parent cdf954b commit 154a07b

File tree

7 files changed

+374
-184
lines changed

7 files changed

+374
-184
lines changed

ravendb/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
AiAgentActionRequest,
101101
AiAgentActionResponse,
102102
AiUsage,
103+
AiConversationCreationOptions,
103104
GetAiAgentOperation,
104105
GetAiAgentsResponse,
105106
AddOrUpdateAiAgentOperation,

ravendb/documents/ai/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22
from .ai_conversation import AiConversation
33
from .ai_conversation_result import AiConversationResult
44
from .ai_agent_parameters_builder import AiAgentParametersBuilder, IAiAgentParametersBuilder
5+
from .ai_answer import AiAnswer, AiConversationStatus
56

67
__all__ = [
78
"AiOperations",
89
"AiConversation",
910
"AiConversationResult",
1011
"AiAgentParametersBuilder",
1112
"IAiAgentParametersBuilder",
13+
"AiAnswer",
14+
"AiConversationStatus",
1215
]

ravendb/documents/ai/ai_answer.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from __future__ import annotations
2+
from typing import Optional, TypeVar, Generic, TYPE_CHECKING
3+
from datetime import timedelta
4+
import enum
5+
6+
if TYPE_CHECKING:
7+
from ravendb.documents.operations.ai.agents import AiUsage
8+
9+
TAnswer = TypeVar("TAnswer")
10+
11+
12+
class AiConversationStatus(enum.Enum):
13+
"""
14+
Represents the status of an AI conversation.
15+
"""
16+
17+
DONE = "Done"
18+
ACTION_REQUIRED = "ActionRequired"
19+
20+
def __str__(self):
21+
return self.value
22+
23+
24+
class AiAnswer(Generic[TAnswer]):
25+
"""
26+
Represents the answer from an AI conversation turn.
27+
28+
This class contains the AI's response, the conversation status,
29+
token usage statistics, and timing information.
30+
"""
31+
32+
def __init__(
33+
self,
34+
answer: Optional[TAnswer] = None,
35+
status: AiConversationStatus = AiConversationStatus.DONE,
36+
usage: Optional[AiUsage] = None,
37+
elapsed: Optional[timedelta] = None,
38+
):
39+
"""
40+
Initialize an AiAnswer instance.
41+
42+
Args:
43+
answer: The answer content produced by the AI
44+
status: The status of the conversation (Done or ActionRequired)
45+
usage: Token usage reported by the model
46+
elapsed: The total time elapsed to produce the answer
47+
"""
48+
self.answer = answer
49+
self.status = status
50+
self.usage = usage
51+
self.elapsed = elapsed
52+
53+
def __str__(self) -> str:
54+
"""String representation for debugging."""
55+
return (
56+
f"AiAnswer(status={self.status.value}, "
57+
f"has_answer={self.answer is not None}, "
58+
f"elapsed={self.elapsed})"
59+
)
60+
61+
def __repr__(self) -> str:
62+
"""Detailed representation for debugging."""
63+
return (
64+
f"AiAnswer(answer={self.answer!r}, "
65+
f"status={self.status!r}, "
66+
f"usage={self.usage!r}, "
67+
f"elapsed={self.elapsed!r})"
68+
)
69+

ravendb/documents/ai/ai_conversation.py

Lines changed: 150 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,17 @@
33
import json
44
import traceback
55
from 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

915
if 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

1718
TResponse = 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,

ravendb/documents/ai/ai_conversation_result.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,19 @@ class AiConversationResult(Generic[TResponse]):
1313
usage statistics, and any action requests that need to be fulfilled.
1414
"""
1515

16-
def __init__(self):
17-
self.conversation_id: Optional[str] = None
18-
self.change_vector: Optional[str] = None
19-
self.response: Optional[TResponse] = None
20-
self.usage: Optional[AiUsage] = None
21-
self.action_requests: List[AiAgentActionRequest] = []
16+
def __init__(
17+
self,
18+
conversation_id: Optional[str] = None,
19+
change_vector: Optional[str] = None,
20+
response: Optional[TResponse] = None,
21+
usage: Optional[AiUsage] = None,
22+
action_requests: Optional[List[AiAgentActionRequest]] = None,
23+
):
24+
self.conversation_id: Optional[str] = conversation_id
25+
self.change_vector: Optional[str] = change_vector
26+
self.response: Optional[TResponse] = response
27+
self.usage: Optional[AiUsage] = usage
28+
self.action_requests: List[AiAgentActionRequest] = action_requests or []
2229

2330
def __str__(self) -> str:
2431
"""String representation for debugging."""

0 commit comments

Comments
 (0)