supervaizer 0.10.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. supervaizer/__init__.py +97 -0
  2. supervaizer/__version__.py +10 -0
  3. supervaizer/account.py +308 -0
  4. supervaizer/account_service.py +93 -0
  5. supervaizer/admin/routes.py +1293 -0
  6. supervaizer/admin/static/js/job-start-form.js +373 -0
  7. supervaizer/admin/templates/agent_detail.html +145 -0
  8. supervaizer/admin/templates/agents.html +249 -0
  9. supervaizer/admin/templates/agents_grid.html +82 -0
  10. supervaizer/admin/templates/base.html +233 -0
  11. supervaizer/admin/templates/case_detail.html +230 -0
  12. supervaizer/admin/templates/cases_list.html +182 -0
  13. supervaizer/admin/templates/cases_table.html +134 -0
  14. supervaizer/admin/templates/console.html +389 -0
  15. supervaizer/admin/templates/dashboard.html +153 -0
  16. supervaizer/admin/templates/job_detail.html +192 -0
  17. supervaizer/admin/templates/job_start_test.html +109 -0
  18. supervaizer/admin/templates/jobs_list.html +180 -0
  19. supervaizer/admin/templates/jobs_table.html +122 -0
  20. supervaizer/admin/templates/navigation.html +163 -0
  21. supervaizer/admin/templates/recent_activity.html +81 -0
  22. supervaizer/admin/templates/server.html +105 -0
  23. supervaizer/admin/templates/server_status_cards.html +121 -0
  24. supervaizer/admin/templates/supervaize_instructions.html +212 -0
  25. supervaizer/agent.py +956 -0
  26. supervaizer/case.py +432 -0
  27. supervaizer/cli.py +395 -0
  28. supervaizer/common.py +324 -0
  29. supervaizer/deploy/__init__.py +16 -0
  30. supervaizer/deploy/cli.py +305 -0
  31. supervaizer/deploy/commands/__init__.py +9 -0
  32. supervaizer/deploy/commands/clean.py +294 -0
  33. supervaizer/deploy/commands/down.py +119 -0
  34. supervaizer/deploy/commands/local.py +460 -0
  35. supervaizer/deploy/commands/plan.py +167 -0
  36. supervaizer/deploy/commands/status.py +169 -0
  37. supervaizer/deploy/commands/up.py +281 -0
  38. supervaizer/deploy/docker.py +377 -0
  39. supervaizer/deploy/driver_factory.py +42 -0
  40. supervaizer/deploy/drivers/__init__.py +39 -0
  41. supervaizer/deploy/drivers/aws_app_runner.py +607 -0
  42. supervaizer/deploy/drivers/base.py +196 -0
  43. supervaizer/deploy/drivers/cloud_run.py +570 -0
  44. supervaizer/deploy/drivers/do_app_platform.py +504 -0
  45. supervaizer/deploy/health.py +404 -0
  46. supervaizer/deploy/state.py +210 -0
  47. supervaizer/deploy/templates/Dockerfile.template +44 -0
  48. supervaizer/deploy/templates/debug_env.py +69 -0
  49. supervaizer/deploy/templates/docker-compose.yml.template +37 -0
  50. supervaizer/deploy/templates/dockerignore.template +66 -0
  51. supervaizer/deploy/templates/entrypoint.sh +20 -0
  52. supervaizer/deploy/utils.py +52 -0
  53. supervaizer/event.py +181 -0
  54. supervaizer/examples/controller_template.py +196 -0
  55. supervaizer/instructions.py +145 -0
  56. supervaizer/job.py +392 -0
  57. supervaizer/job_service.py +156 -0
  58. supervaizer/lifecycle.py +417 -0
  59. supervaizer/parameter.py +233 -0
  60. supervaizer/protocol/__init__.py +11 -0
  61. supervaizer/protocol/a2a/__init__.py +21 -0
  62. supervaizer/protocol/a2a/model.py +227 -0
  63. supervaizer/protocol/a2a/routes.py +99 -0
  64. supervaizer/py.typed +1 -0
  65. supervaizer/routes.py +917 -0
  66. supervaizer/server.py +553 -0
  67. supervaizer/server_utils.py +54 -0
  68. supervaizer/storage.py +462 -0
  69. supervaizer/telemetry.py +81 -0
  70. supervaizer/utils/__init__.py +16 -0
  71. supervaizer/utils/version_check.py +56 -0
  72. supervaizer-0.10.5.dist-info/METADATA +317 -0
  73. supervaizer-0.10.5.dist-info/RECORD +76 -0
  74. supervaizer-0.10.5.dist-info/WHEEL +4 -0
  75. supervaizer-0.10.5.dist-info/entry_points.txt +2 -0
  76. supervaizer-0.10.5.dist-info/licenses/LICENSE.md +346 -0
supervaizer/routes.py ADDED
@@ -0,0 +1,917 @@
1
+ # Copyright (c) 2024-2025 Alain Prasquier - Supervaize.com. All rights reserved.
2
+ #
3
+ # This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
4
+ # If a copy of the MPL was not distributed with this file, you can obtain one at
5
+ # https://mozilla.org/MPL/2.0/.
6
+
7
+ import traceback
8
+ from functools import wraps
9
+ from pathlib import Path
10
+ from typing import (
11
+ TYPE_CHECKING,
12
+ Any,
13
+ Awaitable,
14
+ Callable,
15
+ Dict,
16
+ List,
17
+ Optional,
18
+ TypeVar,
19
+ Union,
20
+ )
21
+
22
+ from cryptography.hazmat.primitives import serialization
23
+ from fastapi import (
24
+ APIRouter,
25
+ BackgroundTasks,
26
+ Body,
27
+ Depends,
28
+ HTTPException,
29
+ Query,
30
+ Request,
31
+ Security,
32
+ status as http_status,
33
+ )
34
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
35
+ from fastapi.templating import Jinja2Templates
36
+
37
+ from supervaizer.agent import (
38
+ Agent,
39
+ AgentMethodParams,
40
+ AgentResponse,
41
+ )
42
+ from supervaizer.case import CaseNodeUpdate, Cases
43
+ from supervaizer.common import SvBaseModel, log
44
+ from supervaizer.job import Job, JobContext, JobResponse, Jobs
45
+ from supervaizer.job_service import service_job_custom, service_job_start
46
+ from supervaizer.lifecycle import EntityStatus
47
+ from supervaizer.server_utils import ErrorResponse, ErrorType, create_error_response
48
+
49
+ if TYPE_CHECKING:
50
+ from enum import Enum
51
+
52
+ from supervaizer.server import Server
53
+
54
+ T = TypeVar("T")
55
+
56
+
57
+ class CaseUpdateRequest(SvBaseModel):
58
+ """Request model for updating a case with answer to a question."""
59
+
60
+ answer: Dict[str, Any]
61
+ message: Optional[str] = None
62
+
63
+
64
+ def handle_route_errors(
65
+ job_conflict_check: bool = False,
66
+ ) -> Callable[
67
+ [Callable[..., Awaitable[T]]], Callable[..., Awaitable[Union[T, JSONResponse]]]
68
+ ]:
69
+ """
70
+ Decorator to handle common route error patterns.
71
+
72
+ Args:
73
+ job_conflict_check: If True, checks for "already exists" in ValueError messages
74
+ and returns a conflict error response
75
+ """
76
+
77
+ def decorator(
78
+ func: Callable[..., Awaitable[T]],
79
+ ) -> Callable[..., Awaitable[Union[T, JSONResponse]]]:
80
+ @wraps(func)
81
+ async def wrapper(*args: Any, **kwargs: Any) -> Union[T, JSONResponse]:
82
+ # log.debug(f"------[DEBUG]----------\n args :{args} \n kwargs :{kwargs}")
83
+ try:
84
+ result: T = await func(*args, **kwargs)
85
+ return result
86
+
87
+ except HTTPException as e:
88
+ return create_error_response(
89
+ error_type=ErrorType.INVALID_REQUEST,
90
+ detail=e.detail if hasattr(e, "detail") else str(e),
91
+ status_code=e.status_code,
92
+ )
93
+ except ValueError as e:
94
+ if job_conflict_check and "already exists" in str(e):
95
+ return create_error_response(
96
+ ErrorType.JOB_ALREADY_EXISTS,
97
+ str(e),
98
+ http_status.HTTP_409_CONFLICT,
99
+ )
100
+ return create_error_response(
101
+ error_type=ErrorType.INVALID_REQUEST,
102
+ detail=str(e),
103
+ status_code=http_status.HTTP_400_BAD_REQUEST,
104
+ traceback=f"Error at line {traceback.extract_tb(e.__traceback__)[-1].lineno}:\n"
105
+ f"{traceback.format_exc()}",
106
+ )
107
+ except Exception as e:
108
+ return create_error_response(
109
+ error_type=ErrorType.INTERNAL_ERROR,
110
+ detail=str(e),
111
+ status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
112
+ traceback=f"Error at line {traceback.extract_tb(e.__traceback__)[-1].lineno}:\n"
113
+ f"{traceback.format_exc()}",
114
+ )
115
+
116
+ return wrapper
117
+
118
+ return decorator
119
+
120
+
121
+ async def get_server() -> "Server":
122
+ """Get the current server instance."""
123
+ raise NotImplementedError("This function should be overridden by the server")
124
+
125
+
126
+ def create_default_routes(server: "Server") -> APIRouter:
127
+ """Create default routes for the server."""
128
+ router = APIRouter(prefix="/supervaizer", tags=["Supervision"])
129
+
130
+ @router.get(
131
+ "/jobs/{job_id}",
132
+ response_model=JobResponse,
133
+ dependencies=[Security(server.verify_api_key)],
134
+ )
135
+ @handle_route_errors()
136
+ async def get_job_status(job_id: str) -> JobResponse:
137
+ """Get the status of a job by its ID"""
138
+ job = Jobs().get_job(job_id, include_persisted=True)
139
+ if not job:
140
+ raise HTTPException(
141
+ status_code=http_status.HTTP_404_NOT_FOUND,
142
+ detail=f"Job with ID {job_id} not found §SRCG01",
143
+ )
144
+ return JobResponse(
145
+ job_id=job.id,
146
+ status=job.status,
147
+ message=f"Job {job.id} status: {job.status.value}",
148
+ payload=job.payload,
149
+ )
150
+
151
+ @router.get(
152
+ "/jobs",
153
+ response_model=Dict[str, List[JobResponse]],
154
+ dependencies=[Security(server.verify_api_key)],
155
+ )
156
+ @handle_route_errors()
157
+ async def get_all_jobs(
158
+ skip: int = Query(default=0, ge=0, description="Number of jobs to skip"),
159
+ limit: int = Query(
160
+ default=100, ge=1, le=1000, description="Number of jobs to return"
161
+ ),
162
+ status: Optional[EntityStatus] = Query(
163
+ default=None, description="Filter jobs by status"
164
+ ),
165
+ ) -> Dict[str, List[JobResponse]]:
166
+ """Get all jobs across all agents with pagination and optional status filtering"""
167
+ jobs_registry = Jobs()
168
+ all_jobs: Dict[str, List[JobResponse]] = {}
169
+
170
+ for agent_name, agent_jobs in jobs_registry.jobs_by_agent.items():
171
+ filtered_jobs = list(agent_jobs.values())
172
+
173
+ # Apply status filter if specified
174
+ if status:
175
+ filtered_jobs = [job for job in filtered_jobs if job.status == status]
176
+
177
+ # Apply pagination
178
+ filtered_jobs = filtered_jobs[skip : skip + limit]
179
+
180
+ if filtered_jobs: # Only include agents that have jobs after filtering
181
+ # Convert each Job object to JobResponse
182
+ jobs_responses = []
183
+ for job in filtered_jobs:
184
+ job_status = job.status
185
+ if isinstance(job_status, str):
186
+ try:
187
+ job_status = EntityStatus(job_status)
188
+ except ValueError:
189
+ job_status = EntityStatus.IN_PROGRESS # fallback or default
190
+ jobs_responses.append(
191
+ JobResponse(
192
+ job_id=job.id,
193
+ status=job_status,
194
+ message=f"Job {job.id} status: {job_status.value}",
195
+ payload=job.payload,
196
+ )
197
+ )
198
+
199
+ all_jobs[agent_name] = jobs_responses
200
+
201
+ return all_jobs
202
+
203
+ @router.post(
204
+ "/jobs/{job_id}/cases/{case_id}/update",
205
+ summary="Update case with answer to question",
206
+ description="Provide an answer to a question that was requested by a case step",
207
+ response_model=Dict[str, str],
208
+ responses={
209
+ http_status.HTTP_200_OK: {"model": Dict[str, str]},
210
+ http_status.HTTP_404_NOT_FOUND: {"model": ErrorResponse},
211
+ http_status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse},
212
+ http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse},
213
+ },
214
+ dependencies=[Security(server.verify_api_key)],
215
+ )
216
+ @handle_route_errors()
217
+ async def update_case_with_answer(
218
+ job_id: str,
219
+ case_id: str,
220
+ request: CaseUpdateRequest = Body(...),
221
+ ) -> Dict[str, str]:
222
+ """Update a case with an answer to a question requested by a case step"""
223
+ log.info(
224
+ f"📥 POST /jobs/{job_id}/cases/{case_id}/update [Update case with answer]"
225
+ )
226
+
227
+ # Get the job first
228
+ job = Jobs().get_job(job_id)
229
+ if not job:
230
+ raise HTTPException(
231
+ status_code=http_status.HTTP_404_NOT_FOUND,
232
+ detail=f"Job with ID {job_id} not found §SRCU01",
233
+ )
234
+
235
+ # Get the case from the Cases registry
236
+ case = Cases().get_case(case_id, job_id)
237
+ if not case:
238
+ log.warning(f"Case with ID {case_id} not found for job {job_id} §SRCU02")
239
+ raise HTTPException(
240
+ status_code=http_status.HTTP_404_NOT_FOUND,
241
+ detail=f"Case with ID {case_id} not found for job {job_id} §SRCU02",
242
+ )
243
+ # Check if the case is in AWAITING status (waiting for human input)
244
+ if case.status != EntityStatus.AWAITING:
245
+ raise HTTPException(
246
+ status_code=http_status.HTTP_400_BAD_REQUEST,
247
+ detail=f"Case {case_id} is not awaiting input. Current status: {case.status.value} §SRC01",
248
+ )
249
+
250
+ # Create a case node update with the answer
251
+ update = CaseNodeUpdate(
252
+ name="Human Input Response",
253
+ payload={
254
+ "answer": request.answer,
255
+ "message": request.message,
256
+ "response_type": "human_input",
257
+ },
258
+ is_final=False,
259
+ )
260
+
261
+ # Update the case with the answer
262
+ # case.update(update) - Redundant, receive_human_input calls update()
263
+
264
+ # Transition the case from AWAITING to IN_PROGRESS
265
+ case.receive_human_input(update)
266
+
267
+ # TODO CALL CUSTOM HOOKS HERE - AS DEFINED IN THE AGENT CONFIGURATION
268
+ # TODO REDEFINE AGENT TO ADD CUSTOM HOOKS HERE
269
+
270
+ log.info(
271
+ f"[Case update] Job {job_id}, Case {case_id} - Answer processed successfully"
272
+ )
273
+
274
+ return {
275
+ "status": "success",
276
+ "message": f"Answer received and processed for case {case_id} in job {job_id}",
277
+ "job_id": job_id,
278
+ "case_id": case_id,
279
+ "case_status": case.status.value,
280
+ }
281
+
282
+ @router.get("/agents", response_model=List[AgentResponse])
283
+ @handle_route_errors()
284
+ async def get_all_agents(
285
+ skip: int = Query(default=0, ge=0, description="Number of jobs to skip"),
286
+ limit: int = Query(
287
+ default=100, ge=1, le=1000, description="Number of jobs to return"
288
+ ),
289
+ ) -> List[AgentResponse]:
290
+ """Get all registered agents with pagination"""
291
+ if not server:
292
+ raise ValueError("Server instance not found")
293
+ return [
294
+ AgentResponse(**a.registration_info)
295
+ for a in server.agents[skip : skip + limit]
296
+ ]
297
+
298
+ @router.get("/agent/{agent_id}", response_model=AgentResponse)
299
+ @handle_route_errors()
300
+ async def get_agent_details(
301
+ agent_id: str,
302
+ ) -> AgentResponse:
303
+ """Get details of a specific agent by ID"""
304
+ if not server:
305
+ raise ValueError("Server instance not found")
306
+ for agent in server.agents:
307
+ if agent.id == agent_id:
308
+ return AgentResponse(**agent.registration_info)
309
+
310
+ raise HTTPException(
311
+ status_code=http_status.HTTP_404_NOT_FOUND,
312
+ detail=f"Agent with ID '{agent_id}' not found",
313
+ )
314
+
315
+ return router
316
+
317
+
318
+ def create_utils_routes(server: "Server") -> APIRouter:
319
+ """Create utility routes."""
320
+ router = APIRouter(prefix="/supervaizer/utils", tags=["Supervision"])
321
+
322
+ @router.get(
323
+ "/public_key",
324
+ summary="Get server's public key",
325
+ description="Returns the server's public key in PEM format",
326
+ response_model=str,
327
+ )
328
+ @handle_route_errors()
329
+ async def get_public_key() -> str:
330
+ pem = server.public_key.public_bytes(
331
+ encoding=serialization.Encoding.PEM,
332
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
333
+ )
334
+ return pem.decode("utf-8")
335
+
336
+ @router.post(
337
+ "/encrypt",
338
+ summary="Encrypt a string",
339
+ description="Encrypts a string using the server's public key. Example: {'key':'value'}",
340
+ response_model=str,
341
+ response_description="The encrypted string",
342
+ )
343
+ @handle_route_errors()
344
+ async def encrypt_string(text: str = Body(...)) -> str:
345
+ return server.encrypt(text)
346
+
347
+ return router
348
+
349
+
350
+ def create_agents_routes(server: "Server") -> APIRouter:
351
+ """Create agent-specific routes."""
352
+ routers = APIRouter(prefix="/supervaizer", tags=["Supervision"])
353
+ for agent in server.agents:
354
+ routers.include_router(create_agent_route(server, agent))
355
+ # Add custom method routes for each agent
356
+ if agent.methods and agent.methods.custom:
357
+ routers.include_router(create_agent_custom_routes(server, agent))
358
+ return routers
359
+
360
+
361
+ def create_agent_route(server: "Server", agent: Agent) -> APIRouter:
362
+ """Create agent-specific routes."""
363
+ # tags: list[str | Enum] = [f"Agent {agent.name} v{agent.version}"]
364
+ tags: list[str | Enum] = ["Supervision"]
365
+ router = APIRouter(
366
+ prefix=agent.path,
367
+ tags=tags,
368
+ )
369
+
370
+ async def get_agent() -> Agent:
371
+ return agent
372
+
373
+ @router.get(
374
+ "/",
375
+ summary=f"Get information about the agent {agent.name}",
376
+ description="Detailed information about the agent, returned as a JSON object with Agent class fields",
377
+ response_model=AgentResponse,
378
+ responses={http_status.HTTP_200_OK: {"model": AgentResponse}},
379
+ tags=tags,
380
+ dependencies=[Security(server.verify_api_key)],
381
+ )
382
+ @handle_route_errors()
383
+ async def agent_info(agent: Agent = Depends(get_agent)) -> AgentResponse:
384
+ log.info(f"📥 GET /[Agent info] {agent.name}")
385
+ return AgentResponse(
386
+ **agent.registration_info,
387
+ )
388
+
389
+ @router.get(
390
+ f"/{agent.instructions_path}",
391
+ summary=f"Get supervaize instructions page for agent {agent.name}",
392
+ description="HTML page displaying agent registration information and instructions",
393
+ response_class=HTMLResponse,
394
+ tags=tags,
395
+ )
396
+ @handle_route_errors()
397
+ async def supervaize_instructions(
398
+ request: Request, agent: Agent = Depends(get_agent)
399
+ ) -> Response:
400
+ """Serve the supervaize instructions HTML page for this agent."""
401
+ log.info(
402
+ f"📥 GET /{agent.instructions_path} [Supervaize Instructions] for agent{agent.name}"
403
+ )
404
+
405
+ registration_info = agent.registration_info
406
+
407
+ # Convert instructions_path string to Path object
408
+ instructions_path = Path(agent.instructions_path)
409
+
410
+ # Check if file exists - if not, return empty HTML
411
+ if not instructions_path.exists() or not instructions_path.is_file():
412
+ return HTMLResponse(content="", status_code=200)
413
+
414
+ # Serve the file (check if it's a Jinja2 template or static HTML)
415
+ with open(instructions_path, "r", encoding="utf-8") as f:
416
+ content = f.read()
417
+ # Simple check: if it contains Jinja2 syntax, render as template
418
+ if "{{" in content or "{%" in content:
419
+ # Render as Jinja2 template
420
+ custom_templates = Jinja2Templates(
421
+ directory=str(instructions_path.parent)
422
+ )
423
+ return custom_templates.TemplateResponse(
424
+ instructions_path.name,
425
+ {
426
+ "request": request,
427
+ "registration_info": registration_info,
428
+ },
429
+ )
430
+ else:
431
+ # Serve as static HTML file
432
+ return FileResponse(
433
+ str(instructions_path),
434
+ media_type="text/html",
435
+ )
436
+
437
+ @router.post(
438
+ "/validate-agent-parameters",
439
+ summary=f"Validate agent parameters for agent: {agent.name}",
440
+ description="Validate agent configuration parameters (secrets, API keys, etc.) before starting a job",
441
+ response_model=Dict[str, Any],
442
+ responses={
443
+ http_status.HTTP_200_OK: {"model": Dict[str, Any]},
444
+ http_status.HTTP_400_BAD_REQUEST: {"model": Dict[str, Any]},
445
+ http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse},
446
+ },
447
+ dependencies=[Security(server.verify_api_key)],
448
+ )
449
+ @handle_route_errors()
450
+ async def validate_agent_parameters(
451
+ body_params: Any = Body(...),
452
+ agent: Agent = Depends(get_agent),
453
+ ) -> Dict[str, Any]:
454
+ """Validate agent parameters for this agent"""
455
+ log.info(
456
+ f"📥 POST /validate-agent-parameters [Validate agent parameters] {agent.name}"
457
+ )
458
+
459
+ if not agent.parameters_setup:
460
+ result = {
461
+ "valid": True,
462
+ "message": "Agent has no parameter setup defined",
463
+ "errors": [],
464
+ "invalid_parameters": {},
465
+ }
466
+ log.info(f"📤 Agent {agent.name}: No parameter setup defined → {result}")
467
+ return result
468
+
469
+ if body_params is None:
470
+ body_params = {}
471
+
472
+ encrypted_agent_parameters = body_params.get("encrypted_agent_parameters")
473
+
474
+ agent_parameters: Dict[str, Any] = {}
475
+ if encrypted_agent_parameters:
476
+ # Basic debug trace
477
+ log.info(
478
+ f"📥 Received encrypted_agent_parameters, length: {len(encrypted_agent_parameters)}"
479
+ )
480
+
481
+ try:
482
+ import json
483
+
484
+ from supervaizer.common import decrypt_value
485
+
486
+ agent_parameters_str = decrypt_value(
487
+ encrypted_agent_parameters, server.private_key
488
+ )
489
+ agent_parameters = (
490
+ json.loads(agent_parameters_str) if agent_parameters_str else {}
491
+ )
492
+
493
+ # Debug: Log the parsed data type and structure
494
+ log.info(f"🔍 Parsed agent_parameters type: {type(agent_parameters)}")
495
+ if isinstance(agent_parameters, list):
496
+ log.info(
497
+ f"🔍 Converting list to dict with {len(agent_parameters)} items"
498
+ )
499
+ # Convert list to dict if needed (common when frontend sends array)
500
+ agent_parameters = {
501
+ f"param_{i}": param for i, param in enumerate(agent_parameters)
502
+ }
503
+ elif isinstance(agent_parameters, dict):
504
+ log.info(
505
+ f"🔍 Agent parameters keys: {list(agent_parameters.keys())}"
506
+ )
507
+ else:
508
+ log.warning(
509
+ f"🔍 Unexpected type: {type(agent_parameters)}, converting to empty dict"
510
+ )
511
+ agent_parameters = {}
512
+
513
+ except Exception as e:
514
+ log.error(f"❌ Decryption failed: {type(e).__name__}: {str(e)}")
515
+ result = {
516
+ "valid": False,
517
+ "message": f"Failed to decrypt agent parameters: {str(e)}",
518
+ "errors": [f"Decryption failed: {str(e)}"],
519
+ "invalid_parameters": {
520
+ "encrypted_agent_parameters": f"Decryption failed: {str(e)}"
521
+ },
522
+ }
523
+ log.info(f"📤 Agent {agent.name}: Decryption failed → {result}")
524
+ return result
525
+
526
+ # Log the incoming request details
527
+ log.info(
528
+ f"🔍 Agent {agent.name}: Incoming request - encrypted_params: {bool(encrypted_agent_parameters)}, parsed_params: {agent_parameters}"
529
+ )
530
+
531
+ # Validate agent parameters
532
+ validation_result = agent.parameters_setup.validate_parameters(agent_parameters)
533
+
534
+ result = {
535
+ "valid": validation_result["valid"],
536
+ "message": "Agent parameters validated successfully"
537
+ if validation_result["valid"]
538
+ else "Agent parameter validation failed",
539
+ "errors": validation_result["errors"],
540
+ "invalid_parameters": validation_result["invalid_parameters"],
541
+ }
542
+
543
+ log.info(f"📤 Agent {agent.name}: Validation result → {result}")
544
+ return result
545
+
546
+ @router.post(
547
+ "/validate-method-fields",
548
+ summary=f"Validate method fields for agent: {agent.name}",
549
+ description="Validate job input fields against the method's field definitions before starting a job",
550
+ response_model=Dict[str, Any],
551
+ responses={
552
+ http_status.HTTP_200_OK: {"model": Dict[str, Any]},
553
+ http_status.HTTP_400_BAD_REQUEST: {"model": Dict[str, Any]},
554
+ http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse},
555
+ },
556
+ dependencies=[Security(server.verify_api_key)],
557
+ )
558
+ @handle_route_errors()
559
+ async def validate_method_fields(
560
+ body_params: Any = Body(...),
561
+ agent: Agent = Depends(get_agent),
562
+ ) -> Dict[str, Any]:
563
+ """Validate method fields for this agent"""
564
+ log.info(
565
+ f"📥 POST /validate-method-fields [Validate method fields] {agent.name}"
566
+ )
567
+
568
+ if body_params is None:
569
+ body_params = {}
570
+
571
+ method_name = body_params.get("method_name", "job_start")
572
+ job_fields = body_params.get("job_fields", {})
573
+
574
+ # Log the incoming request details
575
+ log.info(
576
+ f"🔍 Agent {agent.name}: Incoming request - method: {method_name}, fields: {job_fields}"
577
+ )
578
+
579
+ # Get the method to validate against
580
+ if not agent.methods:
581
+ result = {
582
+ "valid": False,
583
+ "message": "Agent has no methods defined",
584
+ "errors": ["Agent has no methods defined"],
585
+ "invalid_fields": {},
586
+ }
587
+ log.info(f"📤 Agent {agent.name}: No methods defined → {result}")
588
+ return result
589
+
590
+ if method_name == "job_start":
591
+ method = agent.methods.job_start
592
+ elif agent.methods.custom and method_name in agent.methods.custom:
593
+ method = agent.methods.custom[method_name]
594
+ else:
595
+ result = {
596
+ "valid": False,
597
+ "message": f"Method '{method_name}' not found",
598
+ "errors": [f"Method '{method_name}' not found"],
599
+ "invalid_fields": {},
600
+ }
601
+ log.info(
602
+ f"📤 Agent {agent.name}: Method '{method_name}' not found → {result}"
603
+ )
604
+ return result
605
+
606
+ # Validate method fields
607
+ validation_result = method.validate_method_fields(job_fields)
608
+
609
+ result = {
610
+ "valid": validation_result["valid"],
611
+ "message": validation_result["message"],
612
+ "errors": validation_result["errors"],
613
+ "invalid_fields": validation_result["invalid_fields"],
614
+ }
615
+
616
+ log.info(
617
+ f"📤 Agent {agent.name}: Method '{method_name}' validation result → {result}"
618
+ )
619
+ return result
620
+
621
+ if not agent.methods:
622
+ raise ValueError(f"Agent {agent.name} has no methods defined")
623
+
624
+ agent_job_model_name = f"{agent.slug}_Start_Job_Model"
625
+ # Create the dynamic model with the custom name for FastAPI documentation
626
+ _AgentStartAbstractJob = type(
627
+ agent_job_model_name,
628
+ (agent.methods.job_start.job_model,),
629
+ {},
630
+ )
631
+
632
+ @router.post(
633
+ "/jobs",
634
+ summary=f"Start a job with agent: {agent.name}",
635
+ description=f"{agent.methods.job_start.description}",
636
+ responses={
637
+ http_status.HTTP_202_ACCEPTED: {"model": Job},
638
+ http_status.HTTP_400_BAD_REQUEST: {"model": Dict[str, Any]},
639
+ http_status.HTTP_409_CONFLICT: {"model": ErrorResponse},
640
+ http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse},
641
+ },
642
+ response_model=Job,
643
+ status_code=http_status.HTTP_202_ACCEPTED,
644
+ dependencies=[Security(server.verify_api_key)],
645
+ )
646
+ @handle_route_errors(job_conflict_check=True)
647
+ async def start_job(
648
+ background_tasks: BackgroundTasks,
649
+ body_params: Any = Body(...),
650
+ agent: Agent = Depends(get_agent),
651
+ ) -> Union[Job, JSONResponse]:
652
+ """Start a new job for this agent"""
653
+ log.info(f"📥 POST /jobs [Start job] {agent.name} with params {body_params}")
654
+
655
+ if body_params is None:
656
+ body_params = {}
657
+
658
+ job_context_data = body_params.get("job_context")
659
+ if job_context_data is None:
660
+ raise ValueError("job_context is required")
661
+
662
+ sv_context: JobContext = JobContext(**job_context_data)
663
+ job_fields = body_params.get("job_fields", {})
664
+
665
+ # Get job encrypted parameters if available
666
+ encrypted_agent_parameters = body_params.get("encrypted_agent_parameters")
667
+
668
+ # Delegate job creation and scheduling to the service
669
+ new_job = await service_job_start(
670
+ server,
671
+ background_tasks,
672
+ agent,
673
+ sv_context,
674
+ job_fields,
675
+ encrypted_agent_parameters,
676
+ )
677
+
678
+ return new_job
679
+
680
+ @router.get(
681
+ "/jobs",
682
+ summary=f"Get all jobs for agent: {agent.name}",
683
+ description="Get all jobs for this agent with pagination and optional status filtering",
684
+ response_model=List[JobResponse],
685
+ responses={
686
+ http_status.HTTP_200_OK: {"model": List[JobResponse]},
687
+ http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse},
688
+ },
689
+ dependencies=[Security(server.verify_api_key)],
690
+ )
691
+ @handle_route_errors()
692
+ async def get_agent_jobs(
693
+ agent: Agent = Depends(get_agent),
694
+ skip: int = Query(default=0, ge=0, description="Number of jobs to skip"),
695
+ limit: int = Query(
696
+ default=100, ge=1, le=1000, description="Number of jobs to return"
697
+ ),
698
+ status: EntityStatus | None = Query(
699
+ default=None, description="Filter jobs by status"
700
+ ),
701
+ ) -> List[JobResponse] | JSONResponse:
702
+ """Get all jobs for this agent"""
703
+ log.info(f"📥 GET /jobs [Get agent jobs] {agent.name}")
704
+ jobs = list(Jobs().get_agent_jobs(agent.name).values())
705
+
706
+ # Apply status filter if specified
707
+ if status:
708
+ jobs = [job for job in jobs if job.status == status]
709
+
710
+ # Apply pagination
711
+ jobs = jobs[skip : skip + limit]
712
+
713
+ # Convert Job objects to JobResponse objects
714
+ return [
715
+ JobResponse(
716
+ job_id=job.id,
717
+ status=job.status,
718
+ message=f"Job {job.id} status: {job.status.value}",
719
+ payload=job.payload,
720
+ )
721
+ for job in jobs
722
+ ]
723
+
724
+ @router.get(
725
+ "/jobs/{job_id}",
726
+ summary=f"Get job status for agent: {agent.name}",
727
+ description="Get the status and details of a specific job",
728
+ response_model=JobResponse,
729
+ responses={
730
+ http_status.HTTP_200_OK: {"model": Job},
731
+ http_status.HTTP_404_NOT_FOUND: {"model": ErrorResponse},
732
+ http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse},
733
+ },
734
+ dependencies=[Security(server.verify_api_key)],
735
+ )
736
+ @handle_route_errors()
737
+ async def get_job_status(
738
+ job_id: str, agent: Agent = Depends(get_agent)
739
+ ) -> JobResponse:
740
+ """Get the status of a job by its ID for this specific agent"""
741
+ log.info(f"📥 GET /jobs/{job_id} [Get job status] {agent.name}")
742
+ job = Jobs().get_job(job_id, agent_name=agent.name, include_persisted=True)
743
+ if not job:
744
+ raise HTTPException(
745
+ status_code=http_status.HTTP_404_NOT_FOUND,
746
+ detail=f"Job with ID {job_id} not found for agent {agent.name}",
747
+ )
748
+ return JobResponse(
749
+ job_id=job.id,
750
+ status=job.status,
751
+ message=f"Job {job.id} status: {job.status.value}",
752
+ payload=job.payload,
753
+ )
754
+
755
+ @router.post(
756
+ "/stop",
757
+ summary=f"Stop the agent: {agent.name}",
758
+ description="Stop the agent",
759
+ responses={
760
+ http_status.HTTP_202_ACCEPTED: {"model": AgentResponse},
761
+ },
762
+ dependencies=[Security(server.verify_api_key)],
763
+ )
764
+ @handle_route_errors()
765
+ async def stop_agent(
766
+ background_tasks: BackgroundTasks,
767
+ params: dict[str, Any] = Body(...),
768
+ agent: Agent = Depends(get_agent),
769
+ ) -> AgentResponse:
770
+ log.info(f"📥 POST /stop [Stop agent] {agent.name} with params {params}")
771
+ # Pass job_context as 'context' parameter to match agent method expectations
772
+ job_context = params.get("job_context", {})
773
+ result = agent.job_stop({"context": job_context})
774
+ res_info = result.registration_info if result else {}
775
+ return AgentResponse(
776
+ name=agent.name,
777
+ id=agent.id,
778
+ version=agent.version,
779
+ api_path=agent.path,
780
+ description=agent.description,
781
+ **res_info,
782
+ )
783
+
784
+ @router.post(
785
+ "/status",
786
+ summary=f"Get the status of the agent: {agent.name}",
787
+ description="Get the status of the agent",
788
+ responses={
789
+ http_status.HTTP_202_ACCEPTED: {"model": AgentResponse},
790
+ },
791
+ dependencies=[Security(server.verify_api_key)],
792
+ )
793
+ @handle_route_errors()
794
+ async def status_agent(
795
+ params: AgentMethodParams, agent: Agent = Depends(get_agent)
796
+ ) -> AgentResponse:
797
+ log.info(f"📥 POST /status [Status agent] {agent.name} with params {params}")
798
+ result = agent.job_status(params.params)
799
+ return AgentResponse(
800
+ name=agent.name,
801
+ id=agent.id,
802
+ version=agent.version,
803
+ api_path=agent.path,
804
+ description=agent.description,
805
+ **result if result else {},
806
+ )
807
+
808
+ @router.post(
809
+ "/parameters",
810
+ summary=f"Server updates agent: {agent.name}",
811
+ description="Server updates agent onboarding status and/or encrypted parameters",
812
+ response_model=AgentResponse,
813
+ responses={
814
+ http_status.HTTP_200_OK: {"model": AgentResponse},
815
+ http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse},
816
+ },
817
+ dependencies=[Security(server.verify_api_key)],
818
+ )
819
+ @handle_route_errors()
820
+ async def server_update_agent(
821
+ onboarding_status: Optional[str] = Body(None),
822
+ parameters_encrypted: Optional[str] = Body(None),
823
+ agent: Agent = Depends(get_agent),
824
+ ) -> AgentResponse:
825
+ log.info(f"📥 POST /server_update [Server updates agent] {agent.name}")
826
+
827
+ if onboarding_status is not None:
828
+ agent.server_agent_onboarding_status = onboarding_status
829
+ if parameters_encrypted is not None:
830
+ agent.update_parameters_from_server(server, parameters_encrypted)
831
+ # import importlib
832
+
833
+ # importlib.reload(Agent)
834
+ return AgentResponse(**agent.registration_info)
835
+
836
+ return router
837
+
838
+
839
+ def create_agent_custom_routes(server: "Server", agent: Agent) -> APIRouter:
840
+ """Create individual routes for each custom method of an agent."""
841
+ if not agent.methods or not agent.methods.custom:
842
+ raise ValueError(f"Agent {agent.name} has no custom methods defined")
843
+
844
+ tags: list[str | Enum] = ["Supervision"]
845
+ router = APIRouter(
846
+ prefix=agent.path,
847
+ tags=tags,
848
+ )
849
+
850
+ async def get_agent() -> Agent:
851
+ return agent
852
+
853
+ # Create a route for each custom method
854
+ for method_name, method_config in agent.methods.custom.items():
855
+ # Create the dynamic model with the custom name for FastAPI documentation
856
+ custom_job_model_name = f"{agent.slug}_Custom_{method_name}_Job_Model"
857
+ _AgentCustomAbstractJob = type(
858
+ custom_job_model_name,
859
+ (method_config.job_model,),
860
+ {},
861
+ )
862
+
863
+ @router.post(
864
+ f"/custom/{method_name}",
865
+ summary=f"Trigger custom method '{method_name}' for agent: {agent.name}",
866
+ description=f"{method_config.description if hasattr(method_config, 'description') else f'Trigger custom method {method_name}'}",
867
+ response_model=JobResponse,
868
+ responses={
869
+ http_status.HTTP_202_ACCEPTED: {"model": JobResponse},
870
+ http_status.HTTP_400_BAD_REQUEST: {"model": Dict[str, Any]},
871
+ http_status.HTTP_405_METHOD_NOT_ALLOWED: {"model": ErrorResponse},
872
+ },
873
+ dependencies=[Security(server.verify_api_key)],
874
+ name=f"{agent.slug}_custom_{method_name}", # Unique operation ID
875
+ )
876
+ @handle_route_errors()
877
+ async def custom_method_endpoint(
878
+ background_tasks: BackgroundTasks,
879
+ body_params: Any = Body(...),
880
+ agent: Agent = Depends(get_agent),
881
+ ) -> Union[JobResponse, JSONResponse]:
882
+ log.info(
883
+ f"📥 POST /custom/{method_name} [custom job] {agent.name} with params {body_params}"
884
+ )
885
+ log.info(f"body_params: {body_params}")
886
+
887
+ if body_params is None:
888
+ raise ValueError("body_params cannot be None")
889
+
890
+ sv_context: JobContext = body_params.job_context
891
+ job_fields = body_params.job_fields.to_dict()
892
+
893
+ # Get job encrypted parameters if available
894
+ encrypted_agent_parameters = getattr(
895
+ body_params, "encrypted_agent_parameters", None
896
+ )
897
+
898
+ # Delegate job creation and scheduling to the service
899
+ new_job = await service_job_custom(
900
+ method_name,
901
+ server,
902
+ background_tasks,
903
+ agent,
904
+ sv_context,
905
+ job_fields,
906
+ encrypted_agent_parameters,
907
+ )
908
+
909
+ # Convert Job to JobResponse to match the endpoint's response model
910
+ return JobResponse(
911
+ job_id=new_job.id,
912
+ status=new_job.status,
913
+ message=f"Custom method '{method_name}' job started for agent {agent.name}",
914
+ payload=new_job.payload,
915
+ )
916
+
917
+ return router