sapiopycommons 2025.6.19a564__py3-none-any.whl → 2026.1.22a847__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 (63) hide show
  1. sapiopycommons/ai/__init__.py +0 -0
  2. sapiopycommons/ai/agent_service_base.py +2051 -0
  3. sapiopycommons/ai/converter_service_base.py +163 -0
  4. sapiopycommons/ai/external_credentials.py +131 -0
  5. sapiopycommons/ai/protoapi/agent/agent_pb2.py +87 -0
  6. sapiopycommons/ai/protoapi/agent/agent_pb2.pyi +282 -0
  7. sapiopycommons/ai/protoapi/agent/agent_pb2_grpc.py +154 -0
  8. sapiopycommons/ai/protoapi/agent/entry_pb2.py +49 -0
  9. sapiopycommons/ai/protoapi/agent/entry_pb2.pyi +40 -0
  10. sapiopycommons/ai/protoapi/agent/entry_pb2_grpc.py +24 -0
  11. sapiopycommons/ai/protoapi/agent/item/item_container_pb2.py +61 -0
  12. sapiopycommons/ai/protoapi/agent/item/item_container_pb2.pyi +181 -0
  13. sapiopycommons/ai/protoapi/agent/item/item_container_pb2_grpc.py +24 -0
  14. sapiopycommons/ai/protoapi/externalcredentials/external_credentials_pb2.py +41 -0
  15. sapiopycommons/ai/protoapi/externalcredentials/external_credentials_pb2.pyi +36 -0
  16. sapiopycommons/ai/protoapi/externalcredentials/external_credentials_pb2_grpc.py +24 -0
  17. sapiopycommons/ai/protoapi/fielddefinitions/fields_pb2.py +51 -0
  18. sapiopycommons/ai/protoapi/fielddefinitions/fields_pb2.pyi +59 -0
  19. sapiopycommons/ai/protoapi/fielddefinitions/fields_pb2_grpc.py +24 -0
  20. sapiopycommons/ai/protoapi/fielddefinitions/velox_field_def_pb2.py +123 -0
  21. sapiopycommons/ai/protoapi/fielddefinitions/velox_field_def_pb2.pyi +599 -0
  22. sapiopycommons/ai/protoapi/fielddefinitions/velox_field_def_pb2_grpc.py +24 -0
  23. sapiopycommons/ai/protoapi/pipeline/converter/converter_pb2.py +59 -0
  24. sapiopycommons/ai/protoapi/pipeline/converter/converter_pb2.pyi +68 -0
  25. sapiopycommons/ai/protoapi/pipeline/converter/converter_pb2_grpc.py +149 -0
  26. sapiopycommons/ai/protoapi/pipeline/script/script_pb2.py +69 -0
  27. sapiopycommons/ai/protoapi/pipeline/script/script_pb2.pyi +109 -0
  28. sapiopycommons/ai/protoapi/pipeline/script/script_pb2_grpc.py +153 -0
  29. sapiopycommons/ai/protoapi/pipeline/step_output_pb2.py +49 -0
  30. sapiopycommons/ai/protoapi/pipeline/step_output_pb2.pyi +56 -0
  31. sapiopycommons/ai/protoapi/pipeline/step_output_pb2_grpc.py +24 -0
  32. sapiopycommons/ai/protoapi/pipeline/step_pb2.py +43 -0
  33. sapiopycommons/ai/protoapi/pipeline/step_pb2.pyi +44 -0
  34. sapiopycommons/ai/protoapi/pipeline/step_pb2_grpc.py +24 -0
  35. sapiopycommons/ai/protoapi/session/sapio_conn_info_pb2.py +39 -0
  36. sapiopycommons/ai/protoapi/session/sapio_conn_info_pb2.pyi +33 -0
  37. sapiopycommons/ai/protoapi/session/sapio_conn_info_pb2_grpc.py +24 -0
  38. sapiopycommons/ai/protobuf_utils.py +583 -0
  39. sapiopycommons/ai/request_validation.py +561 -0
  40. sapiopycommons/ai/server.py +152 -0
  41. sapiopycommons/ai/test_client.py +534 -0
  42. sapiopycommons/callbacks/callback_util.py +53 -24
  43. sapiopycommons/eln/experiment_handler.py +12 -5
  44. sapiopycommons/files/assay_plate_reader.py +93 -0
  45. sapiopycommons/files/file_text_converter.py +207 -0
  46. sapiopycommons/files/file_util.py +128 -1
  47. sapiopycommons/files/temp_files.py +82 -0
  48. sapiopycommons/flowcyto/flow_cyto.py +2 -24
  49. sapiopycommons/general/accession_service.py +2 -28
  50. sapiopycommons/general/aliases.py +4 -1
  51. sapiopycommons/general/macros.py +172 -0
  52. sapiopycommons/general/time_util.py +199 -4
  53. sapiopycommons/multimodal/multimodal.py +2 -24
  54. sapiopycommons/recordmodel/record_handler.py +200 -111
  55. sapiopycommons/rules/eln_rule_handler.py +3 -0
  56. sapiopycommons/rules/on_save_rule_handler.py +3 -0
  57. sapiopycommons/webhook/webhook_handlers.py +6 -4
  58. sapiopycommons/webhook/webservice_handlers.py +1 -1
  59. {sapiopycommons-2025.6.19a564.dist-info → sapiopycommons-2026.1.22a847.dist-info}/METADATA +2 -2
  60. sapiopycommons-2026.1.22a847.dist-info/RECORD +113 -0
  61. sapiopycommons-2025.6.19a564.dist-info/RECORD +0 -68
  62. {sapiopycommons-2025.6.19a564.dist-info → sapiopycommons-2026.1.22a847.dist-info}/WHEEL +0 -0
  63. {sapiopycommons-2025.6.19a564.dist-info → sapiopycommons-2026.1.22a847.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,2051 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import logging
6
+ import os.path
7
+ import re
8
+ import subprocess
9
+ import traceback
10
+ from abc import abstractmethod, ABC
11
+ from enum import Enum
12
+ from logging import Logger
13
+ from os import PathLike
14
+ from subprocess import CompletedProcess
15
+ from typing import Any, Iterable, Mapping, Sequence
16
+
17
+ from grpc import ServicerContext
18
+ from sapiopylib.rest.User import SapioUser, ensure_logger_initialized
19
+ from sapiopylib.rest.pojo.DataRecord import DataRecord
20
+ from sapiopylib.rest.pojo.DateRange import DateRange
21
+ from sapiopylib.rest.pojo.datatype.FieldDefinition import AbstractVeloxFieldDefinition, SapioDoubleFormat, \
22
+ SapioStringFormat
23
+ from sapiopylib.rest.utils.recordmodel.PyRecordModel import PyRecordModel, AbstractRecordModel
24
+ from sapiopylib.rest.utils.recordmodel.RecordModelWrapper import WrappedRecordModel
25
+
26
+ from sapiopycommons.ai.external_credentials import ExternalCredentials
27
+ from sapiopycommons.ai.protoapi.agent.agent_pb2 import AgentDetailsResponsePbo, \
28
+ AgentDetailsPbo, ProcessStepRequestPbo, ProcessStepResponsePbo, AgentOutputDetailsPbo, AgentIoConfigBasePbo, \
29
+ AgentInputDetailsPbo, ExampleContainerPbo, ProcessStepResponseStatusPbo, AgentCitationPbo
30
+ from sapiopycommons.ai.protoapi.agent.agent_pb2_grpc import AgentServiceServicer
31
+ from sapiopycommons.ai.protoapi.agent.entry_pb2 import StepOutputBatchPbo, StepItemContainerPbo, \
32
+ StepBinaryContainerPbo, StepCsvContainerPbo, StepCsvHeaderRowPbo, StepCsvRowPbo, StepJsonContainerPbo, \
33
+ StepTextContainerPbo
34
+ from sapiopycommons.ai.protoapi.agent.item.item_container_pb2 import ContentTypePbo, StepDataRecordContainerPbo
35
+ from sapiopycommons.ai.protoapi.externalcredentials.external_credentials_pb2 import ExternalCredentialsPbo
36
+ from sapiopycommons.ai.protoapi.fielddefinitions.fields_pb2 import FieldValuePbo, DataRecordPbo
37
+ from sapiopycommons.ai.protoapi.fielddefinitions.velox_field_def_pb2 import VeloxFieldDefPbo, FieldTypePbo, \
38
+ SelectionPropertiesPbo, IntegerPropertiesPbo, DoublePropertiesPbo, BooleanPropertiesPbo, StringPropertiesPbo, \
39
+ DatePropertiesPbo, BooleanDependentFieldEntryPbo, SelectionDependentFieldEntryPbo, DateRangePropertiesPbo
40
+ from sapiopycommons.ai.protoapi.session.sapio_conn_info_pb2 import SapioUserSecretTypePbo, SapioConnectionInfoPbo
41
+ from sapiopycommons.ai.protobuf_utils import ProtobufUtils
42
+ from sapiopycommons.files.file_util import FileUtil
43
+ from sapiopycommons.files.temp_files import TempFileHandler
44
+ from sapiopycommons.general.aliases import FieldMap, FieldValue, SapioRecord, AliasUtil
45
+
46
+
47
+ # FR-47422: Created classes.
48
+ class ContainerType(Enum):
49
+ """
50
+ An enum of the different container contents of a StepItemContainerPbo.
51
+ """
52
+ BINARY = "binary"
53
+ CSV = "csv"
54
+ DATA_RECORDS = "DataRecord"
55
+ JSON = "json"
56
+ TEXT = "text"
57
+
58
+
59
+ class AgentResult(ABC):
60
+ """
61
+ A class representing a result from a Tool of Tools agent. Instantiate one of the subclasses to create a result
62
+ object.
63
+ """
64
+
65
+ @abstractmethod
66
+ def to_pbo(self, content_type: str, file_extensions: list[str] | None = None,
67
+ data_type_name: str | None = None) -> StepOutputBatchPbo:
68
+ """
69
+ Convert this AgentResult object to a StepOutputBatchPbo or list of FieldValueMapPbo proto objects.
70
+
71
+ :param content_type: The content type of the result.
72
+ :param file_extensions: The file extensions of the result. These may be used to download the results as a
73
+ file within the platform.
74
+ :param data_type_name: The data type of the result. Only used for DataRecordResults.
75
+ """
76
+ pass
77
+
78
+
79
+ class BinaryResult(AgentResult):
80
+ """
81
+ A class representing binary results from an agent.
82
+ """
83
+ binary_data: list[bytes]
84
+ name: str
85
+
86
+ def __init__(self, binary_data: list[bytes], name: str | None = None):
87
+ """
88
+ :param binary_data: The binary data as a list of bytes.
89
+ :param name: An optional identifying name for this result that will be accessible to the next agent.
90
+ """
91
+ self.binary_data = binary_data
92
+ self.name = name
93
+
94
+ def to_pbo(self, content_type: str, file_extensions: list[str] | None = None,
95
+ data_type_name: str | None = None) -> StepOutputBatchPbo:
96
+ extensions: list[str] = file_extensions if file_extensions else []
97
+ return StepOutputBatchPbo(
98
+ item_container=StepItemContainerPbo(
99
+ content_type=ContentTypePbo(name=content_type, extensions=extensions),
100
+ container_name=self.name,
101
+ binary_container=StepBinaryContainerPbo(items=self.binary_data)
102
+ )
103
+ )
104
+
105
+
106
+ class CsvResult(AgentResult):
107
+ """
108
+ A class representing CSV results from an agent.
109
+ """
110
+ csv_data: list[dict[str, Any]]
111
+ name: str
112
+
113
+ def __init__(self, csv_data: list[dict[str, Any]], name: str | None = None):
114
+ """
115
+ :param csv_data: The list of CSV data results, provided as a list of dictionaries of column name to value.
116
+ :param name: An optional identifying name for this result that will be accessible to the next agent.
117
+ """
118
+ self.csv_data = csv_data
119
+ self.name = name
120
+
121
+ def to_pbo(self, content_type: str, file_extensions: list[str] | None = None,
122
+ data_type_name: str | None = None) -> StepOutputBatchPbo:
123
+ return StepOutputBatchPbo(
124
+ item_container=StepItemContainerPbo(
125
+ content_type=ContentTypePbo(name=content_type, extensions=[".csv"]),
126
+ container_name=self.name,
127
+ csv_container=StepCsvContainerPbo(
128
+ header=StepCsvHeaderRowPbo(cells=self.csv_data[0].keys()),
129
+ items=[StepCsvRowPbo(cells=[str(x) for x in row.values()]) for row in self.csv_data]
130
+ )
131
+ ) if self.csv_data else None
132
+ )
133
+
134
+
135
+ class DataRecordResult(AgentResult):
136
+ """
137
+ A class representing data record results from an agent.
138
+ """
139
+ new_records: list[FieldMap]
140
+ existing_records: list[DataRecord]
141
+ name: str | None
142
+ data_type_name: str | None
143
+
144
+ def __init__(self, new_records: list[FieldMap] | None = None,
145
+ existing_records: list[SapioRecord] | None = None, name: str | None = None,
146
+ data_type_name: str | None = None):
147
+ """
148
+ :param new_records: A list of field maps, where each map is a dictionary of field names to values. Each entry
149
+ will create a new data record in the system.
150
+ :param existing_records: A list of records that already exist in the system. Any changes made to the fields of
151
+ the provided records will be committed to the database after this agent's results are returned.
152
+ :param name: An optional identifying name for this result that will be accessible to the next agent.
153
+ :param data_type_name: The data type of the new records created by this result. This is only required if the
154
+ output configuration related to this result does not have a set output data type.
155
+ """
156
+ self.name = name
157
+ self.data_type_name = data_type_name.strip() if data_type_name else None
158
+ self.new_records = new_records if new_records is not None else []
159
+ self.existing_records = []
160
+ if existing_records is not None:
161
+ for record in existing_records:
162
+ record_id: int | None = AliasUtil.to_record_id(record)
163
+ if record_id is None or record_id <= 0:
164
+ raise Exception(f"A record provided as an existing record has an invalid record ID.\n{record}")
165
+ if isinstance(record, DataRecord):
166
+ self.existing_records.append(record)
167
+ elif isinstance(record, (PyRecordModel, AbstractRecordModel, WrappedRecordModel)):
168
+ data_record: DataRecord = record.get_data_record()
169
+ data_record.set_fields(record.fields.copy_to_dict())
170
+ self.existing_records.append(data_record)
171
+ else:
172
+ raise Exception(f"Unsupported record type: {type(record)}.")
173
+
174
+ def to_pbo(self, content_type: str, file_extensions: list[str] | None = None,
175
+ data_type_name: str | None = None) -> StepOutputBatchPbo:
176
+ dt: str = data_type_name if data_type_name and data_type_name != "Any" else self.data_type_name
177
+ data_records: list[DataRecordPbo] = []
178
+ for field_map in self.new_records:
179
+ fields: dict[str, FieldValuePbo] = ProtobufUtils.field_map_to_pbo(field_map)
180
+ data_records.append(DataRecordPbo(data_type_name=dt, record_id=None, fields=fields))
181
+ for record in self.existing_records:
182
+ fields: dict[str, FieldValuePbo] = ProtobufUtils.field_map_to_pbo(record.fields)
183
+ data_records.append(DataRecordPbo(data_type_name=record.data_type_name, record_id=record.record_id,
184
+ fields=fields))
185
+ return StepOutputBatchPbo(
186
+ item_container=StepItemContainerPbo(
187
+ content_type=ContentTypePbo(name=content_type),
188
+ container_name=self.name,
189
+ data_record_container=StepDataRecordContainerPbo(items=data_records)
190
+ )
191
+ )
192
+
193
+
194
+ class JsonResult(AgentResult):
195
+ """
196
+ A class representing JSON results from an agent.
197
+ """
198
+ json_data: list[dict[str, Any]]
199
+ name: str
200
+
201
+ def __init__(self, json_data: list[dict[str, Any]], name: str | None = None):
202
+ """
203
+ :param json_data: The list of JSON data results. Each entry in the list represents a separate JSON object.
204
+ These entries must be able to be serialized to JSON using json.dumps().
205
+ :param name: An optional identifying name for this result that will be accessible to the next agent.
206
+ """
207
+ # Verify that the given json_data is actually a list of dictionaries.
208
+ if not isinstance(json_data, list) or not all(isinstance(x, dict) for x in json_data):
209
+ raise ValueError("json_data must be a list of dictionaries.")
210
+ self.json_data = json_data
211
+ self.name = name
212
+
213
+ def to_pbo(self, content_type: str, file_extensions: list[str] | None = None,
214
+ data_type_name: str | None = None) -> StepOutputBatchPbo:
215
+ return StepOutputBatchPbo(
216
+ item_container=StepItemContainerPbo(
217
+ content_type=ContentTypePbo(name=content_type, extensions=[".json"]),
218
+ container_name=self.name,
219
+ json_container=StepJsonContainerPbo(items=[json.dumps(x) for x in self.json_data])
220
+ )
221
+ )
222
+
223
+
224
+ class TextResult(AgentResult):
225
+ """
226
+ A class representing text results from an agent.
227
+ """
228
+ text_data: list[str]
229
+ name: str
230
+
231
+ def __init__(self, text_data: list[str], name: str | None = None):
232
+ """
233
+ :param text_data: The text data as a list of strings.
234
+ :param name: An optional identifying name for this result that will be accessible to the next agent.
235
+ """
236
+ self.text_data = text_data
237
+ self.name = name
238
+
239
+ def to_pbo(self, content_type: str, file_extensions: list[str] | None = None,
240
+ data_type_name: str | None = None) -> StepOutputBatchPbo:
241
+ return StepOutputBatchPbo(
242
+ item_container=StepItemContainerPbo(
243
+ content_type=ContentTypePbo(name=content_type, extensions=[".txt"]),
244
+ container_name=self.name,
245
+ text_container=StepTextContainerPbo(items=self.text_data)
246
+ )
247
+ )
248
+
249
+
250
+ class AgentServiceBase(AgentServiceServicer, ABC):
251
+ """
252
+ A base class for implementing an agent service. Subclasses should implement the register_agents method to register
253
+ their agents with the service.
254
+ """
255
+ debug_mode: bool = False
256
+
257
+ def GetAgentDetails(self, request: AgentDetailsPbo, context: ServicerContext) -> AgentDetailsResponsePbo:
258
+ try:
259
+ # Get the agent details from the registered agents.
260
+ details: list[AgentDetailsPbo] = []
261
+ for agent in self.register_agents():
262
+ details.append(agent().to_pbo())
263
+ if not details:
264
+ raise Exception("No agents registered with this service.")
265
+ return AgentDetailsResponsePbo(agent_framework_version=self.server_version(), agent_details=details)
266
+ except Exception as e:
267
+ # Woe to you if you somehow cause an exception to be raised when just initializing your agents.
268
+ # There's no way to log this.
269
+ print(f"CRITICAL ERROR: {e}")
270
+ print(traceback.format_exc())
271
+ return AgentDetailsResponsePbo()
272
+
273
+ def ProcessData(self, request: ProcessStepRequestPbo, context: ServicerContext) -> ProcessStepResponsePbo:
274
+ try:
275
+ # Convert the SapioConnectionInfo proto object to a SapioUser object.
276
+ user = self._create_user(request.sapio_user)
277
+ # Locate the agent. If a string is returned instead of an agent object,
278
+ # that means that the requested agent was not located.
279
+ agent: AgentBase | str = self.find_agent(user, request, context)
280
+ if isinstance(agent, str):
281
+ return ProcessStepResponsePbo(status=ProcessStepResponseStatusPbo.FAILURE, status_message=agent)
282
+ # Get the agent results from the registered agent matching the request.
283
+ success, msg, results, logs = self.run(agent)
284
+ # Convert the results to protobuf objects.
285
+ output_data: list[StepOutputBatchPbo] = []
286
+ for result, output_config, file_extensions in zip(results, agent.output_configs,
287
+ agent.output_file_extensions):
288
+ base_config: AgentIoConfigBasePbo = output_config.base_config
289
+ output_data.append(result.to_pbo(base_config.content_type, file_extensions,
290
+ base_config.data_type_name))
291
+ # Return a ProcessStepResponse proto object containing the results to the caller.
292
+ status = ProcessStepResponseStatusPbo.SUCCESS if success else ProcessStepResponseStatusPbo.FAILURE
293
+ return ProcessStepResponsePbo(status=status, status_message=msg, output=output_data, log=logs)
294
+ except Exception as e:
295
+ # This try/except should never be needed, as the agent should handle its own exceptions, but better safe
296
+ # than sorry.
297
+ print(f"CRITICAL ERROR: {e}")
298
+ print(traceback.format_exc())
299
+ return ProcessStepResponsePbo(status=ProcessStepResponseStatusPbo.FAILURE,
300
+ status_message=f"CRITICAL ERROR: {e}",
301
+ log=[traceback.format_exc()])
302
+
303
+ @staticmethod
304
+ def _create_user(info: SapioConnectionInfoPbo, timeout_seconds: int = 60) -> SapioUser:
305
+ """
306
+ Create a SapioUser object from the given SapioConnectionInfo proto object.
307
+
308
+ :param info: The SapioConnectionInfo proto object.
309
+ :param timeout_seconds: The request timeout for calls made from this user object.
310
+ """
311
+ guid: str | None = info.app_guid if info.HasField("app_guid") else None
312
+ user = SapioUser(info.webservice_url.rstrip("/"), True, timeout_seconds, guid=guid)
313
+ match info.secret_type:
314
+ case SapioUserSecretTypePbo.SESSION_TOKEN:
315
+ user.api_token = info.secret
316
+ case SapioUserSecretTypePbo.PASSWORD:
317
+ secret: str = info.secret
318
+ if secret.startswith("Basic "):
319
+ secret = secret[6:]
320
+ credentials: list[str] = base64.b64decode(secret).decode().split(":", 1)
321
+ user.username = credentials[0]
322
+ user.password = credentials[1]
323
+ case _:
324
+ raise Exception(f"Unexpected secret type: {info.secret_type}")
325
+ return user
326
+
327
+ @staticmethod
328
+ def server_version() -> int:
329
+ """
330
+ :return: The version of this set of agents.
331
+ """
332
+ return 1
333
+
334
+ @abstractmethod
335
+ def register_agents(self) -> list[type[AgentBase]]:
336
+ """
337
+ Register agent types with this service. Provided agents should implement the AgentBase class.
338
+
339
+ :return: A list of agents to register to this service.
340
+ """
341
+ pass
342
+
343
+ def find_agent(self, user: SapioUser, request: ProcessStepRequestPbo, context: ServicerContext) -> AgentBase | str:
344
+ """
345
+ Locate the requested agent from within this agent service.
346
+
347
+ :param user: A user object that can be used to initialize manager classes using DataMgmtServer to query the
348
+ system.
349
+ :param request: The request object containing the input data.
350
+ :param context: The gRPC context.
351
+ :return: The requested agent, or a string reporting that the agent could not be found that lists all agents
352
+ that this service supports.
353
+ """
354
+ # Locate the agent named in the request.
355
+ agent_name: str = request.agent_name
356
+ registered_agents: dict[str, type[AgentBase]] = {str(a): a for a in self.register_agents()}
357
+ # If the agent is not found, list all of the registered agents for this service so that the caller can correct
358
+ # the agent it is requesting.
359
+ if agent_name not in registered_agents:
360
+ all_agent_names: str = "\n".join(registered_agents.keys())
361
+ msg: str = (f"Agent \"{agent_name}\" not found in the registered agents for this service. The registered "
362
+ f"agents for this service are: \n{all_agent_names}")
363
+ return msg
364
+
365
+ # Instantiate the agent class.
366
+ agent: AgentBase = registered_agents[agent_name]()
367
+ # Setup the agent with details from the request.
368
+ agent.setup(user, request, context, self.debug_mode)
369
+ return agent
370
+
371
+ def run(self, agent: AgentBase) -> tuple[bool, str, list[AgentResult], list[str]]:
372
+ """
373
+ Execute an agent from this service.
374
+
375
+ :param agent: The agent to execute.
376
+ :return: Whether or not the agent succeeded, the status message, the results of the agent, and any logs
377
+ generated by the agent.
378
+ """
379
+ try:
380
+ success: bool = True
381
+ msg: str = ""
382
+ results: list[AgentResult] = []
383
+
384
+ # Validate that the provided inputs match the agent's expected inputs.
385
+ errors: list[str] | None = self.validate_input(agent)
386
+ if errors:
387
+ msg = f"Agent input validation error(s):\n\t" + "\n\t".join(errors)
388
+ success = False
389
+
390
+ # If this is a dry run, then provide the fixed dry run output, even if there were errors with the input.
391
+ # Otherwise, if the inputs were successfully validated, then the agent is executed normally.
392
+ if agent.request.dry_run:
393
+ results = agent.dry_run_output()
394
+ # If the input was validated without errors, then set the return message to say so.
395
+ if success:
396
+ msg = f"{agent} dry run input successfully validated."
397
+ elif success:
398
+ results = agent.run(agent.user)
399
+ # Verify that the run results match what is expected of this agent.
400
+ # Otherwise, update the status message to reflect the successful execution of the agent.
401
+ errors: list[str] | None = self.validate_output(agent, results)
402
+ if errors:
403
+ # Wipe the results list so that malformed results aren't returned
404
+ # to the server and mark the run as a failure.
405
+ msg = f"Agent output validation error(s):\n\t" + "\n\t".join(errors)
406
+ results = []
407
+ success = False
408
+ else:
409
+ msg = f"{agent} successfully completed."
410
+ return success, msg, results, agent.logs
411
+ except Exception as e:
412
+ agent.log_exception("Exception occurred during agent execution.", e)
413
+ return False, str(e), [], agent.logs
414
+ finally:
415
+ # Clean up any temporary files created by the agent. If in debug mode, then log the files instead
416
+ # so that they can be manually inspected.
417
+ if self.debug_mode:
418
+ print("Temporary files/directories created during agent execution:")
419
+ for directory in agent.temp_data.directories:
420
+ print(f"\tDirectory: {directory}")
421
+ for file in agent.temp_data.files:
422
+ print(f"\tFile: {file}")
423
+ else:
424
+ agent.temp_data.cleanup()
425
+
426
+ @staticmethod
427
+ def validate_input(agent: AgentBase) -> list[str] | None:
428
+ """
429
+ Verify that the input from the server matches the expected input according to the agent's input config
430
+ definitions.
431
+
432
+ :param agent: The agent to verify input against.
433
+ :return: A list of errors with the input, if any.
434
+ """
435
+ if len(agent.request.input) != len(agent.input_configs):
436
+ return [f"Expected {len(agent.input_configs)} inputs for this agent,"
437
+ f"but got {len(agent.request.input)} instead."]
438
+ return agent.validate_input()
439
+
440
+ @staticmethod
441
+ def validate_output(agent: AgentBase, results: list[AgentResult]) -> list[str] | None:
442
+ """
443
+ Verify that the output from an agent run matches the expected output according to the agent's output config
444
+ definitions.
445
+
446
+ :param agent: The agent to verify output for.
447
+ :param results: The results of the agent run.
448
+ :return: A list of errors with the output, if any.
449
+ """
450
+ # Validate the output from the agent. Were we actually given a list of results?
451
+ if not isinstance(results, list):
452
+ return [f"Expected a list for results, but got {type(results)} instead."]
453
+ bad_results: list[Any] = [x for x in results if not isinstance(x, AgentResult)]
454
+ if bad_results:
455
+ return [f"The list of results should only contain AgentResult objects. "
456
+ f"Got {[type(x) for x in bad_results]} object(s) instead."]
457
+
458
+ # Look out for missing or out-of-order outputs.
459
+ num_expected_outputs: int = len(agent.output_container_types)
460
+ if len(results) != num_expected_outputs:
461
+ expected_outputs_str: str = ", ".join(x.value for x in agent.output_container_types)
462
+ return [f"Expected {num_expected_outputs} outputs for this agent ({expected_outputs_str}), "
463
+ f"but got {len(results)} instead."]
464
+
465
+ errors: list[str] = []
466
+ output_index: int = 0
467
+ for output in results:
468
+ match agent.output_container_types[output_index]:
469
+ case ContainerType.BINARY:
470
+ if not isinstance(output, BinaryResult):
471
+ errors.append(f"Mismatched output type at index {output_index}: expected "
472
+ f"BinaryResult but got {type(output)}.")
473
+ case ContainerType.CSV:
474
+ if not isinstance(output, CsvResult):
475
+ errors.append(f"Mismatched output type at index {output_index}: expected "
476
+ f"CsvResult but got {type(output)}.")
477
+ case ContainerType.DATA_RECORDS:
478
+ if not isinstance(output, DataRecordResult):
479
+ errors.append(f"Mismatched output type at index {output_index}: expected "
480
+ f"DataRecordResult but got {type(output)}.")
481
+ continue
482
+ config_dt: str = agent.output_configs[output_index].base_config.data_type_name
483
+ result_dt: str = output.data_type_name
484
+ if (not config_dt or config_dt == "Any") and not result_dt and output.new_records:
485
+ errors.append(f"Output {output_index} was not configured with an output data type, "
486
+ f"is creating new records, and did not specify an output data type name "
487
+ f"on the result-level. Data record results that create new records must "
488
+ f"either be configured with a data type name in the agent's init function, "
489
+ f"or provide a data type name on the result object.")
490
+ elif config_dt and config_dt != "Any":
491
+ if result_dt:
492
+ agent.log_warning(f"Output {output_index} was configured with an output data type "
493
+ f"and also specified a data type on the result-level. The result-level "
494
+ f"data type name will be ignored.")
495
+ for record in output.existing_records:
496
+ record_dt: str = record.data_type_name
497
+ if record_dt != config_dt:
498
+ errors.append(f"Output {output_index} expected records of data type "
499
+ f"\"{config_dt}\" but received \"{record_dt}\".\n{record}")
500
+ # Leaving record validation up to the platform. For now, at least. May re-implement agent
501
+ # server level record validation if we see a need for it.
502
+ # else:
503
+ # errors.extend(AgentServiceBase._validate_records_output(agent, config_dt, output))
504
+ case ContainerType.JSON:
505
+ if not isinstance(output, JsonResult):
506
+ errors.append(f"Mismatched output type at index {output_index}: expected "
507
+ f"JsonResult but got {type(output)}.")
508
+ case ContainerType.TEXT:
509
+ if not isinstance(output, TextResult):
510
+ errors.append(f"Mismatched output type at index {output_index}: expected "
511
+ f"TextResult but got {type(output)}.")
512
+ output_index += 1
513
+
514
+ # Run the agent's output validation, but only if no other errors were encountered.
515
+ if not errors:
516
+ errors = agent.validate_output()
517
+ return errors
518
+
519
+ @staticmethod
520
+ def _validate_records_output(agent: AgentBase, data_type_name: str, result: DataRecordResult) -> list[str]:
521
+ """
522
+ UNUSED: Leaving record validation up to the platform.
523
+ """
524
+ errors: list[str] = []
525
+
526
+ def value_field(fd: VeloxFieldDefPbo) -> bool:
527
+ valueless_types: list[FieldTypePbo] = [
528
+ FieldTypePbo.ACTION, FieldTypePbo.PARENTLINK, FieldTypePbo.IDENTIFIER, FieldTypePbo.LINK,
529
+ FieldTypePbo.MULTIPARENTLINK, FieldTypePbo.CHILDLINK, FieldTypePbo.AUTO_ACCESSION
530
+ ]
531
+ return fd.data_field_type not in valueless_types
532
+
533
+ data_types: dict[str, AgentDataType] = {x.data_type_name: x for x in agent.define_data_types()}
534
+ data_type: AgentDataType = data_types.get(data_type_name)
535
+ if data_type:
536
+ field_defs: dict[str, VeloxFieldDefPbo] = {x.data_field_name: x for x in data_type.field_defs if value_field(x)}
537
+
538
+ for i, field_map in enumerate(result.new_records):
539
+ errors.extend(AgentServiceBase._validate_field_map(i, field_map, field_defs))
540
+
541
+ return errors
542
+
543
+ @staticmethod
544
+ def _validate_field_map(i: int, field_map: FieldMap, field_defs: dict[str, VeloxFieldDefPbo]) -> list[str]:
545
+ """
546
+ UNUSED: Leaving record validation up to the platform.
547
+ """
548
+ errors: list[str] = []
549
+
550
+ missing: set[str] = set(field_defs).difference(field_map.keys())
551
+ if missing:
552
+ errors.append(f"Entry {i} is missing field names specified by the AgentDataType: {missing}")
553
+ return errors
554
+
555
+ for field, field_def in field_defs.items():
556
+ value: Any = field_map[field]
557
+ if value is None:
558
+ if field_def.required:
559
+ errors.append(f"Entry {i} is missing required value for field {field}.")
560
+ continue
561
+ match field_def.data_field_type:
562
+ case FieldTypePbo.BOOLEAN:
563
+ if not isinstance(value, bool):
564
+ errors.append(f"Entry {i} field {field} expects a boolean but "
565
+ f"received {type(value)}.")
566
+ case FieldTypePbo.SHORT | FieldTypePbo.INTEGER | FieldTypePbo.LONG \
567
+ | FieldTypePbo.ENUM | FieldTypePbo.DATE | FieldTypePbo.SIDE_LINK:
568
+ if not isinstance(value, int):
569
+ errors.append(f"Entry {i} field {field} expects an integer but "
570
+ f"received {type(value)}.")
571
+ case FieldTypePbo.DOUBLE:
572
+ if not isinstance(value, (int, float)):
573
+ errors.append(f"Entry {i} field {field} expects a float or integer but "
574
+ f"received {type(value)}.")
575
+ case FieldTypePbo.DATE_RANGE:
576
+ if not isinstance(value, str):
577
+ errors.append(f"Entry {i} field {field} expects a string but "
578
+ f"received {type(value)}.")
579
+ else:
580
+ try:
581
+ DateRange.from_str(value)
582
+ except:
583
+ errors.append(f"Entry {i} field {field} expects a date range "
584
+ f"formatted string (<start timestamp>/<end timestamp>) "
585
+ f"but received \"{value}\".")
586
+ case FieldTypePbo.STRING | FieldTypePbo.SELECTION | FieldTypePbo.PICKLIST \
587
+ | FieldTypePbo.ACTION_STRING | FieldTypePbo.FILE_BLOB:
588
+ if not isinstance(value, str):
589
+ errors.append(f"Entry {i} field {field} expects a string but "
590
+ f"received {type(value)}.")
591
+
592
+ return errors
593
+
594
+
595
+ class AgentMetaData:
596
+ """
597
+ A class that allows agents to self-describe their metadata.
598
+ """
599
+ name: str
600
+ description: str
601
+ category: str
602
+ citations: dict[str, str]
603
+ sub_category: str | None
604
+ icon: bytes | None
605
+ license_flag: str | None
606
+
607
+ def __init__(self, name: str, description: str, category: str, citations: dict[str, str],
608
+ sub_category: str | None = None, icon: bytes | None = None, license_flag: str | None = None):
609
+ """
610
+ :param name: The display name of the agent. This should be unique across all agents in the service.
611
+ :param description: The description of the agent.
612
+ :param category: The category of the agent. This is used to group similar agents in the pipeline manager.
613
+ :param citations: Any citations or references for this agent, as a dictionary of citation name to URL.
614
+ :param sub_category: The sub-category of the agent. Agents with the same category and sub-category will appear
615
+ as one item in the list of agents in the pipeline manager. Clicking on this item will then expand to allow
616
+ the selection of which agent in the sub-category should be used. This is used to group multiple agents
617
+ from the same service into one item as not to clutter the list of agents, as some services may have a large
618
+ number of individual agents created for them.
619
+ :param icon: The icon to use for the agent. This will appear in the list of agents and on individual steps
620
+ in the pipeline manager.
621
+ :param license_flag: The license flag for this agent. The system must have this license in order to use this
622
+ agent. If None, the agent is not license locked.
623
+ """
624
+ self.name = name
625
+ self.description = description
626
+ self.category = category
627
+ self.sub_category = sub_category
628
+ self.citations = citations
629
+ self.icon = icon
630
+ self.license_flag = license_flag
631
+
632
+
633
+ class AgentDataType:
634
+ """
635
+ A class that allows agents to self-describe the fields that will be present in the FieldMapResults of the agent.
636
+ """
637
+ data_type_name: str
638
+ display_name: str
639
+ field_defs: list[VeloxFieldDefPbo]
640
+ icon: bytes | None
641
+
642
+ def __init__(self, data_type_name: str, display_name: str,
643
+ field_defs: list[AbstractVeloxFieldDefinition | VeloxFieldDefPbo],
644
+ icon: bytes | None = None):
645
+ """
646
+ :param data_type_name: The name of the data type to use. The length of the data type name
647
+ cannot exceed 63 characters.
648
+ :param display_name: The display name of the data type to use.
649
+ :param field_defs: The fields to use for this data type. The length of the data field names
650
+ cannot exceed 63 characters.
651
+ :param icon: The icon to use for this data type.
652
+ """
653
+ self.data_type_name = data_type_name
654
+ self.display_name = display_name
655
+ self.icon = icon
656
+ self.field_defs = []
657
+ for field_def in field_defs:
658
+ if isinstance(field_def, AbstractVeloxFieldDefinition):
659
+ self.field_defs.append(ProtobufUtils.field_def_to_pbo(field_def))
660
+ else:
661
+ self.field_defs.append(field_def)
662
+
663
+ if len(self.data_type_name) > 63:
664
+ raise Exception(f"Data type name \"{self.data_type_name}\" exceeds the limit of 63 characters.")
665
+ for field_def in self.field_defs:
666
+ if len(field_def.data_field_name) > 63:
667
+ raise Exception(f"Data field name \"{field_def.data_field_name}\" exceeds the limit of 63 characters.")
668
+
669
+
670
+ class AgentBase(ABC):
671
+ """
672
+ A base class for implementing an agent.
673
+ """
674
+ input_configs: list[AgentInputDetailsPbo]
675
+ input_container_types: list[ContainerType]
676
+ output_configs: list[AgentOutputDetailsPbo]
677
+ output_container_types: list[ContainerType]
678
+ output_file_extensions: list[list[str]]
679
+ config_fields: list[VeloxFieldDefPbo]
680
+
681
+ logs: list[str]
682
+ logger: Logger
683
+ _verbose_logging: bool | None = None
684
+
685
+ _temp_data: TempFileHandler | None = None
686
+
687
+ _user: SapioUser | None = None
688
+ _request: ProcessStepRequestPbo | None = None
689
+ _context: ServicerContext | None = None
690
+ _debug_mode: bool | None = None
691
+
692
+ __is_setup: bool
693
+
694
+ @property
695
+ def verbose_logging(self) -> bool:
696
+ if not self.__is_setup:
697
+ raise Exception("Agent must be set up to respond to a request before accessing this property.")
698
+ return self._verbose_logging
699
+
700
+ @property
701
+ def temp_data(self) -> TempFileHandler:
702
+ if not self.__is_setup:
703
+ raise Exception("Agent must be set up to respond to a request before accessing this property.")
704
+ return self._temp_data
705
+
706
+ @property
707
+ def user(self) -> SapioUser:
708
+ if not self.__is_setup:
709
+ raise Exception("Agent must be set up to respond to a request before accessing this property.")
710
+ return self._user
711
+
712
+ @property
713
+ def request(self) -> ProcessStepRequestPbo:
714
+ if not self.__is_setup:
715
+ raise Exception("Agent must be set up to respond to a request before accessing this property.")
716
+ return self._request
717
+
718
+ @property
719
+ def context(self) -> ServicerContext:
720
+ if not self.__is_setup:
721
+ raise Exception("Agent must be set up to respond to a request before accessing this property.")
722
+ return self._context
723
+
724
+ @property
725
+ def debug_mode(self) -> bool:
726
+ if not self.__is_setup:
727
+ raise Exception("Agent must be set up to respond to a request before accessing this property.")
728
+ return self._debug_mode
729
+
730
+ @classmethod
731
+ @abstractmethod
732
+ def identifier(cls):
733
+ """
734
+ :return: The unique identifier of the agent. This is used by the system to determine which agent should be
735
+ updated if an agent is re-imported. This should not be changed after the first time that an agent is
736
+ imported, otherwise a duplicate agent will be created.
737
+ """
738
+ pass
739
+
740
+ @classmethod
741
+ @abstractmethod
742
+ def agent_meta_data(cls) -> AgentMetaData:
743
+ """
744
+ :return: The metadata information of this agent.
745
+ """
746
+ pass
747
+
748
+ @classmethod
749
+ @abstractmethod
750
+ def define_data_types(cls) -> list[AgentDataType] | None:
751
+ """
752
+ :return: The information of the data types of this agent, if applicable. When this agent is first
753
+ imported, these details will be used to create new data types in the system. Note that further
754
+ customization may be made to the data type after it has been imported that can cause it to differ
755
+ from this definition.
756
+ """
757
+ pass
758
+
759
+ def __init__(self):
760
+ self.__is_setup = False
761
+ self.input_configs = []
762
+ self.input_container_types = []
763
+ self.output_configs = []
764
+ self.output_container_types = []
765
+ self.output_file_extensions = []
766
+ self.config_fields = []
767
+ self.logs = []
768
+ self.logger = logging.getLogger(f"AgentBase.{self.__class__.__name__}")
769
+ ensure_logger_initialized(self.logger)
770
+
771
+ def __str__(self):
772
+ return f"{self.agent_meta_data().name} ({self.identifier()})"
773
+
774
+ def __hash__(self):
775
+ return hash(self.identifier())
776
+
777
+ def to_pbo(self) -> AgentDetailsPbo:
778
+ """
779
+ :return: The AgentDetailsPbo proto object representing this agent.
780
+ """
781
+ metadata: AgentMetaData = self.agent_meta_data()
782
+ # TODO: Support multiple data types.
783
+ data_types: list[AgentDataType] | None = self.define_data_types()
784
+ data_type: AgentDataType | None = data_types[0] if data_types else None
785
+ return AgentDetailsPbo(
786
+ import_id=self.identifier(),
787
+ name=metadata.name,
788
+ description=metadata.description,
789
+ category=metadata.category,
790
+ sub_category=metadata.sub_category,
791
+ icon=metadata.icon,
792
+ citation=[AgentCitationPbo(title=x, url=y) for x, y in metadata.citations.items()],
793
+ license_info=metadata.license_flag,
794
+ config_fields=self.config_fields,
795
+ input_configs=self.input_configs,
796
+ output_configs=self.output_configs,
797
+ output_data_type_name=data_type.data_type_name if data_type else None,
798
+ output_data_type_display_name=data_type.display_name if data_type else None,
799
+ output_type_fields=data_type.field_defs if data_type else None,
800
+ output_data_type_icon=data_type.icon if data_type else None,
801
+ )
802
+
803
+ def setup(self, user: SapioUser, request: ProcessStepRequestPbo, context: ServicerContext, debug_mode: bool) -> None:
804
+ """
805
+ Setup the agent with the user, request, and context. This method can be overridden by subclasses to perform
806
+ additional setup.
807
+
808
+ :param user: A user object that can be used to initialize manager classes using DataMgmtServer to query the
809
+ system.
810
+ :param request: The request object containing the input data.
811
+ :param context: The gRPC context.
812
+ :param debug_mode: If true, the agent should run in debug mode, providing additional logging and not cleaning
813
+ up temporary files.
814
+ """
815
+ self.__is_setup = True
816
+ self._user = user
817
+ self._request = request
818
+ self._context = context
819
+ self._verbose_logging = request.verbose_logging
820
+ self._debug_mode = debug_mode
821
+ self._temp_data = TempFileHandler()
822
+
823
+ @staticmethod
824
+ def _parse_jsonl(example: str) -> list[dict[str, Any]]:
825
+ """
826
+ Given a testing example for a JSON output, parse the JSONL into plain JSON.
827
+ """
828
+ # Use a JSON decoder to find all valid JSON strings within the testing example.
829
+ decoder = json.JSONDecoder()
830
+ idx: int = 0
831
+ json_strings: list[str] = []
832
+ while idx < len(example):
833
+ try:
834
+ # Find the next valid JSON object in the example.
835
+ obj, end = decoder.raw_decode(example, idx)
836
+ # Extract the exact substring corresponding to this JSON object
837
+ json_strings.append(example[idx:end].strip())
838
+ idx = end
839
+ except json.JSONDecodeError:
840
+ # Skip invalid character and keep searching
841
+ idx += 1
842
+ # If no valid JSON was encountered, then a bad example was given.
843
+ if not json_strings:
844
+ raise Exception("No valid JSON encountered in testing example.")
845
+
846
+ # At this point, each line is its own top-level JSON object. Verify that every line is a dictionary.
847
+ # For lines that are dictionaries, parse them as JSON.
848
+ data: list[dict[str, Any]] = []
849
+ for line in json_strings:
850
+ line = line.strip()
851
+ if not line.startswith("{") and not line.endswith("}"):
852
+ raise Exception("Testing examples must be JSON dictionaries in the JSONL format. "
853
+ "(The top-level object may not be a list.)")
854
+ data.append(json.loads(line))
855
+ return data
856
+
857
+ @staticmethod
858
+ def get_file_string(path: str) -> str | None:
859
+ """
860
+ :param path: The path of the file to read.
861
+ :return: The file contents as a string, or None if the file does not exist.
862
+ """
863
+ if not os.path.exists(path):
864
+ return None
865
+ with open(path, "r") as f:
866
+ return f.read()
867
+
868
+ @staticmethod
869
+ def get_file_bytes(path: str) -> bytes | None:
870
+ """
871
+ :param path: The path of the file to read.
872
+ :return: The file contents as bytes, or None if the file does not exist.
873
+ """
874
+ if not os.path.exists(path):
875
+ return None
876
+ with open(path, "rb") as f:
877
+ return f.read()
878
+
879
+ @staticmethod
880
+ def _build_example_container(example: str | bytes | None) -> ExampleContainerPbo | None:
881
+ """
882
+ Create an example container from the given example data.
883
+ """
884
+ if example is None:
885
+ return None
886
+ if isinstance(example, str):
887
+ return ExampleContainerPbo(text_example=example)
888
+ if isinstance(example, bytes):
889
+ return ExampleContainerPbo(binary_example=example)
890
+ raise ValueError(f"Unexpected example type: {type(example)}")
891
+
892
+ @staticmethod
893
+ def _data_record_content_type(data_type_name: str | None) -> str:
894
+ data_type: str = data_type_name.strip() if data_type_name else "Any"
895
+ return f"{data_type} DataRecord"
896
+
897
+ def add_binary_input(self, content_type: str, display_name: str, description: str,
898
+ validation: str | None = None, input_count: tuple[int, int] | None = None,
899
+ is_paged: bool = False, page_size: tuple[int, int] | None = None,
900
+ max_request_bytes: int | None = None) -> None:
901
+ """
902
+ Add a binary input configuration to the agent. This determines how many inputs this agent will accept in the
903
+ plan manager, as well as what those inputs are. The IO number of the input will be set to the current number of
904
+ inputs. That is, the first time an add_X_input function is called, the IO number will be 0, the second time it
905
+ is called, the IO number will be 1, and so on.
906
+
907
+ :param content_type: The content type of the input.
908
+ :param display_name: The display name of the input.
909
+ :param description: The description of the input.
910
+ :param validation: An optional validation string for the input.
911
+ :param input_count: A tuple of the minimum and maximum number of inputs allowed for this agent.
912
+ :param is_paged: If true, this input will be paged. If false, this input will not be paged.
913
+ :param page_size: A tuple of the minimum and maximum page size for this agent. The input must be paged in order
914
+ for this to have an effect.
915
+ :param max_request_bytes: The maximum request size in bytes for this agent.
916
+ """
917
+ base_config = AgentIoConfigBasePbo(
918
+ io_number=len(self.input_configs),
919
+ content_type=content_type,
920
+ display_name=display_name,
921
+ description=description,
922
+ structure_example=None,
923
+ # The testing example on the input is never used, hence why it can't be set by this function.
924
+ # The testing example is only used during dry runs, in which the testing_example of the output
925
+ # of the previous step is what gets passed to the next step's input validation.
926
+ testing_example=None
927
+ )
928
+ self._add_input_config(ContainerType.BINARY, base_config, validation, input_count, is_paged, page_size,
929
+ max_request_bytes)
930
+
931
+ def add_csv_input(self, content_type: str, display_name: str, description: str,
932
+ structure_example: str | bytes, validation: str | None = None,
933
+ input_count: tuple[int, int] | None = None, is_paged: bool = False,
934
+ page_size: tuple[int, int] | None = None, max_request_bytes: int | None = None) -> None:
935
+ """
936
+ Add a CSV input configuration to the agent. This determines how many inputs this agent will accept in the
937
+ plan manager, as well as what those inputs are. The IO number of the input will be set to the current number of
938
+ inputs. That is, the first time an add_X_input function is called, the IO number will be 0, the second time it
939
+ is called, the IO number will be 1, and so on.
940
+
941
+ :param content_type: The content type of the input.
942
+ :param display_name: The display name of the input.
943
+ :param description: The description of the input.
944
+ :param structure_example: An example of the structure of the input. This does not need to be an entirely valid
945
+ example, and should often be truncated for brevity.
946
+ :param validation: An optional validation string for the input.
947
+ :param input_count: A tuple of the minimum and maximum number of inputs allowed for this agent.
948
+ :param is_paged: If true, this input will be paged. If false, this input will not be paged.
949
+ :param page_size: A tuple of the minimum and maximum page size for this agent. The input must be paged in order
950
+ for this to have an effect.
951
+ :param max_request_bytes: The maximum request size in bytes for this agent.
952
+ """
953
+ base_config = AgentIoConfigBasePbo(
954
+ io_number=len(self.input_configs),
955
+ content_type=content_type,
956
+ display_name=display_name,
957
+ description=description,
958
+ structure_example=self._build_example_container(structure_example),
959
+ testing_example=None
960
+ )
961
+ self._add_input_config(ContainerType.CSV, base_config, validation, input_count, is_paged, page_size,
962
+ max_request_bytes)
963
+
964
+ def add_data_record_input(self, data_type_name: str | None, display_name: str, description: str,
965
+ structure_example: str | bytes, validation: str | None = None,
966
+ input_count: tuple[int, int] | None = None, is_paged: bool = False,
967
+ page_size: tuple[int, int] | None = None, max_request_bytes: int | None = None) -> None:
968
+ """
969
+ Add a data record input configuration to the agent. This determines how many inputs this agent will accept in
970
+ the plan manager, as well as what those inputs are. The IO number of the input will be set to the current number
971
+ of inputs. That is, the first time an add_X_input function is called, the IO number will be 0, the second time
972
+ it is called, the IO number will be 1, and so on.
973
+
974
+ :param data_type_name: The data type name for the data records input. If None, then any data record can be
975
+ provided as input.
976
+ :param display_name: The display name of the input.
977
+ :param description: The description of the input.
978
+ :param structure_example: An example of the structure of the input. This does not need to be an entirely valid
979
+ example, and should often be truncated for brevity.
980
+ :param validation: An optional validation string for the input.
981
+ :param input_count: A tuple of the minimum and maximum number of inputs allowed for this agent.
982
+ :param is_paged: If true, this input will be paged. If false, this input will not be paged.
983
+ :param page_size: A tuple of the minimum and maximum page size for this agent. The input must be paged in order
984
+ for this to have an effect.
985
+ :param max_request_bytes: The maximum request size in bytes for this agent.
986
+ """
987
+ if data_type_name:
988
+ data_type_name = data_type_name.strip()
989
+ base_config = AgentIoConfigBasePbo(
990
+ io_number=len(self.input_configs),
991
+ content_type=self._data_record_content_type(data_type_name),
992
+ data_type_name=data_type_name,
993
+ display_name=display_name,
994
+ description=description,
995
+ structure_example=self._build_example_container(structure_example),
996
+ testing_example=None
997
+ )
998
+ self._add_input_config(ContainerType.DATA_RECORDS, base_config, validation, input_count, is_paged, page_size,
999
+ max_request_bytes)
1000
+
1001
+ def add_json_input(self, content_type: str, display_name: str, description: str,
1002
+ structure_example: str | bytes, validation: str | None = None,
1003
+ input_count: tuple[int, int] | None = None, is_paged: bool = False,
1004
+ page_size: tuple[int, int] | None = None, max_request_bytes: int | None = None) -> None:
1005
+ """
1006
+ Add a JSON input configuration to the agent. This determines how many inputs this agent will accept in the
1007
+ plan manager, as well as what those inputs are. The IO number of the input will be set to the current number of
1008
+ inputs. That is, the first time an add_X_input function is called, the IO number will be 0, the second time it
1009
+ is called, the IO number will be 1, and so on.
1010
+
1011
+ :param content_type: The content type of the input.
1012
+ :param display_name: The display name of the input.
1013
+ :param description: The description of the input.
1014
+ :param structure_example: An example of the structure of the input. This does not need to be an entirely valid
1015
+ example, and should often be truncated for brevity.
1016
+ :param validation: An optional validation string for the input.
1017
+ :param input_count: A tuple of the minimum and maximum number of inputs allowed for this agent.
1018
+ :param is_paged: If true, this input will be paged. If false, this input will not be paged.
1019
+ :param page_size: A tuple of the minimum and maximum page size for this agent. The input must be paged in order
1020
+ for this to have an effect.
1021
+ :param max_request_bytes: The maximum request size in bytes for this agent.
1022
+ """
1023
+ base_config = AgentIoConfigBasePbo(
1024
+ io_number=len(self.input_configs),
1025
+ content_type=content_type,
1026
+ display_name=display_name,
1027
+ description=description,
1028
+ structure_example=self._build_example_container(structure_example),
1029
+ testing_example=None
1030
+ )
1031
+ self._add_input_config(ContainerType.JSON, base_config, validation, input_count, is_paged, page_size,
1032
+ max_request_bytes)
1033
+
1034
+ def add_text_input(self, content_type: str, display_name: str, description: str,
1035
+ structure_example: str | bytes, validation: str | None = None,
1036
+ input_count: tuple[int, int] | None = None, is_paged: bool = False,
1037
+ page_size: tuple[int, int] | None = None, max_request_bytes: int | None = None) -> None:
1038
+ """
1039
+ Add a text input configuration to the agent. This determines how many inputs this agent will accept in the
1040
+ plan manager, as well as what those inputs are. The IO number of the input will be set to the current number of
1041
+ inputs. That is, the first time an add_X_input function is called, the IO number will be 0, the second time it
1042
+ is called, the IO number will be 1, and so on.
1043
+
1044
+ :param content_type: The content type of the input.
1045
+ :param display_name: The display name of the input.
1046
+ :param description: The description of the input.
1047
+ :param structure_example: An example of the structure of the input. This does not need to be an entirely valid
1048
+ example, and should often be truncated for brevity.
1049
+ :param validation: An optional validation string for the input.
1050
+ :param input_count: A tuple of the minimum and maximum number of inputs allowed for this agent.
1051
+ :param is_paged: If true, this input will be paged. If false, this input will not be paged.
1052
+ :param page_size: A tuple of the minimum and maximum page size for this agent. The input must be paged in order
1053
+ for this to have an effect.
1054
+ :param max_request_bytes: The maximum request size in bytes for this agent.
1055
+ """
1056
+ base_config = AgentIoConfigBasePbo(
1057
+ io_number=len(self.input_configs),
1058
+ content_type=content_type,
1059
+ display_name=display_name,
1060
+ description=description,
1061
+ structure_example=self._build_example_container(structure_example),
1062
+ testing_example=None
1063
+ )
1064
+ self._add_input_config(ContainerType.TEXT, base_config, validation, input_count, is_paged, page_size,
1065
+ max_request_bytes)
1066
+
1067
+ def _add_input_config(self, container_type: ContainerType, base_config: AgentIoConfigBasePbo,
1068
+ validation: str | None = None, input_count: tuple[int, int] | None = None,
1069
+ is_paged: bool = False, page_size: tuple[int, int] | None = None,
1070
+ max_request_bytes: int | None = None) -> None:
1071
+ """
1072
+ Add an input configuration to the agent.
1073
+ """
1074
+ self.input_configs.append(AgentInputDetailsPbo(
1075
+ base_config=base_config,
1076
+ validation=validation,
1077
+ min_input_count=input_count[0] if input_count else None,
1078
+ max_input_count=input_count[1] if input_count else None,
1079
+ paged=is_paged,
1080
+ min_page_size=page_size[0] if page_size else None,
1081
+ max_page_size=page_size[1] if page_size else None,
1082
+ max_request_bytes=max_request_bytes,
1083
+ ))
1084
+ self.input_container_types.append(container_type)
1085
+
1086
+ def add_binary_output(self, content_type: str, display_name: str, description: str,
1087
+ testing_example: str | bytes, file_extensions: list[str] | None = None) -> None:
1088
+ """
1089
+ Add a binary output configuration to the agent. This determines how many outputs this agent will produce in the
1090
+ plan manager, as well as what those outputs are. The IO number of the output will be set to the current number
1091
+ of outputs. That is, the first time an add_X_input function is called, the IO number will be 0, the second time
1092
+ it is called, the IO number will be 1, and so on.
1093
+
1094
+ :param content_type: The content type of the output.
1095
+ :param display_name: The display name of the output.
1096
+ :param description: The description of the output.
1097
+ :param testing_example: An example of the input to be used when testing this agent in the system. This must be
1098
+ an entirely valid example of what an output of this agent could look like so that it can be properly used
1099
+ to run tests with. The provided example may be a string, such as for representing JSON or CSV outputs,
1100
+ or bytes, such as for representing binary outputs like images or files.
1101
+ :param file_extensions: A list of file extensions that this binary output can be saved as.
1102
+ """
1103
+ base_config = AgentIoConfigBasePbo(
1104
+ io_number=len(self.output_configs),
1105
+ content_type=content_type,
1106
+ display_name=display_name,
1107
+ description=description,
1108
+ structure_example=None,
1109
+ testing_example=self._build_example_container(testing_example)
1110
+ )
1111
+ self._add_output_config(ContainerType.BINARY, base_config, file_extensions=file_extensions)
1112
+
1113
+ def add_csv_output(self, content_type: str, display_name: str, description: str,
1114
+ structure_example: str | bytes, testing_example: str | bytes) -> None:
1115
+ """
1116
+ Add a CSV output configuration to the agent. This determines how many outputs this agent will produce in the
1117
+ plan manager, as well as what those outputs are. The IO number of the output will be set to the current number
1118
+ of outputs. That is, the first time an add_X_input function is called, the IO number will be 0, the second time
1119
+ it is called, the IO number will be 1, and so on.
1120
+
1121
+ :param content_type: The content type of the output.
1122
+ :param display_name: The display name of the output.
1123
+ :param description: The description of the output.
1124
+ :param structure_example: An optional example of the structure of the input. This does not need to be an
1125
+ entirely valid example, and should often be truncated for brevity.
1126
+ :param testing_example: An example of the input to be used when testing this agent in the system. This must be
1127
+ an entirely valid example of what an output of this agent could look like so that it can be properly used
1128
+ to run tests with. The provided example may be a string, such as for representing JSON or CSV outputs,
1129
+ or bytes, such as for representing binary outputs like images or files.
1130
+ """
1131
+ base_config = AgentIoConfigBasePbo(
1132
+ io_number=len(self.output_configs),
1133
+ content_type=content_type,
1134
+ display_name=display_name,
1135
+ description=description,
1136
+ structure_example=self._build_example_container(structure_example),
1137
+ testing_example=self._build_example_container(testing_example)
1138
+ )
1139
+ self._add_output_config(ContainerType.CSV, base_config, file_extensions=[".csv"])
1140
+
1141
+ def add_data_record_output(self, data_type_name: str | None, display_name: str, description: str,
1142
+ structure_example: str | bytes, testing_example: str | bytes) -> None:
1143
+ """
1144
+ Add a data record output configuration to the agent. This determines how many outputs this agent will produce in
1145
+ the plan manager, as well as what those outputs are. The IO number of the output will be set to the current
1146
+ number of outputs. That is, the first time an add_X_input function is called, the IO number will be 0, the
1147
+ second time it is called, the IO number will be 1, and so on.
1148
+
1149
+ :param data_type_name: The data type name for the data record output. If None, then any data record may be
1150
+ provided as input.
1151
+ :param display_name: The display name of the output.
1152
+ :param description: The description of the output.
1153
+ :param structure_example: An optional example of the structure of the input. This does not need to be an
1154
+ entirely valid example, and should often be truncated for brevity.
1155
+ :param testing_example: An example of the input to be used when testing this agent in the system. This must be
1156
+ an entirely valid example of what an output of this agent could look like so that it can be properly used
1157
+ to run tests with. The provided example may be a string, such as for representing JSON or CSV outputs,
1158
+ or bytes, such as for representing binary outputs like images or files.
1159
+ """
1160
+ if data_type_name:
1161
+ data_type_name = data_type_name.strip()
1162
+ # See add_json_output for details on parsing testing examples.
1163
+ self._parse_jsonl(testing_example)
1164
+ base_config = AgentIoConfigBasePbo(
1165
+ io_number=len(self.output_configs),
1166
+ content_type=self._data_record_content_type(data_type_name),
1167
+ data_type_name=data_type_name,
1168
+ display_name=display_name,
1169
+ description=description,
1170
+ structure_example=self._build_example_container(structure_example),
1171
+ testing_example=self._build_example_container(testing_example)
1172
+ )
1173
+ self._add_output_config(ContainerType.DATA_RECORDS, base_config, file_extensions=[])
1174
+
1175
+ def add_json_output(self, content_type: str, display_name: str, description: str,
1176
+ structure_example: str | bytes, testing_example: str | bytes) -> None:
1177
+ """
1178
+ Add a JSON output configuration to the agent. This determines how many outputs this agent will produce in the
1179
+ plan manager, as well as what those outputs are. The IO number of the output will be set to the current number
1180
+ of outputs. That is, the first time an add_X_input function is called, the IO number will be 0, the second time
1181
+ it is called, the IO number will be 1, and so on.
1182
+
1183
+ :param content_type: The content type of the output.
1184
+ :param display_name: The display name of the output.
1185
+ :param description: The description of the output.
1186
+ :param structure_example: An optional example of the structure of the input. This does not need to be an
1187
+ entirely valid example, and should often be truncated for brevity.
1188
+ :param testing_example: An example of the input to be used when testing this agent in the system. This must be
1189
+ an entirely valid example of what an output of this agent could look like so that it can be properly used
1190
+ to run tests with. The provided example may be a string, such as for representing JSON or CSV outputs,
1191
+ or bytes, such as for representing binary outputs like images or files.
1192
+ """
1193
+ # The JSON testing example MUST be in the JSONL format with top-level objects being dictionaries.
1194
+ # Attempt to parse it as JSONL. An exception will be thrown if this fails.
1195
+ self._parse_jsonl(testing_example)
1196
+ base_config = AgentIoConfigBasePbo(
1197
+ io_number=len(self.output_configs),
1198
+ content_type=content_type,
1199
+ display_name=display_name,
1200
+ description=description,
1201
+ structure_example=self._build_example_container(structure_example),
1202
+ testing_example=self._build_example_container(testing_example)
1203
+ )
1204
+ self._add_output_config(ContainerType.JSON, base_config, file_extensions=[".json"])
1205
+
1206
+ def add_text_output(self, content_type: str, display_name: str, description: str,
1207
+ structure_example: str | bytes, testing_example: str | bytes) -> None:
1208
+ """
1209
+ Add a text output configuration to the agent. This determines how many outputs this agent will produce in the
1210
+ plan manager, as well as what those outputs are. The IO number of the output will be set to the current number
1211
+ of outputs. That is, the first time an add_X_input function is called, the IO number will be 0, the second time
1212
+ it is called, the IO number will be 1, and so on.
1213
+
1214
+ :param content_type: The content type of the output.
1215
+ :param display_name: The display name of the output.
1216
+ :param description: The description of the output.
1217
+ :param structure_example: An optional example of the structure of the input. This does not need to be an
1218
+ entirely valid example, and should often be truncated for brevity.
1219
+ :param testing_example: An example of the input to be used when testing this agent in the system. This must be
1220
+ an entirely valid example of what an output of this agent could look like so that it can be properly used
1221
+ to run tests with. The provided example may be a string, such as for representing JSON or CSV outputs,
1222
+ or bytes, such as for representing binary outputs like images or files.
1223
+ """
1224
+ base_config = AgentIoConfigBasePbo(
1225
+ io_number=len(self.output_configs),
1226
+ content_type=content_type,
1227
+ display_name=display_name,
1228
+ description=description,
1229
+ structure_example=self._build_example_container(structure_example),
1230
+ testing_example=self._build_example_container(testing_example)
1231
+ )
1232
+ self._add_output_config(ContainerType.TEXT, base_config, file_extensions=[".txt"])
1233
+
1234
+ def _add_output_config(self, container_type: ContainerType, base_config: AgentIoConfigBasePbo,
1235
+ file_extensions: list[str] | None = None) -> None:
1236
+ """
1237
+ Add an output configuration to the agent for the given container type.
1238
+ """
1239
+ self.output_configs.append(AgentOutputDetailsPbo(base_config=base_config))
1240
+ self.output_container_types.append(container_type)
1241
+ self.output_file_extensions.append(file_extensions if file_extensions else [])
1242
+
1243
+ def add_config_field(self, field: VeloxFieldDefPbo) -> None:
1244
+ """
1245
+ Add a configuration field to the agent. This field will be used to configure the agent in the plan manager.
1246
+
1247
+ :param field: The configuration field details.
1248
+ """
1249
+ self.config_fields.append(field)
1250
+
1251
+ def add_config_field_def(self, field: AbstractVeloxFieldDefinition) -> None:
1252
+ """
1253
+ Add a configuration field to the agent. This field will be used to configure the agent in the plan manager.
1254
+
1255
+ :param field: The configuration field details.
1256
+ """
1257
+ self.config_fields.append(ProtobufUtils.field_def_to_pbo(field))
1258
+
1259
+ def add_boolean_config_field(self, field_name: str, display_name: str, description: str,
1260
+ default_value: bool | None = None, optional: bool = False,
1261
+ dependencies: dict[bool, list[str]] | None = None,
1262
+ is_hide_disabled_fields: bool = False) -> None:
1263
+ """
1264
+ Add a boolean configuration field to the agent. This field will be used to configure the agent in the plan
1265
+ manager.
1266
+
1267
+ :param field_name: The name of the field.
1268
+ :param display_name: The display name of the field.
1269
+ :param description: The description of the field.
1270
+ :param default_value: The default value of the field.
1271
+ :param optional: If true, this field is optional. If false, this field is required.
1272
+ :param dependencies: A dictionary of field dependencies. The value of the dictionary is a possible value of
1273
+ this field, and the key is a list of field names for other config fields of this agent that will be
1274
+ disabled if the config field matches the corresponding value.
1275
+ :param is_hide_disabled_fields: If true, fields disabled by a field dependency will be hidden. If false, the
1276
+ dependent fields will be visible, but uneditable.
1277
+ """
1278
+ dependent_fields: list[BooleanDependentFieldEntryPbo] | None = None
1279
+ if dependencies:
1280
+ dependent_fields = []
1281
+ for key, values in dependencies.items():
1282
+ dependent_fields.append(BooleanDependentFieldEntryPbo(key=key, dependent_field_names=values))
1283
+ self.config_fields.append(VeloxFieldDefPbo(
1284
+ data_field_type=FieldTypePbo.BOOLEAN,
1285
+ data_field_name=field_name,
1286
+ display_name=display_name,
1287
+ description=description,
1288
+ required=not optional,
1289
+ editable=True,
1290
+ boolean_properties=BooleanPropertiesPbo(
1291
+ default_value=default_value,
1292
+ dependent_fields=dependent_fields,
1293
+ is_hide_disabled_fields=is_hide_disabled_fields
1294
+ )
1295
+ ))
1296
+
1297
+ def add_double_config_field(self, field_name: str, display_name: str, description: str,
1298
+ default_value: float | None = None, min_value: float = -10.**120,
1299
+ max_value: float = 10.**120, precision: int = 2, optional: bool = False,
1300
+ double_format: SapioDoubleFormat | None = None) -> None:
1301
+ """
1302
+ Add a double configuration field to the agent. This field will be used to configure the agent in the plan
1303
+ manager.
1304
+
1305
+ :param field_name: The name of the field.
1306
+ :param display_name: The display name of the field.
1307
+ :param description: The description of the field.
1308
+ :param default_value: The default value of the field.
1309
+ :param min_value: The minimum value of the field.
1310
+ :param max_value: The maximum value of the field.
1311
+ :param precision: The precision of the field.
1312
+ :param optional: If true, this field is optional. If false, this field is required.
1313
+ :param double_format: The format of the field.
1314
+ """
1315
+ self.config_fields.append(VeloxFieldDefPbo(
1316
+ data_field_type=FieldTypePbo.DOUBLE,
1317
+ data_field_name=field_name,
1318
+ display_name=display_name,
1319
+ description=description,
1320
+ required=not optional,
1321
+ editable=True,
1322
+ double_properties=DoublePropertiesPbo(
1323
+ default_value=default_value,
1324
+ min_value=min_value,
1325
+ max_value=max_value,
1326
+ precision=precision,
1327
+ double_format=ProtobufUtils.double_format_to_pbo(double_format)
1328
+ )
1329
+ ))
1330
+
1331
+ def add_integer_config_field(self, field_name: str, display_name: str, description: str,
1332
+ default_value: int | None = None, min_value: int = -2**31, max_value: int = 2**31-1,
1333
+ optional: bool = False) -> None:
1334
+ """
1335
+ Add an integer configuration field to the agent. This field will be used to configure the agent in the plan
1336
+ manager.
1337
+
1338
+ :param field_name: The name of the field.
1339
+ :param display_name: The display name of the field.
1340
+ :param description: The description of the field.
1341
+ :param default_value: The default value of the field.
1342
+ :param min_value: The minimum value of the field.
1343
+ :param max_value: The maximum value of the field.
1344
+ :param optional: If true, this field is optional. If false, this field is required.
1345
+ """
1346
+ self.config_fields.append(VeloxFieldDefPbo(
1347
+ data_field_type=FieldTypePbo.INTEGER,
1348
+ data_field_name=field_name,
1349
+ display_name=display_name,
1350
+ description=description,
1351
+ required=not optional,
1352
+ editable=True,
1353
+ integer_properties=IntegerPropertiesPbo(
1354
+ default_value=default_value,
1355
+ min_value=min_value,
1356
+ max_value=max_value
1357
+ )
1358
+ ))
1359
+
1360
+ def add_string_config_field(self, field_name: str, display_name: str, description: str,
1361
+ default_value: str | None = None, max_length: int = 1000, optional: bool = False,
1362
+ validation_regex: str | None = None, error_msg: str | None = None,
1363
+ string_format: SapioStringFormat | None = None) -> None:
1364
+ """
1365
+ Add a string configuration field to the agent. This field will be used to configure the agent in the plan
1366
+ manager.
1367
+
1368
+ :param field_name: The name of the field.
1369
+ :param display_name: The display name of the field.
1370
+ :param description: The description of the field.
1371
+ :param default_value: The default value of the field.
1372
+ :param max_length: The maximum length of the field.
1373
+ :param optional: If true, this field is optional. If false, this field is required.
1374
+ :param validation_regex: An optional regex that the field value must match.
1375
+ :param error_msg: An optional error message to display if the field value does not match the regex.
1376
+ :param string_format: The format of the field.
1377
+ """
1378
+ self.config_fields.append(VeloxFieldDefPbo(
1379
+ data_field_type=FieldTypePbo.STRING,
1380
+ data_field_name=field_name,
1381
+ display_name=display_name,
1382
+ description=description,
1383
+ required=not optional,
1384
+ editable=True,
1385
+ string_properties=StringPropertiesPbo(
1386
+ default_value=default_value,
1387
+ max_length=max_length,
1388
+ field_validator=ProtobufUtils.field_validator_pbo(validation_regex, error_msg),
1389
+ string_format=ProtobufUtils.string_format_to_pbo(string_format)
1390
+ )
1391
+ ))
1392
+
1393
+ def add_list_config_field(self, field_name: str, display_name: str, description: str,
1394
+ default_value: str | None = None, allowed_values: list[str] | None = None,
1395
+ direct_edit: bool = False, optional: bool = False,
1396
+ validation_regex: str | None = None, error_msg: str | None = None,
1397
+ dependencies: dict[str, list[str]] | None = None,
1398
+ is_hide_disabled_fields: bool = False) -> None:
1399
+ """
1400
+ Add a list configuration field to the agent. This field will be used to configure the agent in the plan
1401
+ manager.
1402
+
1403
+ :param field_name: The name of the field.
1404
+ :param display_name: The display name of the field.
1405
+ :param description: The description of the field.
1406
+ :param default_value: The default value of the field.
1407
+ :param allowed_values: The list of allowed values for the field.
1408
+ :param direct_edit: If true, the user can enter a value that is not in the list of allowed values. If false,
1409
+ the user can only select from the list of allowed values.
1410
+ :param optional: If true, this field is optional. If false, this field is required.
1411
+ :param validation_regex: An optional regex that the field value must match.
1412
+ :param error_msg: An optional error message to display if the field value does not match the regex.
1413
+ :param dependencies: A dictionary of field values to the fields that should be disabled while this field
1414
+ is set to the key value.
1415
+ :param dependencies: A dictionary of field dependencies. The value of the dictionary is a possible value of
1416
+ this field, and the key is a list of field names for other config fields of this agent that will be
1417
+ disabled if the config field matches the corresponding value.
1418
+ :param is_hide_disabled_fields: If true, fields disabled by a field dependency will be hidden. If false, the
1419
+ dependent fields will be visible, but uneditable.
1420
+ """
1421
+ dependent_fields: list[SelectionDependentFieldEntryPbo] | None = None
1422
+ if dependencies:
1423
+ dependent_fields = []
1424
+ for key, values in dependencies.items():
1425
+ dependent_fields.append(SelectionDependentFieldEntryPbo(key=key, dependent_field_names=values))
1426
+ self.config_fields.append(VeloxFieldDefPbo(
1427
+ data_field_type=FieldTypePbo.SELECTION,
1428
+ data_field_name=field_name,
1429
+ display_name=display_name,
1430
+ description=description,
1431
+ required=not optional,
1432
+ editable=True,
1433
+ selection_properties=SelectionPropertiesPbo(
1434
+ default_value=default_value,
1435
+ static_list_values=allowed_values,
1436
+ direct_edit=direct_edit,
1437
+ field_validator=ProtobufUtils.field_validator_pbo(validation_regex, error_msg),
1438
+ dependent_fields=dependent_fields,
1439
+ is_hide_disabled_fields=is_hide_disabled_fields
1440
+ )
1441
+ ))
1442
+
1443
+ def add_multi_list_config_field(self, field_name: str, display_name: str, description: str,
1444
+ default_value: list[str] | None = None, allowed_values: list[str] | None = None,
1445
+ direct_edit: bool = False, optional: bool = False,
1446
+ validation_regex: str | None = None, error_msg: str | None = None,
1447
+ dependencies: dict[str, list[str]] | None = None,
1448
+ is_hide_disabled_fields: bool = False) -> None:
1449
+ """
1450
+ Add a multi-select list configuration field to the agent. This field will be used to configure the agent in the
1451
+ plan manager.
1452
+
1453
+ :param field_name: The name of the field.
1454
+ :param display_name: The display name of the field.
1455
+ :param description: The description of the field.
1456
+ :param default_value: The default value of the field.
1457
+ :param allowed_values: The list of allowed values for the field.
1458
+ :param direct_edit: If true, the user can enter a value that is not in the list of allowed values. If false,
1459
+ the user can only select from the list of allowed values.
1460
+ :param optional: If true, this field is optional. If false, this field is required.
1461
+ :param validation_regex: An optional regex that the field value must match.
1462
+ :param error_msg: An optional error message to display if the field value does not match the regex.
1463
+ :param dependencies: A dictionary of field dependencies. The value of the dictionary is a possible value of
1464
+ this field, and the key is a list of field names for other config fields of this agent that will be
1465
+ disabled if the config field matches the corresponding value.
1466
+ :param is_hide_disabled_fields: If true, fields disabled by a field dependency will be hidden. If false, the
1467
+ dependent fields will be visible, but uneditable.
1468
+ """
1469
+ dependent_fields: list[SelectionDependentFieldEntryPbo] | None = None
1470
+ if dependencies:
1471
+ dependent_fields = []
1472
+ for key, values in dependencies.items():
1473
+ dependent_fields.append(SelectionDependentFieldEntryPbo(key=key, dependent_field_names=values))
1474
+ self.config_fields.append(VeloxFieldDefPbo(
1475
+ data_field_type=FieldTypePbo.SELECTION,
1476
+ data_field_name=field_name,
1477
+ display_name=display_name,
1478
+ description=description,
1479
+ required=not optional,
1480
+ editable=True,
1481
+ selection_properties=SelectionPropertiesPbo(
1482
+ default_value=",".join(default_value) if default_value else None,
1483
+ static_list_values=allowed_values,
1484
+ multi_select=True,
1485
+ direct_edit=direct_edit,
1486
+ field_validator=ProtobufUtils.field_validator_pbo(validation_regex, error_msg),
1487
+ dependent_fields=dependent_fields,
1488
+ is_hide_disabled_fields=is_hide_disabled_fields
1489
+ )
1490
+ ))
1491
+
1492
+ def add_date_config_field(self, field_name: str, display_name: str, description: str, optional: bool = False,
1493
+ date_time_format: str = "MMM dd, yyyy", default_to_today: bool = False,
1494
+ is_static_date: bool = False) -> None:
1495
+ """
1496
+ Add a date configuration field to the agent. This field will be used to configure the agent in the plan
1497
+ manager.
1498
+
1499
+ :param field_name: The name of the field.
1500
+ :param display_name: The display name of the field.
1501
+ :param description: The description of the field.
1502
+ :param optional: If true, this field is optional. If false, this field is required.
1503
+ :param date_time_format: The format that this date field should appear in. The date format is Java-style.
1504
+ See https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/text/SimpleDateFormat.html for more
1505
+ details.
1506
+ :param default_to_today: If true, the default value of the field will be set to today's date. If false, the
1507
+ default value will be None.
1508
+ :param is_static_date: If true, the user will input the date as UTC. If false, the user will input the date
1509
+ as local time.
1510
+ """
1511
+ self.config_fields.append(VeloxFieldDefPbo(
1512
+ data_field_type=FieldTypePbo.DATE,
1513
+ data_field_name=field_name,
1514
+ display_name=display_name,
1515
+ description=description,
1516
+ required=not optional,
1517
+ editable=True,
1518
+ date_properties=DatePropertiesPbo(
1519
+ default_value="@Today" if default_to_today else None,
1520
+ static_date=is_static_date,
1521
+ date_time_format=date_time_format
1522
+ )
1523
+ ))
1524
+
1525
+ def add_date_range_config_field(self, field_name: str, display_name: str, description: str, optional: bool = False,
1526
+ date_time_format: str = "MMM dd, yyyy", is_static_date: bool = False) -> None:
1527
+ """
1528
+ Add a date range configuration field to the agent. This field will be used to configure the agent in the plan
1529
+ manager. The returned value of a date range field is a string that should be parsed using the DateRange class.
1530
+
1531
+ :param field_name: The name of the field.
1532
+ :param display_name: The display name of the field.
1533
+ :param description: The description of the field.
1534
+ :param optional: If true, this field is optional. If false, this field is required.
1535
+ :param date_time_format: The format that this date field should appear in. The date format is Java-style.
1536
+ See https://docs.oracle.com/en/java/javase/18/docs/api/java.base/java/text/SimpleDateFormat.html for more
1537
+ details.
1538
+ :param is_static_date: If true, the user will input the date as UTC. If false, the user will input the date
1539
+ as local time.
1540
+ """
1541
+ self.config_fields.append(VeloxFieldDefPbo(
1542
+ data_field_type=FieldTypePbo.DATE_RANGE,
1543
+ data_field_name=field_name,
1544
+ display_name=display_name,
1545
+ description=description,
1546
+ required=not optional,
1547
+ editable=True,
1548
+ date_range_properties=DateRangePropertiesPbo(
1549
+ is_static=is_static_date,
1550
+ date_time_format=date_time_format
1551
+ )
1552
+ ))
1553
+
1554
+ def add_credentials_config_field(self, field_name: str, display_name: str, description: str, optional: bool = False,
1555
+ category: str | None = None, dependencies: dict[str, list[str]] | None = None,
1556
+ is_hide_disabled_fields: bool = False) -> None:
1557
+ """
1558
+ Add a list field that asks the user to choose which credentials to use. This field will be used to
1559
+ configure the agent in the plan manager.
1560
+
1561
+ :param field_name: The name of the field.
1562
+ :param display_name: The display name of the field.
1563
+ :param description: The description of the field.
1564
+ :param optional: If true, this field is optional. If false, this field is required.
1565
+ :param category: If provided, only credentials in this category will be shown to the user.
1566
+ :param dependencies: A dictionary of field dependencies. The value of the dictionary is a possible value of
1567
+ this field, and the key is a list of field names for other config fields of this agent that will be
1568
+ disabled if the config field matches the corresponding value.
1569
+ :param is_hide_disabled_fields: If true, fields disabled by a field dependency will be hidden. If false, the
1570
+ dependent fields will be visible, but uneditable.
1571
+ """
1572
+ dependent_fields: list[SelectionDependentFieldEntryPbo] | None = None
1573
+ if dependencies:
1574
+ dependent_fields = []
1575
+ for key, values in dependencies.items():
1576
+ dependent_fields.append(SelectionDependentFieldEntryPbo(key=key, dependent_field_names=values))
1577
+ self.config_fields.append(VeloxFieldDefPbo(
1578
+ data_field_type=FieldTypePbo.SELECTION,
1579
+ data_field_name=field_name,
1580
+ display_name=display_name,
1581
+ description=description,
1582
+ required=not optional,
1583
+ editable=True,
1584
+ selection_properties=SelectionPropertiesPbo(
1585
+ # A credentials field is just a selection field with its list mode set to [ExternalCredentials].
1586
+ list_mode=f"[ExternalCredentials]{category.strip() if category else ''}",
1587
+ multi_select=False,
1588
+ default_value=None,
1589
+ direct_edit=False,
1590
+ dependent_fields=dependent_fields,
1591
+ is_hide_disabled_fields=is_hide_disabled_fields
1592
+ )
1593
+ ))
1594
+
1595
+ @abstractmethod
1596
+ def validate_input(self) -> list[str] | None:
1597
+ """
1598
+ Validate the request given to this agent. If the request is validly formatted, this method should return None.
1599
+ If the request is not valid, this method should return an error message indicating what is wrong with the
1600
+ request.
1601
+
1602
+ This method should not perform any actual processing of the request. It should only validate the inputs and
1603
+ configurations provided in the request.
1604
+
1605
+ The request inputs can be accessed using the self.get_input_*() methods.
1606
+ The request settings can be accessed using the self.get_config_fields() method.
1607
+ The request itself can be accessed using self.request.
1608
+
1609
+ :return: A list of the error messages if the request is not valid. If the request is valid, return an empty
1610
+ list or None.
1611
+ """
1612
+ pass
1613
+
1614
+ def validate_output(self: list[AgentResult]) -> list[str] | None:
1615
+ """
1616
+ Validate the output returned by this agent. This is an optional check that agents are able to run before
1617
+ returning their results to the server. This is called after the agent service verifies that the results
1618
+ match the output configurations defined by the agent.
1619
+
1620
+ :return: A list of the error messages if the results are not valid. If the results are not valid, return an
1621
+ empty list or None.
1622
+ """
1623
+ pass
1624
+
1625
+ def dry_run_output(self) -> list[AgentResult]:
1626
+ """
1627
+ Provide fixed results for a dry run of this agent. This method should not perform any actual processing of the
1628
+ request. It should only return example outputs that can be used to test the next agent in the plan.
1629
+
1630
+ The default implementation of this method looks at the testing_example field of each output configuration
1631
+ and returns a AgentResult object based on the content type of the output.
1632
+
1633
+ :return: A list of AgentResult objects containing example outputs for this agent. Each result in the list
1634
+ corresponds to a separate output from the agent.
1635
+ """
1636
+ results: list[AgentResult] = []
1637
+ for output, container_type in zip(self.output_configs, self.output_container_types):
1638
+ config: AgentIoConfigBasePbo = output.base_config
1639
+ example: ExampleContainerPbo = config.testing_example
1640
+ match container_type:
1641
+ case ContainerType.BINARY:
1642
+ example: bytes = example.binary_example
1643
+ results.append(BinaryResult(binary_data=[example]))
1644
+ case ContainerType.CSV:
1645
+ example: str = example.text_example
1646
+ results.append(CsvResult(FileUtil.tokenize_csv(example.encode())[0]))
1647
+ case ContainerType.DATA_RECORDS:
1648
+ example: str = example.text_example
1649
+ records: list[DataRecord] = [DataRecord.from_json(x) for x in self._parse_jsonl(example)]
1650
+ results.append(DataRecordResult(existing_records=records))
1651
+ case ContainerType.JSON:
1652
+ example: str = example.text_example
1653
+ results.append(JsonResult(json_data=self._parse_jsonl(example)))
1654
+ case ContainerType.TEXT:
1655
+ example: str = example.text_example
1656
+ results.append(TextResult(text_data=[example]))
1657
+ return results
1658
+
1659
+ @abstractmethod
1660
+ def run(self, user: SapioUser) -> list[AgentResult]:
1661
+ """
1662
+ Execute this agent.
1663
+
1664
+ The request inputs can be accessed using the self.get_input_*() methods.
1665
+ The request settings can be accessed using the self.get_config_fields() method.
1666
+ The request itself can be accessed using self.request.
1667
+
1668
+ :param user: A user object that can be used to initialize manager classes using DataMgmtServer to query the
1669
+ system.
1670
+ :return: A list of AgentResult objects containing the response data. Each result in the list corresponds to
1671
+ a separate output from the agent. Field map results do not appear as agent output in the plan manager,
1672
+ instead appearing as records related to the plan step during the run.
1673
+ """
1674
+ pass
1675
+
1676
+ def get_credentials(self, identifier: str | None = None, display_name: str | None = None,
1677
+ category: str | None = None) -> ExternalCredentials:
1678
+ """
1679
+ Get credentials for the given criteria.
1680
+
1681
+ :param identifier: The unique identifier for the credentials.
1682
+ :param display_name: The display name of the credentials.
1683
+ :param category: The category that the credentials are in.
1684
+ :return: An ExternalCredentials object containing the credentials for the given category and host.
1685
+ """
1686
+ if not self.__is_setup:
1687
+ raise Exception("Cannot call this function before the agent has been set up to respond to a request.")
1688
+ # Remove leading/trailing whitespace
1689
+ identifier = identifier.strip() if identifier else None
1690
+ display_name = display_name.strip() if display_name else None
1691
+ category = category.strip() if category else None
1692
+
1693
+ matching_creds: list[ExternalCredentialsPbo] = []
1694
+ for cred in self.request.external_credential:
1695
+ # Do case insensitive comparison
1696
+ if identifier and cred.id.lower != identifier.lower():
1697
+ continue
1698
+ if display_name and cred.display_name.lower != display_name.lower():
1699
+ continue
1700
+ if category and cred.category.lower() != category.lower():
1701
+ continue
1702
+ matching_creds.append(cred)
1703
+ if len(matching_creds) == 0:
1704
+ raise ValueError(f"No credentials found for the criteria. "
1705
+ f"(identifier={identifier}, display_name={display_name}, category={category})")
1706
+ if len(matching_creds) > 1:
1707
+ raise ValueError(f"Multiple credentials found for the given criteria. "
1708
+ f"(identifier={identifier}, display_name={display_name}, category={category})")
1709
+
1710
+ return ExternalCredentials.from_pbo(matching_creds[0])
1711
+
1712
+ def _get_credentials_from_config(self, value: str) -> ExternalCredentials:
1713
+ """
1714
+ Get credentials given the value of a credentials config field.
1715
+
1716
+ :param value: The value of the credentials config field.
1717
+ :return: An ExternalCredentials object containing the credentials.
1718
+ """
1719
+ if not self.__is_setup:
1720
+ raise Exception("Cannot call this function before the agent has been set up to respond to a request.")
1721
+ # Values should be of the format "Name (Identifier)"
1722
+ match = re.match(r"^(.*) \((.*)\)$", value)
1723
+ if not match:
1724
+ raise ValueError(f"Invalid credentials value '{value}'. Expected format 'Name (Identifier)'.")
1725
+ identifier: str = match.group(2)
1726
+ for cred in self.request.external_credential:
1727
+ if cred.id == identifier:
1728
+ return ExternalCredentials.from_pbo(cred)
1729
+ raise ValueError(f"No credentials found with identifier '{identifier}'.")
1730
+
1731
+ def call_subprocess(self,
1732
+ args: str | bytes | PathLike[str] | PathLike[bytes] | Sequence[str | bytes | PathLike[str] | PathLike[bytes]],
1733
+ cwd: str | bytes | PathLike[str] | PathLike[bytes] | None = None,
1734
+ **kwargs) -> CompletedProcess[str]:
1735
+ """
1736
+ Call a subprocess with the given arguments, logging the command and any errors that occur.
1737
+ This function will raise an exception if the return code of the subprocess is non-zero. The output of the
1738
+ subprocess will be captured and returned as part of the CompletedProcess object.
1739
+
1740
+ :param args: The list of arguments to pass to the subprocess.
1741
+ :param cwd: The working directory to run the subprocess in. If None, the current working directory is used.
1742
+ :param kwargs: Additional keyword arguments to pass to subprocess.run().
1743
+ :return: The CompletedProcess object returned by subprocess.run().
1744
+ """
1745
+ try:
1746
+ self.log_info(f"Running subprocess with command: {' '.join(args)}")
1747
+ p: CompletedProcess[str] = subprocess.run(args, check=True, capture_output=True, text=True, cwd=cwd,
1748
+ **kwargs)
1749
+ if p.stdout:
1750
+ self.log_info(f"STDOUT: {p.stdout}")
1751
+ if p.stderr:
1752
+ self.log_info(f"STDERR: {p.stderr}")
1753
+ return p
1754
+ except subprocess.CalledProcessError as e:
1755
+ self.log_error(f"Error running subprocess. Return code: {e.returncode}")
1756
+ if e.stdout:
1757
+ self.log_error(f"STDOUT: {e.stdout}")
1758
+ if e.stderr:
1759
+ self.log_error(f"STDERR: {e.stderr}")
1760
+ raise
1761
+
1762
+ def log_info(self, message: str) -> None:
1763
+ """
1764
+ Log an info message for this agent. If verbose logging is enabled, this message will be included in the logs
1765
+ returned to the caller. Empty/None inputs will not be logged.
1766
+
1767
+ Logging info can be done during initialization, but those logs will not be returned to the caller. Other
1768
+ log calls will be returned to the caller, even if done during initialization.
1769
+
1770
+ :param message: The message to log.
1771
+ """
1772
+ if not message:
1773
+ return
1774
+ if self.__is_setup and self.verbose_logging:
1775
+ self.logs.append(f"INFO: {self}: {message}")
1776
+ self.logger.info(message)
1777
+
1778
+ def log_warning(self, message: str) -> None:
1779
+ """
1780
+ Log a warning message for this agent. This message will be included in the logs returned to the caller.
1781
+ Empty/None inputs will not be logged.
1782
+
1783
+ :param message: The message to log.
1784
+ """
1785
+ if not message:
1786
+ return
1787
+ self.logs.append(f"WARNING: {self}: {message}")
1788
+ self.logger.warning(message)
1789
+
1790
+ def log_error(self, message: str) -> None:
1791
+ """
1792
+ Log an error message for this agent. This message will be included in the logs returned to the caller.
1793
+ Empty/None inputs will not be logged.
1794
+
1795
+ :param message: The message to log.
1796
+ """
1797
+ if not message:
1798
+ return
1799
+ self.logs.append(f"ERROR: {self}: {message}")
1800
+ self.logger.error(message)
1801
+
1802
+ def log_exception(self, message: str, e: Exception) -> None:
1803
+ """
1804
+ Log an exception for this agent. This message will be included in the logs returned to the caller.
1805
+ Empty/None inputs will not be logged.
1806
+
1807
+ :param message: The message to log.
1808
+ :param e: The exception to log.
1809
+ """
1810
+ if not message and not e:
1811
+ return
1812
+ self.logs.append(f"EXCEPTION: {self}: {message} - {e}")
1813
+ self.logger.error(f"{message}\n{traceback.format_exc()}")
1814
+
1815
+ def is_input_partial(self, index: int = 0) -> bool:
1816
+ """
1817
+ Check if the input at the given index is marked as partial.
1818
+
1819
+ :param index: The index of the input to check. Defaults to 0. Used for agents that accept multiple inputs.
1820
+ :return: True if the input is marked as partial, False otherwise.
1821
+ """
1822
+ if not self.__is_setup:
1823
+ raise Exception("Cannot call this function before the agent has been set up to respond to a request.")
1824
+ return self.request.input[index].is_partial
1825
+
1826
+ def get_input_name(self, index: int = 0) -> str | None:
1827
+ """
1828
+ Get the name of the input from the request object.
1829
+
1830
+ :param index: The index of the input to parse. Defaults to 0. Used for agents that accept multiple inputs.
1831
+ :return: The name of the input from the request object, or None if no name is set.
1832
+ """
1833
+ if not self.__is_setup:
1834
+ raise Exception("Cannot call this function before the agent has been set up to respond to a request.")
1835
+ return self.request.input[index].item_container.container_name
1836
+
1837
+ def get_input_content_type(self, index: int = 0) -> ContentTypePbo:
1838
+ """
1839
+ Get the content type of the input from the request object.
1840
+
1841
+ :param index: The index of the input to parse. Defaults to 0. Used for agents that accept multiple inputs.
1842
+ :return: The content type of the input from the request object.
1843
+ """
1844
+ if not self.__is_setup:
1845
+ raise Exception("Cannot call this function before the agent has been set up to respond to a request.")
1846
+ return self.request.input[index].item_container.content_type
1847
+
1848
+ def _validate_get_input(self, index: int, get_type: ContainerType) -> None:
1849
+ """
1850
+ Given an index and the container type being requested from it, validate that the agent is setup to respond
1851
+ to a request, that the index is not out of range, and that the input type from the config matches the
1852
+ input type being requested. If any errors are encountered, raise an exception.
1853
+
1854
+ :param index: The index of the input.
1855
+ :param get_type: The type of the input being requested.
1856
+ """
1857
+ if not self.__is_setup:
1858
+ raise Exception("Cannot call this function before the agent has been set up to respond to a request.")
1859
+ total_inputs: int = len(self.request.input)
1860
+ if index >= total_inputs:
1861
+ raise Exception(f"Index out of range. This agent only has {total_inputs} inputs. Attempted to retrieve "
1862
+ f"input with index {index}.")
1863
+ config: ContainerType = self.input_container_types[index]
1864
+ if config != get_type:
1865
+ raise Exception(f"Input {index} is not a \"{get_type.value}\" input. The container type for this input "
1866
+ f"is \"{config.value}\".")
1867
+
1868
+ def get_input_binary(self, index: int = 0) -> list[bytes]:
1869
+ """
1870
+ Get the binary data from the request object.
1871
+
1872
+ :param index: The index of the input to parse. Defaults to 0. Used for agents that accept multiple inputs.
1873
+ :return: The binary data from the request object.
1874
+ """
1875
+ self._validate_get_input(index, ContainerType.BINARY)
1876
+ container: StepItemContainerPbo = self.request.input[index].item_container
1877
+ if not container.HasField("binary_container"):
1878
+ return []
1879
+ return list(container.binary_container.items)
1880
+
1881
+ def get_input_csv(self, index: int = 0) -> tuple[list[str], list[dict[str, str]]]:
1882
+ """
1883
+ Parse the CSV data from the request object.
1884
+
1885
+ :param index: The index of the input to parse. Defaults to 0. Used for agents that accept multiple inputs.
1886
+ :return: A tuple containing the header row and the data rows. The header row is a list of strings representing
1887
+ the column names, and the data rows are a list of dictionaries where each dictionary represents a row in the
1888
+ CSV with the column names as keys and the corresponding values as strings.
1889
+ """
1890
+ self._validate_get_input(index, ContainerType.CSV)
1891
+ container: StepItemContainerPbo = self.request.input[index].item_container
1892
+ if not container.HasField("csv_container"):
1893
+ return [], []
1894
+ ret_val: list[dict[str, str]] = []
1895
+ headers: Iterable[str] = container.csv_container.header.cells
1896
+ for row in container.csv_container.items:
1897
+ row_dict: dict[str, str] = {}
1898
+ for header, value in zip(headers, row.cells):
1899
+ row_dict[header] = value
1900
+ ret_val.append(row_dict)
1901
+ return list(headers), ret_val
1902
+
1903
+ def get_input_records(self, index: int = 0) -> list[DataRecord]:
1904
+ """
1905
+ Parse the DATA_RECORD data from the request object.
1906
+
1907
+ :param index: The index of the input to parse. Defaults to 0. Used for agents that accept multiple inputs.
1908
+ :return: A list containing the data records from the request object.
1909
+ """
1910
+ self._validate_get_input(index, ContainerType.DATA_RECORDS)
1911
+ container: StepItemContainerPbo = self.request.input[index].item_container
1912
+ if not container.HasField("data_record_container"):
1913
+ return []
1914
+ ret_val: list[DataRecord] = []
1915
+ for record in container.data_record_container.items:
1916
+ ret_val.append(ProtobufUtils.pbo_to_data_record(record))
1917
+ return ret_val
1918
+
1919
+ def get_input_json(self, index: int = 0) -> list[dict[str, Any]]:
1920
+ """
1921
+ Parse the JSON data from the request object.
1922
+
1923
+ :param index: The index of the input to parse. Defaults to 0. Used for agents that accept multiple inputs.
1924
+ :return: A list of parsed JSON objects, which are represented as dictionaries.
1925
+ """
1926
+ self._validate_get_input(index, ContainerType.JSON)
1927
+ container: StepItemContainerPbo = self.request.input[index].item_container
1928
+ if not container.HasField("json_container"):
1929
+ return []
1930
+ input_json: list[Any] = [json.loads(x) for x in container.json_container.items]
1931
+ # Verify that the given JSON actually is a list of dictionaries. If they aren't then the previous step provided
1932
+ # bad input. Agents are enforced to result in a list of dictionaries when returning JSON data, so this is likely
1933
+ # an error caused by a script or static input step.
1934
+ for i, entry in enumerate(input_json):
1935
+ if not isinstance(entry, dict):
1936
+ raise Exception(f"Element {i} of input {index} is not a dictionary object. All top-level JSON inputs "
1937
+ f"are expected to be dictionaries.")
1938
+ return input_json
1939
+
1940
+ def get_input_text(self, index: int = 0) -> list[str]:
1941
+ """
1942
+ Parse the text data from the request object.
1943
+
1944
+ :param index: The index of the input to parse. Defaults to 0. Used for agents that accept multiple inputs.
1945
+ :return: A list of text data as strings.
1946
+ """
1947
+ self._validate_get_input(index, ContainerType.TEXT)
1948
+ container: StepItemContainerPbo = self.request.input[index].item_container
1949
+ if not container.HasField("text_container"):
1950
+ return []
1951
+ return list(container.text_container.items)
1952
+
1953
+ def get_config_defs(self) -> dict[str, VeloxFieldDefPbo]:
1954
+ """
1955
+ Get the config field definitions for this agent.
1956
+
1957
+ :return: A dictionary of field definitions, where the keys are the field names and the values are the
1958
+ VeloxFieldDefPbo objects representing the field definitions.
1959
+ """
1960
+ field_defs: dict[str, VeloxFieldDefPbo] = {}
1961
+ for field_def in self.to_pbo().config_fields:
1962
+ field_defs[field_def.data_field_name] = field_def
1963
+ return field_defs
1964
+
1965
+ def get_config_fields(self) -> dict[str, FieldValue | list[str] | DateRange | ExternalCredentials]:
1966
+ """
1967
+ Get the configuration field values from the request object. If a field is not present in the request,
1968
+ the default value from the config definition will be returned. The returned value for each field will match the
1969
+ field type (e.g. numeric fields return int or float, boolean fields return bool, etc.),
1970
+ along with a few special cases:
1971
+ - Multi-select selection list fields will return a list of strings.
1972
+ - Credentials selection list fields will return an ExternalCredentials object.
1973
+ - Date range fields will return a DateRange object.
1974
+
1975
+ :return: A dictionary of configuration field names and their values.
1976
+ """
1977
+ if not self.__is_setup:
1978
+ raise Exception("Cannot call this function before the agent has been set up to respond to a request.")
1979
+ config_fields: dict[str, Any] = {}
1980
+ raw_configs: Mapping[str, FieldValuePbo] = self.request.config_field_values
1981
+ for field_name, field_def in self.get_config_defs().items():
1982
+ # If the field is present in the request, convert the protobuf value to a Python value.
1983
+ if field_name in raw_configs:
1984
+ field_value: FieldValue = ProtobufUtils.field_pbo_to_value(raw_configs[field_name])
1985
+ # If the field isn't present, use the default value from the field definition.
1986
+ else:
1987
+ field_value: FieldValue = ProtobufUtils.field_def_pbo_to_default_value(field_def)
1988
+ # If the field value is None, continue to the next field.
1989
+ if field_value is None:
1990
+ config_fields[field_name] = field_value
1991
+ continue
1992
+ # If the field is a multi-select selection list, split the value by commas and strip whitespace.
1993
+ # If the field is a credentials selection list, convert the string value(s) to an ExternalCredentials.
1994
+ if field_def.data_field_type == FieldTypePbo.SELECTION:
1995
+ if field_def.selection_properties.multi_select:
1996
+ field_value: list[str] = [x.strip() for x in re.split(r',(?!\s)', field_value) if x.strip()]
1997
+ if field_def.selection_properties.list_mode.startswith("[ExternalCredentials]"):
1998
+ if isinstance(field_value, list):
1999
+ field_value: list[ExternalCredentials] = [self._get_credentials_from_config(x) for x in field_value]
2000
+ else:
2001
+ field_value: ExternalCredentials = self._get_credentials_from_config(field_value)
2002
+ # If the field type is a date range, convert the string value to a DateRange.
2003
+ elif field_def.data_field_type == FieldTypePbo.DATE_RANGE:
2004
+ field_value: DateRange = DateRange.from_str(field_value)
2005
+ config_fields[field_name] = field_value
2006
+ return config_fields
2007
+
2008
+ def get_current_data_type_name(self) -> str:
2009
+ """
2010
+ :return: The data type name that is currently configured for this agent in the system. If the data type name
2011
+ of the output data type is needed during the agent's run function, this is how it should be accessed, as
2012
+ opposed to getting the name from the output_data_type function, as the data type name that the system is
2013
+ currently using may differ from the initial self-described name.
2014
+ """
2015
+ if not self.__is_setup:
2016
+ raise Exception("Cannot call this function before the agent has been set up to respond to a request.")
2017
+ return self.request.output_data_type_name
2018
+
2019
+ @staticmethod
2020
+ def read_from_json(json_data: list[dict[str, Any]], key: str) -> list[Any]:
2021
+ """
2022
+ From a list of dictionaries, return a list of values for the given key from each dictionary. Skips null values.
2023
+
2024
+ :param json_data: The JSON data to read from.
2025
+ :param key: The key to read the values from.
2026
+ :return: A list of values corresponding to the given key in the JSON data.
2027
+ """
2028
+ ret_val: list[Any] = []
2029
+ for entry in json_data:
2030
+ if key in entry:
2031
+ value = entry[key]
2032
+ if isinstance(value, list):
2033
+ ret_val.extend(value)
2034
+ elif value is not None:
2035
+ ret_val.append(value)
2036
+ return ret_val
2037
+
2038
+ @staticmethod
2039
+ def flatten_text(text_data: list[str]) -> list[str]:
2040
+ """
2041
+ From a list of strings that come from a text input, flatten the list by splitting each string on newlines and
2042
+ stripping whitespace. Empty lines will be removed.
2043
+
2044
+ :param text_data: The text data to flatten.
2045
+ :return: A flattened list of strings.
2046
+ """
2047
+ ret_val: list[str] = []
2048
+ for entry in text_data:
2049
+ lines: list[str] = [x.strip() for x in entry.splitlines() if x.strip()]
2050
+ ret_val.extend(lines)
2051
+ return ret_val