agno 2.3.24__py3-none-any.whl → 2.3.26__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 (70) hide show
  1. agno/agent/agent.py +357 -28
  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/agents/router.py +4 -4
  57. agno/os/routers/knowledge/knowledge.py +7 -0
  58. agno/os/routers/teams/router.py +3 -3
  59. agno/os/routers/workflows/router.py +5 -5
  60. agno/os/utils.py +55 -3
  61. agno/team/team.py +131 -0
  62. agno/tools/browserbase.py +78 -6
  63. agno/tools/google_bigquery.py +11 -2
  64. agno/utils/agent.py +30 -1
  65. agno/workflow/workflow.py +198 -0
  66. {agno-2.3.24.dist-info → agno-2.3.26.dist-info}/METADATA +24 -2
  67. {agno-2.3.24.dist-info → agno-2.3.26.dist-info}/RECORD +70 -56
  68. {agno-2.3.24.dist-info → agno-2.3.26.dist-info}/WHEEL +0 -0
  69. {agno-2.3.24.dist-info → agno-2.3.26.dist-info}/licenses/LICENSE +0 -0
  70. {agno-2.3.24.dist-info → agno-2.3.26.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1220 @@
1
+ """
2
+ User Profile Store
3
+ ==================
4
+ Storage backend for User Profile learning type.
5
+
6
+ Stores long-term structured profile fields about users that persist across sessions.
7
+
8
+ Key Features:
9
+ - Structured profile fields (name, preferred_name, and custom fields)
10
+ - Background extraction from conversations
11
+ - Agent tools for in-conversation updates
12
+ - Multi-user isolation (each user has their own profile)
13
+
14
+ Profile Fields (structured):
15
+ - name, preferred_name, and custom fields from extended schemas
16
+ - Updated via `update_profile` tool
17
+ - For concrete facts that fit defined schema fields
18
+
19
+ Note: For unstructured memories, use UserMemoryStore instead.
20
+
21
+ Scope:
22
+ - Profiles are retrieved by user_id only
23
+ - agent_id/team_id stored in DB columns for audit trail
24
+
25
+ Supported Modes:
26
+ - ALWAYS: Automatic extraction after conversations
27
+ - AGENTIC: Agent calls update_user_profile tool directly
28
+ """
29
+
30
+ import inspect
31
+ from copy import deepcopy
32
+ from dataclasses import dataclass, field
33
+ from dataclasses import fields as dc_fields
34
+ from os import getenv
35
+ from textwrap import dedent
36
+ from typing import Any, Callable, Dict, List, Optional, Union, cast
37
+
38
+ from agno.learn.config import LearningMode, UserProfileConfig
39
+ from agno.learn.schemas import UserProfile
40
+ from agno.learn.stores.protocol import LearningStore
41
+ from agno.learn.utils import from_dict_safe, to_dict_safe
42
+ from agno.utils.log import (
43
+ log_debug,
44
+ log_warning,
45
+ set_log_level_to_debug,
46
+ set_log_level_to_info,
47
+ )
48
+
49
+ try:
50
+ from agno.db.base import AsyncBaseDb, BaseDb
51
+ from agno.models.message import Message
52
+ from agno.tools.function import Function
53
+ except ImportError:
54
+ pass
55
+
56
+
57
+ @dataclass
58
+ class UserProfileStore(LearningStore):
59
+ """Storage backend for User Profile learning type.
60
+
61
+ Profiles are retrieved by user_id only - all agents sharing the same DB
62
+ will see the same profile for a given user. agent_id and team_id are
63
+ stored for audit purposes in DB columns.
64
+
65
+ Profile Fields (structured): name, preferred_name, and any custom
66
+ fields added when extending the schema. Updated via `update_profile` tool.
67
+
68
+ Note: For unstructured memories, use UserMemoryStore instead.
69
+
70
+ Args:
71
+ config: UserProfileConfig with all settings including db and model.
72
+ debug_mode: Enable debug logging.
73
+ """
74
+
75
+ config: UserProfileConfig = field(default_factory=UserProfileConfig)
76
+ debug_mode: bool = False
77
+
78
+ # State tracking (internal)
79
+ profile_updated: bool = field(default=False, init=False)
80
+ _schema: Any = field(default=None, init=False)
81
+
82
+ def __post_init__(self):
83
+ self._schema = self.config.schema or UserProfile
84
+
85
+ if self.config.mode == LearningMode.PROPOSE:
86
+ log_warning("UserProfileStore does not support PROPOSE mode.")
87
+ elif self.config.mode == LearningMode.HITL:
88
+ log_warning("UserProfileStore does not support HITL mode.")
89
+
90
+ # =========================================================================
91
+ # LearningStore Protocol Implementation
92
+ # =========================================================================
93
+
94
+ @property
95
+ def learning_type(self) -> str:
96
+ """Unique identifier for this learning type."""
97
+ return "user_profile"
98
+
99
+ @property
100
+ def schema(self) -> Any:
101
+ """Schema class used for profiles."""
102
+ return self._schema
103
+
104
+ def recall(self, user_id: str, **kwargs) -> Optional[Any]:
105
+ """Retrieve user profile from storage.
106
+
107
+ Args:
108
+ user_id: The user to retrieve profile for (required).
109
+ **kwargs: Additional context (ignored).
110
+
111
+ Returns:
112
+ User profile, or None if not found.
113
+ """
114
+ if not user_id:
115
+ return None
116
+ return self.get(user_id=user_id)
117
+
118
+ async def arecall(self, user_id: str, **kwargs) -> Optional[Any]:
119
+ """Async version of recall."""
120
+ if not user_id:
121
+ return None
122
+ return await self.aget(user_id=user_id)
123
+
124
+ def process(
125
+ self,
126
+ messages: List[Any],
127
+ user_id: str,
128
+ agent_id: Optional[str] = None,
129
+ team_id: Optional[str] = None,
130
+ **kwargs,
131
+ ) -> None:
132
+ """Extract user profile from messages.
133
+
134
+ Args:
135
+ messages: Conversation messages to analyze.
136
+ user_id: The user to update profile for (required).
137
+ agent_id: Agent context (stored for audit).
138
+ team_id: Team context (stored for audit).
139
+ **kwargs: Additional context (ignored).
140
+ """
141
+ # process only supported in ALWAYS mode
142
+ # for programmatic extraction, use extract_and_save directly
143
+ if self.config.mode != LearningMode.ALWAYS:
144
+ return
145
+
146
+ if not user_id or not messages:
147
+ return
148
+
149
+ self.extract_and_save(
150
+ messages=messages,
151
+ user_id=user_id,
152
+ agent_id=agent_id,
153
+ team_id=team_id,
154
+ )
155
+
156
+ async def aprocess(
157
+ self,
158
+ messages: List[Any],
159
+ user_id: str,
160
+ agent_id: Optional[str] = None,
161
+ team_id: Optional[str] = None,
162
+ **kwargs,
163
+ ) -> None:
164
+ """Async version of process."""
165
+ if self.config.mode != LearningMode.ALWAYS:
166
+ return
167
+
168
+ if not user_id or not messages:
169
+ return
170
+
171
+ await self.aextract_and_save(
172
+ messages=messages,
173
+ user_id=user_id,
174
+ agent_id=agent_id,
175
+ team_id=team_id,
176
+ )
177
+
178
+ def build_context(self, data: Any) -> str:
179
+ """Build context for the agent.
180
+
181
+ Formats user profile data for injection into the agent's system prompt.
182
+ Designed to enable natural, personalized responses without meta-commentary
183
+ about memory systems.
184
+
185
+ Args:
186
+ data: User profile data from recall().
187
+
188
+ Returns:
189
+ Context string to inject into the agent's system prompt.
190
+ """
191
+ # Build tool documentation based on what's enabled
192
+ tool_docs = self._build_tool_documentation()
193
+
194
+ if not data:
195
+ if self._should_expose_tools:
196
+ return (
197
+ dedent("""\
198
+ <user_profile>
199
+ No profile information saved about this user yet.
200
+
201
+ """)
202
+ + tool_docs
203
+ + dedent("""
204
+ </user_profile>""")
205
+ )
206
+ return ""
207
+
208
+ # Build profile fields section
209
+ profile_parts = []
210
+ updateable_fields = self._get_updateable_fields()
211
+ for field_name in updateable_fields:
212
+ value = getattr(data, field_name, None)
213
+ if value:
214
+ profile_parts.append(f"{field_name.replace('_', ' ').title()}: {value}")
215
+
216
+ if not profile_parts:
217
+ if self._should_expose_tools:
218
+ return (
219
+ dedent("""\
220
+ <user_profile>
221
+ No profile information saved about this user yet.
222
+
223
+ """)
224
+ + tool_docs
225
+ + dedent("""
226
+ </user_profile>""")
227
+ )
228
+ return ""
229
+
230
+ context = "<user_profile>\n"
231
+ context += "\n".join(profile_parts) + "\n"
232
+
233
+ context += dedent("""
234
+ <profile_application_guidelines>
235
+ Apply this knowledge naturally - respond as if you inherently know this information,
236
+ exactly as a colleague would recall shared history without narrating their thought process.
237
+
238
+ - Use profile information to personalize responses appropriately
239
+ - Never say "based on your profile" or "I see that" - just use the information naturally
240
+ - Current conversation always takes precedence over stored profile data
241
+ </profile_application_guidelines>""")
242
+
243
+ if self._should_expose_tools:
244
+ context += (
245
+ dedent("""
246
+
247
+ <profile_updates>
248
+ """)
249
+ + tool_docs
250
+ + dedent("""
251
+ </profile_updates>""")
252
+ )
253
+
254
+ context += "\n</user_profile>"
255
+
256
+ return context
257
+
258
+ def _build_tool_documentation(self) -> str:
259
+ """Build documentation for available profile tools.
260
+
261
+ Returns:
262
+ String documenting which tools are available and when to use them.
263
+ """
264
+ docs = []
265
+
266
+ if self.config.agent_can_update_profile:
267
+ # Get the actual field names to document
268
+ updateable_fields = self._get_updateable_fields()
269
+ if updateable_fields:
270
+ field_names = ", ".join(updateable_fields.keys())
271
+ docs.append(
272
+ f"Use `update_profile` to set structured profile fields ({field_names}) "
273
+ "when the user explicitly shares this information."
274
+ )
275
+
276
+ return "\n\n".join(docs) if docs else ""
277
+
278
+ def get_tools(
279
+ self,
280
+ user_id: Optional[str] = None,
281
+ agent_id: Optional[str] = None,
282
+ team_id: Optional[str] = None,
283
+ **kwargs,
284
+ ) -> List[Callable]:
285
+ """Get tools to expose to agent.
286
+
287
+ Args:
288
+ user_id: The user context (required for tool to work).
289
+ agent_id: Agent context (stored for audit).
290
+ team_id: Team context (stored for audit).
291
+ **kwargs: Additional context (ignored).
292
+
293
+ Returns:
294
+ List containing update_profile tool if enabled.
295
+ """
296
+ if not user_id or not self._should_expose_tools:
297
+ return []
298
+ return self.get_agent_tools(
299
+ user_id=user_id,
300
+ agent_id=agent_id,
301
+ team_id=team_id,
302
+ )
303
+
304
+ async def aget_tools(
305
+ self,
306
+ user_id: Optional[str] = None,
307
+ agent_id: Optional[str] = None,
308
+ team_id: Optional[str] = None,
309
+ **kwargs,
310
+ ) -> List[Callable]:
311
+ """Async version of get_tools."""
312
+ if not user_id or not self._should_expose_tools:
313
+ return []
314
+ return await self.aget_agent_tools(
315
+ user_id=user_id,
316
+ agent_id=agent_id,
317
+ team_id=team_id,
318
+ )
319
+
320
+ @property
321
+ def was_updated(self) -> bool:
322
+ """Check if profile was updated in last operation."""
323
+ return self.profile_updated
324
+
325
+ @property
326
+ def _should_expose_tools(self) -> bool:
327
+ """Check if tools should be exposed to the agent.
328
+
329
+ Returns True if either:
330
+ - mode is AGENTIC (tools are the primary way to update memory), OR
331
+ - enable_agent_tools is explicitly True
332
+ """
333
+ return self.config.mode == LearningMode.AGENTIC or self.config.enable_agent_tools
334
+
335
+ # =========================================================================
336
+ # Properties
337
+ # =========================================================================
338
+
339
+ @property
340
+ def db(self) -> Optional[Union["BaseDb", "AsyncBaseDb"]]:
341
+ """Database backend."""
342
+ return self.config.db
343
+
344
+ @property
345
+ def model(self):
346
+ """Model for extraction."""
347
+ return self.config.model
348
+
349
+ # =========================================================================
350
+ # Debug/Logging
351
+ # =========================================================================
352
+
353
+ def set_log_level(self):
354
+ """Set log level based on debug_mode or environment variable."""
355
+ if self.debug_mode or getenv("AGNO_DEBUG", "false").lower() == "true":
356
+ self.debug_mode = True
357
+ set_log_level_to_debug()
358
+ else:
359
+ set_log_level_to_info()
360
+
361
+ # =========================================================================
362
+ # Schema Field Introspection
363
+ # =========================================================================
364
+
365
+ def _get_updateable_fields(self) -> Dict[str, Dict[str, Any]]:
366
+ """Get schema fields that can be updated via update_profile tool.
367
+
368
+ Returns:
369
+ Dict mapping field name to field info including description.
370
+ Excludes internal fields (user_id, memories, timestamps, etc).
371
+ """
372
+ # Use schema method if available
373
+ if hasattr(self.schema, "get_updateable_fields"):
374
+ return self.schema.get_updateable_fields()
375
+
376
+ # Fallback: introspect dataclass fields
377
+ skip = {"user_id", "memories", "created_at", "updated_at", "agent_id", "team_id"}
378
+
379
+ result = {}
380
+ for f in dc_fields(self.schema):
381
+ if f.name in skip:
382
+ continue
383
+ # Skip fields marked as internal
384
+ if f.metadata.get("internal"):
385
+ continue
386
+
387
+ result[f.name] = {
388
+ "type": f.type,
389
+ "description": f.metadata.get("description", f"User's {f.name.replace('_', ' ')}"),
390
+ }
391
+
392
+ return result
393
+
394
+ def _build_update_profile_tool(
395
+ self,
396
+ user_id: str,
397
+ agent_id: Optional[str] = None,
398
+ team_id: Optional[str] = None,
399
+ ) -> Optional[Callable]:
400
+ """Build a typed update_profile tool dynamically from schema.
401
+
402
+ Creates a function with explicit parameters for each schema field,
403
+ giving the LLM clear typed parameters to work with.
404
+ """
405
+ updateable = self._get_updateable_fields()
406
+
407
+ if not updateable:
408
+ return None
409
+
410
+ # Build parameter list for signature
411
+ params = [
412
+ inspect.Parameter(
413
+ name=field_name,
414
+ kind=inspect.Parameter.KEYWORD_ONLY,
415
+ default=None,
416
+ annotation=Optional[str], # Simplified to str for LLM compatibility
417
+ )
418
+ for field_name in updateable
419
+ ]
420
+
421
+ # Build docstring with field descriptions
422
+ fields_doc = "\n".join(f" {name}: {info['description']}" for name, info in updateable.items())
423
+
424
+ docstring = f"""Update user profile fields.
425
+
426
+ Use this to update structured information about the user.
427
+ Only provide fields you want to update.
428
+
429
+ Args:
430
+ {fields_doc}
431
+
432
+ Returns:
433
+ Confirmation of updated fields.
434
+
435
+ Examples:
436
+ update_profile(name="Alice")
437
+ update_profile(name="Bob", preferred_name="Bobby")
438
+ """
439
+
440
+ # Capture self and IDs in closure
441
+ store = self
442
+
443
+ def update_profile(**kwargs) -> str:
444
+ try:
445
+ profile = store.get(user_id=user_id)
446
+ if profile is None:
447
+ profile = store.schema(user_id=user_id)
448
+
449
+ changed = []
450
+ for field_name, value in kwargs.items():
451
+ if value is not None and field_name in updateable:
452
+ setattr(profile, field_name, value)
453
+ changed.append(f"{field_name}={value}")
454
+
455
+ if changed:
456
+ store.save(
457
+ user_id=user_id,
458
+ profile=profile,
459
+ agent_id=agent_id,
460
+ team_id=team_id,
461
+ )
462
+ log_debug(f"Profile fields updated: {', '.join(changed)}")
463
+ return f"Profile updated: {', '.join(changed)}"
464
+
465
+ return "No fields provided to update"
466
+
467
+ except Exception as e:
468
+ log_warning(f"Error updating profile: {e}")
469
+ return f"Error: {e}"
470
+
471
+ # Set the signature, docstring, and annotations
472
+ # Use cast to satisfy mypy - all Python functions have these attributes
473
+ func = cast(Any, update_profile)
474
+ func.__signature__ = inspect.Signature(params)
475
+ func.__doc__ = docstring
476
+ func.__name__ = "update_profile"
477
+ func.__annotations__ = {field_name: Optional[str] for field_name in updateable}
478
+ func.__annotations__["return"] = str
479
+
480
+ return update_profile
481
+
482
+ async def _abuild_update_profile_tool(
483
+ self,
484
+ user_id: str,
485
+ agent_id: Optional[str] = None,
486
+ team_id: Optional[str] = None,
487
+ ) -> Optional[Callable]:
488
+ """Async version of _build_update_profile_tool."""
489
+ updateable = self._get_updateable_fields()
490
+
491
+ if not updateable:
492
+ return None
493
+
494
+ params = [
495
+ inspect.Parameter(
496
+ name=field_name,
497
+ kind=inspect.Parameter.KEYWORD_ONLY,
498
+ default=None,
499
+ annotation=Optional[str],
500
+ )
501
+ for field_name in updateable
502
+ ]
503
+
504
+ fields_doc = "\n".join(f" {name}: {info['description']}" for name, info in updateable.items())
505
+
506
+ docstring = f"""Update user profile fields.
507
+
508
+ Use this to update structured information about the user.
509
+ Only provide fields you want to update.
510
+
511
+ Args:
512
+ {fields_doc}
513
+
514
+ Returns:
515
+ Confirmation of updated fields.
516
+ """
517
+
518
+ store = self
519
+
520
+ async def update_profile(**kwargs) -> str:
521
+ try:
522
+ profile = await store.aget(user_id=user_id)
523
+ if profile is None:
524
+ profile = store.schema(user_id=user_id)
525
+
526
+ changed = []
527
+ for field_name, value in kwargs.items():
528
+ if value is not None and field_name in updateable:
529
+ setattr(profile, field_name, value)
530
+ changed.append(f"{field_name}={value}")
531
+
532
+ if changed:
533
+ await store.asave(
534
+ user_id=user_id,
535
+ profile=profile,
536
+ agent_id=agent_id,
537
+ team_id=team_id,
538
+ )
539
+ log_debug(f"Profile fields updated: {', '.join(changed)}")
540
+ return f"Profile updated: {', '.join(changed)}"
541
+
542
+ return "No fields provided to update"
543
+
544
+ except Exception as e:
545
+ log_warning(f"Error updating profile: {e}")
546
+ return f"Error: {e}"
547
+
548
+ # Set the signature, docstring, and annotations
549
+ # Use cast to satisfy mypy - all Python functions have these attributes
550
+ func = cast(Any, update_profile)
551
+ func.__signature__ = inspect.Signature(params)
552
+ func.__doc__ = docstring
553
+ func.__name__ = "update_profile"
554
+ func.__annotations__ = {field_name: Optional[str] for field_name in updateable}
555
+ func.__annotations__["return"] = str
556
+
557
+ return update_profile
558
+
559
+ # =========================================================================
560
+ # Agent Tools
561
+ # =========================================================================
562
+
563
+ def get_agent_tools(
564
+ self,
565
+ user_id: str,
566
+ agent_id: Optional[str] = None,
567
+ team_id: Optional[str] = None,
568
+ ) -> List[Callable]:
569
+ """Get the tools to expose to the agent.
570
+
571
+ Args:
572
+ user_id: The user to update (required).
573
+ agent_id: Agent context (stored for audit).
574
+ team_id: Team context (stored for audit).
575
+
576
+ Returns:
577
+ List of callable tools based on config settings.
578
+ """
579
+ tools = []
580
+
581
+ # Profile field update tool
582
+ if self.config.agent_can_update_profile:
583
+ update_profile = self._build_update_profile_tool(
584
+ user_id=user_id,
585
+ agent_id=agent_id,
586
+ team_id=team_id,
587
+ )
588
+ if update_profile:
589
+ tools.append(update_profile)
590
+
591
+ return tools
592
+
593
+ async def aget_agent_tools(
594
+ self,
595
+ user_id: str,
596
+ agent_id: Optional[str] = None,
597
+ team_id: Optional[str] = None,
598
+ ) -> List[Callable]:
599
+ """Get the async tools to expose to the agent."""
600
+ tools = []
601
+
602
+ if self.config.agent_can_update_profile:
603
+ update_profile = await self._abuild_update_profile_tool(
604
+ user_id=user_id,
605
+ agent_id=agent_id,
606
+ team_id=team_id,
607
+ )
608
+ if update_profile:
609
+ tools.append(update_profile)
610
+
611
+ return tools
612
+
613
+ # =========================================================================
614
+ # Read Operations
615
+ # =========================================================================
616
+
617
+ def get(self, user_id: str) -> Optional[Any]:
618
+ """Retrieve user profile by user_id.
619
+
620
+ Args:
621
+ user_id: The unique user identifier.
622
+
623
+ Returns:
624
+ User profile as schema instance, or None if not found.
625
+ """
626
+ if not self.db:
627
+ return None
628
+
629
+ try:
630
+ result = self.db.get_learning(
631
+ learning_type=self.learning_type,
632
+ user_id=user_id,
633
+ )
634
+
635
+ if result and result.get("content"): # type: ignore[union-attr]
636
+ return from_dict_safe(self.schema, result["content"]) # type: ignore[index]
637
+
638
+ return None
639
+
640
+ except Exception as e:
641
+ log_debug(f"UserProfileStore.get failed for user_id={user_id}: {e}")
642
+ return None
643
+
644
+ async def aget(self, user_id: str) -> Optional[Any]:
645
+ """Async version of get."""
646
+ if not self.db:
647
+ return None
648
+
649
+ try:
650
+ if isinstance(self.db, AsyncBaseDb):
651
+ result = await self.db.get_learning(
652
+ learning_type=self.learning_type,
653
+ user_id=user_id,
654
+ )
655
+ else:
656
+ result = self.db.get_learning(
657
+ learning_type=self.learning_type,
658
+ user_id=user_id,
659
+ )
660
+
661
+ if result and result.get("content"):
662
+ return from_dict_safe(self.schema, result["content"])
663
+
664
+ return None
665
+
666
+ except Exception as e:
667
+ log_debug(f"UserProfileStore.aget failed for user_id={user_id}: {e}")
668
+ return None
669
+
670
+ # =========================================================================
671
+ # Write Operations
672
+ # =========================================================================
673
+
674
+ def save(
675
+ self,
676
+ user_id: str,
677
+ profile: Any,
678
+ agent_id: Optional[str] = None,
679
+ team_id: Optional[str] = None,
680
+ ) -> None:
681
+ """Save or update user profile.
682
+
683
+ Args:
684
+ user_id: The unique user identifier.
685
+ profile: The profile data to save.
686
+ agent_id: Agent context (stored in DB column for audit).
687
+ team_id: Team context (stored in DB column for audit).
688
+ """
689
+ if not self.db or not profile:
690
+ return
691
+
692
+ try:
693
+ content = to_dict_safe(profile)
694
+ if not content:
695
+ return
696
+
697
+ self.db.upsert_learning(
698
+ id=self._build_profile_id(user_id=user_id),
699
+ learning_type=self.learning_type,
700
+ user_id=user_id,
701
+ agent_id=agent_id,
702
+ team_id=team_id,
703
+ content=content,
704
+ )
705
+ log_debug(f"UserProfileStore.save: saved profile for user_id={user_id}")
706
+
707
+ except Exception as e:
708
+ log_debug(f"UserProfileStore.save failed for user_id={user_id}: {e}")
709
+
710
+ async def asave(
711
+ self,
712
+ user_id: str,
713
+ profile: Any,
714
+ agent_id: Optional[str] = None,
715
+ team_id: Optional[str] = None,
716
+ ) -> None:
717
+ """Async version of save."""
718
+ if not self.db or not profile:
719
+ return
720
+
721
+ try:
722
+ content = to_dict_safe(profile)
723
+ if not content:
724
+ return
725
+
726
+ if isinstance(self.db, AsyncBaseDb):
727
+ await self.db.upsert_learning(
728
+ id=self._build_profile_id(user_id=user_id),
729
+ learning_type=self.learning_type,
730
+ user_id=user_id,
731
+ agent_id=agent_id,
732
+ team_id=team_id,
733
+ content=content,
734
+ )
735
+ else:
736
+ self.db.upsert_learning(
737
+ id=self._build_profile_id(user_id=user_id),
738
+ learning_type=self.learning_type,
739
+ user_id=user_id,
740
+ agent_id=agent_id,
741
+ team_id=team_id,
742
+ content=content,
743
+ )
744
+ log_debug(f"UserProfileStore.asave: saved profile for user_id={user_id}")
745
+
746
+ except Exception as e:
747
+ log_debug(f"UserProfileStore.asave failed for user_id={user_id}: {e}")
748
+
749
+ # =========================================================================
750
+ # Delete Operations
751
+ # =========================================================================
752
+
753
+ def delete(self, user_id: str) -> bool:
754
+ """Delete a user profile.
755
+
756
+ Args:
757
+ user_id: The unique user identifier.
758
+
759
+ Returns:
760
+ True if deleted, False otherwise.
761
+ """
762
+ if not self.db:
763
+ return False
764
+
765
+ try:
766
+ profile_id = self._build_profile_id(user_id=user_id)
767
+ return self.db.delete_learning(id=profile_id) # type: ignore[return-value]
768
+ except Exception as e:
769
+ log_debug(f"UserProfileStore.delete failed for user_id={user_id}: {e}")
770
+ return False
771
+
772
+ async def adelete(self, user_id: str) -> bool:
773
+ """Async version of delete."""
774
+ if not self.db:
775
+ return False
776
+
777
+ try:
778
+ profile_id = self._build_profile_id(user_id=user_id)
779
+ if isinstance(self.db, AsyncBaseDb):
780
+ return await self.db.delete_learning(id=profile_id)
781
+ else:
782
+ return self.db.delete_learning(id=profile_id)
783
+ except Exception as e:
784
+ log_debug(f"UserProfileStore.adelete failed for user_id={user_id}: {e}")
785
+ return False
786
+
787
+ def clear(
788
+ self,
789
+ user_id: str,
790
+ agent_id: Optional[str] = None,
791
+ team_id: Optional[str] = None,
792
+ ) -> None:
793
+ """Clear user profile (reset to empty).
794
+
795
+ Args:
796
+ user_id: The unique user identifier.
797
+ agent_id: Agent context (stored for audit).
798
+ team_id: Team context (stored for audit).
799
+ """
800
+ if not self.db:
801
+ return
802
+
803
+ try:
804
+ empty_profile = self.schema(user_id=user_id)
805
+ self.save(user_id=user_id, profile=empty_profile, agent_id=agent_id, team_id=team_id)
806
+ log_debug(f"UserProfileStore.clear: cleared profile for user_id={user_id}")
807
+ except Exception as e:
808
+ log_debug(f"UserProfileStore.clear failed for user_id={user_id}: {e}")
809
+
810
+ async def aclear(
811
+ self,
812
+ user_id: str,
813
+ agent_id: Optional[str] = None,
814
+ team_id: Optional[str] = None,
815
+ ) -> None:
816
+ """Async version of clear."""
817
+ if not self.db:
818
+ return
819
+
820
+ try:
821
+ empty_profile = self.schema(user_id=user_id)
822
+ await self.asave(user_id=user_id, profile=empty_profile, agent_id=agent_id, team_id=team_id)
823
+ log_debug(f"UserProfileStore.aclear: cleared profile for user_id={user_id}")
824
+ except Exception as e:
825
+ log_debug(f"UserProfileStore.aclear failed for user_id={user_id}: {e}")
826
+
827
+ # =========================================================================
828
+ # Extraction Operations
829
+ # =========================================================================
830
+
831
+ def extract_and_save(
832
+ self,
833
+ messages: List["Message"],
834
+ user_id: str,
835
+ agent_id: Optional[str] = None,
836
+ team_id: Optional[str] = None,
837
+ ) -> str:
838
+ """Extract user profile information from messages and save.
839
+
840
+ Args:
841
+ messages: Conversation messages to analyze.
842
+ user_id: The unique user identifier.
843
+ agent_id: Agent context (stored for audit).
844
+ team_id: Team context (stored for audit).
845
+
846
+ Returns:
847
+ Response from model.
848
+ """
849
+ if self.model is None:
850
+ log_warning("UserProfileStore.extract_and_save: no model provided")
851
+ return "No model provided for user profile extraction"
852
+
853
+ if not self.db:
854
+ log_warning("UserProfileStore.extract_and_save: no database provided")
855
+ return "No DB provided for user profile store"
856
+
857
+ log_debug("UserProfileStore: Extracting user profile", center=True)
858
+
859
+ self.profile_updated = False
860
+
861
+ existing_profile = self.get(user_id=user_id)
862
+
863
+ tools = self._get_extraction_tools(
864
+ user_id=user_id,
865
+ existing_profile=existing_profile,
866
+ agent_id=agent_id,
867
+ team_id=team_id,
868
+ )
869
+
870
+ functions = self._build_functions_for_model(tools=tools)
871
+
872
+ messages_for_model = [
873
+ self._get_system_message(existing_profile=existing_profile),
874
+ *messages,
875
+ ]
876
+
877
+ model_copy = deepcopy(self.model)
878
+ response = model_copy.response(
879
+ messages=messages_for_model,
880
+ tools=functions,
881
+ )
882
+
883
+ if response.tool_executions:
884
+ self.profile_updated = True
885
+
886
+ log_debug("UserProfileStore: Extraction complete", center=True)
887
+
888
+ return response.content or ("Profile updated" if self.profile_updated else "No updates needed")
889
+
890
+ async def aextract_and_save(
891
+ self,
892
+ messages: List["Message"],
893
+ user_id: str,
894
+ agent_id: Optional[str] = None,
895
+ team_id: Optional[str] = None,
896
+ ) -> str:
897
+ """Async version of extract_and_save."""
898
+ if self.model is None:
899
+ log_warning("UserProfileStore.aextract_and_save: no model provided")
900
+ return "No model provided for user profile extraction"
901
+
902
+ if not self.db:
903
+ log_warning("UserProfileStore.aextract_and_save: no database provided")
904
+ return "No DB provided for user profile store"
905
+
906
+ log_debug("UserProfileStore: Extracting user profile (async)", center=True)
907
+
908
+ self.profile_updated = False
909
+
910
+ existing_profile = await self.aget(user_id=user_id)
911
+
912
+ tools = await self._aget_extraction_tools(
913
+ user_id=user_id,
914
+ existing_profile=existing_profile,
915
+ agent_id=agent_id,
916
+ team_id=team_id,
917
+ )
918
+
919
+ functions = self._build_functions_for_model(tools=tools)
920
+
921
+ messages_for_model = [
922
+ self._get_system_message(existing_profile=existing_profile),
923
+ *messages,
924
+ ]
925
+
926
+ model_copy = deepcopy(self.model)
927
+ response = await model_copy.aresponse(
928
+ messages=messages_for_model,
929
+ tools=functions,
930
+ )
931
+
932
+ if response.tool_executions:
933
+ self.profile_updated = True
934
+
935
+ log_debug("UserProfileStore: Extraction complete", center=True)
936
+
937
+ return response.content or ("Profile updated" if self.profile_updated else "No updates needed")
938
+
939
+ # =========================================================================
940
+ # Update Operations (called by agent tool)
941
+ # =========================================================================
942
+
943
+ def run_user_profile_update(
944
+ self,
945
+ task: str,
946
+ user_id: str,
947
+ agent_id: Optional[str] = None,
948
+ team_id: Optional[str] = None,
949
+ ) -> str:
950
+ """Run a user profile update task.
951
+
952
+ Args:
953
+ task: The update task description.
954
+ user_id: The unique user identifier.
955
+ agent_id: Agent context (stored for audit).
956
+ team_id: Team context (stored for audit).
957
+
958
+ Returns:
959
+ Response from model.
960
+ """
961
+ from agno.models.message import Message
962
+
963
+ messages = [Message(role="user", content=task)]
964
+ return self.extract_and_save(
965
+ messages=messages,
966
+ user_id=user_id,
967
+ agent_id=agent_id,
968
+ team_id=team_id,
969
+ )
970
+
971
+ async def arun_user_profile_update(
972
+ self,
973
+ task: str,
974
+ user_id: str,
975
+ agent_id: Optional[str] = None,
976
+ team_id: Optional[str] = None,
977
+ ) -> str:
978
+ """Async version of run_user_profile_update."""
979
+ from agno.models.message import Message
980
+
981
+ messages = [Message(role="user", content=task)]
982
+ return await self.aextract_and_save(
983
+ messages=messages,
984
+ user_id=user_id,
985
+ agent_id=agent_id,
986
+ team_id=team_id,
987
+ )
988
+
989
+ # =========================================================================
990
+ # Private Helpers
991
+ # =========================================================================
992
+
993
+ def _build_profile_id(self, user_id: str) -> str:
994
+ """Build a unique profile ID."""
995
+ return f"user_profile_{user_id}"
996
+
997
+ def _messages_to_input_string(self, messages: List["Message"]) -> str:
998
+ """Convert messages to input string."""
999
+ if len(messages) == 1:
1000
+ return messages[0].get_content_string()
1001
+ else:
1002
+ return "\n".join([f"{m.role}: {m.get_content_string()}" for m in messages if m.content])
1003
+
1004
+ def _build_functions_for_model(self, tools: List[Callable]) -> List["Function"]:
1005
+ """Convert callables to Functions for model."""
1006
+ from agno.tools.function import Function
1007
+
1008
+ functions = []
1009
+ seen_names = set()
1010
+
1011
+ for tool in tools:
1012
+ try:
1013
+ name = tool.__name__
1014
+ if name in seen_names:
1015
+ continue
1016
+ seen_names.add(name)
1017
+
1018
+ func = Function.from_callable(tool, strict=True)
1019
+ func.strict = True
1020
+ functions.append(func)
1021
+ log_debug(f"Added function {func.name}")
1022
+ except Exception as e:
1023
+ log_warning(f"Could not add function {tool}: {e}")
1024
+
1025
+ return functions
1026
+
1027
+ def _get_system_message(
1028
+ self,
1029
+ existing_profile: Optional[Any] = None,
1030
+ ) -> "Message":
1031
+ """Build system message for profile extraction.
1032
+
1033
+ Guides the model to extract structured profile information from conversations.
1034
+ """
1035
+ from agno.models.message import Message
1036
+
1037
+ if self.config.system_message is not None:
1038
+ return Message(role="system", content=self.config.system_message)
1039
+
1040
+ profile_fields = self._get_updateable_fields()
1041
+
1042
+ system_prompt = dedent("""\
1043
+ You are extracting structured profile information about the user.
1044
+
1045
+ Your goal is to identify and save key identity information that fits the defined profile fields.
1046
+ Only save information the user explicitly states - do not make inferences.
1047
+
1048
+ """)
1049
+
1050
+ # Profile Fields section
1051
+ if profile_fields and self.config.enable_update_profile:
1052
+ system_prompt += dedent("""\
1053
+ ## Profile Fields
1054
+
1055
+ Use `update_profile` to save structured identity information:
1056
+ """)
1057
+
1058
+ for field_name, field_info in profile_fields.items():
1059
+ description = field_info.get("description", f"User's {field_name.replace('_', ' ')}")
1060
+ system_prompt += f"- **{field_name}**: {description}\n"
1061
+
1062
+ if existing_profile:
1063
+ has_values = False
1064
+ for field_name in profile_fields:
1065
+ if getattr(existing_profile, field_name, None):
1066
+ has_values = True
1067
+ break
1068
+
1069
+ if has_values:
1070
+ system_prompt += "\nCurrent values:\n"
1071
+ for field_name in profile_fields:
1072
+ value = getattr(existing_profile, field_name, None)
1073
+ if value:
1074
+ system_prompt += f"- {field_name}: {value}\n"
1075
+
1076
+ system_prompt += "\n"
1077
+
1078
+ # Custom instructions or defaults
1079
+ profile_capture_instructions = self.config.instructions or dedent("""\
1080
+ ## Guidelines
1081
+
1082
+ **DO save:**
1083
+ - Name and preferred name when explicitly stated
1084
+ - Other profile fields when the user provides the information
1085
+
1086
+ **DO NOT save:**
1087
+ - Information that doesn't fit the defined profile fields
1088
+ - Inferences or assumptions - only save what's explicitly stated
1089
+ - Duplicate information that matches existing values
1090
+ """)
1091
+
1092
+ system_prompt += profile_capture_instructions
1093
+
1094
+ # Available actions
1095
+ system_prompt += "\n## Available Actions\n\n"
1096
+
1097
+ if self.config.enable_update_profile and profile_fields:
1098
+ fields_list = ", ".join(profile_fields.keys())
1099
+ system_prompt += f"- `update_profile`: Set profile fields ({fields_list})\n"
1100
+
1101
+ # Examples
1102
+ system_prompt += dedent("""
1103
+ ## Examples
1104
+
1105
+ **Example 1: User introduces themselves**
1106
+ User: "I'm Sarah, but everyone calls me Saz."
1107
+ → update_profile(name="Sarah", preferred_name="Saz")
1108
+
1109
+ **Example 2: Nothing to save**
1110
+ User: "What's the weather like?"
1111
+ → No action needed (no profile information shared)
1112
+
1113
+ ## Final Guidance
1114
+
1115
+ - Only call update_profile when the user explicitly shares profile information
1116
+ - It's fine to do nothing if the conversation reveals no profile data\
1117
+ """)
1118
+
1119
+ if self.config.additional_instructions:
1120
+ system_prompt += f"\n\n{self.config.additional_instructions}"
1121
+
1122
+ return Message(role="system", content=system_prompt)
1123
+
1124
+ def _get_extraction_tools(
1125
+ self,
1126
+ user_id: str,
1127
+ existing_profile: Optional[Any] = None,
1128
+ agent_id: Optional[str] = None,
1129
+ team_id: Optional[str] = None,
1130
+ ) -> List[Callable]:
1131
+ """Get sync extraction tools for the model."""
1132
+ functions: List[Callable] = []
1133
+
1134
+ # Profile update tool
1135
+ if self.config.enable_update_profile:
1136
+ update_profile = self._build_update_profile_tool(
1137
+ user_id=user_id,
1138
+ agent_id=agent_id,
1139
+ team_id=team_id,
1140
+ )
1141
+ if update_profile:
1142
+ functions.append(update_profile)
1143
+
1144
+ return functions
1145
+
1146
+ async def _aget_extraction_tools(
1147
+ self,
1148
+ user_id: str,
1149
+ existing_profile: Optional[Any] = None,
1150
+ agent_id: Optional[str] = None,
1151
+ team_id: Optional[str] = None,
1152
+ ) -> List[Callable]:
1153
+ """Get async extraction tools for the model."""
1154
+ functions: List[Callable] = []
1155
+
1156
+ # Profile update tool
1157
+ if self.config.enable_update_profile:
1158
+ update_profile = await self._abuild_update_profile_tool(
1159
+ user_id=user_id,
1160
+ agent_id=agent_id,
1161
+ team_id=team_id,
1162
+ )
1163
+ if update_profile:
1164
+ functions.append(update_profile)
1165
+
1166
+ return functions
1167
+
1168
+ # =========================================================================
1169
+ # Representation
1170
+ # =========================================================================
1171
+
1172
+ def __repr__(self) -> str:
1173
+ """String representation for debugging."""
1174
+ has_db = self.db is not None
1175
+ has_model = self.model is not None
1176
+ return (
1177
+ f"UserProfileStore("
1178
+ f"mode={self.config.mode.value}, "
1179
+ f"db={has_db}, "
1180
+ f"model={has_model}, "
1181
+ f"enable_agent_tools={self.config.enable_agent_tools})"
1182
+ )
1183
+
1184
+ def print(self, user_id: str, *, raw: bool = False) -> None:
1185
+ """Print formatted user profile.
1186
+
1187
+ Args:
1188
+ user_id: The user to print profile for.
1189
+ raw: If True, print raw dict using pprint instead of formatted panel.
1190
+
1191
+ Example:
1192
+ >>> store.print(user_id="alice@example.com")
1193
+ +---------------- User Profile -----------------+
1194
+ | Name: Alice |
1195
+ | Preferred Name: Ali |
1196
+ +--------------- alice@example.com -------------+
1197
+ """
1198
+ from agno.learn.utils import print_panel
1199
+
1200
+ profile = self.get(user_id=user_id)
1201
+
1202
+ lines = []
1203
+
1204
+ if profile:
1205
+ # Add profile fields
1206
+ updateable_fields = self._get_updateable_fields()
1207
+ for field_name in updateable_fields:
1208
+ value = getattr(profile, field_name, None)
1209
+ if value:
1210
+ display_name = field_name.replace("_", " ").title()
1211
+ lines.append(f"{display_name}: {value}")
1212
+
1213
+ print_panel(
1214
+ title="User Profile",
1215
+ subtitle=user_id,
1216
+ lines=lines,
1217
+ empty_message="No profile data",
1218
+ raw_data=profile,
1219
+ raw=raw,
1220
+ )