supervaizer 0.9.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. supervaizer/__init__.py +88 -0
  2. supervaizer/__version__.py +10 -0
  3. supervaizer/account.py +304 -0
  4. supervaizer/account_service.py +87 -0
  5. supervaizer/admin/routes.py +1254 -0
  6. supervaizer/admin/templates/agent_detail.html +145 -0
  7. supervaizer/admin/templates/agents.html +175 -0
  8. supervaizer/admin/templates/agents_grid.html +80 -0
  9. supervaizer/admin/templates/base.html +233 -0
  10. supervaizer/admin/templates/case_detail.html +230 -0
  11. supervaizer/admin/templates/cases_list.html +182 -0
  12. supervaizer/admin/templates/cases_table.html +134 -0
  13. supervaizer/admin/templates/console.html +389 -0
  14. supervaizer/admin/templates/dashboard.html +153 -0
  15. supervaizer/admin/templates/job_detail.html +192 -0
  16. supervaizer/admin/templates/jobs_list.html +180 -0
  17. supervaizer/admin/templates/jobs_table.html +122 -0
  18. supervaizer/admin/templates/navigation.html +153 -0
  19. supervaizer/admin/templates/recent_activity.html +81 -0
  20. supervaizer/admin/templates/server.html +105 -0
  21. supervaizer/admin/templates/server_status_cards.html +121 -0
  22. supervaizer/agent.py +816 -0
  23. supervaizer/case.py +400 -0
  24. supervaizer/cli.py +135 -0
  25. supervaizer/common.py +283 -0
  26. supervaizer/event.py +181 -0
  27. supervaizer/examples/controller-template.py +195 -0
  28. supervaizer/instructions.py +145 -0
  29. supervaizer/job.py +379 -0
  30. supervaizer/job_service.py +155 -0
  31. supervaizer/lifecycle.py +417 -0
  32. supervaizer/parameter.py +173 -0
  33. supervaizer/protocol/__init__.py +11 -0
  34. supervaizer/protocol/a2a/__init__.py +21 -0
  35. supervaizer/protocol/a2a/model.py +227 -0
  36. supervaizer/protocol/a2a/routes.py +99 -0
  37. supervaizer/protocol/acp/__init__.py +21 -0
  38. supervaizer/protocol/acp/model.py +198 -0
  39. supervaizer/protocol/acp/routes.py +74 -0
  40. supervaizer/py.typed +1 -0
  41. supervaizer/routes.py +667 -0
  42. supervaizer/server.py +554 -0
  43. supervaizer/server_utils.py +54 -0
  44. supervaizer/storage.py +436 -0
  45. supervaizer/telemetry.py +81 -0
  46. supervaizer-0.9.6.dist-info/METADATA +245 -0
  47. supervaizer-0.9.6.dist-info/RECORD +50 -0
  48. supervaizer-0.9.6.dist-info/WHEEL +4 -0
  49. supervaizer-0.9.6.dist-info/entry_points.txt +2 -0
  50. supervaizer-0.9.6.dist-info/licenses/LICENSE.md +346 -0
supervaizer/routes.py ADDED
@@ -0,0 +1,667 @@
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 typing import (
10
+ TYPE_CHECKING,
11
+ Any,
12
+ Awaitable,
13
+ Callable,
14
+ Dict,
15
+ List,
16
+ Optional,
17
+ TypeVar,
18
+ Union,
19
+ )
20
+
21
+ from cryptography.hazmat.primitives import serialization
22
+ from fastapi import (
23
+ APIRouter,
24
+ BackgroundTasks,
25
+ Body,
26
+ Depends,
27
+ HTTPException,
28
+ Query,
29
+ Security,
30
+ status as http_status,
31
+ )
32
+ from fastapi.responses import JSONResponse
33
+ from rich import inspect
34
+
35
+ from supervaizer.agent import (
36
+ Agent,
37
+ AgentMethodParams,
38
+ AgentResponse,
39
+ )
40
+ from supervaizer.case import CaseNodeUpdate, Cases
41
+ from supervaizer.common import SvBaseModel, log
42
+ from supervaizer.job import Job, JobContext, JobResponse, Jobs
43
+ from supervaizer.job_service import service_job_custom, service_job_start
44
+ from supervaizer.lifecycle import EntityStatus
45
+ from supervaizer.server_utils import ErrorResponse, ErrorType, create_error_response
46
+
47
+ if TYPE_CHECKING:
48
+ from enum import Enum
49
+
50
+ from supervaizer.server import Server
51
+
52
+ T = TypeVar("T")
53
+
54
+ insp = inspect
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)
263
+
264
+ # Transition the case from AWAITING to IN_PROGRESS
265
+ case.receive_human_input()
266
+
267
+ log.info(
268
+ f"[Case update] Job {job_id}, Case {case_id} - Answer processed successfully"
269
+ )
270
+
271
+ return {
272
+ "status": "success",
273
+ "message": f"Answer received and processed for case {case_id} in job {job_id}",
274
+ "job_id": job_id,
275
+ "case_id": case_id,
276
+ "case_status": case.status.value,
277
+ }
278
+
279
+ @router.get("/agents", response_model=List[AgentResponse])
280
+ @handle_route_errors()
281
+ async def get_all_agents(
282
+ skip: int = Query(default=0, ge=0, description="Number of jobs to skip"),
283
+ limit: int = Query(
284
+ default=100, ge=1, le=1000, description="Number of jobs to return"
285
+ ),
286
+ ) -> List[AgentResponse]:
287
+ """Get all registered agents with pagination"""
288
+ if not server:
289
+ raise ValueError("Server instance not found")
290
+ return [
291
+ AgentResponse(**a.registration_info)
292
+ for a in server.agents[skip : skip + limit]
293
+ ]
294
+
295
+ @router.get("/agent/{agent_id}", response_model=AgentResponse)
296
+ @handle_route_errors()
297
+ async def get_agent_details(
298
+ agent_id: str,
299
+ ) -> AgentResponse:
300
+ """Get details of a specific agent by ID"""
301
+ if not server:
302
+ raise ValueError("Server instance not found")
303
+ for agent in server.agents:
304
+ if agent.id == agent_id:
305
+ return AgentResponse(**agent.registration_info)
306
+
307
+ raise HTTPException(
308
+ status_code=http_status.HTTP_404_NOT_FOUND,
309
+ detail=f"Agent with ID '{agent_id}' not found",
310
+ )
311
+
312
+ return router
313
+
314
+
315
+ def create_utils_routes(server: "Server") -> APIRouter:
316
+ """Create utility routes."""
317
+ router = APIRouter(prefix="/supervaizer/utils", tags=["Supervision"])
318
+
319
+ @router.get(
320
+ "/public_key",
321
+ summary="Get server's public key",
322
+ description="Returns the server's public key in PEM format",
323
+ response_model=str,
324
+ )
325
+ @handle_route_errors()
326
+ async def get_public_key() -> str:
327
+ pem = server.public_key.public_bytes(
328
+ encoding=serialization.Encoding.PEM,
329
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
330
+ )
331
+ return pem.decode("utf-8")
332
+
333
+ @router.post(
334
+ "/encrypt",
335
+ summary="Encrypt a string",
336
+ description="Encrypts a string using the server's public key. Example: {'key':'value'}",
337
+ response_model=str,
338
+ response_description="The encrypted string",
339
+ )
340
+ @handle_route_errors()
341
+ async def encrypt_string(text: str = Body(...)) -> str:
342
+ return server.encrypt(text)
343
+
344
+ return router
345
+
346
+
347
+ def create_agents_routes(server: "Server") -> APIRouter:
348
+ """Create agent-specific routes."""
349
+ routers = APIRouter(prefix="/supervaizer", tags=["Supervision"])
350
+ for agent in server.agents:
351
+ routers.include_router(create_agent_route(server, agent))
352
+ # Add custom method routes for each agent
353
+ if agent.methods and agent.methods.custom:
354
+ routers.include_router(create_agent_custom_routes(server, agent))
355
+ return routers
356
+
357
+
358
+ def create_agent_route(server: "Server", agent: Agent) -> APIRouter:
359
+ """Create agent-specific routes."""
360
+ # tags: list[str | Enum] = [f"Agent {agent.name} v{agent.version}"]
361
+ tags: list[str | Enum] = ["Supervision"]
362
+ router = APIRouter(
363
+ prefix=agent.path,
364
+ tags=tags,
365
+ )
366
+
367
+ async def get_agent() -> Agent:
368
+ return agent
369
+
370
+ @router.get(
371
+ "/",
372
+ summary=f"Get information about the agent {agent.name}",
373
+ description="Detailed information about the agent, returned as a JSON object with Agent class fields",
374
+ response_model=AgentResponse,
375
+ responses={http_status.HTTP_200_OK: {"model": AgentResponse}},
376
+ tags=tags,
377
+ dependencies=[Security(server.verify_api_key)],
378
+ )
379
+ @handle_route_errors()
380
+ async def agent_info(agent: Agent = Depends(get_agent)) -> AgentResponse:
381
+ log.info(f"📥 GET /[Agent info] {agent.name}")
382
+ return AgentResponse(
383
+ **agent.registration_info,
384
+ )
385
+
386
+ if not agent.methods:
387
+ raise ValueError(f"Agent {agent.name} has no methods defined")
388
+
389
+ agent_job_model_name = f"{agent.slug}_Start_Job_Model"
390
+ # Create the dynamic model with the custom name for FastAPI documentation
391
+ _AgentStartAbstractJob = type(
392
+ agent_job_model_name,
393
+ (agent.methods.job_start.job_model,),
394
+ {},
395
+ )
396
+
397
+ @router.post(
398
+ "/jobs",
399
+ summary=f"Start a job with agent: {agent.name}",
400
+ description=f"{agent.methods.job_start.description}",
401
+ responses={
402
+ http_status.HTTP_202_ACCEPTED: {"model": Job},
403
+ http_status.HTTP_409_CONFLICT: {"model": ErrorResponse},
404
+ http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse},
405
+ },
406
+ response_model=Job,
407
+ status_code=http_status.HTTP_202_ACCEPTED,
408
+ dependencies=[Security(server.verify_api_key)],
409
+ )
410
+ @handle_route_errors(job_conflict_check=True)
411
+ async def start_job(
412
+ background_tasks: BackgroundTasks,
413
+ body_params: Any = Body(...),
414
+ agent: Agent = Depends(get_agent),
415
+ ) -> Union[Job, JSONResponse]:
416
+ """Start a new job for this agent"""
417
+ log.info(f"📥 POST /jobs [Start job] {agent.name} with params {body_params}")
418
+ sv_context: JobContext = body_params.job_context
419
+ job_fields = body_params.job_fields.to_dict()
420
+
421
+ # Get job encrypted parameters if available
422
+ encrypted_agent_parameters = getattr(
423
+ body_params, "encrypted_agent_parameters", None
424
+ )
425
+
426
+ # Delegate job creation and scheduling to the service
427
+ new_job = await service_job_start(
428
+ server,
429
+ background_tasks,
430
+ agent,
431
+ sv_context,
432
+ job_fields,
433
+ encrypted_agent_parameters,
434
+ )
435
+
436
+ return new_job
437
+
438
+ @router.get(
439
+ "/jobs",
440
+ summary=f"Get all jobs for agent: {agent.name}",
441
+ description="Get all jobs for this agent with pagination and optional status filtering",
442
+ response_model=List[JobResponse],
443
+ responses={
444
+ http_status.HTTP_200_OK: {"model": List[JobResponse]},
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 get_agent_jobs(
451
+ agent: Agent = Depends(get_agent),
452
+ skip: int = Query(default=0, ge=0, description="Number of jobs to skip"),
453
+ limit: int = Query(
454
+ default=100, ge=1, le=1000, description="Number of jobs to return"
455
+ ),
456
+ status: EntityStatus | None = Query(
457
+ default=None, description="Filter jobs by status"
458
+ ),
459
+ ) -> List[JobResponse] | JSONResponse:
460
+ """Get all jobs for this agent"""
461
+ log.info(f"📥 GET /jobs [Get agent jobs] {agent.name}")
462
+ jobs = list(Jobs().get_agent_jobs(agent.name).values())
463
+
464
+ # Apply status filter if specified
465
+ if status:
466
+ jobs = [job for job in jobs if job.status == status]
467
+
468
+ # Apply pagination
469
+ jobs = jobs[skip : skip + limit]
470
+
471
+ # Convert Job objects to JobResponse objects
472
+ return [
473
+ JobResponse(
474
+ job_id=job.id,
475
+ status=job.status,
476
+ message=f"Job {job.id} status: {job.status.value}",
477
+ payload=job.payload,
478
+ )
479
+ for job in jobs
480
+ ]
481
+
482
+ @router.get(
483
+ "/jobs/{job_id}",
484
+ summary=f"Get job status for agent: {agent.name}",
485
+ description="Get the status and details of a specific job",
486
+ response_model=JobResponse,
487
+ responses={
488
+ http_status.HTTP_200_OK: {"model": Job},
489
+ http_status.HTTP_404_NOT_FOUND: {"model": ErrorResponse},
490
+ http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse},
491
+ },
492
+ dependencies=[Security(server.verify_api_key)],
493
+ )
494
+ @handle_route_errors()
495
+ async def get_job_status(
496
+ job_id: str, agent: Agent = Depends(get_agent)
497
+ ) -> JobResponse:
498
+ """Get the status of a job by its ID for this specific agent"""
499
+ log.info(f"📥 GET /jobs/{job_id} [Get job status] {agent.name}")
500
+ job = Jobs().get_job(job_id, agent_name=agent.name, include_persisted=True)
501
+ if not job:
502
+ raise HTTPException(
503
+ status_code=http_status.HTTP_404_NOT_FOUND,
504
+ detail=f"Job with ID {job_id} not found for agent {agent.name}",
505
+ )
506
+ return JobResponse(
507
+ job_id=job.id,
508
+ status=job.status,
509
+ message=f"Job {job.id} status: {job.status.value}",
510
+ payload=job.payload,
511
+ )
512
+
513
+ @router.post(
514
+ "/stop",
515
+ summary=f"Stop the agent: {agent.name}",
516
+ description="Stop the agent",
517
+ responses={
518
+ http_status.HTTP_202_ACCEPTED: {"model": AgentResponse},
519
+ },
520
+ dependencies=[Security(server.verify_api_key)],
521
+ )
522
+ @handle_route_errors()
523
+ async def stop_agent(
524
+ background_tasks: BackgroundTasks,
525
+ params: dict[str, Any] = Body(...),
526
+ agent: Agent = Depends(get_agent),
527
+ ) -> AgentResponse:
528
+ log.info(f"📥 POST /stop [Stop agent] {agent.name} with params {params}")
529
+ result = agent.job_stop(params.get("job_context", {}))
530
+ res_info = result.registration_info if result else {}
531
+ return AgentResponse(
532
+ name=agent.name,
533
+ id=agent.id,
534
+ version=agent.version,
535
+ api_path=agent.path,
536
+ description=agent.description,
537
+ **res_info,
538
+ )
539
+
540
+ @router.post(
541
+ "/status",
542
+ summary=f"Get the status of the agent: {agent.name}",
543
+ description="Get the status of the agent",
544
+ responses={
545
+ http_status.HTTP_202_ACCEPTED: {"model": AgentResponse},
546
+ },
547
+ dependencies=[Security(server.verify_api_key)],
548
+ )
549
+ @handle_route_errors()
550
+ async def status_agent(
551
+ params: AgentMethodParams, agent: Agent = Depends(get_agent)
552
+ ) -> AgentResponse:
553
+ log.info(f"📥 POST /status [Status agent] {agent.name} with params {params}")
554
+ result = agent.job_status(params.params)
555
+ return AgentResponse(
556
+ name=agent.name,
557
+ id=agent.id,
558
+ version=agent.version,
559
+ api_path=agent.path,
560
+ description=agent.description,
561
+ **result if result else {},
562
+ )
563
+
564
+ @router.post(
565
+ "/parameters",
566
+ summary=f"Server updates agent: {agent.name}",
567
+ description="Server updates agent onboarding status and/or encrypted parameters",
568
+ response_model=AgentResponse,
569
+ responses={
570
+ http_status.HTTP_200_OK: {"model": AgentResponse},
571
+ http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse},
572
+ },
573
+ dependencies=[Security(server.verify_api_key)],
574
+ )
575
+ @handle_route_errors()
576
+ async def server_update_agent(
577
+ onboarding_status: Optional[str] = Body(None),
578
+ parameters_encrypted: Optional[str] = Body(None),
579
+ agent: Agent = Depends(get_agent),
580
+ ) -> AgentResponse:
581
+ log.info(f"📥 POST /server_update [Server updates agent] {agent.name}")
582
+
583
+ if onboarding_status is not None:
584
+ agent.server_agent_onboarding_status = onboarding_status
585
+ if parameters_encrypted is not None:
586
+ agent.update_parameters_from_server(server, parameters_encrypted)
587
+ # import importlib
588
+
589
+ # importlib.reload(Agent)
590
+ return AgentResponse(**agent.registration_info)
591
+
592
+ return router
593
+
594
+
595
+ def create_agent_custom_routes(server: "Server", agent: Agent) -> APIRouter:
596
+ """Create individual routes for each custom method of an agent."""
597
+ if not agent.methods or not agent.methods.custom:
598
+ raise ValueError(f"Agent {agent.name} has no custom methods defined")
599
+
600
+ tags: list[str | Enum] = ["Supervision"]
601
+ router = APIRouter(
602
+ prefix=agent.path,
603
+ tags=tags,
604
+ )
605
+
606
+ async def get_agent() -> Agent:
607
+ return agent
608
+
609
+ # Create a route for each custom method
610
+ for method_name, method_config in agent.methods.custom.items():
611
+ # Create the dynamic model with the custom name for FastAPI documentation
612
+ custom_job_model_name = f"{agent.slug}_Custom_{method_name}_Job_Model"
613
+ _AgentCustomAbstractJob = type(
614
+ custom_job_model_name,
615
+ (method_config.job_model,),
616
+ {},
617
+ )
618
+
619
+ @router.post(
620
+ f"/custom/{method_name}",
621
+ summary=f"Trigger custom method '{method_name}' for agent: {agent.name}",
622
+ description=f"{method_config.description if hasattr(method_config, 'description') else f'Trigger custom method {method_name}'}",
623
+ response_model=JobResponse,
624
+ responses={
625
+ http_status.HTTP_202_ACCEPTED: {"model": JobResponse},
626
+ http_status.HTTP_405_METHOD_NOT_ALLOWED: {"model": ErrorResponse},
627
+ },
628
+ dependencies=[Security(server.verify_api_key)],
629
+ name=f"{agent.slug}_custom_{method_name}", # Unique operation ID
630
+ )
631
+ @handle_route_errors()
632
+ async def custom_method_endpoint(
633
+ background_tasks: BackgroundTasks,
634
+ body_params: Any = Body(...),
635
+ agent: Agent = Depends(get_agent),
636
+ ) -> Union[JobResponse, JSONResponse]:
637
+ log.info(
638
+ f"📥 POST /custom/{method_name} [custom job] {agent.name} with params {body_params}"
639
+ )
640
+ sv_context: JobContext = body_params.job_context
641
+ job_fields = body_params.job_fields.to_dict()
642
+
643
+ # Get job encrypted parameters if available
644
+ encrypted_agent_parameters = getattr(
645
+ body_params, "encrypted_agent_parameters", None
646
+ )
647
+
648
+ # Delegate job creation and scheduling to the service
649
+ new_job = await service_job_custom(
650
+ method_name,
651
+ server,
652
+ background_tasks,
653
+ agent,
654
+ sv_context,
655
+ job_fields,
656
+ encrypted_agent_parameters,
657
+ )
658
+
659
+ # Convert Job to JobResponse to match the endpoint's response model
660
+ return JobResponse(
661
+ job_id=new_job.id,
662
+ status=new_job.status,
663
+ message=f"Custom method '{method_name}' job started for agent {agent.name}",
664
+ payload=new_job.payload,
665
+ )
666
+
667
+ return router