agno 2.3.16__py3-none-any.whl → 2.3.18__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 (76) hide show
  1. agno/agent/__init__.py +2 -0
  2. agno/agent/agent.py +4 -53
  3. agno/agent/remote.py +351 -0
  4. agno/client/__init__.py +3 -0
  5. agno/client/os.py +2669 -0
  6. agno/db/base.py +20 -0
  7. agno/db/mongo/async_mongo.py +11 -0
  8. agno/db/mongo/mongo.py +10 -0
  9. agno/db/mysql/async_mysql.py +9 -0
  10. agno/db/mysql/mysql.py +9 -0
  11. agno/db/postgres/async_postgres.py +9 -0
  12. agno/db/postgres/postgres.py +9 -0
  13. agno/db/postgres/utils.py +3 -2
  14. agno/db/sqlite/async_sqlite.py +9 -0
  15. agno/db/sqlite/sqlite.py +11 -1
  16. agno/exceptions.py +23 -0
  17. agno/knowledge/chunking/semantic.py +123 -46
  18. agno/knowledge/reader/csv_reader.py +1 -1
  19. agno/knowledge/reader/field_labeled_csv_reader.py +1 -1
  20. agno/knowledge/reader/json_reader.py +1 -1
  21. agno/models/google/gemini.py +5 -0
  22. agno/os/app.py +108 -25
  23. agno/os/auth.py +25 -1
  24. agno/os/interfaces/a2a/a2a.py +7 -6
  25. agno/os/interfaces/a2a/router.py +13 -13
  26. agno/os/interfaces/agui/agui.py +5 -3
  27. agno/os/interfaces/agui/router.py +23 -16
  28. agno/os/interfaces/base.py +7 -7
  29. agno/os/interfaces/slack/router.py +6 -6
  30. agno/os/interfaces/slack/slack.py +7 -7
  31. agno/os/interfaces/whatsapp/router.py +29 -6
  32. agno/os/interfaces/whatsapp/whatsapp.py +11 -8
  33. agno/os/managers.py +326 -0
  34. agno/os/mcp.py +651 -79
  35. agno/os/router.py +125 -18
  36. agno/os/routers/agents/router.py +65 -22
  37. agno/os/routers/agents/schema.py +16 -4
  38. agno/os/routers/database.py +5 -0
  39. agno/os/routers/evals/evals.py +93 -11
  40. agno/os/routers/evals/utils.py +6 -6
  41. agno/os/routers/knowledge/knowledge.py +104 -16
  42. agno/os/routers/memory/memory.py +124 -7
  43. agno/os/routers/metrics/metrics.py +21 -4
  44. agno/os/routers/session/session.py +141 -12
  45. agno/os/routers/teams/router.py +40 -14
  46. agno/os/routers/teams/schema.py +12 -4
  47. agno/os/routers/traces/traces.py +54 -4
  48. agno/os/routers/workflows/router.py +223 -117
  49. agno/os/routers/workflows/schema.py +65 -1
  50. agno/os/schema.py +38 -12
  51. agno/os/utils.py +87 -166
  52. agno/remote/__init__.py +3 -0
  53. agno/remote/base.py +484 -0
  54. agno/run/workflow.py +1 -0
  55. agno/team/__init__.py +2 -0
  56. agno/team/remote.py +287 -0
  57. agno/team/team.py +25 -54
  58. agno/tracing/exporter.py +10 -6
  59. agno/tracing/setup.py +2 -1
  60. agno/utils/agent.py +58 -1
  61. agno/utils/http.py +68 -20
  62. agno/utils/os.py +0 -0
  63. agno/utils/remote.py +23 -0
  64. agno/vectordb/chroma/chromadb.py +452 -16
  65. agno/vectordb/pgvector/pgvector.py +7 -0
  66. agno/vectordb/redis/redisdb.py +1 -1
  67. agno/workflow/__init__.py +2 -0
  68. agno/workflow/agent.py +2 -2
  69. agno/workflow/remote.py +222 -0
  70. agno/workflow/types.py +0 -73
  71. agno/workflow/workflow.py +119 -68
  72. {agno-2.3.16.dist-info → agno-2.3.18.dist-info}/METADATA +1 -1
  73. {agno-2.3.16.dist-info → agno-2.3.18.dist-info}/RECORD +76 -66
  74. {agno-2.3.16.dist-info → agno-2.3.18.dist-info}/WHEEL +0 -0
  75. {agno-2.3.16.dist-info → agno-2.3.18.dist-info}/licenses/LICENSE +0 -0
  76. {agno-2.3.16.dist-info → agno-2.3.18.dist-info}/top_level.txt +0 -0
@@ -1,23 +1,31 @@
1
1
  import base64
2
2
  from os import getenv
3
- from typing import Optional
3
+ from typing import Optional, Union
4
4
 
5
5
  from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
6
6
  from fastapi.responses import PlainTextResponse
7
7
 
8
8
  from agno.agent.agent import Agent
9
+ from agno.agent.remote import RemoteAgent
9
10
  from agno.media import Audio, File, Image, Video
11
+ from agno.team.remote import RemoteTeam
10
12
  from agno.team.team import Team
11
13
  from agno.tools.whatsapp import WhatsAppTools
12
14
  from agno.utils.log import log_error, log_info, log_warning
13
15
  from agno.utils.whatsapp import get_media_async, send_image_message_async, typing_indicator_async, upload_media_async
16
+ from agno.workflow import RemoteWorkflow, Workflow
14
17
 
15
18
  from .security import validate_webhook_signature
16
19
 
17
20
 
18
- def attach_routes(router: APIRouter, agent: Optional[Agent] = None, team: Optional[Team] = None) -> APIRouter:
19
- if agent is None and team is None:
20
- raise ValueError("Either agent or team must be provided.")
21
+ def attach_routes(
22
+ router: APIRouter,
23
+ agent: Optional[Union[Agent, RemoteAgent]] = None,
24
+ team: Optional[Union[Team, RemoteTeam]] = None,
25
+ workflow: Optional[Union[Workflow, RemoteWorkflow]] = None,
26
+ ) -> APIRouter:
27
+ if agent is None and team is None and workflow is None:
28
+ raise ValueError("Either agent, team, or workflow must be provided.")
21
29
 
22
30
  # Create WhatsApp tools instance once for reuse
23
31
  whatsapp_tools = WhatsAppTools(async_mode=True)
@@ -73,7 +81,7 @@ def attach_routes(router: APIRouter, agent: Optional[Agent] = None, team: Option
73
81
  continue
74
82
 
75
83
  message = messages[0]
76
- background_tasks.add_task(process_message, message, agent, team)
84
+ background_tasks.add_task(process_message, message, agent, team, workflow)
77
85
 
78
86
  return {"status": "processing"}
79
87
 
@@ -81,7 +89,12 @@ def attach_routes(router: APIRouter, agent: Optional[Agent] = None, team: Option
81
89
  log_error(f"Error processing webhook: {str(e)}")
82
90
  raise HTTPException(status_code=500, detail=str(e))
83
91
 
84
- async def process_message(message: dict, agent: Optional[Agent], team: Optional[Team]):
92
+ async def process_message(
93
+ message: dict,
94
+ agent: Optional[Union[Agent, RemoteAgent]],
95
+ team: Optional[Union[Team, RemoteTeam]],
96
+ workflow: Optional[Union[Workflow, RemoteWorkflow]] = None,
97
+ ):
85
98
  """Process a single WhatsApp message in the background"""
86
99
  try:
87
100
  message_image = None
@@ -139,6 +152,16 @@ def attach_routes(router: APIRouter, agent: Optional[Agent] = None, team: Option
139
152
  videos=[Video(content=await get_media_async(message_video))] if message_video else None,
140
153
  audio=[Audio(content=await get_media_async(message_audio))] if message_audio else None,
141
154
  )
155
+ elif workflow:
156
+ response = await workflow.arun( # type: ignore
157
+ message_text,
158
+ user_id=phone_number,
159
+ session_id=f"wa:{phone_number}",
160
+ images=[Image(content=await get_media_async(message_image))] if message_image else None,
161
+ files=[File(content=await get_media_async(message_doc))] if message_doc else None,
162
+ videos=[Video(content=await get_media_async(message_video))] if message_video else None,
163
+ audio=[Audio(content=await get_media_async(message_audio))] if message_audio else None,
164
+ )
142
165
 
143
166
  if response.reasoning_content:
144
167
  await _send_whatsapp_message(phone_number, f"Reasoning: \n{response.reasoning_content}", italics=True)
@@ -1,11 +1,12 @@
1
- from typing import List, Optional
1
+ from typing import List, Optional, Union
2
2
 
3
3
  from fastapi.routing import APIRouter
4
4
 
5
- from agno.agent import Agent
5
+ from agno.agent import Agent, RemoteAgent
6
6
  from agno.os.interfaces.base import BaseInterface
7
7
  from agno.os.interfaces.whatsapp.router import attach_routes
8
- from agno.team import Team
8
+ from agno.team import RemoteTeam, Team
9
+ from agno.workflow import RemoteWorkflow, Workflow
9
10
 
10
11
 
11
12
  class Whatsapp(BaseInterface):
@@ -15,22 +16,24 @@ class Whatsapp(BaseInterface):
15
16
 
16
17
  def __init__(
17
18
  self,
18
- agent: Optional[Agent] = None,
19
- team: Optional[Team] = None,
19
+ agent: Optional[Union[Agent, RemoteAgent]] = None,
20
+ team: Optional[Union[Team, RemoteTeam]] = None,
21
+ workflow: Optional[Union[Workflow, RemoteWorkflow]] = None,
20
22
  prefix: str = "/whatsapp",
21
23
  tags: Optional[List[str]] = None,
22
24
  ):
23
25
  self.agent = agent
24
26
  self.team = team
27
+ self.workflow = workflow
25
28
  self.prefix = prefix
26
29
  self.tags = tags or ["Whatsapp"]
27
30
 
28
- if not (self.agent or self.team):
29
- raise ValueError("Whatsapp requires an agent or a team")
31
+ if not (self.agent or self.team or self.workflow):
32
+ raise ValueError("Whatsapp requires an agent, team, or workflow")
30
33
 
31
34
  def get_router(self) -> APIRouter:
32
35
  self.router = APIRouter(prefix=self.prefix, tags=self.tags) # type: ignore
33
36
 
34
- self.router = attach_routes(router=self.router, agent=self.agent, team=self.team)
37
+ self.router = attach_routes(router=self.router, agent=self.agent, team=self.team, workflow=self.workflow)
35
38
 
36
39
  return self.router
agno/os/managers.py ADDED
@@ -0,0 +1,326 @@
1
+ """
2
+ Managers for AgentOS.
3
+
4
+ This module provides various manager classes for AgentOS:
5
+ - WebSocketManager: WebSocket connection management for real-time streaming
6
+ - EventsBuffer: Event buffering for agent/team/workflow reconnection support
7
+ - WebSocketHandler: Handler for sending events over WebSocket connections
8
+
9
+ These managers are used by agents, teams, and workflows for background WebSocket execution.
10
+ """
11
+
12
+ import json
13
+ from dataclasses import dataclass
14
+ from time import time
15
+ from typing import Any, Dict, List, Optional, Union
16
+
17
+ from starlette.websockets import WebSocket
18
+
19
+ from agno.run.agent import RunOutputEvent
20
+ from agno.run.base import RunStatus
21
+ from agno.run.team import TeamRunOutputEvent
22
+ from agno.run.workflow import WorkflowRunOutputEvent
23
+ from agno.utils.log import log_debug, log_warning, logger
24
+ from agno.utils.serialize import json_serializer
25
+
26
+
27
+ @dataclass
28
+ class WebSocketHandler:
29
+ """Generic WebSocket handler for real-time agent/team/workflow events"""
30
+
31
+ websocket: Optional[WebSocket] = None
32
+
33
+ def format_sse_event(self, json_data: str) -> str:
34
+ """Parse JSON data into SSE-compliant format.
35
+
36
+ Args:
37
+ json_data: JSON string containing the event data
38
+
39
+ Returns:
40
+ SSE-formatted response with event type and data
41
+ """
42
+ try:
43
+ # Parse the JSON to extract the event type
44
+ data = json.loads(json_data)
45
+ event_type = data.get("event", "message")
46
+
47
+ # Format as SSE: event: <event_type>\ndata: <json_data>\n\n
48
+ return f"event: {event_type}\ndata: {json_data}\n\n"
49
+ except (json.JSONDecodeError, KeyError):
50
+ # Fallback to generic message event if parsing fails
51
+ return f"event: message\ndata: {json_data}\n\n"
52
+
53
+ async def handle_event(
54
+ self,
55
+ event: Union[RunOutputEvent, TeamRunOutputEvent, WorkflowRunOutputEvent],
56
+ event_index: Optional[int] = None,
57
+ run_id: Optional[str] = None,
58
+ ) -> None:
59
+ """Handle an event object - serializes and sends via WebSocket with event_index for reconnection support"""
60
+ if not self.websocket:
61
+ return
62
+
63
+ try:
64
+ if hasattr(event, "to_dict"):
65
+ data = event.to_dict()
66
+ elif hasattr(event, "__dict__"):
67
+ data = event.__dict__
68
+ elif isinstance(event, dict):
69
+ data = event
70
+ else:
71
+ data = {"type": "message", "content": str(event)}
72
+
73
+ # Add event_index for reconnection support (if provided)
74
+ if event_index is not None:
75
+ data["event_index"] = event_index
76
+ # Only set run_id if not already present in the event data
77
+ # This preserves the agent's own run_id for agent events
78
+ if run_id and "run_id" not in data:
79
+ data["run_id"] = run_id
80
+
81
+ await self.websocket.send_text(self.format_sse_event(json.dumps(data, default=json_serializer)))
82
+
83
+ except RuntimeError as e:
84
+ if "websocket.close" in str(e).lower() or "already completed" in str(e).lower():
85
+ log_debug("WebSocket closed, event not sent (expected during disconnection)")
86
+ else:
87
+ log_warning(f"Failed to handle WebSocket event: {e}")
88
+ except Exception as e:
89
+ log_warning(f"Failed to handle WebSocket event: {e}")
90
+
91
+
92
+ class WebSocketManager:
93
+ """
94
+ Manages WebSocket connections for agent, team, and workflow runs.
95
+
96
+ Handles connection lifecycle, authentication, and message broadcasting
97
+ for real-time event streaming across all execution types.
98
+ """
99
+
100
+ active_connections: Dict[str, WebSocket] # {run_id: websocket}
101
+ authenticated_connections: Dict[WebSocket, bool] # {websocket: is_authenticated}
102
+
103
+ def __init__(
104
+ self,
105
+ active_connections: Optional[Dict[str, WebSocket]] = None,
106
+ ):
107
+ # Store active connections: {run_id: websocket}
108
+ self.active_connections = active_connections or {}
109
+ # Track authentication state for each websocket
110
+ self.authenticated_connections = {}
111
+
112
+ async def connect(self, websocket: WebSocket, requires_auth: bool = True):
113
+ """Accept WebSocket connection"""
114
+ await websocket.accept()
115
+ logger.debug("WebSocket connected")
116
+
117
+ # Send connection confirmation with auth requirement info
118
+ await websocket.send_text(
119
+ json.dumps(
120
+ {
121
+ "event": "connected",
122
+ "message": (
123
+ "Connected to AgentOS. Please authenticate to continue."
124
+ if requires_auth
125
+ else "Connected to AgentOS."
126
+ ),
127
+ "requires_auth": requires_auth,
128
+ }
129
+ )
130
+ )
131
+
132
+ async def authenticate_websocket(self, websocket: WebSocket):
133
+ """Mark a WebSocket connection as authenticated"""
134
+ self.authenticated_connections[websocket] = True
135
+ logger.debug("WebSocket authenticated")
136
+
137
+ # Send authentication confirmation
138
+ await websocket.send_text(
139
+ json.dumps(
140
+ {
141
+ "event": "authenticated",
142
+ "message": "Authentication successful. You can now send commands.",
143
+ }
144
+ )
145
+ )
146
+
147
+ def is_authenticated(self, websocket: WebSocket) -> bool:
148
+ """Check if a WebSocket connection is authenticated"""
149
+ return self.authenticated_connections.get(websocket, False)
150
+
151
+ async def register_websocket(self, run_id: str, websocket: WebSocket):
152
+ """Register a run (agent/team/workflow) with its WebSocket connection"""
153
+ self.active_connections[run_id] = websocket
154
+ logger.debug(f"Registered WebSocket for run_id: {run_id}")
155
+
156
+ async def broadcast_to_run(self, run_id: str, message: str):
157
+ """Broadcast a message to the websocket registered for this run (agent/team/workflow)"""
158
+ if run_id in self.active_connections:
159
+ websocket = self.active_connections[run_id]
160
+ try:
161
+ await websocket.send_text(message)
162
+ except Exception as e:
163
+ log_warning(f"Failed to broadcast to run {run_id}: {e}")
164
+ # Remove dead connection
165
+ await self.disconnect_by_run_id(run_id)
166
+
167
+ async def disconnect_by_run_id(self, run_id: str):
168
+ """Remove WebSocket connection by run_id"""
169
+ if run_id in self.active_connections:
170
+ websocket = self.active_connections[run_id]
171
+ del self.active_connections[run_id]
172
+ # Clean up authentication state
173
+ if websocket in self.authenticated_connections:
174
+ del self.authenticated_connections[websocket]
175
+ logger.debug(f"WebSocket disconnected for run_id: {run_id}")
176
+
177
+ async def disconnect_websocket(self, websocket: WebSocket):
178
+ """Remove WebSocket connection and clean up all associated state"""
179
+ # Remove from authenticated connections
180
+ if websocket in self.authenticated_connections:
181
+ del self.authenticated_connections[websocket]
182
+
183
+ # Remove from active connections
184
+ runs_to_remove = [run_id for run_id, ws in self.active_connections.items() if ws == websocket]
185
+ for run_id in runs_to_remove:
186
+ del self.active_connections[run_id]
187
+
188
+ logger.debug("WebSocket disconnected and cleaned up")
189
+
190
+ async def get_websocket_for_run(self, run_id: str) -> Optional[WebSocket]:
191
+ """Get WebSocket connection for a run (agent/team/workflow)"""
192
+ return self.active_connections.get(run_id)
193
+
194
+
195
+ class EventsBuffer:
196
+ """
197
+ In-memory buffer for events to support WebSocket reconnection.
198
+
199
+ Stores recent events for active runs (agents, teams, workflows), allowing clients
200
+ to catch up on missed events when reconnecting after disconnection or page refresh.
201
+
202
+ Buffers all event types: RunOutputEvent (agents), TeamRunOutputEvent (teams),
203
+ and WorkflowRunOutputEvent (workflows).
204
+ """
205
+
206
+ def __init__(self, max_events_per_run: int = 1000, cleanup_interval: int = 3600):
207
+ """
208
+ Initialize the event buffer.
209
+
210
+ Args:
211
+ max_events_per_run: Maximum number of events to store per run (prevents memory bloat)
212
+ cleanup_interval: How long (in seconds) to keep completed runs in buffer
213
+ """
214
+ # Store all event types (WorkflowRunOutputEvent, RunOutputEvent, TeamRunOutputEvent)
215
+ self.events: Dict[str, List[Union[WorkflowRunOutputEvent, RunOutputEvent, TeamRunOutputEvent]]] = {}
216
+ self.run_metadata: Dict[str, Dict[str, Any]] = {} # {run_id: {status, last_updated, etc}}
217
+ self.max_events_per_run = max_events_per_run
218
+ self.cleanup_interval = cleanup_interval
219
+
220
+ def add_event(self, run_id: str, event: Union[WorkflowRunOutputEvent, RunOutputEvent, TeamRunOutputEvent]) -> int:
221
+ """Add event to buffer for a specific run and return the event index (handles workflow, agent, and team events)"""
222
+ current_time = time()
223
+
224
+ if run_id not in self.events:
225
+ self.events[run_id] = []
226
+ self.run_metadata[run_id] = {
227
+ "status": RunStatus.running,
228
+ "created_at": current_time,
229
+ "last_updated": current_time,
230
+ }
231
+
232
+ self.events[run_id].append(event)
233
+ self.run_metadata[run_id]["last_updated"] = current_time
234
+
235
+ # Get the index of the event we just added (before potential trimming)
236
+ event_index = len(self.events[run_id]) - 1
237
+
238
+ # Keep buffer size under control - trim oldest events if exceeded
239
+ if len(self.events[run_id]) > self.max_events_per_run:
240
+ self.events[run_id] = self.events[run_id][-self.max_events_per_run :]
241
+ log_debug(f"Trimmed event buffer for run {run_id} to {self.max_events_per_run} events")
242
+
243
+ return event_index
244
+
245
+ def get_events(
246
+ self, run_id: str, last_event_index: Optional[int] = None
247
+ ) -> List[Union[WorkflowRunOutputEvent, RunOutputEvent, TeamRunOutputEvent]]:
248
+ """
249
+ Get events since the last received event index.
250
+
251
+ Args:
252
+ run_id: The run ID (agent/team/workflow)
253
+ last_event_index: Index of last event received by client (0-based)
254
+
255
+ Returns:
256
+ List of events since last_event_index, or all events if None
257
+ """
258
+ events = self.events.get(run_id, [])
259
+
260
+ if last_event_index is None:
261
+ # Client has no events, send all
262
+ return events
263
+
264
+ # Client has events up to last_event_index, send new ones
265
+ # last_event_index is 0-based, so we want events starting from index + 1
266
+ if last_event_index >= len(events) - 1:
267
+ # Client is caught up
268
+ return []
269
+
270
+ return events[last_event_index + 1 :]
271
+
272
+ def get_event_count(self, run_id: str) -> int:
273
+ """Get the current number of events for a run"""
274
+ return len(self.events.get(run_id, []))
275
+
276
+ def set_run_completed(self, run_id: str, status: RunStatus) -> None:
277
+ """Mark a run as completed/cancelled/error for future cleanup"""
278
+ if run_id in self.run_metadata:
279
+ self.run_metadata[run_id]["status"] = status
280
+ self.run_metadata[run_id]["completed_at"] = time()
281
+ log_debug(f"Marked run {run_id} as {status}")
282
+
283
+ # Trigger cleanup of old completed runs
284
+ self.cleanup_runs()
285
+
286
+ def cleanup_run(self, run_id: str) -> None:
287
+ """Remove buffer for a completed run (called after retention period)"""
288
+ if run_id in self.events:
289
+ del self.events[run_id]
290
+ if run_id in self.run_metadata:
291
+ del self.run_metadata[run_id]
292
+ log_debug(f"Cleaned up event buffer for run {run_id}")
293
+
294
+ def cleanup_runs(self) -> None:
295
+ """Clean up runs that have been completed for longer than cleanup_interval"""
296
+ current_time = time()
297
+ runs_to_cleanup = []
298
+
299
+ for run_id, metadata in self.run_metadata.items():
300
+ # Only cleanup completed runs
301
+ if metadata["status"] in [RunStatus.completed, RunStatus.error, RunStatus.cancelled]:
302
+ completed_at = metadata.get("completed_at", metadata["last_updated"])
303
+ if current_time - completed_at > self.cleanup_interval:
304
+ runs_to_cleanup.append(run_id)
305
+
306
+ for run_id in runs_to_cleanup:
307
+ self.cleanup_run(run_id)
308
+
309
+ if runs_to_cleanup:
310
+ log_debug(f"Cleaned up {len(runs_to_cleanup)} old run buffers")
311
+
312
+ def get_run_status(self, run_id: str) -> Optional[RunStatus]:
313
+ """Get the status of a run from metadata"""
314
+ metadata = self.run_metadata.get(run_id)
315
+ return metadata["status"] if metadata else None
316
+
317
+
318
+ # Global manager instances
319
+ websocket_manager = WebSocketManager(
320
+ active_connections={},
321
+ )
322
+
323
+ event_buffer = EventsBuffer(
324
+ max_events_per_run=10000, # Keep last 10000 events per run
325
+ cleanup_interval=1800, # Clean up completed runs after 30 minutes
326
+ )