supervaizer 0.10.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. supervaizer/__init__.py +97 -0
  2. supervaizer/__version__.py +10 -0
  3. supervaizer/account.py +308 -0
  4. supervaizer/account_service.py +93 -0
  5. supervaizer/admin/routes.py +1293 -0
  6. supervaizer/admin/static/js/job-start-form.js +373 -0
  7. supervaizer/admin/templates/agent_detail.html +145 -0
  8. supervaizer/admin/templates/agents.html +249 -0
  9. supervaizer/admin/templates/agents_grid.html +82 -0
  10. supervaizer/admin/templates/base.html +233 -0
  11. supervaizer/admin/templates/case_detail.html +230 -0
  12. supervaizer/admin/templates/cases_list.html +182 -0
  13. supervaizer/admin/templates/cases_table.html +134 -0
  14. supervaizer/admin/templates/console.html +389 -0
  15. supervaizer/admin/templates/dashboard.html +153 -0
  16. supervaizer/admin/templates/job_detail.html +192 -0
  17. supervaizer/admin/templates/job_start_test.html +109 -0
  18. supervaizer/admin/templates/jobs_list.html +180 -0
  19. supervaizer/admin/templates/jobs_table.html +122 -0
  20. supervaizer/admin/templates/navigation.html +163 -0
  21. supervaizer/admin/templates/recent_activity.html +81 -0
  22. supervaizer/admin/templates/server.html +105 -0
  23. supervaizer/admin/templates/server_status_cards.html +121 -0
  24. supervaizer/admin/templates/supervaize_instructions.html +212 -0
  25. supervaizer/agent.py +956 -0
  26. supervaizer/case.py +432 -0
  27. supervaizer/cli.py +395 -0
  28. supervaizer/common.py +324 -0
  29. supervaizer/deploy/__init__.py +16 -0
  30. supervaizer/deploy/cli.py +305 -0
  31. supervaizer/deploy/commands/__init__.py +9 -0
  32. supervaizer/deploy/commands/clean.py +294 -0
  33. supervaizer/deploy/commands/down.py +119 -0
  34. supervaizer/deploy/commands/local.py +460 -0
  35. supervaizer/deploy/commands/plan.py +167 -0
  36. supervaizer/deploy/commands/status.py +169 -0
  37. supervaizer/deploy/commands/up.py +281 -0
  38. supervaizer/deploy/docker.py +377 -0
  39. supervaizer/deploy/driver_factory.py +42 -0
  40. supervaizer/deploy/drivers/__init__.py +39 -0
  41. supervaizer/deploy/drivers/aws_app_runner.py +607 -0
  42. supervaizer/deploy/drivers/base.py +196 -0
  43. supervaizer/deploy/drivers/cloud_run.py +570 -0
  44. supervaizer/deploy/drivers/do_app_platform.py +504 -0
  45. supervaizer/deploy/health.py +404 -0
  46. supervaizer/deploy/state.py +210 -0
  47. supervaizer/deploy/templates/Dockerfile.template +44 -0
  48. supervaizer/deploy/templates/debug_env.py +69 -0
  49. supervaizer/deploy/templates/docker-compose.yml.template +37 -0
  50. supervaizer/deploy/templates/dockerignore.template +66 -0
  51. supervaizer/deploy/templates/entrypoint.sh +20 -0
  52. supervaizer/deploy/utils.py +52 -0
  53. supervaizer/event.py +181 -0
  54. supervaizer/examples/controller_template.py +196 -0
  55. supervaizer/instructions.py +145 -0
  56. supervaizer/job.py +392 -0
  57. supervaizer/job_service.py +156 -0
  58. supervaizer/lifecycle.py +417 -0
  59. supervaizer/parameter.py +233 -0
  60. supervaizer/protocol/__init__.py +11 -0
  61. supervaizer/protocol/a2a/__init__.py +21 -0
  62. supervaizer/protocol/a2a/model.py +227 -0
  63. supervaizer/protocol/a2a/routes.py +99 -0
  64. supervaizer/py.typed +1 -0
  65. supervaizer/routes.py +917 -0
  66. supervaizer/server.py +553 -0
  67. supervaizer/server_utils.py +54 -0
  68. supervaizer/storage.py +462 -0
  69. supervaizer/telemetry.py +81 -0
  70. supervaizer/utils/__init__.py +16 -0
  71. supervaizer/utils/version_check.py +56 -0
  72. supervaizer-0.10.5.dist-info/METADATA +317 -0
  73. supervaizer-0.10.5.dist-info/RECORD +76 -0
  74. supervaizer-0.10.5.dist-info/WHEEL +4 -0
  75. supervaizer-0.10.5.dist-info/entry_points.txt +2 -0
  76. supervaizer-0.10.5.dist-info/licenses/LICENSE.md +346 -0
supervaizer/agent.py ADDED
@@ -0,0 +1,956 @@
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
+
8
+ import json
9
+ import re
10
+ from enum import Enum
11
+ from typing import (
12
+ TYPE_CHECKING,
13
+ Any,
14
+ ClassVar,
15
+ Dict,
16
+ List,
17
+ Optional,
18
+ TypeVar,
19
+ )
20
+ import shortuuid
21
+ from pydantic import BaseModel, field_validator, Field
22
+ from rich import inspect, print
23
+ from slugify import slugify
24
+ from supervaizer.__version__ import VERSION
25
+ from supervaizer.common import ApiSuccess, SvBaseModel, log
26
+ from supervaizer.event import JobStartConfirmationEvent
27
+ from supervaizer.job import Job, JobContext, JobResponse
28
+ from supervaizer.job_service import service_job_finished
29
+ from supervaizer.lifecycle import EntityStatus
30
+ from supervaizer.parameter import ParametersSetup
31
+ from supervaizer.case import CaseNodes
32
+
33
+ if TYPE_CHECKING:
34
+ from supervaizer.server import Server
35
+
36
+ insp = inspect
37
+ prnt = print
38
+
39
+ T = TypeVar("T")
40
+
41
+
42
+ class FieldTypeEnum(str, Enum):
43
+ CHAR = "CharField"
44
+ INT = "IntegerField"
45
+ BOOL = "BooleanField"
46
+ CHOICE = "ChoiceField"
47
+ MULTICHOICE = "MultipleChoiceField"
48
+ DATE = "DateField"
49
+ DATETIME = "DateTimeField"
50
+ FLOAT = "FloatField"
51
+ EMAIL = "EmailField"
52
+
53
+
54
+ class AgentMethodField(BaseModel):
55
+ """
56
+ Represents a field specification for generating forms/UI in the Supervaize platform.
57
+
58
+ Fields are used to define user input parameters that will be collected through
59
+ the UI and passed as kwargs to the AgentMethod.method. They follow Django forms
60
+ field definitions for consistency.
61
+
62
+
63
+ - [Django Widgets](https://docs.djangoproject.com/en/5.2/ref/forms/widgets/)
64
+
65
+
66
+ ** field_type - available field types ** [Django Field classes](https://docs.djangoproject.com/en/5.2/ref/forms/fields/#built-in-field-classes)
67
+
68
+ - `CharField` - Text input
69
+ - `IntegerField` - Number input
70
+ - `BooleanField` - Checkbox
71
+ - `ChoiceField` - Dropdown with options
72
+ - `MultipleChoiceField` - Multi-select
73
+ - `JSONField` - JSON data input
74
+
75
+ """
76
+
77
+ name: str = Field(description="The name of the field - displayed in the UI")
78
+ type: Any = Field(
79
+ description="Python type of the field for pydantic validation - note , ChoiceField and MultipleChoiceField are a list[str]"
80
+ )
81
+ field_type: FieldTypeEnum = Field(
82
+ default=FieldTypeEnum.CHAR, description="Field type for persistence"
83
+ )
84
+ description: str | None = Field(
85
+ default=None, description="Description of the field - displayed in the UI"
86
+ )
87
+ choices: list[tuple[str, str]] | list[str] | None = Field(
88
+ default=None, description="For choice fields, list of [value, label] pairs"
89
+ )
90
+
91
+ default: Any = Field(
92
+ default=None, description="Default value for the field - displayed in the UI"
93
+ )
94
+ widget: str | None = Field(
95
+ default=None,
96
+ description="UI widget to use (e.g. RadioSelect, TextInput) - as a django widget name",
97
+ )
98
+ required: bool = Field(
99
+ default=False, description="Whether field is required for form submission"
100
+ )
101
+
102
+ model_config = {
103
+ "reference_group": "Core",
104
+ "json_schema_extra": {
105
+ "examples": [
106
+ {
107
+ "name": "color",
108
+ "type": "list[str]",
109
+ "field_type": "MultipleChoiceField",
110
+ "choices": [["B", "Blue"], ["R", "Red"], ["G", "Green"]],
111
+ "widget": "RadioSelect",
112
+ "required": True,
113
+ },
114
+ {
115
+ "name": "age",
116
+ "type": "int",
117
+ "field_type": "IntegerField",
118
+ "widget": "NumberInput",
119
+ "required": False,
120
+ },
121
+ ]
122
+ },
123
+ }
124
+
125
+
126
+ class AgentJobContextBase(BaseModel):
127
+ """
128
+ Base model for agent job context parameters
129
+ """
130
+
131
+ job_context: JobContext
132
+ job_fields: Dict[str, Any]
133
+
134
+
135
+ class AgentMethodAbstract(BaseModel):
136
+ """
137
+ Represents a method that can be called on an agent.
138
+
139
+ Attributes:
140
+ name: Display name of the method
141
+ method: Name of the actual method in the project's codebase that will be called with the provided parameters
142
+ params: see below
143
+ fields: see below
144
+ description: Optional description of what the method does
145
+
146
+
147
+ 1. params : Dictionary format
148
+ A simple key-value dictionary of parameters what will be passed to the
149
+ AgentMethod.method as kwargs.
150
+ Example:
151
+ {
152
+ "verbose": True,
153
+ "timeout": 60,
154
+ "max_retries": 3
155
+ }
156
+
157
+ 2. fields : Form fields format
158
+ These are the values that will be requested from the user in the Supervaize UI
159
+ and also passed as kwargs to the AgentMethod.method.
160
+ A list of field specifications for generating forms/UI, following the
161
+ django.forms.fields definition
162
+ see : https://docs.djangoproject.com/en/5.1/ref/forms/fields/
163
+ Each field is a dictionary with properties like:
164
+ - name: Field identifier
165
+ - type: Python type of the field for pydantic validation - note , ChoiceField and MultipleChoiceField are a list[str]
166
+ - field_type: Field type (one of: CharField, IntegerField, BooleanField, ChoiceField, MultipleChoiceField)
167
+ - choices: For choice fields, list of [value, label] pairs
168
+ - default: (optional) Default value for the field
169
+ - widget: UI widget to use (e.g. RadioSelect, TextInput)
170
+ - required: Whether field is required
171
+
172
+
173
+
174
+ """
175
+
176
+ name: str = Field(description="The name of the method")
177
+ method: str = Field(
178
+ description="The name of the method in the project's codebase that will be called with the provided parameters"
179
+ )
180
+ params: Dict[str, Any] | None = Field(
181
+ default=None,
182
+ description="A simple key-value dictionary of parameters what will be passed to the AgentMethod.method as kwargs",
183
+ )
184
+ fields: List[AgentMethodField] | None = Field(
185
+ default=None,
186
+ description="A list of field specifications for generating forms/UI, following the django.forms.fields definition",
187
+ )
188
+ description: str | None = Field(
189
+ default=None, description="Optional description of what the method does"
190
+ )
191
+ is_async: bool = Field(
192
+ default=False, description="Whether the method is asynchronous"
193
+ )
194
+
195
+ model_config = {
196
+ "reference_group": "Core",
197
+ "example_dict": {
198
+ "name": "start",
199
+ "method": "example_agent.example_synchronous_job_start",
200
+ "params": {"action": "start"},
201
+ "fields": [
202
+ {
203
+ "name": "Company to research",
204
+ "type": str,
205
+ "field_type": "CharField",
206
+ "max_length": 100,
207
+ "required": True,
208
+ },
209
+ ],
210
+ "description": "Start the collection of new competitor summary",
211
+ },
212
+ }
213
+
214
+ nodes: CaseNodes | None = Field(
215
+ default=None,
216
+ description="The definition of the Case Nodes (=steps) for this method",
217
+ )
218
+
219
+
220
+ class AgentMethod(AgentMethodAbstract):
221
+ @property
222
+ def fields_definitions(self) -> list[Dict[str, Any]]:
223
+ """
224
+ Returns a list of the fields with the type key as a string
225
+ Used for the API response.
226
+ """
227
+ if self.fields:
228
+ result = []
229
+ for field in self.fields:
230
+ d = {k: v for k, v in field.__dict__.items() if k != "type"}
231
+ # type as string
232
+ type_val = field.type
233
+ if hasattr(type_val, "__name__"):
234
+ d["type"] = type_val.__name__
235
+ elif hasattr(type_val, "_name") and type_val._name:
236
+ d["type"] = type_val._name
237
+ else:
238
+ d["type"] = str(type_val)
239
+ result.append(d)
240
+ return result
241
+ return []
242
+
243
+ @property
244
+ def fields_annotations(self) -> type[BaseModel]:
245
+ """
246
+ Creates and returns a dynamic Pydantic model class based on the field definitions.
247
+ """
248
+ if not self.fields:
249
+ return type("EmptyFieldsModel", (BaseModel,), {"to_dict": lambda self: {}})
250
+
251
+ field_annotations = {}
252
+ for field in self.fields:
253
+ field_name = field.name
254
+ field_type = field.type
255
+
256
+ # Convert Python types to proper typing annotations
257
+ if field_type is str:
258
+ annotation_type: type = str
259
+ elif field_type is int:
260
+ annotation_type = int
261
+ elif field_type is bool:
262
+ annotation_type = bool
263
+ elif field_type is list:
264
+ annotation_type = list
265
+ elif field_type is dict:
266
+ annotation_type = dict
267
+ elif field_type is float:
268
+ annotation_type = float
269
+ elif hasattr(field_type, "__origin__") and field_type.__origin__ is list:
270
+ # Handle generic list types like list[str]
271
+ annotation_type = list
272
+ elif hasattr(field_type, "__origin__") and field_type.__origin__ is dict:
273
+ # Handle generic dict types like dict[str, Any]
274
+ annotation_type = dict
275
+ else:
276
+ # Default to Any for unknown types
277
+ annotation_type = Any
278
+
279
+ # Make field optional if not required
280
+ field_annotations[field_name] = (
281
+ annotation_type if field.required else Optional[annotation_type]
282
+ )
283
+
284
+ # Create the dynamic model with proper module information
285
+ model_dict = {
286
+ "__module__": "supervaizer.agent",
287
+ "__annotations__": field_annotations,
288
+ "to_dict": lambda self: {
289
+ k: getattr(self, k)
290
+ for k in field_annotations.keys()
291
+ if hasattr(self, k)
292
+ },
293
+ }
294
+
295
+ return type("DynamicFieldsModel", (BaseModel,), model_dict)
296
+
297
+ def validate_method_fields(self, job_fields: Dict[str, Any]) -> Dict[str, Any]:
298
+ """Validate job fields against the method's field definitions.
299
+
300
+ Args:
301
+ job_fields: Dictionary of field names and values to validate
302
+
303
+ Returns:
304
+ Dictionary with validation results:
305
+ - "valid": bool - whether all fields are valid
306
+ - "errors": List[str] - list of validation error messages
307
+ - "invalid_fields": Dict[str, str] - field name to error message mapping
308
+ """
309
+ if self.fields is None:
310
+ return {
311
+ "valid": True,
312
+ "message": "Method has no field definitions",
313
+ "errors": [],
314
+ "invalid_fields": {},
315
+ }
316
+
317
+ if len(self.fields) == 0:
318
+ return {
319
+ "valid": True,
320
+ "message": "Method fields validated successfully",
321
+ "errors": [],
322
+ "invalid_fields": {},
323
+ }
324
+
325
+ errors = []
326
+ invalid_fields = {}
327
+
328
+ # First check for missing required fields
329
+ for field in self.fields:
330
+ if field.required and field.name not in job_fields:
331
+ error_msg = f"Required field '{field.name}' is missing"
332
+ errors.append(error_msg)
333
+ invalid_fields[field.name] = error_msg
334
+
335
+ # Then validate the provided fields
336
+ for field_name, field_value in job_fields.items():
337
+ # Find the field definition
338
+ field_def = next((f for f in self.fields if f.name == field_name), None)
339
+ if not field_def:
340
+ error_msg = f"Unknown field '{field_name}'"
341
+ errors.append(error_msg)
342
+ invalid_fields[field_name] = error_msg
343
+ continue
344
+
345
+ # Skip validation for None values (optional fields)
346
+ if field_value is None:
347
+ continue
348
+
349
+ # Type validation
350
+ expected_type = field_def.type
351
+ if expected_type:
352
+ try:
353
+ # Handle special cases for type validation
354
+ if expected_type is str:
355
+ if not isinstance(field_value, str):
356
+ error_msg = f"Field '{field_name}' must be a string, got {type(field_value).__name__}"
357
+ errors.append(error_msg)
358
+ invalid_fields[field_name] = error_msg
359
+ elif expected_type is int:
360
+ if not isinstance(field_value, int):
361
+ error_msg = f"Field '{field_name}' must be an integer, got {type(field_value).__name__}"
362
+ errors.append(error_msg)
363
+ invalid_fields[field_name] = error_msg
364
+ elif expected_type is bool:
365
+ if not isinstance(field_value, bool):
366
+ error_msg = f"Field '{field_name}' must be a boolean, got {type(field_value).__name__}"
367
+ errors.append(error_msg)
368
+ invalid_fields[field_name] = error_msg
369
+ elif expected_type is list:
370
+ if not isinstance(field_value, list):
371
+ error_msg = f"Field '{field_name}' must be a list, got {type(field_value).__name__}"
372
+ errors.append(error_msg)
373
+ invalid_fields[field_name] = error_msg
374
+ elif expected_type is dict:
375
+ if not isinstance(field_value, dict):
376
+ error_msg = f"Field '{field_name}' must be a dictionary, got {type(field_value).__name__}"
377
+ errors.append(error_msg)
378
+ invalid_fields[field_name] = error_msg
379
+ elif expected_type is float:
380
+ if not isinstance(field_value, (int, float)):
381
+ error_msg = f"Field '{field_name}' must be a number, got {type(field_value).__name__}"
382
+ errors.append(error_msg)
383
+ invalid_fields[field_name] = error_msg
384
+ except Exception as e:
385
+ error_msg = f"Field '{field_name}' validation failed: {str(e)}"
386
+ errors.append(error_msg)
387
+ invalid_fields[field_name] = error_msg
388
+
389
+ return {
390
+ "valid": len(errors) == 0,
391
+ "message": "Method fields validated successfully"
392
+ if len(errors) == 0
393
+ else "Method field validation failed",
394
+ "errors": errors,
395
+ "invalid_fields": invalid_fields,
396
+ }
397
+
398
+ @property
399
+ def job_model(self) -> type[AgentJobContextBase]:
400
+ """
401
+ Creates and returns a dynamic Pydantic model class combining job context and job fields.
402
+ """
403
+ fields_model = self.fields_annotations
404
+
405
+ return type(
406
+ "AgentJobAbstract",
407
+ (AgentJobContextBase,),
408
+ {
409
+ "__annotations__": {
410
+ "job_context": JobContext,
411
+ "job_fields": fields_model,
412
+ "encrypted_agent_parameters": str | None,
413
+ }
414
+ },
415
+ )
416
+
417
+ @property
418
+ def registration_info(self) -> Dict[str, Any]:
419
+ """
420
+ Returns a JSON-serializable dictionary representation of the AgentMethod.
421
+ """
422
+ return {
423
+ "name": self.name,
424
+ "method": str(self.method),
425
+ "params": self.params,
426
+ "fields": self.fields_definitions,
427
+ "description": self.description,
428
+ "nodes": self.nodes.registration_info if self.nodes else None,
429
+ }
430
+
431
+
432
+ class AgentMethodParams(BaseModel):
433
+ """
434
+ Method parameters for agent operations.
435
+
436
+ """
437
+
438
+ params: Dict[str, Any] = Field(
439
+ default_factory=dict,
440
+ description="A simple key-value dictionary of parameters what will be passed to the AgentMethod.method as kwargs",
441
+ )
442
+
443
+
444
+ class AgentCustomMethodParams(AgentMethodParams):
445
+ method_name: str
446
+
447
+
448
+ class AgentMethodsAbstract(BaseModel):
449
+ job_start: AgentMethod
450
+ job_stop: AgentMethod | None = None
451
+ job_status: AgentMethod | None = None
452
+ human_answer: AgentMethod | None = None
453
+ chat: AgentMethod | None = None
454
+ custom: dict[str, AgentMethod] | None = None
455
+
456
+ @field_validator("custom")
457
+ @classmethod
458
+ def validate_custom_method_keys(
459
+ cls, value: dict[str, AgentMethod]
460
+ ) -> dict[str, AgentMethod]:
461
+ """Validate that custom method keys are valid slug-like values suitable for endpoints."""
462
+ if value:
463
+ for key in value.keys():
464
+ # Check if key is a valid slug format
465
+ if not re.match(r"^[a-z0-9]+(?:-[a-z0-9]+)*$", key):
466
+ raise ValueError(
467
+ f"Custom method key '{key}' is not a valid slug. "
468
+ f"Keys must contain only lowercase letters, numbers, and hyphens, "
469
+ f"and cannot start or end with a hyphen. "
470
+ f"Examples: 'backup', 'health-check', 'sync-data'"
471
+ )
472
+
473
+ # Additional checks for endpoint safety
474
+ if len(key) > 50:
475
+ raise ValueError(
476
+ f"Custom method key '{key}' is too long (max 50 characters)"
477
+ )
478
+
479
+ return value
480
+
481
+
482
+ class AgentMethods(AgentMethodsAbstract):
483
+ @property
484
+ def registration_info(self) -> Dict[str, Any]:
485
+ return {
486
+ "job_start": self.job_start.registration_info,
487
+ "job_stop": self.job_stop.registration_info if self.job_stop else None,
488
+ "job_status": self.job_status.registration_info
489
+ if self.job_status
490
+ else None,
491
+ "human_answer": self.human_answer.registration_info
492
+ if self.human_answer
493
+ else None,
494
+ "chat": self.chat.registration_info if self.chat else None,
495
+ "custom": {
496
+ name: method.registration_info
497
+ for name, method in (self.custom or {}).items()
498
+ },
499
+ }
500
+
501
+
502
+ class AgentAbstract(SvBaseModel):
503
+ """
504
+ Agent model for the Supervaize Control API.
505
+
506
+ This represents an agent that can be registered with the Supervaize Control API.
507
+ It contains metadata about the agent like name, version, description etc. as well as
508
+ the methods it supports and any parameter configurations.
509
+
510
+ The agent ID is automatically generated from the name and must match.
511
+
512
+ Example:
513
+ ```python
514
+ Agent(
515
+ name="Email AI Agent",
516
+ author="@parthshr370", # Author of the agent
517
+ developer="@alain_sv", # Developer of the controller
518
+ maintainer="@aintainer",
519
+ editor="AI Editor",
520
+ version="1.0.0",
521
+ description="AI-powered email processing agent that can fetch, analyze, generate responses, and send/draft emails",
522
+ tags=["email", "ai", "automation", "communication"],
523
+ methods=AgentMethods(
524
+ job_start=process_email_method, # Job start method
525
+ job_stop=job_stop, # Job stop method
526
+ job_status=job_status, # Job status method
527
+ chat=None,
528
+ custom=None,
529
+ ),
530
+ parameters_setup=ParametersSetup.from_list([
531
+ Parameter(
532
+ name="IMAP_USERNAME",
533
+ description="IMAP username for email access",
534
+ is_environment=True,
535
+ is_secret=False,
536
+ ),
537
+ Parameter(
538
+ name="IMAP_PASSWORD",
539
+ description="IMAP password for email access",
540
+ is_environment=True,
541
+ is_secret=True,
542
+ ),
543
+ ]),
544
+ )
545
+ ```
546
+ """
547
+
548
+ supervaizer_VERSION: ClassVar[str] = VERSION
549
+ name: str = Field(description="Display name of the agent")
550
+ id: str = Field(description="Unique ID generated from name")
551
+ author: Optional[str] = Field(default=None, description="Author of the agent")
552
+ developer: Optional[str] = Field(
553
+ default=None, description="Developer of the controller integration"
554
+ )
555
+ maintainer: Optional[str] = Field(
556
+ default=None, description="Maintainer of the integration"
557
+ )
558
+ editor: Optional[str] = Field(
559
+ default=None, description="Editor (usually a company)"
560
+ )
561
+ version: str = Field(default="", description="Version string")
562
+ description: str = Field(
563
+ default="", description="Description of what the agent does"
564
+ )
565
+ tags: list[str] | None = Field(
566
+ default=None, description="Tags for categorizing the agent"
567
+ )
568
+ methods: AgentMethods | None = Field(
569
+ default=None, description="Methods supported by this agent"
570
+ )
571
+ parameters_setup: ParametersSetup | None = Field(
572
+ default=None, description="Parameter configuration"
573
+ )
574
+ server_agent_id: str | None = Field(
575
+ default=None, description="ID assigned by server - Do not set this manually"
576
+ )
577
+ server_agent_status: str | None = Field(
578
+ default=None, description="Current status on server - Do not set this manually"
579
+ )
580
+ server_agent_onboarding_status: str | None = Field(
581
+ default=None, description="Onboarding status - Do not set this manually"
582
+ )
583
+ server_encrypted_parameters: str | None = Field(
584
+ default=None,
585
+ description="Encrypted parameters from server - Do not set this manually",
586
+ )
587
+ max_execution_time: int = Field(
588
+ default=60 * 60,
589
+ description="Maximum execution time in seconds, defaults to 1 hour",
590
+ )
591
+ supervaize_instructions_template_path: Optional[str] = Field(
592
+ default=None,
593
+ description="Optional path to a custom template file for supervaize_instructions.html page",
594
+ )
595
+ instructions_path: str = Field(
596
+ default="supervaize_instructions.html",
597
+ description="Path where the supervaize instructions page is served (relative to agent path)",
598
+ )
599
+
600
+ model_config = {
601
+ "reference_group": "Core",
602
+ }
603
+
604
+
605
+ class Agent(AgentAbstract):
606
+ def __init__(
607
+ self,
608
+ name: str,
609
+ id: str | None = None,
610
+ author: Optional[str] = None,
611
+ developer: Optional[str] = None,
612
+ maintainer: Optional[str] = None,
613
+ editor: Optional[str] = None,
614
+ version: str = "",
615
+ description: str = "",
616
+ tags: list[str] | None = None,
617
+ methods: AgentMethods | None = None,
618
+ parameters_setup: ParametersSetup | None = None,
619
+ server_agent_id: str | None = None,
620
+ server_agent_status: str | None = None,
621
+ server_agent_onboarding_status: str | None = None,
622
+ server_encrypted_parameters: str | None = None,
623
+ max_execution_time: int = 60 * 60, # 1 hour (in seconds)
624
+ **kwargs: Any,
625
+ ) -> None:
626
+ """
627
+ This represents an agent that can be registered with the Supervaize Control API.
628
+ It contains metadata about the agent like name, version, description etc. as well as
629
+ the methods it supports and any parameter configurations.
630
+
631
+ The agent ID is automatically generated from the name and must match.
632
+
633
+ Attributes:
634
+ name (str): Display name of the agent
635
+ id (str): Unique ID generated from name
636
+ author (str, optional): Original author
637
+ developer (str, optional): Current developer
638
+ maintainer (str, optional): Current maintainer
639
+ editor (str, optional): Current editor
640
+ version (str): Version string
641
+ description (str): Description of what the agent does
642
+ tags (list[str], optional): Tags for categorizing the agent
643
+ methods (AgentMethods): Methods supported by this agent
644
+ parameters_setup (ParametersSetup, optional): Parameter configuration
645
+ server_agent_id (str, optional): ID assigned by server
646
+ server_agent_status (str, optional): Current status on server
647
+ server_agent_onboarding_status (str, optional): Onboarding status
648
+ server_encrypted_parameters (str, optional): Encrypted parameters from server
649
+ max_execution_time (int): Maximum execution time in seconds, defaults to 1 hour
650
+
651
+ Tested in tests/test_agent.py
652
+ """
653
+ # Validate or generate agent ID
654
+ agent_id = id or shortuuid.uuid(name=name)
655
+ if id is not None and id != shortuuid.uuid(name=name):
656
+ raise ValueError("Agent ID does not match")
657
+
658
+ # Initialize using Pydantic's mechanism
659
+ super().__init__(
660
+ name=name,
661
+ id=agent_id,
662
+ author=author,
663
+ developer=developer,
664
+ maintainer=maintainer,
665
+ editor=editor,
666
+ version=version,
667
+ description=description,
668
+ tags=tags,
669
+ methods=methods,
670
+ parameters_setup=parameters_setup,
671
+ server_agent_id=server_agent_id,
672
+ server_agent_status=server_agent_status,
673
+ server_agent_onboarding_status=server_agent_onboarding_status,
674
+ server_encrypted_parameters=server_encrypted_parameters,
675
+ max_execution_time=max_execution_time,
676
+ **kwargs,
677
+ )
678
+
679
+ def __str__(self) -> str:
680
+ return f"{self.name} ({self.id})"
681
+
682
+ @property
683
+ def slug(self) -> str:
684
+ return slugify(self.name)
685
+
686
+ @property
687
+ def path(self) -> str:
688
+ return f"/agents/{self.slug}"
689
+
690
+ @property
691
+ def registration_info(self) -> Dict[str, Any]:
692
+ """Returns registration info for the agent"""
693
+ return {
694
+ "name": self.name,
695
+ "id": f"{self.id}",
696
+ "author": self.author,
697
+ "developer": self.developer,
698
+ "maintainer": self.maintainer,
699
+ "editor": self.editor,
700
+ "version": self.version,
701
+ "description": self.description,
702
+ "api_path": self.path,
703
+ "slug": self.slug,
704
+ "tags": self.tags,
705
+ "methods": self.methods.registration_info if self.methods else {},
706
+ "parameters_setup": self.parameters_setup.registration_info
707
+ if self.parameters_setup
708
+ else None,
709
+ "server_agent_id": f"{self.server_agent_id}",
710
+ "server_agent_status": self.server_agent_status,
711
+ "server_agent_onboarding_status": self.server_agent_onboarding_status,
712
+ "server_encrypted_parameters": self.server_encrypted_parameters,
713
+ "max_execution_time": self.max_execution_time,
714
+ "instructions_path": self.instructions_path,
715
+ }
716
+
717
+ def update_agent_from_server(self, server: "Server") -> Optional["Agent"]:
718
+ """
719
+ Update agent attributes and parameters from server registration information.
720
+ Example of agent_registration data is available in mock_api_responses.py
721
+
722
+ Server is used to decrypt parameters if needed
723
+ Tested in tests/test_agent.py/test_agent_update_agent_from_server
724
+ """
725
+ if server.supervisor_account:
726
+ if self.server_agent_id:
727
+ # Get agent by ID from SaaS Server
728
+ from_server = server.supervisor_account.get_agent_by(
729
+ agent_id=self.server_agent_id
730
+ )
731
+
732
+ else:
733
+ # Get agent by name from SaaS Server
734
+ from_server = server.supervisor_account.get_agent_by(
735
+ agent_slug=self.slug
736
+ )
737
+ else:
738
+ return None
739
+ if not isinstance(from_server, ApiSuccess):
740
+ log.error(f"[Agent update_agent_from_server] Failed : {from_server}")
741
+ return None
742
+
743
+ agent_from_server = from_server.detail
744
+ server_agent_id = agent_from_server.get("id") if agent_from_server else None
745
+
746
+ # This should never happen, but just in case
747
+ if self.server_agent_id and self.server_agent_id != server_agent_id:
748
+ message = f"Agent ID mismatch: {self.server_agent_id} != {server_agent_id}"
749
+ raise ValueError(message)
750
+
751
+ # Update agent attributes
752
+ self.server_agent_id = server_agent_id
753
+ self.server_agent_status = (
754
+ agent_from_server.get("status") if agent_from_server else None
755
+ )
756
+ self.server_agent_onboarding_status = (
757
+ agent_from_server.get("onboarding_status") if agent_from_server else None
758
+ )
759
+
760
+ # If agent is configured, get encrypted parameters
761
+ if self.server_agent_onboarding_status == "configured":
762
+ log.debug(
763
+ f"[Agent configured] getting encrypted parameters for {self.name}"
764
+ )
765
+ server_encrypted_parameters = (
766
+ agent_from_server.get("parameters_encrypted")
767
+ if agent_from_server
768
+ else None
769
+ )
770
+ self.update_parameters_from_server(server, server_encrypted_parameters)
771
+ else:
772
+ log.debug("[Agent not onboarded] skipping encrypted parameters")
773
+
774
+ return self
775
+
776
+ def update_parameters_from_server(
777
+ self, server: "Server", server_encrypted_parameters: str | None
778
+ ) -> None:
779
+ if server_encrypted_parameters and self.parameters_setup:
780
+ self.server_encrypted_parameters = server_encrypted_parameters
781
+ decrypted = server.decrypt(server_encrypted_parameters)
782
+ self.parameters_setup.update_values_from_server(json.loads(decrypted))
783
+ else:
784
+ log.debug("[No encrypted parameters] for {self.name}")
785
+
786
+ def _execute(self, action: str, params: Dict[str, Any] = {}) -> JobResponse:
787
+ """
788
+ Execute an agent method and return a JobResponse
789
+ """
790
+
791
+ module_name, func_name = action.rsplit(".", 1)
792
+ module = __import__(module_name, fromlist=[func_name])
793
+ method = getattr(module, func_name)
794
+ log.debug(f"[Agent method] {method.__name__} with params {params}")
795
+ result = method(**params)
796
+ if not isinstance(result, JobResponse):
797
+ raise TypeError(
798
+ f"Method {func_name} must return a JobResponse object, got {type(result).__name__}"
799
+ )
800
+ return result
801
+
802
+ def job_start(
803
+ self,
804
+ job: Job,
805
+ job_fields: Dict[str, Any],
806
+ context: JobContext,
807
+ server: "Server",
808
+ method_name: str = "job_start",
809
+ ) -> Job:
810
+ """Execute the agent's start method in the background
811
+
812
+ Args:
813
+ job (Job): The job instance to execute
814
+ job_fields (dict): The job-specific parameters
815
+ context (SupervaizeContextModel): The context of the job
816
+ Returns:
817
+ Job: The updated job instance
818
+ """
819
+ if not self.methods:
820
+ raise ValueError("Agent methods not defined")
821
+ log.debug(
822
+ f"[Agent job_start] Run <{self.methods.job_start.method}> - Job <{job.id}>"
823
+ )
824
+ event = JobStartConfirmationEvent(
825
+ job=job,
826
+ account=server.supervisor_account,
827
+ )
828
+ if server.supervisor_account is not None:
829
+ server.supervisor_account.send_event(sender=job, event=event)
830
+ else:
831
+ log.warning(
832
+ f"[Agent job_start] No supervisor account defined for server, skipping event send for job {job.id}"
833
+ )
834
+
835
+ # Mark job as in progress when execution starts
836
+ job.add_response(
837
+ JobResponse(
838
+ job_id=job.id,
839
+ status=EntityStatus.IN_PROGRESS,
840
+ message="Starting job execution",
841
+ payload=None,
842
+ )
843
+ )
844
+
845
+ # Execute the method
846
+ if method_name == "job_start":
847
+ action = self.methods.job_start
848
+ else:
849
+ if not self.methods.custom:
850
+ raise ValueError(f"Custom method {method_name} not found")
851
+ action = self.methods.custom[method_name]
852
+
853
+ action_method = action.method
854
+ method_params = action.params or {}
855
+ params = (
856
+ method_params
857
+ | {"fields": job_fields}
858
+ | {"context": context}
859
+ | {"agent_parameters": job.agent_parameters}
860
+ )
861
+ log.debug(
862
+ f"[Agent job_start] action_method : {action_method} - params : {params}"
863
+ )
864
+ try:
865
+ if self.methods.job_start.is_async:
866
+ # TODO: Implement async job execution & test
867
+ raise NotImplementedError(
868
+ "[Agent job_start] Async job execution is not implemented"
869
+ )
870
+ started = self._execute(action_method, params)
871
+ job_response = JobResponse(
872
+ job_id=job.id,
873
+ status=EntityStatus.IN_PROGRESS,
874
+ message="Job started ",
875
+ payload={"intermediary_deliverable": started},
876
+ )
877
+ else:
878
+ job_response = self._execute(action_method, params)
879
+ if (
880
+ job_response.status == EntityStatus.COMPLETED
881
+ or job_response.status == EntityStatus.FAILED
882
+ or job_response.status == EntityStatus.CANCELLED
883
+ or job_response.status == EntityStatus.CANCELLING
884
+ ):
885
+ job.add_response(job_response)
886
+ service_job_finished(job, server=server)
887
+ elif job_response.status == EntityStatus.AWAITING:
888
+ log.debug(
889
+ f"[Agent job_start] Job is awaiting input, adding response : Job {job.id} status {job_response} §SAS02"
890
+ )
891
+ job.add_response(job_response)
892
+ else:
893
+ log.warning(
894
+ f"[Agent job_start] Job is not a terminal status, skipping job finish : Job {job.id} status {job_response} §SAS01"
895
+ )
896
+
897
+ except Exception as e:
898
+ # Handle any execution errors
899
+ error_msg = f"Job execution failed: {str(e)}"
900
+ log.error(f"[Agent job_start] Job failed : {job.id} - {error_msg}")
901
+ job_response = JobResponse(
902
+ job_id=job.id,
903
+ status=EntityStatus.FAILED,
904
+ message=error_msg,
905
+ payload=None,
906
+ error=e,
907
+ )
908
+ job.add_response(job_response)
909
+ raise
910
+ return job
911
+
912
+ def job_stop(self, params: Dict[str, Any] = {}) -> Any:
913
+ if not self.methods or not self.methods.job_stop:
914
+ raise ValueError("Agent methods not defined")
915
+ method = self.methods.job_stop.method
916
+ return self._execute(method, params)
917
+
918
+ def job_status(self, params: Dict[str, Any] = {}) -> Any:
919
+ if not self.methods or not self.methods.job_status:
920
+ raise ValueError("Agent methods not defined")
921
+ method = self.methods.job_status.method
922
+ return self._execute(method, params)
923
+
924
+ def chat(self, context: str, message: str) -> Any:
925
+ if not self.methods or not self.methods.chat:
926
+ raise ValueError("Chat method not configured")
927
+ method = self.methods.chat.method
928
+ params = {"context": context, "message": message}
929
+ return self._execute(method, params)
930
+
931
+ @property
932
+ def custom_methods_names(self) -> list[str] | None:
933
+ if self.methods and self.methods.custom:
934
+ return list(self.methods.custom.keys())
935
+ return None
936
+
937
+
938
+ class AgentResponse(BaseModel):
939
+ """Response model for agent endpoints - values provided by Agent.registration_info"""
940
+
941
+ name: str
942
+ id: str
943
+ author: Optional[str] = None
944
+ developer: Optional[str] = None
945
+ maintainer: Optional[str] = None
946
+ editor: Optional[str] = None
947
+ version: str
948
+ api_path: str
949
+ description: str
950
+ tags: Optional[list[str]] = None
951
+ methods: Optional[AgentMethods] = None
952
+ parameters_setup: Optional[List[Dict[str, Any]]] = None
953
+ server_agent_id: Optional[str] = None
954
+ server_agent_status: Optional[str] = None
955
+ server_agent_onboarding_status: Optional[str] = None
956
+ server_encrypted_parameters: Optional[str] = None