agno 2.0.3__py3-none-any.whl → 2.0.5__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 (58) hide show
  1. agno/agent/agent.py +229 -164
  2. agno/db/dynamo/dynamo.py +8 -0
  3. agno/db/firestore/firestore.py +8 -0
  4. agno/db/gcs_json/gcs_json_db.py +9 -0
  5. agno/db/json/json_db.py +8 -0
  6. agno/db/migrations/v1_to_v2.py +191 -23
  7. agno/db/mongo/mongo.py +68 -0
  8. agno/db/mysql/mysql.py +13 -3
  9. agno/db/mysql/schemas.py +27 -27
  10. agno/db/postgres/postgres.py +19 -11
  11. agno/db/redis/redis.py +6 -0
  12. agno/db/singlestore/schemas.py +1 -1
  13. agno/db/singlestore/singlestore.py +8 -1
  14. agno/db/sqlite/sqlite.py +12 -3
  15. agno/integrations/discord/client.py +1 -0
  16. agno/knowledge/knowledge.py +92 -66
  17. agno/knowledge/reader/reader_factory.py +7 -3
  18. agno/knowledge/reader/web_search_reader.py +12 -6
  19. agno/models/base.py +2 -2
  20. agno/models/message.py +109 -0
  21. agno/models/openai/chat.py +3 -0
  22. agno/models/openai/responses.py +12 -0
  23. agno/models/response.py +5 -0
  24. agno/models/siliconflow/__init__.py +5 -0
  25. agno/models/siliconflow/siliconflow.py +25 -0
  26. agno/os/app.py +164 -41
  27. agno/os/auth.py +24 -14
  28. agno/os/interfaces/agui/utils.py +98 -134
  29. agno/os/router.py +128 -55
  30. agno/os/routers/evals/utils.py +9 -9
  31. agno/os/routers/health.py +25 -0
  32. agno/os/routers/home.py +52 -0
  33. agno/os/routers/knowledge/knowledge.py +11 -11
  34. agno/os/routers/session/session.py +24 -8
  35. agno/os/schema.py +29 -2
  36. agno/os/utils.py +0 -8
  37. agno/run/agent.py +3 -3
  38. agno/run/team.py +3 -3
  39. agno/run/workflow.py +64 -10
  40. agno/session/team.py +1 -0
  41. agno/team/team.py +189 -94
  42. agno/tools/duckduckgo.py +15 -11
  43. agno/tools/googlesearch.py +1 -1
  44. agno/tools/mem0.py +11 -17
  45. agno/tools/memory.py +34 -6
  46. agno/utils/common.py +90 -1
  47. agno/utils/streamlit.py +14 -8
  48. agno/utils/string.py +32 -0
  49. agno/utils/tools.py +1 -1
  50. agno/vectordb/chroma/chromadb.py +8 -2
  51. agno/workflow/step.py +115 -16
  52. agno/workflow/workflow.py +16 -13
  53. {agno-2.0.3.dist-info → agno-2.0.5.dist-info}/METADATA +6 -5
  54. {agno-2.0.3.dist-info → agno-2.0.5.dist-info}/RECORD +57 -54
  55. agno/knowledge/reader/url_reader.py +0 -128
  56. {agno-2.0.3.dist-info → agno-2.0.5.dist-info}/WHEEL +0 -0
  57. {agno-2.0.3.dist-info → agno-2.0.5.dist-info}/licenses/LICENSE +0 -0
  58. {agno-2.0.3.dist-info → agno-2.0.5.dist-info}/top_level.txt +0 -0
@@ -2,10 +2,9 @@
2
2
 
3
3
  import json
4
4
  import uuid
5
- from collections import deque
6
5
  from collections.abc import Iterator
7
6
  from dataclasses import dataclass
8
- from typing import AsyncIterator, Deque, List, Optional, Set, Tuple, Union
7
+ from typing import AsyncIterator, List, Set, Tuple, Union
9
8
 
10
9
  from ag_ui.core import (
11
10
  BaseEvent,
@@ -34,39 +33,22 @@ from agno.utils.message import get_text_from_message
34
33
  class EventBuffer:
35
34
  """Buffer to manage event ordering constraints, relevant when mapping Agno responses to AG-UI events."""
36
35
 
37
- buffer: Deque[BaseEvent]
38
- blocking_tool_call_id: Optional[str] # The tool call that's currently blocking the buffer
39
36
  active_tool_call_ids: Set[str] # All currently active tool calls
40
37
  ended_tool_call_ids: Set[str] # All tool calls that have ended
41
38
 
42
39
  def __init__(self):
43
- self.buffer = deque()
44
- self.blocking_tool_call_id = None
45
40
  self.active_tool_call_ids = set()
46
41
  self.ended_tool_call_ids = set()
47
42
 
48
- def is_blocked(self) -> bool:
49
- """Check if the buffer is currently blocked by an active tool call."""
50
- return self.blocking_tool_call_id is not None
51
-
52
43
  def start_tool_call(self, tool_call_id: str) -> None:
53
- """Start a new tool call, marking it the current blocking tool call if needed."""
44
+ """Start a new tool call."""
54
45
  self.active_tool_call_ids.add(tool_call_id)
55
- if self.blocking_tool_call_id is None:
56
- self.blocking_tool_call_id = tool_call_id
57
46
 
58
- def end_tool_call(self, tool_call_id: str) -> bool:
59
- """End a tool call, marking it as ended and unblocking the buffer if needed."""
47
+ def end_tool_call(self, tool_call_id: str) -> None:
48
+ """End a tool call."""
60
49
  self.active_tool_call_ids.discard(tool_call_id)
61
50
  self.ended_tool_call_ids.add(tool_call_id)
62
51
 
63
- # Unblock the buffer if the current blocking tool call is the one ending
64
- if tool_call_id == self.blocking_tool_call_id:
65
- self.blocking_tool_call_id = None
66
- return True
67
-
68
- return False
69
-
70
52
 
71
53
  def convert_agui_messages_to_agno_messages(messages: List[AGUIMessage]) -> List[Message]:
72
54
  """Convert AG-UI messages to Agno messages."""
@@ -169,6 +151,12 @@ def _create_events_from_chunk(
169
151
 
170
152
  # Handle starting a new tool call
171
153
  elif chunk.event == RunEvent.tool_call_started:
154
+ # End the current text message if one is active before starting tool calls
155
+ if message_started:
156
+ end_message_event = TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, message_id=message_id)
157
+ events_to_emit.append(end_message_event)
158
+ message_started = False # Reset message_started state
159
+
172
160
  if chunk.tool is not None: # type: ignore
173
161
  tool_call = chunk.tool # type: ignore
174
162
  start_event = ToolCallStartEvent(
@@ -195,7 +183,7 @@ def _create_events_from_chunk(
195
183
  type=EventType.TOOL_CALL_END,
196
184
  tool_call_id=tool_call.tool_call_id, # type: ignore
197
185
  )
198
- events_to_emit.append(end_event) # type: ignore
186
+ events_to_emit.append(end_event)
199
187
 
200
188
  if tool_call.result is not None:
201
189
  result_event = ToolCallResultEvent(
@@ -205,27 +193,17 @@ def _create_events_from_chunk(
205
193
  role="tool",
206
194
  message_id=str(uuid.uuid4()),
207
195
  )
208
- events_to_emit.append(result_event) # type: ignore
209
-
210
- if tool_call.result is not None:
211
- result_event = ToolCallResultEvent(
212
- type=EventType.TOOL_CALL_RESULT,
213
- tool_call_id=tool_call.tool_call_id, # type: ignore
214
- content=str(tool_call.result),
215
- role="tool",
216
- message_id=str(uuid.uuid4()),
217
- )
218
- events_to_emit.append(result_event) # type: ignore
196
+ events_to_emit.append(result_event)
219
197
 
220
198
  # Handle reasoning
221
199
  elif chunk.event == RunEvent.reasoning_started:
222
- step_started_event = StepStartedEvent(type=EventType.STEP_STARTED, step_name="reasoning") # type: ignore
223
- events_to_emit.append(step_started_event) # type: ignore
200
+ step_started_event = StepStartedEvent(type=EventType.STEP_STARTED, step_name="reasoning")
201
+ events_to_emit.append(step_started_event)
224
202
  elif chunk.event == RunEvent.reasoning_completed:
225
- step_started_event = StepFinishedEvent(type=EventType.STEP_FINISHED, step_name="reasoning") # type: ignore
226
- events_to_emit.append(step_started_event) # type: ignore
203
+ step_finished_event = StepFinishedEvent(type=EventType.STEP_FINISHED, step_name="reasoning")
204
+ events_to_emit.append(step_finished_event)
227
205
 
228
- return events_to_emit, message_started # type: ignore
206
+ return events_to_emit, message_started
229
207
 
230
208
 
231
209
  def _create_completion_events(
@@ -251,7 +229,7 @@ def _create_completion_events(
251
229
  # End the message and run, denoting the end of the session
252
230
  if message_started:
253
231
  end_message_event = TextMessageEndEvent(type=EventType.TEXT_MESSAGE_END, message_id=message_id)
254
- events_to_emit.append(end_message_event) # type: ignore
232
+ events_to_emit.append(end_message_event)
255
233
 
256
234
  # emit frontend tool calls, i.e. external_execution=True
257
235
  if isinstance(chunk, RunPausedEvent) and chunk.tools is not None:
@@ -265,14 +243,14 @@ def _create_completion_events(
265
243
  tool_call_name=tool.tool_name,
266
244
  parent_message_id=message_id,
267
245
  )
268
- events_to_emit.append(start_event) # type: ignore
246
+ events_to_emit.append(start_event)
269
247
 
270
248
  args_event = ToolCallArgsEvent(
271
249
  type=EventType.TOOL_CALL_ARGS,
272
250
  tool_call_id=tool.tool_call_id,
273
251
  delta=json.dumps(tool.tool_args),
274
252
  )
275
- events_to_emit.append(args_event) # type: ignore
253
+ events_to_emit.append(args_event)
276
254
 
277
255
  end_event = ToolCallEndEvent(
278
256
  type=EventType.TOOL_CALL_END,
@@ -280,85 +258,25 @@ def _create_completion_events(
280
258
  )
281
259
  events_to_emit.append(end_event)
282
260
 
283
- # emit frontend tool calls, i.e. external_execution=True
284
- if isinstance(chunk, RunPausedEvent) and chunk.tools is not None:
285
- for tool in chunk.tools:
286
- if tool.tool_call_id is None or tool.tool_name is None:
287
- continue
288
-
289
- start_event = ToolCallStartEvent(
290
- type=EventType.TOOL_CALL_START,
291
- tool_call_id=tool.tool_call_id,
292
- tool_call_name=tool.tool_name,
293
- parent_message_id=message_id,
294
- )
295
- events_to_emit.append(start_event) # type: ignore
296
-
297
- args_event = ToolCallArgsEvent(
298
- type=EventType.TOOL_CALL_ARGS,
299
- tool_call_id=tool.tool_call_id,
300
- delta=json.dumps(tool.tool_args),
301
- )
302
- events_to_emit.append(args_event) # type: ignore
303
-
304
- end_event = ToolCallEndEvent(
305
- type=EventType.TOOL_CALL_END,
306
- tool_call_id=tool.tool_call_id,
307
- )
308
- events_to_emit.append(end_event) # type: ignore
309
-
310
261
  run_finished_event = RunFinishedEvent(type=EventType.RUN_FINISHED, thread_id=thread_id, run_id=run_id)
311
- events_to_emit.append(run_finished_event) # type: ignore
262
+ events_to_emit.append(run_finished_event)
312
263
 
313
- return events_to_emit # type: ignore
264
+ return events_to_emit
314
265
 
315
266
 
316
267
  def _emit_event_logic(event: BaseEvent, event_buffer: EventBuffer) -> List[BaseEvent]:
317
- """Process an event through the buffer and return events to actually emit."""
318
- events_to_emit: List[BaseEvent] = []
319
-
320
- if event_buffer.is_blocked():
321
- # Handle events related to the current blocking tool call
322
- if event.type == EventType.TOOL_CALL_ARGS:
323
- if hasattr(event, "tool_call_id") and event.tool_call_id in event_buffer.active_tool_call_ids: # type: ignore
324
- events_to_emit.append(event)
325
- else:
326
- event_buffer.buffer.append(event)
327
- elif event.type == EventType.TOOL_CALL_END:
328
- tool_call_id = getattr(event, "tool_call_id", None)
329
- if tool_call_id and tool_call_id == event_buffer.blocking_tool_call_id:
330
- events_to_emit.append(event)
331
- event_buffer.end_tool_call(tool_call_id)
332
- # Flush buffered events after ending the blocking tool call
333
- while event_buffer.buffer:
334
- buffered_event = event_buffer.buffer.popleft()
335
- # Recursively process buffered events
336
- nested_events = _emit_event_logic(buffered_event, event_buffer)
337
- events_to_emit.extend(nested_events)
338
- elif tool_call_id and tool_call_id in event_buffer.active_tool_call_ids:
339
- event_buffer.buffer.append(event)
340
- event_buffer.end_tool_call(tool_call_id)
341
- else:
342
- event_buffer.buffer.append(event)
343
- # Handle all other events
344
- elif event.type == EventType.TOOL_CALL_START:
345
- event_buffer.buffer.append(event)
346
- else:
347
- event_buffer.buffer.append(event)
348
- # If the buffer is not blocked, emit the events normally
349
- else:
350
- if event.type == EventType.TOOL_CALL_START:
351
- tool_call_id = getattr(event, "tool_call_id", None)
352
- if tool_call_id:
353
- event_buffer.start_tool_call(tool_call_id)
354
- events_to_emit.append(event)
355
- elif event.type == EventType.TOOL_CALL_END:
356
- tool_call_id = getattr(event, "tool_call_id", None)
357
- if tool_call_id:
358
- event_buffer.end_tool_call(tool_call_id)
359
- events_to_emit.append(event)
360
- else:
361
- events_to_emit.append(event)
268
+ """Process an event and return events to actually emit."""
269
+ events_to_emit: List[BaseEvent] = [event]
270
+
271
+ # Update the event buffer state for tracking purposes
272
+ if event.type == EventType.TOOL_CALL_START:
273
+ tool_call_id = getattr(event, "tool_call_id", None)
274
+ if tool_call_id:
275
+ event_buffer.start_tool_call(tool_call_id)
276
+ elif event.type == EventType.TOOL_CALL_END:
277
+ tool_call_id = getattr(event, "tool_call_id", None)
278
+ if tool_call_id:
279
+ event_buffer.end_tool_call(tool_call_id)
362
280
 
363
281
  return events_to_emit
364
282
 
@@ -370,23 +288,22 @@ def stream_agno_response_as_agui_events(
370
288
  message_id = str(uuid.uuid4())
371
289
  message_started = False
372
290
  event_buffer = EventBuffer()
291
+ stream_completed = False
292
+
293
+ completion_chunk = None
373
294
 
374
295
  for chunk in response_stream:
375
- # Handle the lifecycle end event
296
+ # Check if this is a completion event
376
297
  if (
377
298
  chunk.event == RunEvent.run_completed
378
299
  or chunk.event == TeamRunEvent.run_completed
379
300
  or chunk.event == RunEvent.run_paused
380
301
  ):
381
- completion_events = _create_completion_events(
382
- chunk, event_buffer, message_started, message_id, thread_id, run_id
383
- )
384
- for event in completion_events:
385
- events_to_emit = _emit_event_logic(event_buffer=event_buffer, event=event)
386
- for emit_event in events_to_emit:
387
- yield emit_event
302
+ # Store completion chunk but don't process it yet
303
+ completion_chunk = chunk
304
+ stream_completed = True
388
305
  else:
389
- # Process regular chunk
306
+ # Process regular chunk immediately
390
307
  events_from_chunk, message_started = _create_events_from_chunk(
391
308
  chunk, message_id, message_started, event_buffer
392
309
  )
@@ -396,6 +313,30 @@ def stream_agno_response_as_agui_events(
396
313
  for emit_event in events_to_emit:
397
314
  yield emit_event
398
315
 
316
+ # Process ONLY completion cleanup events, not content from completion chunk
317
+ if completion_chunk:
318
+ completion_events = _create_completion_events(
319
+ completion_chunk, event_buffer, message_started, message_id, thread_id, run_id
320
+ )
321
+ for event in completion_events:
322
+ events_to_emit = _emit_event_logic(event_buffer=event_buffer, event=event)
323
+ for emit_event in events_to_emit:
324
+ yield emit_event
325
+
326
+ # Ensure completion events are always emitted even when stream ends naturally
327
+ if not stream_completed:
328
+ # Create a synthetic completion event to ensure proper cleanup
329
+ from agno.run.agent import RunCompletedEvent
330
+
331
+ synthetic_completion = RunCompletedEvent()
332
+ completion_events = _create_completion_events(
333
+ synthetic_completion, event_buffer, message_started, message_id, thread_id, run_id
334
+ )
335
+ for event in completion_events:
336
+ events_to_emit = _emit_event_logic(event_buffer=event_buffer, event=event)
337
+ for emit_event in events_to_emit:
338
+ yield emit_event
339
+
399
340
 
400
341
  # Async version - thin wrapper
401
342
  async def async_stream_agno_response_as_agui_events(
@@ -407,23 +348,22 @@ async def async_stream_agno_response_as_agui_events(
407
348
  message_id = str(uuid.uuid4())
408
349
  message_started = False
409
350
  event_buffer = EventBuffer()
351
+ stream_completed = False
352
+
353
+ completion_chunk = None
410
354
 
411
355
  async for chunk in response_stream:
412
- # Handle the lifecycle end event
356
+ # Check if this is a completion event
413
357
  if (
414
358
  chunk.event == RunEvent.run_completed
415
359
  or chunk.event == TeamRunEvent.run_completed
416
360
  or chunk.event == RunEvent.run_paused
417
361
  ):
418
- completion_events = _create_completion_events(
419
- chunk, event_buffer, message_started, message_id, thread_id, run_id
420
- )
421
- for event in completion_events:
422
- events_to_emit = _emit_event_logic(event_buffer=event_buffer, event=event)
423
- for emit_event in events_to_emit:
424
- yield emit_event
362
+ # Store completion chunk but don't process it yet
363
+ completion_chunk = chunk
364
+ stream_completed = True
425
365
  else:
426
- # Process regular chunk
366
+ # Process regular chunk immediately
427
367
  events_from_chunk, message_started = _create_events_from_chunk(
428
368
  chunk, message_id, message_started, event_buffer
429
369
  )
@@ -432,3 +372,27 @@ async def async_stream_agno_response_as_agui_events(
432
372
  events_to_emit = _emit_event_logic(event_buffer=event_buffer, event=event)
433
373
  for emit_event in events_to_emit:
434
374
  yield emit_event
375
+
376
+ # Process ONLY completion cleanup events, not content from completion chunk
377
+ if completion_chunk:
378
+ completion_events = _create_completion_events(
379
+ completion_chunk, event_buffer, message_started, message_id, thread_id, run_id
380
+ )
381
+ for event in completion_events:
382
+ events_to_emit = _emit_event_logic(event_buffer=event_buffer, event=event)
383
+ for emit_event in events_to_emit:
384
+ yield emit_event
385
+
386
+ # Ensure completion events are always emitted even when stream ends naturally
387
+ if not stream_completed:
388
+ # Create a synthetic completion event to ensure proper cleanup
389
+ from agno.run.agent import RunCompletedEvent
390
+
391
+ synthetic_completion = RunCompletedEvent()
392
+ completion_events = _create_completion_events(
393
+ synthetic_completion, event_buffer, message_started, message_id, thread_id, run_id
394
+ )
395
+ for event in completion_events:
396
+ events_to_emit = _emit_event_logic(event_buffer=event_buffer, event=event)
397
+ for emit_event in events_to_emit:
398
+ yield emit_event
agno/os/router.py CHANGED
@@ -18,13 +18,12 @@ from pydantic import BaseModel
18
18
  from agno.agent.agent import Agent
19
19
  from agno.media import Audio, Image, Video
20
20
  from agno.media import File as FileMedia
21
- from agno.os.auth import get_authentication_dependency
21
+ from agno.os.auth import get_authentication_dependency, validate_websocket_token
22
22
  from agno.os.schema import (
23
23
  AgentResponse,
24
24
  AgentSummaryResponse,
25
25
  BadRequestResponse,
26
26
  ConfigResponse,
27
- HealthResponse,
28
27
  InterfaceResponse,
29
28
  InternalServerErrorResponse,
30
29
  Model,
@@ -49,7 +48,7 @@ from agno.os.utils import (
49
48
  from agno.run.agent import RunErrorEvent, RunOutput, RunOutputEvent
50
49
  from agno.run.team import RunErrorEvent as TeamRunErrorEvent
51
50
  from agno.run.team import TeamRunOutputEvent
52
- from agno.run.workflow import WorkflowErrorEvent, WorkflowRunOutputEvent
51
+ from agno.run.workflow import WorkflowErrorEvent, WorkflowRunOutput, WorkflowRunOutputEvent
53
52
  from agno.team.team import Team
54
53
  from agno.utils.log import log_debug, log_error, log_warning, logger
55
54
  from agno.workflow.workflow import Workflow
@@ -110,6 +109,7 @@ class WebSocketManager:
110
109
  """Manages WebSocket connections for workflow runs"""
111
110
 
112
111
  active_connections: Dict[str, WebSocket] # {run_id: websocket}
112
+ authenticated_connections: Dict[WebSocket, bool] # {websocket: is_authenticated}
113
113
 
114
114
  def __init__(
115
115
  self,
@@ -117,22 +117,51 @@ class WebSocketManager:
117
117
  ):
118
118
  # Store active connections: {run_id: websocket}
119
119
  self.active_connections = active_connections or {}
120
+ # Track authentication state for each websocket
121
+ self.authenticated_connections = {}
120
122
 
121
- async def connect(self, websocket: WebSocket):
123
+ async def connect(self, websocket: WebSocket, requires_auth: bool = True):
122
124
  """Accept WebSocket connection"""
123
125
  await websocket.accept()
124
126
  logger.debug("WebSocket connected")
125
127
 
126
- # Send connection confirmation
128
+ # If auth is not required, mark as authenticated immediately
129
+ self.authenticated_connections[websocket] = not requires_auth
130
+
131
+ # Send connection confirmation with auth requirement info
127
132
  await websocket.send_text(
128
133
  json.dumps(
129
134
  {
130
135
  "event": "connected",
131
- "message": "Connected to workflow events",
136
+ "message": (
137
+ "Connected to workflow events. Please authenticate to continue."
138
+ if requires_auth
139
+ else "Connected to workflow events. Authentication not required."
140
+ ),
141
+ "requires_auth": requires_auth,
132
142
  }
133
143
  )
134
144
  )
135
145
 
146
+ async def authenticate_websocket(self, websocket: WebSocket):
147
+ """Mark a WebSocket connection as authenticated"""
148
+ self.authenticated_connections[websocket] = True
149
+ logger.debug("WebSocket authenticated")
150
+
151
+ # Send authentication confirmation
152
+ await websocket.send_text(
153
+ json.dumps(
154
+ {
155
+ "event": "authenticated",
156
+ "message": "Authentication successful. You can now send commands.",
157
+ }
158
+ )
159
+ )
160
+
161
+ def is_authenticated(self, websocket: WebSocket) -> bool:
162
+ """Check if a WebSocket connection is authenticated"""
163
+ return self.authenticated_connections.get(websocket, False)
164
+
136
165
  async def register_workflow_websocket(self, run_id: str, websocket: WebSocket):
137
166
  """Register a workflow run with its WebSocket connection"""
138
167
  self.active_connections[run_id] = websocket
@@ -141,9 +170,26 @@ class WebSocketManager:
141
170
  async def disconnect_by_run_id(self, run_id: str):
142
171
  """Remove WebSocket connection by run_id"""
143
172
  if run_id in self.active_connections:
173
+ websocket = self.active_connections[run_id]
144
174
  del self.active_connections[run_id]
175
+ # Clean up authentication state
176
+ if websocket in self.authenticated_connections:
177
+ del self.authenticated_connections[websocket]
145
178
  logger.debug(f"WebSocket disconnected for run_id: {run_id}")
146
179
 
180
+ async def disconnect_websocket(self, websocket: WebSocket):
181
+ """Remove WebSocket connection and clean up all associated state"""
182
+ # Remove from authenticated connections
183
+ if websocket in self.authenticated_connections:
184
+ del self.authenticated_connections[websocket]
185
+
186
+ # Remove from active connections
187
+ runs_to_remove = [run_id for run_id, ws in self.active_connections.items() if ws == websocket]
188
+ for run_id in runs_to_remove:
189
+ del self.active_connections[run_id]
190
+
191
+ logger.debug("WebSocket disconnected and cleaned up")
192
+
147
193
  async def get_websocket_for_run(self, run_id: str) -> Optional[WebSocket]:
148
194
  """Get WebSocket connection for a workflow run"""
149
195
  return self.active_connections.get(run_id)
@@ -288,7 +334,7 @@ async def handle_workflow_via_websocket(websocket: WebSocket, message: dict, os:
288
334
  session_id = str(uuid4())
289
335
 
290
336
  # Execute workflow in background with streaming
291
- await workflow.arun(
337
+ workflow_result = await workflow.arun(
292
338
  input=user_message,
293
339
  session_id=session_id,
294
340
  user_id=user_id,
@@ -298,6 +344,10 @@ async def handle_workflow_via_websocket(websocket: WebSocket, message: dict, os:
298
344
  websocket=websocket,
299
345
  )
300
346
 
347
+ workflow_run_output = cast(WorkflowRunOutput, workflow_result)
348
+
349
+ await websocket_manager.register_workflow_websocket(workflow_run_output.run_id, websocket) # type: ignore
350
+
301
351
  except Exception as e:
302
352
  logger.error(f"Error executing workflow via WebSocket: {e}")
303
353
  await websocket.send_text(json.dumps({"event": "error", "error": str(e)}))
@@ -334,6 +384,77 @@ async def workflow_response_streamer(
334
384
  return
335
385
 
336
386
 
387
+ def get_websocket_router(
388
+ os: "AgentOS",
389
+ settings: AgnoAPISettings = AgnoAPISettings(),
390
+ ) -> APIRouter:
391
+ """
392
+ Create WebSocket router without HTTP authentication dependencies.
393
+ WebSocket endpoints handle authentication internally via message-based auth.
394
+ """
395
+ ws_router = APIRouter()
396
+
397
+ @ws_router.websocket(
398
+ "/workflows/ws",
399
+ name="workflow_websocket",
400
+ )
401
+ async def workflow_websocket_endpoint(websocket: WebSocket):
402
+ """WebSocket endpoint for receiving real-time workflow events"""
403
+ requires_auth = bool(settings.os_security_key)
404
+ await websocket_manager.connect(websocket, requires_auth=requires_auth)
405
+
406
+ try:
407
+ while True:
408
+ data = await websocket.receive_text()
409
+ message = json.loads(data)
410
+ action = message.get("action")
411
+
412
+ # Handle authentication first
413
+ if action == "authenticate":
414
+ token = message.get("token")
415
+ if not token:
416
+ await websocket.send_text(json.dumps({"event": "auth_error", "error": "Token is required"}))
417
+ continue
418
+
419
+ if validate_websocket_token(token, settings):
420
+ await websocket_manager.authenticate_websocket(websocket)
421
+ else:
422
+ await websocket.send_text(json.dumps({"event": "auth_error", "error": "Invalid token"}))
423
+ continue
424
+
425
+ # Check authentication for all other actions (only when required)
426
+ elif requires_auth and not websocket_manager.is_authenticated(websocket):
427
+ await websocket.send_text(
428
+ json.dumps(
429
+ {
430
+ "event": "auth_required",
431
+ "error": "Authentication required. Send authenticate action with valid token.",
432
+ }
433
+ )
434
+ )
435
+ continue
436
+
437
+ # Handle authenticated actions
438
+ elif action == "ping":
439
+ await websocket.send_text(json.dumps({"event": "pong"}))
440
+
441
+ elif action == "start-workflow":
442
+ # Handle workflow execution directly via WebSocket
443
+ await handle_workflow_via_websocket(websocket, message, os)
444
+
445
+ else:
446
+ await websocket.send_text(json.dumps({"event": "error", "error": f"Unknown action: {action}"}))
447
+
448
+ except Exception as e:
449
+ if "1012" not in str(e):
450
+ logger.error(f"WebSocket error: {e}")
451
+ finally:
452
+ # Clean up the websocket connection
453
+ await websocket_manager.disconnect_websocket(websocket)
454
+
455
+ return ws_router
456
+
457
+
337
458
  def get_base_router(
338
459
  os: "AgentOS",
339
460
  settings: AgnoAPISettings = AgnoAPISettings(),
@@ -346,7 +467,6 @@ def get_base_router(
346
467
  - Agent management and execution
347
468
  - Team collaboration and coordination
348
469
  - Workflow automation and orchestration
349
- - Real-time WebSocket communications
350
470
 
351
471
  All endpoints include detailed documentation, examples, and proper error handling.
352
472
  """
@@ -362,24 +482,6 @@ def get_base_router(
362
482
  )
363
483
 
364
484
  # -- Main Routes ---
365
-
366
- @router.get(
367
- "/health",
368
- tags=["Core"],
369
- operation_id="health_check",
370
- summary="Health Check",
371
- description="Check the health status of the AgentOS API. Returns a simple status indicator.",
372
- response_model=HealthResponse,
373
- responses={
374
- 200: {
375
- "description": "API is healthy and operational",
376
- "content": {"application/json": {"example": {"status": "ok"}}},
377
- }
378
- },
379
- )
380
- async def health_check() -> HealthResponse:
381
- return HealthResponse(status="ok")
382
-
383
485
  @router.get(
384
486
  "/config",
385
487
  response_model=ConfigResponse,
@@ -1228,35 +1330,6 @@ def get_base_router(
1228
1330
 
1229
1331
  # -- Workflow routes ---
1230
1332
 
1231
- @router.websocket(
1232
- "/workflows/ws",
1233
- name="workflow_websocket",
1234
- )
1235
- async def workflow_websocket_endpoint(websocket: WebSocket):
1236
- """WebSocket endpoint for receiving real-time workflow events"""
1237
- await websocket_manager.connect(websocket)
1238
-
1239
- try:
1240
- while True:
1241
- data = await websocket.receive_text()
1242
- message = json.loads(data)
1243
- action = message.get("action")
1244
-
1245
- if action == "ping":
1246
- await websocket.send_text(json.dumps({"event": "pong"}))
1247
-
1248
- elif action == "start-workflow":
1249
- # Handle workflow execution directly via WebSocket
1250
- await handle_workflow_via_websocket(websocket, message, os)
1251
- except Exception as e:
1252
- if "1012" not in str(e):
1253
- logger.error(f"WebSocket error: {e}")
1254
- finally:
1255
- # Clean up any run_ids associated with this websocket
1256
- runs_to_remove = [run_id for run_id, ws in websocket_manager.active_connections.items() if ws == websocket]
1257
- for run_id in runs_to_remove:
1258
- await websocket_manager.disconnect_by_run_id(run_id)
1259
-
1260
1333
  @router.get(
1261
1334
  "/workflows",
1262
1335
  response_model=List[WorkflowSummaryResponse],