agno 2.0.4__py3-none-any.whl → 2.0.6__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/agent.py +127 -102
  2. agno/db/dynamo/dynamo.py +9 -7
  3. agno/db/firestore/firestore.py +7 -4
  4. agno/db/gcs_json/gcs_json_db.py +6 -4
  5. agno/db/json/json_db.py +10 -6
  6. agno/db/migrations/v1_to_v2.py +191 -23
  7. agno/db/mongo/mongo.py +67 -6
  8. agno/db/mysql/mysql.py +7 -6
  9. agno/db/mysql/schemas.py +27 -27
  10. agno/db/postgres/postgres.py +7 -6
  11. agno/db/redis/redis.py +3 -3
  12. agno/db/singlestore/singlestore.py +4 -4
  13. agno/db/sqlite/sqlite.py +7 -6
  14. agno/db/utils.py +0 -14
  15. agno/integrations/discord/client.py +1 -0
  16. agno/knowledge/embedder/openai.py +19 -11
  17. agno/knowledge/knowledge.py +11 -10
  18. agno/knowledge/reader/reader_factory.py +7 -3
  19. agno/knowledge/reader/web_search_reader.py +12 -6
  20. agno/knowledge/reader/website_reader.py +33 -16
  21. agno/media.py +70 -0
  22. agno/models/aimlapi/aimlapi.py +2 -2
  23. agno/models/base.py +31 -4
  24. agno/models/cerebras/cerebras_openai.py +2 -2
  25. agno/models/deepinfra/deepinfra.py +2 -2
  26. agno/models/deepseek/deepseek.py +2 -2
  27. agno/models/fireworks/fireworks.py +2 -2
  28. agno/models/internlm/internlm.py +2 -2
  29. agno/models/langdb/langdb.py +4 -4
  30. agno/models/litellm/litellm_openai.py +2 -2
  31. agno/models/message.py +135 -0
  32. agno/models/meta/llama_openai.py +2 -2
  33. agno/models/nebius/nebius.py +2 -2
  34. agno/models/nexus/__init__.py +3 -0
  35. agno/models/nexus/nexus.py +25 -0
  36. agno/models/nvidia/nvidia.py +2 -2
  37. agno/models/openai/responses.py +6 -0
  38. agno/models/openrouter/openrouter.py +2 -2
  39. agno/models/perplexity/perplexity.py +2 -2
  40. agno/models/portkey/portkey.py +3 -3
  41. agno/models/response.py +2 -1
  42. agno/models/sambanova/sambanova.py +2 -2
  43. agno/models/together/together.py +2 -2
  44. agno/models/vercel/v0.py +2 -2
  45. agno/models/xai/xai.py +2 -2
  46. agno/os/app.py +162 -42
  47. agno/os/interfaces/agui/utils.py +98 -134
  48. agno/os/router.py +3 -1
  49. agno/os/routers/health.py +0 -1
  50. agno/os/routers/home.py +52 -0
  51. agno/os/routers/knowledge/knowledge.py +2 -2
  52. agno/os/schema.py +21 -0
  53. agno/os/utils.py +1 -9
  54. agno/run/agent.py +19 -3
  55. agno/run/team.py +18 -3
  56. agno/run/workflow.py +10 -0
  57. agno/team/team.py +70 -45
  58. agno/tools/duckduckgo.py +15 -11
  59. agno/tools/e2b.py +14 -7
  60. agno/tools/file_generation.py +350 -0
  61. agno/tools/function.py +2 -0
  62. agno/tools/googlesearch.py +1 -1
  63. agno/utils/gemini.py +24 -4
  64. agno/utils/string.py +32 -0
  65. agno/utils/tools.py +1 -1
  66. agno/vectordb/chroma/chromadb.py +66 -25
  67. agno/vectordb/lancedb/lance_db.py +15 -4
  68. agno/vectordb/milvus/milvus.py +6 -0
  69. agno/workflow/step.py +4 -3
  70. agno/workflow/workflow.py +4 -0
  71. {agno-2.0.4.dist-info → agno-2.0.6.dist-info}/METADATA +9 -5
  72. {agno-2.0.4.dist-info → agno-2.0.6.dist-info}/RECORD +75 -72
  73. agno/knowledge/reader/url_reader.py +0 -128
  74. {agno-2.0.4.dist-info → agno-2.0.6.dist-info}/WHEEL +0 -0
  75. {agno-2.0.4.dist-info → agno-2.0.6.dist-info}/licenses/LICENSE +0 -0
  76. {agno-2.0.4.dist-info → agno-2.0.6.dist-info}/top_level.txt +0 -0
agno/os/app.py CHANGED
@@ -1,11 +1,12 @@
1
1
  from contextlib import asynccontextmanager
2
2
  from functools import partial
3
3
  from os import getenv
4
- from typing import Any, Dict, List, Optional, Union
4
+ from typing import Any, Dict, List, Optional, Set, Union
5
5
  from uuid import uuid4
6
6
 
7
- from fastapi import FastAPI, HTTPException
7
+ from fastapi import APIRouter, FastAPI, HTTPException
8
8
  from fastapi.responses import JSONResponse
9
+ from fastapi.routing import APIRoute
9
10
  from rich import box
10
11
  from rich.panel import Panel
11
12
  from starlette.middleware.cors import CORSMiddleware
@@ -30,13 +31,15 @@ from agno.os.interfaces.base import BaseInterface
30
31
  from agno.os.router import get_base_router, get_websocket_router
31
32
  from agno.os.routers.evals import get_eval_router
32
33
  from agno.os.routers.health import get_health_router
34
+ from agno.os.routers.home import get_home_router
33
35
  from agno.os.routers.knowledge import get_knowledge_router
34
36
  from agno.os.routers.memory import get_memory_router
35
37
  from agno.os.routers.metrics import get_metrics_router
36
38
  from agno.os.routers.session import get_session_router
37
39
  from agno.os.settings import AgnoAPISettings
38
- from agno.os.utils import generate_id
39
40
  from agno.team.team import Team
41
+ from agno.utils.log import logger
42
+ from agno.utils.string import generate_id, generate_id_from_name
40
43
  from agno.workflow.workflow import Workflow
41
44
 
42
45
 
@@ -70,8 +73,30 @@ class AgentOS:
70
73
  fastapi_app: Optional[FastAPI] = None,
71
74
  lifespan: Optional[Any] = None,
72
75
  enable_mcp: bool = False,
76
+ replace_routes: bool = True,
73
77
  telemetry: bool = True,
74
78
  ):
79
+ """Initialize AgentOS.
80
+
81
+ Args:
82
+ os_id: Unique identifier for this AgentOS instance
83
+ name: Name of the AgentOS instance
84
+ description: Description of the AgentOS instance
85
+ version: Version of the AgentOS instance
86
+ agents: List of agents to include in the OS
87
+ teams: List of teams to include in the OS
88
+ workflows: List of workflows to include in the OS
89
+ interfaces: List of interfaces to include in the OS
90
+ config: Configuration file path or AgentOSConfig instance
91
+ settings: API settings for the OS
92
+ fastapi_app: Optional custom FastAPI app to use instead of creating a new one
93
+ lifespan: Optional lifespan context manager for the FastAPI app
94
+ enable_mcp: Whether to enable MCP (Model Context Protocol)
95
+ replace_routes: If False and using a custom fastapi_app, skip AgentOS routes that
96
+ conflict with existing routes, preferring the user's custom routes.
97
+ If True (default), AgentOS routes will override conflicting custom routes.
98
+ telemetry: Whether to enable telemetry
99
+ """
75
100
  if not agents and not workflows and not teams:
76
101
  raise ValueError("Either agents, teams or workflows must be provided.")
77
102
 
@@ -92,11 +117,13 @@ class AgentOS:
92
117
 
93
118
  self.interfaces = interfaces or []
94
119
 
95
- self.os_id: Optional[str] = os_id
120
+ self.os_id = os_id
96
121
  self.name = name
97
122
  self.version = version
98
123
  self.description = description
99
124
 
125
+ self.replace_routes = replace_routes
126
+
100
127
  self.telemetry = telemetry
101
128
 
102
129
  self.enable_mcp = enable_mcp
@@ -146,7 +173,10 @@ class AgentOS:
146
173
  for workflow in self.workflows:
147
174
  # TODO: track MCP tools in workflow members
148
175
  if not workflow.id:
149
- workflow.id = generate_id(workflow.name)
176
+ workflow.id = generate_id_from_name(workflow.name)
177
+
178
+ if not self.os_id:
179
+ self.os_id = generate_id(self.name) if self.name else str(uuid4())
150
180
 
151
181
  if self.telemetry:
152
182
  from agno.api.os import OSLaunch, log_os_telemetry
@@ -207,18 +237,29 @@ class AgentOS:
207
237
  else:
208
238
  self.fastapi_app = self._make_app(lifespan=self.lifespan)
209
239
 
210
- # Add routes
211
- self.fastapi_app.include_router(get_base_router(self, settings=self.settings))
212
- self.fastapi_app.include_router(get_websocket_router(self, settings=self.settings))
213
- self.fastapi_app.include_router(get_health_router())
240
+ # Add routes with conflict detection
241
+ self._add_router(get_base_router(self, settings=self.settings))
242
+ self._add_router(get_websocket_router(self, settings=self.settings))
243
+ self._add_router(get_health_router())
244
+ self._add_router(get_home_router(self))
214
245
 
215
246
  for interface in self.interfaces:
216
247
  interface_router = interface.get_router()
217
- self.fastapi_app.include_router(interface_router)
248
+ self._add_router(interface_router)
218
249
 
219
250
  self._auto_discover_databases()
220
251
  self._auto_discover_knowledge_instances()
221
- self._setup_routers()
252
+
253
+ routers = [
254
+ get_session_router(dbs=self.dbs),
255
+ get_memory_router(dbs=self.dbs),
256
+ get_eval_router(dbs=self.dbs, agents=self.agents, teams=self.teams),
257
+ get_metrics_router(dbs=self.dbs),
258
+ get_knowledge_router(knowledge_instances=self.knowledge_instances),
259
+ ]
260
+
261
+ for router in routers:
262
+ self._add_router(router)
222
263
 
223
264
  # Mount MCP if needed
224
265
  if self.enable_mcp and self.mcp_app:
@@ -266,6 +307,94 @@ class AgentOS:
266
307
 
267
308
  return app.routes
268
309
 
310
+ def _get_existing_route_paths(self) -> Dict[str, List[str]]:
311
+ """Get all existing route paths and methods from the FastAPI app.
312
+
313
+ Returns:
314
+ Dict[str, List[str]]: Dictionary mapping paths to list of HTTP methods
315
+ """
316
+ if not self.fastapi_app:
317
+ return {}
318
+
319
+ existing_paths: Dict[str, Any] = {}
320
+ for route in self.fastapi_app.routes:
321
+ if isinstance(route, APIRoute):
322
+ path = route.path
323
+ methods = list(route.methods) if route.methods else []
324
+ if path in existing_paths:
325
+ existing_paths[path].extend(methods)
326
+ else:
327
+ existing_paths[path] = methods
328
+ return existing_paths
329
+
330
+ def _add_router(self, router: APIRouter) -> None:
331
+ """Add a router to the FastAPI app, avoiding route conflicts.
332
+
333
+ Args:
334
+ router: The APIRouter to add
335
+ """
336
+ if not self.fastapi_app:
337
+ return
338
+
339
+ # Get existing routes
340
+ existing_paths = self._get_existing_route_paths()
341
+
342
+ # Check for conflicts
343
+ conflicts = []
344
+ conflicting_routes = []
345
+
346
+ for route in router.routes:
347
+ if isinstance(route, APIRoute):
348
+ full_path = route.path
349
+ route_methods = list(route.methods) if route.methods else []
350
+
351
+ if full_path in existing_paths:
352
+ conflicting_methods: Set[str] = set(route_methods) & set(existing_paths[full_path])
353
+ if conflicting_methods:
354
+ conflicts.append({"path": full_path, "methods": list(conflicting_methods), "route": route})
355
+ conflicting_routes.append(route)
356
+
357
+ if conflicts and self._app_set:
358
+ if self.replace_routes:
359
+ # Log warnings but still add all routes (AgentOS routes will override)
360
+ for conflict in conflicts:
361
+ methods_str = ", ".join(conflict["methods"]) # type: ignore
362
+ logger.warning(
363
+ f"Route conflict detected: {methods_str} {conflict['path']} - "
364
+ f"AgentOS route will override existing custom route"
365
+ )
366
+
367
+ # Remove conflicting routes
368
+ for route in self.fastapi_app.routes:
369
+ for conflict in conflicts:
370
+ if isinstance(route, APIRoute):
371
+ if route.path == conflict["path"] and list(route.methods) == list(conflict["methods"]):
372
+ self.fastapi_app.routes.pop(self.fastapi_app.routes.index(route))
373
+
374
+ self.fastapi_app.include_router(router)
375
+
376
+ else:
377
+ # Skip conflicting AgentOS routes, prefer user's existing routes
378
+ for conflict in conflicts:
379
+ methods_str = ", ".join(conflict["methods"]) # type: ignore
380
+ logger.debug(
381
+ f"Skipping conflicting AgentOS route: {methods_str} {conflict['path']} - "
382
+ f"Using existing custom route instead"
383
+ )
384
+
385
+ # Create a new router without the conflicting routes
386
+ filtered_router = APIRouter()
387
+ for route in router.routes:
388
+ if route not in conflicting_routes:
389
+ filtered_router.routes.append(route)
390
+
391
+ # Use the filtered router if it has any routes left
392
+ if filtered_router.routes:
393
+ self.fastapi_app.include_router(filtered_router)
394
+ else:
395
+ # No conflicts, add router normally
396
+ self.fastapi_app.include_router(router)
397
+
269
398
  def _get_telemetry_data(self) -> Dict[str, Any]:
270
399
  """Get the telemetry data for the OS"""
271
400
  return {
@@ -293,18 +422,25 @@ class AgentOS:
293
422
  def _auto_discover_databases(self) -> None:
294
423
  """Auto-discover the databases used by all contextual agents, teams and workflows."""
295
424
  dbs = {}
425
+ knowledge_dbs = {} # Track databases specifically used for knowledge
296
426
 
297
427
  for agent in self.agents or []:
298
428
  if agent.db:
299
429
  dbs[agent.db.id] = agent.db
300
430
  if agent.knowledge and agent.knowledge.contents_db:
301
- dbs[agent.knowledge.contents_db.id] = agent.knowledge.contents_db
431
+ knowledge_dbs[agent.knowledge.contents_db.id] = agent.knowledge.contents_db
432
+ # Also add to general dbs if it's used for both purposes
433
+ if agent.knowledge.contents_db.id not in dbs:
434
+ dbs[agent.knowledge.contents_db.id] = agent.knowledge.contents_db
302
435
 
303
436
  for team in self.teams or []:
304
437
  if team.db:
305
438
  dbs[team.db.id] = team.db
306
439
  if team.knowledge and team.knowledge.contents_db:
307
- dbs[team.knowledge.contents_db.id] = team.knowledge.contents_db
440
+ knowledge_dbs[team.knowledge.contents_db.id] = team.knowledge.contents_db
441
+ # Also add to general dbs if it's used for both purposes
442
+ if team.knowledge.contents_db.id not in dbs:
443
+ dbs[team.knowledge.contents_db.id] = team.knowledge.contents_db
308
444
 
309
445
  for workflow in self.workflows or []:
310
446
  if workflow.db:
@@ -317,6 +453,7 @@ class AgentOS:
317
453
  dbs[interface.team.db.id] = interface.team.db
318
454
 
319
455
  self.dbs = dbs
456
+ self.knowledge_dbs = knowledge_dbs
320
457
 
321
458
  def _auto_discover_knowledge_instances(self) -> None:
322
459
  """Auto-discover the knowledge instances used by all contextual agents, teams and workflows."""
@@ -381,16 +518,17 @@ class AgentOS:
381
518
  if knowledge_config.dbs is None:
382
519
  knowledge_config.dbs = []
383
520
 
384
- multiple_dbs: bool = len(self.dbs.keys()) > 1
521
+ multiple_knowledge_dbs: bool = len(self.knowledge_dbs.keys()) > 1
385
522
  dbs_with_specific_config = [db.db_id for db in knowledge_config.dbs]
386
523
 
387
- for db_id in self.dbs.keys():
524
+ # Only add databases that are actually used for knowledge contents
525
+ for db_id in self.knowledge_dbs.keys():
388
526
  if db_id not in dbs_with_specific_config:
389
527
  knowledge_config.dbs.append(
390
528
  DatabaseConfig(
391
529
  db_id=db_id,
392
530
  domain_config=KnowledgeDomainConfig(
393
- display_name="Knowledge" if not multiple_dbs else "Knowledge in database " + db_id
531
+ display_name="Knowledge" if not multiple_knowledge_dbs else "Knowledge in database " + db_id
394
532
  ),
395
533
  )
396
534
  )
@@ -441,29 +579,6 @@ class AgentOS:
441
579
 
442
580
  return evals_config
443
581
 
444
- def _setup_routers(self) -> None:
445
- """Add all routers to the FastAPI app."""
446
- if not self.dbs or not self.fastapi_app:
447
- return
448
-
449
- routers = [
450
- get_session_router(dbs=self.dbs),
451
- get_memory_router(dbs=self.dbs),
452
- get_eval_router(dbs=self.dbs, agents=self.agents, teams=self.teams),
453
- get_metrics_router(dbs=self.dbs),
454
- get_knowledge_router(knowledge_instances=self.knowledge_instances),
455
- ]
456
-
457
- for router in routers:
458
- self.fastapi_app.include_router(router)
459
-
460
- def set_os_id(self) -> str:
461
- # If os_id is already set, keep it instead of overriding with UUID
462
- if self.os_id is None:
463
- self.os_id = str(uuid4())
464
-
465
- return self.os_id
466
-
467
582
  def serve(
468
583
  self,
469
584
  app: Union[str, FastAPI],
@@ -485,13 +600,18 @@ class AgentOS:
485
600
  from rich.align import Align
486
601
  from rich.console import Console, Group
487
602
 
488
- aligned_endpoint = Align.center(f"[bold cyan]{public_endpoint}[/bold cyan]")
489
- connection_endpoint = f"\n\n[bold dark_orange]Running on:[/bold dark_orange] http://{host}:{port}"
603
+ panel_group = []
604
+ panel_group.append(Align.center(f"[bold cyan]{public_endpoint}[/bold cyan]"))
605
+ panel_group.append(
606
+ Align.center(f"\n\n[bold dark_orange]OS running on:[/bold dark_orange] http://{host}:{port}")
607
+ )
608
+ if bool(self.settings.os_security_key):
609
+ panel_group.append(Align.center("\n\n[bold chartreuse3]:lock: Security Enabled[/bold chartreuse3]"))
490
610
 
491
611
  console = Console()
492
612
  console.print(
493
613
  Panel(
494
- Group(aligned_endpoint, connection_endpoint),
614
+ Group(*panel_group),
495
615
  title="AgentOS",
496
616
  expand=False,
497
617
  border_style="dark_orange",
@@ -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