agno 2.3.21__py3-none-any.whl → 2.3.23__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 (74) hide show
  1. agno/agent/agent.py +48 -2
  2. agno/agent/remote.py +234 -73
  3. agno/client/a2a/__init__.py +10 -0
  4. agno/client/a2a/client.py +554 -0
  5. agno/client/a2a/schemas.py +112 -0
  6. agno/client/a2a/utils.py +369 -0
  7. agno/db/migrations/utils.py +19 -0
  8. agno/db/migrations/v1_to_v2.py +54 -16
  9. agno/db/migrations/versions/v2_3_0.py +92 -53
  10. agno/db/mysql/async_mysql.py +5 -7
  11. agno/db/mysql/mysql.py +5 -7
  12. agno/db/mysql/schemas.py +39 -21
  13. agno/db/postgres/async_postgres.py +172 -42
  14. agno/db/postgres/postgres.py +186 -38
  15. agno/db/postgres/schemas.py +39 -21
  16. agno/db/postgres/utils.py +6 -2
  17. agno/db/singlestore/schemas.py +41 -21
  18. agno/db/singlestore/singlestore.py +14 -3
  19. agno/db/sqlite/async_sqlite.py +7 -2
  20. agno/db/sqlite/schemas.py +36 -21
  21. agno/db/sqlite/sqlite.py +3 -7
  22. agno/knowledge/chunking/document.py +3 -2
  23. agno/knowledge/chunking/markdown.py +8 -3
  24. agno/knowledge/chunking/recursive.py +2 -2
  25. agno/models/base.py +4 -0
  26. agno/models/google/gemini.py +27 -4
  27. agno/models/openai/chat.py +1 -1
  28. agno/models/openai/responses.py +14 -7
  29. agno/os/middleware/jwt.py +66 -27
  30. agno/os/routers/agents/router.py +3 -3
  31. agno/os/routers/evals/evals.py +2 -2
  32. agno/os/routers/knowledge/knowledge.py +5 -5
  33. agno/os/routers/knowledge/schemas.py +1 -1
  34. agno/os/routers/memory/memory.py +4 -4
  35. agno/os/routers/session/session.py +2 -2
  36. agno/os/routers/teams/router.py +4 -4
  37. agno/os/routers/traces/traces.py +3 -3
  38. agno/os/routers/workflows/router.py +3 -3
  39. agno/os/schema.py +1 -1
  40. agno/reasoning/deepseek.py +11 -1
  41. agno/reasoning/gemini.py +6 -2
  42. agno/reasoning/groq.py +8 -3
  43. agno/reasoning/openai.py +2 -0
  44. agno/remote/base.py +106 -9
  45. agno/skills/__init__.py +17 -0
  46. agno/skills/agent_skills.py +370 -0
  47. agno/skills/errors.py +32 -0
  48. agno/skills/loaders/__init__.py +4 -0
  49. agno/skills/loaders/base.py +27 -0
  50. agno/skills/loaders/local.py +216 -0
  51. agno/skills/skill.py +65 -0
  52. agno/skills/utils.py +107 -0
  53. agno/skills/validator.py +277 -0
  54. agno/team/remote.py +220 -60
  55. agno/team/team.py +41 -3
  56. agno/tools/brandfetch.py +27 -18
  57. agno/tools/browserbase.py +150 -13
  58. agno/tools/function.py +6 -1
  59. agno/tools/mcp/mcp.py +300 -17
  60. agno/tools/mcp/multi_mcp.py +269 -14
  61. agno/tools/toolkit.py +89 -21
  62. agno/utils/mcp.py +49 -8
  63. agno/utils/string.py +43 -1
  64. agno/workflow/condition.py +4 -2
  65. agno/workflow/loop.py +20 -1
  66. agno/workflow/remote.py +173 -33
  67. agno/workflow/router.py +4 -1
  68. agno/workflow/steps.py +4 -0
  69. agno/workflow/workflow.py +14 -0
  70. {agno-2.3.21.dist-info → agno-2.3.23.dist-info}/METADATA +13 -14
  71. {agno-2.3.21.dist-info → agno-2.3.23.dist-info}/RECORD +74 -60
  72. {agno-2.3.21.dist-info → agno-2.3.23.dist-info}/WHEEL +0 -0
  73. {agno-2.3.21.dist-info → agno-2.3.23.dist-info}/licenses/LICENSE +0 -0
  74. {agno-2.3.21.dist-info → agno-2.3.23.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  import time
2
2
  from datetime import date, datetime, timedelta, timezone
3
- from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple, Union
3
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple, Union, cast
4
4
  from uuid import uuid4
5
5
 
6
6
  if TYPE_CHECKING:
@@ -27,7 +27,7 @@ from agno.db.schemas.knowledge import KnowledgeRow
27
27
  from agno.db.schemas.memory import UserMemory
28
28
  from agno.session import AgentSession, Session, TeamSession, WorkflowSession
29
29
  from agno.utils.log import log_debug, log_error, log_info, log_warning
30
- from agno.utils.string import generate_id
30
+ from agno.utils.string import generate_id, sanitize_postgres_string, sanitize_postgres_strings
31
31
 
32
32
  try:
33
33
  from sqlalchemy import ForeignKey, Index, String, UniqueConstraint, and_, case, func, or_, select, update
@@ -91,7 +91,11 @@ class PostgresDb(BaseDb):
91
91
  """
92
92
  _engine: Optional[Engine] = db_engine
93
93
  if _engine is None and db_url is not None:
94
- _engine = create_engine(db_url)
94
+ _engine = create_engine(
95
+ db_url,
96
+ pool_pre_ping=True,
97
+ pool_recycle=3600,
98
+ )
95
99
  if _engine is None:
96
100
  raise ValueError("One of db_url or db_engine must be provided")
97
101
 
@@ -172,7 +176,10 @@ class PostgresDb(BaseDb):
172
176
  Table: SQLAlchemy Table object
173
177
  """
174
178
  try:
175
- table_schema = get_table_schema_definition(table_type).copy()
179
+ # Pass traces_table_name and db_schema for spans table foreign key resolution
180
+ table_schema = get_table_schema_definition(
181
+ table_type, traces_table_name=self.trace_table_name, db_schema=self.db_schema
182
+ ).copy()
176
183
 
177
184
  columns: List[Column] = []
178
185
  indexes: List[str] = []
@@ -195,12 +202,7 @@ class PostgresDb(BaseDb):
195
202
 
196
203
  # Handle foreign key constraint
197
204
  if "foreign_key" in col_config:
198
- fk_ref = col_config["foreign_key"]
199
- # For spans table, dynamically replace the traces table reference
200
- # with the actual trace table name configured for this db instance
201
- if table_type == "spans" and "trace_id" in fk_ref:
202
- fk_ref = f"{self.db_schema}.{self.trace_table_name}.trace_id"
203
- column_args.append(ForeignKey(fk_ref))
205
+ column_args.append(ForeignKey(col_config["foreign_key"]))
204
206
 
205
207
  columns.append(Column(*column_args, **column_kwargs)) # type: ignore
206
208
 
@@ -512,6 +514,11 @@ class PostgresDb(BaseDb):
512
514
 
513
515
  if user_id is not None:
514
516
  stmt = stmt.where(table.c.user_id == user_id)
517
+
518
+ # Filter by session_type to ensure we get the correct session type
519
+ session_type_value = session_type.value if isinstance(session_type, SessionType) else session_type
520
+ stmt = stmt.where(table.c.session_type == session_type_value)
521
+
515
522
  result = sess.execute(stmt).fetchone()
516
523
  if result is None:
517
524
  return None
@@ -596,9 +603,7 @@ class PostgresDb(BaseDb):
596
603
  stmt = stmt.where(table.c.created_at <= end_timestamp)
597
604
  if session_name is not None:
598
605
  stmt = stmt.where(
599
- func.coalesce(func.json_extract_path_text(table.c.session_data, "session_name"), "").ilike(
600
- f"%{session_name}%"
601
- )
606
+ func.coalesce(table.c.session_data["session_name"].astext, "").ilike(f"%{session_name}%")
602
607
  )
603
608
  if session_type is not None:
604
609
  session_type_value = session_type.value if isinstance(session_type, SessionType) else session_type
@@ -663,6 +668,8 @@ class PostgresDb(BaseDb):
663
668
  return None
664
669
 
665
670
  with self.Session() as sess, sess.begin():
671
+ # Sanitize session_name to remove null bytes
672
+ sanitized_session_name = sanitize_postgres_string(session_name)
666
673
  stmt = (
667
674
  update(table)
668
675
  .where(table.c.session_id == session_id)
@@ -672,7 +679,7 @@ class PostgresDb(BaseDb):
672
679
  func.jsonb_set(
673
680
  func.cast(table.c.session_data, postgresql.JSONB),
674
681
  text("'{session_name}'"),
675
- func.to_jsonb(session_name),
682
+ func.to_jsonb(sanitized_session_name),
676
683
  ),
677
684
  postgresql.JSON,
678
685
  )
@@ -728,6 +735,21 @@ class PostgresDb(BaseDb):
728
735
  return None
729
736
 
730
737
  session_dict = session.to_dict()
738
+ # Sanitize JSON/dict fields to remove null bytes from nested strings
739
+ if session_dict.get("agent_data"):
740
+ session_dict["agent_data"] = sanitize_postgres_strings(session_dict["agent_data"])
741
+ if session_dict.get("team_data"):
742
+ session_dict["team_data"] = sanitize_postgres_strings(session_dict["team_data"])
743
+ if session_dict.get("workflow_data"):
744
+ session_dict["workflow_data"] = sanitize_postgres_strings(session_dict["workflow_data"])
745
+ if session_dict.get("session_data"):
746
+ session_dict["session_data"] = sanitize_postgres_strings(session_dict["session_data"])
747
+ if session_dict.get("summary"):
748
+ session_dict["summary"] = sanitize_postgres_strings(session_dict["summary"])
749
+ if session_dict.get("metadata"):
750
+ session_dict["metadata"] = sanitize_postgres_strings(session_dict["metadata"])
751
+ if session_dict.get("runs"):
752
+ session_dict["runs"] = sanitize_postgres_strings(session_dict["runs"])
731
753
 
732
754
  if isinstance(session, AgentSession):
733
755
  with self.Session() as sess, sess.begin():
@@ -881,6 +903,18 @@ class PostgresDb(BaseDb):
881
903
  session_records = []
882
904
  for agent_session in agent_sessions:
883
905
  session_dict = agent_session.to_dict()
906
+ # Sanitize JSON/dict fields to remove null bytes from nested strings
907
+ if session_dict.get("agent_data"):
908
+ session_dict["agent_data"] = sanitize_postgres_strings(session_dict["agent_data"])
909
+ if session_dict.get("session_data"):
910
+ session_dict["session_data"] = sanitize_postgres_strings(session_dict["session_data"])
911
+ if session_dict.get("summary"):
912
+ session_dict["summary"] = sanitize_postgres_strings(session_dict["summary"])
913
+ if session_dict.get("metadata"):
914
+ session_dict["metadata"] = sanitize_postgres_strings(session_dict["metadata"])
915
+ if session_dict.get("runs"):
916
+ session_dict["runs"] = sanitize_postgres_strings(session_dict["runs"])
917
+
884
918
  # Use preserved updated_at if flag is set (even if None), otherwise use current time
885
919
  updated_at = session_dict.get("updated_at") if preserve_updated_at else int(time.time())
886
920
  session_records.append(
@@ -926,6 +960,18 @@ class PostgresDb(BaseDb):
926
960
  session_records = []
927
961
  for team_session in team_sessions:
928
962
  session_dict = team_session.to_dict()
963
+ # Sanitize JSON/dict fields to remove null bytes from nested strings
964
+ if session_dict.get("team_data"):
965
+ session_dict["team_data"] = sanitize_postgres_strings(session_dict["team_data"])
966
+ if session_dict.get("session_data"):
967
+ session_dict["session_data"] = sanitize_postgres_strings(session_dict["session_data"])
968
+ if session_dict.get("summary"):
969
+ session_dict["summary"] = sanitize_postgres_strings(session_dict["summary"])
970
+ if session_dict.get("metadata"):
971
+ session_dict["metadata"] = sanitize_postgres_strings(session_dict["metadata"])
972
+ if session_dict.get("runs"):
973
+ session_dict["runs"] = sanitize_postgres_strings(session_dict["runs"])
974
+
929
975
  # Use preserved updated_at if flag is set (even if None), otherwise use current time
930
976
  updated_at = session_dict.get("updated_at") if preserve_updated_at else int(time.time())
931
977
  session_records.append(
@@ -971,6 +1017,18 @@ class PostgresDb(BaseDb):
971
1017
  session_records = []
972
1018
  for workflow_session in workflow_sessions:
973
1019
  session_dict = workflow_session.to_dict()
1020
+ # Sanitize JSON/dict fields to remove null bytes from nested strings
1021
+ if session_dict.get("workflow_data"):
1022
+ session_dict["workflow_data"] = sanitize_postgres_strings(session_dict["workflow_data"])
1023
+ if session_dict.get("session_data"):
1024
+ session_dict["session_data"] = sanitize_postgres_strings(session_dict["session_data"])
1025
+ if session_dict.get("summary"):
1026
+ session_dict["summary"] = sanitize_postgres_strings(session_dict["summary"])
1027
+ if session_dict.get("metadata"):
1028
+ session_dict["metadata"] = sanitize_postgres_strings(session_dict["metadata"])
1029
+ if session_dict.get("runs"):
1030
+ session_dict["runs"] = sanitize_postgres_strings(session_dict["runs"])
1031
+
974
1032
  # Use preserved updated_at if flag is set (even if None), otherwise use current time
975
1033
  updated_at = session_dict.get("updated_at") if preserve_updated_at else int(time.time())
976
1034
  session_records.append(
@@ -1098,16 +1156,35 @@ class PostgresDb(BaseDb):
1098
1156
  return []
1099
1157
 
1100
1158
  with self.Session() as sess, sess.begin():
1159
+ # Filter out NULL topics and ensure topics is an array before extracting elements
1160
+ # jsonb_typeof returns 'array' for JSONB arrays
1161
+ conditions = [
1162
+ table.c.topics.is_not(None),
1163
+ func.jsonb_typeof(table.c.topics) == "array",
1164
+ ]
1165
+
1101
1166
  try:
1102
- stmt = select(func.jsonb_array_elements_text(table.c.topics))
1167
+ # jsonb_array_elements_text is a set-returning function that must be used with select_from
1168
+ stmt = select(func.jsonb_array_elements_text(table.c.topics).label("topic"))
1169
+ stmt = stmt.select_from(table)
1170
+ stmt = stmt.where(and_(*conditions))
1103
1171
  result = sess.execute(stmt).fetchall()
1104
1172
  except ProgrammingError:
1105
1173
  # Retrying with json_array_elements_text. This works in older versions,
1106
1174
  # where the topics column was of type JSON instead of JSONB
1107
- stmt = select(func.json_array_elements_text(table.c.topics))
1175
+ # For JSON (not JSONB), we use json_typeof
1176
+ json_conditions = [
1177
+ table.c.topics.is_not(None),
1178
+ func.json_typeof(table.c.topics) == "array",
1179
+ ]
1180
+ stmt = select(func.json_array_elements_text(table.c.topics).label("topic"))
1181
+ stmt = stmt.select_from(table)
1182
+ stmt = stmt.where(and_(*json_conditions))
1108
1183
  result = sess.execute(stmt).fetchall()
1109
1184
 
1110
- return list(set([record[0] for record in result]))
1185
+ # Extract topics from records - each record is a Row with a 'topic' attribute
1186
+ topics = [record.topic for record in result if record.topic is not None]
1187
+ return list(set(topics))
1111
1188
 
1112
1189
  except Exception as e:
1113
1190
  log_error(f"Exception reading from memory table: {e}")
@@ -1348,6 +1425,10 @@ class PostgresDb(BaseDb):
1348
1425
  if table is None:
1349
1426
  return None
1350
1427
 
1428
+ # Sanitize string fields to remove null bytes (PostgreSQL doesn't allow them)
1429
+ sanitized_input = sanitize_postgres_string(memory.input)
1430
+ sanitized_feedback = sanitize_postgres_string(memory.feedback)
1431
+
1351
1432
  with self.Session() as sess, sess.begin():
1352
1433
  if memory.memory_id is None:
1353
1434
  memory.memory_id = str(uuid4())
@@ -1357,24 +1438,26 @@ class PostgresDb(BaseDb):
1357
1438
  stmt = postgresql.insert(table).values(
1358
1439
  memory_id=memory.memory_id,
1359
1440
  memory=memory.memory,
1360
- input=memory.input,
1441
+ input=sanitized_input,
1361
1442
  user_id=memory.user_id,
1362
1443
  agent_id=memory.agent_id,
1363
1444
  team_id=memory.team_id,
1364
1445
  topics=memory.topics,
1365
- feedback=memory.feedback,
1446
+ feedback=sanitized_feedback,
1366
1447
  created_at=memory.created_at,
1367
- updated_at=memory.created_at,
1448
+ updated_at=memory.updated_at
1449
+ if memory.updated_at is not None
1450
+ else (memory.created_at if memory.created_at is not None else current_time),
1368
1451
  )
1369
1452
  stmt = stmt.on_conflict_do_update( # type: ignore
1370
1453
  index_elements=["memory_id"],
1371
1454
  set_=dict(
1372
1455
  memory=memory.memory,
1373
1456
  topics=memory.topics,
1374
- input=memory.input,
1457
+ input=sanitized_input,
1375
1458
  agent_id=memory.agent_id,
1376
1459
  team_id=memory.team_id,
1377
- feedback=memory.feedback,
1460
+ feedback=sanitized_feedback,
1378
1461
  updated_at=current_time,
1379
1462
  # Preserve created_at on update - don't overwrite existing value
1380
1463
  created_at=table.c.created_at,
@@ -1432,16 +1515,20 @@ class PostgresDb(BaseDb):
1432
1515
  # Use preserved updated_at if flag is set (even if None), otherwise use current time
1433
1516
  updated_at = memory.updated_at if preserve_updated_at else current_time
1434
1517
 
1518
+ # Sanitize string fields to remove null bytes (PostgreSQL doesn't allow them)
1519
+ sanitized_input = sanitize_postgres_string(memory.input)
1520
+ sanitized_feedback = sanitize_postgres_string(memory.feedback)
1521
+
1435
1522
  memory_records.append(
1436
1523
  {
1437
1524
  "memory_id": memory.memory_id,
1438
1525
  "memory": memory.memory,
1439
- "input": memory.input,
1526
+ "input": sanitized_input,
1440
1527
  "user_id": memory.user_id,
1441
1528
  "agent_id": memory.agent_id,
1442
1529
  "team_id": memory.team_id,
1443
1530
  "topics": memory.topics,
1444
- "feedback": memory.feedback,
1531
+ "feedback": sanitized_feedback,
1445
1532
  "created_at": memory.created_at,
1446
1533
  "updated_at": updated_at,
1447
1534
  }
@@ -1747,8 +1834,7 @@ class PostgresDb(BaseDb):
1747
1834
  stmt = select(table)
1748
1835
 
1749
1836
  # Apply sorting
1750
- if sort_by is not None:
1751
- stmt = stmt.order_by(getattr(table.c, sort_by) * (1 if sort_order == "asc" else -1))
1837
+ stmt = apply_sorting(stmt, table, sort_by, sort_order)
1752
1838
 
1753
1839
  # Get total count before applying limit and pagination
1754
1840
  count_stmt = select(func.count()).select_from(stmt.alias())
@@ -1807,10 +1893,19 @@ class PostgresDb(BaseDb):
1807
1893
  }
1808
1894
 
1809
1895
  # Build insert and update data only for fields that exist in the table
1896
+ # String fields that need sanitization
1897
+ string_fields = {"name", "description", "type", "status", "status_message", "external_id", "linked_to"}
1898
+
1810
1899
  for model_field, table_column in field_mapping.items():
1811
1900
  if table_column in table_columns:
1812
1901
  value = getattr(knowledge_row, model_field, None)
1813
1902
  if value is not None:
1903
+ # Sanitize string fields to remove null bytes
1904
+ if table_column in string_fields and isinstance(value, str):
1905
+ value = sanitize_postgres_string(value)
1906
+ # Sanitize metadata dict if present
1907
+ elif table_column == "metadata" and isinstance(value, dict):
1908
+ value = sanitize_postgres_strings(value)
1814
1909
  insert_data[table_column] = value
1815
1910
  # Don't include ID in update_fields since it's the primary key
1816
1911
  if table_column != "id":
@@ -1865,8 +1960,22 @@ class PostgresDb(BaseDb):
1865
1960
 
1866
1961
  with self.Session() as sess, sess.begin():
1867
1962
  current_time = int(time.time())
1963
+ eval_data = eval_run.model_dump()
1964
+ # Sanitize string fields in eval_run
1965
+ if eval_data.get("name"):
1966
+ eval_data["name"] = sanitize_postgres_string(eval_data["name"])
1967
+ if eval_data.get("evaluated_component_name"):
1968
+ eval_data["evaluated_component_name"] = sanitize_postgres_string(
1969
+ eval_data["evaluated_component_name"]
1970
+ )
1971
+ # Sanitize nested dicts/JSON fields
1972
+ if eval_data.get("eval_data"):
1973
+ eval_data["eval_data"] = sanitize_postgres_strings(eval_data["eval_data"])
1974
+ if eval_data.get("eval_input"):
1975
+ eval_data["eval_input"] = sanitize_postgres_strings(eval_data["eval_input"])
1976
+
1868
1977
  stmt = postgresql.insert(table).values(
1869
- {"created_at": current_time, "updated_at": current_time, **eval_run.model_dump()}
1978
+ {"created_at": current_time, "updated_at": current_time, **eval_data}
1870
1979
  )
1871
1980
  sess.execute(stmt)
1872
1981
 
@@ -2080,8 +2189,12 @@ class PostgresDb(BaseDb):
2080
2189
  return None
2081
2190
 
2082
2191
  with self.Session() as sess, sess.begin():
2192
+ # Sanitize string field to remove null bytes
2193
+ sanitized_name = sanitize_postgres_string(name)
2083
2194
  stmt = (
2084
- table.update().where(table.c.run_id == eval_run_id).values(name=name, updated_at=int(time.time()))
2195
+ table.update()
2196
+ .where(table.c.run_id == eval_run_id)
2197
+ .values(name=sanitized_name, updated_at=int(time.time()))
2085
2198
  )
2086
2199
  sess.execute(stmt)
2087
2200
 
@@ -2278,15 +2391,25 @@ class PostgresDb(BaseDb):
2278
2391
 
2279
2392
  # Serialize content, categories, and notes into a JSON dict for DB storage
2280
2393
  content_dict = serialize_cultural_knowledge(cultural_knowledge)
2394
+ # Sanitize content_dict to remove null bytes from nested strings
2395
+ if content_dict:
2396
+ content_dict = cast(Dict[str, Any], sanitize_postgres_strings(content_dict))
2397
+
2398
+ # Sanitize string fields to remove null bytes (PostgreSQL doesn't allow them)
2399
+ sanitized_name = sanitize_postgres_string(cultural_knowledge.name)
2400
+ sanitized_summary = sanitize_postgres_string(cultural_knowledge.summary)
2401
+ sanitized_input = sanitize_postgres_string(cultural_knowledge.input)
2281
2402
 
2282
2403
  with self.Session() as sess, sess.begin():
2283
2404
  stmt = postgresql.insert(table).values(
2284
2405
  id=cultural_knowledge.id,
2285
- name=cultural_knowledge.name,
2286
- summary=cultural_knowledge.summary,
2406
+ name=sanitized_name,
2407
+ summary=sanitized_summary,
2287
2408
  content=content_dict if content_dict else None,
2288
- metadata=cultural_knowledge.metadata,
2289
- input=cultural_knowledge.input,
2409
+ metadata=sanitize_postgres_strings(cultural_knowledge.metadata)
2410
+ if cultural_knowledge.metadata
2411
+ else None,
2412
+ input=sanitized_input,
2290
2413
  created_at=cultural_knowledge.created_at,
2291
2414
  updated_at=int(time.time()),
2292
2415
  agent_id=cultural_knowledge.agent_id,
@@ -2295,11 +2418,13 @@ class PostgresDb(BaseDb):
2295
2418
  stmt = stmt.on_conflict_do_update( # type: ignore
2296
2419
  index_elements=["id"],
2297
2420
  set_=dict(
2298
- name=cultural_knowledge.name,
2299
- summary=cultural_knowledge.summary,
2421
+ name=sanitized_name,
2422
+ summary=sanitized_summary,
2300
2423
  content=content_dict if content_dict else None,
2301
- metadata=cultural_knowledge.metadata,
2302
- input=cultural_knowledge.input,
2424
+ metadata=sanitize_postgres_strings(cultural_knowledge.metadata)
2425
+ if cultural_knowledge.metadata
2426
+ else None,
2427
+ input=sanitized_input,
2303
2428
  updated_at=int(time.time()),
2304
2429
  agent_id=cultural_knowledge.agent_id,
2305
2430
  team_id=cultural_knowledge.team_id,
@@ -2458,6 +2583,13 @@ class PostgresDb(BaseDb):
2458
2583
  trace_dict = trace.to_dict()
2459
2584
  trace_dict.pop("total_spans", None)
2460
2585
  trace_dict.pop("error_count", None)
2586
+ # Sanitize string fields and nested JSON structures
2587
+ if trace_dict.get("name"):
2588
+ trace_dict["name"] = sanitize_postgres_string(trace_dict["name"])
2589
+ if trace_dict.get("status"):
2590
+ trace_dict["status"] = sanitize_postgres_string(trace_dict["status"])
2591
+ # Sanitize any nested dict/JSON fields
2592
+ trace_dict = cast(Dict[str, Any], sanitize_postgres_strings(trace_dict))
2461
2593
 
2462
2594
  with self.Session() as sess, sess.begin():
2463
2595
  # Use upsert to handle concurrent inserts atomically
@@ -2781,7 +2913,15 @@ class PostgresDb(BaseDb):
2781
2913
  return
2782
2914
 
2783
2915
  with self.Session() as sess, sess.begin():
2784
- stmt = postgresql.insert(table).values(span.to_dict())
2916
+ span_dict = span.to_dict()
2917
+ # Sanitize string fields and nested JSON structures
2918
+ if span_dict.get("name"):
2919
+ span_dict["name"] = sanitize_postgres_string(span_dict["name"])
2920
+ if span_dict.get("status_code"):
2921
+ span_dict["status_code"] = sanitize_postgres_string(span_dict["status_code"])
2922
+ # Sanitize any nested dict/JSON fields
2923
+ span_dict = cast(Dict[str, Any], sanitize_postgres_strings(span_dict))
2924
+ stmt = postgresql.insert(table).values(span_dict)
2785
2925
  sess.execute(stmt)
2786
2926
 
2787
2927
  except Exception as e:
@@ -2803,7 +2943,15 @@ class PostgresDb(BaseDb):
2803
2943
 
2804
2944
  with self.Session() as sess, sess.begin():
2805
2945
  for span in spans:
2806
- stmt = postgresql.insert(table).values(span.to_dict())
2946
+ span_dict = span.to_dict()
2947
+ # Sanitize string fields and nested JSON structures
2948
+ if span_dict.get("name"):
2949
+ span_dict["name"] = sanitize_postgres_string(span_dict["name"])
2950
+ if span_dict.get("status_code"):
2951
+ span_dict["status_code"] = sanitize_postgres_string(span_dict["status_code"])
2952
+ # Sanitize any nested dict/JSON fields
2953
+ span_dict = sanitize_postgres_strings(span_dict)
2954
+ stmt = postgresql.insert(table).values(span_dict)
2807
2955
  sess.execute(stmt)
2808
2956
 
2809
2957
  except Exception as e:
@@ -137,37 +137,56 @@ TRACE_TABLE_SCHEMA = {
137
137
  "created_at": {"type": String, "nullable": False, "index": True}, # ISO 8601 datetime string
138
138
  }
139
139
 
140
- SPAN_TABLE_SCHEMA = {
141
- "span_id": {"type": String, "primary_key": True, "nullable": False},
142
- "trace_id": {
143
- "type": String,
144
- "nullable": False,
145
- "index": True,
146
- "foreign_key": "agno_traces.trace_id", # Foreign key to traces table
147
- },
148
- "parent_span_id": {"type": String, "nullable": True, "index": True},
149
- "name": {"type": String, "nullable": False},
150
- "span_kind": {"type": String, "nullable": False},
151
- "status_code": {"type": String, "nullable": False},
152
- "status_message": {"type": Text, "nullable": True},
153
- "start_time": {"type": String, "nullable": False, "index": True}, # ISO 8601 datetime string
154
- "end_time": {"type": String, "nullable": False}, # ISO 8601 datetime string
155
- "duration_ms": {"type": BigInteger, "nullable": False},
156
- "attributes": {"type": JSONB, "nullable": True},
157
- "created_at": {"type": String, "nullable": False, "index": True}, # ISO 8601 datetime string
158
- }
159
140
 
141
+ def _get_span_table_schema(traces_table_name: str = "agno_traces", db_schema: str = "agno") -> dict[str, Any]:
142
+ """Get the span table schema with the correct foreign key reference.
143
+
144
+ Args:
145
+ traces_table_name: The name of the traces table to reference in the foreign key.
146
+ db_schema: The database schema name.
147
+
148
+ Returns:
149
+ The span table schema dictionary.
150
+ """
151
+ return {
152
+ "span_id": {"type": String, "primary_key": True, "nullable": False},
153
+ "trace_id": {
154
+ "type": String,
155
+ "nullable": False,
156
+ "index": True,
157
+ "foreign_key": f"{db_schema}.{traces_table_name}.trace_id",
158
+ },
159
+ "parent_span_id": {"type": String, "nullable": True, "index": True},
160
+ "name": {"type": String, "nullable": False},
161
+ "span_kind": {"type": String, "nullable": False},
162
+ "status_code": {"type": String, "nullable": False},
163
+ "status_message": {"type": Text, "nullable": True},
164
+ "start_time": {"type": String, "nullable": False, "index": True}, # ISO 8601 datetime string
165
+ "end_time": {"type": String, "nullable": False}, # ISO 8601 datetime string
166
+ "duration_ms": {"type": BigInteger, "nullable": False},
167
+ "attributes": {"type": JSONB, "nullable": True},
168
+ "created_at": {"type": String, "nullable": False, "index": True}, # ISO 8601 datetime string
169
+ }
160
170
 
161
- def get_table_schema_definition(table_type: str) -> dict[str, Any]:
171
+
172
+ def get_table_schema_definition(
173
+ table_type: str, traces_table_name: str = "agno_traces", db_schema: str = "agno"
174
+ ) -> dict[str, Any]:
162
175
  """
163
176
  Get the expected schema definition for the given table.
164
177
 
165
178
  Args:
166
179
  table_type (str): The type of table to get the schema for.
180
+ traces_table_name (str): The name of the traces table (used for spans foreign key).
181
+ db_schema (str): The database schema name (used for spans foreign key).
167
182
 
168
183
  Returns:
169
184
  Dict[str, Any]: Dictionary containing column definitions for the table
170
185
  """
186
+ # Handle spans table specially to resolve the foreign key reference
187
+ if table_type == "spans":
188
+ return _get_span_table_schema(traces_table_name, db_schema)
189
+
171
190
  schemas = {
172
191
  "sessions": SESSION_TABLE_SCHEMA,
173
192
  "evals": EVAL_TABLE_SCHEMA,
@@ -177,7 +196,6 @@ def get_table_schema_definition(table_type: str) -> dict[str, Any]:
177
196
  "culture": CULTURAL_KNOWLEDGE_TABLE_SCHEMA,
178
197
  "versions": VERSIONS_TABLE_SCHEMA,
179
198
  "traces": TRACE_TABLE_SCHEMA,
180
- "spans": SPAN_TABLE_SCHEMA,
181
199
  }
182
200
 
183
201
  schema = schemas.get(table_type, {})
agno/db/postgres/utils.py CHANGED
@@ -15,6 +15,7 @@ from agno.utils.log import log_debug, log_error, log_warning
15
15
  try:
16
16
  from sqlalchemy import Table, func
17
17
  from sqlalchemy.dialects import postgresql
18
+ from sqlalchemy.exc import NoSuchTableError
18
19
  from sqlalchemy.inspection import inspect
19
20
  from sqlalchemy.orm import Session
20
21
  from sqlalchemy.sql.expression import text
@@ -183,6 +184,9 @@ async def ais_valid_table(db_engine: AsyncEngine, table_name: str, table_type: s
183
184
  return False
184
185
 
185
186
  return True
187
+ except NoSuchTableError:
188
+ log_error(f"Table {db_schema}.{table_name} does not exist")
189
+ return False
186
190
  except Exception as e:
187
191
  log_error(f"Error validating table schema for {db_schema}.{table_name}: {e}")
188
192
  return False
@@ -317,8 +321,8 @@ def calculate_date_metrics(date_to_process: date, sessions_data: dict) -> dict:
317
321
  model_counts[f"{model_id}:{model_provider}"] = (
318
322
  model_counts.get(f"{model_id}:{model_provider}", 0) + 1
319
323
  )
320
-
321
- session_metrics = session.get("session_data", {}).get("session_metrics", {})
324
+ session_data = session.get("session_data", {}) or {}
325
+ session_metrics = session_data.get("session_metrics", {}) or {}
322
326
  for field in token_metrics:
323
327
  token_metrics[field] += session_metrics.get(field, 0)
324
328
 
@@ -131,35 +131,56 @@ TRACE_TABLE_SCHEMA = {
131
131
  "created_at": {"type": lambda: String(64), "nullable": False, "index": True}, # ISO 8601 datetime string
132
132
  }
133
133
 
134
- SPAN_TABLE_SCHEMA = {
135
- "span_id": {"type": lambda: String(128), "primary_key": True, "nullable": False},
136
- "trace_id": {
137
- "type": lambda: String(128),
138
- "nullable": False,
139
- "index": True,
140
- "foreign_key": "agno_traces.trace_id", # Foreign key to traces table
141
- },
142
- "parent_span_id": {"type": lambda: String(128), "nullable": True, "index": True},
143
- "name": {"type": lambda: String(512), "nullable": False},
144
- "span_kind": {"type": lambda: String(50), "nullable": False},
145
- "status_code": {"type": lambda: String(20), "nullable": False},
146
- "status_message": {"type": Text, "nullable": True},
147
- "start_time": {"type": lambda: String(64), "nullable": False, "index": True}, # ISO 8601 datetime string
148
- "end_time": {"type": lambda: String(64), "nullable": False}, # ISO 8601 datetime string
149
- "duration_ms": {"type": BigInteger, "nullable": False},
150
- "attributes": {"type": JSON, "nullable": True},
151
- "created_at": {"type": lambda: String(64), "nullable": False, "index": True}, # ISO 8601 datetime string
152
- }
153
134
 
135
+ def _get_span_table_schema(traces_table_name: str = "agno_traces", db_schema: str = "agno") -> dict[str, Any]:
136
+ """Get the span table schema with the correct foreign key reference.
137
+
138
+ Args:
139
+ traces_table_name: The name of the traces table to reference in the foreign key.
140
+ db_schema: The database schema name.
141
+
142
+ Returns:
143
+ The span table schema dictionary.
144
+ """
145
+ return {
146
+ "span_id": {"type": lambda: String(128), "primary_key": True, "nullable": False},
147
+ "trace_id": {
148
+ "type": lambda: String(128),
149
+ "nullable": False,
150
+ "index": True,
151
+ "foreign_key": f"{db_schema}.{traces_table_name}.trace_id",
152
+ },
153
+ "parent_span_id": {"type": lambda: String(128), "nullable": True, "index": True},
154
+ "name": {"type": lambda: String(512), "nullable": False},
155
+ "span_kind": {"type": lambda: String(50), "nullable": False},
156
+ "status_code": {"type": lambda: String(20), "nullable": False},
157
+ "status_message": {"type": Text, "nullable": True},
158
+ "start_time": {"type": lambda: String(64), "nullable": False, "index": True}, # ISO 8601 datetime string
159
+ "end_time": {"type": lambda: String(64), "nullable": False}, # ISO 8601 datetime string
160
+ "duration_ms": {"type": BigInteger, "nullable": False},
161
+ "attributes": {"type": JSON, "nullable": True},
162
+ "created_at": {"type": lambda: String(64), "nullable": False, "index": True}, # ISO 8601 datetime string
163
+ }
154
164
 
155
- def get_table_schema_definition(table_type: str) -> dict[str, Any]:
165
+
166
+ def get_table_schema_definition(
167
+ table_type: str, traces_table_name: str = "agno_traces", db_schema: str = "agno"
168
+ ) -> dict[str, Any]:
156
169
  """
157
170
  Get the expected schema definition for the given table.
171
+
158
172
  Args:
159
173
  table_type (str): The type of table to get the schema for.
174
+ traces_table_name (str): The name of the traces table (used for spans foreign key).
175
+ db_schema (str): The database schema name (used for spans foreign key).
176
+
160
177
  Returns:
161
178
  Dict[str, Any]: Dictionary containing column definitions for the table
162
179
  """
180
+ # Handle spans table specially to resolve the foreign key reference
181
+ if table_type == "spans":
182
+ return _get_span_table_schema(traces_table_name, db_schema)
183
+
163
184
  schemas = {
164
185
  "sessions": SESSION_TABLE_SCHEMA,
165
186
  "evals": EVAL_TABLE_SCHEMA,
@@ -169,7 +190,6 @@ def get_table_schema_definition(table_type: str) -> dict[str, Any]:
169
190
  "culture": CULTURAL_KNOWLEDGE_TABLE_SCHEMA,
170
191
  "versions": VERSIONS_TABLE_SCHEMA,
171
192
  "traces": TRACE_TABLE_SCHEMA,
172
- "spans": SPAN_TABLE_SCHEMA,
173
193
  }
174
194
  schema = schemas.get(table_type, {})
175
195