agno 2.1.8__py3-none-any.whl → 2.1.10__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 (57) hide show
  1. agno/agent/agent.py +646 -133
  2. agno/culture/__init__.py +3 -0
  3. agno/culture/manager.py +954 -0
  4. agno/db/async_postgres/async_postgres.py +232 -0
  5. agno/db/async_postgres/schemas.py +15 -0
  6. agno/db/async_postgres/utils.py +58 -0
  7. agno/db/base.py +83 -6
  8. agno/db/dynamo/dynamo.py +162 -0
  9. agno/db/dynamo/schemas.py +44 -0
  10. agno/db/dynamo/utils.py +59 -0
  11. agno/db/firestore/firestore.py +231 -0
  12. agno/db/firestore/schemas.py +10 -0
  13. agno/db/firestore/utils.py +96 -0
  14. agno/db/gcs_json/gcs_json_db.py +190 -0
  15. agno/db/gcs_json/utils.py +58 -0
  16. agno/db/in_memory/in_memory_db.py +118 -0
  17. agno/db/in_memory/utils.py +58 -0
  18. agno/db/json/json_db.py +129 -0
  19. agno/db/json/utils.py +58 -0
  20. agno/db/mongo/mongo.py +222 -0
  21. agno/db/mongo/schemas.py +10 -0
  22. agno/db/mongo/utils.py +59 -0
  23. agno/db/mysql/mysql.py +232 -1
  24. agno/db/mysql/schemas.py +14 -0
  25. agno/db/mysql/utils.py +58 -0
  26. agno/db/postgres/postgres.py +242 -0
  27. agno/db/postgres/schemas.py +15 -0
  28. agno/db/postgres/utils.py +58 -0
  29. agno/db/redis/redis.py +181 -0
  30. agno/db/redis/schemas.py +14 -0
  31. agno/db/redis/utils.py +58 -0
  32. agno/db/schemas/__init__.py +2 -1
  33. agno/db/schemas/culture.py +120 -0
  34. agno/db/singlestore/schemas.py +14 -0
  35. agno/db/singlestore/singlestore.py +231 -0
  36. agno/db/singlestore/utils.py +58 -0
  37. agno/db/sqlite/schemas.py +14 -0
  38. agno/db/sqlite/sqlite.py +274 -7
  39. agno/db/sqlite/utils.py +62 -0
  40. agno/db/surrealdb/models.py +51 -1
  41. agno/db/surrealdb/surrealdb.py +154 -0
  42. agno/db/surrealdb/utils.py +61 -1
  43. agno/knowledge/knowledge.py +4 -0
  44. agno/knowledge/reader/field_labeled_csv_reader.py +0 -2
  45. agno/memory/manager.py +28 -11
  46. agno/models/message.py +4 -0
  47. agno/os/app.py +28 -6
  48. agno/team/team.py +9 -9
  49. agno/tools/gmail.py +59 -14
  50. agno/tools/googlecalendar.py +13 -20
  51. agno/workflow/condition.py +31 -9
  52. agno/workflow/router.py +31 -9
  53. {agno-2.1.8.dist-info → agno-2.1.10.dist-info}/METADATA +1 -1
  54. {agno-2.1.8.dist-info → agno-2.1.10.dist-info}/RECORD +57 -54
  55. {agno-2.1.8.dist-info → agno-2.1.10.dist-info}/WHEEL +0 -0
  56. {agno-2.1.8.dist-info → agno-2.1.10.dist-info}/licenses/LICENSE +0 -0
  57. {agno-2.1.8.dist-info → agno-2.1.10.dist-info}/top_level.txt +0 -0
@@ -7,6 +7,7 @@ from agno.db.postgres.utils import (
7
7
  get_dates_to_calculate_metrics_for,
8
8
  )
9
9
  from agno.db.schemas import UserMemory
10
+ from agno.db.schemas.culture import CulturalKnowledge
10
11
  from agno.db.schemas.evals import EvalFilterType, EvalRunRecord, EvalType
11
12
  from agno.db.schemas.knowledge import KnowledgeRow
12
13
  from agno.db.surrealdb import utils
@@ -19,6 +20,7 @@ from agno.db.surrealdb.metrics import (
19
20
  )
20
21
  from agno.db.surrealdb.models import (
21
22
  TableType,
23
+ deserialize_cultural_knowledge,
22
24
  deserialize_eval_run_record,
23
25
  deserialize_knowledge_row,
24
26
  deserialize_session,
@@ -30,6 +32,7 @@ from agno.db.surrealdb.models import (
30
32
  desurrealize_user_memory,
31
33
  get_schema,
32
34
  get_session_type,
35
+ serialize_cultural_knowledge,
33
36
  serialize_eval_run_record,
34
37
  serialize_knowledge_row,
35
38
  serialize_session,
@@ -60,6 +63,7 @@ class SurrealDb(BaseDb):
60
63
  metrics_table: Optional[str] = None,
61
64
  eval_table: Optional[str] = None,
62
65
  knowledge_table: Optional[str] = None,
66
+ culture_table: Optional[str] = None,
63
67
  id: Optional[str] = None,
64
68
  ):
65
69
  """
@@ -80,6 +84,7 @@ class SurrealDb(BaseDb):
80
84
  metrics_table=metrics_table,
81
85
  eval_table=eval_table,
82
86
  knowledge_table=knowledge_table,
87
+ culture_table=culture_table,
83
88
  )
84
89
  self._client = client
85
90
  self._db_url = db_url
@@ -101,6 +106,7 @@ class SurrealDb(BaseDb):
101
106
  def table_names(self) -> dict[TableType, str]:
102
107
  return {
103
108
  "agents": self._agents_table_name,
109
+ "culture": self.culture_table_name,
104
110
  "evals": self.eval_table_name,
105
111
  "knowledge": self.knowledge_table_name,
106
112
  "memories": self.memory_table_name,
@@ -127,6 +133,8 @@ class SurrealDb(BaseDb):
127
133
  table_name = self.memory_table_name
128
134
  elif table_type == "knowledge":
129
135
  table_name = self.knowledge_table_name
136
+ elif table_type == "culture":
137
+ table_name = self.culture_table_name
130
138
  elif table_type == "users":
131
139
  table_name = self._users_table_name
132
140
  elif table_type == "agents":
@@ -463,6 +471,152 @@ class SurrealDb(BaseDb):
463
471
  table = self._get_table("memories")
464
472
  _ = self.client.delete(table)
465
473
 
474
+ # -- Cultural Knowledge methods --
475
+ def clear_cultural_knowledge(self) -> None:
476
+ """Delete all cultural knowledge from the database.
477
+
478
+ Raises:
479
+ Exception: If an error occurs during deletion.
480
+ """
481
+ table = self._get_table("culture")
482
+ _ = self.client.delete(table)
483
+
484
+ def delete_cultural_knowledge(self, id: str) -> None:
485
+ """Delete cultural knowledge by ID.
486
+
487
+ Args:
488
+ id (str): The ID of the cultural knowledge to delete.
489
+
490
+ Raises:
491
+ Exception: If an error occurs during deletion.
492
+ """
493
+ table = self._get_table("culture")
494
+ rec_id = RecordID(table, id)
495
+ self.client.delete(rec_id)
496
+
497
+ def get_cultural_knowledge(
498
+ self, id: str, deserialize: Optional[bool] = True
499
+ ) -> Optional[Union[CulturalKnowledge, Dict[str, Any]]]:
500
+ """Get cultural knowledge by ID.
501
+
502
+ Args:
503
+ id (str): The ID of the cultural knowledge to retrieve.
504
+ deserialize (Optional[bool]): Whether to deserialize to CulturalKnowledge object. Defaults to True.
505
+
506
+ Returns:
507
+ Optional[Union[CulturalKnowledge, Dict[str, Any]]]: The cultural knowledge if found, None otherwise.
508
+
509
+ Raises:
510
+ Exception: If an error occurs during retrieval.
511
+ """
512
+ table = self._get_table("culture")
513
+ rec_id = RecordID(table, id)
514
+ result = self.client.select(rec_id)
515
+
516
+ if result is None:
517
+ return None
518
+
519
+ if not deserialize:
520
+ return result # type: ignore
521
+
522
+ return deserialize_cultural_knowledge(result) # type: ignore
523
+
524
+ def get_all_cultural_knowledge(
525
+ self,
526
+ agent_id: Optional[str] = None,
527
+ team_id: Optional[str] = None,
528
+ name: Optional[str] = None,
529
+ limit: Optional[int] = None,
530
+ page: Optional[int] = None,
531
+ sort_by: Optional[str] = None,
532
+ sort_order: Optional[str] = None,
533
+ deserialize: Optional[bool] = True,
534
+ ) -> Union[List[CulturalKnowledge], Tuple[List[Dict[str, Any]], int]]:
535
+ """Get all cultural knowledge with filtering and pagination.
536
+
537
+ Args:
538
+ agent_id (Optional[str]): Filter by agent ID.
539
+ team_id (Optional[str]): Filter by team ID.
540
+ name (Optional[str]): Filter by name (case-insensitive partial match).
541
+ limit (Optional[int]): Maximum number of results to return.
542
+ page (Optional[int]): Page number for pagination.
543
+ sort_by (Optional[str]): Field to sort by.
544
+ sort_order (Optional[str]): Sort order ('asc' or 'desc').
545
+ deserialize (Optional[bool]): Whether to deserialize to CulturalKnowledge objects. Defaults to True.
546
+
547
+ Returns:
548
+ Union[List[CulturalKnowledge], Tuple[List[Dict[str, Any]], int]]:
549
+ - When deserialize=True: List of CulturalKnowledge objects
550
+ - When deserialize=False: Tuple with list of dictionaries and total count
551
+
552
+ Raises:
553
+ Exception: If an error occurs during retrieval.
554
+ """
555
+ table = self._get_table("culture")
556
+
557
+ # Build where clauses
558
+ where_clauses: List[WhereClause] = []
559
+ if agent_id is not None:
560
+ agent_rec_id = RecordID(self._get_table("agents"), agent_id)
561
+ where_clauses.append(("agent", "=", agent_rec_id)) # type: ignore
562
+ if team_id is not None:
563
+ team_rec_id = RecordID(self._get_table("teams"), team_id)
564
+ where_clauses.append(("team", "=", team_rec_id)) # type: ignore
565
+ if name is not None:
566
+ where_clauses.append(("string::lowercase(name)", "CONTAINS", name.lower())) # type: ignore
567
+
568
+ # Build query for total count
569
+ count_query = COUNT_QUERY.format(
570
+ table=table,
571
+ where=""
572
+ if not where_clauses
573
+ else f"WHERE {' AND '.join(f'{w[0]} {w[1]} ${chr(97 + i)}' for i, w in enumerate(where_clauses))}", # type: ignore
574
+ )
575
+ params = {chr(97 + i): w[2] for i, w in enumerate(where_clauses)} # type: ignore
576
+ total_count = self._query_one(count_query, params, int) or 0
577
+
578
+ # Build main query
579
+ order_limit = order_limit_start(sort_by, sort_order, limit, page)
580
+ query = f"SELECT * FROM {table}"
581
+ if where_clauses:
582
+ query += f" WHERE {' AND '.join(f'{w[0]} {w[1]} ${chr(97 + i)}' for i, w in enumerate(where_clauses))}" # type: ignore
583
+ query += order_limit
584
+
585
+ results = self._query(query, params, list) or []
586
+
587
+ if not deserialize:
588
+ return results, total_count # type: ignore
589
+
590
+ return [deserialize_cultural_knowledge(r) for r in results] # type: ignore
591
+
592
+ def upsert_cultural_knowledge(
593
+ self, cultural_knowledge: CulturalKnowledge, deserialize: Optional[bool] = True
594
+ ) -> Optional[Union[CulturalKnowledge, Dict[str, Any]]]:
595
+ """Upsert cultural knowledge in SurrealDB.
596
+
597
+ Args:
598
+ cultural_knowledge (CulturalKnowledge): The cultural knowledge to upsert.
599
+ deserialize (Optional[bool]): Whether to deserialize the result. Defaults to True.
600
+
601
+ Returns:
602
+ Optional[Union[CulturalKnowledge, Dict[str, Any]]]: The upserted cultural knowledge.
603
+
604
+ Raises:
605
+ Exception: If an error occurs during upsert.
606
+ """
607
+ table = self._get_table("culture", create_table_if_not_found=True)
608
+ serialized = serialize_cultural_knowledge(cultural_knowledge, table)
609
+
610
+ result = self.client.upsert(serialized["id"], serialized)
611
+
612
+ if result is None:
613
+ return None
614
+
615
+ if not deserialize:
616
+ return result # type: ignore
617
+
618
+ return deserialize_cultural_knowledge(result) # type: ignore
619
+
466
620
  def delete_user_memory(self, memory_id: str, user_id: Optional[str] = None) -> None:
467
621
  """Delete a user memory from the database.
468
622
 
@@ -1,8 +1,9 @@
1
1
  import dataclasses
2
- from typing import Any, Optional, Sequence, TypeVar, Union, cast
2
+ from typing import Any, Dict, Optional, Sequence, TypeVar, Union, cast
3
3
 
4
4
  from surrealdb import BlockingHttpSurrealConnection, BlockingWsSurrealConnection, Surreal
5
5
 
6
+ from agno.db.schemas.culture import CulturalKnowledge
6
7
  from agno.utils.log import logger
7
8
 
8
9
  RecordType = TypeVar("RecordType")
@@ -85,3 +86,62 @@ def query_one(
85
86
  raise ValueError(f"Expected single record, got {len(response)} records: {response}")
86
87
  else:
87
88
  raise ValueError(f"Unexpected response type: {type(response)}")
89
+
90
+
91
+ # -- Cultural Knowledge util methods --
92
+
93
+
94
+ def serialize_cultural_knowledge_for_db(cultural_knowledge: CulturalKnowledge) -> Dict[str, Any]:
95
+ """Serialize a CulturalKnowledge object for database storage.
96
+
97
+ Converts the model's separate content, categories, and notes fields
98
+ into a single dict for the database content field.
99
+
100
+ Args:
101
+ cultural_knowledge (CulturalKnowledge): The cultural knowledge object to serialize.
102
+
103
+ Returns:
104
+ Dict[str, Any]: A dictionary with content, categories, and notes.
105
+ """
106
+ content_dict: Dict[str, Any] = {}
107
+ if cultural_knowledge.content is not None:
108
+ content_dict["content"] = cultural_knowledge.content
109
+ if cultural_knowledge.categories is not None:
110
+ content_dict["categories"] = cultural_knowledge.categories
111
+ if cultural_knowledge.notes is not None:
112
+ content_dict["notes"] = cultural_knowledge.notes
113
+
114
+ return content_dict if content_dict else {}
115
+
116
+
117
+ def deserialize_cultural_knowledge_from_db(db_row: Dict[str, Any]) -> CulturalKnowledge:
118
+ """Deserialize a database row to a CulturalKnowledge object.
119
+
120
+ The database stores content as a dict containing content, categories, and notes.
121
+ This method extracts those fields and converts them back to the model format.
122
+
123
+ Args:
124
+ db_row (Dict[str, Any]): The database row as a dictionary.
125
+
126
+ Returns:
127
+ CulturalKnowledge: The cultural knowledge object.
128
+ """
129
+ # Extract content, categories, and notes from the content field
130
+ content_json = db_row.get("content", {}) or {}
131
+
132
+ return CulturalKnowledge.from_dict(
133
+ {
134
+ "id": db_row.get("id"),
135
+ "name": db_row.get("name"),
136
+ "summary": db_row.get("summary"),
137
+ "content": content_json.get("content"),
138
+ "categories": content_json.get("categories"),
139
+ "notes": content_json.get("notes"),
140
+ "metadata": db_row.get("metadata"),
141
+ "input": db_row.get("input"),
142
+ "created_at": db_row.get("created_at"),
143
+ "updated_at": db_row.get("updated_at"),
144
+ "agent_id": db_row.get("agent_id"),
145
+ "team_id": db_row.get("team_id"),
146
+ }
147
+ )
@@ -122,6 +122,7 @@ class Knowledge:
122
122
  exclude=exclude,
123
123
  upsert=upsert,
124
124
  skip_if_exists=skip_if_exists,
125
+ reader=reader,
125
126
  )
126
127
  for url in urls:
127
128
  await self.add_content_async(
@@ -133,6 +134,7 @@ class Knowledge:
133
134
  exclude=exclude,
134
135
  upsert=upsert,
135
136
  skip_if_exists=skip_if_exists,
137
+ reader=reader,
136
138
  )
137
139
  for i, text_content in enumerate(text_contents):
138
140
  content_name = f"{name}_{i}" if name else f"text_content_{i}"
@@ -146,6 +148,7 @@ class Knowledge:
146
148
  exclude=exclude,
147
149
  upsert=upsert,
148
150
  skip_if_exists=skip_if_exists,
151
+ reader=reader,
149
152
  )
150
153
  if topics:
151
154
  await self.add_content_async(
@@ -168,6 +171,7 @@ class Knowledge:
168
171
  remote_content=remote_content,
169
172
  upsert=upsert,
170
173
  skip_if_exists=skip_if_exists,
174
+ reader=reader,
171
175
  )
172
176
 
173
177
  else:
@@ -33,8 +33,6 @@ class FieldLabeledCSVReader(Reader):
33
33
  self.format_headers = format_headers
34
34
  self.skip_empty_fields = skip_empty_fields
35
35
 
36
- logger.info(f"FieldLabeledCSVReader initialized - chunk_title: {chunk_title}, field_names: {self.field_names}")
37
-
38
36
  @classmethod
39
37
  def get_supported_chunking_strategies(cls) -> List[ChunkingStrategyType]:
40
38
  """Chunking is not supported - each row is already a logical document unit."""
agno/memory/manager.py CHANGED
@@ -12,7 +12,13 @@ from agno.db.schemas import UserMemory
12
12
  from agno.models.base import Model
13
13
  from agno.models.message import Message
14
14
  from agno.tools.function import Function
15
- from agno.utils.log import log_debug, log_error, log_warning, set_log_level_to_debug, set_log_level_to_info
15
+ from agno.utils.log import (
16
+ log_debug,
17
+ log_error,
18
+ log_warning,
19
+ set_log_level_to_debug,
20
+ set_log_level_to_info,
21
+ )
16
22
  from agno.utils.prompts import get_json_output_prompt
17
23
  from agno.utils.string import parse_response_model_str
18
24
 
@@ -21,7 +27,8 @@ class MemorySearchResponse(BaseModel):
21
27
  """Model for Memory Search Response."""
22
28
 
23
29
  memory_ids: List[str] = Field(
24
- ..., description="The IDs of the memories that are most semantically similar to the query."
30
+ ...,
31
+ description="The IDs of the memories that are most semantically similar to the query.",
25
32
  )
26
33
 
27
34
 
@@ -32,11 +39,11 @@ class MemoryManager:
32
39
  # Model used for memory management
33
40
  model: Optional[Model] = None
34
41
 
35
- # Provide the system message for the manager as a string. If not provided, a default prompt will be used.
42
+ # Provide the system message for the manager as a string. If not provided, the default system message will be used.
36
43
  system_message: Optional[str] = None
37
- # Provide the memory capture instructions for the manager as a string. If not provided, a default prompt will be used.
44
+ # Provide the memory capture instructions for the manager as a string. If not provided, the default memory capture instructions will be used.
38
45
  memory_capture_instructions: Optional[str] = None
39
- # Additional instructions for the manager. These instructions are appended to the default system prompt.
46
+ # Additional instructions for the manager. These instructions are appended to the default system message.
40
47
  additional_instructions: Optional[str] = None
41
48
 
42
49
  # Whether memories were created in the last run
@@ -731,14 +738,16 @@ class MemoryManager:
731
738
  if self.system_message is not None:
732
739
  return Message(role="system", content=self.system_message)
733
740
 
734
- memory_capture_instructions = self.memory_capture_instructions or dedent("""\
741
+ memory_capture_instructions = self.memory_capture_instructions or dedent(
742
+ """\
735
743
  Memories should capture personal information about the user that is relevant to the current conversation, such as:
736
744
  - Personal facts: name, age, occupation, location, interests, and preferences
737
745
  - Opinions and preferences: what the user likes, dislikes, enjoys, or finds frustrating
738
746
  - Significant life events or experiences shared by the user
739
747
  - Important context about the user's current situation, challenges, or goals
740
748
  - Any other details that offer meaningful insight into the user's personality, perspective, or needs
741
- """)
749
+ """
750
+ )
742
751
 
743
752
  # -*- Return a system message for the memory manager
744
753
  system_prompt_lines = [
@@ -852,7 +861,9 @@ class MemoryManager:
852
861
 
853
862
  # Generate a response from the Model (includes running function calls)
854
863
  response = model_copy.response(
855
- messages=messages_for_model, tools=self._tools_for_model, functions=self._functions_for_model
864
+ messages=messages_for_model,
865
+ tools=self._tools_for_model,
866
+ functions=self._functions_for_model,
856
867
  )
857
868
 
858
869
  if response.tool_calls is not None and len(response.tool_calls) > 0:
@@ -928,7 +939,9 @@ class MemoryManager:
928
939
 
929
940
  # Generate a response from the Model (includes running function calls)
930
941
  response = await model_copy.aresponse(
931
- messages=messages_for_model, tools=self._tools_for_model, functions=self._functions_for_model
942
+ messages=messages_for_model,
943
+ tools=self._tools_for_model,
944
+ functions=self._functions_for_model,
932
945
  )
933
946
 
934
947
  if response.tool_calls is not None and len(response.tool_calls) > 0:
@@ -983,7 +996,9 @@ class MemoryManager:
983
996
 
984
997
  # Generate a response from the Model (includes running function calls)
985
998
  response = model_copy.response(
986
- messages=messages_for_model, tools=self._tools_for_model, functions=self._functions_for_model
999
+ messages=messages_for_model,
1000
+ tools=self._tools_for_model,
1001
+ functions=self._functions_for_model,
987
1002
  )
988
1003
 
989
1004
  if response.tool_calls is not None and len(response.tool_calls) > 0:
@@ -1051,7 +1066,9 @@ class MemoryManager:
1051
1066
 
1052
1067
  # Generate a response from the Model (includes running function calls)
1053
1068
  response = await model_copy.aresponse(
1054
- messages=messages_for_model, tools=self._tools_for_model, functions=self._functions_for_model
1069
+ messages=messages_for_model,
1070
+ tools=self._tools_for_model,
1071
+ functions=self._functions_for_model,
1055
1072
  )
1056
1073
 
1057
1074
  if response.tool_calls is not None and len(response.tool_calls) > 0:
agno/models/message.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import json
2
2
  from time import time
3
3
  from typing import Any, Dict, List, Optional, Sequence, Union
4
+ from uuid import uuid4
4
5
 
5
6
  from pydantic import BaseModel, ConfigDict, Field
6
7
 
@@ -51,6 +52,8 @@ class Citations(BaseModel):
51
52
  class Message(BaseModel):
52
53
  """Message sent to the Model"""
53
54
 
55
+ id: str = Field(default_factory=lambda: str(uuid4()))
56
+
54
57
  # The role of the message author.
55
58
  # One of system, user, assistant, or tool.
56
59
  role: str
@@ -259,6 +262,7 @@ class Message(BaseModel):
259
262
  def to_dict(self) -> Dict[str, Any]:
260
263
  """Returns the message as a dictionary."""
261
264
  message_dict = {
265
+ "id": self.id,
262
266
  "content": self.content,
263
267
  "reasoning_content": self.reasoning_content,
264
268
  "from_history": self.from_history,
agno/os/app.py CHANGED
@@ -13,6 +13,7 @@ from starlette.requests import Request
13
13
 
14
14
  from agno.agent.agent import Agent
15
15
  from agno.db.base import AsyncBaseDb, BaseDb
16
+ from agno.knowledge.knowledge import Knowledge
16
17
  from agno.os.config import (
17
18
  AgentOSConfig,
18
19
  DatabaseConfig,
@@ -98,6 +99,7 @@ class AgentOS:
98
99
  agents: Optional[List[Agent]] = None,
99
100
  teams: Optional[List[Team]] = None,
100
101
  workflows: Optional[List[Workflow]] = None,
102
+ knowledge: Optional[List[Knowledge]] = None,
101
103
  interfaces: Optional[List[BaseInterface]] = None,
102
104
  a2a_interface: bool = False,
103
105
  config: Optional[Union[str, AgentOSConfig]] = None,
@@ -122,6 +124,7 @@ class AgentOS:
122
124
  agents: List of agents to include in the OS
123
125
  teams: List of teams to include in the OS
124
126
  workflows: List of workflows to include in the OS
127
+ knowledge: List of knowledge bases to include in the OS
125
128
  interfaces: List of interfaces to include in the OS
126
129
  a2a_interface: Whether to expose the OS agents and teams in an A2A server
127
130
  config: Configuration file path or AgentOSConfig instance
@@ -133,8 +136,8 @@ class AgentOS:
133
136
  telemetry: Whether to enable telemetry
134
137
 
135
138
  """
136
- if not agents and not workflows and not teams:
137
- raise ValueError("Either agents, teams or workflows must be provided.")
139
+ if not agents and not workflows and not teams and not knowledge:
140
+ raise ValueError("Either agents, teams, workflows or knowledge bases must be provided.")
138
141
 
139
142
  self.config = load_yaml_config(config) if isinstance(config, str) else config
140
143
 
@@ -143,7 +146,7 @@ class AgentOS:
143
146
  self.teams: Optional[List[Team]] = teams
144
147
  self.interfaces = interfaces or []
145
148
  self.a2a_interface = a2a_interface
146
-
149
+ self.knowledge = knowledge
147
150
  self.settings: AgnoAPISettings = settings or AgnoAPISettings()
148
151
 
149
152
  self._app_set = False
@@ -480,6 +483,10 @@ class AgentOS:
480
483
  if workflow.db:
481
484
  self._register_db_with_validation(dbs, workflow.db)
482
485
 
486
+ for knowledge_base in self.knowledge or []:
487
+ if knowledge_base.contents_db:
488
+ self._register_db_with_validation(knowledge_dbs, knowledge_base.contents_db)
489
+
483
490
  for interface in self.interfaces or []:
484
491
  if interface.agent and interface.agent.db:
485
492
  self._register_db_with_validation(dbs, interface.agent.db)
@@ -534,14 +541,29 @@ class AgentOS:
534
541
 
535
542
  def _auto_discover_knowledge_instances(self) -> None:
536
543
  """Auto-discover the knowledge instances used by all contextual agents, teams and workflows."""
537
- knowledge_instances = []
544
+ seen_ids = set()
545
+ knowledge_instances: List[Knowledge] = []
546
+
547
+ def _add_knowledge_if_not_duplicate(knowledge: "Knowledge") -> None:
548
+ """Add knowledge instance if it's not already in the list (by object identity or db_id)."""
549
+ # Use database ID if available, otherwise use object ID as fallback
550
+ if not knowledge.contents_db:
551
+ return
552
+ if knowledge.contents_db.id in seen_ids:
553
+ return
554
+ seen_ids.add(knowledge.contents_db.id)
555
+ knowledge_instances.append(knowledge)
556
+
538
557
  for agent in self.agents or []:
539
558
  if agent.knowledge:
540
- knowledge_instances.append(agent.knowledge)
559
+ _add_knowledge_if_not_duplicate(agent.knowledge)
541
560
 
542
561
  for team in self.teams or []:
543
562
  if team.knowledge:
544
- knowledge_instances.append(team.knowledge)
563
+ _add_knowledge_if_not_duplicate(team.knowledge)
564
+
565
+ for knowledge_base in self.knowledge or []:
566
+ _add_knowledge_if_not_duplicate(knowledge_base)
545
567
 
546
568
  self.knowledge_instances = knowledge_instances
547
569
 
agno/team/team.py CHANGED
@@ -1674,11 +1674,11 @@ class Team:
1674
1674
  )
1675
1675
  self.model = cast(Model, self.model)
1676
1676
 
1677
- if metadata is not None:
1678
- if self.metadata is not None:
1679
- merge_dictionaries(metadata, self.metadata)
1680
- else:
1677
+ if self.metadata is not None:
1678
+ if metadata is None:
1681
1679
  metadata = self.metadata
1680
+ else:
1681
+ merge_dictionaries(metadata, self.metadata)
1682
1682
 
1683
1683
  # Create a new run_response for this attempt
1684
1684
  run_response = TeamRunOutput(
@@ -2201,7 +2201,7 @@ class Team:
2201
2201
 
2202
2202
  # Execute post-hooks after output is generated but before response is returned
2203
2203
  if self.post_hooks is not None:
2204
- self._execute_post_hooks(
2204
+ await self._aexecute_post_hooks(
2205
2205
  hooks=self.post_hooks, # type: ignore
2206
2206
  run_output=run_response,
2207
2207
  session_state=session_state,
@@ -2428,11 +2428,11 @@ class Team:
2428
2428
 
2429
2429
  self.model = cast(Model, self.model)
2430
2430
 
2431
- if metadata is not None:
2432
- if self.metadata is not None:
2433
- merge_dictionaries(metadata, self.metadata)
2434
- else:
2431
+ if self.metadata is not None:
2432
+ if metadata is None:
2435
2433
  metadata = self.metadata
2434
+ else:
2435
+ merge_dictionaries(metadata, self.metadata)
2436
2436
 
2437
2437
  # Get knowledge filters
2438
2438
  effective_filters = knowledge_filters
agno/tools/gmail.py CHANGED
@@ -124,20 +124,6 @@ class GmailTools(Toolkit):
124
124
  self.scopes = scopes or self.DEFAULT_SCOPES
125
125
  self.port = port
126
126
 
127
- """ tools functions:
128
- enable_get_latest_emails (bool): Enable getting latest emails.
129
- enable_get_emails_from_user (bool): Enable getting emails from specific user.
130
- enable_get_unread_emails (bool): Enable getting unread emails.
131
- enable_get_starred_emails (bool): Enable getting starred emails.
132
- enable_get_emails_by_context (bool): Enable getting emails by context.
133
- enable_get_emails_by_date (bool): Enable getting emails by date.
134
- enable_get_emails_by_thread (bool): Enable getting emails by thread.
135
- enable_create_draft_email (bool): Enable creating draft emails.
136
- enable_send_email (bool): Enable sending emails.
137
- enable_send_email_reply (bool): Enable sending email replies.
138
- all (bool): Enable all tools.
139
- """
140
-
141
127
  tools: List[Any] = [
142
128
  # Reading emails
143
129
  self.get_latest_emails,
@@ -148,6 +134,9 @@ class GmailTools(Toolkit):
148
134
  self.get_emails_by_date,
149
135
  self.get_emails_by_thread,
150
136
  self.search_emails,
137
+ # Email management
138
+ self.mark_email_as_read,
139
+ self.mark_email_as_unread,
151
140
  # Composing emails
152
141
  self.create_draft_email,
153
142
  self.send_email,
@@ -173,12 +162,18 @@ class GmailTools(Toolkit):
173
162
  "get_emails_by_thread",
174
163
  "search_emails",
175
164
  ]
165
+ modify_operations = ["mark_email_as_read", "mark_email_as_unread"]
176
166
  if any(read_operation in self.functions for read_operation in read_operations):
177
167
  read_scope = "https://www.googleapis.com/auth/gmail.readonly"
178
168
  write_scope = "https://www.googleapis.com/auth/gmail.modify"
179
169
  if read_scope not in self.scopes and write_scope not in self.scopes:
180
170
  raise ValueError(f"The scope {read_scope} is required for email reading operations")
181
171
 
172
+ if any(modify_operation in self.functions for modify_operation in modify_operations):
173
+ modify_scope = "https://www.googleapis.com/auth/gmail.modify"
174
+ if modify_scope not in self.scopes:
175
+ raise ValueError(f"The scope {modify_scope} is required for email modification operations")
176
+
182
177
  def _auth(self) -> None:
183
178
  """Authenticate with Gmail API"""
184
179
  token_file = Path(self.token_path or "token.json")
@@ -555,6 +550,56 @@ class GmailTools(Toolkit):
555
550
  except Exception as error:
556
551
  return f"Unexpected error retrieving emails with query '{query}': {type(error).__name__}: {error}"
557
552
 
553
+ @authenticate
554
+ def mark_email_as_read(self, message_id: str) -> str:
555
+ """
556
+ Mark a specific email as read by removing the 'UNREAD' label.
557
+ This is crucial for long polling scenarios to prevent processing the same email multiple times.
558
+
559
+ Args:
560
+ message_id (str): The ID of the message to mark as read
561
+
562
+ Returns:
563
+ str: Success message or error description
564
+ """
565
+ try:
566
+ # Remove the UNREAD label to mark the email as read
567
+ modify_request = {"removeLabelIds": ["UNREAD"]}
568
+
569
+ self.service.users().messages().modify(userId="me", id=message_id, body=modify_request).execute() # type: ignore
570
+
571
+ return f"Successfully marked email {message_id} as read. Labels removed: UNREAD"
572
+
573
+ except HttpError as error:
574
+ return f"HTTP Error marking email {message_id} as read: {error}"
575
+ except Exception as error:
576
+ return f"Error marking email {message_id} as read: {type(error).__name__}: {error}"
577
+
578
+ @authenticate
579
+ def mark_email_as_unread(self, message_id: str) -> str:
580
+ """
581
+ Mark a specific email as unread by adding the 'UNREAD' label.
582
+ This is useful for flagging emails that need attention or re-processing.
583
+
584
+ Args:
585
+ message_id (str): The ID of the message to mark as unread
586
+
587
+ Returns:
588
+ str: Success message or error description
589
+ """
590
+ try:
591
+ # Add the UNREAD label to mark the email as unread
592
+ modify_request = {"addLabelIds": ["UNREAD"]}
593
+
594
+ self.service.users().messages().modify(userId="me", id=message_id, body=modify_request).execute() # type: ignore
595
+
596
+ return f"Successfully marked email {message_id} as unread. Labels added: UNREAD"
597
+
598
+ except HttpError as error:
599
+ return f"HTTP Error marking email {message_id} as unread: {error}"
600
+ except Exception as error:
601
+ return f"Error marking email {message_id} as unread: {type(error).__name__}: {error}"
602
+
558
603
  def _validate_email_params(self, to: str, subject: str, body: str) -> None:
559
604
  """Validate email parameters."""
560
605
  if not to: