arcade-mcp/libs/arcade-serve/arcade_serve/core/base.py
Eric Gustin f4558ef3a8
Tool Error Handling (#539)
# Improvements to Arcade TDK Error Handling
I tried my very best to not make any breaking changes in this PR. So,
you will notice various "Deprecation" notices throughout.

### Instructions for PR reviewers
1. Pull down this PR's branch
2. Pull down the Engine's tool error handling PR's branch
3. Update your installed arcadepy to have the following:
- In `arcadepy/resources/tools/tools.py`, if you want to test out
including stacktraces, then you need to update `ToolsResource.execute`
to accept a `include_error_stacktrace` argument and also include the
"include_error_stacktrace" argument to the POST to the Engine inside of
the function's execute method's body.
- In `arcadepy/types/execute_tool_response.py` add the following enum
      ```py
      class ErrorKind(str, Enum):
          """Error kind that is comprised of
          - the who (toolkit, tool, upstream)
          - the when (load time, definition parsing time, runtime)
- the what (bad_definition, bad_input, bad_output, retry,
context_required, fatal, etc.)"""
      
          TOOLKIT_LOAD_FAILED = "TOOLKIT_LOAD_FAILED"
TOOL_DEFINITION_BAD_DEFINITION = "TOOL_DEFINITION_BAD_DEFINITION"
TOOL_DEFINITION_BAD_INPUT_SCHEMA = "TOOL_DEFINITION_BAD_INPUT_SCHEMA"
TOOL_DEFINITION_BAD_OUTPUT_SCHEMA = "TOOL_DEFINITION_BAD_OUTPUT_SCHEMA"
          TOOL_RUNTIME_BAD_INPUT_VALUE = "TOOL_RUNTIME_BAD_INPUT_VALUE"
TOOL_RUNTIME_BAD_OUTPUT_VALUE = "TOOL_RUNTIME_BAD_OUTPUT_VALUE"
          TOOL_RUNTIME_RETRY = "TOOL_RUNTIME_RETRY"
TOOL_RUNTIME_CONTEXT_REQUIRED = "TOOL_RUNTIME_CONTEXT_REQUIRED"
          TOOL_RUNTIME_FATAL = "TOOL_RUNTIME_FATAL"
          UPSTREAM_RUNTIME_BAD_REQUEST = "UPSTREAM_RUNTIME_BAD_REQUEST"
          UPSTREAM_RUNTIME_AUTH_ERROR = "UPSTREAM_RUNTIME_AUTH_ERROR"
          UPSTREAM_RUNTIME_NOT_FOUND = "UPSTREAM_RUNTIME_NOT_FOUND"
UPSTREAM_RUNTIME_VALIDATION_ERROR = "UPSTREAM_RUNTIME_VALIDATION_ERROR"
          UPSTREAM_RUNTIME_RATE_LIMIT = "UPSTREAM_RUNTIME_RATE_LIMIT"
UPSTREAM_RUNTIME_SERVER_ERROR = "UPSTREAM_RUNTIME_SERVER_ERROR"
          UPSTREAM_RUNTIME_UNMAPPED = "UPSTREAM_RUNTIME_UNMAPPED"
          UNKNOWN = "UNKNOWN"
      ```
- In `arcadepy/types/execute_tool_response.py` add the following fields
to OutputError:
      ```py
      kind: ErrorKind
      status_code: Optional[int] = None
      stacktrace: Optional[str] = None
      extra: Optional[dict[str, Any]] = None
      ```
### Example Client Usage
```py
# Example of handling an upstream rate limit
error = response.output.error
if  error and error.kind == ErrorKind.UPSTREAM_RUNTIME_RATE_LIMIT:
    sleep_time = error.retry_after_ms / 1000
    time.sleep(sleep_time)
    # and then execute again
```
```py
# Examples of determining what type of runtime error it is
error = response.output.error
if error:
    is_retryable_error = error.kind == ErrorKind.TOOL_RUNTIME_RETRY
    is_a_bug_in_the_tool = error.kind == ErrorKind.TOOL_RUNTIME_FATAL
    is_additional_context_required = error.kind == ErrorKind.TOOL_RUNTIME_CONTEXT_REQUIRED
```

### Example Tool Usage
```py
# EXAMPLE 1 letting Arcade handle upstream error handling for you
reddit_client.post(params) # Arcade's httpx adapter will handle error handling for you!

# ------------------------------------

# EXAMPLE 2 handling upstream bad request yourself, but letting Arcade handle the rest
try:
    reddit_client.post(params)
except httpx.HTTPStatusError as e:
    if e.status_code == 400:
        raise UpstreamError("My extra custom message) from e
    raise
```
```py
# EXAMPLE 1 letting Arcade handle it for you
risky_element = my_risky_list[42] # Arcade will raise a FatalToolError for you

# ------------------------------------

# EXAMPLE 2 handling it yourself for extra flexibility
try:
    risky_element = my_risky_list[42]
except IndexError as e:
    raise FatalToolError("My extra custom message") from e
```
### Non-runtime Error Message Examples
Example ToolkitLoadError Messages:
```
- [TOOLKIT_LOAD_FAILED] ToolkitLoadError when loading toolkit 'sample_tool': Could not import module mock_module. Reason: Mock import error
- [TOOLKIT_LOAD_FAILED] ToolkitLoadError when loading toolkit 'test_toolkit': Tool 'ValidTool' in toolkit 'test_toolkit' already exists in the catalog.
```
Example ToolDefinitionError Messages
```
- [TOOL_DEFINITION_BAD_DEFINITION] ToolDefinitionError in definition of tool 'tool_missing_description': Tool 'tool_missing_description' is missing a description
- [TOOL_DEFINITION_BAD_DEFINITION] ToolDefinitionError in definition of tool 'tool_with_invalid_secret_type': Secret keys must be strings (error in tool ToolWithInvalidSecretType).
- [TOOL_DEFINITION_BAD_DEFINITION] ToolDefinitionError in definition of tool 'tool_with_empty_secret': Secrets must have a non-empty key (error in tool ToolWithEmptySecret).
- [TOOL_DEFINITION_BAD_DEFINITION] ToolDefinitionError in definition of tool 'tool_with_invalid_metadata_type': Metadata must be strings (error in tool ToolWithInvalidMetadataType).
- [TOOL_DEFINITION_BAD_DEFINITION] ToolDefinitionError in definition of tool 'tool_with_metadata_requiring_auth_without_auth': Tool ToolWithMetadataRequiringAuthWithoutAuth declares metadata key 'client_id', which requires that the tool has an auth requirement, but no auth requirement was provided. Please specify an auth requirement.
- [TOOL_DEFINITION_BAD_DEFINITION] ToolDefinitionError in definition of tool 'tool_with_empty_metadata': Metadata must have a non-empty key (error in tool ToolWithEmptyMetadata).
- [TOOL_DEFINITION_BAD_DEFINITION] ToolDefinitionError in definition of tool 'tool_with_unsupported_param_type': Unsupported parameter type: <class 'test_catalog.MyFancyTestClass'>
```
Example ToolInputSchemaError Messages
```
- [TOOL_DEFINITION_BAD_INPUT_SCHEMA] ToolInputSchemaError in definition of tool 'tool_with_missing_input_parameter_annotation': Parameter 'input_text' is missing a description
- [TOOL_DEFINITION_BAD_INPUT_SCHEMA] ToolInputSchemaError in definition of tool 'tool_with_no_type_annotation': Parameter param has no type annotation.
- [TOOL_DEFINITION_BAD_INPUT_SCHEMA] ToolInputSchemaError in definition of tool 'tool_with_invalid_param_name': Invalid parameter name: '123invalid' is not a valid identifier. Identifiers must start with a letter or underscore, and can only contain letters, digits, or underscores.
- [TOOL_DEFINITION_BAD_INPUT_SCHEMA] ToolInputSchemaError in definition of tool 'tool_with_too_many_annotations': Parameter param: Annotated[str, 'name', 'desc', 'extra'] has too many string annotations. Expected 0, 1, or 2, got 3.
- [TOOL_DEFINITION_BAD_INPUT_SCHEMA] ToolInputSchemaError in definition of tool 'tool_with_required_union_param': Parameter param is a union type. Only optional types are supported.
- [TOOL_DEFINITION_BAD_INPUT_SCHEMA] ToolInputSchemaError in definition of tool 'tool_with_non_callable_default_factory': Default factory for parameter param: Annotated[str, 'Parameter'] = FieldInfo(annotation=NoneType, required=False, default_factory=str) is not callable.
- [TOOL_DEFINITION_BAD_INPUT_SCHEMA] ToolInputSchemaError in definition of tool 'tool_with_multiple_tool_contexts': Only one ToolContext parameter is supported, but tool tool_with_multiple_tool_contexts has multiple.
```
Example ToolOutputSchemaError Messages
```
- [TOOL_DEFINITION_BAD_OUTPUT_SCHEMA] ToolOutputSchemaError in definition of tool 'tool_missing_return_type_hint': Tool 'ToolMissingReturnTypeHint' must have a return type
- [TOOL_DEFINITION_BAD_OUTPUT_SCHEMA] ToolOutputSchemaError in definition of tool 'tool_with_unsupported_output_type': Unsupported output type '<class 'test_catalog.MyFancyTestClass'>'. Only built-in Python types, TypedDicts, Pydantic models, and standard collections are supported as tool output types.
```
### Runtime Error Message Examples
Example Tool Runtime Error Messages
```
- [TOOL_RUNTIME_FATAL] FatalToolError during execution of tool 'get_posts_in_subreddit': list index out of range
- [TOOL_RUNTIME_CONTEXT_REQUIRED] ContextRequiredToolError during execution of tool 'get_posts_in_subreddit': Ambiguous username. Please provide a more specific username
- [TOOL_RUNTIME_RETRY] RetryableToolError during execution of tool 'get_posts_in_subreddit': Retry with subreddit=learnpython or subreddit=learnprogramming
```

Example Upstream Runtime Error Messages
```
- [UPSTREAM_RUNTIME_RATE_LIMIT] UpstreamRateLimitError during execution of tool 'get_posts_in_subreddit': 429 Client Error: Too Many Requests
- [UPSTREAM_RUNTIME_BAD_REQUEST] UpstreamError during execution of tool 'get_posts_in_subreddit': 400 Client Error: Bad request. Missing 'id' parameter.
- [UPSTREAM_RUNTIME_BAD_REQUEST] UpstreamError during execution of tool 'search_files': Upstream Google API error: Invalid value '-23'. Values must be within the range: [value: 1\n, value: 1000\n]
```
2025-09-10 10:45:18 -07:00

185 lines
6.3 KiB
Python

import logging
import os
import time
from datetime import datetime
from typing import Any, Callable, ClassVar
from arcade_core.catalog import ToolCatalog, Toolkit
from arcade_core.executor import ToolExecutor
from arcade_core.schema import (
ToolCallRequest,
ToolCallResponse,
ToolDefinition,
)
from opentelemetry import trace
from opentelemetry.metrics import Meter
from arcade_serve.core.common import Router, Worker
from arcade_serve.core.components import (
CallToolComponent,
CatalogComponent,
HealthCheckComponent,
WorkerComponent,
)
logger = logging.getLogger(__name__)
class BaseWorker(Worker):
"""
A base worker class that provides a default implementation for registering tools and invoking them.
Worker implementations for specific web frameworks will inherit from this class.
"""
base_path = "/worker" # By default, prefix all our routes with /worker
default_components: ClassVar[tuple[type[WorkerComponent], ...]] = (
CatalogComponent,
CallToolComponent,
HealthCheckComponent,
)
def __init__(
self, secret: str | None = None, disable_auth: bool = False, otel_meter: Meter | None = None
) -> None:
"""
Initialize the BaseWorker with an empty ToolCatalog.
If no secret is provided, the worker will use the ARCADE_WORKER_SECRET environment variable.
"""
self.catalog = ToolCatalog()
self.disable_auth = disable_auth
if disable_auth:
logger.warning(
"Warning: Worker is running without authentication. Not recommended for production."
)
self.secret = self._set_secret(secret, disable_auth)
self.environment = os.environ.get("ARCADE_ENVIRONMENT", "local")
self.tool_counter = None
if otel_meter:
self.tool_counter = otel_meter.create_counter(
"tool_call", "requests", "Total number of tools called"
)
def _set_secret(self, secret: str | None, disable_auth: bool) -> str:
if disable_auth:
return ""
# If secret is provided, use it
if secret:
return secret
# If secret is not provided, try to get it from environment variables
env_secret = os.environ.get("ARCADE_WORKER_SECRET")
if env_secret:
return env_secret
raise ValueError(
"No secret provided for worker. Set the ARCADE_WORKER_SECRET environment variable."
)
def get_catalog(self) -> list[ToolDefinition]:
"""
Get the catalog as a list of ToolDefinitions.
"""
return [tool.definition for tool in self.catalog]
def register_tool(self, tool: Callable, toolkit_name: str) -> None:
"""
Register a tool to the catalog.
"""
self.catalog.add_tool(tool, toolkit_name)
def register_toolkit(self, toolkit: Toolkit) -> None:
"""
Register a toolkit to the catalog.
"""
self.catalog.add_toolkit(toolkit)
async def call_tool(self, tool_request: ToolCallRequest) -> ToolCallResponse:
"""
Call (invoke) a tool using the ToolExecutor.
"""
tool_fqname = tool_request.tool.get_fully_qualified_name()
try:
materialized_tool = self.catalog.get_tool(tool_fqname)
except KeyError:
raise ValueError(
f"Tool {tool_fqname} not found in catalog with toolkit version {tool_request.tool.version}."
)
start_time = time.time()
if self.tool_counter:
self.tool_counter.add(
1,
{
"tool_name": tool_fqname.name,
"toolkit_version": str(tool_fqname.toolkit_version),
"toolkit_name": tool_fqname.toolkit_name,
"environment": self.environment,
},
)
execution_id = tool_request.execution_id or ""
logger.info(
f"{execution_id} | Calling tool: {tool_fqname} version: {tool_request.tool.version}"
)
logger.debug(f"{execution_id} | Tool inputs: {tool_request.inputs}")
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("RunTool"):
output = await ToolExecutor.run(
func=materialized_tool.tool,
definition=materialized_tool.definition,
input_model=materialized_tool.input_model,
output_model=materialized_tool.output_model,
context=tool_request.context,
**tool_request.inputs or {},
)
end_time = time.time() # End time in seconds
duration_ms = (end_time - start_time) * 1000 # Convert to milliseconds
if output.error:
logger.warning(
f"{execution_id} | Tool {tool_fqname} version {tool_request.tool.version} failed"
)
logger.warning(f"{execution_id} | Tool error: {output.error.message}")
logger.warning(
f"{execution_id} | Tool developer message: {output.error.developer_message}"
)
logger.debug(
f"{execution_id} | duration: {duration_ms}ms | Tool output: {output.value}"
)
if output.error.stacktrace:
logger.debug(f"{execution_id} | Tool traceback: {output.error.stacktrace}")
else:
logger.info(
f"{execution_id} | Tool {tool_fqname} version {tool_request.tool.version} success"
)
logger.debug(
f"{execution_id} | duration: {duration_ms}ms | Tool output: {output.value}"
)
return ToolCallResponse(
execution_id=execution_id,
duration=duration_ms,
finished_at=datetime.now().isoformat(),
success=not output.error,
output=output,
)
def health_check(self) -> dict[str, Any]:
"""
Provide a health check that serves as a heartbeat of worker health.
"""
return {"status": "ok", "tool_count": str(len(self.catalog))}
def register_routes(self, router: Router) -> None:
"""
Register the necessary routes to the application.
"""
for component_cls in self.default_components:
component_cls(self).register(router)