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.
- agno/agent/agent.py +229 -164
- agno/db/dynamo/dynamo.py +8 -0
- agno/db/firestore/firestore.py +8 -0
- agno/db/gcs_json/gcs_json_db.py +9 -0
- agno/db/json/json_db.py +8 -0
- agno/db/migrations/v1_to_v2.py +191 -23
- agno/db/mongo/mongo.py +68 -0
- agno/db/mysql/mysql.py +13 -3
- agno/db/mysql/schemas.py +27 -27
- agno/db/postgres/postgres.py +19 -11
- agno/db/redis/redis.py +6 -0
- agno/db/singlestore/schemas.py +1 -1
- agno/db/singlestore/singlestore.py +8 -1
- agno/db/sqlite/sqlite.py +12 -3
- agno/integrations/discord/client.py +1 -0
- agno/knowledge/knowledge.py +92 -66
- agno/knowledge/reader/reader_factory.py +7 -3
- agno/knowledge/reader/web_search_reader.py +12 -6
- agno/models/base.py +2 -2
- agno/models/message.py +109 -0
- agno/models/openai/chat.py +3 -0
- agno/models/openai/responses.py +12 -0
- agno/models/response.py +5 -0
- agno/models/siliconflow/__init__.py +5 -0
- agno/models/siliconflow/siliconflow.py +25 -0
- agno/os/app.py +164 -41
- agno/os/auth.py +24 -14
- agno/os/interfaces/agui/utils.py +98 -134
- agno/os/router.py +128 -55
- agno/os/routers/evals/utils.py +9 -9
- agno/os/routers/health.py +25 -0
- agno/os/routers/home.py +52 -0
- agno/os/routers/knowledge/knowledge.py +11 -11
- agno/os/routers/session/session.py +24 -8
- agno/os/schema.py +29 -2
- agno/os/utils.py +0 -8
- agno/run/agent.py +3 -3
- agno/run/team.py +3 -3
- agno/run/workflow.py +64 -10
- agno/session/team.py +1 -0
- agno/team/team.py +189 -94
- agno/tools/duckduckgo.py +15 -11
- agno/tools/googlesearch.py +1 -1
- agno/tools/mem0.py +11 -17
- agno/tools/memory.py +34 -6
- agno/utils/common.py +90 -1
- agno/utils/streamlit.py +14 -8
- agno/utils/string.py +32 -0
- agno/utils/tools.py +1 -1
- agno/vectordb/chroma/chromadb.py +8 -2
- agno/workflow/step.py +115 -16
- agno/workflow/workflow.py +16 -13
- {agno-2.0.3.dist-info → agno-2.0.5.dist-info}/METADATA +6 -5
- {agno-2.0.3.dist-info → agno-2.0.5.dist-info}/RECORD +57 -54
- agno/knowledge/reader/url_reader.py +0 -128
- {agno-2.0.3.dist-info → agno-2.0.5.dist-info}/WHEEL +0 -0
- {agno-2.0.3.dist-info → agno-2.0.5.dist-info}/licenses/LICENSE +0 -0
- {agno-2.0.3.dist-info → agno-2.0.5.dist-info}/top_level.txt +0 -0
agno/os/interfaces/agui/utils.py
CHANGED
|
@@ -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,
|
|
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
|
|
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) ->
|
|
59
|
-
"""End a tool call
|
|
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)
|
|
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)
|
|
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")
|
|
223
|
-
events_to_emit.append(step_started_event)
|
|
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
|
-
|
|
226
|
-
events_to_emit.append(
|
|
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
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
262
|
+
events_to_emit.append(run_finished_event)
|
|
312
263
|
|
|
313
|
-
return events_to_emit
|
|
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
|
|
318
|
-
events_to_emit: List[BaseEvent] = []
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
419
|
-
|
|
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
|
-
#
|
|
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":
|
|
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],
|