Skip to content

Commit 5444d91

Browse files
author
hugin-bot
committed
RavenDB-24609 Add GetConversationMessages API for AI agent conversations
Python port of the RavenDB C# client's GetConversationMessages feature (ravendb/ravendb#22619). Adds AiOperations.get_conversation_messages() with timestamp-based paging (before/after cursors) and detail-level filtering (Simple/Detailed/Full), presenting a unified message timeline across conversation history documents. New models: AiConversationMessage, AiToolCallResult, AiConversationMessagesResult, GetConversationMessagesOptions, AiConversationDetailLevel, AiMessageRole; split per-concern mirroring the C# source layout. See: https://issues.hibernatingrhinos.com/issue/RavenDB-24609
1 parent c3f3ca5 commit 5444d91

8 files changed

Lines changed: 1198 additions & 0 deletions

File tree

ravendb/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,13 @@
115115
GetAiAgentsResponse,
116116
AddOrUpdateAiAgentOperation,
117117
DeleteAiAgentOperation,
118+
AiConversationDetailLevel,
119+
AiMessageRole,
120+
AiToolCallResult,
121+
AiConversationMessage,
122+
AiConversationMessagesResult,
123+
GetConversationMessagesOptions,
124+
GetConversationMessagesOperation,
118125
)
119126
from ravendb.documents.operations.ai import (
120127
ChunkingOptions,

ravendb/documents/ai/ai_operations.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
AiAgentConfiguration,
1313
AiAgentConfigurationResult,
1414
GetAiAgentsResponse,
15+
GetConversationMessagesOptions,
16+
AiConversationMessagesResult,
1517
)
1618

1719

@@ -95,6 +97,29 @@ def conversation(
9597

9698
return AiConversation(self._store, agent_id, creation_options, conversation_id, change_vector, debug)
9799

100+
def get_conversation_messages(
101+
self,
102+
conversation_id_or_options: "str | GetConversationMessagesOptions",
103+
) -> "Optional[AiConversationMessagesResult]":
104+
"""
105+
Reads messages from an AI conversation.
106+
107+
Args:
108+
conversation_id_or_options: Either a conversation document ID string,
109+
or a GetConversationMessagesOptions instance with full control
110+
over paging and filtering.
111+
112+
Returns:
113+
AiConversationMessagesResult containing the conversation messages.
114+
Returns None if the conversation does not exist.
115+
"""
116+
from ravendb.documents.operations.ai.agents.get_conversation_messages_operation import (
117+
GetConversationMessagesOperation,
118+
)
119+
120+
operation = GetConversationMessagesOperation(conversation_id_or_options)
121+
return self._store.maintenance.send(operation)
122+
98123
def conversation_with_id(self, conversation_id: str, change_vector: str = None) -> AiConversation:
99124
"""
100125
Continues an existing conversation by its ID.

ravendb/documents/operations/ai/agents/__init__.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,21 @@
3939
AiConversationParameterOptions,
4040
)
4141

42+
from .ai_conversation_detail_level import AiConversationDetailLevel
43+
44+
from .ai_conversation_message import (
45+
AiMessageRole,
46+
AiToolCallResult,
47+
AiConversationMessage,
48+
)
49+
50+
from .ai_conversation_messages_result import AiConversationMessagesResult
51+
52+
from .get_conversation_messages_operation import (
53+
GetConversationMessagesOptions,
54+
GetConversationMessagesOperation,
55+
)
56+
4257
__all__ = [
4358
"AiAgentConfiguration",
4459
"AiAgentConfigurationResult",
@@ -68,4 +83,11 @@
6883
"GetAiAgentsResponse",
6984
"AddOrUpdateAiAgentOperation",
7085
"DeleteAiAgentOperation",
86+
"AiConversationDetailLevel",
87+
"AiMessageRole",
88+
"AiToolCallResult",
89+
"AiConversationMessage",
90+
"AiConversationMessagesResult",
91+
"GetConversationMessagesOptions",
92+
"GetConversationMessagesOperation",
7193
]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from __future__ import annotations
2+
3+
from enum import Enum
4+
5+
6+
class AiConversationDetailLevel(Enum):
7+
"""
8+
Controls the level of detail when reading conversation messages.
9+
10+
Simple: User messages (including attachment-only) and assistant messages
11+
that have content only. System prompts, tool calls, summaries, and
12+
internal messages are excluded.
13+
Detailed: Includes system messages, tool calls with results, and per-message
14+
usage. Summaries and internal messages are excluded.
15+
Full: No filtering — includes all messages: system, tool calls, summaries,
16+
internal. Intended for debugging and future-proofing.
17+
"""
18+
19+
SIMPLE = "Simple"
20+
DETAILED = "Detailed"
21+
FULL = "Full"
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from datetime import datetime
5+
from enum import Enum
6+
from typing import Any, Dict, List, Optional, TYPE_CHECKING
7+
8+
if TYPE_CHECKING:
9+
from ravendb.documents.operations.ai.agents.run_conversation_operation import AiUsage
10+
11+
12+
class AiMessageRole(Enum):
13+
"""Role of a message in an AI conversation."""
14+
15+
SYSTEM = "System"
16+
USER = "User"
17+
ASSISTANT = "Assistant"
18+
SUMMARY = "Summary"
19+
INTERNAL = "Internal"
20+
21+
22+
def _parse_timestamp(ts_value: Any) -> Optional[datetime]:
23+
"""Parse a RavenDB timestamp string into a datetime.
24+
25+
Handles Z suffix and 7-digit fractional seconds (truncated to 6 for Python 3.9).
26+
"""
27+
if not ts_value:
28+
return None
29+
if isinstance(ts_value, datetime):
30+
return ts_value
31+
if isinstance(ts_value, str):
32+
# Replace Z suffix with +00:00 for Python fromisoformat
33+
ts_str = ts_value.replace("Z", "+00:00")
34+
# Truncate fractional seconds to 6 digits (Python 3.9 limit)
35+
if "." in ts_str:
36+
dot_idx = ts_str.index(".")
37+
remaining = ts_str[dot_idx + 1 :]
38+
frac_end = len(remaining)
39+
for i, c in enumerate(remaining):
40+
if c in ("+", "-") and i > 0:
41+
frac_end = i
42+
break
43+
frac = remaining[:frac_end][:6]
44+
rest = remaining[frac_end:]
45+
ts_str = ts_str[: dot_idx + 1] + frac + rest
46+
try:
47+
return datetime.fromisoformat(ts_str)
48+
except (ValueError, AttributeError):
49+
return None
50+
return None
51+
52+
53+
@dataclass
54+
class AiToolCallResult:
55+
"""
56+
Represents a tool call that originated in an assistant message, with its
57+
eventual response (result) and optional sub-conversation ID merged in.
58+
"""
59+
60+
id: Optional[str] = None
61+
"""The tool call ID from the model."""
62+
63+
name: Optional[str] = None
64+
"""Tool name."""
65+
66+
arguments: Optional[str] = None
67+
"""Arguments the model passed, as JSON string."""
68+
69+
result: Optional[str] = None
70+
"""The tool's response content. None if still pending (ActionRequired)."""
71+
72+
sub_conversation_id: Optional[str] = None
73+
"""If this tool call was a sub-agent invocation, the ID of the spawned sub-conversation."""
74+
75+
@classmethod
76+
def from_json(cls, json_dict: Dict[str, Any]) -> AiToolCallResult:
77+
return cls(
78+
id=json_dict.get("Id"),
79+
name=json_dict.get("Name"),
80+
arguments=json_dict.get("Arguments"),
81+
result=json_dict.get("Result"),
82+
sub_conversation_id=json_dict.get("SubConversationId"),
83+
)
84+
85+
def to_json(self) -> Dict[str, Any]:
86+
return {
87+
"Id": self.id,
88+
"Name": self.name,
89+
"Arguments": self.arguments,
90+
"Result": self.result,
91+
"SubConversationId": self.sub_conversation_id,
92+
}
93+
94+
95+
@dataclass
96+
class AiConversationMessage:
97+
"""
98+
A single message in an AI agent conversation.
99+
100+
Attributes:
101+
role: The role of the message sender.
102+
content: Text content. When the stored message has multiple text parts,
103+
they are joined with line breaks. None for assistant messages that
104+
only initiated tool calls.
105+
attachments: Attachment file names associated with this message, if any.
106+
timestamp: When this message was recorded (UTC). Guaranteed unique and
107+
monotonic within a conversation — safe to use as a paging cursor.
108+
tool_calls: Tool calls initiated by this assistant message, with their
109+
responses inlined.
110+
usage: Token usage for this message (typically on assistant messages).
111+
sub_conversation_id: For Internal role messages: the ID of the
112+
sub-conversation this message relates to.
113+
"""
114+
115+
role: Optional[AiMessageRole] = None
116+
content: Optional[str] = None
117+
attachments: Optional[List[str]] = None
118+
timestamp: Optional[datetime] = None
119+
tool_calls: Optional[List[AiToolCallResult]] = None
120+
usage: Optional[Any] = None # AiUsage when resolved
121+
sub_conversation_id: Optional[str] = None
122+
123+
@classmethod
124+
def from_json(cls, json_dict: Dict[str, Any]) -> AiConversationMessage:
125+
from ravendb.documents.operations.ai.agents.run_conversation_operation import AiUsage
126+
127+
role_str = json_dict.get("Role")
128+
if role_str is None:
129+
raise ValueError("Message is missing 'Role' field")
130+
try:
131+
role = AiMessageRole(role_str)
132+
except ValueError:
133+
raise ValueError(f"Unknown AiMessageRole: '{role_str}'")
134+
135+
content = json_dict.get("Content")
136+
raw_attachments = json_dict.get("Attachments")
137+
attachments = raw_attachments if raw_attachments is not None else None
138+
timestamp = _parse_timestamp(json_dict.get("Timestamp"))
139+
140+
tool_calls = None
141+
raw_tool_calls = json_dict.get("ToolCalls")
142+
if raw_tool_calls:
143+
tool_calls = [AiToolCallResult.from_json(tc) for tc in raw_tool_calls]
144+
145+
usage = None
146+
raw_usage = json_dict.get("Usage")
147+
if raw_usage:
148+
usage = AiUsage.from_json(raw_usage)
149+
150+
sub_conversation_id = json_dict.get("SubConversationId")
151+
152+
return cls(
153+
role=role,
154+
content=content,
155+
attachments=attachments,
156+
timestamp=timestamp,
157+
tool_calls=tool_calls,
158+
usage=usage,
159+
sub_conversation_id=sub_conversation_id,
160+
)
161+
162+
def to_json(self) -> Dict[str, Any]:
163+
return {
164+
"Role": self.role.value if self.role else None,
165+
"Content": self.content,
166+
"Attachments": self.attachments if self.attachments is not None else None,
167+
"Timestamp": self.timestamp.isoformat() if self.timestamp else None,
168+
"ToolCalls": [tc.to_json() for tc in self.tool_calls] if self.tool_calls else None,
169+
"Usage": self.usage.to_json() if self.usage else None,
170+
"SubConversationId": self.sub_conversation_id,
171+
}
172+
173+
def __repr__(self) -> str:
174+
return (
175+
f"AiConversationMessage(role={self.role}, content={self.content!r}, "
176+
f"timestamp={self.timestamp}, tool_calls={len(self.tool_calls) if self.tool_calls else 0})"
177+
)

0 commit comments

Comments
 (0)