kader 0.1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,409 @@
1
+ """
2
+ Conversation management for agents.
3
+
4
+ Provides conversation history management and context windowing
5
+ to handle token limits and maintain coherent conversations.
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ from .types import get_timestamp
13
+
14
+ # Import Message from providers for compatibility
15
+ # This allows the conversation module to work with the existing Message type
16
+ try:
17
+ from kader.providers.base import Message
18
+ except ImportError:
19
+ # Fallback for standalone usage
20
+ Message = None # type: ignore
21
+
22
+
23
+ @dataclass
24
+ class ConversationMessage:
25
+ """Wrapper around a Message with metadata.
26
+
27
+ Provides additional metadata for conversation management
28
+ while maintaining compatibility with the provider's Message type.
29
+
30
+ Attributes:
31
+ message: Dictionary representation of the underlying message
32
+ message_id: Index/position in the conversation
33
+ created_at: ISO timestamp when message was added
34
+ updated_at: ISO timestamp when message was last updated
35
+ """
36
+
37
+ message: dict[str, Any]
38
+ message_id: int
39
+ created_at: str = field(default_factory=get_timestamp)
40
+ updated_at: str = field(default_factory=get_timestamp)
41
+
42
+ @property
43
+ def role(self) -> str:
44
+ """Get the message role."""
45
+ return self.message.get("role", "")
46
+
47
+ @property
48
+ def content(self) -> str:
49
+ """Get the message content."""
50
+ return self.message.get("content", "")
51
+
52
+ @property
53
+ def tool_calls(self) -> list[dict[str, Any]] | None:
54
+ """Get tool calls if present."""
55
+ return self.message.get("tool_calls")
56
+
57
+ @property
58
+ def tool_call_id(self) -> str | None:
59
+ """Get tool call ID if this is a tool response."""
60
+ return self.message.get("tool_call_id")
61
+
62
+ def to_dict(self) -> dict[str, Any]:
63
+ """Convert to dictionary for serialization.
64
+
65
+ Returns:
66
+ Dictionary representation
67
+ """
68
+ return {
69
+ "message": self.message,
70
+ "message_id": self.message_id,
71
+ "created_at": self.created_at,
72
+ "updated_at": self.updated_at,
73
+ }
74
+
75
+ @classmethod
76
+ def from_dict(cls, data: dict[str, Any]) -> "ConversationMessage":
77
+ """Create from dictionary.
78
+
79
+ Args:
80
+ data: Dictionary representation
81
+
82
+ Returns:
83
+ New ConversationMessage instance
84
+ """
85
+ return cls(
86
+ message=data.get("message", {}),
87
+ message_id=data.get("message_id", 0),
88
+ created_at=data.get("created_at", get_timestamp()),
89
+ updated_at=data.get("updated_at", get_timestamp()),
90
+ )
91
+
92
+ @classmethod
93
+ def from_message(cls, message: Any, index: int) -> "ConversationMessage":
94
+ """Create from a provider Message object.
95
+
96
+ Args:
97
+ message: Message object (from kader.providers.base)
98
+ index: Position in conversation
99
+
100
+ Returns:
101
+ New ConversationMessage instance
102
+ """
103
+ # Handle both Message objects and dicts
104
+ if hasattr(message, "to_dict"):
105
+ msg_dict = message.to_dict()
106
+ elif isinstance(message, dict):
107
+ msg_dict = message
108
+ else:
109
+ msg_dict = {"role": "user", "content": str(message)}
110
+
111
+ return cls(
112
+ message=msg_dict,
113
+ message_id=index,
114
+ )
115
+
116
+ def to_message(self) -> Any:
117
+ """Convert back to a provider Message object.
118
+
119
+ Returns:
120
+ Message object if kader.providers.base is available, else dict
121
+ """
122
+ if Message is not None:
123
+ return Message(
124
+ role=self.message.get("role", "user"),
125
+ content=self.message.get("content", ""),
126
+ name=self.message.get("name"),
127
+ tool_call_id=self.message.get("tool_call_id"),
128
+ tool_calls=self.message.get("tool_calls"),
129
+ )
130
+ return self.message
131
+
132
+
133
+ class ConversationManager(ABC):
134
+ """Abstract base class for conversation management.
135
+
136
+ Provides interface for managing conversation history and
137
+ applying context management strategies.
138
+ """
139
+
140
+ @abstractmethod
141
+ def add_message(self, message: Any) -> ConversationMessage:
142
+ """Add a message to the conversation.
143
+
144
+ Args:
145
+ message: Message to add (Message object or dict)
146
+
147
+ Returns:
148
+ The wrapped ConversationMessage
149
+ """
150
+ ...
151
+
152
+ @abstractmethod
153
+ def add_messages(self, messages: list[Any]) -> list[ConversationMessage]:
154
+ """Add multiple messages to the conversation.
155
+
156
+ Args:
157
+ messages: List of messages to add
158
+
159
+ Returns:
160
+ List of wrapped ConversationMessages
161
+ """
162
+ ...
163
+
164
+ @abstractmethod
165
+ def get_messages(self) -> list[ConversationMessage]:
166
+ """Get all conversation messages.
167
+
168
+ Returns:
169
+ List of ConversationMessages
170
+ """
171
+ ...
172
+
173
+ @abstractmethod
174
+ def get_state(self) -> dict[str, Any]:
175
+ """Get manager state for persistence.
176
+
177
+ Returns:
178
+ State dictionary
179
+ """
180
+ ...
181
+
182
+ @abstractmethod
183
+ def set_state(self, state: dict[str, Any]) -> None:
184
+ """Restore manager state.
185
+
186
+ Args:
187
+ state: State dictionary
188
+ """
189
+ ...
190
+
191
+ @abstractmethod
192
+ def apply_window(self) -> list[dict[str, Any]]:
193
+ """Apply context management strategy and return messages.
194
+
195
+ Returns:
196
+ List of message dictionaries ready for LLM consumption
197
+ """
198
+ ...
199
+
200
+ @abstractmethod
201
+ def clear(self) -> None:
202
+ """Clear all messages from the conversation."""
203
+ ...
204
+
205
+
206
+ class SlidingWindowConversationManager(ConversationManager):
207
+ """Sliding window conversation manager.
208
+
209
+ Maintains a fixed number of recent message pairs to prevent
210
+ exceeding model context limits. Preserves tool call/result pairs.
211
+
212
+ Attributes:
213
+ window_size: Maximum number of message pairs to keep
214
+ messages: List of conversation messages
215
+ """
216
+
217
+ def __init__(self, window_size: int = 20) -> None:
218
+ """Initialize the sliding window manager.
219
+
220
+ Args:
221
+ window_size: Maximum message pairs to keep (default: 20)
222
+ """
223
+ self.window_size = window_size
224
+ self._messages: list[ConversationMessage] = []
225
+ self._next_id = 0
226
+
227
+ def add_message(self, message: Any) -> ConversationMessage:
228
+ """Add a message to the conversation.
229
+
230
+ Args:
231
+ message: Message to add (Message object or dict)
232
+
233
+ Returns:
234
+ The wrapped ConversationMessage
235
+ """
236
+ conv_msg = ConversationMessage.from_message(message, self._next_id)
237
+ self._messages.append(conv_msg)
238
+ self._next_id += 1
239
+ return conv_msg
240
+
241
+ def add_messages(self, messages: list[Any]) -> list[ConversationMessage]:
242
+ """Add multiple messages to the conversation.
243
+
244
+ Args:
245
+ messages: List of messages to add
246
+
247
+ Returns:
248
+ List of wrapped ConversationMessages
249
+ """
250
+ return [self.add_message(msg) for msg in messages]
251
+
252
+ def get_messages(self) -> list[ConversationMessage]:
253
+ """Get all conversation messages.
254
+
255
+ Returns:
256
+ List of ConversationMessages
257
+ """
258
+ return list(self._messages)
259
+
260
+ def get_state(self) -> dict[str, Any]:
261
+ """Get manager state for persistence.
262
+
263
+ Returns:
264
+ State dictionary
265
+ """
266
+ return {
267
+ "window_size": self.window_size,
268
+ "next_id": self._next_id,
269
+ "messages": [msg.to_dict() for msg in self._messages],
270
+ }
271
+
272
+ def set_state(self, state: dict[str, Any]) -> None:
273
+ """Restore manager state.
274
+
275
+ Args:
276
+ state: State dictionary
277
+ """
278
+ self.window_size = state.get("window_size", self.window_size)
279
+ self._next_id = state.get("next_id", 0)
280
+ self._messages = [
281
+ ConversationMessage.from_dict(msg_data)
282
+ for msg_data in state.get("messages", [])
283
+ ]
284
+
285
+ def apply_window(self) -> list[dict[str, Any]]:
286
+ """Apply sliding window and return messages for LLM.
287
+
288
+ Keeps the most recent messages within the window size,
289
+ while preserving complete tool call/result pairs.
290
+
291
+ Returns:
292
+ List of message dictionaries
293
+ """
294
+ if not self._messages:
295
+ return []
296
+
297
+ # Calculate how many messages to keep (pairs = user + assistant)
298
+ max_messages = self.window_size * 2
299
+
300
+ if len(self._messages) <= max_messages:
301
+ return [msg.message for msg in self._messages]
302
+
303
+ # Start from the end and work backwards
304
+ # Ensure we don't break tool call/result pairs
305
+ messages_to_keep = self._messages[-max_messages:]
306
+
307
+ # Check if first message is a tool result without its call
308
+ # If so, find and include the tool call
309
+ result = []
310
+ tool_call_ids_needed: set[str] = set()
311
+
312
+ for msg in reversed(messages_to_keep):
313
+ if msg.role == "tool" and msg.tool_call_id:
314
+ tool_call_ids_needed.add(msg.tool_call_id)
315
+
316
+ # Include any assistant messages with tool calls that are needed
317
+ for msg in reversed(self._messages):
318
+ if msg in messages_to_keep:
319
+ result.insert(0, msg.message)
320
+ elif msg.role == "assistant" and msg.tool_calls:
321
+ # Check if any of the tool calls are needed
322
+ for tc in msg.tool_calls:
323
+ tc_id = tc.get("id", "")
324
+ if tc_id in tool_call_ids_needed:
325
+ result.insert(0, msg.message)
326
+ tool_call_ids_needed.discard(tc_id)
327
+ break
328
+
329
+ if not tool_call_ids_needed:
330
+ break
331
+
332
+ return result
333
+
334
+ def clear(self) -> None:
335
+ """Clear all messages from the conversation."""
336
+ self._messages.clear()
337
+ self._next_id = 0
338
+
339
+ def __len__(self) -> int:
340
+ """Return number of messages."""
341
+ return len(self._messages)
342
+
343
+
344
+ class NullConversationManager(ConversationManager):
345
+ """No-op conversation manager.
346
+
347
+ For short interactions or when managing context manually.
348
+ Does not store any messages.
349
+ """
350
+
351
+ def add_message(self, message: Any) -> ConversationMessage:
352
+ """Add a message (no-op, returns wrapper).
353
+
354
+ Args:
355
+ message: Message to add
356
+
357
+ Returns:
358
+ The wrapped ConversationMessage (not stored)
359
+ """
360
+ return ConversationMessage.from_message(message, 0)
361
+
362
+ def add_messages(self, messages: list[Any]) -> list[ConversationMessage]:
363
+ """Add multiple messages (no-op).
364
+
365
+ Args:
366
+ messages: List of messages to add
367
+
368
+ Returns:
369
+ List of wrapped ConversationMessages (not stored)
370
+ """
371
+ return [
372
+ ConversationMessage.from_message(msg, i) for i, msg in enumerate(messages)
373
+ ]
374
+
375
+ def get_messages(self) -> list[ConversationMessage]:
376
+ """Get all messages (always empty).
377
+
378
+ Returns:
379
+ Empty list
380
+ """
381
+ return []
382
+
383
+ def get_state(self) -> dict[str, Any]:
384
+ """Get manager state (empty).
385
+
386
+ Returns:
387
+ Empty dict
388
+ """
389
+ return {}
390
+
391
+ def set_state(self, state: dict[str, Any]) -> None:
392
+ """Restore manager state (no-op).
393
+
394
+ Args:
395
+ state: State dictionary (ignored)
396
+ """
397
+ pass
398
+
399
+ def apply_window(self) -> list[dict[str, Any]]:
400
+ """Apply context management (returns empty).
401
+
402
+ Returns:
403
+ Empty list
404
+ """
405
+ return []
406
+
407
+ def clear(self) -> None:
408
+ """Clear messages (no-op)."""
409
+ pass