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.
- supervaizer/__init__.py +88 -0
- supervaizer/__version__.py +10 -0
- supervaizer/account.py +304 -0
- supervaizer/account_service.py +87 -0
- supervaizer/admin/routes.py +1254 -0
- supervaizer/admin/templates/agent_detail.html +145 -0
- supervaizer/admin/templates/agents.html +175 -0
- supervaizer/admin/templates/agents_grid.html +80 -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/jobs_list.html +180 -0
- supervaizer/admin/templates/jobs_table.html +122 -0
- supervaizer/admin/templates/navigation.html +153 -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/agent.py +816 -0
- supervaizer/case.py +400 -0
- supervaizer/cli.py +135 -0
- supervaizer/common.py +283 -0
- supervaizer/event.py +181 -0
- supervaizer/examples/controller-template.py +195 -0
- supervaizer/instructions.py +145 -0
- supervaizer/job.py +379 -0
- supervaizer/job_service.py +155 -0
- supervaizer/lifecycle.py +417 -0
- supervaizer/parameter.py +173 -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/protocol/acp/__init__.py +21 -0
- supervaizer/protocol/acp/model.py +198 -0
- supervaizer/protocol/acp/routes.py +74 -0
- supervaizer/py.typed +1 -0
- supervaizer/routes.py +667 -0
- supervaizer/server.py +554 -0
- supervaizer/server_utils.py +54 -0
- supervaizer/storage.py +436 -0
- supervaizer/telemetry.py +81 -0
- supervaizer-0.9.6.dist-info/METADATA +245 -0
- supervaizer-0.9.6.dist-info/RECORD +50 -0
- supervaizer-0.9.6.dist-info/WHEEL +4 -0
- supervaizer-0.9.6.dist-info/entry_points.txt +2 -0
- 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
|