supervaizer 0.9.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. supervaizer/__init__.py +88 -0
  2. supervaizer/__version__.py +10 -0
  3. supervaizer/account.py +304 -0
  4. supervaizer/account_service.py +87 -0
  5. supervaizer/admin/routes.py +1254 -0
  6. supervaizer/admin/templates/agent_detail.html +145 -0
  7. supervaizer/admin/templates/agents.html +175 -0
  8. supervaizer/admin/templates/agents_grid.html +80 -0
  9. supervaizer/admin/templates/base.html +233 -0
  10. supervaizer/admin/templates/case_detail.html +230 -0
  11. supervaizer/admin/templates/cases_list.html +182 -0
  12. supervaizer/admin/templates/cases_table.html +134 -0
  13. supervaizer/admin/templates/console.html +389 -0
  14. supervaizer/admin/templates/dashboard.html +153 -0
  15. supervaizer/admin/templates/job_detail.html +192 -0
  16. supervaizer/admin/templates/jobs_list.html +180 -0
  17. supervaizer/admin/templates/jobs_table.html +122 -0
  18. supervaizer/admin/templates/navigation.html +153 -0
  19. supervaizer/admin/templates/recent_activity.html +81 -0
  20. supervaizer/admin/templates/server.html +105 -0
  21. supervaizer/admin/templates/server_status_cards.html +121 -0
  22. supervaizer/agent.py +816 -0
  23. supervaizer/case.py +400 -0
  24. supervaizer/cli.py +135 -0
  25. supervaizer/common.py +283 -0
  26. supervaizer/event.py +181 -0
  27. supervaizer/examples/controller-template.py +195 -0
  28. supervaizer/instructions.py +145 -0
  29. supervaizer/job.py +379 -0
  30. supervaizer/job_service.py +155 -0
  31. supervaizer/lifecycle.py +417 -0
  32. supervaizer/parameter.py +173 -0
  33. supervaizer/protocol/__init__.py +11 -0
  34. supervaizer/protocol/a2a/__init__.py +21 -0
  35. supervaizer/protocol/a2a/model.py +227 -0
  36. supervaizer/protocol/a2a/routes.py +99 -0
  37. supervaizer/protocol/acp/__init__.py +21 -0
  38. supervaizer/protocol/acp/model.py +198 -0
  39. supervaizer/protocol/acp/routes.py +74 -0
  40. supervaizer/py.typed +1 -0
  41. supervaizer/routes.py +667 -0
  42. supervaizer/server.py +554 -0
  43. supervaizer/server_utils.py +54 -0
  44. supervaizer/storage.py +436 -0
  45. supervaizer/telemetry.py +81 -0
  46. supervaizer-0.9.6.dist-info/METADATA +245 -0
  47. supervaizer-0.9.6.dist-info/RECORD +50 -0
  48. supervaizer-0.9.6.dist-info/WHEEL +4 -0
  49. supervaizer-0.9.6.dist-info/entry_points.txt +2 -0
  50. supervaizer-0.9.6.dist-info/licenses/LICENSE.md +346 -0
supervaizer/agent.py ADDED
@@ -0,0 +1,816 @@
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
+
32
+ if TYPE_CHECKING:
33
+ from supervaizer.server import Server
34
+
35
+ insp = inspect
36
+ prnt = print
37
+
38
+ T = TypeVar("T")
39
+
40
+
41
+ class FieldTypeEnum(str, Enum):
42
+ CHAR = "CharField"
43
+ INT = "IntegerField"
44
+ BOOL = "BooleanField"
45
+ CHOICE = "ChoiceField"
46
+ MULTICHOICE = "MultipleChoiceField"
47
+ DATE = "DateField"
48
+ DATETIME = "DateTimeField"
49
+ FLOAT = "FloatField"
50
+ EMAIL = "EmailField"
51
+
52
+
53
+ class AgentMethodField(BaseModel):
54
+ """
55
+ Represents a field specification for generating forms/UI in the Supervaize platform.
56
+
57
+ Fields are used to define user input parameters that will be collected through
58
+ the UI and passed as kwargs to the AgentMethod.method. They follow Django forms
59
+ field definitions for consistency.
60
+
61
+
62
+ - [Django Widgets](https://docs.djangoproject.com/en/5.2/ref/forms/widgets/)
63
+
64
+
65
+ ** field_type - available field types ** [Django Field classes](https://docs.djangoproject.com/en/5.2/ref/forms/fields/#built-in-field-classes)
66
+
67
+ - `CharField` - Text input
68
+ - `IntegerField` - Number input
69
+ - `BooleanField` - Checkbox
70
+ - `ChoiceField` - Dropdown with options
71
+ - `MultipleChoiceField` - Multi-select
72
+ - `JSONField` - JSON data input
73
+
74
+ """
75
+
76
+ name: str = Field(description="The name of the field - displayed in the UI")
77
+ type: Any = Field(
78
+ description="Python type of the field for pydantic validation - note , ChoiceField and MultipleChoiceField are a list[str]"
79
+ )
80
+ field_type: FieldTypeEnum = Field(
81
+ default=FieldTypeEnum.CHAR, description="Field type for persistence"
82
+ )
83
+ description: str | None = Field(
84
+ default=None, description="Description of the field - displayed in the UI"
85
+ )
86
+ # TODO: confirm the structure of choices (list[str] or list[tuple(str)) - How do we integrate it in Supervaize
87
+ choices: 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
+
215
+ class AgentMethod(AgentMethodAbstract):
216
+ @property
217
+ def fields_definitions(self) -> list[Dict[str, Any]]:
218
+ """
219
+ Returns a list of the fields with the type key as a string
220
+ Used for the API response.
221
+ """
222
+ if self.fields:
223
+ result = []
224
+ for field in self.fields:
225
+ d = {k: v for k, v in field.__dict__.items() if k != "type"}
226
+ # type as string
227
+ type_val = field.type
228
+ if hasattr(type_val, "__name__"):
229
+ d["type"] = type_val.__name__
230
+ elif hasattr(type_val, "_name") and type_val._name:
231
+ d["type"] = type_val._name
232
+ else:
233
+ d["type"] = str(type_val)
234
+ result.append(d)
235
+ return result
236
+ return []
237
+
238
+ @property
239
+ def fields_annotations(self) -> type[BaseModel]:
240
+ """
241
+ Creates and returns a dynamic Pydantic model class based on the field definitions.
242
+ """
243
+ if not self.fields:
244
+ return type("EmptyFieldsModel", (BaseModel,), {"to_dict": lambda self: {}})
245
+
246
+ field_annotations = {}
247
+ field_defaults: Dict[str, None] = {}
248
+ for field in self.fields:
249
+ field_name = field.name
250
+ field_type = field.type
251
+ is_required = field.required
252
+ field_annotations[field_name] = (
253
+ field_type if is_required else Optional[field_type]
254
+ )
255
+ if not is_required:
256
+ field_defaults[field_name] = None
257
+
258
+ def to_dict(self: BaseModel) -> Dict[str, Any]:
259
+ return {
260
+ field_name: getattr(self, field_name)
261
+ for field_name in self.__annotations__
262
+ }
263
+
264
+ return type(
265
+ "DynamicFieldsModel",
266
+ (BaseModel,),
267
+ {
268
+ "__annotations__": field_annotations,
269
+ "to_dict": to_dict,
270
+ **field_defaults,
271
+ },
272
+ )
273
+
274
+ @property
275
+ def job_model(self) -> type[AgentJobContextBase]:
276
+ """
277
+ Creates and returns a dynamic Pydantic model class combining job context and job fields.
278
+ """
279
+ fields_model = self.fields_annotations
280
+
281
+ return type(
282
+ "AgentJobAbstract",
283
+ (AgentJobContextBase,),
284
+ {
285
+ "__annotations__": {
286
+ "job_context": JobContext,
287
+ "job_fields": fields_model,
288
+ "encrypted_agent_parameters": str | None,
289
+ }
290
+ },
291
+ )
292
+
293
+ @property
294
+ def registration_info(self) -> Dict[str, Any]:
295
+ """
296
+ Returns a JSON-serializable dictionary representation of the AgentMethod.
297
+ """
298
+ return {
299
+ "name": self.name,
300
+ "method": str(self.method),
301
+ "params": self.params,
302
+ "fields": self.fields_definitions,
303
+ "description": self.description,
304
+ }
305
+
306
+
307
+ class AgentMethodParams(BaseModel):
308
+ """
309
+ Method parameters for agent operations.
310
+
311
+ """
312
+
313
+ params: Dict[str, Any] = Field(
314
+ default_factory=dict,
315
+ description="A simple key-value dictionary of parameters what will be passed to the AgentMethod.method as kwargs",
316
+ )
317
+
318
+
319
+ class AgentCustomMethodParams(AgentMethodParams):
320
+ method_name: str
321
+
322
+
323
+ class AgentMethodsAbstract(BaseModel):
324
+ job_start: AgentMethod
325
+ job_stop: AgentMethod
326
+ job_status: AgentMethod
327
+ chat: AgentMethod | None = None
328
+ custom: dict[str, AgentMethod] | None = None
329
+
330
+ @field_validator("custom")
331
+ @classmethod
332
+ def validate_custom_method_keys(
333
+ cls, value: dict[str, AgentMethod]
334
+ ) -> dict[str, AgentMethod]:
335
+ """Validate that custom method keys are valid slug-like values suitable for endpoints."""
336
+ if value:
337
+ for key in value.keys():
338
+ # Check if key is a valid slug format
339
+ if not re.match(r"^[a-z0-9]+(?:-[a-z0-9]+)*$", key):
340
+ raise ValueError(
341
+ f"Custom method key '{key}' is not a valid slug. "
342
+ f"Keys must contain only lowercase letters, numbers, and hyphens, "
343
+ f"and cannot start or end with a hyphen. "
344
+ f"Examples: 'backup', 'health-check', 'sync-data'"
345
+ )
346
+
347
+ # Additional checks for endpoint safety
348
+ if len(key) > 50:
349
+ raise ValueError(
350
+ f"Custom method key '{key}' is too long (max 50 characters)"
351
+ )
352
+
353
+ return value
354
+
355
+
356
+ class AgentMethods(AgentMethodsAbstract):
357
+ @property
358
+ def registration_info(self) -> Dict[str, Any]:
359
+ return {
360
+ "job_start": self.job_start.registration_info,
361
+ "job_stop": self.job_stop.registration_info,
362
+ "job_status": self.job_status.registration_info,
363
+ "chat": self.chat.registration_info if self.chat else None,
364
+ "custom": {
365
+ name: method.registration_info
366
+ for name, method in (self.custom or {}).items()
367
+ },
368
+ }
369
+
370
+
371
+ class AgentAbstract(SvBaseModel):
372
+ """
373
+ Agent model for the Supervaize Control API.
374
+
375
+ This represents an agent that can be registered with the Supervaize Control API.
376
+ It contains metadata about the agent like name, version, description etc. as well as
377
+ the methods it supports and any parameter configurations.
378
+
379
+ The agent ID is automatically generated from the name and must match.
380
+
381
+ Example:
382
+ ```python
383
+ Agent(
384
+ name="Email AI Agent",
385
+ author="@parthshr370", # Author of the agent
386
+ developer="@alain_sv", # Developer of the controller
387
+ maintainer="@aintainer",
388
+ editor="AI Editor",
389
+ version="1.0.0",
390
+ description="AI-powered email processing agent that can fetch, analyze, generate responses, and send/draft emails",
391
+ tags=["email", "ai", "automation", "communication"],
392
+ methods=AgentMethods(
393
+ job_start=process_email_method, # Job start method
394
+ job_stop=job_stop, # Job stop method
395
+ job_status=job_status, # Job status method
396
+ chat=None,
397
+ custom=None,
398
+ ),
399
+ parameters_setup=ParametersSetup.from_list([
400
+ Parameter(
401
+ name="IMAP_USERNAME",
402
+ description="IMAP username for email access",
403
+ is_environment=True,
404
+ is_secret=False,
405
+ ),
406
+ Parameter(
407
+ name="IMAP_PASSWORD",
408
+ description="IMAP password for email access",
409
+ is_environment=True,
410
+ is_secret=True,
411
+ ),
412
+ ]),
413
+ )
414
+ ```
415
+ """
416
+
417
+ supervaizer_VERSION: ClassVar[str] = VERSION
418
+ name: str = Field(description="Display name of the agent")
419
+ id: str = Field(description="Unique ID generated from name")
420
+ author: Optional[str] = Field(default=None, description="Author of the agent")
421
+ developer: Optional[str] = Field(
422
+ default=None, description="Developer of the controller integration"
423
+ )
424
+ maintainer: Optional[str] = Field(
425
+ default=None, description="Maintainer of the integration"
426
+ )
427
+ editor: Optional[str] = Field(
428
+ default=None, description="Editor (usually a company)"
429
+ )
430
+ version: str = Field(default="", description="Version string")
431
+ description: str = Field(
432
+ default="", description="Description of what the agent does"
433
+ )
434
+ tags: list[str] | None = Field(
435
+ default=None, description="Tags for categorizing the agent"
436
+ )
437
+ methods: AgentMethods | None = Field(
438
+ default=None, description="Methods supported by this agent"
439
+ )
440
+ parameters_setup: ParametersSetup | None = Field(
441
+ default=None, description="Parameter configuration"
442
+ )
443
+ server_agent_id: str | None = Field(
444
+ default=None, description="ID assigned by server - Do not set this manually"
445
+ )
446
+ server_agent_status: str | None = Field(
447
+ default=None, description="Current status on server - Do not set this manually"
448
+ )
449
+ server_agent_onboarding_status: str | None = Field(
450
+ default=None, description="Onboarding status - Do not set this manually"
451
+ )
452
+ server_encrypted_parameters: str | None = Field(
453
+ default=None,
454
+ description="Encrypted parameters from server - Do not set this manually",
455
+ )
456
+ max_execution_time: int = Field(
457
+ default=60 * 60,
458
+ description="Maximum execution time in seconds, defaults to 1 hour",
459
+ )
460
+
461
+ model_config = {
462
+ "reference_group": "Core",
463
+ }
464
+
465
+
466
+ class Agent(AgentAbstract):
467
+ def __init__(
468
+ self,
469
+ name: str,
470
+ id: str | None = None,
471
+ author: Optional[str] = None,
472
+ developer: Optional[str] = None,
473
+ maintainer: Optional[str] = None,
474
+ editor: Optional[str] = None,
475
+ version: str = "",
476
+ description: str = "",
477
+ tags: list[str] | None = None,
478
+ methods: AgentMethods | None = None,
479
+ parameters_setup: ParametersSetup | None = None,
480
+ server_agent_id: str | None = None,
481
+ server_agent_status: str | None = None,
482
+ server_agent_onboarding_status: str | None = None,
483
+ server_encrypted_parameters: str | None = None,
484
+ max_execution_time: int = 60 * 60, # 1 hour (in seconds)
485
+ **kwargs: Any,
486
+ ) -> None:
487
+ """
488
+ This represents an agent that can be registered with the Supervaize Control API.
489
+ It contains metadata about the agent like name, version, description etc. as well as
490
+ the methods it supports and any parameter configurations.
491
+
492
+ The agent ID is automatically generated from the name and must match.
493
+
494
+ Attributes:
495
+ name (str): Display name of the agent
496
+ id (str): Unique ID generated from name
497
+ author (str, optional): Original author
498
+ developer (str, optional): Current developer
499
+ maintainer (str, optional): Current maintainer
500
+ editor (str, optional): Current editor
501
+ version (str): Version string
502
+ description (str): Description of what the agent does
503
+ tags (list[str], optional): Tags for categorizing the agent
504
+ methods (AgentMethods): Methods supported by this agent
505
+ parameters_setup (ParametersSetup, optional): Parameter configuration
506
+ server_agent_id (str, optional): ID assigned by server
507
+ server_agent_status (str, optional): Current status on server
508
+ server_agent_onboarding_status (str, optional): Onboarding status
509
+ server_encrypted_parameters (str, optional): Encrypted parameters from server
510
+ max_execution_time (int): Maximum execution time in seconds, defaults to 1 hour
511
+
512
+ Tested in tests/test_agent.py
513
+ """
514
+ # Validate or generate agent ID
515
+ agent_id = id or shortuuid.uuid(name=name)
516
+ if id is not None and id != shortuuid.uuid(name=name):
517
+ raise ValueError("Agent ID does not match")
518
+
519
+ # Initialize using Pydantic's mechanism
520
+ super().__init__(
521
+ name=name,
522
+ id=agent_id,
523
+ author=author,
524
+ developer=developer,
525
+ maintainer=maintainer,
526
+ editor=editor,
527
+ version=version,
528
+ description=description,
529
+ tags=tags,
530
+ methods=methods,
531
+ parameters_setup=parameters_setup,
532
+ server_agent_id=server_agent_id,
533
+ server_agent_status=server_agent_status,
534
+ server_agent_onboarding_status=server_agent_onboarding_status,
535
+ server_encrypted_parameters=server_encrypted_parameters,
536
+ max_execution_time=max_execution_time,
537
+ **kwargs,
538
+ )
539
+
540
+ def __str__(self) -> str:
541
+ return f"{self.name} ({self.id})"
542
+
543
+ @property
544
+ def slug(self) -> str:
545
+ return slugify(self.name)
546
+
547
+ @property
548
+ def path(self) -> str:
549
+ return f"/agents/{self.slug}"
550
+
551
+ @property
552
+ def registration_info(self) -> Dict[str, Any]:
553
+ """Returns registration info for the agent"""
554
+ return {
555
+ "name": self.name,
556
+ "id": self.id,
557
+ "author": self.author,
558
+ "developer": self.developer,
559
+ "maintainer": self.maintainer,
560
+ "editor": self.editor,
561
+ "version": self.version,
562
+ "description": self.description,
563
+ "api_path": self.path,
564
+ "slug": self.slug,
565
+ "tags": self.tags,
566
+ "methods": self.methods.registration_info if self.methods else {},
567
+ "parameters_setup": self.parameters_setup.registration_info
568
+ if self.parameters_setup
569
+ else None,
570
+ "server_agent_id": self.server_agent_id,
571
+ "server_agent_status": self.server_agent_status,
572
+ "server_agent_onboarding_status": self.server_agent_onboarding_status,
573
+ "server_encrypted_parameters": self.server_encrypted_parameters,
574
+ "max_execution_time": self.max_execution_time,
575
+ }
576
+
577
+ def update_agent_from_server(self, server: "Server") -> Optional["Agent"]:
578
+ """
579
+ Update agent attributes and parameters from server registration information.
580
+ Example of agent_registration data is available in mock_api_responses.py
581
+
582
+ Server is used to decrypt parameters if needed
583
+ Tested in tests/test_agent.py/test_agent_update_agent_from_server
584
+ """
585
+ if server.supervisor_account:
586
+ if self.server_agent_id:
587
+ # Get agent by ID from SaaS Server
588
+ from_server = server.supervisor_account.get_agent_by(
589
+ agent_id=self.server_agent_id
590
+ )
591
+
592
+ else:
593
+ # Get agent by name from SaaS Server
594
+ from_server = server.supervisor_account.get_agent_by(
595
+ agent_slug=self.slug
596
+ )
597
+ else:
598
+ return None
599
+ if not isinstance(from_server, ApiSuccess):
600
+ log.error(f"[Agent update_agent_from_server] Failed : {from_server}")
601
+ return None
602
+
603
+ agent_from_server = from_server.detail
604
+ server_agent_id = agent_from_server.get("id") if agent_from_server else None
605
+
606
+ # This should never happen, but just in case
607
+ if self.server_agent_id and self.server_agent_id != server_agent_id:
608
+ message = f"Agent ID mismatch: {self.server_agent_id} != {server_agent_id}"
609
+ raise ValueError(message)
610
+
611
+ # Update agent attributes
612
+ self.server_agent_id = server_agent_id
613
+ self.server_agent_status = (
614
+ agent_from_server.get("status") if agent_from_server else None
615
+ )
616
+ self.server_agent_onboarding_status = (
617
+ agent_from_server.get("onboarding_status") if agent_from_server else None
618
+ )
619
+
620
+ # If agent is configured, get encrypted parameters
621
+ if self.server_agent_onboarding_status == "configured":
622
+ log.debug(
623
+ f"[Agent configured] getting encrypted parameters for {self.name}"
624
+ )
625
+ server_encrypted_parameters = (
626
+ agent_from_server.get("parameters_encrypted")
627
+ if agent_from_server
628
+ else None
629
+ )
630
+ self.update_parameters_from_server(server, server_encrypted_parameters)
631
+ else:
632
+ log.debug("[Agent not onboarded] skipping encrypted parameters")
633
+
634
+ return self
635
+
636
+ def update_parameters_from_server(
637
+ self, server: "Server", server_encrypted_parameters: str | None
638
+ ) -> None:
639
+ if server_encrypted_parameters and self.parameters_setup:
640
+ self.server_encrypted_parameters = server_encrypted_parameters
641
+ decrypted = server.decrypt(server_encrypted_parameters)
642
+ self.parameters_setup.update_values_from_server(json.loads(decrypted))
643
+ else:
644
+ log.debug("[No encrypted parameters] for {self.name}")
645
+
646
+ def _execute(self, action: str, params: Dict[str, Any] = {}) -> JobResponse:
647
+ """
648
+ Execute an agent method and return a JobResponse
649
+ """
650
+
651
+ module_name, func_name = action.rsplit(".", 1)
652
+ module = __import__(module_name, fromlist=[func_name])
653
+ method = getattr(module, func_name)
654
+ log.debug(f"[Agent method] {method.__name__} with params {params}")
655
+ result = method(**params)
656
+ if not isinstance(result, JobResponse):
657
+ raise TypeError(
658
+ f"Method {func_name} must return a JobResponse object, got {type(result).__name__}"
659
+ )
660
+ return result
661
+
662
+ def job_start(
663
+ self,
664
+ job: Job,
665
+ job_fields: Dict[str, Any],
666
+ context: JobContext,
667
+ server: "Server",
668
+ method_name: str = "job_start",
669
+ ) -> Job:
670
+ """Execute the agent's start method in the background
671
+
672
+ Args:
673
+ job (Job): The job instance to execute
674
+ job_fields (dict): The job-specific parameters
675
+ context (SupervaizeContextModel): The context of the job
676
+ Returns:
677
+ Job: The updated job instance
678
+ """
679
+ if not self.methods:
680
+ raise ValueError("Agent methods not defined")
681
+ log.debug(
682
+ f"[Agent job_start] Run <{self.methods.job_start.method}> - Job <{job.id}>"
683
+ )
684
+ event = JobStartConfirmationEvent(
685
+ job=job,
686
+ account=server.supervisor_account,
687
+ )
688
+ if server.supervisor_account is not None:
689
+ server.supervisor_account.send_event(sender=job, event=event)
690
+ else:
691
+ log.warning(
692
+ f"[Agent job_start] No supervisor account defined for server, skipping event send for job {job.id}"
693
+ )
694
+
695
+ # Mark job as in progress when execution starts
696
+ job.add_response(
697
+ JobResponse(
698
+ job_id=job.id,
699
+ status=EntityStatus.IN_PROGRESS,
700
+ message="Starting job execution",
701
+ payload=None,
702
+ )
703
+ )
704
+
705
+ # Execute the method
706
+ if method_name == "job_start":
707
+ action = self.methods.job_start
708
+ else:
709
+ if not self.methods.custom:
710
+ raise ValueError(f"Custom method {method_name} not found")
711
+ action = self.methods.custom[method_name]
712
+
713
+ action_method = action.method
714
+ method_params = action.params or {}
715
+ params = (
716
+ method_params
717
+ | {"fields": job_fields}
718
+ | {"context": context}
719
+ | {"agent_parameters": job.agent_parameters}
720
+ )
721
+ log.debug(
722
+ f"[Agent job_start] action_method : {action_method} - params : {params}"
723
+ )
724
+ try:
725
+ if self.methods.job_start.is_async:
726
+ # TODO: Implement async job execution & test
727
+ raise NotImplementedError(
728
+ "[Agent job_start] Async job execution is not implemented"
729
+ )
730
+ started = self._execute(action_method, params)
731
+ job_response = JobResponse(
732
+ job_id=job.id,
733
+ status=EntityStatus.IN_PROGRESS,
734
+ message="Job started ",
735
+ payload={"intermediary_deliverable": started},
736
+ )
737
+ else:
738
+ job_response = self._execute(action_method, params)
739
+ if (
740
+ job_response.status == EntityStatus.COMPLETED
741
+ or job_response.status == EntityStatus.FAILED
742
+ or job_response.status == EntityStatus.CANCELLED
743
+ or job_response.status == EntityStatus.CANCELLING
744
+ ):
745
+ job.add_response(job_response)
746
+ service_job_finished(job, server=server)
747
+ elif job_response.status == EntityStatus.AWAITING:
748
+ log.debug(
749
+ f"[Agent job_start] Job is awaiting input, adding response : Job {job.id} status {job_response} §SAS02"
750
+ )
751
+ job.add_response(job_response)
752
+ else:
753
+ log.warning(
754
+ f"[Agent job_start] Job is not a terminal status, skipping job finish : Job {job.id} status {job_response} §SAS01"
755
+ )
756
+
757
+ except Exception as e:
758
+ # Handle any execution errors
759
+ error_msg = f"Job execution failed: {str(e)}"
760
+ log.error(f"[Agent job_start] Job failed : {job.id} - {error_msg}")
761
+ job_response = JobResponse(
762
+ job_id=job.id,
763
+ status=EntityStatus.FAILED,
764
+ message=error_msg,
765
+ payload=None,
766
+ error=e,
767
+ )
768
+ job.add_response(job_response)
769
+ raise
770
+ return job
771
+
772
+ def job_stop(self, params: Dict[str, Any] = {}) -> Any:
773
+ if not self.methods:
774
+ raise ValueError("Agent methods not defined")
775
+ method = self.methods.job_stop.method
776
+ return self._execute(method, params)
777
+
778
+ def job_status(self, params: Dict[str, Any] = {}) -> Any:
779
+ if not self.methods:
780
+ raise ValueError("Agent methods not defined")
781
+ method = self.methods.job_status.method
782
+ return self._execute(method, params)
783
+
784
+ def chat(self, context: str, message: str) -> Any:
785
+ if not self.methods or not self.methods.chat:
786
+ raise ValueError("Chat method not configured")
787
+ method = self.methods.chat.method
788
+ params = {"context": context, "message": message}
789
+ return self._execute(method, params)
790
+
791
+ @property
792
+ def custom_methods_names(self) -> list[str] | None:
793
+ if self.methods and self.methods.custom:
794
+ return list(self.methods.custom.keys())
795
+ return None
796
+
797
+
798
+ class AgentResponse(BaseModel):
799
+ """Response model for agent endpoints - values provided by Agent.registration_info"""
800
+
801
+ name: str
802
+ id: str
803
+ author: Optional[str] = None
804
+ developer: Optional[str] = None
805
+ maintainer: Optional[str] = None
806
+ editor: Optional[str] = None
807
+ version: str
808
+ api_path: str
809
+ description: str
810
+ tags: Optional[list[str]] = None
811
+ methods: Optional[AgentMethods] = None
812
+ parameters_setup: Optional[List[Dict[str, Any]]] = None
813
+ server_agent_id: Optional[str] = None
814
+ server_agent_status: Optional[str] = None
815
+ server_agent_onboarding_status: Optional[str] = None
816
+ server_encrypted_parameters: Optional[str] = None