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.
- agno/agent/agent.py +297 -11
- agno/db/base.py +214 -0
- agno/db/dynamo/dynamo.py +47 -0
- agno/db/firestore/firestore.py +47 -0
- agno/db/gcs_json/gcs_json_db.py +47 -0
- agno/db/in_memory/in_memory_db.py +47 -0
- agno/db/json/json_db.py +47 -0
- agno/db/mongo/async_mongo.py +229 -0
- agno/db/mongo/mongo.py +47 -0
- agno/db/mongo/schemas.py +16 -0
- agno/db/mysql/async_mysql.py +47 -0
- agno/db/mysql/mysql.py +47 -0
- agno/db/postgres/async_postgres.py +231 -0
- agno/db/postgres/postgres.py +239 -0
- agno/db/postgres/schemas.py +19 -0
- agno/db/redis/redis.py +47 -0
- agno/db/singlestore/singlestore.py +47 -0
- agno/db/sqlite/async_sqlite.py +242 -0
- agno/db/sqlite/schemas.py +18 -0
- agno/db/sqlite/sqlite.py +239 -0
- agno/db/surrealdb/surrealdb.py +47 -0
- agno/knowledge/chunking/code.py +90 -0
- agno/knowledge/chunking/document.py +62 -2
- agno/knowledge/chunking/strategy.py +14 -0
- agno/knowledge/knowledge.py +7 -1
- agno/knowledge/reader/arxiv_reader.py +1 -0
- agno/knowledge/reader/csv_reader.py +1 -0
- agno/knowledge/reader/docx_reader.py +1 -0
- agno/knowledge/reader/firecrawl_reader.py +1 -0
- agno/knowledge/reader/json_reader.py +1 -0
- agno/knowledge/reader/markdown_reader.py +1 -0
- agno/knowledge/reader/pdf_reader.py +1 -0
- agno/knowledge/reader/pptx_reader.py +1 -0
- agno/knowledge/reader/s3_reader.py +1 -0
- agno/knowledge/reader/tavily_reader.py +1 -0
- agno/knowledge/reader/text_reader.py +1 -0
- agno/knowledge/reader/web_search_reader.py +1 -0
- agno/knowledge/reader/website_reader.py +1 -0
- agno/knowledge/reader/wikipedia_reader.py +1 -0
- agno/knowledge/reader/youtube_reader.py +1 -0
- agno/knowledge/utils.py +1 -0
- agno/learn/__init__.py +65 -0
- agno/learn/config.py +463 -0
- agno/learn/curate.py +185 -0
- agno/learn/machine.py +690 -0
- agno/learn/schemas.py +1043 -0
- agno/learn/stores/__init__.py +35 -0
- agno/learn/stores/entity_memory.py +3275 -0
- agno/learn/stores/learned_knowledge.py +1583 -0
- agno/learn/stores/protocol.py +117 -0
- agno/learn/stores/session_context.py +1217 -0
- agno/learn/stores/user_memory.py +1495 -0
- agno/learn/stores/user_profile.py +1220 -0
- agno/learn/utils.py +209 -0
- agno/models/base.py +59 -0
- agno/os/routers/knowledge/knowledge.py +7 -0
- agno/tools/browserbase.py +78 -6
- agno/tools/google_bigquery.py +11 -2
- agno/utils/agent.py +30 -1
- {agno-2.3.24.dist-info → agno-2.3.25.dist-info}/METADATA +24 -2
- {agno-2.3.24.dist-info → agno-2.3.25.dist-info}/RECORD +64 -50
- {agno-2.3.24.dist-info → agno-2.3.25.dist-info}/WHEEL +0 -0
- {agno-2.3.24.dist-info → agno-2.3.25.dist-info}/licenses/LICENSE +0 -0
- {agno-2.3.24.dist-info → agno-2.3.25.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
|
+
)
|