agno 2.2.1__py3-none-any.whl → 2.2.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 (69) hide show
  1. agno/agent/agent.py +735 -574
  2. agno/culture/manager.py +22 -24
  3. agno/db/async_postgres/__init__.py +1 -1
  4. agno/db/dynamo/dynamo.py +0 -2
  5. agno/db/firestore/firestore.py +0 -2
  6. agno/db/gcs_json/gcs_json_db.py +0 -4
  7. agno/db/gcs_json/utils.py +0 -24
  8. agno/db/in_memory/in_memory_db.py +0 -3
  9. agno/db/json/json_db.py +4 -10
  10. agno/db/json/utils.py +0 -24
  11. agno/db/mongo/__init__.py +15 -1
  12. agno/db/mongo/async_mongo.py +1999 -0
  13. agno/db/mongo/mongo.py +0 -2
  14. agno/db/mysql/mysql.py +0 -3
  15. agno/db/postgres/__init__.py +1 -1
  16. agno/db/{async_postgres → postgres}/async_postgres.py +19 -22
  17. agno/db/postgres/postgres.py +7 -10
  18. agno/db/postgres/utils.py +106 -2
  19. agno/db/redis/redis.py +0 -2
  20. agno/db/singlestore/singlestore.py +0 -3
  21. agno/db/sqlite/__init__.py +2 -1
  22. agno/db/sqlite/async_sqlite.py +2269 -0
  23. agno/db/sqlite/sqlite.py +0 -2
  24. agno/db/sqlite/utils.py +96 -0
  25. agno/db/surrealdb/surrealdb.py +0 -6
  26. agno/knowledge/knowledge.py +3 -3
  27. agno/knowledge/reader/reader_factory.py +16 -0
  28. agno/knowledge/reader/tavily_reader.py +194 -0
  29. agno/memory/manager.py +28 -25
  30. agno/models/anthropic/claude.py +63 -6
  31. agno/models/base.py +251 -32
  32. agno/models/response.py +69 -0
  33. agno/os/router.py +7 -5
  34. agno/os/routers/memory/memory.py +2 -1
  35. agno/os/routers/memory/schemas.py +5 -2
  36. agno/os/schema.py +25 -20
  37. agno/os/utils.py +9 -2
  38. agno/run/agent.py +23 -30
  39. agno/run/base.py +17 -1
  40. agno/run/team.py +23 -29
  41. agno/run/workflow.py +17 -12
  42. agno/session/agent.py +3 -0
  43. agno/session/summary.py +4 -1
  44. agno/session/team.py +1 -1
  45. agno/team/team.py +599 -367
  46. agno/tools/dalle.py +2 -4
  47. agno/tools/eleven_labs.py +23 -25
  48. agno/tools/function.py +40 -0
  49. agno/tools/mcp/__init__.py +10 -0
  50. agno/tools/mcp/mcp.py +324 -0
  51. agno/tools/mcp/multi_mcp.py +347 -0
  52. agno/tools/mcp/params.py +24 -0
  53. agno/tools/slack.py +18 -3
  54. agno/tools/tavily.py +146 -0
  55. agno/utils/agent.py +366 -1
  56. agno/utils/mcp.py +92 -2
  57. agno/utils/media.py +166 -1
  58. agno/utils/print_response/workflow.py +17 -1
  59. agno/utils/team.py +89 -1
  60. agno/workflow/step.py +0 -1
  61. agno/workflow/types.py +10 -15
  62. {agno-2.2.1.dist-info → agno-2.2.3.dist-info}/METADATA +28 -25
  63. {agno-2.2.1.dist-info → agno-2.2.3.dist-info}/RECORD +66 -62
  64. agno/db/async_postgres/schemas.py +0 -139
  65. agno/db/async_postgres/utils.py +0 -347
  66. agno/tools/mcp.py +0 -679
  67. {agno-2.2.1.dist-info → agno-2.2.3.dist-info}/WHEEL +0 -0
  68. {agno-2.2.1.dist-info → agno-2.2.3.dist-info}/licenses/LICENSE +0 -0
  69. {agno-2.2.1.dist-info → agno-2.2.3.dist-info}/top_level.txt +0 -0
agno/utils/agent.py CHANGED
@@ -1,8 +1,9 @@
1
1
  from asyncio import Future, Task
2
- from typing import AsyncIterator, Iterator, List, Optional, Sequence, Union
2
+ from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, Iterator, List, Optional, Sequence, Union
3
3
 
4
4
  from agno.media import Audio, File, Image, Video
5
5
  from agno.models.message import Message
6
+ from agno.models.metrics import Metrics
6
7
  from agno.run.agent import RunEvent, RunInput, RunOutput, RunOutputEvent
7
8
  from agno.run.team import RunOutputEvent as TeamRunOutputEvent
8
9
  from agno.run.team import TeamRunOutput
@@ -16,6 +17,10 @@ from agno.utils.events import (
16
17
  )
17
18
  from agno.utils.log import log_debug, log_warning
18
19
 
20
+ if TYPE_CHECKING:
21
+ from agno.agent.agent import Agent
22
+ from agno.team.team import Team
23
+
19
24
 
20
25
  async def await_for_background_tasks(
21
26
  memory_task: Optional[Task] = None,
@@ -370,3 +375,363 @@ def scrub_history_messages_from_run_output(run_response: Union[RunOutput, TeamRu
370
375
  # Remove messages with from_history=True
371
376
  if run_response.messages:
372
377
  run_response.messages = [msg for msg in run_response.messages if not msg.from_history]
378
+
379
+
380
+ def get_run_output_util(
381
+ entity: Union["Agent", "Team"], run_id: str, session_id: Optional[str] = None
382
+ ) -> Optional[Union[RunOutput, TeamRunOutput]]:
383
+ """
384
+ Get a RunOutput from the database.
385
+
386
+ Args:
387
+ run_id (str): The run_id to load from storage.
388
+ session_id (Optional[str]): The session_id to load from storage.
389
+ """
390
+ if session_id is not None:
391
+ if entity._has_async_db():
392
+ raise ValueError("Async database not supported for sync functions")
393
+
394
+ session = entity.get_session(session_id=session_id)
395
+ if session is not None:
396
+ run_response = session.get_run(run_id=run_id)
397
+ if run_response is not None:
398
+ return run_response
399
+ else:
400
+ log_warning(f"RunOutput {run_id} not found in Session {session_id}")
401
+ elif entity.cached_session is not None:
402
+ run_response = entity.cached_session.get_run(run_id=run_id)
403
+ if run_response is not None:
404
+ return run_response
405
+ else:
406
+ log_warning(f"RunOutput {run_id} not found in Session {entity.cached_session.session_id}")
407
+ return None
408
+ return None
409
+
410
+
411
+ async def aget_run_output_util(
412
+ entity: Union["Agent", "Team"], run_id: str, session_id: Optional[str] = None
413
+ ) -> Optional[Union[RunOutput, TeamRunOutput]]:
414
+ """
415
+ Get a RunOutput from the database.
416
+
417
+ Args:
418
+ run_id (str): The run_id to load from storage.
419
+ session_id (Optional[str]): The session_id to load from storage.
420
+ """
421
+ if session_id is not None:
422
+ session = await entity.aget_session(session_id=session_id)
423
+ if session is not None:
424
+ run_response = session.get_run(run_id=run_id)
425
+ if run_response is not None:
426
+ return run_response
427
+ else:
428
+ log_warning(f"RunOutput {run_id} not found in Session {session_id}")
429
+ elif entity.cached_session is not None:
430
+ run_response = entity.cached_session.get_run(run_id=run_id)
431
+ if run_response is not None:
432
+ return run_response
433
+ else:
434
+ log_warning(f"RunOutput {run_id} not found in Session {entity.cached_session.session_id}")
435
+ return None
436
+ return None
437
+
438
+
439
+ def get_last_run_output_util(
440
+ entity: Union["Agent", "Team"], session_id: Optional[str] = None
441
+ ) -> Optional[Union[RunOutput, TeamRunOutput]]:
442
+ """
443
+ Get the last run response from the database.
444
+
445
+ Args:
446
+ session_id (Optional[str]): The session_id to load from storage.
447
+
448
+ Returns:
449
+ RunOutput: The last run response from the database.
450
+ """
451
+ if session_id is not None:
452
+ if entity._has_async_db():
453
+ raise ValueError("Async database not supported for sync functions")
454
+
455
+ session = entity.get_session(session_id=session_id)
456
+ if session is not None and session.runs is not None and len(session.runs) > 0:
457
+ for run_output in reversed(session.runs):
458
+ if entity.__class__.__name__ == "Agent":
459
+ if hasattr(run_output, "agent_id") and run_output.agent_id == entity.id:
460
+ return run_output
461
+ elif entity.__class__.__name__ == "Team":
462
+ if hasattr(run_output, "team_id") and run_output.team_id == entity.id:
463
+ return run_output
464
+ else:
465
+ log_warning(f"No run responses found in Session {session_id}")
466
+
467
+ elif (
468
+ entity.cached_session is not None
469
+ and entity.cached_session.runs is not None
470
+ and len(entity.cached_session.runs) > 0
471
+ ):
472
+ for run_output in reversed(entity.cached_session.runs):
473
+ if entity.__class__.__name__ == "Agent":
474
+ if hasattr(run_output, "agent_id") and run_output.agent_id == entity.id:
475
+ return run_output
476
+ elif entity.__class__.__name__ == "Team":
477
+ if hasattr(run_output, "team_id") and run_output.team_id == entity.id:
478
+ return run_output
479
+ return None
480
+
481
+
482
+ async def aget_last_run_output_util(
483
+ entity: Union["Agent", "Team"], session_id: Optional[str] = None
484
+ ) -> Optional[Union[RunOutput, TeamRunOutput]]:
485
+ """
486
+ Get the last run response from the database.
487
+
488
+ Args:
489
+ session_id (Optional[str]): The session_id to load from storage.
490
+
491
+ Returns:
492
+ RunOutput: The last run response from the database.
493
+ """
494
+ if session_id is not None:
495
+ session = await entity.aget_session(session_id=session_id)
496
+ if session is not None and session.runs is not None and len(session.runs) > 0:
497
+ for run_output in reversed(session.runs):
498
+ if entity.__class__.__name__ == "Agent":
499
+ if hasattr(run_output, "agent_id") and run_output.agent_id == entity.id:
500
+ return run_output
501
+ elif entity.__class__.__name__ == "Team":
502
+ if hasattr(run_output, "team_id") and run_output.team_id == entity.id:
503
+ return run_output
504
+ else:
505
+ log_warning(f"No run responses found in Session {session_id}")
506
+
507
+ elif (
508
+ entity.cached_session is not None
509
+ and entity.cached_session.runs is not None
510
+ and len(entity.cached_session.runs) > 0
511
+ ):
512
+ for run_output in reversed(entity.cached_session.runs):
513
+ if entity.__class__.__name__ == "Agent":
514
+ if hasattr(run_output, "agent_id") and run_output.agent_id == entity.id:
515
+ return run_output
516
+ elif entity.__class__.__name__ == "Team":
517
+ if hasattr(run_output, "team_id") and run_output.team_id == entity.id:
518
+ return run_output
519
+ return None
520
+
521
+
522
+ def set_session_name_util(
523
+ entity: Union["Agent", "Team"], session_id: str, autogenerate: bool = False, session_name: Optional[str] = None
524
+ ) -> Union[AgentSession, TeamSession]:
525
+ """Set the session name and save to storage"""
526
+ if entity._has_async_db():
527
+ raise ValueError("Async database not supported for sync functions")
528
+
529
+ session = entity.get_session(session_id=session_id) # type: ignore
530
+
531
+ if session is None:
532
+ raise Exception("No session found")
533
+
534
+ # -*- Generate name for session
535
+ if autogenerate:
536
+ session_name = entity.generate_session_name(session=session) # type: ignore
537
+ log_debug(f"Generated Session Name: {session_name}")
538
+ elif session_name is None:
539
+ raise Exception("No session name provided")
540
+
541
+ # -*- Rename session
542
+ if session.session_data is None:
543
+ session.session_data = {"session_name": session_name}
544
+ else:
545
+ session.session_data["session_name"] = session_name
546
+ # -*- Save to storage
547
+ entity.save_session(session=session) # type: ignore
548
+
549
+ return session
550
+
551
+
552
+ async def aset_session_name_util(
553
+ entity: Union["Agent", "Team"], session_id: str, autogenerate: bool = False, session_name: Optional[str] = None
554
+ ) -> Union[AgentSession, TeamSession]:
555
+ """Set the session name and save to storage"""
556
+ session = await entity.aget_session(session_id=session_id) # type: ignore
557
+
558
+ if session is None:
559
+ raise Exception("Session not found")
560
+
561
+ # -*- Generate name for session
562
+ if autogenerate:
563
+ session_name = entity.generate_session_name(session=session) # type: ignore
564
+ log_debug(f"Generated Session Name: {session_name}")
565
+ elif session_name is None:
566
+ raise Exception("No session name provided")
567
+
568
+ # -*- Rename session
569
+ if session.session_data is None:
570
+ session.session_data = {"session_name": session_name}
571
+ else:
572
+ session.session_data["session_name"] = session_name
573
+
574
+ # -*- Save to storage
575
+ await entity.asave_session(session=session) # type: ignore
576
+
577
+ return session
578
+
579
+
580
+ def get_session_name_util(entity: Union["Agent", "Team"], session_id: str) -> str:
581
+ """Get the session name for the given session ID and user ID."""
582
+
583
+ if entity._has_async_db():
584
+ raise ValueError("Async database not supported for sync functions")
585
+
586
+ session = entity.get_session(session_id=session_id) # type: ignore
587
+ if session is None:
588
+ raise Exception("Session not found")
589
+ return session.session_data.get("session_name", "") if session.session_data is not None else "" # type: ignore
590
+
591
+
592
+ async def aget_session_name_util(entity: Union["Agent", "Team"], session_id: str) -> str:
593
+ """Get the session name for the given session ID and user ID."""
594
+ session = await entity.aget_session(session_id=session_id) # type: ignore
595
+ if session is None:
596
+ raise Exception("Session not found")
597
+ return session.session_data.get("session_name", "") if session.session_data is not None else "" # type: ignore
598
+
599
+
600
+ def get_session_state_util(entity: Union["Agent", "Team"], session_id: str) -> Dict[str, Any]:
601
+ """Get the session state for the given session ID and user ID."""
602
+ if entity._has_async_db():
603
+ raise ValueError("Async database not supported for sync functions")
604
+
605
+ session = entity.get_session(session_id=session_id) # type: ignore
606
+ if session is None:
607
+ raise Exception("Session not found")
608
+ return session.session_data.get("session_state", {}) if session.session_data is not None else {} # type: ignore
609
+
610
+
611
+ async def aget_session_state_util(entity: Union["Agent", "Team"], session_id: str) -> Dict[str, Any]:
612
+ """Get the session state for the given session ID and user ID."""
613
+ session = await entity.aget_session(session_id=session_id) # type: ignore
614
+ if session is None:
615
+ raise Exception("Session not found")
616
+ return session.session_data.get("session_state", {}) if session.session_data is not None else {} # type: ignore
617
+
618
+
619
+ def update_session_state_util(
620
+ entity: Union["Agent", "Team"], session_state_updates: Dict[str, Any], session_id: str
621
+ ) -> str:
622
+ """
623
+ Update the session state for the given session ID and user ID.
624
+ Args:
625
+ session_state_updates: The updates to apply to the session state. Should be a dictionary of key-value pairs.
626
+ session_id: The session ID to update. If not provided, the current cached session ID is used.
627
+ Returns:
628
+ dict: The updated session state.
629
+ """
630
+ if entity._has_async_db():
631
+ raise ValueError("Async database not supported for sync functions")
632
+
633
+ session = entity.get_session(session_id=session_id) # type: ignore
634
+ if session is None:
635
+ raise Exception("Session not found")
636
+
637
+ if session.session_data is not None and "session_state" not in session.session_data:
638
+ session.session_data["session_state"] = {}
639
+
640
+ for key, value in session_state_updates.items():
641
+ session.session_data["session_state"][key] = value # type: ignore
642
+
643
+ entity.save_session(session=session) # type: ignore
644
+
645
+ return session.session_data["session_state"] # type: ignore
646
+
647
+
648
+ async def aupdate_session_state_util(
649
+ entity: Union["Agent", "Team"], session_state_updates: Dict[str, Any], session_id: str
650
+ ) -> str:
651
+ """
652
+ Update the session state for the given session ID and user ID.
653
+ Args:
654
+ session_state_updates: The updates to apply to the session state. Should be a dictionary of key-value pairs.
655
+ session_id: The session ID to update. If not provided, the current cached session ID is used.
656
+ Returns:
657
+ dict: The updated session state.
658
+ """
659
+ session = await entity.aget_session(session_id=session_id) # type: ignore
660
+ if session is None:
661
+ raise Exception("Session not found")
662
+
663
+ if session.session_data is not None and "session_state" not in session.session_data:
664
+ session.session_data["session_state"] = {}
665
+
666
+ for key, value in session_state_updates.items():
667
+ session.session_data["session_state"][key] = value # type: ignore
668
+
669
+ await entity.asave_session(session=session) # type: ignore
670
+
671
+ return session.session_data["session_state"] # type: ignore
672
+
673
+
674
+ def get_session_metrics_util(entity: Union["Agent", "Team"], session_id: str) -> Optional[Metrics]:
675
+ """Get the session metrics for the given session ID and user ID."""
676
+ if entity._has_async_db():
677
+ raise ValueError("Async database not supported for sync functions")
678
+
679
+ session = entity.get_session(session_id=session_id) # type: ignore
680
+ if session is None:
681
+ raise Exception("Session not found")
682
+
683
+ if session.session_data is not None:
684
+ if isinstance(session.session_data.get("session_metrics"), dict):
685
+ return Metrics(**session.session_data.get("session_metrics", {}))
686
+ elif isinstance(session.session_data.get("session_metrics"), Metrics):
687
+ return session.session_data.get("session_metrics")
688
+ return None
689
+
690
+
691
+ async def aget_session_metrics_util(entity: Union["Agent", "Team"], session_id: str) -> Optional[Metrics]:
692
+ """Get the session metrics for the given session ID and user ID."""
693
+ session = await entity.aget_session(session_id=session_id) # type: ignore
694
+ if session is None:
695
+ raise Exception("Session not found")
696
+
697
+ if session.session_data is not None:
698
+ if isinstance(session.session_data.get("session_metrics"), dict):
699
+ return Metrics(**session.session_data.get("session_metrics", {}))
700
+ elif isinstance(session.session_data.get("session_metrics"), Metrics):
701
+ return session.session_data.get("session_metrics")
702
+ return None
703
+
704
+
705
+ def get_chat_history_util(entity: Union["Agent", "Team"], session_id: str) -> List[Message]:
706
+ """Read the chat history from the session
707
+
708
+ Args:
709
+ session_id: The session ID to get the chat history for. If not provided, the current cached session ID is used.
710
+ Returns:
711
+ List[Message]: The chat history from the session.
712
+ """
713
+ if entity._has_async_db():
714
+ raise ValueError("Async database not supported for sync functions")
715
+
716
+ session = entity.get_session(session_id=session_id) # type: ignore
717
+
718
+ if session is None:
719
+ raise Exception("Session not found")
720
+
721
+ return session.get_chat_history()
722
+
723
+
724
+ async def aget_chat_history_util(entity: Union["Agent", "Team"], session_id: str) -> List[Message]:
725
+ """Read the chat history from the session
726
+
727
+ Args:
728
+ session_id: The session ID to get the chat history for. If not provided, the current cached session ID is used.
729
+ Returns:
730
+ List[Message]: The chat history from the session.
731
+ """
732
+ session = await entity.aget_session(session_id=session_id) # type: ignore
733
+
734
+ if session is None:
735
+ raise Exception("Session not found")
736
+
737
+ return session.get_chat_history()
agno/utils/mcp.py CHANGED
@@ -27,9 +27,13 @@ def get_entrypoint_for_tool(tool: MCPTool, session: ClientSession):
27
27
  Returns:
28
28
  Callable: The entrypoint function for the tool
29
29
  """
30
- from agno.agent import Agent
31
30
 
32
- async def call_tool(agent: Agent, tool_name: str, **kwargs) -> ToolResult:
31
+ async def call_tool(tool_name: str, **kwargs) -> ToolResult:
32
+ try:
33
+ await session.send_ping()
34
+ except Exception as e:
35
+ print(e)
36
+
33
37
  try:
34
38
  log_debug(f"Calling MCP Tool '{tool_name}' with args: {kwargs}")
35
39
  result: CallToolResult = await session.call_tool(tool_name, kwargs) # type: ignore
@@ -122,3 +126,89 @@ def get_entrypoint_for_tool(tool: MCPTool, session: ClientSession):
122
126
  return ToolResult(content=f"Error: {e}")
123
127
 
124
128
  return partial(call_tool, tool_name=tool.name)
129
+
130
+
131
+ def prepare_command(command: str) -> list[str]:
132
+ """Sanitize a command and split it into parts before using it to run a MCP server."""
133
+ import os
134
+ import shutil
135
+ from shlex import split
136
+
137
+ # Block dangerous characters
138
+ if any(char in command for char in ["&", "|", ";", "`", "$", "(", ")"]):
139
+ raise ValueError("MCP command can't contain shell metacharacters")
140
+
141
+ parts = split(command)
142
+ if not parts:
143
+ raise ValueError("MCP command can't be empty")
144
+
145
+ # Only allow specific executables
146
+ ALLOWED_COMMANDS = {
147
+ # Python
148
+ "python",
149
+ "python3",
150
+ "uv",
151
+ "uvx",
152
+ "pipx",
153
+ # Node
154
+ "node",
155
+ "npm",
156
+ "npx",
157
+ "yarn",
158
+ "pnpm",
159
+ "bun",
160
+ # Other runtimes
161
+ "deno",
162
+ "java",
163
+ "ruby",
164
+ "docker",
165
+ }
166
+
167
+ executable = parts[0].split("/")[-1]
168
+
169
+ # Check if it's a relative path starting with ./ or ../
170
+ if executable.startswith("./") or executable.startswith("../"):
171
+ # Allow relative paths to binaries
172
+ return parts
173
+
174
+ # Check if it's an absolute path to a binary
175
+ if executable.startswith("/") and os.path.isfile(executable):
176
+ # Allow absolute paths to existing files
177
+ return parts
178
+
179
+ # Check if it's a binary in current directory without ./
180
+ if "/" not in executable and os.path.isfile(executable):
181
+ # Allow binaries in current directory
182
+ return parts
183
+
184
+ # Check if it's a binary in PATH
185
+ if shutil.which(executable):
186
+ return parts
187
+
188
+ if executable not in ALLOWED_COMMANDS:
189
+ raise ValueError(f"MCP command needs to use one of the following executables: {ALLOWED_COMMANDS}")
190
+
191
+ first_part = parts[0]
192
+ executable = first_part.split("/")[-1]
193
+
194
+ # Allow known commands
195
+ if executable in ALLOWED_COMMANDS:
196
+ return parts
197
+
198
+ # Allow relative paths to custom binaries
199
+ if first_part.startswith(("./", "../")):
200
+ return parts
201
+
202
+ # Allow absolute paths to existing files
203
+ if first_part.startswith("/") and os.path.isfile(first_part):
204
+ return parts
205
+
206
+ # Allow binaries in current directory without ./
207
+ if "/" not in first_part and os.path.isfile(first_part):
208
+ return parts
209
+
210
+ # Allow binaries in PATH
211
+ if shutil.which(first_part):
212
+ return parts
213
+
214
+ raise ValueError(f"MCP command needs to use one of the following executables: {ALLOWED_COMMANDS}")
agno/utils/media.py CHANGED
@@ -2,10 +2,11 @@ import base64
2
2
  import time
3
3
  from enum import Enum
4
4
  from pathlib import Path
5
- from typing import List
5
+ from typing import List, Optional
6
6
 
7
7
  import httpx
8
8
 
9
+ from agno.media import Audio, File, Image, Video
9
10
  from agno.utils.log import log_info, log_warning
10
11
 
11
12
 
@@ -185,3 +186,167 @@ def download_knowledge_filters_sample_data(
185
186
  )
186
187
  file_paths.append(str(download_path))
187
188
  return file_paths
189
+
190
+
191
+ def reconstruct_image_from_dict(img_data):
192
+ """
193
+ Reconstruct an Image object from dictionary data.
194
+
195
+ Handles both base64-encoded content (from database) and regular image data (url/filepath).
196
+ """
197
+ try:
198
+ if isinstance(img_data, dict):
199
+ # If content is base64 string, decode it back to bytes
200
+ if "content" in img_data and isinstance(img_data["content"], str):
201
+ return Image.from_base64(
202
+ img_data["content"],
203
+ id=img_data.get("id"),
204
+ mime_type=img_data.get("mime_type"),
205
+ format=img_data.get("format"),
206
+ detail=img_data.get("detail"),
207
+ original_prompt=img_data.get("original_prompt"),
208
+ revised_prompt=img_data.get("revised_prompt"),
209
+ alt_text=img_data.get("alt_text"),
210
+ )
211
+ else:
212
+ # Regular image (filepath/url)
213
+ return Image(**img_data)
214
+ return img_data
215
+ except Exception as e:
216
+ log_warning(f"Failed to reconstruct image from dict: {e}")
217
+ return None
218
+
219
+
220
+ def reconstruct_video_from_dict(vid_data):
221
+ """
222
+ Reconstruct a Video object from dictionary data.
223
+
224
+ Handles both base64-encoded content (from database) and regular video data (url/filepath).
225
+ """
226
+ try:
227
+ if isinstance(vid_data, dict):
228
+ # If content is base64 string, decode it back to bytes
229
+ if "content" in vid_data and isinstance(vid_data["content"], str):
230
+ return Video.from_base64(
231
+ vid_data["content"],
232
+ id=vid_data.get("id"),
233
+ mime_type=vid_data.get("mime_type"),
234
+ format=vid_data.get("format"),
235
+ )
236
+ else:
237
+ # Regular video (filepath/url)
238
+ return Video(**vid_data)
239
+ return vid_data
240
+ except Exception as e:
241
+ log_warning(f"Failed to reconstruct video from dict: {e}")
242
+ return None
243
+
244
+
245
+ def reconstruct_audio_from_dict(aud_data):
246
+ """
247
+ Reconstruct an Audio object from dictionary data.
248
+
249
+ Handles both base64-encoded content (from database) and regular audio data (url/filepath).
250
+ """
251
+ try:
252
+ if isinstance(aud_data, dict):
253
+ # If content is base64 string, decode it back to bytes
254
+ if "content" in aud_data and isinstance(aud_data["content"], str):
255
+ return Audio.from_base64(
256
+ aud_data["content"],
257
+ id=aud_data.get("id"),
258
+ mime_type=aud_data.get("mime_type"),
259
+ transcript=aud_data.get("transcript"),
260
+ expires_at=aud_data.get("expires_at"),
261
+ sample_rate=aud_data.get("sample_rate", 24000),
262
+ channels=aud_data.get("channels", 1),
263
+ )
264
+ else:
265
+ # Regular audio (filepath/url)
266
+ return Audio(**aud_data)
267
+ return aud_data
268
+ except Exception as e:
269
+ log_warning(f"Failed to reconstruct audio from dict: {e}")
270
+ return None
271
+
272
+
273
+ def reconstruct_file_from_dict(file_data):
274
+ """
275
+ Reconstruct a File object from dictionary data.
276
+
277
+ Handles both base64-encoded content (from database) and regular file data (url/filepath).
278
+ """
279
+ try:
280
+ if isinstance(file_data, dict):
281
+ # If content is base64 string, decode it back to bytes
282
+ if "content" in file_data and isinstance(file_data["content"], str):
283
+ return File.from_base64(
284
+ file_data["content"],
285
+ id=file_data.get("id"),
286
+ mime_type=file_data.get("mime_type"),
287
+ filename=file_data.get("filename"),
288
+ name=file_data.get("name"),
289
+ format=file_data.get("format"),
290
+ )
291
+ else:
292
+ # Regular file (filepath/url)
293
+ return File(**file_data)
294
+ return file_data
295
+ except Exception as e:
296
+ log_warning(f"Failed to reconstruct file from dict: {e}")
297
+ return None
298
+
299
+
300
+ def reconstruct_images(images: Optional[List[dict]]) -> Optional[List[Image]]:
301
+ """Reconstruct a list of Image objects from list of dictionaries.
302
+
303
+ Failed reconstructions are skipped with a warning logged.
304
+ """
305
+ if not images:
306
+ return None
307
+ reconstructed = [reconstruct_image_from_dict(img_data) for img_data in images]
308
+ valid_images = [img for img in reconstructed if img is not None]
309
+ return valid_images if valid_images else None
310
+
311
+
312
+ def reconstruct_videos(videos: Optional[List[dict]]) -> Optional[List[Video]]:
313
+ """Reconstruct a list of Video objects from list of dictionaries.
314
+
315
+ Failed reconstructions are skipped with a warning logged.
316
+ """
317
+ if not videos:
318
+ return None
319
+ reconstructed = [reconstruct_video_from_dict(vid_data) for vid_data in videos]
320
+ valid_videos = [vid for vid in reconstructed if vid is not None]
321
+ return valid_videos if valid_videos else None
322
+
323
+
324
+ def reconstruct_audio_list(audio: Optional[List[dict]]) -> Optional[List[Audio]]:
325
+ """Reconstruct a list of Audio objects from list of dictionaries.
326
+
327
+ Failed reconstructions are skipped with a warning logged.
328
+ """
329
+ if not audio:
330
+ return None
331
+ reconstructed = [reconstruct_audio_from_dict(aud_data) for aud_data in audio]
332
+ valid_audio = [aud for aud in reconstructed if aud is not None]
333
+ return valid_audio if valid_audio else None
334
+
335
+
336
+ def reconstruct_files(files: Optional[List[dict]]) -> Optional[List[File]]:
337
+ """Reconstruct a list of File objects from list of dictionaries.
338
+
339
+ Failed reconstructions are skipped with a warning logged.
340
+ """
341
+ if not files:
342
+ return None
343
+ reconstructed = [reconstruct_file_from_dict(file_data) for file_data in files]
344
+ valid_files = [f for f in reconstructed if f is not None]
345
+ return valid_files if valid_files else None
346
+
347
+
348
+ def reconstruct_response_audio(audio: Optional[dict]) -> Optional[Audio]:
349
+ """Reconstruct a single Audio object for response audio."""
350
+ if not audio:
351
+ return None
352
+ return reconstruct_audio_from_dict(audio)