agno 2.1.0__py3-none-any.whl → 2.1.2__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 (53) hide show
  1. agno/agent/agent.py +13 -1
  2. agno/db/base.py +8 -4
  3. agno/db/dynamo/dynamo.py +69 -17
  4. agno/db/firestore/firestore.py +68 -29
  5. agno/db/gcs_json/gcs_json_db.py +68 -17
  6. agno/db/in_memory/in_memory_db.py +83 -14
  7. agno/db/json/json_db.py +79 -15
  8. agno/db/mongo/mongo.py +92 -74
  9. agno/db/mysql/mysql.py +17 -3
  10. agno/db/postgres/postgres.py +21 -3
  11. agno/db/redis/redis.py +38 -11
  12. agno/db/singlestore/singlestore.py +14 -3
  13. agno/db/sqlite/sqlite.py +34 -46
  14. agno/db/utils.py +50 -22
  15. agno/knowledge/knowledge.py +6 -0
  16. agno/knowledge/reader/field_labeled_csv_reader.py +294 -0
  17. agno/knowledge/reader/pdf_reader.py +28 -52
  18. agno/knowledge/reader/reader_factory.py +12 -0
  19. agno/memory/manager.py +12 -4
  20. agno/models/anthropic/claude.py +4 -1
  21. agno/models/aws/bedrock.py +52 -112
  22. agno/models/openai/responses.py +1 -1
  23. agno/os/app.py +24 -30
  24. agno/os/interfaces/__init__.py +1 -0
  25. agno/os/interfaces/a2a/__init__.py +3 -0
  26. agno/os/interfaces/a2a/a2a.py +42 -0
  27. agno/os/interfaces/a2a/router.py +252 -0
  28. agno/os/interfaces/a2a/utils.py +924 -0
  29. agno/os/interfaces/agui/agui.py +21 -5
  30. agno/os/interfaces/agui/router.py +12 -0
  31. agno/os/interfaces/base.py +4 -2
  32. agno/os/interfaces/slack/slack.py +13 -8
  33. agno/os/interfaces/whatsapp/whatsapp.py +12 -5
  34. agno/os/mcp.py +1 -1
  35. agno/os/router.py +39 -9
  36. agno/os/routers/memory/memory.py +5 -3
  37. agno/os/routers/memory/schemas.py +1 -0
  38. agno/os/utils.py +36 -10
  39. agno/run/base.py +2 -13
  40. agno/team/team.py +13 -1
  41. agno/tools/mcp.py +46 -1
  42. agno/utils/merge_dict.py +22 -1
  43. agno/utils/serialize.py +32 -0
  44. agno/utils/streamlit.py +1 -1
  45. agno/workflow/parallel.py +90 -14
  46. agno/workflow/step.py +30 -27
  47. agno/workflow/types.py +4 -6
  48. agno/workflow/workflow.py +5 -3
  49. {agno-2.1.0.dist-info → agno-2.1.2.dist-info}/METADATA +16 -14
  50. {agno-2.1.0.dist-info → agno-2.1.2.dist-info}/RECORD +53 -47
  51. {agno-2.1.0.dist-info → agno-2.1.2.dist-info}/WHEEL +0 -0
  52. {agno-2.1.0.dist-info → agno-2.1.2.dist-info}/licenses/LICENSE +0 -0
  53. {agno-2.1.0.dist-info → agno-2.1.2.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  """Main class for the AG-UI app, used to expose an Agno Agent or Team in an AG-UI compatible format."""
2
2
 
3
- from typing import Optional
3
+ from typing import List, Optional
4
4
 
5
5
  from fastapi.routing import APIRouter
6
6
 
@@ -15,16 +15,32 @@ class AGUI(BaseInterface):
15
15
 
16
16
  router: APIRouter
17
17
 
18
- def __init__(self, agent: Optional[Agent] = None, team: Optional[Team] = None):
18
+ def __init__(
19
+ self,
20
+ agent: Optional[Agent] = None,
21
+ team: Optional[Team] = None,
22
+ prefix: str = "",
23
+ tags: Optional[List[str]] = None,
24
+ ):
25
+ """
26
+ Initialize the AGUI interface.
27
+
28
+ Args:
29
+ agent: The agent to expose via AG-UI
30
+ team: The team to expose via AG-UI
31
+ prefix: Custom prefix for the router (e.g., "/agui/v1", "/chat/public")
32
+ tags: Custom tags for the router (e.g., ["AGUI", "Chat"], defaults to ["AGUI"])
33
+ """
19
34
  self.agent = agent
20
35
  self.team = team
36
+ self.prefix = prefix
37
+ self.tags = tags or ["AGUI"]
21
38
 
22
39
  if not (self.agent or self.team):
23
40
  raise ValueError("AGUI requires an agent or a team")
24
41
 
25
- def get_router(self, **kwargs) -> APIRouter:
26
- # Cannot be overridden
27
- self.router = APIRouter(tags=["AGUI"])
42
+ def get_router(self) -> APIRouter:
43
+ self.router = APIRouter(prefix=self.prefix, tags=self.tags) # type: ignore
28
44
 
29
45
  self.router = attach_routes(router=self.router, agent=self.agent, team=self.team)
30
46
 
@@ -34,12 +34,18 @@ async def run_agent(agent: Agent, run_input: RunAgentInput) -> AsyncIterator[Bas
34
34
  messages = convert_agui_messages_to_agno_messages(run_input.messages or [])
35
35
  yield RunStartedEvent(type=EventType.RUN_STARTED, thread_id=run_input.thread_id, run_id=run_id)
36
36
 
37
+ # Look for user_id in run_input.forwarded_props
38
+ user_id = None
39
+ if run_input.forwarded_props and isinstance(run_input.forwarded_props, dict):
40
+ user_id = run_input.forwarded_props.get("user_id")
41
+
37
42
  # Request streaming response from agent
38
43
  response_stream = agent.arun(
39
44
  input=messages,
40
45
  session_id=run_input.thread_id,
41
46
  stream=True,
42
47
  stream_intermediate_steps=True,
48
+ user_id=user_id,
43
49
  )
44
50
 
45
51
  # Stream the response content in AG-UI format
@@ -64,12 +70,18 @@ async def run_team(team: Team, input: RunAgentInput) -> AsyncIterator[BaseEvent]
64
70
  messages = convert_agui_messages_to_agno_messages(input.messages or [])
65
71
  yield RunStartedEvent(type=EventType.RUN_STARTED, thread_id=input.thread_id, run_id=run_id)
66
72
 
73
+ # Look for user_id in input.forwarded_props
74
+ user_id = None
75
+ if input.forwarded_props and isinstance(input.forwarded_props, dict):
76
+ user_id = input.forwarded_props.get("user_id")
77
+
67
78
  # Request streaming response from team
68
79
  response_stream = team.arun(
69
80
  input=messages,
70
81
  session_id=input.thread_id,
71
82
  stream=True,
72
83
  stream_intermediate_steps=True,
84
+ user_id=user_id,
73
85
  )
74
86
 
75
87
  # Stream the response content in AG-UI format
@@ -1,5 +1,5 @@
1
1
  from abc import ABC, abstractmethod
2
- from typing import Optional
2
+ from typing import List, Optional
3
3
 
4
4
  from fastapi import APIRouter
5
5
 
@@ -11,11 +11,13 @@ from agno.workflow.workflow import Workflow
11
11
  class BaseInterface(ABC):
12
12
  type: str
13
13
  version: str = "1.0"
14
- router_prefix: str = ""
15
14
  agent: Optional[Agent] = None
16
15
  team: Optional[Team] = None
17
16
  workflow: Optional[Workflow] = None
18
17
 
18
+ prefix: str
19
+ tags: List[str]
20
+
19
21
  router: APIRouter
20
22
 
21
23
  @abstractmethod
@@ -1,5 +1,4 @@
1
- import logging
2
- from typing import Optional
1
+ from typing import List, Optional
3
2
 
4
3
  from fastapi.routing import APIRouter
5
4
 
@@ -9,25 +8,31 @@ from agno.os.interfaces.slack.router import attach_routes
9
8
  from agno.team.team import Team
10
9
  from agno.workflow.workflow import Workflow
11
10
 
12
- logger = logging.getLogger(__name__)
13
-
14
11
 
15
12
  class Slack(BaseInterface):
16
13
  type = "slack"
17
14
 
18
15
  router: APIRouter
19
16
 
20
- def __init__(self, agent: Optional[Agent] = None, team: Optional[Team] = None, workflow: Optional[Workflow] = None):
17
+ def __init__(
18
+ self,
19
+ agent: Optional[Agent] = None,
20
+ team: Optional[Team] = None,
21
+ workflow: Optional[Workflow] = None,
22
+ prefix: str = "/slack",
23
+ tags: Optional[List[str]] = None,
24
+ ):
21
25
  self.agent = agent
22
26
  self.team = team
23
27
  self.workflow = workflow
28
+ self.prefix = prefix
29
+ self.tags = tags or ["Slack"]
24
30
 
25
31
  if not (self.agent or self.team or self.workflow):
26
32
  raise ValueError("Slack requires an agent, team or workflow")
27
33
 
28
- def get_router(self, **kwargs) -> APIRouter:
29
- # Cannot be overridden
30
- self.router = APIRouter(prefix="/slack", tags=["Slack"])
34
+ def get_router(self) -> APIRouter:
35
+ self.router = APIRouter(prefix=self.prefix, tags=self.tags) # type: ignore
31
36
 
32
37
  self.router = attach_routes(router=self.router, agent=self.agent, team=self.team, workflow=self.workflow)
33
38
 
@@ -1,4 +1,4 @@
1
- from typing import Optional
1
+ from typing import List, Optional
2
2
 
3
3
  from fastapi.routing import APIRouter
4
4
 
@@ -13,16 +13,23 @@ class Whatsapp(BaseInterface):
13
13
 
14
14
  router: APIRouter
15
15
 
16
- def __init__(self, agent: Optional[Agent] = None, team: Optional[Team] = None):
16
+ def __init__(
17
+ self,
18
+ agent: Optional[Agent] = None,
19
+ team: Optional[Team] = None,
20
+ prefix: str = "/whatsapp",
21
+ tags: Optional[List[str]] = None,
22
+ ):
17
23
  self.agent = agent
18
24
  self.team = team
25
+ self.prefix = prefix
26
+ self.tags = tags or ["Whatsapp"]
19
27
 
20
28
  if not (self.agent or self.team):
21
29
  raise ValueError("Whatsapp requires an agent or a team")
22
30
 
23
- def get_router(self, **kwargs) -> APIRouter:
24
- # Cannot be overridden
25
- self.router = APIRouter(prefix="/whatsapp", tags=["Whatsapp"])
31
+ def get_router(self) -> APIRouter:
32
+ self.router = APIRouter(prefix=self.prefix, tags=self.tags) # type: ignore
26
33
 
27
34
  self.router = attach_routes(router=self.router, agent=self.agent, team=self.team)
28
35
 
agno/os/mcp.py CHANGED
@@ -68,7 +68,7 @@ def get_mcp_server(
68
68
  teams=[TeamSummaryResponse.from_team(team) for team in os.teams] if os.teams else [],
69
69
  workflows=[WorkflowSummaryResponse.from_workflow(w) for w in os.workflows] if os.workflows else [],
70
70
  interfaces=[
71
- InterfaceResponse(type=interface.type, version=interface.version, route=interface.router_prefix)
71
+ InterfaceResponse(type=interface.type, version=interface.version, route=interface.prefix)
72
72
  for interface in os.interfaces
73
73
  ],
74
74
  )
agno/os/router.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import json
2
+ from itertools import chain
2
3
  from typing import TYPE_CHECKING, Any, AsyncGenerator, Callable, Dict, List, Optional, Union, cast
3
4
  from uuid import uuid4
4
5
 
@@ -643,7 +644,7 @@ def get_base_router(
643
644
  os_id=os.id or "Unnamed OS",
644
645
  description=os.description,
645
646
  available_models=os.config.available_models if os.config else [],
646
- databases=[db.id for db in os.dbs.values()],
647
+ databases=list({db.id for db in chain(os.dbs.values(), os.knowledge_dbs.values())}),
647
648
  chat=os.config.chat if os.config else None,
648
649
  session=os._get_session_config(),
649
650
  memory=os._get_memory_config(),
@@ -654,7 +655,7 @@ def get_base_router(
654
655
  teams=[TeamSummaryResponse.from_team(team) for team in os.teams] if os.teams else [],
655
656
  workflows=[WorkflowSummaryResponse.from_workflow(w) for w in os.workflows] if os.workflows else [],
656
657
  interfaces=[
657
- InterfaceResponse(type=interface.type, version=interface.version, route=interface.router_prefix)
658
+ InterfaceResponse(type=interface.type, version=interface.version, route=interface.prefix)
658
659
  for interface in os.interfaces
659
660
  ],
660
661
  )
@@ -784,19 +785,39 @@ def get_base_router(
784
785
 
785
786
  if files:
786
787
  for file in files:
787
- if file.content_type in ["image/png", "image/jpeg", "image/jpg", "image/webp"]:
788
+ if file.content_type in [
789
+ "image/png",
790
+ "image/jpeg",
791
+ "image/jpg",
792
+ "image/gif",
793
+ "image/webp",
794
+ "image/bmp",
795
+ "image/tiff",
796
+ "image/tif",
797
+ "image/avif",
798
+ ]:
788
799
  try:
789
800
  base64_image = process_image(file)
790
801
  base64_images.append(base64_image)
791
802
  except Exception as e:
792
803
  log_error(f"Error processing image {file.filename}: {e}")
793
804
  continue
794
- elif file.content_type in ["audio/wav", "audio/mp3", "audio/mpeg"]:
805
+ elif file.content_type in [
806
+ "audio/wav",
807
+ "audio/wave",
808
+ "audio/mp3",
809
+ "audio/mpeg",
810
+ "audio/ogg",
811
+ "audio/mp4",
812
+ "audio/m4a",
813
+ "audio/aac",
814
+ "audio/flac",
815
+ ]:
795
816
  try:
796
- base64_audio = process_audio(file)
797
- base64_audios.append(base64_audio)
817
+ audio = process_audio(file)
818
+ base64_audios.append(audio)
798
819
  except Exception as e:
799
- log_error(f"Error processing audio {file.filename}: {e}")
820
+ log_error(f"Error processing audio {file.filename} with content type {file.content_type}: {e}")
800
821
  continue
801
822
  elif file.content_type in [
802
823
  "video/x-flv",
@@ -819,10 +840,19 @@ def get_base_router(
819
840
  continue
820
841
  elif file.content_type in [
821
842
  "application/pdf",
822
- "text/csv",
843
+ "application/json",
844
+ "application/x-javascript",
823
845
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
846
+ "text/javascript",
847
+ "application/x-python",
848
+ "text/x-python",
824
849
  "text/plain",
825
- "application/json",
850
+ "text/html",
851
+ "text/css",
852
+ "text/md",
853
+ "text/csv",
854
+ "text/xml",
855
+ "text/rtf",
826
856
  ]:
827
857
  # Process document files
828
858
  try:
@@ -120,10 +120,11 @@ def attach_routes(router: APIRouter, dbs: dict[str, BaseDb]) -> APIRouter:
120
120
  )
121
121
  async def delete_memory(
122
122
  memory_id: str = Path(description="Memory ID to delete"),
123
+ user_id: Optional[str] = Query(default=None, description="User ID to delete memory for"),
123
124
  db_id: Optional[str] = Query(default=None, description="Database ID to use for deletion"),
124
125
  ) -> None:
125
126
  db = get_db(dbs, db_id)
126
- db.delete_user_memory(memory_id=memory_id)
127
+ db.delete_user_memory(memory_id=memory_id, user_id=user_id)
127
128
 
128
129
  @router.delete(
129
130
  "/memories",
@@ -145,7 +146,7 @@ def attach_routes(router: APIRouter, dbs: dict[str, BaseDb]) -> APIRouter:
145
146
  db_id: Optional[str] = Query(default=None, description="Database ID to use for deletion"),
146
147
  ) -> None:
147
148
  db = get_db(dbs, db_id)
148
- db.delete_user_memories(memory_ids=request.memory_ids)
149
+ db.delete_user_memories(memory_ids=request.memory_ids, user_id=request.user_id)
149
150
 
150
151
  @router.get(
151
152
  "/memories",
@@ -249,10 +250,11 @@ def attach_routes(router: APIRouter, dbs: dict[str, BaseDb]) -> APIRouter:
249
250
  )
250
251
  async def get_memory(
251
252
  memory_id: str = Path(description="Memory ID to retrieve"),
253
+ user_id: Optional[str] = Query(default=None, description="User ID to query memory for"),
252
254
  db_id: Optional[str] = Query(default=None, description="Database ID to query memory from"),
253
255
  ) -> UserMemorySchema:
254
256
  db = get_db(dbs, db_id)
255
- user_memory = db.get_user_memory(memory_id=memory_id, deserialize=False)
257
+ user_memory = db.get_user_memory(memory_id=memory_id, user_id=user_id, deserialize=False)
256
258
  if not user_memory:
257
259
  raise HTTPException(status_code=404, detail=f"Memory with ID {memory_id} not found")
258
260
 
@@ -6,6 +6,7 @@ from pydantic import BaseModel
6
6
 
7
7
  class DeleteMemoriesRequest(BaseModel):
8
8
  memory_ids: List[str]
9
+ user_id: Optional[str] = None
9
10
 
10
11
 
11
12
  class UserMemorySchema(BaseModel):
agno/os/utils.py CHANGED
@@ -93,18 +93,40 @@ def get_session_name(session: Dict[str, Any]) -> str:
93
93
 
94
94
  # For teams, identify the first Team run and avoid using the first member's run
95
95
  if session.get("session_type") == "team":
96
- run = runs[0] if not runs[0].get("agent_id") else runs[1]
96
+ run = None
97
+ for r in runs:
98
+ # If agent_id is not present, it's a team run
99
+ if not r.get("agent_id"):
100
+ run = r
101
+ break
102
+ # Fallback to first run if no team run found
103
+ if run is None and runs:
104
+ run = runs[0]
97
105
 
98
- # For workflows, pass along the first step_executor_run
99
106
  elif session.get("session_type") == "workflow":
100
107
  try:
101
- run = session["runs"][0]["step_executor_runs"][0]
108
+ workflow_run = runs[0]
109
+ workflow_input = workflow_run.get("input")
110
+ if isinstance(workflow_input, str):
111
+ return workflow_input
112
+ elif isinstance(workflow_input, dict):
113
+ try:
114
+ import json
115
+ return json.dumps(workflow_input)
116
+ except (TypeError, ValueError):
117
+ pass
118
+
119
+ workflow_name = session.get("workflow_data", {}).get("name")
120
+ return f"New {workflow_name} Session" if workflow_name else ""
102
121
  except (KeyError, IndexError, TypeError):
103
122
  return ""
104
123
 
105
124
  # For agents, use the first run
106
125
  else:
107
- run = runs[0]
126
+ run = runs[0] if runs else None
127
+
128
+ if run is None:
129
+ return ""
108
130
 
109
131
  if not isinstance(run, dict):
110
132
  run = run.to_dict()
@@ -150,13 +172,17 @@ def process_document(file: UploadFile) -> Optional[FileMedia]:
150
172
  return None
151
173
 
152
174
 
153
- def extract_format(file: UploadFile):
154
- _format = None
175
+ def extract_format(file: UploadFile) -> Optional[str]:
176
+ """Extract the File format from file name or content_type."""
177
+ # Get the format from the filename
155
178
  if file.filename and "." in file.filename:
156
- _format = file.filename.split(".")[-1].lower()
157
- elif file.content_type:
158
- _format = file.content_type.split("/")[-1]
159
- return _format
179
+ return file.filename.split(".")[-1].lower()
180
+
181
+ # Fallback to the file content_type
182
+ if file.content_type:
183
+ return file.content_type.strip().split("/")[-1]
184
+
185
+ return None
160
186
 
161
187
 
162
188
  def format_tools(agent_tools: List[Union[Dict[str, Any], Toolkit, Function, Callable]]):
agno/run/base.py CHANGED
@@ -117,19 +117,8 @@ class BaseRunOutputEvent:
117
117
 
118
118
  def to_json(self, separators=(", ", ": "), indent: Optional[int] = 2) -> str:
119
119
  import json
120
- from datetime import date, datetime, time
121
- from enum import Enum
122
-
123
- def json_serializer(obj):
124
- # Datetime like
125
- if isinstance(obj, (datetime, date, time)):
126
- return obj.isoformat()
127
- # Enums
128
- if isinstance(obj, Enum):
129
- v = obj.value
130
- return v if isinstance(v, (str, int, float, bool, type(None))) else obj.name
131
- # Fallback to string
132
- return str(obj)
120
+
121
+ from agno.utils.serialize import json_serializer
133
122
 
134
123
  try:
135
124
  _dict = self.to_dict()
agno/team/team.py CHANGED
@@ -4035,6 +4035,12 @@ class Team:
4035
4035
  log_warning("Reasoning error. Reasoning response is empty, continuing regular session...")
4036
4036
  break
4037
4037
 
4038
+ if isinstance(reasoning_agent_response.content, str):
4039
+ log_warning(
4040
+ "Reasoning error. Content is a string, not structured output. Continuing regular session..."
4041
+ )
4042
+ break
4043
+
4038
4044
  if reasoning_agent_response.content.reasoning_steps is None:
4039
4045
  log_warning("Reasoning error. Reasoning steps are empty, continuing regular session...")
4040
4046
  break
@@ -4261,6 +4267,12 @@ class Team:
4261
4267
  log_warning("Reasoning error. Reasoning response is empty, continuing regular session...")
4262
4268
  break
4263
4269
 
4270
+ if isinstance(reasoning_agent_response.content, str):
4271
+ log_warning(
4272
+ "Reasoning error. Content is a string, not structured output. Continuing regular session..."
4273
+ )
4274
+ break
4275
+
4264
4276
  if reasoning_agent_response.content.reasoning_steps is None:
4265
4277
  log_warning("Reasoning error. Reasoning steps are empty, continuing regular session...")
4266
4278
  break
@@ -4866,7 +4878,7 @@ class Team:
4866
4878
  additional_information.append(f"Your name is: {self.name}.")
4867
4879
 
4868
4880
  if self.knowledge is not None and self.enable_agentic_knowledge_filters:
4869
- valid_filters = getattr(self.knowledge, "valid_metadata_filters", None)
4881
+ valid_filters = self.knowledge.get_valid_filters()
4870
4882
  if valid_filters:
4871
4883
  valid_filters_str = ", ".join(valid_filters)
4872
4884
  additional_information.append(
agno/tools/mcp.py CHANGED
@@ -22,6 +22,8 @@ except (ImportError, ModuleNotFoundError):
22
22
 
23
23
  def _prepare_command(command: str) -> list[str]:
24
24
  """Sanitize a command and split it into parts before using it to run a MCP server."""
25
+ import os
26
+ import shutil
25
27
  from shlex import split
26
28
 
27
29
  # Block dangerous characters
@@ -55,10 +57,53 @@ def _prepare_command(command: str) -> list[str]:
55
57
  }
56
58
 
57
59
  executable = parts[0].split("/")[-1]
60
+
61
+ # Check if it's a relative path starting with ./ or ../
62
+ if executable.startswith("./") or executable.startswith("../"):
63
+ # Allow relative paths to binaries
64
+ return parts
65
+
66
+ # Check if it's an absolute path to a binary
67
+ if executable.startswith("/") and os.path.isfile(executable):
68
+ # Allow absolute paths to existing files
69
+ return parts
70
+
71
+ # Check if it's a binary in current directory without ./
72
+ if "/" not in executable and os.path.isfile(executable):
73
+ # Allow binaries in current directory
74
+ return parts
75
+
76
+ # Check if it's a binary in PATH
77
+ if shutil.which(executable):
78
+ return parts
79
+
58
80
  if executable not in ALLOWED_COMMANDS:
59
81
  raise ValueError(f"MCP command needs to use one of the following executables: {ALLOWED_COMMANDS}")
60
82
 
61
- return parts
83
+ first_part = parts[0]
84
+ executable = first_part.split("/")[-1]
85
+
86
+ # Allow known commands
87
+ if executable in ALLOWED_COMMANDS:
88
+ return parts
89
+
90
+ # Allow relative paths to custom binaries
91
+ if first_part.startswith(("./", "../")):
92
+ return parts
93
+
94
+ # Allow absolute paths to existing files
95
+ if first_part.startswith("/") and os.path.isfile(first_part):
96
+ return parts
97
+
98
+ # Allow binaries in current directory without ./
99
+ if "/" not in first_part and os.path.isfile(first_part):
100
+ return parts
101
+
102
+ # Allow binaries in PATH
103
+ if shutil.which(first_part):
104
+ return parts
105
+
106
+ raise ValueError(f"MCP command needs to use one of the following executables: {ALLOWED_COMMANDS}")
62
107
 
63
108
 
64
109
  @dataclass
agno/utils/merge_dict.py CHANGED
@@ -1,4 +1,4 @@
1
- from typing import Any, Dict
1
+ from typing import Any, Dict, List
2
2
 
3
3
 
4
4
  def merge_dictionaries(a: Dict[str, Any], b: Dict[str, Any]) -> None:
@@ -18,3 +18,24 @@ def merge_dictionaries(a: Dict[str, Any], b: Dict[str, Any]) -> None:
18
18
  merge_dictionaries(a[key], b[key])
19
19
  else:
20
20
  a[key] = b[key]
21
+
22
+
23
+ def merge_parallel_session_states(original_state: Dict[str, Any], modified_states: List[Dict[str, Any]]) -> None:
24
+ """
25
+ Smart merge for parallel session states that only applies actual changes.
26
+ This prevents parallel steps from overwriting each other's changes.
27
+ """
28
+ if not original_state or not modified_states:
29
+ return
30
+
31
+ # Collect all actual changes (keys where value differs from original)
32
+ all_changes = {}
33
+ for modified_state in modified_states:
34
+ if modified_state:
35
+ for key, value in modified_state.items():
36
+ if key not in original_state or original_state[key] != value:
37
+ all_changes[key] = value
38
+
39
+ # Apply all collected changes to the original state
40
+ for key, value in all_changes.items():
41
+ original_state[key] = value
@@ -0,0 +1,32 @@
1
+ """JSON serialization utilities for handling datetime and enum objects."""
2
+
3
+ from datetime import date, datetime, time
4
+ from enum import Enum
5
+ from typing import Any
6
+
7
+
8
+ def json_serializer(obj: Any) -> Any:
9
+ """Custom JSON serializer for objects not serializable by default json module.
10
+
11
+ Handles:
12
+ - datetime, date, time objects -> ISO format strings
13
+ - Enum objects -> their values (or names if values are not JSON-serializable)
14
+ - All other objects -> string representation
15
+
16
+ Args:
17
+ obj: Object to serialize
18
+
19
+ Returns:
20
+ JSON-serializable representation of the object
21
+ """
22
+ # Datetime like
23
+ if isinstance(obj, (datetime, date, time)):
24
+ return obj.isoformat()
25
+
26
+ # Enums
27
+ if isinstance(obj, Enum):
28
+ v = obj.value
29
+ return v if isinstance(v, (str, int, float, bool, type(None))) else obj.name
30
+
31
+ # Fallback to string
32
+ return str(obj)
agno/utils/streamlit.py CHANGED
@@ -452,7 +452,7 @@ MODELS = [
452
452
  "gpt-4o",
453
453
  "o3-mini",
454
454
  "gpt-5",
455
- "claude-4-sonnet",
455
+ "claude-sonnet-4-5-20250929",
456
456
  "gemini-2.5-pro",
457
457
  ]
458
458