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.
- supervaizer/__init__.py +97 -0
- supervaizer/__version__.py +10 -0
- supervaizer/account.py +308 -0
- supervaizer/account_service.py +93 -0
- supervaizer/admin/routes.py +1293 -0
- supervaizer/admin/static/js/job-start-form.js +373 -0
- supervaizer/admin/templates/agent_detail.html +145 -0
- supervaizer/admin/templates/agents.html +249 -0
- supervaizer/admin/templates/agents_grid.html +82 -0
- supervaizer/admin/templates/base.html +233 -0
- supervaizer/admin/templates/case_detail.html +230 -0
- supervaizer/admin/templates/cases_list.html +182 -0
- supervaizer/admin/templates/cases_table.html +134 -0
- supervaizer/admin/templates/console.html +389 -0
- supervaizer/admin/templates/dashboard.html +153 -0
- supervaizer/admin/templates/job_detail.html +192 -0
- supervaizer/admin/templates/job_start_test.html +109 -0
- supervaizer/admin/templates/jobs_list.html +180 -0
- supervaizer/admin/templates/jobs_table.html +122 -0
- supervaizer/admin/templates/navigation.html +163 -0
- supervaizer/admin/templates/recent_activity.html +81 -0
- supervaizer/admin/templates/server.html +105 -0
- supervaizer/admin/templates/server_status_cards.html +121 -0
- supervaizer/admin/templates/supervaize_instructions.html +212 -0
- supervaizer/agent.py +956 -0
- supervaizer/case.py +432 -0
- supervaizer/cli.py +395 -0
- supervaizer/common.py +324 -0
- supervaizer/deploy/__init__.py +16 -0
- supervaizer/deploy/cli.py +305 -0
- supervaizer/deploy/commands/__init__.py +9 -0
- supervaizer/deploy/commands/clean.py +294 -0
- supervaizer/deploy/commands/down.py +119 -0
- supervaizer/deploy/commands/local.py +460 -0
- supervaizer/deploy/commands/plan.py +167 -0
- supervaizer/deploy/commands/status.py +169 -0
- supervaizer/deploy/commands/up.py +281 -0
- supervaizer/deploy/docker.py +377 -0
- supervaizer/deploy/driver_factory.py +42 -0
- supervaizer/deploy/drivers/__init__.py +39 -0
- supervaizer/deploy/drivers/aws_app_runner.py +607 -0
- supervaizer/deploy/drivers/base.py +196 -0
- supervaizer/deploy/drivers/cloud_run.py +570 -0
- supervaizer/deploy/drivers/do_app_platform.py +504 -0
- supervaizer/deploy/health.py +404 -0
- supervaizer/deploy/state.py +210 -0
- supervaizer/deploy/templates/Dockerfile.template +44 -0
- supervaizer/deploy/templates/debug_env.py +69 -0
- supervaizer/deploy/templates/docker-compose.yml.template +37 -0
- supervaizer/deploy/templates/dockerignore.template +66 -0
- supervaizer/deploy/templates/entrypoint.sh +20 -0
- supervaizer/deploy/utils.py +52 -0
- supervaizer/event.py +181 -0
- supervaizer/examples/controller_template.py +196 -0
- supervaizer/instructions.py +145 -0
- supervaizer/job.py +392 -0
- supervaizer/job_service.py +156 -0
- supervaizer/lifecycle.py +417 -0
- supervaizer/parameter.py +233 -0
- supervaizer/protocol/__init__.py +11 -0
- supervaizer/protocol/a2a/__init__.py +21 -0
- supervaizer/protocol/a2a/model.py +227 -0
- supervaizer/protocol/a2a/routes.py +99 -0
- supervaizer/py.typed +1 -0
- supervaizer/routes.py +917 -0
- supervaizer/server.py +553 -0
- supervaizer/server_utils.py +54 -0
- supervaizer/storage.py +462 -0
- supervaizer/telemetry.py +81 -0
- supervaizer/utils/__init__.py +16 -0
- supervaizer/utils/version_check.py +56 -0
- supervaizer-0.10.5.dist-info/METADATA +317 -0
- supervaizer-0.10.5.dist-info/RECORD +76 -0
- supervaizer-0.10.5.dist-info/WHEEL +4 -0
- supervaizer-0.10.5.dist-info/entry_points.txt +2 -0
- 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
|