arcade-core 2.2.2__py3-none-any.whl → 2.4.0__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.
arcade_core/auth.py CHANGED
@@ -51,6 +51,15 @@ class Atlassian(OAuth2):
51
51
  super().__init__(id=id, scopes=scopes)
52
52
 
53
53
 
54
+ class ClickUp(OAuth2):
55
+ """Marks a tool as requiring ClickUp authorization."""
56
+
57
+ provider_id: str = "clickup"
58
+
59
+ def __init__(self, *, id: Optional[str] = None, scopes: Optional[list[str]] = None): # noqa: A002
60
+ super().__init__(id=id, scopes=scopes)
61
+
62
+
54
63
  class Discord(OAuth2):
55
64
  """Marks a tool as requiring Discord authorization."""
56
65
 
arcade_core/catalog.py CHANGED
@@ -27,7 +27,12 @@ from pydantic_core import PydanticUndefined
27
27
 
28
28
  from arcade_core.annotations import Inferrable
29
29
  from arcade_core.auth import OAuth2, ToolAuthorization
30
- from arcade_core.errors import ToolDefinitionError
30
+ from arcade_core.errors import (
31
+ ToolDefinitionError,
32
+ ToolInputSchemaError,
33
+ ToolkitLoadError,
34
+ ToolOutputSchemaError,
35
+ )
31
36
  from arcade_core.schema import (
32
37
  TOOL_NAME_SEPARATOR,
33
38
  FullyQualifiedName,
@@ -224,7 +229,9 @@ class ToolCatalog(BaseModel):
224
229
  fully_qualified_name = definition.get_fully_qualified_name()
225
230
 
226
231
  if fully_qualified_name in self._tools:
227
- raise KeyError(f"Tool '{definition.name}' already exists in the catalog.")
232
+ raise ToolkitLoadError(
233
+ f"Tool '{definition.name}' in toolkit '{toolkit_name}' already exists in the catalog."
234
+ )
228
235
 
229
236
  if str(fully_qualified_name).lower() in self._disabled_tools:
230
237
  logger.info(f"Tool '{fully_qualified_name!s}' is disabled and will not be cataloged.")
@@ -270,20 +277,26 @@ class ToolCatalog(BaseModel):
270
277
  tool_func = getattr(module, tool_name)
271
278
  self.add_tool(tool_func, toolkit, module)
272
279
 
280
+ except ToolDefinitionError as e:
281
+ raise e.with_context(tool_name) from e
282
+ except ToolkitLoadError as e:
283
+ raise e.with_context(toolkit.name) from e
284
+ except ImportError as e:
285
+ raise ToolkitLoadError(
286
+ f"Could not import module {module_name}. Reason: {e}"
287
+ ).with_context(tool_name)
273
288
  except AttributeError as e:
274
289
  raise ToolDefinitionError(
275
290
  f"Could not import tool {tool_name} in module {module_name}. Reason: {e}"
276
- )
277
- except ImportError as e:
278
- raise ToolDefinitionError(f"Could not import module {module_name}. Reason: {e}")
291
+ ).with_context(tool_name)
279
292
  except TypeError as e:
280
293
  raise ToolDefinitionError(
281
294
  f"Type error encountered while adding tool {tool_name} from {module_name}. Reason: {e}"
282
- )
295
+ ).with_context(tool_name)
283
296
  except Exception as e:
284
297
  raise ToolDefinitionError(
285
298
  f"Error encountered while adding tool {tool_name} from {module_name}. Reason: {e}"
286
- )
299
+ ).with_context(tool_name)
287
300
 
288
301
  def __getitem__(self, name: FullyQualifiedName) -> MaterializedTool:
289
302
  return self.get_tool(name)
@@ -392,11 +405,11 @@ class ToolCatalog(BaseModel):
392
405
  # Hard requirement: tools must have descriptions
393
406
  tool_description = getattr(tool, "__tool_description__", None)
394
407
  if not tool_description:
395
- raise ToolDefinitionError(f"Tool {raw_tool_name} is missing a description")
408
+ raise ToolDefinitionError(f"Tool '{raw_tool_name}' is missing a description")
396
409
 
397
410
  # If the function returns a value, it must have a type annotation
398
411
  if does_function_return_value(tool) and tool.__annotations__.get("return") is None:
399
- raise ToolDefinitionError(f"Tool {raw_tool_name} must have a return type annotation")
412
+ raise ToolOutputSchemaError(f"Tool '{raw_tool_name}' must have a return type")
400
413
 
401
414
  auth_requirement = create_auth_requirement(tool)
402
415
  secrets_requirement = create_secrets_requirement(tool)
@@ -438,7 +451,7 @@ def create_input_definition(func: Callable) -> ToolInput:
438
451
  for _, param in inspect.signature(func, follow_wrapped=True).parameters.items():
439
452
  if param.annotation is ToolContext:
440
453
  if tool_context_param_name is not None:
441
- raise ToolDefinitionError(
454
+ raise ToolInputSchemaError(
442
455
  f"Only one ToolContext parameter is supported, but tool {func.__name__} has multiple."
443
456
  )
444
457
 
@@ -483,7 +496,11 @@ def create_output_definition(func: Callable) -> ToolOutput:
483
496
  )
484
497
 
485
498
  if hasattr(return_type, "__metadata__"):
486
- description = return_type.__metadata__[0] if return_type.__metadata__ else None # type: ignore[assignment]
499
+ description = (
500
+ return_type.__metadata__[0]
501
+ if return_type.__metadata__
502
+ else "No description provided for return type."
503
+ )
487
504
  return_type = return_type.__origin__
488
505
 
489
506
  # Unwrap Optional types
@@ -631,7 +648,7 @@ def extract_field_info(param: inspect.Parameter) -> ToolParamInfo:
631
648
  """
632
649
  annotation = param.annotation
633
650
  if annotation == inspect.Parameter.empty:
634
- raise ToolDefinitionError(f"Parameter {param} has no type annotation.")
651
+ raise ToolInputSchemaError(f"Parameter {param} has no type annotation.")
635
652
 
636
653
  # Get the majority of the param info from either the Pydantic Field() or regular inspection
637
654
  if isinstance(param.default, FieldInfo):
@@ -650,7 +667,7 @@ def extract_field_info(param: inspect.Parameter) -> ToolParamInfo:
650
667
  elif len(str_annotations) == 2:
651
668
  new_name = str_annotations[0]
652
669
  if not new_name.isidentifier():
653
- raise ToolDefinitionError(
670
+ raise ToolInputSchemaError(
654
671
  f"Invalid parameter name: '{new_name}' is not a valid identifier. "
655
672
  "Identifiers must start with a letter or underscore, "
656
673
  "and can only contain letters, digits, or underscores."
@@ -658,7 +675,7 @@ def extract_field_info(param: inspect.Parameter) -> ToolParamInfo:
658
675
  param_info.name = new_name
659
676
  param_info.description = str_annotations[1]
660
677
  else:
661
- raise ToolDefinitionError(
678
+ raise ToolInputSchemaError(
662
679
  f"Parameter {param} has too many string annotations. Expected 0, 1, or 2, got {len(str_annotations)}."
663
680
  )
664
681
 
@@ -673,10 +690,10 @@ def extract_field_info(param: inspect.Parameter) -> ToolParamInfo:
673
690
 
674
691
  # Final reality check
675
692
  if param_info.description is None:
676
- raise ToolDefinitionError(f"Parameter {param_info.name} is missing a description")
693
+ raise ToolInputSchemaError(f"Parameter '{param_info.name}' is missing a description")
677
694
 
678
695
  if wire_type_info.wire_type is None:
679
- raise ToolDefinitionError(f"Unknown parameter type: {param_info.field_type}")
696
+ raise ToolInputSchemaError(f"Unknown parameter type: {param_info.field_type}")
680
697
 
681
698
  return ToolParamInfo.from_param_info(param_info, wire_type_info, is_inferrable)
682
699
 
@@ -792,6 +809,7 @@ def extract_properties(type_to_check: type) -> dict[str, WireTypeInfo] | None:
792
809
  field_type = next(arg for arg in get_args(field_type) if arg is not type(None))
793
810
 
794
811
  # Get wire type info recursively
812
+ # field_type cannot be None here due to the check above
795
813
  wire_info = get_wire_type_info(field_type)
796
814
  properties[field_name] = wire_info
797
815
 
@@ -870,7 +888,7 @@ def extract_python_param_info(param: inspect.Parameter) -> ParamInfo:
870
888
  # Union types are not currently supported
871
889
  # (other than optional, which is handled above)
872
890
  if is_union(field_type):
873
- raise ToolDefinitionError(
891
+ raise ToolInputSchemaError(
874
892
  f"Parameter {param.name} is a union type. Only optional types are supported."
875
893
  )
876
894
 
@@ -890,7 +908,7 @@ def extract_pydantic_param_info(param: inspect.Parameter) -> ParamInfo:
890
908
  if callable(param.default.default_factory):
891
909
  default_value = param.default.default_factory()
892
910
  else:
893
- raise ToolDefinitionError(f"Default factory for parameter {param} is not callable.")
911
+ raise ToolInputSchemaError(f"Default factory for parameter {param} is not callable.")
894
912
 
895
913
  # If the param is Annotated[], unwrap the annotation to get the "real" type
896
914
  # Otherwise, use the literal type
@@ -973,7 +991,9 @@ def create_func_models(func: Callable) -> tuple[type[BaseModel], type[BaseModel]
973
991
  tool_field_info = extract_field_info(param)
974
992
  param_fields = {
975
993
  "default": tool_field_info.default,
976
- "description": tool_field_info.description,
994
+ "description": tool_field_info.description
995
+ if tool_field_info.description
996
+ else "No description provided.",
977
997
  # TODO more here?
978
998
  }
979
999
  input_fields[name] = (tool_field_info.field_type, Field(**param_fields))
@@ -981,7 +1001,6 @@ def create_func_models(func: Callable) -> tuple[type[BaseModel], type[BaseModel]
981
1001
  input_model = create_model(f"{snake_to_pascal_case(func.__name__)}Input", **input_fields) # type: ignore[call-overload]
982
1002
 
983
1003
  output_model = determine_output_model(func)
984
-
985
1004
  return input_model, output_model
986
1005
 
987
1006
 
@@ -991,8 +1010,14 @@ def determine_output_model(func: Callable) -> type[BaseModel]: # noqa: C901
991
1010
  """
992
1011
  return_annotation = inspect.signature(func).return_annotation
993
1012
  output_model_name = f"{snake_to_pascal_case(func.__name__)}Output"
1013
+
1014
+ # If the return annotation is empty, create a model with no fields
994
1015
  if return_annotation is inspect.Signature.empty:
995
1016
  return create_model(output_model_name)
1017
+
1018
+ # If the return annotation has an __origin__ attribute
1019
+ # and does not have a __metadata__ attribute.
1020
+ # This is the case for TypedDicts.
996
1021
  elif hasattr(return_annotation, "__origin__"):
997
1022
  if hasattr(return_annotation, "__metadata__"):
998
1023
  field_type = return_annotation.__args__[0]
@@ -1008,15 +1033,30 @@ def determine_output_model(func: Callable) -> type[BaseModel]: # noqa: C901
1008
1033
  )
1009
1034
  return create_model(
1010
1035
  output_model_name,
1011
- result=(typeddict_model, Field(description=str(description))),
1036
+ result=(
1037
+ typeddict_model,
1038
+ Field(
1039
+ description=str(description)
1040
+ if description
1041
+ else "No description provided."
1042
+ ),
1043
+ ),
1012
1044
  )
1013
1045
 
1046
+ # If the return annotation has a description, use it
1014
1047
  if description:
1015
- return create_model(
1016
- output_model_name,
1017
- result=(field_type, Field(description=str(description))),
1018
- )
1019
- # Handle Union types
1048
+ try:
1049
+ return create_model(
1050
+ output_model_name,
1051
+ result=(field_type, Field(description=str(description))),
1052
+ )
1053
+ except Exception:
1054
+ raise ToolOutputSchemaError(
1055
+ f"Unsupported output type '{field_type}'. Only built-in Python types, TypedDicts, "
1056
+ "Pydantic models, and standard collections are supported as tool output types."
1057
+ )
1058
+
1059
+ # If the return annotation is a Union type
1020
1060
  origin = return_annotation.__origin__
1021
1061
  if origin is typing.Union:
1022
1062
  # For union types, create a model with the first non-None argument
@@ -1037,10 +1077,15 @@ def determine_output_model(func: Callable) -> type[BaseModel]: # noqa: C901
1037
1077
  )
1038
1078
  return create_model(
1039
1079
  output_model_name,
1040
- result=(arg, Field(description="No description provided.")),
1080
+ result=(
1081
+ arg,
1082
+ Field(description="No description provided."),
1083
+ ),
1041
1084
  )
1042
- # when the return_annotation has an __origin__ attribute
1085
+
1086
+ # If the return annotation has an __origin__ attribute
1043
1087
  # and does not have a __metadata__ attribute.
1088
+ # This is the case for TypedDicts.
1044
1089
  return create_model(
1045
1090
  output_model_name,
1046
1091
  result=(
@@ -1049,7 +1094,7 @@ def determine_output_model(func: Callable) -> type[BaseModel]: # noqa: C901
1049
1094
  ),
1050
1095
  )
1051
1096
  else:
1052
- # Check if return type is TypedDict
1097
+ # If the return annotation is a TypedDict
1053
1098
  if is_typeddict(return_annotation):
1054
1099
  typeddict_model = create_model_from_typeddict(return_annotation, output_model_name)
1055
1100
  return create_model(
@@ -1060,10 +1105,13 @@ def determine_output_model(func: Callable) -> type[BaseModel]: # noqa: C901
1060
1105
  ),
1061
1106
  )
1062
1107
 
1063
- # Handle simple return types (like str)
1108
+ # If the return annotation is a simple type (like str)
1064
1109
  return create_model(
1065
1110
  output_model_name,
1066
- result=(return_annotation, Field(description="No description provided.")),
1111
+ result=(
1112
+ return_annotation,
1113
+ Field(description="No description provided."),
1114
+ ),
1067
1115
  )
1068
1116
 
1069
1117
 
arcade_core/errors.py CHANGED
@@ -1,103 +1,378 @@
1
1
  import traceback
2
- from typing import Optional
3
-
4
-
5
- class ToolkitError(Exception):
2
+ import warnings
3
+ from abc import ABC, abstractmethod
4
+ from enum import Enum
5
+ from typing import Any
6
+
7
+
8
+ class ErrorKind(str, Enum):
9
+ """Error kind that is comprised of
10
+ - the who (toolkit, tool, upstream)
11
+ - the when (load time, definition parsing time, runtime)
12
+ - the what (bad_definition, bad_input, bad_output, retry, context_required, fatal, etc.)"""
13
+
14
+ TOOLKIT_LOAD_FAILED = "TOOLKIT_LOAD_FAILED"
15
+ TOOL_DEFINITION_BAD_DEFINITION = "TOOL_DEFINITION_BAD_DEFINITION"
16
+ TOOL_DEFINITION_BAD_INPUT_SCHEMA = "TOOL_DEFINITION_BAD_INPUT_SCHEMA"
17
+ TOOL_DEFINITION_BAD_OUTPUT_SCHEMA = "TOOL_DEFINITION_BAD_OUTPUT_SCHEMA"
18
+ TOOL_RUNTIME_BAD_INPUT_VALUE = "TOOL_RUNTIME_BAD_INPUT_VALUE"
19
+ TOOL_RUNTIME_BAD_OUTPUT_VALUE = "TOOL_RUNTIME_BAD_OUTPUT_VALUE"
20
+ TOOL_RUNTIME_RETRY = "TOOL_RUNTIME_RETRY"
21
+ TOOL_RUNTIME_CONTEXT_REQUIRED = "TOOL_RUNTIME_CONTEXT_REQUIRED"
22
+ TOOL_RUNTIME_FATAL = "TOOL_RUNTIME_FATAL"
23
+ UPSTREAM_RUNTIME_BAD_REQUEST = "UPSTREAM_RUNTIME_BAD_REQUEST"
24
+ UPSTREAM_RUNTIME_AUTH_ERROR = "UPSTREAM_RUNTIME_AUTH_ERROR"
25
+ UPSTREAM_RUNTIME_NOT_FOUND = "UPSTREAM_RUNTIME_NOT_FOUND"
26
+ UPSTREAM_RUNTIME_VALIDATION_ERROR = "UPSTREAM_RUNTIME_VALIDATION_ERROR"
27
+ UPSTREAM_RUNTIME_RATE_LIMIT = "UPSTREAM_RUNTIME_RATE_LIMIT"
28
+ UPSTREAM_RUNTIME_SERVER_ERROR = "UPSTREAM_RUNTIME_SERVER_ERROR"
29
+ UPSTREAM_RUNTIME_UNMAPPED = "UPSTREAM_RUNTIME_UNMAPPED"
30
+ UNKNOWN = "UNKNOWN"
31
+
32
+
33
+ class ToolkitError(Exception, ABC):
6
34
  """
7
- Base class for all errors related to toolkits.
35
+ Base class for all Arcade errors.
36
+
37
+ Note: This class is an abstract class and cannot be instantiated directly.
38
+
39
+ These errors are ultimately converted to the ToolCallError schema.
40
+ Attributes expected from subclasses:
41
+ message : str # user-facing error message
42
+ kind : ErrorKind # the error kind
43
+ can_retry : bool # whether the operation can be retried
44
+ developer_message : str | None # developer-facing error details
45
+ status_code : int | None # HTTP status code when relevant
46
+ additional_prompt_content : str | None # content for retry prompts
47
+ retry_after_ms : int | None # milliseconds to wait before retry
48
+ stacktrace : str | None # stacktrace information
49
+ extra : dict[str, Any] | None # arbitrary structured metadata
8
50
  """
9
51
 
10
- pass
52
+ def __new__(cls, *args: Any, **kwargs: Any) -> "ToolkitError":
53
+ abs_methods = getattr(cls, "__abstractmethods__", None)
54
+ if abs_methods:
55
+ raise TypeError(f"Can't instantiate abstract class {cls.__name__}")
56
+ return super().__new__(cls)
57
+
58
+ @abstractmethod
59
+ def create_message_prefix(self, name: str) -> str:
60
+ pass
61
+
62
+ def with_context(self, name: str) -> "ToolkitError":
63
+ """
64
+ Add context to the error message.
65
+
66
+ Args:
67
+ name: The name of the tool or toolkit that caused the error.
68
+
69
+ Returns:
70
+ The error with the context added to the message.
71
+ """
72
+ prefix = self.create_message_prefix(name)
73
+ self.message = f"{prefix}{self.message}" # type: ignore[has-type]
74
+ if hasattr(self, "developer_message") and self.developer_message: # type: ignore[has-type]
75
+ self.developer_message = f"{prefix}{self.developer_message}" # type: ignore[has-type]
76
+
77
+ return self
78
+
79
+ @property
80
+ def is_toolkit_error(self) -> bool:
81
+ """Check if this error originated from loading a toolkit."""
82
+ return hasattr(self, "kind") and self.kind.name.startswith("TOOLKIT_")
83
+
84
+ @property
85
+ def is_tool_error(self) -> bool:
86
+ """Check if this error originated from a tool."""
87
+ return hasattr(self, "kind") and self.kind.name.startswith("TOOL_")
88
+
89
+ @property
90
+ def is_upstream_error(self) -> bool:
91
+ """Check if this error originated from an upstream service."""
92
+ return hasattr(self, "kind") and self.kind.name.startswith("UPSTREAM_")
93
+
94
+ def __str__(self) -> str:
95
+ return self.message
11
96
 
12
97
 
13
98
  class ToolkitLoadError(ToolkitError):
14
99
  """
15
- Raised when there is an error loading a toolkit.
100
+ Raised while importing / loading a toolkit package
101
+ (e.g. missing dependency, SyntaxError in module top-level code).
16
102
  """
17
103
 
18
- pass
104
+ kind: ErrorKind = ErrorKind.TOOLKIT_LOAD_FAILED
105
+ can_retry: bool = False
19
106
 
107
+ def __init__(self, message: str) -> None:
108
+ super().__init__(message)
109
+ self.message = message
20
110
 
21
- class ToolError(Exception):
22
- """
23
- Base class for all errors related to tools.
111
+ def create_message_prefix(self, toolkit_name: str) -> str:
112
+ return f"[{self.kind.value}] {type(self).__name__} when loading toolkit '{toolkit_name}': "
113
+
114
+
115
+ class ToolError(ToolkitError):
24
116
  """
117
+ Any error related to an Arcade tool.
25
118
 
26
- pass
119
+ Note: This class is an abstract class and cannot be instantiated directly.
120
+ """
27
121
 
28
122
 
123
+ # ------ definition-time errors (tool developer's responsibility) ------
29
124
  class ToolDefinitionError(ToolError):
30
125
  """
31
- Raised when there is an error in the definition of a tool.
126
+ Raised when there is an error in the definition/signature of a tool.
32
127
  """
33
128
 
34
- pass
129
+ kind: ErrorKind = ErrorKind.TOOL_DEFINITION_BAD_DEFINITION
130
+
131
+ def __init__(self, message: str) -> None:
132
+ super().__init__(message)
133
+ self.message = message
134
+
135
+ def create_message_prefix(self, tool_name: str) -> str:
136
+ return f"[{self.kind.value}] {type(self).__name__} in definition of tool '{tool_name}': "
137
+
138
+
139
+ class ToolInputSchemaError(ToolDefinitionError):
140
+ """Raised when there is an error in the schema of a tool's input parameter."""
141
+
142
+ kind: ErrorKind = ErrorKind.TOOL_DEFINITION_BAD_INPUT_SCHEMA
143
+
144
+
145
+ class ToolOutputSchemaError(ToolDefinitionError):
146
+ """Raised when there is an error in the schema of a tool's output parameter."""
147
+
148
+ kind: ErrorKind = ErrorKind.TOOL_DEFINITION_BAD_OUTPUT_SCHEMA
35
149
 
36
150
 
37
151
  # ------ runtime errors ------
152
+ class ToolRuntimeError(ToolError, RuntimeError):
153
+ """
154
+ Any failure starting from when the tool call begins until the tool call returns.
38
155
 
156
+ Note: This class should typically not be instantiated directly, but rather subclassed.
157
+ """
158
+
159
+ kind: ErrorKind = ErrorKind.TOOL_RUNTIME_FATAL
160
+ can_retry: bool = False
161
+ status_code: int | None = None
162
+ extra: dict[str, Any] | None = None
39
163
 
40
- class ToolRuntimeError(RuntimeError):
41
164
  def __init__(
42
165
  self,
43
166
  message: str,
44
- developer_message: Optional[str] = None,
167
+ developer_message: str | None = None,
168
+ *,
169
+ extra: dict[str, Any] | None = None,
45
170
  ):
46
171
  super().__init__(message)
47
172
  self.message = message
48
- self.developer_message = developer_message
173
+ self.developer_message = developer_message # type: ignore[assignment]
174
+ self.extra = extra
49
175
 
50
- def traceback_info(self) -> str | None:
51
- # return the traceback information of the parent exception
176
+ def create_message_prefix(self, tool_name: str) -> str:
177
+ return f"[{self.kind.value}] {type(self).__name__} during execution of tool '{tool_name}': "
178
+
179
+ def stacktrace(self) -> str | None:
52
180
  if self.__cause__:
53
181
  return "\n".join(traceback.format_exception(self.__cause__))
54
182
  return None
55
183
 
184
+ def traceback_info(self) -> str | None:
185
+ """DEPRECATED: Use stacktrace() instead.
186
+
187
+ This method is deprecated and will be removed in a future major version.
188
+ """
189
+ return self.stacktrace()
56
190
 
191
+ # wire-format helper
192
+ def to_payload(self) -> dict[str, Any]:
193
+ return {
194
+ "message": self.message,
195
+ "developer_message": self.developer_message,
196
+ "kind": self.kind,
197
+ "can_retry": self.can_retry,
198
+ "status_code": self.status_code,
199
+ **(self.extra or {}),
200
+ }
201
+
202
+
203
+ # 1. ------ serialization errors ------
204
+ class ToolSerializationError(ToolRuntimeError):
205
+ """
206
+ Raised when there is an error serializing/marshalling the tool call arguments or return value.
207
+
208
+ Note: This class is not intended to be instantiated directly, but rather subclassed.
209
+ """
210
+
211
+
212
+ class ToolInputError(ToolSerializationError):
213
+ """
214
+ Raised when there is an error parsing a tool call argument.
215
+ """
216
+
217
+ kind: ErrorKind = ErrorKind.TOOL_RUNTIME_BAD_INPUT_VALUE
218
+ status_code: int = 400
219
+
220
+
221
+ class ToolOutputError(ToolSerializationError):
222
+ """
223
+ Raised when there is an error serializing a tool call return value.
224
+ """
225
+
226
+ kind: ErrorKind = ErrorKind.TOOL_RUNTIME_BAD_OUTPUT_VALUE
227
+ status_code: int = 500
228
+
229
+
230
+ # 2. ------ tool-body errors ------
57
231
  class ToolExecutionError(ToolRuntimeError):
58
232
  """
59
- Raised when there is an error executing a tool.
233
+ DEPRECATED: Raised when there is an error executing a tool.
234
+
235
+ ToolExecutionError is deprecated and will be removed in a future major version.
236
+ Use more specific error types instead:
237
+ - RetryableToolError for retryable errors
238
+ - ContextRequiredToolError for errors requiring user context
239
+ - FatalToolError for fatal/unexpected errors
240
+ - UpstreamError for upstream service errors
241
+ - UpstreamRateLimitError for upstream rate limiting errors
60
242
  """
61
243
 
62
- pass
244
+ def __init__(
245
+ self,
246
+ message: str,
247
+ developer_message: str | None = None,
248
+ *,
249
+ extra: dict[str, Any] | None = None,
250
+ ):
251
+ if type(self) is ToolExecutionError:
252
+ warnings.warn(
253
+ "ToolExecutionError is deprecated and will be removed in a future major version. "
254
+ "Use more specific error types instead: RetryableToolError, ContextRequiredToolError, "
255
+ "FatalToolError, UpstreamError, or UpstreamRateLimitError.",
256
+ DeprecationWarning,
257
+ stacklevel=2,
258
+ )
259
+ super().__init__(message, developer_message=developer_message, extra=extra)
63
260
 
64
261
 
65
262
  class RetryableToolError(ToolExecutionError):
66
263
  """
67
- Raised when a tool error is retryable.
264
+ Raised when a tool execution error is retryable.
68
265
  """
69
266
 
267
+ kind: ErrorKind = ErrorKind.TOOL_RUNTIME_RETRY
268
+ can_retry: bool = True
269
+
70
270
  def __init__(
71
271
  self,
72
272
  message: str,
73
- developer_message: Optional[str] = None,
74
- additional_prompt_content: Optional[str] = None,
75
- retry_after_ms: Optional[int] = None,
273
+ developer_message: str | None = None,
274
+ additional_prompt_content: str | None = None, # TODO: Make required in next major version
275
+ retry_after_ms: int | None = None,
276
+ extra: dict[str, Any] | None = None,
76
277
  ):
77
- super().__init__(message, developer_message)
278
+ super().__init__(message, developer_message=developer_message, extra=extra)
78
279
  self.additional_prompt_content = additional_prompt_content
79
280
  self.retry_after_ms = retry_after_ms
80
281
 
81
282
 
82
- class ToolSerializationError(ToolRuntimeError):
283
+ class ContextRequiredToolError(ToolExecutionError):
83
284
  """
84
- Raised when there is an error executing a tool.
285
+ Raised when the combination of additional content from the tool AND
286
+ additional context from the end-user/orchestrator is required before retrying the tool.
287
+
288
+ This is typically used when an argument provided to the tool is invalid in some way,
289
+ and immediately prompting an LLM to retry the tool call is not desired.
85
290
  """
86
291
 
87
- pass
292
+ kind: ErrorKind = ErrorKind.TOOL_RUNTIME_CONTEXT_REQUIRED
88
293
 
294
+ def __init__(
295
+ self,
296
+ message: str,
297
+ additional_prompt_content: str,
298
+ developer_message: str | None = None,
299
+ *,
300
+ extra: dict[str, Any] | None = None,
301
+ ):
302
+ super().__init__(message, developer_message=developer_message, extra=extra)
303
+ self.additional_prompt_content = additional_prompt_content
89
304
 
90
- class ToolInputError(ToolSerializationError):
305
+
306
+ class FatalToolError(ToolExecutionError):
91
307
  """
92
- Raised when there is an error in the input to a tool.
308
+ Raised when there is an unexpected or unknown error executing a tool.
93
309
  """
94
310
 
95
- pass
311
+ status_code: int = 500
96
312
 
313
+ def __init__(
314
+ self,
315
+ message: str,
316
+ developer_message: str | None = None,
317
+ *,
318
+ extra: dict[str, Any] | None = None,
319
+ ):
320
+ super().__init__(message, developer_message=developer_message, extra=extra)
97
321
 
98
- class ToolOutputError(ToolSerializationError):
322
+
323
+ # 3. ------ upstream errors in tool body------
324
+ class UpstreamError(ToolExecutionError):
325
+ """
326
+ Error from an upstream service/API during tool execution.
327
+
328
+ This class handles all upstream failures except rate limiting.
329
+ The status_code and extra dict provide details about the specific error type.
330
+ """
331
+
332
+ def __init__(
333
+ self,
334
+ message: str,
335
+ developer_message: str | None = None,
336
+ *,
337
+ status_code: int,
338
+ extra: dict[str, Any] | None = None,
339
+ ):
340
+ super().__init__(message, developer_message=developer_message, extra=extra)
341
+ self.status_code = status_code
342
+ # Determine retryability based on status code
343
+ self.can_retry = status_code >= 500 or status_code == 429
344
+ # Set appropriate error kind based on status
345
+ if status_code in (401, 403):
346
+ self.kind = ErrorKind.UPSTREAM_RUNTIME_AUTH_ERROR
347
+ elif status_code == 404:
348
+ self.kind = ErrorKind.UPSTREAM_RUNTIME_NOT_FOUND
349
+ elif status_code == 429:
350
+ self.kind = ErrorKind.UPSTREAM_RUNTIME_RATE_LIMIT
351
+ elif status_code >= 500:
352
+ self.kind = ErrorKind.UPSTREAM_RUNTIME_SERVER_ERROR
353
+ elif 400 <= status_code < 500:
354
+ self.kind = ErrorKind.UPSTREAM_RUNTIME_BAD_REQUEST
355
+ else:
356
+ self.kind = ErrorKind.UPSTREAM_RUNTIME_UNMAPPED
357
+
358
+
359
+ class UpstreamRateLimitError(UpstreamError):
99
360
  """
100
- Raised when there is an error in the output of a tool.
361
+ Rate limit error from an upstream service.
362
+
363
+ Special case of UpstreamError that includes retry_after_ms information.
101
364
  """
102
365
 
103
- pass
366
+ kind: ErrorKind = ErrorKind.UPSTREAM_RUNTIME_RATE_LIMIT
367
+ can_retry: bool = True
368
+
369
+ def __init__(
370
+ self,
371
+ message: str,
372
+ retry_after_ms: int,
373
+ developer_message: str | None = None,
374
+ *,
375
+ extra: dict[str, Any] | None = None,
376
+ ):
377
+ super().__init__(message, status_code=429, developer_message=developer_message, extra=extra)
378
+ self.retry_after_ms = retry_after_ms
arcade_core/executor.py CHANGED
@@ -6,11 +6,9 @@ from typing import Any
6
6
  from pydantic import BaseModel, ValidationError
7
7
 
8
8
  from arcade_core.errors import (
9
- RetryableToolError,
10
9
  ToolInputError,
11
10
  ToolOutputError,
12
11
  ToolRuntimeError,
13
- ToolSerializationError,
14
12
  )
15
13
  from arcade_core.output import output_factory
16
14
  from arcade_core.schema import (
@@ -69,31 +67,26 @@ class ToolExecutor:
69
67
  # return the output
70
68
  return output_factory.success(data=output, logs=tool_call_logs)
71
69
 
72
- except RetryableToolError as e:
73
- return output_factory.fail_retry(
74
- message=e.message,
75
- developer_message=e.developer_message,
76
- additional_prompt_content=e.additional_prompt_content,
77
- retry_after_ms=e.retry_after_ms,
78
- )
79
-
80
- except ToolSerializationError as e:
81
- return output_factory.fail(message=e.message, developer_message=e.developer_message)
82
-
83
- # should catch all tool exceptions due to the try/except in the tool decorator
84
70
  except ToolRuntimeError as e:
71
+ e.with_context(func.__name__)
85
72
  return output_factory.fail(
86
73
  message=e.message,
87
74
  developer_message=e.developer_message,
88
- traceback_info=e.traceback_info(),
75
+ stacktrace=e.stacktrace(),
76
+ additional_prompt_content=getattr(e, "additional_prompt_content", None),
77
+ retry_after_ms=getattr(e, "retry_after_ms", None),
78
+ kind=e.kind,
79
+ can_retry=e.can_retry,
80
+ status_code=e.status_code,
81
+ extra=e.extra,
89
82
  )
90
83
 
91
84
  # if we get here we're in trouble
92
85
  except Exception as e:
93
86
  return output_factory.fail(
94
- message="Error in execution",
87
+ message=f"Error in execution of '{func.__name__}'",
95
88
  developer_message=str(e),
96
- traceback_info=traceback.format_exc(),
89
+ stacktrace=traceback.format_exc(),
97
90
  )
98
91
 
99
92
  @staticmethod
arcade_core/output.py CHANGED
@@ -1,7 +1,8 @@
1
- from typing import TypeVar
1
+ from typing import Any, TypeVar
2
2
 
3
3
  from pydantic import BaseModel
4
4
 
5
+ from arcade_core.errors import ErrorKind
5
6
  from arcade_core.schema import ToolCallError, ToolCallLog, ToolCallOutput
6
7
  from arcade_core.utils import coerce_empty_list_to_none
7
8
 
@@ -25,14 +26,27 @@ class ToolOutputFactory:
25
26
 
26
27
  The executor guarantees that `data` is either a string, a dict, or None.
27
28
  """
28
- value: str | int | float | bool | dict | list[str] | None
29
+ value: str | int | float | bool | dict | list | None
29
30
  if data is None:
30
31
  value = ""
31
32
  elif hasattr(data, "result"):
32
- value = getattr(data, "result", "")
33
+ result = getattr(data, "result", "")
34
+ # Handle None result the same way as None data
35
+ if result is None:
36
+ value = ""
37
+ # If the result is a BaseModel (e.g., from TypedDict conversion), convert to dict
38
+ elif isinstance(result, BaseModel):
39
+ value = result.model_dump()
40
+ # If the result is a list, check if it contains BaseModel objects
41
+ elif isinstance(result, list):
42
+ value = [
43
+ item.model_dump() if isinstance(item, BaseModel) else item for item in result
44
+ ]
45
+ else:
46
+ value = result
33
47
  elif isinstance(data, BaseModel):
34
48
  value = data.model_dump()
35
- elif isinstance(data, (str, int, float, bool, list)):
49
+ elif isinstance(data, (str, int, float, bool, list, dict)):
36
50
  value = data
37
51
  else:
38
52
  raise ValueError(f"Unsupported data output type: {type(data)}")
@@ -48,15 +62,26 @@ class ToolOutputFactory:
48
62
  *,
49
63
  message: str,
50
64
  developer_message: str | None = None,
51
- traceback_info: str | None = None,
65
+ stacktrace: str | None = None,
52
66
  logs: list[ToolCallLog] | None = None,
67
+ additional_prompt_content: str | None = None,
68
+ retry_after_ms: int | None = None,
69
+ kind: ErrorKind = ErrorKind.UNKNOWN,
70
+ can_retry: bool = False,
71
+ status_code: int | None = None,
72
+ extra: dict[str, Any] | None = None,
53
73
  ) -> ToolCallOutput:
54
74
  return ToolCallOutput(
55
75
  error=ToolCallError(
56
76
  message=message,
57
77
  developer_message=developer_message,
58
- can_retry=False,
59
- traceback_info=traceback_info,
78
+ can_retry=can_retry,
79
+ additional_prompt_content=additional_prompt_content,
80
+ retry_after_ms=retry_after_ms,
81
+ stacktrace=stacktrace,
82
+ kind=kind,
83
+ status_code=status_code,
84
+ extra=extra,
60
85
  ),
61
86
  logs=coerce_empty_list_to_none(logs),
62
87
  )
@@ -68,9 +93,17 @@ class ToolOutputFactory:
68
93
  developer_message: str | None = None,
69
94
  additional_prompt_content: str | None = None,
70
95
  retry_after_ms: int | None = None,
71
- traceback_info: str | None = None,
96
+ stacktrace: str | None = None,
72
97
  logs: list[ToolCallLog] | None = None,
98
+ kind: ErrorKind = ErrorKind.TOOL_RUNTIME_RETRY,
99
+ status_code: int = 500,
100
+ extra: dict[str, Any] | None = None,
73
101
  ) -> ToolCallOutput:
102
+ """
103
+ DEPRECATED: Use ToolOutputFactory.fail instead.
104
+ This method will be removed in version 3.0.0
105
+ """
106
+
74
107
  return ToolCallOutput(
75
108
  error=ToolCallError(
76
109
  message=message,
@@ -78,7 +111,10 @@ class ToolOutputFactory:
78
111
  can_retry=True,
79
112
  additional_prompt_content=additional_prompt_content,
80
113
  retry_after_ms=retry_after_ms,
81
- traceback_info=traceback_info,
114
+ stacktrace=stacktrace,
115
+ kind=kind,
116
+ status_code=status_code,
117
+ extra=extra,
82
118
  ),
83
119
  logs=coerce_empty_list_to_none(logs),
84
120
  )
arcade_core/schema.py CHANGED
@@ -5,6 +5,8 @@ from typing import Any, Literal
5
5
 
6
6
  from pydantic import BaseModel, Field
7
7
 
8
+ from arcade_core.errors import ErrorKind
9
+
8
10
  # allow for custom tool name separator
9
11
  TOOL_NAME_SEPARATOR = os.getenv("ARCADE_TOOL_NAME_SEPARATOR", ".")
10
12
 
@@ -390,6 +392,8 @@ class ToolCallError(BaseModel):
390
392
 
391
393
  message: str
392
394
  """The user-facing error message."""
395
+ kind: ErrorKind
396
+ """The error kind that uniquely identifies the kind of error."""
393
397
  developer_message: str | None = None
394
398
  """The developer-facing error details."""
395
399
  can_retry: bool = False
@@ -398,8 +402,27 @@ class ToolCallError(BaseModel):
398
402
  """Additional content to be included in the retry prompt."""
399
403
  retry_after_ms: int | None = None
400
404
  """The number of milliseconds (if any) to wait before retrying the tool call."""
401
- traceback_info: str | None = None
402
- """The traceback information for the tool call."""
405
+ stacktrace: str | None = None
406
+ """The stacktrace information for the tool call."""
407
+ status_code: int | None = None
408
+ """The HTTP status code of the error."""
409
+ extra: dict[str, Any] | None = None
410
+ """Additional information about the error."""
411
+
412
+ @property
413
+ def is_toolkit_error(self) -> bool:
414
+ """Check if this error originated from loading a toolkit."""
415
+ return self.kind.name.startswith("TOOLKIT_")
416
+
417
+ @property
418
+ def is_tool_error(self) -> bool:
419
+ """Check if this error originated from a tool."""
420
+ return self.kind.name.startswith("TOOL_")
421
+
422
+ @property
423
+ def is_upstream_error(self) -> bool:
424
+ """Check if this error originated from an upstream service."""
425
+ return self.kind.name.startswith("UPSTREAM_")
403
426
 
404
427
 
405
428
  class ToolCallRequiresAuthorization(BaseModel):
@@ -418,7 +441,7 @@ class ToolCallRequiresAuthorization(BaseModel):
418
441
  class ToolCallOutput(BaseModel):
419
442
  """The output of a tool invocation."""
420
443
 
421
- value: str | int | float | bool | dict | list[str] | None = None
444
+ value: str | int | float | bool | dict | list | None = None
422
445
  """The value returned by the tool."""
423
446
  logs: list[ToolCallLog] | None = None
424
447
  """The logs that occurred during the tool invocation."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arcade-core
3
- Version: 2.2.2
3
+ Version: 2.4.0
4
4
  Summary: Arcade Core - Core library for Arcade platform
5
5
  Author-email: Arcade <dev@arcade.dev>
6
6
  License: MIT
@@ -1,19 +1,19 @@
1
1
  arcade_core/__init__.py,sha256=1heu3AROAjpistehPzY2H-2nkj_IjQEh-vVlVOCRF1E,88
2
2
  arcade_core/annotations.py,sha256=Nst6aejLWXlpTu7GwzWETu1gQCG1XVAUR_qcFbNvyRc,198
3
- arcade_core/auth.py,sha256=-x7NipXciB6ASMjH_iTCdOl86s-R0JzQKeWkhDg1aDI,5613
4
- arcade_core/catalog.py,sha256=WVSTuJN2euY7PR6FBFADTR79teeetBJPFG06O-ohYjs,39492
3
+ arcade_core/auth.py,sha256=On9sJPOxvHjKBxgKC1yqp7oijF6KYBsG6fG8KUw-9OY,5882
4
+ arcade_core/catalog.py,sha256=X7_pZDXm-lEmQhfVwN4QVB2Q7IuO0qlM2avMJ3h66_k,41298
5
5
  arcade_core/config.py,sha256=e98XQAkYySGW9T_yrJg54BB8Wuq06GPVHp7xqe2d1vU,572
6
6
  arcade_core/config_model.py,sha256=GYO37yKi7ih6EYKPpX1Kl-K1XwM2JyEJguyaJ7j9TY8,4260
7
- arcade_core/errors.py,sha256=h4H1ck4TP-CDKPuRA5EVtRnplWwk9ofwFWE5h4AuMyg,2043
8
- arcade_core/executor.py,sha256=87fge4Mvvggn-ibVdxsin3IlPv5oRaZFdP70KYCIE7I,4550
9
- arcade_core/output.py,sha256=T5CP2wDdySgzaKspHRNA1g94nB6V6mbama0GmI9gUTI,2560
7
+ arcade_core/errors.py,sha256=fsi7m6TQQSsdSNHl4rBoSN_YH3ZV910gjvBFqB207f4,13326
8
+ arcade_core/executor.py,sha256=aFRqB4OdC4b8JJN3zekx0hOWYmihWHAZqVWVlSFXzE4,4308
9
+ arcade_core/output.py,sha256=CMY1pHlQIR27Beiz2I-Yg1aO-P-pbsEbhBZ1RdYuflc,4040
10
10
  arcade_core/parse.py,sha256=gIo9w0iaKU_FpR9pFbUOxRfCK-cizg9SacoQC_jmgCc,1855
11
11
  arcade_core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- arcade_core/schema.py,sha256=ldgw2GYIhdVMM_fbrvbtAWQYbtr23g3z71F0kt5SnH0,14666
12
+ arcade_core/schema.py,sha256=Rvhgs5kEG8KkcpabSKhxXvZPNrFt_YVj815T70Iky3A,15477
13
13
  arcade_core/telemetry.py,sha256=qDv8T-wO8nFi0Qh93WKaPH1b6asfoJoyyfA7ZOxPnbA,5566
14
14
  arcade_core/toolkit.py,sha256=O-e8Pq6AKk78d78c16TPhEjufNuCjM_AWfLknpQvmy0,11108
15
15
  arcade_core/utils.py,sha256=anzEqj7Q_3dko-oY8U2QcH62rmQE7Y4fQBm0WjwLlIE,2980
16
16
  arcade_core/version.py,sha256=CpXi3jGlx23RvRyU7iytOMZrnspdWw4yofS8lpP1AJU,18
17
- arcade_core-2.2.2.dist-info/METADATA,sha256=RnM4R8_HgyjnYvmXJVJVt5xKSIH8cHAtYcgvrA5_9mE,2557
18
- arcade_core-2.2.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
19
- arcade_core-2.2.2.dist-info/RECORD,,
17
+ arcade_core-2.4.0.dist-info/METADATA,sha256=ln-4E-91IHvTVDf3FyavOKROlVx8lfiwzpIosCnqZnM,2557
18
+ arcade_core-2.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
19
+ arcade_core-2.4.0.dist-info/RECORD,,