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.
- agno/agent/__init__.py +2 -0
- agno/agent/agent.py +4 -53
- agno/agent/remote.py +351 -0
- agno/client/__init__.py +3 -0
- agno/client/os.py +2669 -0
- agno/db/base.py +20 -0
- agno/db/mongo/async_mongo.py +11 -0
- agno/db/mongo/mongo.py +10 -0
- agno/db/mysql/async_mysql.py +9 -0
- agno/db/mysql/mysql.py +9 -0
- agno/db/postgres/async_postgres.py +9 -0
- agno/db/postgres/postgres.py +9 -0
- agno/db/postgres/utils.py +3 -2
- agno/db/sqlite/async_sqlite.py +9 -0
- agno/db/sqlite/sqlite.py +11 -1
- agno/exceptions.py +23 -0
- agno/knowledge/chunking/semantic.py +123 -46
- agno/knowledge/reader/csv_reader.py +1 -1
- agno/knowledge/reader/field_labeled_csv_reader.py +1 -1
- agno/knowledge/reader/json_reader.py +1 -1
- agno/models/google/gemini.py +5 -0
- agno/os/app.py +108 -25
- agno/os/auth.py +25 -1
- agno/os/interfaces/a2a/a2a.py +7 -6
- agno/os/interfaces/a2a/router.py +13 -13
- agno/os/interfaces/agui/agui.py +5 -3
- agno/os/interfaces/agui/router.py +23 -16
- agno/os/interfaces/base.py +7 -7
- agno/os/interfaces/slack/router.py +6 -6
- agno/os/interfaces/slack/slack.py +7 -7
- agno/os/interfaces/whatsapp/router.py +29 -6
- agno/os/interfaces/whatsapp/whatsapp.py +11 -8
- agno/os/managers.py +326 -0
- agno/os/mcp.py +651 -79
- agno/os/router.py +125 -18
- agno/os/routers/agents/router.py +65 -22
- agno/os/routers/agents/schema.py +16 -4
- agno/os/routers/database.py +5 -0
- agno/os/routers/evals/evals.py +93 -11
- agno/os/routers/evals/utils.py +6 -6
- agno/os/routers/knowledge/knowledge.py +104 -16
- agno/os/routers/memory/memory.py +124 -7
- agno/os/routers/metrics/metrics.py +21 -4
- agno/os/routers/session/session.py +141 -12
- agno/os/routers/teams/router.py +40 -14
- agno/os/routers/teams/schema.py +12 -4
- agno/os/routers/traces/traces.py +54 -4
- agno/os/routers/workflows/router.py +223 -117
- agno/os/routers/workflows/schema.py +65 -1
- agno/os/schema.py +38 -12
- agno/os/utils.py +87 -166
- agno/remote/__init__.py +3 -0
- agno/remote/base.py +484 -0
- agno/run/workflow.py +1 -0
- agno/team/__init__.py +2 -0
- agno/team/remote.py +287 -0
- agno/team/team.py +25 -54
- agno/tracing/exporter.py +10 -6
- agno/tracing/setup.py +2 -1
- agno/utils/agent.py +58 -1
- agno/utils/http.py +68 -20
- agno/utils/os.py +0 -0
- agno/utils/remote.py +23 -0
- agno/vectordb/chroma/chromadb.py +452 -16
- agno/vectordb/pgvector/pgvector.py +7 -0
- agno/vectordb/redis/redisdb.py +1 -1
- agno/workflow/__init__.py +2 -0
- agno/workflow/agent.py +2 -2
- agno/workflow/remote.py +222 -0
- agno/workflow/types.py +0 -73
- agno/workflow/workflow.py +119 -68
- {agno-2.3.16.dist-info → agno-2.3.18.dist-info}/METADATA +1 -1
- {agno-2.3.16.dist-info → agno-2.3.18.dist-info}/RECORD +76 -66
- {agno-2.3.16.dist-info → agno-2.3.18.dist-info}/WHEEL +0 -0
- {agno-2.3.16.dist-info → agno-2.3.18.dist-info}/licenses/LICENSE +0 -0
- {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(
|
|
19
|
-
|
|
20
|
-
|
|
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(
|
|
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
|
|
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
|
+
)
|