agnt5 0.3.2a1__cp310-abi3-manylinux_2_34_aarch64.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.

Potentially problematic release.


This version of agnt5 might be problematic. Click here for more details.

agnt5/memory.py ADDED
@@ -0,0 +1,521 @@
1
+ """Memory classes for AGNT5 SDK.
2
+
3
+ Provides memory abstractions for workflows and agents:
4
+ - ConversationMemory: KV-backed message history for sessions
5
+ - SemanticMemory: Vector-backed semantic search for user/tenant memory (Phase 3)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ from ._telemetry import setup_module_logger
15
+ from .lm import Message, MessageRole
16
+
17
+ logger = setup_module_logger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class MemoryMessage:
22
+ """Message stored in conversation memory.
23
+
24
+ Attributes:
25
+ role: Message role (user, assistant, system)
26
+ content: Message content text
27
+ timestamp: Unix timestamp when message was added
28
+ metadata: Optional additional metadata
29
+ """
30
+ role: str
31
+ content: str
32
+ timestamp: float = field(default_factory=time.time)
33
+ metadata: Dict[str, Any] = field(default_factory=dict)
34
+
35
+ def to_dict(self) -> Dict[str, Any]:
36
+ """Convert to dictionary for serialization."""
37
+ return {
38
+ "role": self.role,
39
+ "content": self.content,
40
+ "timestamp": self.timestamp,
41
+ "metadata": self.metadata,
42
+ }
43
+
44
+ @classmethod
45
+ def from_dict(cls, data: Dict[str, Any]) -> "MemoryMessage":
46
+ """Create from dictionary."""
47
+ return cls(
48
+ role=data.get("role", "user"),
49
+ content=data.get("content", ""),
50
+ timestamp=data.get("timestamp", time.time()),
51
+ metadata=data.get("metadata", {}),
52
+ )
53
+
54
+ def to_lm_message(self) -> Message:
55
+ """Convert to LM Message for agent prompts."""
56
+ role_map = {
57
+ "user": MessageRole.USER,
58
+ "assistant": MessageRole.ASSISTANT,
59
+ "system": MessageRole.SYSTEM,
60
+ }
61
+ return Message(
62
+ role=role_map.get(self.role, MessageRole.USER),
63
+ content=self.content,
64
+ )
65
+
66
+
67
+ class ConversationMemory:
68
+ """KV-backed conversation memory for session history.
69
+
70
+ Stores sequential message history for a session, enabling multi-turn
71
+ conversations. Messages are persisted to the platform and loaded on demand.
72
+
73
+ Example:
74
+ ```python
75
+ # In a workflow
76
+ @workflow
77
+ async def chat_workflow(ctx: WorkflowContext, message: str) -> str:
78
+ # Load conversation history
79
+ conversation = ConversationMemory(ctx.session_id)
80
+ history = await conversation.get_messages()
81
+
82
+ # Process with agent
83
+ result = await agent.run_sync(message, history=history)
84
+
85
+ # Save new messages
86
+ await conversation.add("user", message)
87
+ await conversation.add("assistant", result.output)
88
+
89
+ return result.output
90
+ ```
91
+ """
92
+
93
+ def __init__(self, session_id: str) -> None:
94
+ """Initialize conversation memory for a session.
95
+
96
+ Args:
97
+ session_id: Unique identifier for the conversation session
98
+ """
99
+ self.session_id = session_id
100
+ self._entity_key = f"conversation:{session_id}"
101
+ self._entity_type = "ConversationMemory"
102
+ self._state_adapter = None
103
+ self._cache: Optional[List[MemoryMessage]] = None
104
+
105
+ def _get_adapter(self):
106
+ """Get or create state adapter for persistence."""
107
+ if self._state_adapter is None:
108
+ from .entity import _get_state_adapter, EntityStateAdapter
109
+ try:
110
+ self._state_adapter = _get_state_adapter()
111
+ except RuntimeError:
112
+ # Not in worker context - create standalone adapter
113
+ self._state_adapter = EntityStateAdapter()
114
+ return self._state_adapter
115
+
116
+ async def get_messages(self, limit: int = 50) -> List[MemoryMessage]:
117
+ """Get recent messages from conversation history.
118
+
119
+ Args:
120
+ limit: Maximum number of messages to return (most recent)
121
+
122
+ Returns:
123
+ List of MemoryMessage objects, ordered chronologically
124
+ """
125
+ adapter = self._get_adapter()
126
+
127
+ # Load session data from storage
128
+ session_data = await adapter.load_state(self._entity_type, self._entity_key)
129
+
130
+ if not session_data:
131
+ return []
132
+
133
+ messages_data = session_data.get("messages", [])
134
+
135
+ # Convert to MemoryMessage objects
136
+ messages = [MemoryMessage.from_dict(m) for m in messages_data]
137
+
138
+ # Apply limit (return most recent)
139
+ if limit and len(messages) > limit:
140
+ messages = messages[-limit:]
141
+
142
+ # Cache for potential add() calls
143
+ self._cache = messages
144
+
145
+ return messages
146
+
147
+ async def add(self, role: str, content: str, metadata: Optional[Dict[str, Any]] = None) -> None:
148
+ """Add a message to the conversation.
149
+
150
+ Args:
151
+ role: Message role ("user", "assistant", "system")
152
+ content: Message content text
153
+ metadata: Optional additional metadata to store
154
+ """
155
+ adapter = self._get_adapter()
156
+
157
+ # Load current state with version for optimistic locking
158
+ current_state, current_version = await adapter.load_with_version(
159
+ self._entity_type, self._entity_key
160
+ )
161
+
162
+ # Get existing messages or start fresh
163
+ messages_data = current_state.get("messages", []) if current_state else []
164
+
165
+ # Create new message
166
+ new_message = MemoryMessage(
167
+ role=role,
168
+ content=content,
169
+ timestamp=time.time(),
170
+ metadata=metadata or {},
171
+ )
172
+
173
+ # Append message
174
+ messages_data.append(new_message.to_dict())
175
+
176
+ # Build session data
177
+ now = time.time()
178
+ session_data = {
179
+ "session_id": self.session_id,
180
+ "created_at": current_state.get("created_at", now) if current_state else now,
181
+ "last_message_at": now,
182
+ "message_count": len(messages_data),
183
+ "messages": messages_data,
184
+ }
185
+
186
+ # Save to storage
187
+ try:
188
+ await adapter.save_state(
189
+ self._entity_type,
190
+ self._entity_key,
191
+ session_data,
192
+ current_version,
193
+ )
194
+ logger.debug(f"Saved message to conversation {self.session_id}: {role}")
195
+ except Exception as e:
196
+ logger.error(f"Failed to save message to conversation {self.session_id}: {e}")
197
+ raise
198
+
199
+ async def clear(self) -> None:
200
+ """Clear all messages in this conversation."""
201
+ adapter = self._get_adapter()
202
+
203
+ # Load current version for optimistic locking
204
+ _, current_version = await adapter.load_with_version(
205
+ self._entity_type, self._entity_key
206
+ )
207
+
208
+ # Save empty session
209
+ now = time.time()
210
+ session_data = {
211
+ "session_id": self.session_id,
212
+ "created_at": now,
213
+ "last_message_at": now,
214
+ "message_count": 0,
215
+ "messages": [],
216
+ }
217
+
218
+ try:
219
+ await adapter.save_state(
220
+ self._entity_type,
221
+ self._entity_key,
222
+ session_data,
223
+ current_version,
224
+ )
225
+ self._cache = []
226
+ logger.info(f"Cleared conversation {self.session_id}")
227
+ except Exception as e:
228
+ logger.error(f"Failed to clear conversation {self.session_id}: {e}")
229
+ raise
230
+
231
+ async def get_as_lm_messages(self, limit: int = 50) -> List[Message]:
232
+ """Get messages formatted for LLM consumption.
233
+
234
+ Convenience method that returns messages as LM Message objects,
235
+ ready to pass to agent.run() or lm.generate().
236
+
237
+ Args:
238
+ limit: Maximum number of messages to return
239
+
240
+ Returns:
241
+ List of Message objects for LM API
242
+ """
243
+ messages = await self.get_messages(limit=limit)
244
+ return [m.to_lm_message() for m in messages]
245
+
246
+
247
+ class MemoryScope:
248
+ """Memory scope for semantic memory.
249
+
250
+ Scopes determine the isolation level of memories:
251
+ - USER: Isolated per user (most common)
252
+ - TENANT: Shared across users in a tenant
253
+ - AGENT: Isolated per agent instance
254
+ - SESSION: Isolated per session (ephemeral)
255
+ - GLOBAL: Shared across all users/tenants
256
+ """
257
+ USER = "user"
258
+ TENANT = "tenant"
259
+ AGENT = "agent"
260
+ SESSION = "session"
261
+ GLOBAL = "global"
262
+
263
+ @classmethod
264
+ def valid_scopes(cls) -> List[str]:
265
+ """Return list of valid scope strings."""
266
+ return [cls.USER, cls.TENANT, cls.AGENT, cls.SESSION, cls.GLOBAL]
267
+
268
+
269
+ @dataclass
270
+ class MemoryResult:
271
+ """Result from semantic memory search.
272
+
273
+ Attributes:
274
+ id: Unique identifier for this memory
275
+ content: The original text content that was stored
276
+ score: Similarity score (0.0 to 1.0, higher is more similar)
277
+ metadata: Optional metadata associated with the memory
278
+ """
279
+ id: str
280
+ content: str
281
+ score: float
282
+ metadata: Dict[str, Any] = field(default_factory=dict)
283
+
284
+
285
+ @dataclass
286
+ class MemoryMetadata:
287
+ """Metadata for storing with a memory.
288
+
289
+ Attributes:
290
+ source: Optional source identifier (e.g., "chat", "document", "api")
291
+ created_at: Optional timestamp string
292
+ extra: Additional key-value metadata
293
+ """
294
+ source: Optional[str] = None
295
+ created_at: Optional[str] = None
296
+ extra: Dict[str, str] = field(default_factory=dict)
297
+
298
+
299
+ class SemanticMemory:
300
+ """Vector-backed semantic memory for user/tenant knowledge.
301
+
302
+ Provides semantic search capabilities over stored memories using
303
+ vector embeddings. Memories are automatically embedded and indexed
304
+ for fast similarity search.
305
+
306
+ Requires:
307
+ - OPENAI_API_KEY for embeddings
308
+ - One of: QDRANT_URL, PINECONE_API_KEY+PINECONE_HOST, or POSTGRES_URL for vector storage
309
+
310
+ Example:
311
+ ```python
312
+ from agnt5 import SemanticMemory, MemoryScope
313
+
314
+ # Create memory scoped to a user
315
+ memory = SemanticMemory(MemoryScope.USER, "user-123")
316
+
317
+ # Store some memories
318
+ await memory.store("User prefers dark mode")
319
+ await memory.store("User's favorite color is blue")
320
+
321
+ # Search for relevant memories
322
+ results = await memory.search("color preferences")
323
+ for result in results:
324
+ print(f"{result.content} (score: {result.score:.2f})")
325
+
326
+ # Delete a memory
327
+ await memory.forget(results[0].id)
328
+ ```
329
+ """
330
+
331
+ def __init__(self, scope: str, scope_id: str) -> None:
332
+ """Initialize semantic memory for a scope.
333
+
334
+ Args:
335
+ scope: Memory scope (use MemoryScope constants: USER, TENANT, AGENT, SESSION, GLOBAL)
336
+ scope_id: The unique identifier for the scope (e.g., user_id, tenant_id)
337
+
338
+ Raises:
339
+ ValueError: If scope is not a valid scope string
340
+ """
341
+ if scope not in MemoryScope.valid_scopes():
342
+ raise ValueError(
343
+ f"Invalid scope '{scope}'. Must be one of: {MemoryScope.valid_scopes()}"
344
+ )
345
+ self.scope = scope
346
+ self.scope_id = scope_id
347
+ self._inner = None # Lazy initialization
348
+
349
+ async def _get_inner(self):
350
+ """Get or create the underlying Rust SemanticMemory instance."""
351
+ if self._inner is None:
352
+ try:
353
+ from ._core import PySemanticMemory, PyMemoryScope
354
+ except ImportError as e:
355
+ raise ImportError(
356
+ "SemanticMemory requires the agnt5 Rust extension. "
357
+ f"Import error: {e}"
358
+ ) from e
359
+
360
+ # Map scope string to PyMemoryScope
361
+ scope_map = {
362
+ MemoryScope.USER: PyMemoryScope.user(),
363
+ MemoryScope.TENANT: PyMemoryScope.tenant(),
364
+ MemoryScope.AGENT: PyMemoryScope.agent(),
365
+ MemoryScope.SESSION: PyMemoryScope.session(),
366
+ MemoryScope.GLOBAL: PyMemoryScope.global_(),
367
+ }
368
+ py_scope = scope_map.get(self.scope)
369
+ if py_scope is None:
370
+ py_scope = PyMemoryScope.from_str(self.scope)
371
+
372
+ self._inner = await PySemanticMemory.from_env(py_scope, self.scope_id)
373
+ return self._inner
374
+
375
+ async def store(self, content: str, metadata: Optional[MemoryMetadata] = None) -> str:
376
+ """Store content in semantic memory.
377
+
378
+ The content is automatically embedded and indexed for semantic search.
379
+
380
+ Args:
381
+ content: Text content to store
382
+ metadata: Optional metadata to associate with the memory
383
+
384
+ Returns:
385
+ The unique ID of the stored memory
386
+
387
+ Raises:
388
+ RuntimeError: If embedder or vector database is not configured
389
+ """
390
+ inner = await self._get_inner()
391
+ if metadata is not None:
392
+ from ._core import PyMemoryMetadata
393
+ py_metadata = PyMemoryMetadata(
394
+ source=metadata.source,
395
+ created_at=metadata.created_at,
396
+ extra=metadata.extra,
397
+ )
398
+ return await inner.store_with_metadata(content, py_metadata)
399
+ return await inner.store(content)
400
+
401
+ async def store_batch(
402
+ self,
403
+ contents: List[str],
404
+ metadata: Optional[List[MemoryMetadata]] = None,
405
+ ) -> List[str]:
406
+ """Store multiple contents in batch (more efficient for RAG indexing).
407
+
408
+ Uses batch embedding and batch upsert for better performance when
409
+ indexing many documents.
410
+
411
+ Args:
412
+ contents: List of text contents to store
413
+ metadata: Optional list of metadata (must match contents length)
414
+
415
+ Returns:
416
+ List of unique IDs for all stored memories
417
+
418
+ Raises:
419
+ RuntimeError: If embedder or vector database is not configured
420
+ ValueError: If metadata length doesn't match contents length
421
+
422
+ Example:
423
+ ```python
424
+ # Index documents in batch
425
+ docs = ["Doc 1 content...", "Doc 2 content...", "Doc 3 content..."]
426
+ ids = await memory.store_batch(docs)
427
+
428
+ # With metadata for source tracking
429
+ metadata = [
430
+ MemoryMetadata(source="file1.pdf"),
431
+ MemoryMetadata(source="file2.pdf"),
432
+ MemoryMetadata(source="file3.pdf"),
433
+ ]
434
+ ids = await memory.store_batch(docs, metadata=metadata)
435
+ ```
436
+ """
437
+ inner = await self._get_inner()
438
+ if metadata is not None:
439
+ if len(metadata) != len(contents):
440
+ raise ValueError(
441
+ f"Metadata length ({len(metadata)}) must match contents length ({len(contents)})"
442
+ )
443
+ from ._core import PyMemoryMetadata
444
+ py_metadata = [
445
+ PyMemoryMetadata(
446
+ source=m.source,
447
+ created_at=m.created_at,
448
+ extra=m.extra,
449
+ )
450
+ for m in metadata
451
+ ]
452
+ return await inner.store_batch_with_metadata(contents, py_metadata)
453
+ return await inner.store_batch(contents)
454
+
455
+ async def search(self, query: str, limit: int = 10, min_score: Optional[float] = None) -> List[MemoryResult]:
456
+ """Search for relevant memories using vector similarity.
457
+
458
+ Args:
459
+ query: Search query text (will be embedded)
460
+ limit: Maximum number of results to return (default: 10)
461
+ min_score: Optional minimum similarity score filter (0.0 to 1.0)
462
+
463
+ Returns:
464
+ List of MemoryResult objects, ranked by similarity score (highest first)
465
+
466
+ Raises:
467
+ RuntimeError: If embedder or vector database is not configured
468
+ """
469
+ inner = await self._get_inner()
470
+ if min_score is not None:
471
+ results = await inner.search_with_options(query, limit, min_score)
472
+ else:
473
+ results = await inner.search(query, limit)
474
+
475
+ return [
476
+ MemoryResult(
477
+ id=r.id,
478
+ content=r.content,
479
+ score=r.score,
480
+ metadata=dict(r.metadata.extra) if r.metadata else {},
481
+ )
482
+ for r in results
483
+ ]
484
+
485
+ async def forget(self, memory_id: str) -> bool:
486
+ """Delete a memory by its ID.
487
+
488
+ Args:
489
+ memory_id: The unique ID of the memory to delete
490
+
491
+ Returns:
492
+ True if the memory was deleted, False if it wasn't found
493
+
494
+ Raises:
495
+ RuntimeError: If vector database is not configured
496
+ """
497
+ inner = await self._get_inner()
498
+ return await inner.forget(memory_id)
499
+
500
+ async def get(self, memory_id: str) -> Optional[MemoryResult]:
501
+ """Get a specific memory by ID.
502
+
503
+ Args:
504
+ memory_id: The unique ID of the memory to retrieve
505
+
506
+ Returns:
507
+ MemoryResult if found, None otherwise
508
+
509
+ Raises:
510
+ RuntimeError: If vector database is not configured
511
+ """
512
+ inner = await self._get_inner()
513
+ result = await inner.get(memory_id)
514
+ if result is None:
515
+ return None
516
+ return MemoryResult(
517
+ id=result.id,
518
+ content=result.content,
519
+ score=result.score,
520
+ metadata=dict(result.metadata.extra) if result.metadata else {},
521
+ )