agno 2.3.26__py3-none-any.whl → 2.4.1__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 (140) hide show
  1. agno/agent/__init__.py +4 -0
  2. agno/agent/agent.py +1368 -541
  3. agno/agent/remote.py +13 -0
  4. agno/db/base.py +339 -0
  5. agno/db/postgres/async_postgres.py +116 -12
  6. agno/db/postgres/postgres.py +1242 -25
  7. agno/db/postgres/schemas.py +48 -1
  8. agno/db/sqlite/async_sqlite.py +119 -4
  9. agno/db/sqlite/schemas.py +51 -0
  10. agno/db/sqlite/sqlite.py +1186 -13
  11. agno/db/utils.py +37 -1
  12. agno/integrations/discord/client.py +12 -1
  13. agno/knowledge/__init__.py +4 -0
  14. agno/knowledge/chunking/code.py +1 -1
  15. agno/knowledge/chunking/semantic.py +1 -1
  16. agno/knowledge/chunking/strategy.py +4 -0
  17. agno/knowledge/filesystem.py +412 -0
  18. agno/knowledge/knowledge.py +3722 -2182
  19. agno/knowledge/protocol.py +134 -0
  20. agno/knowledge/reader/arxiv_reader.py +2 -2
  21. agno/knowledge/reader/base.py +9 -7
  22. agno/knowledge/reader/csv_reader.py +236 -13
  23. agno/knowledge/reader/docx_reader.py +2 -2
  24. agno/knowledge/reader/field_labeled_csv_reader.py +169 -5
  25. agno/knowledge/reader/firecrawl_reader.py +2 -2
  26. agno/knowledge/reader/json_reader.py +2 -2
  27. agno/knowledge/reader/markdown_reader.py +2 -2
  28. agno/knowledge/reader/pdf_reader.py +5 -4
  29. agno/knowledge/reader/pptx_reader.py +2 -2
  30. agno/knowledge/reader/reader_factory.py +118 -1
  31. agno/knowledge/reader/s3_reader.py +2 -2
  32. agno/knowledge/reader/tavily_reader.py +2 -2
  33. agno/knowledge/reader/text_reader.py +2 -2
  34. agno/knowledge/reader/web_search_reader.py +2 -2
  35. agno/knowledge/reader/website_reader.py +5 -3
  36. agno/knowledge/reader/wikipedia_reader.py +2 -2
  37. agno/knowledge/reader/youtube_reader.py +2 -2
  38. agno/knowledge/remote_content/__init__.py +29 -0
  39. agno/knowledge/remote_content/config.py +204 -0
  40. agno/knowledge/remote_content/remote_content.py +74 -17
  41. agno/knowledge/utils.py +37 -29
  42. agno/learn/__init__.py +6 -0
  43. agno/learn/machine.py +35 -0
  44. agno/learn/schemas.py +82 -11
  45. agno/learn/stores/__init__.py +3 -0
  46. agno/learn/stores/decision_log.py +1156 -0
  47. agno/learn/stores/learned_knowledge.py +6 -6
  48. agno/models/anthropic/claude.py +24 -0
  49. agno/models/aws/bedrock.py +20 -0
  50. agno/models/base.py +60 -6
  51. agno/models/cerebras/cerebras.py +34 -2
  52. agno/models/cohere/chat.py +25 -0
  53. agno/models/google/gemini.py +50 -5
  54. agno/models/litellm/chat.py +38 -0
  55. agno/models/n1n/__init__.py +3 -0
  56. agno/models/n1n/n1n.py +57 -0
  57. agno/models/openai/chat.py +25 -1
  58. agno/models/openrouter/openrouter.py +46 -0
  59. agno/models/perplexity/perplexity.py +2 -0
  60. agno/models/response.py +16 -0
  61. agno/os/app.py +83 -44
  62. agno/os/interfaces/slack/router.py +10 -1
  63. agno/os/interfaces/whatsapp/router.py +6 -0
  64. agno/os/middleware/__init__.py +2 -0
  65. agno/os/middleware/trailing_slash.py +27 -0
  66. agno/os/router.py +1 -0
  67. agno/os/routers/agents/router.py +29 -16
  68. agno/os/routers/agents/schema.py +6 -4
  69. agno/os/routers/components/__init__.py +3 -0
  70. agno/os/routers/components/components.py +475 -0
  71. agno/os/routers/evals/schemas.py +4 -3
  72. agno/os/routers/health.py +3 -3
  73. agno/os/routers/knowledge/knowledge.py +128 -3
  74. agno/os/routers/knowledge/schemas.py +12 -0
  75. agno/os/routers/memory/schemas.py +4 -2
  76. agno/os/routers/metrics/metrics.py +9 -11
  77. agno/os/routers/metrics/schemas.py +10 -6
  78. agno/os/routers/registry/__init__.py +3 -0
  79. agno/os/routers/registry/registry.py +337 -0
  80. agno/os/routers/teams/router.py +20 -8
  81. agno/os/routers/teams/schema.py +6 -4
  82. agno/os/routers/traces/traces.py +5 -5
  83. agno/os/routers/workflows/router.py +38 -11
  84. agno/os/routers/workflows/schema.py +1 -1
  85. agno/os/schema.py +92 -26
  86. agno/os/utils.py +84 -19
  87. agno/reasoning/anthropic.py +2 -2
  88. agno/reasoning/azure_ai_foundry.py +2 -2
  89. agno/reasoning/deepseek.py +2 -2
  90. agno/reasoning/default.py +6 -7
  91. agno/reasoning/gemini.py +2 -2
  92. agno/reasoning/helpers.py +6 -7
  93. agno/reasoning/manager.py +4 -10
  94. agno/reasoning/ollama.py +2 -2
  95. agno/reasoning/openai.py +2 -2
  96. agno/reasoning/vertexai.py +2 -2
  97. agno/registry/__init__.py +3 -0
  98. agno/registry/registry.py +68 -0
  99. agno/run/agent.py +59 -0
  100. agno/run/base.py +7 -0
  101. agno/run/team.py +57 -0
  102. agno/skills/agent_skills.py +10 -3
  103. agno/team/__init__.py +3 -1
  104. agno/team/team.py +1165 -330
  105. agno/tools/duckduckgo.py +25 -71
  106. agno/tools/exa.py +0 -21
  107. agno/tools/function.py +35 -83
  108. agno/tools/knowledge.py +9 -4
  109. agno/tools/mem0.py +11 -10
  110. agno/tools/memory.py +47 -46
  111. agno/tools/parallel.py +0 -7
  112. agno/tools/reasoning.py +30 -23
  113. agno/tools/tavily.py +4 -1
  114. agno/tools/websearch.py +93 -0
  115. agno/tools/website.py +1 -1
  116. agno/tools/wikipedia.py +1 -1
  117. agno/tools/workflow.py +48 -47
  118. agno/utils/agent.py +42 -5
  119. agno/utils/events.py +160 -2
  120. agno/utils/print_response/agent.py +0 -31
  121. agno/utils/print_response/team.py +0 -2
  122. agno/utils/print_response/workflow.py +0 -2
  123. agno/utils/team.py +61 -11
  124. agno/vectordb/lancedb/lance_db.py +4 -1
  125. agno/vectordb/mongodb/mongodb.py +1 -1
  126. agno/vectordb/pgvector/pgvector.py +3 -3
  127. agno/vectordb/qdrant/qdrant.py +4 -4
  128. agno/workflow/__init__.py +3 -1
  129. agno/workflow/condition.py +0 -21
  130. agno/workflow/loop.py +0 -21
  131. agno/workflow/parallel.py +0 -21
  132. agno/workflow/router.py +0 -21
  133. agno/workflow/step.py +117 -24
  134. agno/workflow/steps.py +0 -21
  135. agno/workflow/workflow.py +427 -63
  136. {agno-2.3.26.dist-info → agno-2.4.1.dist-info}/METADATA +49 -76
  137. {agno-2.3.26.dist-info → agno-2.4.1.dist-info}/RECORD +140 -126
  138. {agno-2.3.26.dist-info → agno-2.4.1.dist-info}/WHEEL +1 -1
  139. {agno-2.3.26.dist-info → agno-2.4.1.dist-info}/licenses/LICENSE +0 -0
  140. {agno-2.3.26.dist-info → agno-2.4.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,475 @@
1
+ import logging
2
+ import time
3
+ from typing import Any, Dict, List, Optional, Union
4
+
5
+ from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
6
+
7
+ from agno.db.base import AsyncBaseDb, BaseDb
8
+ from agno.db.base import ComponentType as DbComponentType
9
+ from agno.os.auth import get_authentication_dependency
10
+ from agno.os.schema import (
11
+ BadRequestResponse,
12
+ ComponentConfigResponse,
13
+ ComponentCreate,
14
+ ComponentResponse,
15
+ ComponentType,
16
+ ComponentUpdate,
17
+ ConfigCreate,
18
+ ConfigUpdate,
19
+ InternalServerErrorResponse,
20
+ NotFoundResponse,
21
+ PaginatedResponse,
22
+ PaginationInfo,
23
+ UnauthenticatedResponse,
24
+ ValidationErrorResponse,
25
+ )
26
+ from agno.os.settings import AgnoAPISettings
27
+ from agno.registry import Registry
28
+ from agno.utils.log import log_error, log_warning
29
+ from agno.utils.string import generate_id_from_name
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ def _resolve_db_in_config(
35
+ config: Dict[str, Any],
36
+ os_db: BaseDb,
37
+ registry: Optional[Registry] = None,
38
+ ) -> Dict[str, Any]:
39
+ """
40
+ Resolve db reference in config by looking up in registry or OS db.
41
+
42
+ If config contains a db dict with an id, this function will:
43
+ 1. Check if the id matches the OS db
44
+ 2. Check if the id exists in the registry
45
+ 3. Convert the found db to a dict for serialization
46
+
47
+ Args:
48
+ config: The config dict that may contain a db reference
49
+ os_db: The OS database instance
50
+ registry: Optional registry containing registered databases
51
+
52
+ Returns:
53
+ Updated config dict with resolved db
54
+ """
55
+ component_db = config.get("db")
56
+ if component_db is not None and isinstance(component_db, dict):
57
+ component_db_id = component_db.get("id")
58
+ if component_db_id is not None:
59
+ resolved_db = None
60
+ # First check if it matches the OS db
61
+ if component_db_id == os_db.id:
62
+ resolved_db = os_db
63
+ # Then check the registry
64
+ elif registry is not None:
65
+ resolved_db = registry.get_db(component_db_id)
66
+
67
+ # Store the full db dict for serialization
68
+ if resolved_db is not None:
69
+ config["db"] = resolved_db.to_dict()
70
+ else:
71
+ log_error(f"Could not resolve db with id: {component_db_id}")
72
+ elif component_db is None and "db" in config:
73
+ # Explicitly set to None, remove the key
74
+ config.pop("db", None)
75
+
76
+ return config
77
+
78
+
79
+ def get_components_router(
80
+ os_db: Union[BaseDb, AsyncBaseDb],
81
+ settings: AgnoAPISettings = AgnoAPISettings(),
82
+ registry: Optional[Registry] = None,
83
+ ) -> APIRouter:
84
+ """Create components router."""
85
+ router = APIRouter(
86
+ dependencies=[Depends(get_authentication_dependency(settings))],
87
+ tags=["Components"],
88
+ responses={
89
+ 400: {"description": "Bad Request", "model": BadRequestResponse},
90
+ 401: {"description": "Unauthorized", "model": UnauthenticatedResponse},
91
+ 404: {"description": "Not Found", "model": NotFoundResponse},
92
+ 422: {"description": "Validation Error", "model": ValidationErrorResponse},
93
+ 500: {"description": "Internal Server Error", "model": InternalServerErrorResponse},
94
+ },
95
+ )
96
+ return attach_routes(router=router, os_db=os_db, registry=registry)
97
+
98
+
99
+ def attach_routes(
100
+ router: APIRouter, os_db: Union[BaseDb, AsyncBaseDb], registry: Optional[Registry] = None
101
+ ) -> APIRouter:
102
+ # Component routes require sync database
103
+ if not isinstance(os_db, BaseDb):
104
+ raise ValueError("Component routes require a sync database (BaseDb), not an async database.")
105
+ db: BaseDb = os_db # Type narrowed after isinstance check
106
+
107
+ @router.get(
108
+ "/components",
109
+ response_model=PaginatedResponse[ComponentResponse],
110
+ response_model_exclude_none=True,
111
+ status_code=200,
112
+ operation_id="list_components",
113
+ summary="List Components",
114
+ description="Retrieve a paginated list of components with optional filtering by type.",
115
+ )
116
+ async def list_components(
117
+ component_type: Optional[ComponentType] = Query(None, description="Filter by type: agent, team, workflow"),
118
+ page: int = Query(1, ge=1, description="Page number"),
119
+ limit: int = Query(20, ge=1, le=100, description="Items per page"),
120
+ ) -> PaginatedResponse[ComponentResponse]:
121
+ try:
122
+ start_time_ms = time.time() * 1000
123
+ offset = (page - 1) * limit
124
+
125
+ components, total_count = db.list_components(
126
+ component_type=DbComponentType(component_type.value) if component_type else None,
127
+ limit=limit,
128
+ offset=offset,
129
+ )
130
+
131
+ total_pages = (total_count + limit - 1) // limit if limit > 0 else 0
132
+
133
+ return PaginatedResponse(
134
+ data=[ComponentResponse(**c) for c in components],
135
+ meta=PaginationInfo(
136
+ page=page,
137
+ limit=limit,
138
+ total_pages=total_pages,
139
+ total_count=total_count,
140
+ search_time_ms=round(time.time() * 1000 - start_time_ms, 2),
141
+ ),
142
+ )
143
+ except Exception as e:
144
+ log_error(f"Error listing components: {e}")
145
+ raise HTTPException(status_code=500, detail="Internal server error")
146
+
147
+ @router.post(
148
+ "/components",
149
+ response_model=ComponentResponse,
150
+ response_model_exclude_none=True,
151
+ status_code=201,
152
+ operation_id="create_component",
153
+ summary="Create Component",
154
+ description="Create a new component (agent, team, or workflow) with initial config.",
155
+ )
156
+ async def create_component(
157
+ body: ComponentCreate,
158
+ ) -> ComponentResponse:
159
+ try:
160
+ component_id = body.component_id
161
+ if component_id is None:
162
+ component_id = generate_id_from_name(body.name)
163
+
164
+ # TODO: Create links from config
165
+
166
+ # Prepare config - ensure it's a dict and resolve db reference
167
+ config = body.config or {}
168
+ config = _resolve_db_in_config(config, db, registry)
169
+
170
+ # Warn if creating a team without members
171
+ if body.component_type == ComponentType.TEAM:
172
+ members = config.get("members")
173
+ if not members or len(members) == 0:
174
+ log_warning(
175
+ f"Creating team '{body.name}' without members. "
176
+ "If this is unintended, add members to the config."
177
+ )
178
+
179
+ component, _config = db.create_component_with_config(
180
+ component_id=component_id,
181
+ component_type=DbComponentType(body.component_type.value),
182
+ name=body.name,
183
+ description=body.description,
184
+ metadata=body.metadata,
185
+ config=config,
186
+ label=body.label,
187
+ stage=body.stage or "draft",
188
+ notes=body.notes,
189
+ )
190
+
191
+ return ComponentResponse(**component)
192
+ except ValueError as e:
193
+ raise HTTPException(status_code=400, detail=str(e))
194
+ except Exception as e:
195
+ log_error(f"Error creating component: {e}")
196
+ raise HTTPException(status_code=500, detail="Internal server error")
197
+
198
+ @router.get(
199
+ "/components/{component_id}",
200
+ response_model=ComponentResponse,
201
+ response_model_exclude_none=True,
202
+ status_code=200,
203
+ operation_id="get_component",
204
+ summary="Get Component",
205
+ description="Retrieve a component by ID.",
206
+ )
207
+ async def get_component(
208
+ component_id: str = Path(description="Component ID"),
209
+ ) -> ComponentResponse:
210
+ try:
211
+ component = db.get_component(component_id)
212
+ if component is None:
213
+ raise HTTPException(status_code=404, detail=f"Component {component_id} not found")
214
+ return ComponentResponse(**component)
215
+ except HTTPException:
216
+ raise
217
+ except Exception as e:
218
+ log_error(f"Error getting component: {e}")
219
+ raise HTTPException(status_code=500, detail="Internal server error")
220
+
221
+ @router.patch(
222
+ "/components/{component_id}",
223
+ response_model=ComponentResponse,
224
+ response_model_exclude_none=True,
225
+ status_code=200,
226
+ operation_id="update_component",
227
+ summary="Update Component",
228
+ description="Partially update a component by ID.",
229
+ )
230
+ async def update_component(
231
+ component_id: str = Path(description="Component ID"),
232
+ body: ComponentUpdate = Body(description="Component fields to update"),
233
+ ) -> ComponentResponse:
234
+ try:
235
+ existing = db.get_component(component_id)
236
+ if existing is None:
237
+ raise HTTPException(status_code=404, detail=f"Component {component_id} not found")
238
+
239
+ update_kwargs: Dict[str, Any] = {"component_id": component_id}
240
+ if body.name is not None:
241
+ update_kwargs["name"] = body.name
242
+ if body.description is not None:
243
+ update_kwargs["description"] = body.description
244
+ if body.metadata is not None:
245
+ update_kwargs["metadata"] = body.metadata
246
+ if body.component_type is not None:
247
+ update_kwargs["component_type"] = DbComponentType(body.component_type)
248
+
249
+ component = db.upsert_component(**update_kwargs)
250
+ return ComponentResponse(**component)
251
+ except HTTPException:
252
+ raise
253
+ except ValueError as e:
254
+ raise HTTPException(status_code=400, detail=str(e))
255
+ except Exception as e:
256
+ log_error(f"Error updating component: {e}")
257
+ raise HTTPException(status_code=500, detail="Internal server error")
258
+
259
+ @router.delete(
260
+ "/components/{component_id}",
261
+ status_code=204,
262
+ operation_id="delete_component",
263
+ summary="Delete Component",
264
+ description="Delete a component by ID.",
265
+ )
266
+ async def delete_component(
267
+ component_id: str = Path(description="Component ID"),
268
+ ) -> None:
269
+ try:
270
+ deleted = db.delete_component(component_id)
271
+ if not deleted:
272
+ raise HTTPException(status_code=404, detail=f"Component {component_id} not found")
273
+ except HTTPException:
274
+ raise
275
+ except Exception as e:
276
+ log_error(f"Error deleting component: {e}")
277
+ raise HTTPException(status_code=500, detail="Internal server error")
278
+
279
+ @router.get(
280
+ "/components/{component_id}/configs",
281
+ response_model=List[ComponentConfigResponse],
282
+ response_model_exclude_none=True,
283
+ status_code=200,
284
+ operation_id="list_configs",
285
+ summary="List Configs",
286
+ description="List all configs for a component.",
287
+ )
288
+ async def list_configs(
289
+ component_id: str = Path(description="Component ID"),
290
+ include_config: bool = Query(True, description="Include full config blob"),
291
+ ) -> List[ComponentConfigResponse]:
292
+ try:
293
+ configs = db.list_configs(component_id, include_config=include_config)
294
+ return [ComponentConfigResponse(**c) for c in configs]
295
+ except Exception as e:
296
+ log_error(f"Error listing configs: {e}")
297
+ raise HTTPException(status_code=500, detail="Internal server error")
298
+
299
+ @router.post(
300
+ "/components/{component_id}/configs",
301
+ response_model=ComponentConfigResponse,
302
+ response_model_exclude_none=True,
303
+ status_code=201,
304
+ operation_id="create_config",
305
+ summary="Create Config Version",
306
+ description="Create a new config version for a component.",
307
+ )
308
+ async def create_config(
309
+ component_id: str = Path(description="Component ID"),
310
+ body: ConfigCreate = Body(description="Config data"),
311
+ ) -> ComponentConfigResponse:
312
+ try:
313
+ # Resolve db from config if present
314
+ config_data = body.config or {}
315
+ config_data = _resolve_db_in_config(config_data, db, registry)
316
+
317
+ config = db.upsert_config(
318
+ component_id=component_id,
319
+ version=None, # Always create new
320
+ config=config_data,
321
+ label=body.label,
322
+ stage=body.stage,
323
+ notes=body.notes,
324
+ links=body.links,
325
+ )
326
+ return ComponentConfigResponse(**config)
327
+ except ValueError as e:
328
+ raise HTTPException(status_code=400, detail=str(e))
329
+ except Exception as e:
330
+ log_error(f"Error creating config: {e}")
331
+ raise HTTPException(status_code=500, detail="Internal server error")
332
+
333
+ @router.patch(
334
+ "/components/{component_id}/configs/{version}",
335
+ response_model=ComponentConfigResponse,
336
+ response_model_exclude_none=True,
337
+ status_code=200,
338
+ operation_id="update_config",
339
+ summary="Update Draft Config",
340
+ description="Update an existing draft config. Cannot update published configs.",
341
+ )
342
+ async def update_config(
343
+ component_id: str = Path(description="Component ID"),
344
+ version: int = Path(description="Version number"),
345
+ body: ConfigUpdate = Body(description="Config fields to update"),
346
+ ) -> ComponentConfigResponse:
347
+ try:
348
+ # Resolve db from config if present
349
+ config_data = body.config
350
+ if config_data is not None:
351
+ config_data = _resolve_db_in_config(config_data, db, registry)
352
+
353
+ config = db.upsert_config(
354
+ component_id=component_id,
355
+ version=version, # Always update existing
356
+ config=config_data,
357
+ label=body.label,
358
+ stage=body.stage,
359
+ notes=body.notes,
360
+ links=body.links,
361
+ )
362
+ return ComponentConfigResponse(**config)
363
+ except ValueError as e:
364
+ raise HTTPException(status_code=400, detail=str(e))
365
+ except Exception as e:
366
+ log_error(f"Error updating config: {e}")
367
+ raise HTTPException(status_code=500, detail="Internal server error")
368
+
369
+ @router.get(
370
+ "/components/{component_id}/configs/current",
371
+ response_model=ComponentConfigResponse,
372
+ response_model_exclude_none=True,
373
+ status_code=200,
374
+ operation_id="get_current_config",
375
+ summary="Get Current Config",
376
+ description="Get the current config version for a component.",
377
+ )
378
+ async def get_current_config(
379
+ component_id: str = Path(description="Component ID"),
380
+ ) -> ComponentConfigResponse:
381
+ try:
382
+ config = db.get_config(component_id)
383
+ if config is None:
384
+ raise HTTPException(status_code=404, detail=f"No current config for {component_id}")
385
+ return ComponentConfigResponse(**config)
386
+ except HTTPException:
387
+ raise
388
+ except Exception as e:
389
+ log_error(f"Error getting config: {e}")
390
+ raise HTTPException(status_code=500, detail="Internal server error")
391
+
392
+ @router.get(
393
+ "/components/{component_id}/configs/{version}",
394
+ response_model=ComponentConfigResponse,
395
+ response_model_exclude_none=True,
396
+ status_code=200,
397
+ operation_id="get_config",
398
+ summary="Get Config Version",
399
+ description="Get a specific config version by number.",
400
+ )
401
+ async def get_config_version(
402
+ component_id: str = Path(description="Component ID"),
403
+ version: int = Path(description="Version number"),
404
+ ) -> ComponentConfigResponse:
405
+ try:
406
+ config = db.get_config(component_id, version=version)
407
+
408
+ if config is None:
409
+ raise HTTPException(status_code=404, detail=f"Config {component_id} v{version} not found")
410
+ return ComponentConfigResponse(**config)
411
+ except HTTPException:
412
+ raise
413
+ except Exception as e:
414
+ log_error(f"Error getting config: {e}")
415
+ raise HTTPException(status_code=500, detail="Internal server error")
416
+
417
+ @router.delete(
418
+ "/components/{component_id}/configs/{version}",
419
+ status_code=204,
420
+ operation_id="delete_config",
421
+ summary="Delete Config Version",
422
+ description="Delete a specific draft config version. Cannot delete published or current configs.",
423
+ )
424
+ async def delete_config_version(
425
+ component_id: str = Path(description="Component ID"),
426
+ version: int = Path(description="Version number"),
427
+ ) -> None:
428
+ try:
429
+ # Resolve version number
430
+ deleted = db.delete_config(component_id, version=version)
431
+ if not deleted:
432
+ raise HTTPException(status_code=404, detail=f"Config {component_id} v{version} not found")
433
+ except HTTPException:
434
+ raise
435
+ except ValueError as e:
436
+ raise HTTPException(status_code=400, detail=str(e))
437
+ except Exception as e:
438
+ log_error(f"Error deleting config: {e}")
439
+ raise HTTPException(status_code=500, detail="Internal server error")
440
+
441
+ @router.post(
442
+ "/components/{component_id}/configs/{version}/set-current",
443
+ response_model=ComponentResponse,
444
+ response_model_exclude_none=True,
445
+ status_code=200,
446
+ operation_id="set_current_config",
447
+ summary="Set Current Config Version",
448
+ description="Set a published config version as current (for rollback).",
449
+ )
450
+ async def set_current_config(
451
+ component_id: str = Path(description="Component ID"),
452
+ version: int = Path(description="Version number"),
453
+ ) -> ComponentResponse:
454
+ try:
455
+ success = db.set_current_version(component_id, version=version)
456
+ if not success:
457
+ raise HTTPException(
458
+ status_code=404, detail=f"Component {component_id} or config version {version} not found"
459
+ )
460
+
461
+ # Fetch and return updated component
462
+ component = db.get_component(component_id)
463
+ if component is None:
464
+ raise HTTPException(status_code=404, detail=f"Component {component_id} not found")
465
+
466
+ return ComponentResponse(**component)
467
+ except HTTPException:
468
+ raise
469
+ except ValueError as e:
470
+ raise HTTPException(status_code=400, detail=str(e))
471
+ except Exception as e:
472
+ log_error(f"Error setting current config: {e}")
473
+ raise HTTPException(status_code=500, detail="Internal server error")
474
+
475
+ return router
@@ -1,5 +1,5 @@
1
1
  from dataclasses import asdict
2
- from datetime import datetime, timezone
2
+ from datetime import datetime
3
3
  from typing import Any, Dict, List, Literal, Optional
4
4
 
5
5
  from pydantic import BaseModel, Field
@@ -10,6 +10,7 @@ from agno.eval.accuracy import AccuracyEval
10
10
  from agno.eval.agent_as_judge import AgentAsJudgeEval
11
11
  from agno.eval.performance import PerformanceEval
12
12
  from agno.eval.reliability import ReliabilityEval
13
+ from agno.os.utils import to_utc_datetime
13
14
 
14
15
 
15
16
  class EvalRunInput(BaseModel):
@@ -74,8 +75,8 @@ class EvalSchema(BaseModel):
74
75
  eval_type=eval_run["eval_type"],
75
76
  eval_data=eval_run["eval_data"],
76
77
  eval_input=eval_run.get("eval_input"),
77
- created_at=datetime.fromtimestamp(eval_run["created_at"], tz=timezone.utc),
78
- updated_at=datetime.fromtimestamp(eval_run["updated_at"], tz=timezone.utc),
78
+ created_at=to_utc_datetime(eval_run.get("created_at")),
79
+ updated_at=to_utc_datetime(eval_run.get("updated_at")),
79
80
  )
80
81
 
81
82
  @classmethod
agno/os/routers/health.py CHANGED
@@ -8,7 +8,7 @@ from agno.os.schema import HealthResponse
8
8
  def get_health_router(health_endpoint: str = "/health") -> APIRouter:
9
9
  router = APIRouter(tags=["Health"])
10
10
 
11
- started_time_stamp = datetime.now(timezone.utc).timestamp()
11
+ started_at = datetime.now(timezone.utc)
12
12
 
13
13
  @router.get(
14
14
  health_endpoint,
@@ -20,12 +20,12 @@ def get_health_router(health_endpoint: str = "/health") -> APIRouter:
20
20
  200: {
21
21
  "description": "API is healthy and operational",
22
22
  "content": {
23
- "application/json": {"example": {"status": "ok", "instantiated_at": str(started_time_stamp)}}
23
+ "application/json": {"example": {"status": "ok", "instantiated_at": "2025-06-10T12:00:00Z"}}
24
24
  },
25
25
  }
26
26
  },
27
27
  )
28
28
  async def health_check() -> HealthResponse:
29
- return HealthResponse(status="ok", instantiated_at=str(started_time_stamp))
29
+ return HealthResponse(status="ok", instantiated_at=started_at)
30
30
 
31
31
  return router
@@ -206,6 +206,113 @@ def attach_routes(router: APIRouter, knowledge_instances: List[Union[Knowledge,
206
206
  )
207
207
  return response
208
208
 
209
+ @router.post(
210
+ "/knowledge/remote-content",
211
+ response_model=ContentResponseSchema,
212
+ status_code=202,
213
+ operation_id="upload_remote_content",
214
+ summary="Upload Remote Content",
215
+ description=(
216
+ "Upload content from a remote source (S3, GCS, SharePoint, GitHub) to the knowledge base. "
217
+ "Content is processed asynchronously in the background. "
218
+ "Use the /knowledge/config endpoint to see available remote content sources."
219
+ ),
220
+ responses={
221
+ 202: {
222
+ "description": "Remote content upload accepted for processing",
223
+ "content": {
224
+ "application/json": {
225
+ "example": {
226
+ "id": "content-456",
227
+ "name": "reports/q1-2024.pdf",
228
+ "description": "Q1 Report from S3",
229
+ "metadata": {"source": "s3-docs"},
230
+ "status": "processing",
231
+ }
232
+ }
233
+ },
234
+ },
235
+ 400: {
236
+ "description": "Invalid request - unknown config or missing path",
237
+ "model": BadRequestResponse,
238
+ },
239
+ 422: {"description": "Validation error in request body", "model": ValidationErrorResponse},
240
+ },
241
+ )
242
+ async def upload_remote_content(
243
+ request: Request,
244
+ background_tasks: BackgroundTasks,
245
+ config_id: str = Form(..., description="ID of the configured remote content source (from /knowledge/config)"),
246
+ path: str = Form(..., description="Path to file or folder in the remote source"),
247
+ name: Optional[str] = Form(None, description="Content name (auto-generated if not provided)"),
248
+ description: Optional[str] = Form(None, description="Content description"),
249
+ metadata: Optional[str] = Form(None, description="JSON metadata object"),
250
+ reader_id: Optional[str] = Form(None, description="ID of the reader to use for processing"),
251
+ chunker: Optional[str] = Form(None, description="Chunking strategy to apply"),
252
+ chunk_size: Optional[int] = Form(None, description="Chunk size for processing"),
253
+ chunk_overlap: Optional[int] = Form(None, description="Chunk overlap for processing"),
254
+ db_id: Optional[str] = Query(default=None, description="Database ID to use for content storage"),
255
+ ):
256
+ knowledge = get_knowledge_instance_by_db_id(knowledge_instances, db_id)
257
+
258
+ if isinstance(knowledge, RemoteKnowledge):
259
+ # TODO: Forward to remote knowledge instance
260
+ raise HTTPException(status_code=501, detail="Remote content upload not yet supported for RemoteKnowledge")
261
+
262
+ # Validate that the config_id exists in configured sources
263
+ config = knowledge._get_remote_config_by_id(config_id)
264
+ if config is None:
265
+ raise HTTPException(
266
+ status_code=400,
267
+ detail=f"Unknown content source: {config_id}. Check /knowledge/config for available sources.",
268
+ )
269
+
270
+ # Parse metadata if provided
271
+ parsed_metadata = None
272
+ if metadata:
273
+ try:
274
+ parsed_metadata = json.loads(metadata)
275
+ except json.JSONDecodeError:
276
+ parsed_metadata = {"value": metadata}
277
+
278
+ # Use the config's factory methods to create the remote content object
279
+ # If path ends with '/', treat as folder, otherwise treat as file
280
+ is_folder = path.endswith("/")
281
+ if is_folder:
282
+ if hasattr(config, "folder"):
283
+ remote_content = config.folder(path.rstrip("/"))
284
+ else:
285
+ raise HTTPException(status_code=400, detail=f"Config {config_id} does not support folder uploads")
286
+ else:
287
+ if hasattr(config, "file"):
288
+ remote_content = config.file(path)
289
+ else:
290
+ raise HTTPException(status_code=400, detail=f"Config {config_id} does not support file uploads")
291
+
292
+ # Set name from path if not provided
293
+ content_name = name or path
294
+
295
+ content = Content(
296
+ name=content_name,
297
+ description=description,
298
+ metadata=parsed_metadata,
299
+ remote_content=remote_content,
300
+ )
301
+ content_hash = knowledge._build_content_hash(content)
302
+ content.content_hash = content_hash
303
+ content.id = generate_id(content_hash)
304
+
305
+ background_tasks.add_task(process_content, knowledge, content, reader_id, chunker, chunk_size, chunk_overlap)
306
+
307
+ response = ContentResponseSchema(
308
+ id=content.id,
309
+ name=content_name,
310
+ description=description,
311
+ metadata=parsed_metadata,
312
+ status=ContentStatus.PROCESSING,
313
+ )
314
+ return response
315
+
209
316
  @router.patch(
210
317
  "/knowledge/content/{content_id}",
211
318
  response_model=ContentResponseSchema,
@@ -671,7 +778,7 @@ def attach_routes(router: APIRouter, knowledge_instances: List[Union[Knowledge,
671
778
  # Use max_results if specified, otherwise use a higher limit for search then paginate
672
779
  search_limit = request.max_results
673
780
 
674
- results = await knowledge.async_search(
781
+ results = await knowledge.asearch(
675
782
  query=request.query, max_results=search_limit, filters=request.filters, search_type=request.search_type
676
783
  )
677
784
 
@@ -1047,13 +1154,31 @@ def attach_routes(router: APIRouter, knowledge_instances: List[Union[Knowledge,
1047
1154
  search_types=search_types,
1048
1155
  )
1049
1156
  )
1050
- filters = await knowledge.async_get_valid_filters()
1157
+ filters = await knowledge.aget_valid_filters()
1158
+
1159
+ # Get remote content sources if available
1160
+ remote_content_sources = None
1161
+ if hasattr(knowledge, "_get_remote_configs") and callable(knowledge._get_remote_configs):
1162
+ remote_configs = knowledge._get_remote_configs()
1163
+ if remote_configs:
1164
+ from agno.os.routers.knowledge.schemas import RemoteContentSourceSchema
1165
+
1166
+ remote_content_sources = [
1167
+ RemoteContentSourceSchema(
1168
+ id=config.id,
1169
+ name=config.name,
1170
+ type=config.__class__.__name__.replace("Config", "").lower(),
1171
+ metadata=config.metadata,
1172
+ )
1173
+ for config in remote_configs
1174
+ ]
1051
1175
  return ConfigResponseSchema(
1052
1176
  readers=reader_schemas,
1053
1177
  vector_dbs=vector_dbs,
1054
1178
  readersForType=types_of_readers,
1055
1179
  chunkers=chunkers_dict,
1056
1180
  filters=filters,
1181
+ remote_content_sources=remote_content_sources,
1057
1182
  )
1058
1183
 
1059
1184
  return router
@@ -1098,7 +1223,7 @@ async def process_content(
1098
1223
  log_debug(f"Set chunking strategy: {chunker}")
1099
1224
 
1100
1225
  log_debug(f"Using reader: {content.reader.__class__.__name__}")
1101
- await knowledge._load_content_async(content, upsert=False, skip_if_exists=True)
1226
+ await knowledge._aload_content(content, upsert=False, skip_if_exists=True)
1102
1227
  log_info(f"Content {content.id} processed successfully")
1103
1228
  except Exception as e:
1104
1229
  log_info(f"Error processing content: {e}")