agno 2.2.13__py3-none-any.whl → 2.4.3__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 (383) hide show
  1. agno/agent/__init__.py +6 -0
  2. agno/agent/agent.py +5252 -3145
  3. agno/agent/remote.py +525 -0
  4. agno/api/api.py +2 -0
  5. agno/client/__init__.py +3 -0
  6. agno/client/a2a/__init__.py +10 -0
  7. agno/client/a2a/client.py +554 -0
  8. agno/client/a2a/schemas.py +112 -0
  9. agno/client/a2a/utils.py +369 -0
  10. agno/client/os.py +2669 -0
  11. agno/compression/__init__.py +3 -0
  12. agno/compression/manager.py +247 -0
  13. agno/culture/manager.py +2 -2
  14. agno/db/base.py +927 -6
  15. agno/db/dynamo/dynamo.py +788 -2
  16. agno/db/dynamo/schemas.py +128 -0
  17. agno/db/dynamo/utils.py +26 -3
  18. agno/db/firestore/firestore.py +674 -50
  19. agno/db/firestore/schemas.py +41 -0
  20. agno/db/firestore/utils.py +25 -10
  21. agno/db/gcs_json/gcs_json_db.py +506 -3
  22. agno/db/gcs_json/utils.py +14 -2
  23. agno/db/in_memory/in_memory_db.py +203 -4
  24. agno/db/in_memory/utils.py +14 -2
  25. agno/db/json/json_db.py +498 -2
  26. agno/db/json/utils.py +14 -2
  27. agno/db/migrations/manager.py +199 -0
  28. agno/db/migrations/utils.py +19 -0
  29. agno/db/migrations/v1_to_v2.py +54 -16
  30. agno/db/migrations/versions/__init__.py +0 -0
  31. agno/db/migrations/versions/v2_3_0.py +977 -0
  32. agno/db/mongo/async_mongo.py +1013 -39
  33. agno/db/mongo/mongo.py +684 -4
  34. agno/db/mongo/schemas.py +48 -0
  35. agno/db/mongo/utils.py +17 -0
  36. agno/db/mysql/__init__.py +2 -1
  37. agno/db/mysql/async_mysql.py +2958 -0
  38. agno/db/mysql/mysql.py +722 -53
  39. agno/db/mysql/schemas.py +77 -11
  40. agno/db/mysql/utils.py +151 -8
  41. agno/db/postgres/async_postgres.py +1254 -137
  42. agno/db/postgres/postgres.py +2316 -93
  43. agno/db/postgres/schemas.py +153 -21
  44. agno/db/postgres/utils.py +22 -7
  45. agno/db/redis/redis.py +531 -3
  46. agno/db/redis/schemas.py +36 -0
  47. agno/db/redis/utils.py +31 -15
  48. agno/db/schemas/evals.py +1 -0
  49. agno/db/schemas/memory.py +20 -9
  50. agno/db/singlestore/schemas.py +70 -1
  51. agno/db/singlestore/singlestore.py +737 -74
  52. agno/db/singlestore/utils.py +13 -3
  53. agno/db/sqlite/async_sqlite.py +1069 -89
  54. agno/db/sqlite/schemas.py +133 -1
  55. agno/db/sqlite/sqlite.py +2203 -165
  56. agno/db/sqlite/utils.py +21 -11
  57. agno/db/surrealdb/models.py +25 -0
  58. agno/db/surrealdb/surrealdb.py +603 -1
  59. agno/db/utils.py +60 -0
  60. agno/eval/__init__.py +26 -3
  61. agno/eval/accuracy.py +25 -12
  62. agno/eval/agent_as_judge.py +871 -0
  63. agno/eval/base.py +29 -0
  64. agno/eval/performance.py +10 -4
  65. agno/eval/reliability.py +22 -13
  66. agno/eval/utils.py +2 -1
  67. agno/exceptions.py +42 -0
  68. agno/hooks/__init__.py +3 -0
  69. agno/hooks/decorator.py +164 -0
  70. agno/integrations/discord/client.py +13 -2
  71. agno/knowledge/__init__.py +4 -0
  72. agno/knowledge/chunking/code.py +90 -0
  73. agno/knowledge/chunking/document.py +65 -4
  74. agno/knowledge/chunking/fixed.py +4 -1
  75. agno/knowledge/chunking/markdown.py +102 -11
  76. agno/knowledge/chunking/recursive.py +2 -2
  77. agno/knowledge/chunking/semantic.py +130 -48
  78. agno/knowledge/chunking/strategy.py +18 -0
  79. agno/knowledge/embedder/azure_openai.py +0 -1
  80. agno/knowledge/embedder/google.py +1 -1
  81. agno/knowledge/embedder/mistral.py +1 -1
  82. agno/knowledge/embedder/nebius.py +1 -1
  83. agno/knowledge/embedder/openai.py +16 -12
  84. agno/knowledge/filesystem.py +412 -0
  85. agno/knowledge/knowledge.py +4261 -1199
  86. agno/knowledge/protocol.py +134 -0
  87. agno/knowledge/reader/arxiv_reader.py +3 -2
  88. agno/knowledge/reader/base.py +9 -7
  89. agno/knowledge/reader/csv_reader.py +91 -42
  90. agno/knowledge/reader/docx_reader.py +9 -10
  91. agno/knowledge/reader/excel_reader.py +225 -0
  92. agno/knowledge/reader/field_labeled_csv_reader.py +38 -48
  93. agno/knowledge/reader/firecrawl_reader.py +3 -2
  94. agno/knowledge/reader/json_reader.py +16 -22
  95. agno/knowledge/reader/markdown_reader.py +15 -14
  96. agno/knowledge/reader/pdf_reader.py +33 -28
  97. agno/knowledge/reader/pptx_reader.py +9 -10
  98. agno/knowledge/reader/reader_factory.py +135 -1
  99. agno/knowledge/reader/s3_reader.py +8 -16
  100. agno/knowledge/reader/tavily_reader.py +3 -3
  101. agno/knowledge/reader/text_reader.py +15 -14
  102. agno/knowledge/reader/utils/__init__.py +17 -0
  103. agno/knowledge/reader/utils/spreadsheet.py +114 -0
  104. agno/knowledge/reader/web_search_reader.py +8 -65
  105. agno/knowledge/reader/website_reader.py +16 -13
  106. agno/knowledge/reader/wikipedia_reader.py +36 -3
  107. agno/knowledge/reader/youtube_reader.py +3 -2
  108. agno/knowledge/remote_content/__init__.py +33 -0
  109. agno/knowledge/remote_content/config.py +266 -0
  110. agno/knowledge/remote_content/remote_content.py +105 -17
  111. agno/knowledge/utils.py +76 -22
  112. agno/learn/__init__.py +71 -0
  113. agno/learn/config.py +463 -0
  114. agno/learn/curate.py +185 -0
  115. agno/learn/machine.py +725 -0
  116. agno/learn/schemas.py +1114 -0
  117. agno/learn/stores/__init__.py +38 -0
  118. agno/learn/stores/decision_log.py +1156 -0
  119. agno/learn/stores/entity_memory.py +3275 -0
  120. agno/learn/stores/learned_knowledge.py +1583 -0
  121. agno/learn/stores/protocol.py +117 -0
  122. agno/learn/stores/session_context.py +1217 -0
  123. agno/learn/stores/user_memory.py +1495 -0
  124. agno/learn/stores/user_profile.py +1220 -0
  125. agno/learn/utils.py +209 -0
  126. agno/media.py +22 -6
  127. agno/memory/__init__.py +14 -1
  128. agno/memory/manager.py +223 -8
  129. agno/memory/strategies/__init__.py +15 -0
  130. agno/memory/strategies/base.py +66 -0
  131. agno/memory/strategies/summarize.py +196 -0
  132. agno/memory/strategies/types.py +37 -0
  133. agno/models/aimlapi/aimlapi.py +17 -0
  134. agno/models/anthropic/claude.py +434 -59
  135. agno/models/aws/bedrock.py +121 -20
  136. agno/models/aws/claude.py +131 -274
  137. agno/models/azure/ai_foundry.py +10 -6
  138. agno/models/azure/openai_chat.py +33 -10
  139. agno/models/base.py +1162 -561
  140. agno/models/cerebras/cerebras.py +120 -24
  141. agno/models/cerebras/cerebras_openai.py +21 -2
  142. agno/models/cohere/chat.py +65 -6
  143. agno/models/cometapi/cometapi.py +18 -1
  144. agno/models/dashscope/dashscope.py +2 -3
  145. agno/models/deepinfra/deepinfra.py +18 -1
  146. agno/models/deepseek/deepseek.py +69 -3
  147. agno/models/fireworks/fireworks.py +18 -1
  148. agno/models/google/gemini.py +959 -89
  149. agno/models/google/utils.py +22 -0
  150. agno/models/groq/groq.py +48 -18
  151. agno/models/huggingface/huggingface.py +17 -6
  152. agno/models/ibm/watsonx.py +16 -6
  153. agno/models/internlm/internlm.py +18 -1
  154. agno/models/langdb/langdb.py +13 -1
  155. agno/models/litellm/chat.py +88 -9
  156. agno/models/litellm/litellm_openai.py +18 -1
  157. agno/models/message.py +24 -5
  158. agno/models/meta/llama.py +40 -13
  159. agno/models/meta/llama_openai.py +22 -21
  160. agno/models/metrics.py +12 -0
  161. agno/models/mistral/mistral.py +8 -4
  162. agno/models/n1n/__init__.py +3 -0
  163. agno/models/n1n/n1n.py +57 -0
  164. agno/models/nebius/nebius.py +6 -7
  165. agno/models/nvidia/nvidia.py +20 -3
  166. agno/models/ollama/__init__.py +2 -0
  167. agno/models/ollama/chat.py +17 -6
  168. agno/models/ollama/responses.py +100 -0
  169. agno/models/openai/__init__.py +2 -0
  170. agno/models/openai/chat.py +117 -26
  171. agno/models/openai/open_responses.py +46 -0
  172. agno/models/openai/responses.py +110 -32
  173. agno/models/openrouter/__init__.py +2 -0
  174. agno/models/openrouter/openrouter.py +67 -2
  175. agno/models/openrouter/responses.py +146 -0
  176. agno/models/perplexity/perplexity.py +19 -1
  177. agno/models/portkey/portkey.py +7 -6
  178. agno/models/requesty/requesty.py +19 -2
  179. agno/models/response.py +20 -2
  180. agno/models/sambanova/sambanova.py +20 -3
  181. agno/models/siliconflow/siliconflow.py +19 -2
  182. agno/models/together/together.py +20 -3
  183. agno/models/vercel/v0.py +20 -3
  184. agno/models/vertexai/claude.py +124 -4
  185. agno/models/vllm/vllm.py +19 -14
  186. agno/models/xai/xai.py +19 -2
  187. agno/os/app.py +467 -137
  188. agno/os/auth.py +253 -5
  189. agno/os/config.py +22 -0
  190. agno/os/interfaces/a2a/a2a.py +7 -6
  191. agno/os/interfaces/a2a/router.py +635 -26
  192. agno/os/interfaces/a2a/utils.py +32 -33
  193. agno/os/interfaces/agui/agui.py +5 -3
  194. agno/os/interfaces/agui/router.py +26 -16
  195. agno/os/interfaces/agui/utils.py +97 -57
  196. agno/os/interfaces/base.py +7 -7
  197. agno/os/interfaces/slack/router.py +16 -7
  198. agno/os/interfaces/slack/slack.py +7 -7
  199. agno/os/interfaces/whatsapp/router.py +35 -7
  200. agno/os/interfaces/whatsapp/security.py +3 -1
  201. agno/os/interfaces/whatsapp/whatsapp.py +11 -8
  202. agno/os/managers.py +326 -0
  203. agno/os/mcp.py +652 -79
  204. agno/os/middleware/__init__.py +4 -0
  205. agno/os/middleware/jwt.py +718 -115
  206. agno/os/middleware/trailing_slash.py +27 -0
  207. agno/os/router.py +105 -1558
  208. agno/os/routers/agents/__init__.py +3 -0
  209. agno/os/routers/agents/router.py +655 -0
  210. agno/os/routers/agents/schema.py +288 -0
  211. agno/os/routers/components/__init__.py +3 -0
  212. agno/os/routers/components/components.py +475 -0
  213. agno/os/routers/database.py +155 -0
  214. agno/os/routers/evals/evals.py +111 -18
  215. agno/os/routers/evals/schemas.py +38 -5
  216. agno/os/routers/evals/utils.py +80 -11
  217. agno/os/routers/health.py +3 -3
  218. agno/os/routers/knowledge/knowledge.py +284 -35
  219. agno/os/routers/knowledge/schemas.py +14 -2
  220. agno/os/routers/memory/memory.py +274 -11
  221. agno/os/routers/memory/schemas.py +44 -3
  222. agno/os/routers/metrics/metrics.py +30 -15
  223. agno/os/routers/metrics/schemas.py +10 -6
  224. agno/os/routers/registry/__init__.py +3 -0
  225. agno/os/routers/registry/registry.py +337 -0
  226. agno/os/routers/session/session.py +143 -14
  227. agno/os/routers/teams/__init__.py +3 -0
  228. agno/os/routers/teams/router.py +550 -0
  229. agno/os/routers/teams/schema.py +280 -0
  230. agno/os/routers/traces/__init__.py +3 -0
  231. agno/os/routers/traces/schemas.py +414 -0
  232. agno/os/routers/traces/traces.py +549 -0
  233. agno/os/routers/workflows/__init__.py +3 -0
  234. agno/os/routers/workflows/router.py +757 -0
  235. agno/os/routers/workflows/schema.py +139 -0
  236. agno/os/schema.py +157 -584
  237. agno/os/scopes.py +469 -0
  238. agno/os/settings.py +3 -0
  239. agno/os/utils.py +574 -185
  240. agno/reasoning/anthropic.py +85 -1
  241. agno/reasoning/azure_ai_foundry.py +93 -1
  242. agno/reasoning/deepseek.py +102 -2
  243. agno/reasoning/default.py +6 -7
  244. agno/reasoning/gemini.py +87 -3
  245. agno/reasoning/groq.py +109 -2
  246. agno/reasoning/helpers.py +6 -7
  247. agno/reasoning/manager.py +1238 -0
  248. agno/reasoning/ollama.py +93 -1
  249. agno/reasoning/openai.py +115 -1
  250. agno/reasoning/vertexai.py +85 -1
  251. agno/registry/__init__.py +3 -0
  252. agno/registry/registry.py +68 -0
  253. agno/remote/__init__.py +3 -0
  254. agno/remote/base.py +581 -0
  255. agno/run/__init__.py +2 -4
  256. agno/run/agent.py +134 -19
  257. agno/run/base.py +49 -1
  258. agno/run/cancel.py +65 -52
  259. agno/run/cancellation_management/__init__.py +9 -0
  260. agno/run/cancellation_management/base.py +78 -0
  261. agno/run/cancellation_management/in_memory_cancellation_manager.py +100 -0
  262. agno/run/cancellation_management/redis_cancellation_manager.py +236 -0
  263. agno/run/requirement.py +181 -0
  264. agno/run/team.py +111 -19
  265. agno/run/workflow.py +2 -1
  266. agno/session/agent.py +57 -92
  267. agno/session/summary.py +1 -1
  268. agno/session/team.py +62 -115
  269. agno/session/workflow.py +353 -57
  270. agno/skills/__init__.py +17 -0
  271. agno/skills/agent_skills.py +377 -0
  272. agno/skills/errors.py +32 -0
  273. agno/skills/loaders/__init__.py +4 -0
  274. agno/skills/loaders/base.py +27 -0
  275. agno/skills/loaders/local.py +216 -0
  276. agno/skills/skill.py +65 -0
  277. agno/skills/utils.py +107 -0
  278. agno/skills/validator.py +277 -0
  279. agno/table.py +10 -0
  280. agno/team/__init__.py +5 -1
  281. agno/team/remote.py +447 -0
  282. agno/team/team.py +3769 -2202
  283. agno/tools/brandfetch.py +27 -18
  284. agno/tools/browserbase.py +225 -16
  285. agno/tools/crawl4ai.py +3 -0
  286. agno/tools/duckduckgo.py +25 -71
  287. agno/tools/exa.py +0 -21
  288. agno/tools/file.py +14 -13
  289. agno/tools/file_generation.py +12 -6
  290. agno/tools/firecrawl.py +15 -7
  291. agno/tools/function.py +94 -113
  292. agno/tools/google_bigquery.py +11 -2
  293. agno/tools/google_drive.py +4 -3
  294. agno/tools/knowledge.py +9 -4
  295. agno/tools/mcp/mcp.py +301 -18
  296. agno/tools/mcp/multi_mcp.py +269 -14
  297. agno/tools/mem0.py +11 -10
  298. agno/tools/memory.py +47 -46
  299. agno/tools/mlx_transcribe.py +10 -7
  300. agno/tools/models/nebius.py +5 -5
  301. agno/tools/models_labs.py +20 -10
  302. agno/tools/nano_banana.py +151 -0
  303. agno/tools/parallel.py +0 -7
  304. agno/tools/postgres.py +76 -36
  305. agno/tools/python.py +14 -6
  306. agno/tools/reasoning.py +30 -23
  307. agno/tools/redshift.py +406 -0
  308. agno/tools/shopify.py +1519 -0
  309. agno/tools/spotify.py +919 -0
  310. agno/tools/tavily.py +4 -1
  311. agno/tools/toolkit.py +253 -18
  312. agno/tools/websearch.py +93 -0
  313. agno/tools/website.py +1 -1
  314. agno/tools/wikipedia.py +1 -1
  315. agno/tools/workflow.py +56 -48
  316. agno/tools/yfinance.py +12 -11
  317. agno/tracing/__init__.py +12 -0
  318. agno/tracing/exporter.py +161 -0
  319. agno/tracing/schemas.py +276 -0
  320. agno/tracing/setup.py +112 -0
  321. agno/utils/agent.py +251 -10
  322. agno/utils/cryptography.py +22 -0
  323. agno/utils/dttm.py +33 -0
  324. agno/utils/events.py +264 -7
  325. agno/utils/hooks.py +111 -3
  326. agno/utils/http.py +161 -2
  327. agno/utils/mcp.py +49 -8
  328. agno/utils/media.py +22 -1
  329. agno/utils/models/ai_foundry.py +9 -2
  330. agno/utils/models/claude.py +20 -5
  331. agno/utils/models/cohere.py +9 -2
  332. agno/utils/models/llama.py +9 -2
  333. agno/utils/models/mistral.py +4 -2
  334. agno/utils/os.py +0 -0
  335. agno/utils/print_response/agent.py +99 -16
  336. agno/utils/print_response/team.py +223 -24
  337. agno/utils/print_response/workflow.py +0 -2
  338. agno/utils/prompts.py +8 -6
  339. agno/utils/remote.py +23 -0
  340. agno/utils/response.py +1 -13
  341. agno/utils/string.py +91 -2
  342. agno/utils/team.py +62 -12
  343. agno/utils/tokens.py +657 -0
  344. agno/vectordb/base.py +15 -2
  345. agno/vectordb/cassandra/cassandra.py +1 -1
  346. agno/vectordb/chroma/__init__.py +2 -1
  347. agno/vectordb/chroma/chromadb.py +468 -23
  348. agno/vectordb/clickhouse/clickhousedb.py +1 -1
  349. agno/vectordb/couchbase/couchbase.py +6 -2
  350. agno/vectordb/lancedb/lance_db.py +7 -38
  351. agno/vectordb/lightrag/lightrag.py +7 -6
  352. agno/vectordb/milvus/milvus.py +118 -84
  353. agno/vectordb/mongodb/__init__.py +2 -1
  354. agno/vectordb/mongodb/mongodb.py +14 -31
  355. agno/vectordb/pgvector/pgvector.py +120 -66
  356. agno/vectordb/pineconedb/pineconedb.py +2 -19
  357. agno/vectordb/qdrant/__init__.py +2 -1
  358. agno/vectordb/qdrant/qdrant.py +33 -56
  359. agno/vectordb/redis/__init__.py +2 -1
  360. agno/vectordb/redis/redisdb.py +19 -31
  361. agno/vectordb/singlestore/singlestore.py +17 -9
  362. agno/vectordb/surrealdb/surrealdb.py +2 -38
  363. agno/vectordb/weaviate/__init__.py +2 -1
  364. agno/vectordb/weaviate/weaviate.py +7 -3
  365. agno/workflow/__init__.py +5 -1
  366. agno/workflow/agent.py +2 -2
  367. agno/workflow/condition.py +12 -10
  368. agno/workflow/loop.py +28 -9
  369. agno/workflow/parallel.py +21 -13
  370. agno/workflow/remote.py +362 -0
  371. agno/workflow/router.py +12 -9
  372. agno/workflow/step.py +261 -36
  373. agno/workflow/steps.py +12 -8
  374. agno/workflow/types.py +40 -77
  375. agno/workflow/workflow.py +939 -213
  376. {agno-2.2.13.dist-info → agno-2.4.3.dist-info}/METADATA +134 -181
  377. agno-2.4.3.dist-info/RECORD +677 -0
  378. {agno-2.2.13.dist-info → agno-2.4.3.dist-info}/WHEEL +1 -1
  379. agno/tools/googlesearch.py +0 -98
  380. agno/tools/memori.py +0 -339
  381. agno-2.2.13.dist-info/RECORD +0 -575
  382. {agno-2.2.13.dist-info → agno-2.4.3.dist-info}/licenses/LICENSE +0 -0
  383. {agno-2.2.13.dist-info → agno-2.4.3.dist-info}/top_level.txt +0 -0
agno/db/mysql/mysql.py CHANGED
@@ -1,11 +1,13 @@
1
1
  import time
2
2
  from datetime import date, datetime, timedelta, timezone
3
- from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
3
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple, Union
4
4
  from uuid import uuid4
5
5
 
6
- from sqlalchemy import Index, UniqueConstraint
6
+ if TYPE_CHECKING:
7
+ from agno.tracing.schemas import Span, Trace
7
8
 
8
9
  from agno.db.base import BaseDb, SessionType
10
+ from agno.db.migrations.manager import MigrationManager
9
11
  from agno.db.mysql.schemas import get_table_schema_definition
10
12
  from agno.db.mysql.utils import (
11
13
  apply_sorting,
@@ -28,7 +30,7 @@ from agno.utils.log import log_debug, log_error, log_info, log_warning
28
30
  from agno.utils.string import generate_id
29
31
 
30
32
  try:
31
- from sqlalchemy import TEXT, and_, cast, func, update
33
+ from sqlalchemy import TEXT, ForeignKey, Index, UniqueConstraint, and_, cast, func, update
32
34
  from sqlalchemy.dialects import mysql
33
35
  from sqlalchemy.engine import Engine, create_engine
34
36
  from sqlalchemy.orm import scoped_session, sessionmaker
@@ -41,6 +43,7 @@ except ImportError:
41
43
  class MySQLDb(BaseDb):
42
44
  def __init__(
43
45
  self,
46
+ id: Optional[str] = None,
44
47
  db_engine: Optional[Engine] = None,
45
48
  db_schema: Optional[str] = None,
46
49
  db_url: Optional[str] = None,
@@ -50,7 +53,10 @@ class MySQLDb(BaseDb):
50
53
  metrics_table: Optional[str] = None,
51
54
  eval_table: Optional[str] = None,
52
55
  knowledge_table: Optional[str] = None,
53
- id: Optional[str] = None,
56
+ traces_table: Optional[str] = None,
57
+ spans_table: Optional[str] = None,
58
+ versions_table: Optional[str] = None,
59
+ create_schema: bool = True,
54
60
  ):
55
61
  """
56
62
  Interface for interacting with a MySQL database.
@@ -61,6 +67,7 @@ class MySQLDb(BaseDb):
61
67
  3. Raise an error if neither is provided
62
68
 
63
69
  Args:
70
+ id (Optional[str]): ID of the database.
64
71
  db_url (Optional[str]): The database URL to connect to.
65
72
  db_engine (Optional[Engine]): The SQLAlchemy database engine to use.
66
73
  db_schema (Optional[str]): The database schema to use.
@@ -70,7 +77,11 @@ class MySQLDb(BaseDb):
70
77
  metrics_table (Optional[str]): Name of the table to store metrics.
71
78
  eval_table (Optional[str]): Name of the table to store evaluation runs data.
72
79
  knowledge_table (Optional[str]): Name of the table to store knowledge content.
73
- id (Optional[str]): ID of the database.
80
+ traces_table (Optional[str]): Name of the table to store run traces.
81
+ spans_table (Optional[str]): Name of the table to store span events.
82
+ versions_table (Optional[str]): Name of the table to store schema versions.
83
+ create_schema (bool): Whether to automatically create the database schema if it doesn't exist.
84
+ Set to False if schema is managed externally (e.g., via migrations). Defaults to True.
74
85
 
75
86
  Raises:
76
87
  ValueError: If neither db_url nor db_engine is provided.
@@ -90,6 +101,9 @@ class MySQLDb(BaseDb):
90
101
  metrics_table=metrics_table,
91
102
  eval_table=eval_table,
92
103
  knowledge_table=knowledge_table,
104
+ traces_table=traces_table,
105
+ spans_table=spans_table,
106
+ versions_table=versions_table,
93
107
  )
94
108
 
95
109
  _engine: Optional[Engine] = db_engine
@@ -101,11 +115,21 @@ class MySQLDb(BaseDb):
101
115
  self.db_url: Optional[str] = db_url
102
116
  self.db_engine: Engine = _engine
103
117
  self.db_schema: str = db_schema if db_schema is not None else "ai"
104
- self.metadata: MetaData = MetaData()
118
+ self.metadata: MetaData = MetaData(schema=self.db_schema)
119
+ self.create_schema: bool = create_schema
105
120
 
106
121
  # Initialize database session
107
122
  self.Session: scoped_session = scoped_session(sessionmaker(bind=self.db_engine))
108
123
 
124
+ def close(self) -> None:
125
+ """Close database connections and dispose of the connection pool.
126
+
127
+ Should be called during application shutdown to properly release
128
+ all database connections.
129
+ """
130
+ if self.db_engine is not None:
131
+ self.db_engine.dispose()
132
+
109
133
  # -- DB methods --
110
134
  def table_exists(self, table_name: str) -> bool:
111
135
  """Check if a table with the given name exists in the MySQL database.
@@ -119,22 +143,22 @@ class MySQLDb(BaseDb):
119
143
  with self.Session() as sess:
120
144
  return is_table_available(session=sess, table_name=table_name, db_schema=self.db_schema)
121
145
 
122
- def _create_table(self, table_name: str, table_type: str, db_schema: str) -> Table:
146
+ def _create_table(self, table_name: str, table_type: str) -> Table:
123
147
  """
124
148
  Create a table with the appropriate schema based on the table type.
125
149
 
126
150
  Args:
127
151
  table_name (str): Name of the table to create
128
152
  table_type (str): Type of table (used to get schema definition)
129
- db_schema (str): Database schema name
130
153
 
131
154
  Returns:
132
155
  Table: SQLAlchemy Table object
133
156
  """
134
157
  try:
135
- table_schema = get_table_schema_definition(table_type)
136
-
137
- log_debug(f"Creating table {table_name}")
158
+ # Pass traces_table_name and db_schema for spans table foreign key resolution
159
+ table_schema = get_table_schema_definition(
160
+ table_type, traces_table_name=self.trace_table_name, db_schema=self.db_schema
161
+ ).copy()
138
162
 
139
163
  columns: List[Column] = []
140
164
  indexes: List[str] = []
@@ -154,11 +178,15 @@ class MySQLDb(BaseDb):
154
178
  if col_config.get("unique", False):
155
179
  column_kwargs["unique"] = True
156
180
  unique_constraints.append(col_name)
181
+
182
+ # Handle foreign key constraint
183
+ if "foreign_key" in col_config:
184
+ column_args.append(ForeignKey(col_config["foreign_key"]))
185
+
157
186
  columns.append(Column(*column_args, **column_kwargs)) # type: ignore
158
187
 
159
188
  # Create the table object
160
- table_metadata = MetaData(schema=db_schema)
161
- table = Table(table_name, table_metadata, *columns, schema=db_schema)
189
+ table = Table(table_name, self.metadata, *columns, schema=self.db_schema)
162
190
 
163
191
  # Add multi-column unique constraints with table-specific names
164
192
  for constraint in schema_unique_constraints:
@@ -171,17 +199,22 @@ class MySQLDb(BaseDb):
171
199
  idx_name = f"idx_{table_name}_{idx_col}"
172
200
  table.append_constraint(Index(idx_name, idx_col))
173
201
 
174
- with self.Session() as sess, sess.begin():
175
- create_schema(session=sess, db_schema=db_schema)
202
+ if self.create_schema:
203
+ with self.Session() as sess, sess.begin():
204
+ create_schema(session=sess, db_schema=self.db_schema)
176
205
 
177
206
  # Create table
178
- table.create(self.db_engine, checkfirst=True)
207
+ table_created = False
208
+ if not self.table_exists(table_name):
209
+ table.create(self.db_engine, checkfirst=True)
210
+ log_debug(f"Successfully created table '{table_name}'")
211
+ table_created = True
212
+ else:
213
+ log_debug(f"Table {self.db_schema}.{table_name} already exists, skipping creation")
179
214
 
180
215
  # Create indexes
181
216
  for idx in table.indexes:
182
217
  try:
183
- log_debug(f"Creating index: {idx.name}")
184
-
185
218
  # Check if index already exists
186
219
  with self.Session() as sess:
187
220
  exists_query = text(
@@ -190,24 +223,35 @@ class MySQLDb(BaseDb):
190
223
  )
191
224
  exists = (
192
225
  sess.execute(
193
- exists_query, {"schema": db_schema, "table_name": table_name, "index_name": idx.name}
226
+ exists_query,
227
+ {"schema": self.db_schema, "table_name": table_name, "index_name": idx.name},
194
228
  ).scalar()
195
229
  is not None
196
230
  )
197
231
  if exists:
198
- log_debug(f"Index {idx.name} already exists in {db_schema}.{table_name}, skipping creation")
232
+ log_debug(
233
+ f"Index {idx.name} already exists in {self.db_schema}.{table_name}, skipping creation"
234
+ )
199
235
  continue
200
236
 
201
237
  idx.create(self.db_engine)
202
238
 
239
+ log_debug(f"Created index: {idx.name} for table {self.db_schema}.{table_name}")
203
240
  except Exception as e:
204
241
  log_error(f"Error creating index {idx.name}: {e}")
205
242
 
206
- log_debug(f"Successfully created table {db_schema}.{table_name}")
243
+ # Store the schema version for the created table
244
+ if table_name != self.versions_table_name and table_created:
245
+ latest_schema_version = MigrationManager(self).latest_schema_version
246
+ self.upsert_schema_version(table_name=table_name, version=latest_schema_version.public)
247
+ log_info(
248
+ f"Successfully stored version {latest_schema_version.public} in database for table {table_name}"
249
+ )
250
+
207
251
  return table
208
252
 
209
253
  except Exception as e:
210
- log_error(f"Could not create table {db_schema}.{table_name}: {e}")
254
+ log_error(f"Could not create table {self.db_schema}.{table_name}: {e}")
211
255
  raise
212
256
 
213
257
  def _create_all_tables(self):
@@ -218,17 +262,20 @@ class MySQLDb(BaseDb):
218
262
  (self.metrics_table_name, "metrics"),
219
263
  (self.eval_table_name, "evals"),
220
264
  (self.knowledge_table_name, "knowledge"),
265
+ (self.culture_table_name, "culture"),
266
+ (self.trace_table_name, "traces"),
267
+ (self.span_table_name, "spans"),
268
+ (self.versions_table_name, "versions"),
221
269
  ]
222
270
 
223
271
  for table_name, table_type in tables_to_create:
224
- self._create_table(table_name=table_name, table_type=table_type, db_schema=self.db_schema)
272
+ self._get_or_create_table(table_name=table_name, table_type=table_type, create_table_if_not_found=True)
225
273
 
226
274
  def _get_table(self, table_type: str, create_table_if_not_found: Optional[bool] = False) -> Optional[Table]:
227
275
  if table_type == "sessions":
228
276
  self.session_table = self._get_or_create_table(
229
277
  table_name=self.session_table_name,
230
278
  table_type="sessions",
231
- db_schema=self.db_schema,
232
279
  create_table_if_not_found=create_table_if_not_found,
233
280
  )
234
281
  return self.session_table
@@ -237,7 +284,6 @@ class MySQLDb(BaseDb):
237
284
  self.memory_table = self._get_or_create_table(
238
285
  table_name=self.memory_table_name,
239
286
  table_type="memories",
240
- db_schema=self.db_schema,
241
287
  create_table_if_not_found=create_table_if_not_found,
242
288
  )
243
289
  return self.memory_table
@@ -246,7 +292,6 @@ class MySQLDb(BaseDb):
246
292
  self.metrics_table = self._get_or_create_table(
247
293
  table_name=self.metrics_table_name,
248
294
  table_type="metrics",
249
- db_schema=self.db_schema,
250
295
  create_table_if_not_found=create_table_if_not_found,
251
296
  )
252
297
  return self.metrics_table
@@ -255,7 +300,6 @@ class MySQLDb(BaseDb):
255
300
  self.eval_table = self._get_or_create_table(
256
301
  table_name=self.eval_table_name,
257
302
  table_type="evals",
258
- db_schema=self.db_schema,
259
303
  create_table_if_not_found=create_table_if_not_found,
260
304
  )
261
305
  return self.eval_table
@@ -264,7 +308,6 @@ class MySQLDb(BaseDb):
264
308
  self.knowledge_table = self._get_or_create_table(
265
309
  table_name=self.knowledge_table_name,
266
310
  table_type="knowledge",
267
- db_schema=self.db_schema,
268
311
  create_table_if_not_found=create_table_if_not_found,
269
312
  )
270
313
  return self.knowledge_table
@@ -273,15 +316,42 @@ class MySQLDb(BaseDb):
273
316
  self.culture_table = self._get_or_create_table(
274
317
  table_name=self.culture_table_name,
275
318
  table_type="culture",
276
- db_schema=self.db_schema,
277
319
  create_table_if_not_found=create_table_if_not_found,
278
320
  )
279
321
  return self.culture_table
280
322
 
323
+ if table_type == "versions":
324
+ self.versions_table = self._get_or_create_table(
325
+ table_name=self.versions_table_name,
326
+ table_type="versions",
327
+ create_table_if_not_found=create_table_if_not_found,
328
+ )
329
+ return self.versions_table
330
+
331
+ if table_type == "traces":
332
+ self.traces_table = self._get_or_create_table(
333
+ table_name=self.trace_table_name,
334
+ table_type="traces",
335
+ create_table_if_not_found=create_table_if_not_found,
336
+ )
337
+ return self.traces_table
338
+
339
+ if table_type == "spans":
340
+ # Ensure traces table exists first (spans has FK to traces)
341
+ if create_table_if_not_found:
342
+ self._get_table(table_type="traces", create_table_if_not_found=True)
343
+
344
+ self.spans_table = self._get_or_create_table(
345
+ table_name=self.span_table_name,
346
+ table_type="spans",
347
+ create_table_if_not_found=create_table_if_not_found,
348
+ )
349
+ return self.spans_table
350
+
281
351
  raise ValueError(f"Unknown table type: {table_type}")
282
352
 
283
353
  def _get_or_create_table(
284
- self, table_name: str, table_type: str, db_schema: str, create_table_if_not_found: Optional[bool] = False
354
+ self, table_name: str, table_type: str, create_table_if_not_found: Optional[bool] = False
285
355
  ) -> Optional[Table]:
286
356
  """
287
357
  Check if the table exists and is valid, else create it.
@@ -289,38 +359,71 @@ class MySQLDb(BaseDb):
289
359
  Args:
290
360
  table_name (str): Name of the table to get or create
291
361
  table_type (str): Type of table (used to get schema definition)
292
- db_schema (str): Database schema name
293
362
 
294
363
  Returns:
295
364
  Table: SQLAlchemy Table object representing the schema.
296
365
  """
297
366
 
298
367
  with self.Session() as sess, sess.begin():
299
- table_is_available = is_table_available(session=sess, table_name=table_name, db_schema=db_schema)
368
+ table_is_available = is_table_available(session=sess, table_name=table_name, db_schema=self.db_schema)
300
369
 
301
370
  if not table_is_available:
302
371
  if not create_table_if_not_found:
303
372
  return None
304
373
 
305
- return self._create_table(table_name=table_name, table_type=table_type, db_schema=db_schema)
374
+ created_table = self._create_table(table_name=table_name, table_type=table_type)
375
+
376
+ return created_table
306
377
 
307
378
  if not is_valid_table(
308
379
  db_engine=self.db_engine,
309
380
  table_name=table_name,
310
381
  table_type=table_type,
311
- db_schema=db_schema,
382
+ db_schema=self.db_schema,
312
383
  ):
313
- raise ValueError(f"Table {db_schema}.{table_name} has an invalid schema")
384
+ raise ValueError(f"Table {self.db_schema}.{table_name} has an invalid schema")
314
385
 
315
386
  try:
316
- table = Table(table_name, self.metadata, schema=db_schema, autoload_with=self.db_engine)
317
- log_debug(f"Loaded existing table {db_schema}.{table_name}")
387
+ table = Table(table_name, self.metadata, schema=self.db_schema, autoload_with=self.db_engine)
318
388
  return table
319
389
 
320
390
  except Exception as e:
321
- log_error(f"Error loading existing table {db_schema}.{table_name}: {e}")
391
+ log_error(f"Error loading existing table {self.db_schema}.{table_name}: {e}")
322
392
  raise
323
393
 
394
+ def get_latest_schema_version(self, table_name: str) -> str:
395
+ """Get the latest version of the database schema."""
396
+ table = self._get_table(table_type="versions", create_table_if_not_found=True)
397
+ with self.Session() as sess:
398
+ # Latest version for the given table
399
+ stmt = select(table).where(table.c.table_name == table_name).order_by(table.c.version.desc()).limit(1) # type: ignore
400
+ result = sess.execute(stmt).fetchone()
401
+ if result is None:
402
+ return "2.0.0"
403
+ version_dict = dict(result._mapping)
404
+ return version_dict.get("version") or "2.0.0"
405
+
406
+ def upsert_schema_version(self, table_name: str, version: str) -> None:
407
+ """Upsert the schema version into the database."""
408
+ table = self._get_table(table_type="versions", create_table_if_not_found=True)
409
+ if table is None:
410
+ return
411
+ current_datetime = datetime.now().isoformat()
412
+ with self.Session() as sess, sess.begin():
413
+ stmt = mysql.insert(table).values( # type: ignore
414
+ table_name=table_name,
415
+ version=version,
416
+ created_at=current_datetime, # Store as ISO format string
417
+ updated_at=current_datetime,
418
+ )
419
+ # Update version if table_name already exists
420
+ stmt = stmt.on_duplicate_key_update(
421
+ version=version,
422
+ created_at=current_datetime,
423
+ updated_at=current_datetime,
424
+ )
425
+ sess.execute(stmt)
426
+
324
427
  # -- Session methods --
325
428
  def delete_session(self, session_id: str) -> bool:
326
429
  """
@@ -454,7 +557,7 @@ class MySQLDb(BaseDb):
454
557
  Args:
455
558
  session_type (Optional[SessionType]): The type of sessions to get.
456
559
  user_id (Optional[str]): The ID of the user to filter by.
457
- entity_id (Optional[str]): The ID of the agent / workflow to filter by.
560
+ component_id (Optional[str]): The ID of the agent / workflow to filter by.
458
561
  start_timestamp (Optional[int]): The start timestamp to filter by.
459
562
  end_timestamp (Optional[int]): The end timestamp to filter by.
460
563
  session_name (Optional[str]): The name of the session to filter by.
@@ -463,7 +566,6 @@ class MySQLDb(BaseDb):
463
566
  sort_by (Optional[str]): The field to sort by. Defaults to None.
464
567
  sort_order (Optional[str]): The sort order. Defaults to None.
465
568
  deserialize (Optional[bool]): Whether to serialize the sessions. Defaults to True.
466
- create_table_if_not_found (Optional[bool]): Whether to create the table if it doesn't exist.
467
569
 
468
570
  Returns:
469
571
  Union[List[Session], Tuple[List[Dict], int]]:
@@ -1020,9 +1122,12 @@ class MySQLDb(BaseDb):
1020
1122
  except Exception as e:
1021
1123
  log_error(f"Error deleting user memories: {e}")
1022
1124
 
1023
- def get_all_memory_topics(self) -> List[str]:
1125
+ def get_all_memory_topics(self, user_id: Optional[str] = None) -> List[str]:
1024
1126
  """Get all memory topics from the database.
1025
1127
 
1128
+ Args:
1129
+ user_id (Optional[str]): Optional user ID to filter topics.
1130
+
1026
1131
  Returns:
1027
1132
  List[str]: List of memory topics.
1028
1133
  """
@@ -1195,7 +1300,7 @@ class MySQLDb(BaseDb):
1195
1300
  log_error(f"Exception clearing user memories: {e}")
1196
1301
 
1197
1302
  def get_user_memory_stats(
1198
- self, limit: Optional[int] = None, page: Optional[int] = None
1303
+ self, limit: Optional[int] = None, page: Optional[int] = None, user_id: Optional[str] = None
1199
1304
  ) -> Tuple[List[Dict[str, Any]], int]:
1200
1305
  """Get user memories stats.
1201
1306
 
@@ -1224,17 +1329,20 @@ class MySQLDb(BaseDb):
1224
1329
  return [], 0
1225
1330
 
1226
1331
  with self.Session() as sess, sess.begin():
1227
- stmt = (
1228
- select(
1229
- table.c.user_id,
1230
- func.count(table.c.memory_id).label("total_memories"),
1231
- func.max(table.c.updated_at).label("last_memory_updated_at"),
1232
- )
1233
- .where(table.c.user_id.is_not(None))
1234
- .group_by(table.c.user_id)
1235
- .order_by(func.max(table.c.updated_at).desc())
1332
+ stmt = select(
1333
+ table.c.user_id,
1334
+ func.count(table.c.memory_id).label("total_memories"),
1335
+ func.max(table.c.updated_at).label("last_memory_updated_at"),
1236
1336
  )
1237
1337
 
1338
+ if user_id is not None:
1339
+ stmt = stmt.where(table.c.user_id == user_id)
1340
+ else:
1341
+ stmt = stmt.where(table.c.user_id.is_not(None))
1342
+
1343
+ stmt = stmt.group_by(table.c.user_id)
1344
+ stmt = stmt.order_by(func.max(table.c.updated_at).desc())
1345
+
1238
1346
  count_stmt = select(func.count()).select_from(stmt.alias())
1239
1347
  total_count = sess.execute(count_stmt).scalar()
1240
1348
 
@@ -1287,6 +1395,8 @@ class MySQLDb(BaseDb):
1287
1395
  if memory.memory_id is None:
1288
1396
  memory.memory_id = str(uuid4())
1289
1397
 
1398
+ current_time = int(time.time())
1399
+
1290
1400
  stmt = mysql.insert(table).values(
1291
1401
  memory_id=memory.memory_id,
1292
1402
  memory=memory.memory,
@@ -1295,7 +1405,9 @@ class MySQLDb(BaseDb):
1295
1405
  agent_id=memory.agent_id,
1296
1406
  team_id=memory.team_id,
1297
1407
  topics=memory.topics,
1298
- updated_at=int(time.time()),
1408
+ feedback=memory.feedback,
1409
+ created_at=memory.created_at,
1410
+ updated_at=memory.created_at,
1299
1411
  )
1300
1412
  stmt = stmt.on_duplicate_key_update(
1301
1413
  memory=memory.memory,
@@ -1303,7 +1415,10 @@ class MySQLDb(BaseDb):
1303
1415
  input=memory.input,
1304
1416
  agent_id=memory.agent_id,
1305
1417
  team_id=memory.team_id,
1306
- updated_at=int(time.time()),
1418
+ feedback=memory.feedback,
1419
+ updated_at=current_time,
1420
+ # Preserve created_at on update - don't overwrite existing value
1421
+ created_at=table.c.created_at,
1307
1422
  )
1308
1423
  sess.execute(stmt)
1309
1424
 
@@ -1358,12 +1473,14 @@ class MySQLDb(BaseDb):
1358
1473
  # Prepare bulk data
1359
1474
  bulk_data = []
1360
1475
  current_time = int(time.time())
1476
+
1361
1477
  for memory in memories:
1362
1478
  if memory.memory_id is None:
1363
1479
  memory.memory_id = str(uuid4())
1364
1480
 
1365
1481
  # Use preserved updated_at if flag is set and value exists, otherwise use current time
1366
1482
  updated_at = memory.updated_at if preserve_updated_at else current_time
1483
+
1367
1484
  bulk_data.append(
1368
1485
  {
1369
1486
  "memory_id": memory.memory_id,
@@ -1373,6 +1490,8 @@ class MySQLDb(BaseDb):
1373
1490
  "agent_id": memory.agent_id,
1374
1491
  "team_id": memory.team_id,
1375
1492
  "topics": memory.topics,
1493
+ "feedback": memory.feedback,
1494
+ "created_at": memory.created_at,
1376
1495
  "updated_at": updated_at,
1377
1496
  }
1378
1497
  )
@@ -1388,7 +1507,10 @@ class MySQLDb(BaseDb):
1388
1507
  input=stmt.inserted.input,
1389
1508
  agent_id=stmt.inserted.agent_id,
1390
1509
  team_id=stmt.inserted.team_id,
1510
+ feedback=stmt.inserted.feedback,
1391
1511
  updated_at=stmt.inserted.updated_at,
1512
+ # Preserve created_at on update
1513
+ created_at=table.c.created_at,
1392
1514
  )
1393
1515
  sess.execute(stmt, bulk_data)
1394
1516
 
@@ -2306,3 +2428,550 @@ class MySQLDb(BaseDb):
2306
2428
  for memory in memories:
2307
2429
  self.upsert_user_memory(memory)
2308
2430
  log_info(f"Migrated {len(memories)} memories to table: {self.memory_table}")
2431
+
2432
+ # --- Traces ---
2433
+ def _get_traces_base_query(self, table: Table, spans_table: Optional[Table] = None):
2434
+ """Build base query for traces with aggregated span counts.
2435
+
2436
+ Args:
2437
+ table: The traces table.
2438
+ spans_table: The spans table (optional).
2439
+
2440
+ Returns:
2441
+ SQLAlchemy select statement with total_spans and error_count calculated dynamically.
2442
+ """
2443
+ from sqlalchemy import case, literal
2444
+
2445
+ if spans_table is not None:
2446
+ # JOIN with spans table to calculate total_spans and error_count
2447
+ return (
2448
+ select(
2449
+ table,
2450
+ func.coalesce(func.count(spans_table.c.span_id), 0).label("total_spans"),
2451
+ func.coalesce(func.sum(case((spans_table.c.status_code == "ERROR", 1), else_=0)), 0).label(
2452
+ "error_count"
2453
+ ),
2454
+ )
2455
+ .select_from(table.outerjoin(spans_table, table.c.trace_id == spans_table.c.trace_id))
2456
+ .group_by(table.c.trace_id)
2457
+ )
2458
+ else:
2459
+ # Fallback if spans table doesn't exist
2460
+ return select(table, literal(0).label("total_spans"), literal(0).label("error_count"))
2461
+
2462
+ def _get_trace_component_level_expr(self, workflow_id_col, team_id_col, agent_id_col, name_col):
2463
+ """Build a SQL CASE expression that returns the component level for a trace.
2464
+
2465
+ Component levels (higher = more important):
2466
+ - 3: Workflow root (.run or .arun with workflow_id)
2467
+ - 2: Team root (.run or .arun with team_id)
2468
+ - 1: Agent root (.run or .arun with agent_id)
2469
+ - 0: Child span (not a root)
2470
+
2471
+ Args:
2472
+ workflow_id_col: SQL column/expression for workflow_id
2473
+ team_id_col: SQL column/expression for team_id
2474
+ agent_id_col: SQL column/expression for agent_id
2475
+ name_col: SQL column/expression for name
2476
+
2477
+ Returns:
2478
+ SQLAlchemy CASE expression returning the component level as an integer.
2479
+ """
2480
+ from sqlalchemy import and_, case, or_
2481
+
2482
+ is_root_name = or_(name_col.like("%.run%"), name_col.like("%.arun%"))
2483
+
2484
+ return case(
2485
+ # Workflow root (level 3)
2486
+ (and_(workflow_id_col.isnot(None), is_root_name), 3),
2487
+ # Team root (level 2)
2488
+ (and_(team_id_col.isnot(None), is_root_name), 2),
2489
+ # Agent root (level 1)
2490
+ (and_(agent_id_col.isnot(None), is_root_name), 1),
2491
+ # Child span or unknown (level 0)
2492
+ else_=0,
2493
+ )
2494
+
2495
+ def upsert_trace(self, trace: "Trace") -> None:
2496
+ """Create or update a single trace record in the database.
2497
+
2498
+ Uses INSERT ... ON DUPLICATE KEY UPDATE (upsert) to handle concurrent inserts
2499
+ atomically and avoid race conditions.
2500
+
2501
+ Args:
2502
+ trace: The Trace object to store (one per trace_id).
2503
+ """
2504
+ from sqlalchemy import case
2505
+
2506
+ try:
2507
+ table = self._get_table(table_type="traces", create_table_if_not_found=True)
2508
+ if table is None:
2509
+ return
2510
+
2511
+ trace_dict = trace.to_dict()
2512
+ trace_dict.pop("total_spans", None)
2513
+ trace_dict.pop("error_count", None)
2514
+
2515
+ with self.Session() as sess, sess.begin():
2516
+ # Use upsert to handle concurrent inserts atomically
2517
+ # On conflict, update fields while preserving existing non-null context values
2518
+ # and keeping the earliest start_time
2519
+ insert_stmt = mysql.insert(table).values(trace_dict)
2520
+
2521
+ # Build component level expressions for comparing trace priority
2522
+ new_level = self._get_trace_component_level_expr(
2523
+ insert_stmt.inserted.workflow_id,
2524
+ insert_stmt.inserted.team_id,
2525
+ insert_stmt.inserted.agent_id,
2526
+ insert_stmt.inserted.name,
2527
+ )
2528
+ existing_level = self._get_trace_component_level_expr(
2529
+ table.c.workflow_id,
2530
+ table.c.team_id,
2531
+ table.c.agent_id,
2532
+ table.c.name,
2533
+ )
2534
+
2535
+ # Build the ON DUPLICATE KEY UPDATE clause
2536
+ # Use LEAST for start_time, GREATEST for end_time to capture full trace duration
2537
+ # MySQL stores timestamps as ISO strings, so string comparison works for ISO format
2538
+ # Duration is calculated using TIMESTAMPDIFF in microseconds then converted to ms
2539
+ upsert_stmt = insert_stmt.on_duplicate_key_update(
2540
+ end_time=func.greatest(table.c.end_time, insert_stmt.inserted.end_time),
2541
+ start_time=func.least(table.c.start_time, insert_stmt.inserted.start_time),
2542
+ # Calculate duration in milliseconds using TIMESTAMPDIFF
2543
+ # TIMESTAMPDIFF(MICROSECOND, start, end) / 1000 gives milliseconds
2544
+ duration_ms=func.timestampdiff(
2545
+ text("MICROSECOND"),
2546
+ func.least(table.c.start_time, insert_stmt.inserted.start_time),
2547
+ func.greatest(table.c.end_time, insert_stmt.inserted.end_time),
2548
+ )
2549
+ / 1000,
2550
+ status=insert_stmt.inserted.status,
2551
+ # Update name only if new trace is from a higher-level component
2552
+ # Priority: workflow (3) > team (2) > agent (1) > child spans (0)
2553
+ name=case(
2554
+ (new_level > existing_level, insert_stmt.inserted.name),
2555
+ else_=table.c.name,
2556
+ ),
2557
+ # Preserve existing non-null context values using COALESCE
2558
+ run_id=func.coalesce(insert_stmt.inserted.run_id, table.c.run_id),
2559
+ session_id=func.coalesce(insert_stmt.inserted.session_id, table.c.session_id),
2560
+ user_id=func.coalesce(insert_stmt.inserted.user_id, table.c.user_id),
2561
+ agent_id=func.coalesce(insert_stmt.inserted.agent_id, table.c.agent_id),
2562
+ team_id=func.coalesce(insert_stmt.inserted.team_id, table.c.team_id),
2563
+ workflow_id=func.coalesce(insert_stmt.inserted.workflow_id, table.c.workflow_id),
2564
+ )
2565
+ sess.execute(upsert_stmt)
2566
+
2567
+ except Exception as e:
2568
+ log_error(f"Error creating trace: {e}")
2569
+ # Don't raise - tracing should not break the main application flow
2570
+
2571
+ def get_trace(
2572
+ self,
2573
+ trace_id: Optional[str] = None,
2574
+ run_id: Optional[str] = None,
2575
+ ):
2576
+ """Get a single trace by trace_id or other filters.
2577
+
2578
+ Args:
2579
+ trace_id: The unique trace identifier.
2580
+ run_id: Filter by run ID (returns first match).
2581
+
2582
+ Returns:
2583
+ Optional[Trace]: The trace if found, None otherwise.
2584
+
2585
+ Note:
2586
+ If multiple filters are provided, trace_id takes precedence.
2587
+ For other filters, the most recent trace is returned.
2588
+ """
2589
+ try:
2590
+ from agno.tracing.schemas import Trace
2591
+
2592
+ table = self._get_table(table_type="traces")
2593
+ if table is None:
2594
+ return None
2595
+
2596
+ # Get spans table for JOIN
2597
+ spans_table = self._get_table(table_type="spans")
2598
+
2599
+ with self.Session() as sess:
2600
+ # Build query with aggregated span counts
2601
+ stmt = self._get_traces_base_query(table, spans_table)
2602
+
2603
+ if trace_id:
2604
+ stmt = stmt.where(table.c.trace_id == trace_id)
2605
+ elif run_id:
2606
+ stmt = stmt.where(table.c.run_id == run_id)
2607
+ else:
2608
+ log_debug("get_trace called without any filter parameters")
2609
+ return None
2610
+
2611
+ # Order by most recent and get first result
2612
+ stmt = stmt.order_by(table.c.start_time.desc()).limit(1)
2613
+ result = sess.execute(stmt).fetchone()
2614
+
2615
+ if result:
2616
+ return Trace.from_dict(dict(result._mapping))
2617
+ return None
2618
+
2619
+ except Exception as e:
2620
+ log_error(f"Error getting trace: {e}")
2621
+ return None
2622
+
2623
+ def get_traces(
2624
+ self,
2625
+ run_id: Optional[str] = None,
2626
+ session_id: Optional[str] = None,
2627
+ user_id: Optional[str] = None,
2628
+ agent_id: Optional[str] = None,
2629
+ team_id: Optional[str] = None,
2630
+ workflow_id: Optional[str] = None,
2631
+ status: Optional[str] = None,
2632
+ start_time: Optional[datetime] = None,
2633
+ end_time: Optional[datetime] = None,
2634
+ limit: Optional[int] = 20,
2635
+ page: Optional[int] = 1,
2636
+ ) -> tuple[List, int]:
2637
+ """Get traces matching the provided filters with pagination.
2638
+
2639
+ Args:
2640
+ run_id: Filter by run ID.
2641
+ session_id: Filter by session ID.
2642
+ user_id: Filter by user ID.
2643
+ agent_id: Filter by agent ID.
2644
+ team_id: Filter by team ID.
2645
+ workflow_id: Filter by workflow ID.
2646
+ status: Filter by status (OK, ERROR, UNSET).
2647
+ start_time: Filter traces starting after this datetime.
2648
+ end_time: Filter traces ending before this datetime.
2649
+ limit: Maximum number of traces to return per page.
2650
+ page: Page number (1-indexed).
2651
+
2652
+ Returns:
2653
+ tuple[List[Trace], int]: Tuple of (list of matching traces, total count).
2654
+ """
2655
+ try:
2656
+ from agno.tracing.schemas import Trace
2657
+
2658
+ log_debug(
2659
+ f"get_traces called with filters: run_id={run_id}, session_id={session_id}, user_id={user_id}, agent_id={agent_id}, page={page}, limit={limit}"
2660
+ )
2661
+
2662
+ table = self._get_table(table_type="traces")
2663
+ if table is None:
2664
+ log_debug("Traces table not found")
2665
+ return [], 0
2666
+
2667
+ # Get spans table for JOIN
2668
+ spans_table = self._get_table(table_type="spans")
2669
+
2670
+ with self.Session() as sess:
2671
+ # Build base query with aggregated span counts
2672
+ base_stmt = self._get_traces_base_query(table, spans_table)
2673
+
2674
+ # Apply filters
2675
+ if run_id:
2676
+ base_stmt = base_stmt.where(table.c.run_id == run_id)
2677
+ if session_id:
2678
+ base_stmt = base_stmt.where(table.c.session_id == session_id)
2679
+ if user_id:
2680
+ base_stmt = base_stmt.where(table.c.user_id == user_id)
2681
+ if agent_id:
2682
+ base_stmt = base_stmt.where(table.c.agent_id == agent_id)
2683
+ if team_id:
2684
+ base_stmt = base_stmt.where(table.c.team_id == team_id)
2685
+ if workflow_id:
2686
+ base_stmt = base_stmt.where(table.c.workflow_id == workflow_id)
2687
+ if status:
2688
+ base_stmt = base_stmt.where(table.c.status == status)
2689
+ if start_time:
2690
+ # Convert datetime to ISO string for comparison
2691
+ base_stmt = base_stmt.where(table.c.start_time >= start_time.isoformat())
2692
+ if end_time:
2693
+ # Convert datetime to ISO string for comparison
2694
+ base_stmt = base_stmt.where(table.c.end_time <= end_time.isoformat())
2695
+
2696
+ # Get total count
2697
+ count_stmt = select(func.count()).select_from(base_stmt.alias())
2698
+ total_count = sess.execute(count_stmt).scalar() or 0
2699
+
2700
+ # Apply pagination
2701
+ offset = (page - 1) * limit if page and limit else 0
2702
+ paginated_stmt = base_stmt.order_by(table.c.start_time.desc()).limit(limit).offset(offset)
2703
+
2704
+ results = sess.execute(paginated_stmt).fetchall()
2705
+
2706
+ traces = [Trace.from_dict(dict(row._mapping)) for row in results]
2707
+ return traces, total_count
2708
+
2709
+ except Exception as e:
2710
+ log_error(f"Error getting traces: {e}")
2711
+ return [], 0
2712
+
2713
+ def get_trace_stats(
2714
+ self,
2715
+ user_id: Optional[str] = None,
2716
+ agent_id: Optional[str] = None,
2717
+ team_id: Optional[str] = None,
2718
+ workflow_id: Optional[str] = None,
2719
+ start_time: Optional[datetime] = None,
2720
+ end_time: Optional[datetime] = None,
2721
+ limit: Optional[int] = 20,
2722
+ page: Optional[int] = 1,
2723
+ ) -> tuple[List[Dict[str, Any]], int]:
2724
+ """Get trace statistics grouped by session.
2725
+
2726
+ Args:
2727
+ user_id: Filter by user ID.
2728
+ agent_id: Filter by agent ID.
2729
+ team_id: Filter by team ID.
2730
+ workflow_id: Filter by workflow ID.
2731
+ start_time: Filter sessions with traces created after this datetime.
2732
+ end_time: Filter sessions with traces created before this datetime.
2733
+ limit: Maximum number of sessions to return per page.
2734
+ page: Page number (1-indexed).
2735
+
2736
+ Returns:
2737
+ tuple[List[Dict], int]: Tuple of (list of session stats dicts, total count).
2738
+ Each dict contains: session_id, user_id, agent_id, team_id, total_traces,
2739
+ workflow_id, first_trace_at, last_trace_at.
2740
+ """
2741
+ try:
2742
+ table = self._get_table(table_type="traces")
2743
+ if table is None:
2744
+ log_debug("Traces table not found")
2745
+ return [], 0
2746
+
2747
+ with self.Session() as sess:
2748
+ # Build base query grouped by session_id
2749
+ base_stmt = (
2750
+ select(
2751
+ table.c.session_id,
2752
+ table.c.user_id,
2753
+ table.c.agent_id,
2754
+ table.c.team_id,
2755
+ table.c.workflow_id,
2756
+ func.count(table.c.trace_id).label("total_traces"),
2757
+ func.min(table.c.created_at).label("first_trace_at"),
2758
+ func.max(table.c.created_at).label("last_trace_at"),
2759
+ )
2760
+ .where(table.c.session_id.isnot(None)) # Only sessions with session_id
2761
+ .group_by(
2762
+ table.c.session_id, table.c.user_id, table.c.agent_id, table.c.team_id, table.c.workflow_id
2763
+ )
2764
+ )
2765
+
2766
+ # Apply filters
2767
+ if user_id:
2768
+ base_stmt = base_stmt.where(table.c.user_id == user_id)
2769
+ if workflow_id:
2770
+ base_stmt = base_stmt.where(table.c.workflow_id == workflow_id)
2771
+ if team_id:
2772
+ base_stmt = base_stmt.where(table.c.team_id == team_id)
2773
+ if agent_id:
2774
+ base_stmt = base_stmt.where(table.c.agent_id == agent_id)
2775
+ if start_time:
2776
+ # Convert datetime to ISO string for comparison
2777
+ base_stmt = base_stmt.where(table.c.created_at >= start_time.isoformat())
2778
+ if end_time:
2779
+ # Convert datetime to ISO string for comparison
2780
+ base_stmt = base_stmt.where(table.c.created_at <= end_time.isoformat())
2781
+
2782
+ # Get total count of sessions
2783
+ count_stmt = select(func.count()).select_from(base_stmt.alias())
2784
+ total_count = sess.execute(count_stmt).scalar() or 0
2785
+
2786
+ # Apply pagination and ordering
2787
+ offset = (page - 1) * limit if page and limit else 0
2788
+ paginated_stmt = base_stmt.order_by(func.max(table.c.created_at).desc()).limit(limit).offset(offset)
2789
+
2790
+ results = sess.execute(paginated_stmt).fetchall()
2791
+
2792
+ # Convert to list of dicts with datetime objects
2793
+ stats_list = []
2794
+ for row in results:
2795
+ # Convert ISO strings to datetime objects
2796
+ first_trace_at_str = row.first_trace_at
2797
+ last_trace_at_str = row.last_trace_at
2798
+
2799
+ # Parse ISO format strings to datetime objects
2800
+ first_trace_at = datetime.fromisoformat(first_trace_at_str.replace("Z", "+00:00"))
2801
+ last_trace_at = datetime.fromisoformat(last_trace_at_str.replace("Z", "+00:00"))
2802
+
2803
+ stats_list.append(
2804
+ {
2805
+ "session_id": row.session_id,
2806
+ "user_id": row.user_id,
2807
+ "agent_id": row.agent_id,
2808
+ "team_id": row.team_id,
2809
+ "workflow_id": row.workflow_id,
2810
+ "total_traces": row.total_traces,
2811
+ "first_trace_at": first_trace_at,
2812
+ "last_trace_at": last_trace_at,
2813
+ }
2814
+ )
2815
+
2816
+ return stats_list, total_count
2817
+
2818
+ except Exception as e:
2819
+ log_error(f"Error getting trace stats: {e}")
2820
+ return [], 0
2821
+
2822
+ # --- Spans ---
2823
+ def create_span(self, span: "Span") -> None:
2824
+ """Create a single span in the database.
2825
+
2826
+ Args:
2827
+ span: The Span object to store.
2828
+ """
2829
+ try:
2830
+ table = self._get_table(table_type="spans", create_table_if_not_found=True)
2831
+ if table is None:
2832
+ return
2833
+
2834
+ with self.Session() as sess, sess.begin():
2835
+ stmt = mysql.insert(table).values(span.to_dict())
2836
+ sess.execute(stmt)
2837
+
2838
+ except Exception as e:
2839
+ log_error(f"Error creating span: {e}")
2840
+
2841
+ def create_spans(self, spans: List) -> None:
2842
+ """Create multiple spans in the database as a batch.
2843
+
2844
+ Args:
2845
+ spans: List of Span objects to store.
2846
+ """
2847
+ if not spans:
2848
+ return
2849
+
2850
+ try:
2851
+ table = self._get_table(table_type="spans", create_table_if_not_found=True)
2852
+ if table is None:
2853
+ return
2854
+
2855
+ with self.Session() as sess, sess.begin():
2856
+ for span in spans:
2857
+ stmt = mysql.insert(table).values(span.to_dict())
2858
+ sess.execute(stmt)
2859
+
2860
+ except Exception as e:
2861
+ log_error(f"Error creating spans batch: {e}")
2862
+
2863
+ def get_span(self, span_id: str):
2864
+ """Get a single span by its span_id.
2865
+
2866
+ Args:
2867
+ span_id: The unique span identifier.
2868
+
2869
+ Returns:
2870
+ Optional[Span]: The span if found, None otherwise.
2871
+ """
2872
+ try:
2873
+ from agno.tracing.schemas import Span
2874
+
2875
+ table = self._get_table(table_type="spans")
2876
+ if table is None:
2877
+ return None
2878
+
2879
+ with self.Session() as sess:
2880
+ stmt = select(table).where(table.c.span_id == span_id)
2881
+ result = sess.execute(stmt).fetchone()
2882
+ if result:
2883
+ return Span.from_dict(dict(result._mapping))
2884
+ return None
2885
+
2886
+ except Exception as e:
2887
+ log_error(f"Error getting span: {e}")
2888
+ return None
2889
+
2890
+ def get_spans(
2891
+ self,
2892
+ trace_id: Optional[str] = None,
2893
+ parent_span_id: Optional[str] = None,
2894
+ limit: Optional[int] = 1000,
2895
+ ) -> List:
2896
+ """Get spans matching the provided filters.
2897
+
2898
+ Args:
2899
+ trace_id: Filter by trace ID.
2900
+ parent_span_id: Filter by parent span ID.
2901
+ limit: Maximum number of spans to return.
2902
+
2903
+ Returns:
2904
+ List[Span]: List of matching spans.
2905
+ """
2906
+ try:
2907
+ from agno.tracing.schemas import Span
2908
+
2909
+ table = self._get_table(table_type="spans")
2910
+ if table is None:
2911
+ return []
2912
+
2913
+ with self.Session() as sess:
2914
+ stmt = select(table)
2915
+
2916
+ # Apply filters
2917
+ if trace_id:
2918
+ stmt = stmt.where(table.c.trace_id == trace_id)
2919
+ if parent_span_id:
2920
+ stmt = stmt.where(table.c.parent_span_id == parent_span_id)
2921
+
2922
+ if limit:
2923
+ stmt = stmt.limit(limit)
2924
+
2925
+ results = sess.execute(stmt).fetchall()
2926
+ return [Span.from_dict(dict(row._mapping)) for row in results]
2927
+
2928
+ except Exception as e:
2929
+ log_error(f"Error getting spans: {e}")
2930
+ return []
2931
+
2932
+ # -- Learning methods (stubs) --
2933
+ def get_learning(
2934
+ self,
2935
+ learning_type: str,
2936
+ user_id: Optional[str] = None,
2937
+ agent_id: Optional[str] = None,
2938
+ team_id: Optional[str] = None,
2939
+ session_id: Optional[str] = None,
2940
+ namespace: Optional[str] = None,
2941
+ entity_id: Optional[str] = None,
2942
+ entity_type: Optional[str] = None,
2943
+ ) -> Optional[Dict[str, Any]]:
2944
+ raise NotImplementedError("Learning methods not yet implemented for MySQLDb")
2945
+
2946
+ def upsert_learning(
2947
+ self,
2948
+ id: str,
2949
+ learning_type: str,
2950
+ content: Dict[str, Any],
2951
+ user_id: Optional[str] = None,
2952
+ agent_id: Optional[str] = None,
2953
+ team_id: Optional[str] = None,
2954
+ session_id: Optional[str] = None,
2955
+ namespace: Optional[str] = None,
2956
+ entity_id: Optional[str] = None,
2957
+ entity_type: Optional[str] = None,
2958
+ metadata: Optional[Dict[str, Any]] = None,
2959
+ ) -> None:
2960
+ raise NotImplementedError("Learning methods not yet implemented for MySQLDb")
2961
+
2962
+ def delete_learning(self, id: str) -> bool:
2963
+ raise NotImplementedError("Learning methods not yet implemented for MySQLDb")
2964
+
2965
+ def get_learnings(
2966
+ self,
2967
+ learning_type: Optional[str] = None,
2968
+ user_id: Optional[str] = None,
2969
+ agent_id: Optional[str] = None,
2970
+ team_id: Optional[str] = None,
2971
+ session_id: Optional[str] = None,
2972
+ namespace: Optional[str] = None,
2973
+ entity_id: Optional[str] = None,
2974
+ entity_type: Optional[str] = None,
2975
+ limit: Optional[int] = None,
2976
+ ) -> List[Dict[str, Any]]:
2977
+ raise NotImplementedError("Learning methods not yet implemented for MySQLDb")