Add @tool.deprecated (#247)

## PR Description
Add the ability to mark a tool as deprecated and display the warning in
the user's runtime. This PR also lays the foundation for future work for
emitting other levels of logs (debug, info, etc) that occur during the
tool's execution.

NOTE: Updates to the Arcade Clients (Python and JS) still need to be
done before the deprecation warning is emitted, but this PR needs to be
merged before those updates!

Let's cross our fingers that we'll never need to deprecate
`@tool.deprecated`!

### Example

1. Mark your tool as deprecated
```python
from typing import Annotated

from arcade.sdk import tool


@tool.deprecated("Use the 'Math.AddInt' tool instead.") # order of decorators does not matter
@tool
def add(
    a: Annotated[int, "The first number"], b: Annotated[int, "The second number"]
) -> Annotated[int, "The sum of the two numbers"]:
"""
Add two numbers together
"""
return a + b
```

2. Call the deprecated tool
```python
from arcadepy import Arcade

client = Arcade()

tool_input = {"a": 9001, "b": 42}

response = client.tools.execute(
    tool_name="Math.Add",
    input=tool_input,
    user_id="me@example.com",
)
print(f"The result of adding {tool_input['a']} and {tool_input['b']} is: {response.output.value}")
```

3. Observe the DeprecationWarning:
``` 
❯ python examples/call_a_tool_directly.py 
/Users/ericgustin/repos/Team/arcade-ai/examples/call_a_tool_directly.py:22: DeprecationWarning: 'Math.Add' is deprecated: Use the `Math.AddInt` tool instead.
  response = client.tools.execute(
The result of adding 9001 and 42 is: 9043
```
This commit is contained in:
Eric Gustin 2025-02-18 13:27:49 -08:00 committed by GitHub
parent 3870e84108
commit e636b686c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 174 additions and 7 deletions

View file

@ -382,6 +382,7 @@ class ToolCatalog(BaseModel):
tool_name = snake_to_pascal_case(raw_tool_name)
fully_qualified_name = FullyQualifiedName.from_toolkit(tool_name, toolkit_definition)
deprecation_message = getattr(tool, "__tool_deprecation_message__", None)
return ToolDefinition(
name=tool_name,
@ -393,6 +394,7 @@ class ToolCatalog(BaseModel):
requirements=ToolRequirements(
authorization=auth_requirement,
),
deprecation_message=deprecation_message,
)

View file

@ -12,7 +12,7 @@ from arcade.core.errors import (
ToolSerializationError,
)
from arcade.core.output import output_factory
from arcade.core.schema import ToolCallOutput, ToolContext, ToolDefinition
from arcade.core.schema import ToolCallLog, ToolCallOutput, ToolContext, ToolDefinition
class ToolExecutor:
@ -29,6 +29,15 @@ class ToolExecutor:
"""
Execute a callable function with validated inputs and outputs via Pydantic models.
"""
# only gathering deprecation log for now
tool_call_logs = []
if definition.deprecation_message is not None:
tool_call_logs.append(
ToolCallLog(
message=definition.deprecation_message, level="warning", subtype="deprecation"
)
)
try:
# serialize the input model
inputs = await ToolExecutor._serialize_input(input_model, **kwargs)
@ -50,7 +59,7 @@ class ToolExecutor:
output = await ToolExecutor._serialize_output(output_model, results)
# return the output
return output_factory.success(data=output)
return output_factory.success(data=output, logs=tool_call_logs)
except RetryableToolError as e:
return output_factory.fail_retry(

View file

@ -1,6 +1,7 @@
from typing import TypeVar
from arcade.core.schema import ToolCallError, ToolCallOutput
from arcade.core.schema import ToolCallError, ToolCallLog, ToolCallOutput
from arcade.core.utils import coerce_empty_list_to_none
T = TypeVar("T")
@ -14,9 +15,11 @@ class ToolOutputFactory:
self,
*,
data: T | None = None,
logs: list[ToolCallLog] | None = None,
) -> ToolCallOutput:
value = getattr(data, "result", "") if data else ""
return ToolCallOutput(value=value)
logs = coerce_empty_list_to_none(logs)
return ToolCallOutput(value=value, logs=logs)
def fail(
self,
@ -24,6 +27,7 @@ class ToolOutputFactory:
message: str,
developer_message: str | None = None,
traceback_info: str | None = None,
logs: list[ToolCallLog] | None = None,
) -> ToolCallOutput:
return ToolCallOutput(
error=ToolCallError(
@ -31,7 +35,8 @@ class ToolOutputFactory:
developer_message=developer_message,
can_retry=False,
traceback_info=traceback_info,
)
),
logs=coerce_empty_list_to_none(logs),
)
def fail_retry(
@ -42,6 +47,7 @@ class ToolOutputFactory:
additional_prompt_content: str | None = None,
retry_after_ms: int | None = None,
traceback_info: str | None = None,
logs: list[ToolCallLog] | None = None,
) -> ToolCallOutput:
return ToolCallOutput(
error=ToolCallError(
@ -50,7 +56,8 @@ class ToolOutputFactory:
can_retry=True,
additional_prompt_content=additional_prompt_content,
retry_after_ms=retry_after_ms,
)
),
logs=coerce_empty_list_to_none(logs),
)

View file

@ -191,6 +191,9 @@ class ToolDefinition(BaseModel):
requirements: ToolRequirements
"""The requirements (e.g. authorization) for the tool to run."""
deprecation_message: Optional[str] = None
"""The message to display when the tool is deprecated."""
def get_fully_qualified_name(self) -> FullyQualifiedName:
return FullyQualifiedName(self.name, self.toolkit.name, self.toolkit.version)
@ -259,6 +262,24 @@ class ToolCallRequest(BaseModel):
"""The context for the tool invocation."""
class ToolCallLog(BaseModel):
"""A log that occurred during the tool invocation."""
message: str
"""The user-facing warning message."""
level: Literal[
"debug",
"info",
"warning",
"error",
]
"""The level of severity for the log."""
subtype: Optional[Literal["deprecation"]] = None
"""Optional field for further categorization of the log."""
class ToolCallError(BaseModel):
"""The error that occurred during the tool invocation."""
@ -294,6 +315,8 @@ class ToolCallOutput(BaseModel):
value: Union[str, int, float, bool, dict, list[str]] | None = None
"""The value returned by the tool."""
logs: list[ToolCallLog] | None = None
"""The logs that occurred during the tool invocation."""
error: ToolCallError | None = None
"""The error that occurred during the tool invocation."""
requires_authorization: ToolCallRequiresAuthorization | None = None

View file

@ -78,3 +78,12 @@ def does_function_return_value(func: Callable) -> bool:
visitor = ReturnVisitor()
visitor.visit(tree)
return visitor.returns_value
def coerce_empty_list_to_none(lst: list[Any] | None) -> list[Any] | None:
"""
Coerces empty lists to None, otherwise returns the list unchanged.
"""
if isinstance(lst, list) and len(lst) == 0:
return None
return lst

View file

@ -59,3 +59,14 @@ def tool(
if func:
return decorator(func)
return decorator
def _tool_deprecated(message: str) -> Callable:
def decorator(func: Callable) -> Callable:
func.__tool_deprecation_message__ = message # type: ignore[attr-defined]
return func
return decorator
tool.deprecated = _tool_deprecated # type: ignore[attr-defined]

View file

@ -4,7 +4,7 @@ import pytest
from arcade.core.catalog import ToolCatalog
from arcade.core.executor import ToolExecutor
from arcade.core.schema import ToolCallError, ToolCallOutput, ToolContext
from arcade.core.schema import ToolCallError, ToolCallLog, ToolCallOutput, ToolContext
from arcade.sdk import tool
from arcade.sdk.errors import RetryableToolError, ToolExecutionError
@ -15,6 +15,13 @@ def simple_tool(inp: Annotated[str, "input"]) -> Annotated[str, "output"]:
return inp
@tool.deprecated("Use simple_tool instead")
@tool
def simple_deprecated_tool(inp: Annotated[str, "input"]) -> Annotated[str, "output"]:
"""Simple tool that is deprecated"""
return inp
@tool
def retryable_error_tool() -> Annotated[str, "output"]:
"""Tool that raises a retryable error"""
@ -43,6 +50,7 @@ def bad_output_error_tool() -> Annotated[str, "output"]:
catalog = ToolCatalog()
catalog.add_tool(simple_tool, "simple_toolkit")
catalog.add_tool(simple_deprecated_tool, "simple_toolkit")
catalog.add_tool(retryable_error_tool, "simple_toolkit")
catalog.add_tool(exec_error_tool, "simple_toolkit")
catalog.add_tool(unexpected_error_tool, "simple_toolkit")
@ -54,6 +62,20 @@ catalog.add_tool(bad_output_error_tool, "simple_toolkit")
"tool_func, inputs, expected_output",
[
(simple_tool, {"inp": "test"}, ToolCallOutput(value="test")),
(
simple_deprecated_tool,
{"inp": "test"},
ToolCallOutput(
value="test",
logs=[
ToolCallLog(
message="Use simple_tool instead",
level="warning",
subtype="deprecation",
)
],
),
),
(
retryable_error_tool,
{},
@ -110,6 +132,7 @@ catalog.add_tool(bad_output_error_tool, "simple_toolkit")
],
ids=[
"simple_tool",
"simple_deprecated_tool",
"retryable_error_tool",
"exec_error_tool",
"unexpected_error_tool",
@ -152,3 +175,12 @@ def check_output(output: ToolCallOutput, expected_output: ToolCallOutput):
# normal tool execution
else:
assert output.value == expected_output.value
# check logs
output_logs = output.logs or []
expected_logs = expected_output.logs or []
assert len(output_logs) == len(expected_logs)
for output_log, expected_log in zip(output_logs, expected_logs):
assert output_log.message == expected_log.message
assert output_log.level == expected_log.level
assert output_log.subtype == expected_log.subtype

View file

@ -115,3 +115,77 @@ def test_tool_decorator_with_auth_failure(auth_class, auth_kwargs):
)
def test_tool(x, y):
return x + y
def test_tool_deprecated_ordering_no_auth():
"""
Checks the behavior of @tool.deprecated when used before and after the @tool decorator.
The order of the decorators should not matter.
"""
message = "Deprecated: please use new_tool instead."
@tool.deprecated(message)
@tool
def func_deprecated_after(x):
"""Test description for func_deprecated_after"""
return x
assert hasattr(func_deprecated_after, "__tool_deprecation_message__")
assert func_deprecated_after.__tool_deprecation_message__ == message
assert func_deprecated_after.__tool_name__ == "FuncDeprecatedAfter"
assert (
func_deprecated_after.__tool_description__ == "Test description for func_deprecated_after"
)
assert func_deprecated_after.__tool_requires_auth__ is None
@tool
@tool.deprecated(message)
def func_deprecated_before(x):
"""Test description for func_deprecated_before"""
return x
assert hasattr(func_deprecated_before, "__tool_deprecation_message__")
assert func_deprecated_before.__tool_deprecation_message__ == message
assert func_deprecated_before.__tool_name__ == "FuncDeprecatedBefore"
assert (
func_deprecated_before.__tool_description__ == "Test description for func_deprecated_before"
)
assert func_deprecated_before.__tool_requires_auth__ is None
def test_tool_deprecated_ordering_with_auth():
"""
Checks the behavior of @tool.deprecated when used with authentication.
The order of the decorators should not matter.
"""
message = "Deprecated: please use new_tool instead."
@tool.deprecated(message)
@tool(requires_auth=OAuth2(id="my_auth_id", scopes=["test_scope"]))
def func_deprecated_after_auth(x):
"""Test description for func_deprecated_after_auth"""
return x
assert hasattr(func_deprecated_after_auth, "__tool_deprecation_message__")
assert func_deprecated_after_auth.__tool_deprecation_message__ == message
assert func_deprecated_after_auth.__tool_name__ == "FuncDeprecatedAfterAuth"
assert (
func_deprecated_after_auth.__tool_description__
== "Test description for func_deprecated_after_auth"
)
assert func_deprecated_after_auth.__tool_requires_auth__ is not None
@tool(requires_auth=OAuth2(id="my_auth_id", scopes=["test_scope"]))
@tool.deprecated(message)
def func_deprecated_before_auth(x):
"""Test description for func_deprecated_before_auth"""
return x
assert hasattr(func_deprecated_before_auth, "__tool_deprecation_message__")
assert func_deprecated_before_auth.__tool_deprecation_message__ == message
assert func_deprecated_before_auth.__tool_name__ == "FuncDeprecatedBeforeAuth"
assert (
func_deprecated_before_auth.__tool_description__
== "Test description for func_deprecated_before_auth"
)
assert func_deprecated_before_auth.__tool_requires_auth__ is not None