agno 2.3.7__py3-none-any.whl → 2.3.9__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 +391 -335
- agno/db/mongo/async_mongo.py +0 -24
- agno/db/mongo/mongo.py +0 -16
- agno/db/mysql/__init__.py +2 -1
- agno/db/mysql/async_mysql.py +2888 -0
- agno/db/mysql/mysql.py +17 -27
- agno/db/mysql/utils.py +139 -6
- agno/db/postgres/async_postgres.py +10 -26
- agno/db/postgres/postgres.py +7 -25
- agno/db/redis/redis.py +0 -4
- agno/db/schemas/evals.py +1 -0
- agno/db/singlestore/singlestore.py +5 -12
- agno/db/sqlite/async_sqlite.py +2 -26
- agno/db/sqlite/sqlite.py +0 -20
- agno/eval/__init__.py +10 -0
- agno/eval/agent_as_judge.py +860 -0
- agno/eval/base.py +29 -0
- agno/eval/utils.py +2 -1
- agno/exceptions.py +7 -0
- agno/knowledge/embedder/openai.py +8 -8
- agno/knowledge/knowledge.py +1142 -176
- agno/media.py +22 -6
- agno/models/aws/claude.py +8 -7
- agno/models/base.py +160 -11
- agno/models/deepseek/deepseek.py +67 -0
- agno/models/google/gemini.py +65 -11
- agno/models/google/utils.py +22 -0
- agno/models/message.py +2 -0
- agno/models/openai/chat.py +4 -0
- agno/models/openai/responses.py +3 -2
- agno/os/app.py +64 -74
- agno/os/interfaces/a2a/router.py +3 -4
- agno/os/interfaces/a2a/utils.py +1 -1
- agno/os/interfaces/agui/router.py +2 -0
- agno/os/middleware/jwt.py +8 -6
- agno/os/router.py +3 -1607
- agno/os/routers/agents/__init__.py +3 -0
- agno/os/routers/agents/router.py +581 -0
- agno/os/routers/agents/schema.py +261 -0
- agno/os/routers/evals/evals.py +26 -6
- agno/os/routers/evals/schemas.py +34 -2
- agno/os/routers/evals/utils.py +101 -20
- agno/os/routers/knowledge/knowledge.py +1 -1
- agno/os/routers/teams/__init__.py +3 -0
- agno/os/routers/teams/router.py +496 -0
- agno/os/routers/teams/schema.py +257 -0
- agno/os/routers/workflows/__init__.py +3 -0
- agno/os/routers/workflows/router.py +545 -0
- agno/os/routers/workflows/schema.py +75 -0
- agno/os/schema.py +1 -559
- agno/os/utils.py +139 -2
- agno/team/team.py +159 -100
- agno/tools/file_generation.py +12 -6
- agno/tools/firecrawl.py +15 -7
- agno/tools/workflow.py +8 -1
- agno/utils/hooks.py +64 -5
- agno/utils/http.py +2 -2
- agno/utils/media.py +11 -1
- agno/utils/print_response/agent.py +8 -0
- agno/utils/print_response/team.py +8 -0
- agno/vectordb/pgvector/pgvector.py +88 -51
- agno/workflow/parallel.py +11 -5
- agno/workflow/step.py +17 -5
- agno/workflow/types.py +38 -2
- agno/workflow/workflow.py +12 -4
- {agno-2.3.7.dist-info → agno-2.3.9.dist-info}/METADATA +8 -3
- {agno-2.3.7.dist-info → agno-2.3.9.dist-info}/RECORD +70 -58
- agno/tools/memori.py +0 -339
- {agno-2.3.7.dist-info → agno-2.3.9.dist-info}/WHEEL +0 -0
- {agno-2.3.7.dist-info → agno-2.3.9.dist-info}/licenses/LICENSE +0 -0
- {agno-2.3.7.dist-info → agno-2.3.9.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, List, Optional, Union, cast
|
|
3
|
+
from uuid import uuid4
|
|
4
|
+
|
|
5
|
+
from fastapi import (
|
|
6
|
+
APIRouter,
|
|
7
|
+
BackgroundTasks,
|
|
8
|
+
Depends,
|
|
9
|
+
Form,
|
|
10
|
+
HTTPException,
|
|
11
|
+
Request,
|
|
12
|
+
WebSocket,
|
|
13
|
+
)
|
|
14
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
from agno.exceptions import InputCheckError, OutputCheckError
|
|
18
|
+
from agno.os.auth import get_authentication_dependency, validate_websocket_token
|
|
19
|
+
from agno.os.routers.workflows.schema import WorkflowResponse
|
|
20
|
+
from agno.os.schema import (
|
|
21
|
+
BadRequestResponse,
|
|
22
|
+
InternalServerErrorResponse,
|
|
23
|
+
NotFoundResponse,
|
|
24
|
+
UnauthenticatedResponse,
|
|
25
|
+
ValidationErrorResponse,
|
|
26
|
+
WorkflowSummaryResponse,
|
|
27
|
+
)
|
|
28
|
+
from agno.os.settings import AgnoAPISettings
|
|
29
|
+
from agno.os.utils import (
|
|
30
|
+
format_sse_event,
|
|
31
|
+
get_request_kwargs,
|
|
32
|
+
get_workflow_by_id,
|
|
33
|
+
)
|
|
34
|
+
from agno.run.workflow import WorkflowErrorEvent, WorkflowRunOutput
|
|
35
|
+
from agno.utils.log import log_warning, logger
|
|
36
|
+
from agno.workflow.workflow import Workflow
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from agno.os.app import AgentOS
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class WebSocketManager:
|
|
43
|
+
"""Manages WebSocket connections for workflow runs"""
|
|
44
|
+
|
|
45
|
+
active_connections: Dict[str, WebSocket] # {run_id: websocket}
|
|
46
|
+
authenticated_connections: Dict[WebSocket, bool] # {websocket: is_authenticated}
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
active_connections: Optional[Dict[str, WebSocket]] = None,
|
|
51
|
+
):
|
|
52
|
+
# Store active connections: {run_id: websocket}
|
|
53
|
+
self.active_connections = active_connections or {}
|
|
54
|
+
# Track authentication state for each websocket
|
|
55
|
+
self.authenticated_connections = {}
|
|
56
|
+
|
|
57
|
+
async def connect(self, websocket: WebSocket, requires_auth: bool = True):
|
|
58
|
+
"""Accept WebSocket connection"""
|
|
59
|
+
await websocket.accept()
|
|
60
|
+
logger.debug("WebSocket connected")
|
|
61
|
+
|
|
62
|
+
# If auth is not required, mark as authenticated immediately
|
|
63
|
+
self.authenticated_connections[websocket] = not requires_auth
|
|
64
|
+
|
|
65
|
+
# Send connection confirmation with auth requirement info
|
|
66
|
+
await websocket.send_text(
|
|
67
|
+
json.dumps(
|
|
68
|
+
{
|
|
69
|
+
"event": "connected",
|
|
70
|
+
"message": (
|
|
71
|
+
"Connected to workflow events. Please authenticate to continue."
|
|
72
|
+
if requires_auth
|
|
73
|
+
else "Connected to workflow events. Authentication not required."
|
|
74
|
+
),
|
|
75
|
+
"requires_auth": requires_auth,
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
async def authenticate_websocket(self, websocket: WebSocket):
|
|
81
|
+
"""Mark a WebSocket connection as authenticated"""
|
|
82
|
+
self.authenticated_connections[websocket] = True
|
|
83
|
+
logger.debug("WebSocket authenticated")
|
|
84
|
+
|
|
85
|
+
# Send authentication confirmation
|
|
86
|
+
await websocket.send_text(
|
|
87
|
+
json.dumps(
|
|
88
|
+
{
|
|
89
|
+
"event": "authenticated",
|
|
90
|
+
"message": "Authentication successful. You can now send commands.",
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def is_authenticated(self, websocket: WebSocket) -> bool:
|
|
96
|
+
"""Check if a WebSocket connection is authenticated"""
|
|
97
|
+
return self.authenticated_connections.get(websocket, False)
|
|
98
|
+
|
|
99
|
+
async def register_workflow_websocket(self, run_id: str, websocket: WebSocket):
|
|
100
|
+
"""Register a workflow run with its WebSocket connection"""
|
|
101
|
+
self.active_connections[run_id] = websocket
|
|
102
|
+
logger.debug(f"Registered WebSocket for run_id: {run_id}")
|
|
103
|
+
|
|
104
|
+
async def disconnect_by_run_id(self, run_id: str):
|
|
105
|
+
"""Remove WebSocket connection by run_id"""
|
|
106
|
+
if run_id in self.active_connections:
|
|
107
|
+
websocket = self.active_connections[run_id]
|
|
108
|
+
del self.active_connections[run_id]
|
|
109
|
+
# Clean up authentication state
|
|
110
|
+
if websocket in self.authenticated_connections:
|
|
111
|
+
del self.authenticated_connections[websocket]
|
|
112
|
+
logger.debug(f"WebSocket disconnected for run_id: {run_id}")
|
|
113
|
+
|
|
114
|
+
async def disconnect_websocket(self, websocket: WebSocket):
|
|
115
|
+
"""Remove WebSocket connection and clean up all associated state"""
|
|
116
|
+
# Remove from authenticated connections
|
|
117
|
+
if websocket in self.authenticated_connections:
|
|
118
|
+
del self.authenticated_connections[websocket]
|
|
119
|
+
|
|
120
|
+
# Remove from active connections
|
|
121
|
+
runs_to_remove = [run_id for run_id, ws in self.active_connections.items() if ws == websocket]
|
|
122
|
+
for run_id in runs_to_remove:
|
|
123
|
+
del self.active_connections[run_id]
|
|
124
|
+
|
|
125
|
+
logger.debug("WebSocket disconnected and cleaned up")
|
|
126
|
+
|
|
127
|
+
async def get_websocket_for_run(self, run_id: str) -> Optional[WebSocket]:
|
|
128
|
+
"""Get WebSocket connection for a workflow run"""
|
|
129
|
+
return self.active_connections.get(run_id)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# Global manager instance
|
|
133
|
+
websocket_manager = WebSocketManager(
|
|
134
|
+
active_connections={},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def handle_workflow_via_websocket(websocket: WebSocket, message: dict, os: "AgentOS"):
|
|
139
|
+
"""Handle workflow execution directly via WebSocket"""
|
|
140
|
+
try:
|
|
141
|
+
workflow_id = message.get("workflow_id")
|
|
142
|
+
session_id = message.get("session_id")
|
|
143
|
+
user_message = message.get("message", "")
|
|
144
|
+
user_id = message.get("user_id")
|
|
145
|
+
|
|
146
|
+
if not workflow_id:
|
|
147
|
+
await websocket.send_text(json.dumps({"event": "error", "error": "workflow_id is required"}))
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
# Get workflow from OS
|
|
151
|
+
workflow = get_workflow_by_id(workflow_id, os.workflows)
|
|
152
|
+
if not workflow:
|
|
153
|
+
await websocket.send_text(json.dumps({"event": "error", "error": f"Workflow {workflow_id} not found"}))
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
# Generate session_id if not provided
|
|
157
|
+
# Use workflow's default session_id if not provided in message
|
|
158
|
+
if not session_id:
|
|
159
|
+
if workflow.session_id:
|
|
160
|
+
session_id = workflow.session_id
|
|
161
|
+
else:
|
|
162
|
+
session_id = str(uuid4())
|
|
163
|
+
|
|
164
|
+
# Execute workflow in background with streaming
|
|
165
|
+
workflow_result = await workflow.arun( # type: ignore
|
|
166
|
+
input=user_message,
|
|
167
|
+
session_id=session_id,
|
|
168
|
+
user_id=user_id,
|
|
169
|
+
stream=True,
|
|
170
|
+
stream_events=True,
|
|
171
|
+
background=True,
|
|
172
|
+
websocket=websocket,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
workflow_run_output = cast(WorkflowRunOutput, workflow_result)
|
|
176
|
+
|
|
177
|
+
await websocket_manager.register_workflow_websocket(workflow_run_output.run_id, websocket) # type: ignore
|
|
178
|
+
|
|
179
|
+
except (InputCheckError, OutputCheckError) as e:
|
|
180
|
+
await websocket.send_text(
|
|
181
|
+
json.dumps(
|
|
182
|
+
{
|
|
183
|
+
"event": "error",
|
|
184
|
+
"error": str(e),
|
|
185
|
+
"error_type": e.type,
|
|
186
|
+
"error_id": e.error_id,
|
|
187
|
+
"additional_data": e.additional_data,
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
except Exception as e:
|
|
192
|
+
logger.error(f"Error executing workflow via WebSocket: {e}")
|
|
193
|
+
error_payload = {
|
|
194
|
+
"event": "error",
|
|
195
|
+
"error": str(e),
|
|
196
|
+
"error_type": e.type if hasattr(e, "type") else None,
|
|
197
|
+
"error_id": e.error_id if hasattr(e, "error_id") else None,
|
|
198
|
+
}
|
|
199
|
+
error_payload = {k: v for k, v in error_payload.items() if v is not None}
|
|
200
|
+
await websocket.send_text(json.dumps(error_payload))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def workflow_response_streamer(
|
|
204
|
+
workflow: Workflow,
|
|
205
|
+
input: Optional[Union[str, Dict[str, Any], List[Any], BaseModel]] = None,
|
|
206
|
+
session_id: Optional[str] = None,
|
|
207
|
+
user_id: Optional[str] = None,
|
|
208
|
+
background_tasks: Optional[BackgroundTasks] = None,
|
|
209
|
+
**kwargs: Any,
|
|
210
|
+
) -> AsyncGenerator:
|
|
211
|
+
try:
|
|
212
|
+
# Pass background_tasks if provided
|
|
213
|
+
if background_tasks is not None:
|
|
214
|
+
kwargs["background_tasks"] = background_tasks
|
|
215
|
+
|
|
216
|
+
run_response = workflow.arun(
|
|
217
|
+
input=input,
|
|
218
|
+
session_id=session_id,
|
|
219
|
+
user_id=user_id,
|
|
220
|
+
stream=True,
|
|
221
|
+
stream_events=True,
|
|
222
|
+
**kwargs,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
async for run_response_chunk in run_response:
|
|
226
|
+
yield format_sse_event(run_response_chunk) # type: ignore
|
|
227
|
+
|
|
228
|
+
except (InputCheckError, OutputCheckError) as e:
|
|
229
|
+
error_response = WorkflowErrorEvent(
|
|
230
|
+
error=str(e),
|
|
231
|
+
error_type=e.type,
|
|
232
|
+
error_id=e.error_id,
|
|
233
|
+
additional_data=e.additional_data,
|
|
234
|
+
)
|
|
235
|
+
yield format_sse_event(error_response)
|
|
236
|
+
|
|
237
|
+
except Exception as e:
|
|
238
|
+
import traceback
|
|
239
|
+
|
|
240
|
+
traceback.print_exc()
|
|
241
|
+
error_response = WorkflowErrorEvent(
|
|
242
|
+
error=str(e),
|
|
243
|
+
error_type=e.type if hasattr(e, "type") else None,
|
|
244
|
+
error_id=e.error_id if hasattr(e, "error_id") else None,
|
|
245
|
+
)
|
|
246
|
+
yield format_sse_event(error_response)
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def get_websocket_router(
|
|
251
|
+
os: "AgentOS",
|
|
252
|
+
settings: AgnoAPISettings = AgnoAPISettings(),
|
|
253
|
+
) -> APIRouter:
|
|
254
|
+
"""
|
|
255
|
+
Create WebSocket router without HTTP authentication dependencies.
|
|
256
|
+
WebSocket endpoints handle authentication internally via message-based auth.
|
|
257
|
+
"""
|
|
258
|
+
ws_router = APIRouter()
|
|
259
|
+
|
|
260
|
+
@ws_router.websocket(
|
|
261
|
+
"/workflows/ws",
|
|
262
|
+
name="workflow_websocket",
|
|
263
|
+
)
|
|
264
|
+
async def workflow_websocket_endpoint(websocket: WebSocket):
|
|
265
|
+
"""WebSocket endpoint for receiving real-time workflow events"""
|
|
266
|
+
requires_auth = bool(settings.os_security_key)
|
|
267
|
+
await websocket_manager.connect(websocket, requires_auth=requires_auth)
|
|
268
|
+
|
|
269
|
+
try:
|
|
270
|
+
while True:
|
|
271
|
+
data = await websocket.receive_text()
|
|
272
|
+
message = json.loads(data)
|
|
273
|
+
action = message.get("action")
|
|
274
|
+
|
|
275
|
+
# Handle authentication first
|
|
276
|
+
if action == "authenticate":
|
|
277
|
+
token = message.get("token")
|
|
278
|
+
if not token:
|
|
279
|
+
await websocket.send_text(json.dumps({"event": "auth_error", "error": "Token is required"}))
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
if validate_websocket_token(token, settings):
|
|
283
|
+
await websocket_manager.authenticate_websocket(websocket)
|
|
284
|
+
else:
|
|
285
|
+
await websocket.send_text(json.dumps({"event": "auth_error", "error": "Invalid token"}))
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
# Check authentication for all other actions (only when required)
|
|
289
|
+
elif requires_auth and not websocket_manager.is_authenticated(websocket):
|
|
290
|
+
await websocket.send_text(
|
|
291
|
+
json.dumps(
|
|
292
|
+
{
|
|
293
|
+
"event": "auth_required",
|
|
294
|
+
"error": "Authentication required. Send authenticate action with valid token.",
|
|
295
|
+
}
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
# Handle authenticated actions
|
|
301
|
+
elif action == "ping":
|
|
302
|
+
await websocket.send_text(json.dumps({"event": "pong"}))
|
|
303
|
+
|
|
304
|
+
elif action == "start-workflow":
|
|
305
|
+
# Handle workflow execution directly via WebSocket
|
|
306
|
+
await handle_workflow_via_websocket(websocket, message, os)
|
|
307
|
+
|
|
308
|
+
else:
|
|
309
|
+
await websocket.send_text(json.dumps({"event": "error", "error": f"Unknown action: {action}"}))
|
|
310
|
+
|
|
311
|
+
except Exception as e:
|
|
312
|
+
if "1012" not in str(e) and "1001" not in str(e):
|
|
313
|
+
logger.error(f"WebSocket error: {e}")
|
|
314
|
+
finally:
|
|
315
|
+
# Clean up the websocket connection
|
|
316
|
+
await websocket_manager.disconnect_websocket(websocket)
|
|
317
|
+
|
|
318
|
+
return ws_router
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def get_workflow_router(
|
|
322
|
+
os: "AgentOS",
|
|
323
|
+
settings: AgnoAPISettings = AgnoAPISettings(),
|
|
324
|
+
) -> APIRouter:
|
|
325
|
+
"""Create the workflow router with comprehensive OpenAPI documentation."""
|
|
326
|
+
router = APIRouter(
|
|
327
|
+
dependencies=[Depends(get_authentication_dependency(settings))],
|
|
328
|
+
responses={
|
|
329
|
+
400: {"description": "Bad Request", "model": BadRequestResponse},
|
|
330
|
+
401: {"description": "Unauthorized", "model": UnauthenticatedResponse},
|
|
331
|
+
404: {"description": "Not Found", "model": NotFoundResponse},
|
|
332
|
+
422: {"description": "Validation Error", "model": ValidationErrorResponse},
|
|
333
|
+
500: {"description": "Internal Server Error", "model": InternalServerErrorResponse},
|
|
334
|
+
},
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
@router.get(
|
|
338
|
+
"/workflows",
|
|
339
|
+
response_model=List[WorkflowSummaryResponse],
|
|
340
|
+
response_model_exclude_none=True,
|
|
341
|
+
tags=["Workflows"],
|
|
342
|
+
operation_id="get_workflows",
|
|
343
|
+
summary="List All Workflows",
|
|
344
|
+
description=(
|
|
345
|
+
"Retrieve a comprehensive list of all workflows configured in this OS instance.\n\n"
|
|
346
|
+
"**Return Information:**\n"
|
|
347
|
+
"- Workflow metadata (ID, name, description)\n"
|
|
348
|
+
"- Input schema requirements\n"
|
|
349
|
+
"- Step sequence and execution flow\n"
|
|
350
|
+
"- Associated agents and teams"
|
|
351
|
+
),
|
|
352
|
+
responses={
|
|
353
|
+
200: {
|
|
354
|
+
"description": "List of workflows retrieved successfully",
|
|
355
|
+
"content": {
|
|
356
|
+
"application/json": {
|
|
357
|
+
"example": [
|
|
358
|
+
{
|
|
359
|
+
"id": "content-creation-workflow",
|
|
360
|
+
"name": "Content Creation Workflow",
|
|
361
|
+
"description": "Automated content creation from blog posts to social media",
|
|
362
|
+
"db_id": "123",
|
|
363
|
+
}
|
|
364
|
+
]
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
)
|
|
370
|
+
async def get_workflows() -> List[WorkflowSummaryResponse]:
|
|
371
|
+
if os.workflows is None:
|
|
372
|
+
return []
|
|
373
|
+
|
|
374
|
+
return [WorkflowSummaryResponse.from_workflow(workflow) for workflow in os.workflows]
|
|
375
|
+
|
|
376
|
+
@router.get(
|
|
377
|
+
"/workflows/{workflow_id}",
|
|
378
|
+
response_model=WorkflowResponse,
|
|
379
|
+
response_model_exclude_none=True,
|
|
380
|
+
tags=["Workflows"],
|
|
381
|
+
operation_id="get_workflow",
|
|
382
|
+
summary="Get Workflow Details",
|
|
383
|
+
description=("Retrieve detailed configuration and step information for a specific workflow."),
|
|
384
|
+
responses={
|
|
385
|
+
200: {
|
|
386
|
+
"description": "Workflow details retrieved successfully",
|
|
387
|
+
"content": {
|
|
388
|
+
"application/json": {
|
|
389
|
+
"example": {
|
|
390
|
+
"id": "content-creation-workflow",
|
|
391
|
+
"name": "Content Creation Workflow",
|
|
392
|
+
"description": "Automated content creation from blog posts to social media",
|
|
393
|
+
"db_id": "123",
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
404: {"description": "Workflow not found", "model": NotFoundResponse},
|
|
399
|
+
},
|
|
400
|
+
)
|
|
401
|
+
async def get_workflow(workflow_id: str) -> WorkflowResponse:
|
|
402
|
+
workflow = get_workflow_by_id(workflow_id, os.workflows)
|
|
403
|
+
if workflow is None:
|
|
404
|
+
raise HTTPException(status_code=404, detail="Workflow not found")
|
|
405
|
+
|
|
406
|
+
return await WorkflowResponse.from_workflow(workflow)
|
|
407
|
+
|
|
408
|
+
@router.post(
|
|
409
|
+
"/workflows/{workflow_id}/runs",
|
|
410
|
+
tags=["Workflows"],
|
|
411
|
+
operation_id="create_workflow_run",
|
|
412
|
+
response_model_exclude_none=True,
|
|
413
|
+
summary="Execute Workflow",
|
|
414
|
+
description=(
|
|
415
|
+
"Execute a workflow with the provided input data. Workflows can run in streaming or batch mode.\n\n"
|
|
416
|
+
"**Execution Modes:**\n"
|
|
417
|
+
"- **Streaming (`stream=true`)**: Real-time step-by-step execution updates via SSE\n"
|
|
418
|
+
"- **Non-Streaming (`stream=false`)**: Complete workflow execution with final result\n\n"
|
|
419
|
+
"**Workflow Execution Process:**\n"
|
|
420
|
+
"1. Input validation against workflow schema\n"
|
|
421
|
+
"2. Sequential or parallel step execution based on workflow design\n"
|
|
422
|
+
"3. Data flow between steps with transformation\n"
|
|
423
|
+
"4. Error handling and automatic retries where configured\n"
|
|
424
|
+
"5. Final result compilation and response\n\n"
|
|
425
|
+
"**Session Management:**\n"
|
|
426
|
+
"Workflows support session continuity for stateful execution across multiple runs."
|
|
427
|
+
),
|
|
428
|
+
responses={
|
|
429
|
+
200: {
|
|
430
|
+
"description": "Workflow executed successfully",
|
|
431
|
+
"content": {
|
|
432
|
+
"text/event-stream": {
|
|
433
|
+
"example": 'event: RunStarted\ndata: {"content": "Hello!", "run_id": "123..."}\n\n'
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
400: {"description": "Invalid input data or workflow configuration", "model": BadRequestResponse},
|
|
438
|
+
404: {"description": "Workflow not found", "model": NotFoundResponse},
|
|
439
|
+
500: {"description": "Workflow execution error", "model": InternalServerErrorResponse},
|
|
440
|
+
},
|
|
441
|
+
)
|
|
442
|
+
async def create_workflow_run(
|
|
443
|
+
workflow_id: str,
|
|
444
|
+
request: Request,
|
|
445
|
+
background_tasks: BackgroundTasks,
|
|
446
|
+
message: str = Form(...),
|
|
447
|
+
stream: bool = Form(True),
|
|
448
|
+
session_id: Optional[str] = Form(None),
|
|
449
|
+
user_id: Optional[str] = Form(None),
|
|
450
|
+
):
|
|
451
|
+
kwargs = await get_request_kwargs(request, create_workflow_run)
|
|
452
|
+
|
|
453
|
+
if hasattr(request.state, "user_id"):
|
|
454
|
+
if user_id:
|
|
455
|
+
log_warning("User ID parameter passed in both request state and kwargs, using request state")
|
|
456
|
+
user_id = request.state.user_id
|
|
457
|
+
if hasattr(request.state, "session_id"):
|
|
458
|
+
if session_id:
|
|
459
|
+
log_warning("Session ID parameter passed in both request state and kwargs, using request state")
|
|
460
|
+
session_id = request.state.session_id
|
|
461
|
+
if hasattr(request.state, "session_state"):
|
|
462
|
+
session_state = request.state.session_state
|
|
463
|
+
if "session_state" in kwargs:
|
|
464
|
+
log_warning("Session state parameter passed in both request state and kwargs, using request state")
|
|
465
|
+
kwargs["session_state"] = session_state
|
|
466
|
+
if hasattr(request.state, "dependencies"):
|
|
467
|
+
dependencies = request.state.dependencies
|
|
468
|
+
if "dependencies" in kwargs:
|
|
469
|
+
log_warning("Dependencies parameter passed in both request state and kwargs, using request state")
|
|
470
|
+
kwargs["dependencies"] = dependencies
|
|
471
|
+
if hasattr(request.state, "metadata"):
|
|
472
|
+
metadata = request.state.metadata
|
|
473
|
+
if "metadata" in kwargs:
|
|
474
|
+
log_warning("Metadata parameter passed in both request state and kwargs, using request state")
|
|
475
|
+
kwargs["metadata"] = metadata
|
|
476
|
+
|
|
477
|
+
# Retrieve the workflow by ID
|
|
478
|
+
workflow = get_workflow_by_id(workflow_id, os.workflows)
|
|
479
|
+
if workflow is None:
|
|
480
|
+
raise HTTPException(status_code=404, detail="Workflow not found")
|
|
481
|
+
|
|
482
|
+
if session_id:
|
|
483
|
+
logger.debug(f"Continuing session: {session_id}")
|
|
484
|
+
else:
|
|
485
|
+
logger.debug("Creating new session")
|
|
486
|
+
session_id = str(uuid4())
|
|
487
|
+
|
|
488
|
+
# Return based on stream parameter
|
|
489
|
+
try:
|
|
490
|
+
if stream:
|
|
491
|
+
return StreamingResponse(
|
|
492
|
+
workflow_response_streamer(
|
|
493
|
+
workflow,
|
|
494
|
+
input=message,
|
|
495
|
+
session_id=session_id,
|
|
496
|
+
user_id=user_id,
|
|
497
|
+
background_tasks=background_tasks,
|
|
498
|
+
**kwargs,
|
|
499
|
+
),
|
|
500
|
+
media_type="text/event-stream",
|
|
501
|
+
)
|
|
502
|
+
else:
|
|
503
|
+
run_response = await workflow.arun(
|
|
504
|
+
input=message,
|
|
505
|
+
session_id=session_id,
|
|
506
|
+
user_id=user_id,
|
|
507
|
+
stream=False,
|
|
508
|
+
background_tasks=background_tasks,
|
|
509
|
+
**kwargs,
|
|
510
|
+
)
|
|
511
|
+
return run_response.to_dict()
|
|
512
|
+
|
|
513
|
+
except InputCheckError as e:
|
|
514
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
515
|
+
except Exception as e:
|
|
516
|
+
# Handle unexpected runtime errors
|
|
517
|
+
raise HTTPException(status_code=500, detail=f"Error running workflow: {str(e)}")
|
|
518
|
+
|
|
519
|
+
@router.post(
|
|
520
|
+
"/workflows/{workflow_id}/runs/{run_id}/cancel",
|
|
521
|
+
tags=["Workflows"],
|
|
522
|
+
operation_id="cancel_workflow_run",
|
|
523
|
+
summary="Cancel Workflow Run",
|
|
524
|
+
description=(
|
|
525
|
+
"Cancel a currently executing workflow run, stopping all active steps and cleanup.\n"
|
|
526
|
+
"**Note:** Complex workflows with multiple parallel steps may take time to fully cancel."
|
|
527
|
+
),
|
|
528
|
+
responses={
|
|
529
|
+
200: {},
|
|
530
|
+
404: {"description": "Workflow or run not found", "model": NotFoundResponse},
|
|
531
|
+
500: {"description": "Failed to cancel workflow run", "model": InternalServerErrorResponse},
|
|
532
|
+
},
|
|
533
|
+
)
|
|
534
|
+
async def cancel_workflow_run(workflow_id: str, run_id: str):
|
|
535
|
+
workflow = get_workflow_by_id(workflow_id, os.workflows)
|
|
536
|
+
|
|
537
|
+
if workflow is None:
|
|
538
|
+
raise HTTPException(status_code=404, detail="Workflow not found")
|
|
539
|
+
|
|
540
|
+
if not workflow.cancel_run(run_id=run_id):
|
|
541
|
+
raise HTTPException(status_code=500, detail="Failed to cancel run")
|
|
542
|
+
|
|
543
|
+
return JSONResponse(content={}, status_code=200)
|
|
544
|
+
|
|
545
|
+
return router
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from agno.os.routers.agents.schema import AgentResponse
|
|
6
|
+
from agno.os.routers.teams.schema import TeamResponse
|
|
7
|
+
from agno.os.utils import get_workflow_input_schema_dict
|
|
8
|
+
from agno.workflow.agent import WorkflowAgent
|
|
9
|
+
from agno.workflow.workflow import Workflow
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WorkflowResponse(BaseModel):
|
|
13
|
+
id: Optional[str] = Field(None, description="Unique identifier for the workflow")
|
|
14
|
+
name: Optional[str] = Field(None, description="Name of the workflow")
|
|
15
|
+
db_id: Optional[str] = Field(None, description="Database identifier")
|
|
16
|
+
description: Optional[str] = Field(None, description="Description of the workflow")
|
|
17
|
+
input_schema: Optional[Dict[str, Any]] = Field(None, description="Input schema for the workflow")
|
|
18
|
+
steps: Optional[List[Dict[str, Any]]] = Field(None, description="List of workflow steps")
|
|
19
|
+
agent: Optional[AgentResponse] = Field(None, description="Agent configuration if used")
|
|
20
|
+
team: Optional[TeamResponse] = Field(None, description="Team configuration if used")
|
|
21
|
+
metadata: Optional[Dict[str, Any]] = Field(None, description="Additional metadata")
|
|
22
|
+
workflow_agent: bool = Field(False, description="Whether this workflow uses a WorkflowAgent")
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
async def _resolve_agents_and_teams_recursively(cls, steps: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
26
|
+
"""Parse Agents and Teams into AgentResponse and TeamResponse objects.
|
|
27
|
+
|
|
28
|
+
If the given steps have nested steps, recursively work on those."""
|
|
29
|
+
if not steps:
|
|
30
|
+
return steps
|
|
31
|
+
|
|
32
|
+
def _prune_none(value: Any) -> Any:
|
|
33
|
+
# Recursively remove None values from dicts and lists
|
|
34
|
+
if isinstance(value, dict):
|
|
35
|
+
return {k: _prune_none(v) for k, v in value.items() if v is not None}
|
|
36
|
+
if isinstance(value, list):
|
|
37
|
+
return [_prune_none(v) for v in value]
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
for idx, step in enumerate(steps):
|
|
41
|
+
if step.get("agent"):
|
|
42
|
+
# Convert to dict and exclude fields that are None
|
|
43
|
+
agent_response = await AgentResponse.from_agent(step.get("agent")) # type: ignore
|
|
44
|
+
step["agent"] = agent_response.model_dump(exclude_none=True)
|
|
45
|
+
|
|
46
|
+
if step.get("team"):
|
|
47
|
+
team_response = await TeamResponse.from_team(step.get("team")) # type: ignore
|
|
48
|
+
step["team"] = team_response.model_dump(exclude_none=True)
|
|
49
|
+
|
|
50
|
+
if step.get("steps"):
|
|
51
|
+
step["steps"] = await cls._resolve_agents_and_teams_recursively(step["steps"])
|
|
52
|
+
|
|
53
|
+
# Prune None values in the entire step
|
|
54
|
+
steps[idx] = _prune_none(step)
|
|
55
|
+
|
|
56
|
+
return steps
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
async def from_workflow(cls, workflow: Workflow) -> "WorkflowResponse":
|
|
60
|
+
workflow_dict = workflow.to_dict()
|
|
61
|
+
steps = workflow_dict.get("steps")
|
|
62
|
+
|
|
63
|
+
if steps:
|
|
64
|
+
steps = await cls._resolve_agents_and_teams_recursively(steps)
|
|
65
|
+
|
|
66
|
+
return cls(
|
|
67
|
+
id=workflow.id,
|
|
68
|
+
name=workflow.name,
|
|
69
|
+
db_id=workflow.db.id if workflow.db else None,
|
|
70
|
+
description=workflow.description,
|
|
71
|
+
steps=steps,
|
|
72
|
+
input_schema=get_workflow_input_schema_dict(workflow),
|
|
73
|
+
metadata=workflow.metadata,
|
|
74
|
+
workflow_agent=isinstance(workflow.agent, WorkflowAgent) if workflow.agent else False,
|
|
75
|
+
)
|