agno 2.3.24__py3-none-any.whl → 2.3.25__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.
Files changed (64) hide show
  1. agno/agent/agent.py +297 -11
  2. agno/db/base.py +214 -0
  3. agno/db/dynamo/dynamo.py +47 -0
  4. agno/db/firestore/firestore.py +47 -0
  5. agno/db/gcs_json/gcs_json_db.py +47 -0
  6. agno/db/in_memory/in_memory_db.py +47 -0
  7. agno/db/json/json_db.py +47 -0
  8. agno/db/mongo/async_mongo.py +229 -0
  9. agno/db/mongo/mongo.py +47 -0
  10. agno/db/mongo/schemas.py +16 -0
  11. agno/db/mysql/async_mysql.py +47 -0
  12. agno/db/mysql/mysql.py +47 -0
  13. agno/db/postgres/async_postgres.py +231 -0
  14. agno/db/postgres/postgres.py +239 -0
  15. agno/db/postgres/schemas.py +19 -0
  16. agno/db/redis/redis.py +47 -0
  17. agno/db/singlestore/singlestore.py +47 -0
  18. agno/db/sqlite/async_sqlite.py +242 -0
  19. agno/db/sqlite/schemas.py +18 -0
  20. agno/db/sqlite/sqlite.py +239 -0
  21. agno/db/surrealdb/surrealdb.py +47 -0
  22. agno/knowledge/chunking/code.py +90 -0
  23. agno/knowledge/chunking/document.py +62 -2
  24. agno/knowledge/chunking/strategy.py +14 -0
  25. agno/knowledge/knowledge.py +7 -1
  26. agno/knowledge/reader/arxiv_reader.py +1 -0
  27. agno/knowledge/reader/csv_reader.py +1 -0
  28. agno/knowledge/reader/docx_reader.py +1 -0
  29. agno/knowledge/reader/firecrawl_reader.py +1 -0
  30. agno/knowledge/reader/json_reader.py +1 -0
  31. agno/knowledge/reader/markdown_reader.py +1 -0
  32. agno/knowledge/reader/pdf_reader.py +1 -0
  33. agno/knowledge/reader/pptx_reader.py +1 -0
  34. agno/knowledge/reader/s3_reader.py +1 -0
  35. agno/knowledge/reader/tavily_reader.py +1 -0
  36. agno/knowledge/reader/text_reader.py +1 -0
  37. agno/knowledge/reader/web_search_reader.py +1 -0
  38. agno/knowledge/reader/website_reader.py +1 -0
  39. agno/knowledge/reader/wikipedia_reader.py +1 -0
  40. agno/knowledge/reader/youtube_reader.py +1 -0
  41. agno/knowledge/utils.py +1 -0
  42. agno/learn/__init__.py +65 -0
  43. agno/learn/config.py +463 -0
  44. agno/learn/curate.py +185 -0
  45. agno/learn/machine.py +690 -0
  46. agno/learn/schemas.py +1043 -0
  47. agno/learn/stores/__init__.py +35 -0
  48. agno/learn/stores/entity_memory.py +3275 -0
  49. agno/learn/stores/learned_knowledge.py +1583 -0
  50. agno/learn/stores/protocol.py +117 -0
  51. agno/learn/stores/session_context.py +1217 -0
  52. agno/learn/stores/user_memory.py +1495 -0
  53. agno/learn/stores/user_profile.py +1220 -0
  54. agno/learn/utils.py +209 -0
  55. agno/models/base.py +59 -0
  56. agno/os/routers/knowledge/knowledge.py +7 -0
  57. agno/tools/browserbase.py +78 -6
  58. agno/tools/google_bigquery.py +11 -2
  59. agno/utils/agent.py +30 -1
  60. {agno-2.3.24.dist-info → agno-2.3.25.dist-info}/METADATA +24 -2
  61. {agno-2.3.24.dist-info → agno-2.3.25.dist-info}/RECORD +64 -50
  62. {agno-2.3.24.dist-info → agno-2.3.25.dist-info}/WHEEL +0 -0
  63. {agno-2.3.24.dist-info → agno-2.3.25.dist-info}/licenses/LICENSE +0 -0
  64. {agno-2.3.24.dist-info → agno-2.3.25.dist-info}/top_level.txt +0 -0
agno/learn/schemas.py ADDED
@@ -0,0 +1,1043 @@
1
+ """
2
+ LearningMachine Schemas
3
+ =======================
4
+ Dataclasses for each learning type.
5
+
6
+ Uses pure dataclasses to avoid runtime overhead.
7
+ All parsing is done via from_dict() which never raises.
8
+
9
+ Classes are designed to be extended - from_dict() and to_dict()
10
+ automatically handle subclass fields via dataclasses.fields().
11
+
12
+ Field Descriptions
13
+ When extending schemas, use field metadata to provide descriptions
14
+ that will be shown to the LLM:
15
+
16
+ @dataclass
17
+ class MyUserProfile(UserProfile):
18
+ company: Optional[str] = field(
19
+ default=None,
20
+ metadata={"description": "Where they work"}
21
+ )
22
+
23
+ The LLM will see this description when deciding how to update fields.
24
+
25
+ Schemas:
26
+ - UserProfile: Long-term user memory
27
+ - SessionContext: Current session state
28
+ - LearnedKnowledge: Reusable knowledge/insights
29
+ - EntityMemory: Third-party entity facts
30
+ - Decision: Decision logs (Phase 2)
31
+ - Feedback: Behavioral feedback (Phase 2)
32
+ - InstructionUpdate: Self-improvement (Phase 3)
33
+ """
34
+
35
+ from dataclasses import asdict, dataclass, field, fields
36
+ from typing import Any, Dict, List, Optional
37
+
38
+ from agno.learn.utils import _parse_json, _safe_get
39
+ from agno.utils.log import log_debug
40
+
41
+ # =============================================================================
42
+ # Helper for debug logging
43
+ # =============================================================================
44
+
45
+
46
+ def _truncate_for_log(data: Any, max_len: int = 100) -> str:
47
+ """Truncate data for logging to avoid massive log entries."""
48
+ s = str(data)
49
+ if len(s) > max_len:
50
+ return s[:max_len] + "..."
51
+ return s
52
+
53
+
54
+ # =============================================================================
55
+ # User Profile Schema
56
+ # =============================================================================
57
+
58
+
59
+ @dataclass
60
+ class UserProfile:
61
+ """Schema for User Profile learning type.
62
+
63
+ Captures long-term structured profile information about a user that persists
64
+ across sessions. Designed to be extended with custom fields.
65
+
66
+ ## Extending with Custom Fields
67
+
68
+ Use field metadata to provide descriptions for the LLM:
69
+
70
+ @dataclass
71
+ class MyUserProfile(UserProfile):
72
+ company: Optional[str] = field(
73
+ default=None,
74
+ metadata={"description": "Company or organization they work for"}
75
+ )
76
+ role: Optional[str] = field(
77
+ default=None,
78
+ metadata={"description": "Job title or role"}
79
+ )
80
+ timezone: Optional[str] = field(
81
+ default=None,
82
+ metadata={"description": "User's timezone (e.g., America/New_York)"}
83
+ )
84
+
85
+ Attributes:
86
+ user_id: Required unique identifier for the user.
87
+ name: User's full name.
88
+ preferred_name: How they prefer to be addressed (nickname, first name, etc).
89
+ agent_id: Which agent created this profile.
90
+ team_id: Which team created this profile.
91
+ created_at: When the profile was created (ISO format).
92
+ updated_at: When the profile was last updated (ISO format).
93
+ """
94
+
95
+ user_id: str
96
+ name: Optional[str] = field(default=None, metadata={"description": "User's full name"})
97
+ preferred_name: Optional[str] = field(
98
+ default=None, metadata={"description": "How they prefer to be addressed (nickname, first name, etc)"}
99
+ )
100
+ agent_id: Optional[str] = field(default=None, metadata={"internal": True})
101
+ team_id: Optional[str] = field(default=None, metadata={"internal": True})
102
+ created_at: Optional[str] = field(default=None, metadata={"internal": True})
103
+ updated_at: Optional[str] = field(default=None, metadata={"internal": True})
104
+
105
+ @classmethod
106
+ def from_dict(cls, data: Any) -> Optional["UserProfile"]:
107
+ """Parse from dict/JSON, returning None on any failure.
108
+
109
+ Works with subclasses - automatically handles additional fields.
110
+ """
111
+ if data is None:
112
+ return None
113
+ if isinstance(data, cls):
114
+ return data
115
+
116
+ try:
117
+ parsed = _parse_json(data)
118
+ if not parsed:
119
+ log_debug(f"{cls.__name__}.from_dict: _parse_json returned None for data={_truncate_for_log(data)}")
120
+ return None
121
+
122
+ # user_id is required
123
+ if not parsed.get("user_id"):
124
+ log_debug(f"{cls.__name__}.from_dict: missing required field 'user_id'")
125
+ return None
126
+
127
+ # Get field names for this class (includes subclass fields)
128
+ field_names = {f.name for f in fields(cls)}
129
+ kwargs = {k: v for k, v in parsed.items() if k in field_names}
130
+
131
+ return cls(**kwargs)
132
+ except Exception as e:
133
+ log_debug(f"{cls.__name__}.from_dict failed: {e}, data={_truncate_for_log(data)}")
134
+ return None
135
+
136
+ def to_dict(self) -> Dict[str, Any]:
137
+ """Convert to dict. Works with subclasses."""
138
+ try:
139
+ return asdict(self)
140
+ except Exception as e:
141
+ log_debug(f"{self.__class__.__name__}.to_dict failed: {e}")
142
+ return {}
143
+
144
+ @classmethod
145
+ def get_updateable_fields(cls) -> Dict[str, Dict[str, Any]]:
146
+ """Get fields that can be updated via update_profile tool.
147
+
148
+ Returns:
149
+ Dict mapping field name to field info including description.
150
+ Excludes internal fields (user_id, timestamps, etc).
151
+ """
152
+ skip = {"user_id", "created_at", "updated_at", "agent_id", "team_id"}
153
+
154
+ result = {}
155
+ for f in fields(cls):
156
+ if f.name in skip:
157
+ continue
158
+ # Skip fields marked as internal
159
+ if f.metadata.get("internal"):
160
+ continue
161
+
162
+ result[f.name] = {
163
+ "type": f.type,
164
+ "description": f.metadata.get("description", f"User's {f.name.replace('_', ' ')}"),
165
+ }
166
+
167
+ return result
168
+
169
+ def __repr__(self) -> str:
170
+ return f"UserProfile(user_id={self.user_id})"
171
+
172
+
173
+ @dataclass
174
+ class Memories:
175
+ """Schema for Memories learning type.
176
+
177
+ Captures unstructured observations about a user that don't fit
178
+ into structured profile fields. These are long-term memories
179
+ that persist across sessions.
180
+
181
+ Attributes:
182
+ user_id: Required unique identifier for the user.
183
+ memories: List of memory entries, each with 'id' and 'content'.
184
+ agent_id: Which agent created these memories.
185
+ team_id: Which team created these memories.
186
+ created_at: When the memories were created (ISO format).
187
+ updated_at: When the memories were last updated (ISO format).
188
+ """
189
+
190
+ user_id: str
191
+ memories: List[Dict[str, Any]] = field(default_factory=list)
192
+ agent_id: Optional[str] = field(default=None, metadata={"internal": True})
193
+ team_id: Optional[str] = field(default=None, metadata={"internal": True})
194
+ created_at: Optional[str] = field(default=None, metadata={"internal": True})
195
+ updated_at: Optional[str] = field(default=None, metadata={"internal": True})
196
+
197
+ @classmethod
198
+ def from_dict(cls, data: Any) -> Optional["Memories"]:
199
+ """Parse from dict/JSON, returning None on any failure.
200
+
201
+ Works with subclasses - automatically handles additional fields.
202
+ """
203
+ if data is None:
204
+ return None
205
+ if isinstance(data, cls):
206
+ return data
207
+
208
+ try:
209
+ parsed = _parse_json(data)
210
+ if not parsed:
211
+ log_debug(f"{cls.__name__}.from_dict: _parse_json returned None for data={_truncate_for_log(data)}")
212
+ return None
213
+
214
+ # user_id is required
215
+ if not parsed.get("user_id"):
216
+ log_debug(f"{cls.__name__}.from_dict: missing required field 'user_id'")
217
+ return None
218
+
219
+ # Get field names for this class (includes subclass fields)
220
+ field_names = {f.name for f in fields(cls)}
221
+ kwargs = {k: v for k, v in parsed.items() if k in field_names}
222
+
223
+ return cls(**kwargs)
224
+ except Exception as e:
225
+ log_debug(f"{cls.__name__}.from_dict failed: {e}, data={_truncate_for_log(data)}")
226
+ return None
227
+
228
+ def to_dict(self) -> Dict[str, Any]:
229
+ """Convert to dict. Works with subclasses."""
230
+ try:
231
+ return asdict(self)
232
+ except Exception as e:
233
+ log_debug(f"{self.__class__.__name__}.to_dict failed: {e}")
234
+ return {}
235
+
236
+ def add_memory(self, content: str, **kwargs) -> str:
237
+ """Add a new memory.
238
+
239
+ Args:
240
+ content: The memory text to add.
241
+ **kwargs: Additional fields (source, timestamp, etc.)
242
+
243
+ Returns:
244
+ The generated memory ID.
245
+ """
246
+ import uuid
247
+
248
+ memory_id = str(uuid.uuid4())[:8]
249
+
250
+ if content and content.strip():
251
+ self.memories.append({"id": memory_id, "content": content.strip(), **kwargs})
252
+
253
+ return memory_id
254
+
255
+ def get_memory(self, memory_id: str) -> Optional[Dict[str, Any]]:
256
+ """Get a specific memory by ID."""
257
+ for mem in self.memories:
258
+ if isinstance(mem, dict) and mem.get("id") == memory_id:
259
+ return mem
260
+ return None
261
+
262
+ def update_memory(self, memory_id: str, content: str, **kwargs) -> bool:
263
+ """Update an existing memory.
264
+
265
+ Returns:
266
+ True if memory was found and updated, False otherwise.
267
+ """
268
+ for mem in self.memories:
269
+ if isinstance(mem, dict) and mem.get("id") == memory_id:
270
+ mem["content"] = content.strip()
271
+ mem.update(kwargs)
272
+ return True
273
+ return False
274
+
275
+ def delete_memory(self, memory_id: str) -> bool:
276
+ """Delete a memory by ID.
277
+
278
+ Returns:
279
+ True if memory was found and deleted, False otherwise.
280
+ """
281
+ original_len = len(self.memories)
282
+ self.memories = [mem for mem in self.memories if not (isinstance(mem, dict) and mem.get("id") == memory_id)]
283
+ return len(self.memories) < original_len
284
+
285
+ def get_memories_text(self) -> str:
286
+ """Get all memories as a formatted string for prompts."""
287
+ if not self.memories:
288
+ return ""
289
+
290
+ lines = []
291
+ for m in self.memories:
292
+ content = m.get("content") if isinstance(m, dict) else str(m)
293
+ if content:
294
+ lines.append(f"- {content}")
295
+
296
+ return "\n".join(lines)
297
+
298
+ def __repr__(self) -> str:
299
+ return f"Memories(user_id={self.user_id})"
300
+
301
+
302
+ # =============================================================================
303
+ # Session Context Schema
304
+ # =============================================================================
305
+
306
+
307
+ @dataclass
308
+ class SessionContext:
309
+ """Schema for Session Context learning type.
310
+
311
+ Captures state and summary for the current session.
312
+ Unlike UserProfile which accumulates, this is REPLACED on each update.
313
+
314
+ Key behavior: Extraction receives the previous context and updates it,
315
+ ensuring continuity even when message history is truncated.
316
+
317
+ Attributes:
318
+ session_id: Required unique identifier for the session.
319
+ user_id: Which user this session belongs to.
320
+ summary: What's happened in this session.
321
+ goal: What the user is trying to accomplish.
322
+ plan: Steps to achieve the goal.
323
+ progress: Which steps have been completed.
324
+ agent_id: Which agent is running this session.
325
+ team_id: Which team is running this session.
326
+ created_at: When the session started (ISO format).
327
+ updated_at: When the context was last updated (ISO format).
328
+
329
+ Example - Extending with custom fields:
330
+ @dataclass
331
+ class MySessionContext(SessionContext):
332
+ mood: Optional[str] = field(
333
+ default=None,
334
+ metadata={"description": "User's current mood or emotional state"}
335
+ )
336
+ blockers: List[str] = field(
337
+ default_factory=list,
338
+ metadata={"description": "Current blockers or obstacles"}
339
+ )
340
+ """
341
+
342
+ session_id: str
343
+ user_id: Optional[str] = None
344
+ summary: Optional[str] = field(
345
+ default=None, metadata={"description": "Summary of what's been discussed in this session"}
346
+ )
347
+ goal: Optional[str] = field(default=None, metadata={"description": "What the user is trying to accomplish"})
348
+ plan: Optional[List[str]] = field(default=None, metadata={"description": "Steps to achieve the goal"})
349
+ progress: Optional[List[str]] = field(default=None, metadata={"description": "Which steps have been completed"})
350
+ agent_id: Optional[str] = field(default=None, metadata={"internal": True})
351
+ team_id: Optional[str] = field(default=None, metadata={"internal": True})
352
+ created_at: Optional[str] = field(default=None, metadata={"internal": True})
353
+ updated_at: Optional[str] = field(default=None, metadata={"internal": True})
354
+
355
+ @classmethod
356
+ def from_dict(cls, data: Any) -> Optional["SessionContext"]:
357
+ """Parse from dict/JSON, returning None on any failure."""
358
+ if data is None:
359
+ return None
360
+ if isinstance(data, cls):
361
+ return data
362
+
363
+ try:
364
+ parsed = _parse_json(data)
365
+ if not parsed:
366
+ log_debug(f"{cls.__name__}.from_dict: _parse_json returned None for data={_truncate_for_log(data)}")
367
+ return None
368
+
369
+ # session_id is required
370
+ if not parsed.get("session_id"):
371
+ log_debug(f"{cls.__name__}.from_dict: missing required field 'session_id'")
372
+ return None
373
+
374
+ field_names = {f.name for f in fields(cls)}
375
+ kwargs = {k: v for k, v in parsed.items() if k in field_names}
376
+
377
+ return cls(**kwargs)
378
+ except Exception as e:
379
+ log_debug(f"{cls.__name__}.from_dict failed: {e}, data={_truncate_for_log(data)}")
380
+ return None
381
+
382
+ def to_dict(self) -> Dict[str, Any]:
383
+ """Convert to dict."""
384
+ try:
385
+ return asdict(self)
386
+ except Exception as e:
387
+ log_debug(f"{self.__class__.__name__}.to_dict failed: {e}")
388
+ return {}
389
+
390
+ def get_context_text(self) -> str:
391
+ """Get session context as a formatted string for prompts."""
392
+ parts = []
393
+
394
+ if self.summary:
395
+ parts.append(f"Summary: {self.summary}")
396
+
397
+ if self.goal:
398
+ parts.append(f"Goal: {self.goal}")
399
+
400
+ if self.plan:
401
+ plan_text = "\n".join(f" {i + 1}. {step}" for i, step in enumerate(self.plan))
402
+ parts.append(f"Plan:\n{plan_text}")
403
+
404
+ if self.progress:
405
+ progress_text = "\n".join(f" ✓ {step}" for step in self.progress)
406
+ parts.append(f"Completed:\n{progress_text}")
407
+
408
+ return "\n\n".join(parts)
409
+
410
+ def __repr__(self) -> str:
411
+ return f"SessionContext(session_id={self.session_id})"
412
+
413
+
414
+ # =============================================================================
415
+ # Learned Knowledge Schema
416
+ # =============================================================================
417
+
418
+
419
+ @dataclass
420
+ class LearnedKnowledge:
421
+ """Schema for Learned Knowledge learning type.
422
+
423
+ Captures reusable insights that apply across users and agents.
424
+
425
+ - title: Short, descriptive title for the learning.
426
+ - learning: The actual insight or pattern.
427
+ - context: When/where this learning applies.
428
+ - tags: Categories for organization.
429
+ - namespace: Sharing boundary for this learning.
430
+
431
+ Example:
432
+ LearnedKnowledge(
433
+ title="Python async best practices",
434
+ learning="Always use asyncio.gather() for concurrent I/O tasks",
435
+ context="When optimizing I/O-bound Python applications",
436
+ tags=["python", "async", "performance"]
437
+ )
438
+ """
439
+
440
+ title: str
441
+ learning: str
442
+ context: Optional[str] = None
443
+ tags: Optional[List[str]] = None
444
+ user_id: Optional[str] = field(default=None, metadata={"internal": True})
445
+ namespace: Optional[str] = field(default=None, metadata={"internal": True})
446
+ agent_id: Optional[str] = field(default=None, metadata={"internal": True})
447
+ team_id: Optional[str] = field(default=None, metadata={"internal": True})
448
+ created_at: Optional[str] = field(default=None, metadata={"internal": True})
449
+ updated_at: Optional[str] = field(default=None, metadata={"internal": True})
450
+
451
+ @classmethod
452
+ def from_dict(cls, data: Any) -> Optional["LearnedKnowledge"]:
453
+ """Parse from dict/JSON, returning None on any failure."""
454
+ if data is None:
455
+ return None
456
+ if isinstance(data, cls):
457
+ return data
458
+
459
+ try:
460
+ parsed = _parse_json(data)
461
+ if not parsed:
462
+ log_debug(f"{cls.__name__}.from_dict: _parse_json returned None for data={_truncate_for_log(data)}")
463
+ return None
464
+
465
+ # title and learning are required
466
+ if not parsed.get("title") or not parsed.get("learning"):
467
+ log_debug(f"{cls.__name__}.from_dict: missing required fields 'title' or 'learning'")
468
+ return None
469
+
470
+ field_names = {f.name for f in fields(cls)}
471
+ kwargs = {k: v for k, v in parsed.items() if k in field_names}
472
+
473
+ return cls(**kwargs)
474
+ except Exception as e:
475
+ log_debug(f"{cls.__name__}.from_dict failed: {e}, data={_truncate_for_log(data)}")
476
+ return None
477
+
478
+ def to_dict(self) -> Dict[str, Any]:
479
+ """Convert to dict."""
480
+ try:
481
+ return asdict(self)
482
+ except Exception as e:
483
+ log_debug(f"{self.__class__.__name__}.to_dict failed: {e}")
484
+ return {}
485
+
486
+ def to_text(self) -> str:
487
+ """Convert learning to searchable text format for vector storage."""
488
+ parts = [f"Title: {self.title}", f"Learning: {self.learning}"]
489
+ if self.context:
490
+ parts.append(f"Context: {self.context}")
491
+ if self.tags:
492
+ parts.append(f"Tags: {', '.join(self.tags)}")
493
+ return "\n".join(parts)
494
+
495
+ def __repr__(self) -> str:
496
+ return f"LearnedKnowledge(title={self.title})"
497
+
498
+
499
+ # =============================================================================
500
+ # Entity Memory Schema
501
+ # =============================================================================
502
+
503
+
504
+ @dataclass
505
+ class EntityMemory:
506
+ """Schema for Entity Memory learning type.
507
+
508
+ Captures facts about third-party entities: companies, projects,
509
+ people, systems, products. Like UserProfile but for non-users.
510
+
511
+ Structure:
512
+ - **Core**: name, description, properties (key-value pairs)
513
+ - **Facts**: Semantic memory ("Acme uses PostgreSQL")
514
+ - **Events**: Episodic memory ("Acme launched v2 on Jan 15")
515
+ - **Relationships**: Graph edges ("Bob is CEO of Acme")
516
+
517
+ Common Entity Types:
518
+ - "company", "project", "person", "system", "product"
519
+ - Any string is valid.
520
+
521
+ Example:
522
+ EntityMemory(
523
+ entity_id="acme_corp",
524
+ entity_type="company",
525
+ name="Acme Corporation",
526
+ description="Enterprise software company",
527
+ properties={"industry": "fintech", "size": "startup"},
528
+ facts=[
529
+ {"id": "f1", "content": "Uses PostgreSQL for main database"},
530
+ {"id": "f2", "content": "API uses OAuth2 authentication"},
531
+ ],
532
+ events=[
533
+ {"id": "e1", "content": "Launched v2.0", "date": "2024-01-15"},
534
+ ],
535
+ relationships=[
536
+ {"entity_id": "bob_smith", "relation": "CEO"},
537
+ ],
538
+ )
539
+
540
+ Attributes:
541
+ entity_id: Unique identifier (lowercase, underscores: "acme_corp").
542
+ entity_type: Type of entity ("company", "project", "person", etc).
543
+ name: Display name for the entity.
544
+ description: Brief description of what this entity is.
545
+ properties: Key-value properties (industry, tech_stack, etc).
546
+ facts: Semantic memories - timeless facts about the entity.
547
+ events: Episodic memories - time-bound occurrences.
548
+ relationships: Connections to other entities.
549
+ namespace: Sharing boundary for this entity.
550
+ user_id: Owner user (if namespace="user").
551
+ agent_id: Which agent created this.
552
+ team_id: Which team context.
553
+ created_at: When first created.
554
+ updated_at: When last modified.
555
+ """
556
+
557
+ entity_id: str
558
+ entity_type: str = field(metadata={"description": "Type: company, project, person, system, product, etc"})
559
+
560
+ # Core properties
561
+ name: Optional[str] = field(default=None, metadata={"description": "Display name for the entity"})
562
+ description: Optional[str] = field(
563
+ default=None, metadata={"description": "Brief description of what this entity is"}
564
+ )
565
+ properties: Dict[str, str] = field(
566
+ default_factory=dict, metadata={"description": "Key-value properties (industry, tech_stack, etc)"}
567
+ )
568
+
569
+ # Semantic memory (facts)
570
+ facts: List[Dict[str, Any]] = field(default_factory=list)
571
+ # [{"id": "abc", "content": "Uses PostgreSQL", "confidence": 0.9, "source": "..."}]
572
+
573
+ # Episodic memory (events)
574
+ events: List[Dict[str, Any]] = field(default_factory=list)
575
+ # [{"id": "xyz", "content": "Had outage on 2024-01-15", "date": "2024-01-15"}]
576
+
577
+ # Relationships (graph edges)
578
+ relationships: List[Dict[str, Any]] = field(default_factory=list)
579
+ # [{"entity_id": "bob", "relation": "CEO", "direction": "incoming"}]
580
+
581
+ # Scope
582
+ namespace: Optional[str] = field(default=None, metadata={"internal": True})
583
+ user_id: Optional[str] = field(default=None, metadata={"internal": True})
584
+ agent_id: Optional[str] = field(default=None, metadata={"internal": True})
585
+ team_id: Optional[str] = field(default=None, metadata={"internal": True})
586
+ created_at: Optional[str] = field(default=None, metadata={"internal": True})
587
+ updated_at: Optional[str] = field(default=None, metadata={"internal": True})
588
+
589
+ @classmethod
590
+ def from_dict(cls, data: Any) -> Optional["EntityMemory"]:
591
+ """Parse from dict/JSON, returning None on any failure."""
592
+ if data is None:
593
+ return None
594
+ if isinstance(data, cls):
595
+ return data
596
+
597
+ try:
598
+ parsed = _parse_json(data)
599
+ if not parsed:
600
+ log_debug(f"{cls.__name__}.from_dict: _parse_json returned None for data={_truncate_for_log(data)}")
601
+ return None
602
+
603
+ # entity_id and entity_type are required
604
+ if not parsed.get("entity_id") or not parsed.get("entity_type"):
605
+ log_debug(f"{cls.__name__}.from_dict: missing required fields 'entity_id' or 'entity_type'")
606
+ return None
607
+
608
+ field_names = {f.name for f in fields(cls)}
609
+ kwargs = {k: v for k, v in parsed.items() if k in field_names}
610
+
611
+ return cls(**kwargs)
612
+ except Exception as e:
613
+ log_debug(f"{cls.__name__}.from_dict failed: {e}, data={_truncate_for_log(data)}")
614
+ return None
615
+
616
+ def to_dict(self) -> Dict[str, Any]:
617
+ """Convert to dict."""
618
+ try:
619
+ return asdict(self)
620
+ except Exception as e:
621
+ log_debug(f"{self.__class__.__name__}.to_dict failed: {e}")
622
+ return {}
623
+
624
+ def add_fact(self, content: str, **kwargs) -> str:
625
+ """Add a new fact to the entity.
626
+
627
+ Args:
628
+ content: The fact text.
629
+ **kwargs: Additional fields (confidence, source, etc).
630
+
631
+ Returns:
632
+ The generated fact ID.
633
+ """
634
+ import uuid
635
+
636
+ fact_id = str(uuid.uuid4())[:8]
637
+
638
+ if content and content.strip():
639
+ self.facts.append({"id": fact_id, "content": content.strip(), **kwargs})
640
+
641
+ return fact_id
642
+
643
+ def add_event(self, content: str, date: Optional[str] = None, **kwargs) -> str:
644
+ """Add a new event to the entity.
645
+
646
+ Args:
647
+ content: The event description.
648
+ date: When the event occurred (ISO format or natural language).
649
+ **kwargs: Additional fields.
650
+
651
+ Returns:
652
+ The generated event ID.
653
+ """
654
+ import uuid
655
+
656
+ event_id = str(uuid.uuid4())[:8]
657
+
658
+ if content and content.strip():
659
+ event = {"id": event_id, "content": content.strip(), **kwargs}
660
+ if date:
661
+ event["date"] = date
662
+ self.events.append(event)
663
+
664
+ return event_id
665
+
666
+ def add_relationship(self, related_entity_id: str, relation: str, direction: str = "outgoing", **kwargs) -> str:
667
+ """Add a relationship to another entity.
668
+
669
+ Args:
670
+ related_entity_id: The other entity's ID.
671
+ relation: The relationship type ("CEO", "owns", "part_of", etc).
672
+ direction: "outgoing" (this → other) or "incoming" (other → this).
673
+ **kwargs: Additional fields.
674
+
675
+ Returns:
676
+ The generated relationship ID.
677
+ """
678
+ import uuid
679
+
680
+ rel_id = str(uuid.uuid4())[:8]
681
+
682
+ self.relationships.append(
683
+ {"id": rel_id, "entity_id": related_entity_id, "relation": relation, "direction": direction, **kwargs}
684
+ )
685
+
686
+ return rel_id
687
+
688
+ def get_fact(self, fact_id: str) -> Optional[Dict[str, Any]]:
689
+ """Get a specific fact by ID."""
690
+ for fact in self.facts:
691
+ if isinstance(fact, dict) and fact.get("id") == fact_id:
692
+ return fact
693
+ return None
694
+
695
+ def update_fact(self, fact_id: str, content: str, **kwargs) -> bool:
696
+ """Update an existing fact.
697
+
698
+ Returns:
699
+ True if fact was found and updated, False otherwise.
700
+ """
701
+ for fact in self.facts:
702
+ if isinstance(fact, dict) and fact.get("id") == fact_id:
703
+ fact["content"] = content.strip()
704
+ fact.update(kwargs)
705
+ return True
706
+ return False
707
+
708
+ def delete_fact(self, fact_id: str) -> bool:
709
+ """Delete a fact by ID.
710
+
711
+ Returns:
712
+ True if fact was found and deleted, False otherwise.
713
+ """
714
+ original_len = len(self.facts)
715
+ self.facts = [f for f in self.facts if not (isinstance(f, dict) and f.get("id") == fact_id)]
716
+ return len(self.facts) < original_len
717
+
718
+ def get_context_text(self) -> str:
719
+ """Get entity as formatted string for prompts."""
720
+ parts = []
721
+
722
+ if self.name:
723
+ parts.append(f"**{self.name}** ({self.entity_type})")
724
+ else:
725
+ parts.append(f"**{self.entity_id}** ({self.entity_type})")
726
+
727
+ if self.description:
728
+ parts.append(self.description)
729
+
730
+ if self.properties:
731
+ props = ", ".join(f"{k}: {v}" for k, v in self.properties.items())
732
+ parts.append(f"Properties: {props}")
733
+
734
+ if self.facts:
735
+ facts_text = "\n".join(f" - {f.get('content', f)}" for f in self.facts)
736
+ parts.append(f"Facts:\n{facts_text}")
737
+
738
+ if self.events:
739
+ events_text = "\n".join(
740
+ f" - {e.get('content', e)}" + (f" ({e.get('date')})" if e.get("date") else "") for e in self.events
741
+ )
742
+ parts.append(f"Events:\n{events_text}")
743
+
744
+ if self.relationships:
745
+ rels_text = "\n".join(f" - {r.get('relation')}: {r.get('entity_id')}" for r in self.relationships)
746
+ parts.append(f"Relationships:\n{rels_text}")
747
+
748
+ return "\n\n".join(parts)
749
+
750
+ @classmethod
751
+ def get_updateable_fields(cls) -> Dict[str, Dict[str, Any]]:
752
+ """Get fields that can be updated via update tools.
753
+
754
+ Returns:
755
+ Dict mapping field name to field info including description.
756
+ Excludes internal fields and collections (facts, events, relationships).
757
+ """
758
+ skip = {
759
+ "entity_id",
760
+ "entity_type",
761
+ "facts",
762
+ "events",
763
+ "relationships",
764
+ "namespace",
765
+ "user_id",
766
+ "agent_id",
767
+ "team_id",
768
+ "created_at",
769
+ "updated_at",
770
+ }
771
+
772
+ result = {}
773
+ for f in fields(cls):
774
+ if f.name in skip:
775
+ continue
776
+ if f.metadata.get("internal"):
777
+ continue
778
+
779
+ result[f.name] = {
780
+ "type": f.type,
781
+ "description": f.metadata.get("description", f"Entity's {f.name.replace('_', ' ')}"),
782
+ }
783
+
784
+ return result
785
+
786
+ def __repr__(self) -> str:
787
+ return f"EntityMemory(entity_id={self.entity_id})"
788
+
789
+
790
+ # =============================================================================
791
+ # Extraction Response Models (internal use by stores)
792
+ # =============================================================================
793
+
794
+
795
+ @dataclass
796
+ class UserProfileExtractionResponse:
797
+ """Response model for user profile extraction from LLM.
798
+
799
+ Used internally by UserProfileStore during background extraction.
800
+ """
801
+
802
+ name: Optional[str] = None
803
+ preferred_name: Optional[str] = None
804
+ new_memories: List[str] = field(default_factory=list)
805
+
806
+ @classmethod
807
+ def from_dict(cls, data: Any) -> Optional["UserProfileExtractionResponse"]:
808
+ """Parse from dict/JSON, returning None on any failure."""
809
+ if data is None:
810
+ return None
811
+ if isinstance(data, cls):
812
+ return data
813
+
814
+ try:
815
+ parsed = _parse_json(data)
816
+ if not parsed:
817
+ log_debug(f"{cls.__name__}.from_dict: _parse_json returned None for data={_truncate_for_log(data)}")
818
+ return None
819
+
820
+ return cls(
821
+ name=_safe_get(parsed, "name"),
822
+ preferred_name=_safe_get(parsed, "preferred_name"),
823
+ new_memories=_safe_get(parsed, "new_memories") or [],
824
+ )
825
+ except Exception as e:
826
+ log_debug(f"{cls.__name__}.from_dict failed: {e}, data={_truncate_for_log(data)}")
827
+ return None
828
+
829
+
830
+ @dataclass
831
+ class SessionSummaryExtractionResponse:
832
+ """Response model for summary-only session extraction from LLM."""
833
+
834
+ summary: str = ""
835
+
836
+ @classmethod
837
+ def from_dict(cls, data: Any) -> Optional["SessionSummaryExtractionResponse"]:
838
+ """Parse from dict/JSON, returning None on any failure."""
839
+ if data is None:
840
+ return None
841
+ if isinstance(data, cls):
842
+ return data
843
+
844
+ try:
845
+ parsed = _parse_json(data)
846
+ if not parsed:
847
+ log_debug(f"{cls.__name__}.from_dict: _parse_json returned None for data={_truncate_for_log(data)}")
848
+ return None
849
+
850
+ return cls(summary=_safe_get(parsed, "summary") or "")
851
+ except Exception as e:
852
+ log_debug(f"{cls.__name__}.from_dict failed: {e}, data={_truncate_for_log(data)}")
853
+ return None
854
+
855
+
856
+ @dataclass
857
+ class SessionPlanningExtractionResponse:
858
+ """Response model for full planning extraction from LLM."""
859
+
860
+ summary: str = ""
861
+ goal: Optional[str] = None
862
+ plan: Optional[List[str]] = None
863
+ progress: Optional[List[str]] = None
864
+
865
+ @classmethod
866
+ def from_dict(cls, data: Any) -> Optional["SessionPlanningExtractionResponse"]:
867
+ """Parse from dict/JSON, returning None on any failure."""
868
+ if data is None:
869
+ return None
870
+ if isinstance(data, cls):
871
+ return data
872
+
873
+ try:
874
+ parsed = _parse_json(data)
875
+ if not parsed:
876
+ log_debug(f"{cls.__name__}.from_dict: _parse_json returned None for data={_truncate_for_log(data)}")
877
+ return None
878
+
879
+ return cls(
880
+ summary=_safe_get(parsed, "summary") or "",
881
+ goal=_safe_get(parsed, "goal"),
882
+ plan=_safe_get(parsed, "plan"),
883
+ progress=_safe_get(parsed, "progress"),
884
+ )
885
+ except Exception as e:
886
+ log_debug(f"{cls.__name__}.from_dict failed: {e}, data={_truncate_for_log(data)}")
887
+ return None
888
+
889
+
890
+ # =============================================================================
891
+ # Phase 2 Schemas (Placeholders)
892
+ # =============================================================================
893
+
894
+
895
+ @dataclass
896
+ class Decision:
897
+ """Schema for Decision Logs. (Phase 2)
898
+
899
+ Records decisions made by the agent with reasoning and context.
900
+ """
901
+
902
+ decision: str
903
+ reasoning: Optional[str] = None
904
+ context: Optional[str] = None
905
+ outcome: Optional[str] = None
906
+ agent_id: Optional[str] = None
907
+ team_id: Optional[str] = None
908
+ created_at: Optional[str] = None
909
+
910
+ @classmethod
911
+ def from_dict(cls, data: Any) -> Optional["Decision"]:
912
+ """Parse from dict/JSON, returning None on any failure."""
913
+ if data is None:
914
+ return None
915
+ if isinstance(data, cls):
916
+ return data
917
+
918
+ try:
919
+ parsed = _parse_json(data)
920
+ if not parsed:
921
+ log_debug(f"{cls.__name__}.from_dict: _parse_json returned None for data={_truncate_for_log(data)}")
922
+ return None
923
+
924
+ if not parsed.get("decision"):
925
+ log_debug(f"{cls.__name__}.from_dict: missing required field 'decision'")
926
+ return None
927
+
928
+ field_names = {f.name for f in fields(cls)}
929
+ kwargs = {k: v for k, v in parsed.items() if k in field_names}
930
+
931
+ return cls(**kwargs)
932
+ except Exception as e:
933
+ log_debug(f"{cls.__name__}.from_dict failed: {e}, data={_truncate_for_log(data)}")
934
+ return None
935
+
936
+ def to_dict(self) -> Dict[str, Any]:
937
+ """Convert to dict."""
938
+ try:
939
+ return asdict(self)
940
+ except Exception as e:
941
+ log_debug(f"{self.__class__.__name__}.to_dict failed: {e}")
942
+ return {}
943
+
944
+
945
+ @dataclass
946
+ class Feedback:
947
+ """Schema for Behavioral Feedback. (Phase 2)
948
+
949
+ Captures signals about what worked and what didn't.
950
+ """
951
+
952
+ signal: str # thumbs_up, thumbs_down, correction, regeneration
953
+ learning: Optional[str] = None
954
+ context: Optional[str] = None
955
+ agent_id: Optional[str] = None
956
+ team_id: Optional[str] = None
957
+ created_at: Optional[str] = None
958
+
959
+ @classmethod
960
+ def from_dict(cls, data: Any) -> Optional["Feedback"]:
961
+ """Parse from dict/JSON, returning None on any failure."""
962
+ if data is None:
963
+ return None
964
+ if isinstance(data, cls):
965
+ return data
966
+
967
+ try:
968
+ parsed = _parse_json(data)
969
+ if not parsed:
970
+ log_debug(f"{cls.__name__}.from_dict: _parse_json returned None for data={_truncate_for_log(data)}")
971
+ return None
972
+
973
+ if not parsed.get("signal"):
974
+ log_debug(f"{cls.__name__}.from_dict: missing required field 'signal'")
975
+ return None
976
+
977
+ field_names = {f.name for f in fields(cls)}
978
+ kwargs = {k: v for k, v in parsed.items() if k in field_names}
979
+
980
+ return cls(**kwargs)
981
+ except Exception as e:
982
+ log_debug(f"{cls.__name__}.from_dict failed: {e}, data={_truncate_for_log(data)}")
983
+ return None
984
+
985
+ def to_dict(self) -> Dict[str, Any]:
986
+ """Convert to dict."""
987
+ try:
988
+ return asdict(self)
989
+ except Exception as e:
990
+ log_debug(f"{self.__class__.__name__}.to_dict failed: {e}")
991
+ return {}
992
+
993
+
994
+ @dataclass
995
+ class InstructionUpdate:
996
+ """Schema for Self-Improvement. (Phase 3)
997
+
998
+ Proposes updates to agent instructions based on feedback patterns.
999
+ """
1000
+
1001
+ current_instruction: str
1002
+ proposed_instruction: str
1003
+ reasoning: str
1004
+ evidence: Optional[List[str]] = None
1005
+ agent_id: Optional[str] = None
1006
+ team_id: Optional[str] = None
1007
+ created_at: Optional[str] = None
1008
+
1009
+ @classmethod
1010
+ def from_dict(cls, data: Any) -> Optional["InstructionUpdate"]:
1011
+ """Parse from dict/JSON, returning None on any failure."""
1012
+ if data is None:
1013
+ return None
1014
+ if isinstance(data, cls):
1015
+ return data
1016
+
1017
+ try:
1018
+ parsed = _parse_json(data)
1019
+ if not parsed:
1020
+ log_debug(f"{cls.__name__}.from_dict: _parse_json returned None for data={_truncate_for_log(data)}")
1021
+ return None
1022
+
1023
+ required = ["current_instruction", "proposed_instruction", "reasoning"]
1024
+ missing = [k for k in required if not parsed.get(k)]
1025
+ if missing:
1026
+ log_debug(f"{cls.__name__}.from_dict: missing required fields {missing}")
1027
+ return None
1028
+
1029
+ field_names = {f.name for f in fields(cls)}
1030
+ kwargs = {k: v for k, v in parsed.items() if k in field_names}
1031
+
1032
+ return cls(**kwargs)
1033
+ except Exception as e:
1034
+ log_debug(f"{cls.__name__}.from_dict failed: {e}, data={_truncate_for_log(data)}")
1035
+ return None
1036
+
1037
+ def to_dict(self) -> Dict[str, Any]:
1038
+ """Convert to dict."""
1039
+ try:
1040
+ return asdict(self)
1041
+ except Exception as e:
1042
+ log_debug(f"{self.__class__.__name__}.to_dict failed: {e}")
1043
+ return {}