arcade-core 2.3.0__py3-none-any.whl → 2.5.0rc1__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.
@@ -0,0 +1,253 @@
1
+ """
2
+ Discovery utilities for Arcade Tools.
3
+
4
+ Provides modular, testable functions to discover toolkits and local tool files,
5
+ load modules, collect tools, and build a ToolCatalog.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.util
11
+ from pathlib import Path
12
+ from types import ModuleType
13
+ from typing import Any
14
+
15
+ from loguru import logger
16
+
17
+ from arcade_core.catalog import ToolCatalog
18
+ from arcade_core.parse import get_tools_from_file
19
+ from arcade_core.toolkit import Toolkit, ToolkitLoadError
20
+
21
+ DISCOVERY_PATTERNS = ["*.py", "tools/*.py", "arcade_tools/*.py", "tools/**/*.py"]
22
+ FILTER_PATTERNS = ["_test.py", "test_*.py", "__pycache__", "*.lock", "*.egg-info", "*.pyc"]
23
+
24
+
25
+ def normalize_package_name(package_name: str) -> str:
26
+ """Normalize a package name for import resolution."""
27
+ return package_name.lower().replace("-", "_")
28
+
29
+
30
+ def load_toolkit_from_package(package_name: str, show_packages: bool = False) -> Toolkit:
31
+ """Attempt to load a Toolkit from an installed package name."""
32
+ toolkit = Toolkit.from_package(package_name)
33
+ if show_packages:
34
+ logger.info(f"Loading package: {toolkit.name}")
35
+ return toolkit
36
+
37
+
38
+ def load_package(package_name: str, show_packages: bool = False) -> Toolkit:
39
+ """Load a toolkit for a specific package name.
40
+
41
+ Raises ToolkitLoadError if the package is not found.
42
+ """
43
+ normalized = normalize_package_name(package_name)
44
+ try:
45
+ return load_toolkit_from_package(normalized, show_packages)
46
+ except ToolkitLoadError:
47
+ return load_toolkit_from_package(f"arcade_{normalized}", show_packages)
48
+
49
+
50
+ def find_candidate_tool_files(root: Path | None = None) -> list[Path]:
51
+ """Find candidate Python files for auto-discovery in common locations."""
52
+ cwd = root or Path.cwd()
53
+
54
+ candidates: list[Path] = []
55
+ for pattern in DISCOVERY_PATTERNS:
56
+ candidates.extend(cwd.glob(pattern))
57
+ # Deduplicate candidates (same file might match multiple patterns)
58
+ unique_candidates = list(set(candidates))
59
+ # Filter out private, cache, and tests
60
+ return [
61
+ p for p in unique_candidates if not any(p.match(pattern) for pattern in FILTER_PATTERNS)
62
+ ]
63
+
64
+
65
+ def analyze_files_for_tools(files: list[Path]) -> list[tuple[Path, list[str]]]:
66
+ """Parse files with a fast AST pass to find declared @tool function names."""
67
+ results: list[tuple[Path, list[str]]] = []
68
+ for file_path in files:
69
+ try:
70
+ names = get_tools_from_file(file_path)
71
+ if names:
72
+ logger.info(f"Found {len(names)} tool(s) in {file_path.name}: {', '.join(names)}")
73
+ results.append((file_path, names))
74
+ except Exception:
75
+ logger.exception(f"Could not parse {file_path}")
76
+ return results
77
+
78
+
79
+ def load_module_from_path(file_path: Path) -> ModuleType:
80
+ """Dynamically import a Python module from a file path."""
81
+ import sys
82
+
83
+ # Add the directory containing the file to sys.path temporarily
84
+ # This allows local imports to work
85
+ file_dir = str(file_path.parent)
86
+ path_added = False
87
+ if file_dir not in sys.path:
88
+ sys.path.insert(0, file_dir)
89
+ path_added = True
90
+
91
+ try:
92
+ spec = importlib.util.spec_from_file_location(
93
+ f"_tools_{file_path.stem}",
94
+ file_path,
95
+ )
96
+ if not spec or not spec.loader:
97
+ raise ToolkitLoadError(f"Unable to create import spec for {file_path}")
98
+
99
+ module = importlib.util.module_from_spec(spec)
100
+ try:
101
+ spec.loader.exec_module(module)
102
+ except Exception:
103
+ logger.exception(f"Failed to load {file_path}")
104
+ raise ToolkitLoadError(f"Failed to load {file_path}")
105
+
106
+ return module
107
+ finally:
108
+ # Remove the path we added
109
+ if path_added and file_dir in sys.path:
110
+ sys.path.remove(file_dir)
111
+
112
+
113
+ def collect_tools_from_modules(
114
+ files_with_tools: list[tuple[Path, list[str]]],
115
+ ) -> list[tuple[Any, ModuleType]]:
116
+ """Load modules and collect the expected tool callables.
117
+
118
+ Returns a list of (callable, module) pairs.
119
+ """
120
+ discovered: list[tuple[Any, ModuleType]] = []
121
+
122
+ for file_path, expected_names in files_with_tools:
123
+ logger.debug(f"Loading tools from {file_path}...")
124
+ try:
125
+ module = load_module_from_path(file_path)
126
+ except ToolkitLoadError:
127
+ continue
128
+
129
+ for name in expected_names:
130
+ if hasattr(module, name):
131
+ attr = getattr(module, name)
132
+ if callable(attr) and hasattr(attr, "__tool_name__"):
133
+ discovered.append((attr, module))
134
+ else:
135
+ logger.warning(
136
+ f"Expected {name} to be a tool but it wasn't (missing __tool_name__)\n\n"
137
+ )
138
+ return discovered
139
+
140
+
141
+ def build_minimal_toolkit(
142
+ server_name: str | None,
143
+ server_version: str | None,
144
+ description: str | None = None,
145
+ ) -> Toolkit:
146
+ """Create a minimal Toolkit to host locally discovered tools."""
147
+ name = server_name or "ArcadeMCP"
148
+ version = server_version or "0.1.0dev"
149
+ pkg = f"{name}.{Path.cwd().name}"
150
+ desc = description or f"MCP Server for {name} version {version}"
151
+ return Toolkit(name=name, package_name=pkg, version=version, description=desc)
152
+
153
+
154
+ def build_catalog_from_toolkits(toolkits: list[Toolkit]) -> ToolCatalog:
155
+ """Create a ToolCatalog and add the provided toolkits."""
156
+ catalog = ToolCatalog()
157
+ for tk in toolkits:
158
+ catalog.add_toolkit(tk)
159
+ return catalog
160
+
161
+
162
+ def add_discovered_tools(
163
+ catalog: ToolCatalog,
164
+ toolkit: Toolkit,
165
+ tools: list[tuple[Any, ModuleType]],
166
+ ) -> None:
167
+ """Add discovered local tools to the catalog, preserving module context."""
168
+ for tool_func, module in tools:
169
+ if module.__name__ not in __import__("sys").modules:
170
+ __import__("sys").modules[module.__name__] = module
171
+ catalog.add_tool(tool_func, toolkit, module)
172
+
173
+
174
+ def load_toolkits_for_option(tool_package: str, show_packages: bool = False) -> list[Toolkit]:
175
+ """
176
+ Load toolkits for a given package option.
177
+
178
+ Args:
179
+ tool_package: Package name or comma-separated list of package names
180
+ show_packages: Whether to log loaded packages
181
+
182
+ Returns:
183
+ List of loaded toolkits
184
+ """
185
+ toolkits = []
186
+ packages = [p.strip() for p in tool_package.split(",")]
187
+
188
+ for package in packages:
189
+ try:
190
+ toolkit = load_package(package, show_packages)
191
+ toolkits.append(toolkit)
192
+ except ToolkitLoadError as e:
193
+ logger.warning(f"Failed to load package '{package}': {e}")
194
+
195
+ return toolkits
196
+
197
+
198
+ def load_all_installed_toolkits(show_packages: bool = False) -> list[Toolkit]:
199
+ """
200
+ Discover and load all installed arcade toolkits.
201
+
202
+ Args:
203
+ show_packages: Whether to log loaded packages
204
+
205
+ Returns:
206
+ List of all installed toolkits
207
+ """
208
+ toolkits = Toolkit.find_all_arcade_toolkits()
209
+
210
+ if show_packages:
211
+ for toolkit in toolkits:
212
+ logger.info(f"Loading package: {toolkit.name}")
213
+
214
+ return toolkits
215
+
216
+
217
+ def discover_tools(
218
+ tool_package: str | None = None,
219
+ show_packages: bool = False,
220
+ discover_installed: bool = False,
221
+ server_name: str | None = None,
222
+ server_version: str | None = None,
223
+ ) -> ToolCatalog:
224
+ """High-level discovery that returns a ToolCatalog.
225
+
226
+ This function is pure (does not sys.exit); callers should handle errors.
227
+ """
228
+ # 1) Package-based discovery
229
+ if tool_package:
230
+ toolkits = load_toolkits_for_option(tool_package, show_packages)
231
+ return build_catalog_from_toolkits(toolkits)
232
+
233
+ # 2) Discover all installed packages
234
+ if discover_installed:
235
+ toolkits = load_all_installed_toolkits(show_packages)
236
+ return build_catalog_from_toolkits(toolkits)
237
+
238
+ # 3) Local file discovery
239
+ logger.info("Auto-discovering tools from current directory")
240
+ files = find_candidate_tool_files()
241
+ if not files:
242
+ # Return empty catalog; caller can decide how to handle
243
+ return ToolCatalog()
244
+
245
+ files_with_tools = analyze_files_for_tools(files)
246
+ if not files_with_tools:
247
+ return ToolCatalog()
248
+
249
+ discovered = collect_tools_from_modules(files_with_tools)
250
+ catalog = ToolCatalog()
251
+ toolkit = build_minimal_toolkit(server_name, server_version)
252
+ add_discovered_tools(catalog, toolkit, discovered)
253
+ return catalog
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