diff --git a/.editorconfig b/.editorconfig index 9395b543..f26b76cc 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,5 +1,14 @@ -max_line_length = 120 +# Stop the editor from looking for .editorconfig files in the parent directories +root = true -[*.json] +[*] +charset = utf-8 +insert_final_newline = true +end_of_line = lf indent_style = space indent_size = 4 +max_line_length = 120 + +[*.{json,jsonc,yml,yaml}] +indent_style = space +indent_size = 2 # This is also set in .prettierrc.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5e3015bf..c120b156 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,10 +13,5 @@ repos: rev: "v0.1.6" hooks: - id: ruff - args: [--exit-non-zero-on-fix] + args: [--fix] - id: ruff-format - - - repo: https://github.com/pre-commit/mirrors-prettier - rev: "v3.0.3" - hooks: - - id: prettier diff --git a/.prettierrc.toml b/.prettierrc.toml new file mode 100644 index 00000000..6ce165be --- /dev/null +++ b/.prettierrc.toml @@ -0,0 +1,11 @@ +# See https://prettier.io/docs/en/configuration +trailingComma = "es5" +tabWidth = 4 +semi = false +singleQuote = false + +[[overrides]] +files = [ "*.json", "*.jsonc", "*.yml", "*.yaml" ] + + [overrides.options] + tabWidth = 2 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 38fc02bc..7d0ea6ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,9 +13,9 @@ Report bugs at https://github.com/spartee/arcade-ai/issues If you are reporting a bug, please include: -- Your operating system name and version. -- Any details about your local setup that might be helpful in troubleshooting. -- Detailed steps to reproduce the bug. +- Your operating system name and version. +- Any details about your local setup that might be helpful in troubleshooting. +- Detailed steps to reproduce the bug. ## Fix Bugs @@ -37,10 +37,10 @@ The best way to send feedback is to file an issue at https://github.com/spartee/ If you are proposing a new feature: -- Explain in detail how it would work. -- Keep the scope as narrow as possible, to make it easier to implement. -- Remember that this is a volunteer-driven project, and that contributions - are welcome :) +- Explain in detail how it would work. +- Keep the scope as narrow as possible, to make it easier to implement. +- Remember that this is a volunteer-driven project, and that contributions + are welcome :) # Get Started! diff --git a/arcade/Makefile b/arcade/Makefile index fcd6e24f..48853764 100644 --- a/arcade/Makefile +++ b/arcade/Makefile @@ -13,8 +13,6 @@ check: ## Run code quality tools. @poetry run pre-commit run -a @echo "🚀 Static type checking: Running mypy" @poetry run mypy - @echo "🚀 Checking for obsolete dependencies: Running deptry" - @poetry run deptry . .PHONY: test test: ## Test the code with pytest diff --git a/arcade/arcade/actor/common/exception/__init__.py b/arcade/arcade/actor/common/exception/__init__.py deleted file mode 100644 index e5a0d9b4..00000000 --- a/arcade/arcade/actor/common/exception/__init__.py +++ /dev/null @@ -1 +0,0 @@ -#!/usr/bin/env python3 diff --git a/arcade/arcade/actor/common/exception/errors.py b/arcade/arcade/actor/common/exception/errors.py deleted file mode 100644 index 50d3b71b..00000000 --- a/arcade/arcade/actor/common/exception/errors.py +++ /dev/null @@ -1,112 +0,0 @@ -from typing import Any, Optional - -from fastapi import HTTPException -from starlette.background import BackgroundTask - -from arcade.actor.common.response_code import CustomErrorCode, StandardResponseCode - - -class BaseExceptionMixin(Exception): - code: int - - def __init__( - self, - *, - msg: Optional[str] = None, - data: Any = None, - background: BackgroundTask | None = None, - ): - self.msg = msg - self.data = data - # The original background task: https://www.starlette.io/background/ - self.background = background - - -class HTTPError(HTTPException): - def __init__(self, *, code: int, msg: Any = None, headers: dict[str, Any] | None = None): - super().__init__(status_code=code, detail=msg, headers=headers) - - -class CustomError(BaseExceptionMixin): - def __init__( - self, *, error: CustomErrorCode, data: Any = None, background: BackgroundTask | None = None - ): - self.code = error.code - super().__init__(msg=error.msg, data=data, background=background) - - -class RequestError(BaseExceptionMixin): - code = StandardResponseCode.HTTP_400 - - def __init__( - self, - *, - msg: str = "Bad Request", - data: Any = None, - background: BackgroundTask | None = None, - ): - super().__init__(msg=msg, data=data, background=background) - - -class ForbiddenError(BaseExceptionMixin): - code = StandardResponseCode.HTTP_403 - - def __init__( - self, *, msg: str = "Forbidden", data: Any = None, background: BackgroundTask | None = None - ): - super().__init__(msg=msg, data=data, background=background) - - -class NotFoundError(BaseExceptionMixin): - code = StandardResponseCode.HTTP_404 - - def __init__( - self, *, msg: str = "Not Found", data: Any = None, background: BackgroundTask | None = None - ): - super().__init__(msg=msg, data=data, background=background) - - -class ServerError(BaseExceptionMixin): - code = StandardResponseCode.HTTP_500 - - def __init__( - self, - *, - msg: str = "Internal Server Error", - data: Any = None, - background: BackgroundTask | None = None, - ): - super().__init__(msg=msg, data=data, background=background) - - -class GatewayError(BaseExceptionMixin): - code = StandardResponseCode.HTTP_502 - - def __init__( - self, - *, - msg: str = "Bad Gateway", - data: Any = None, - background: BackgroundTask | None = None, - ): - super().__init__(msg=msg, data=data, background=background) - - -class AuthorizationError(BaseExceptionMixin): - code = StandardResponseCode.HTTP_401 - - def __init__( - self, - *, - msg: str = "Permission Denied", - data: Any = None, - background: BackgroundTask | None = None, - ): - super().__init__(msg=msg, data=data, background=background) - - -class TokenError(HTTPError): - code = StandardResponseCode.HTTP_401 - - def __init__(self, *, msg: str = "Not Authenticated", headers: dict[str, Any] | None = None): - super().__init__(code=self.code, msg=msg, headers=headers or {"WWW-Authenticate": "Bearer"}) diff --git a/arcade/arcade/actor/common/exception/exception_handler.py b/arcade/arcade/actor/common/exception/exception_handler.py deleted file mode 100644 index b1ce71e4..00000000 --- a/arcade/arcade/actor/common/exception/exception_handler.py +++ /dev/null @@ -1,231 +0,0 @@ -#!/usr/bin/env python3 -from fastapi import FastAPI, Request -from fastapi.exceptions import RequestValidationError -from pydantic import ValidationError -from pydantic.errors import PydanticUserError -from starlette.exceptions import HTTPException -from starlette.middleware.cors import CORSMiddleware - -from arcade.actor.common.exception.errors import BaseExceptionMixin -from arcade.actor.common.log import log -from arcade.actor.common.response_code import ( - CustomResponseCode, - StandardResponseCode, - response_base, -) -from arcade.actor.core.conf import settings -from arcade.actor.schemas.base import ( - CUSTOM_USAGE_ERROR_MESSAGES, -) -from arcade.actor.utils.serializers import MsgSpecJSONResponse - - -async def _validation_exception_handler( - request: Request, e: RequestValidationError | ValidationError -): - """ - Data validation exception handling - - :param e: - :return: - """ - error = e.errors()[0] - if error.get("type") == "json_invalid": - message = "JSON parsing failed" - else: - error_input = error.get("input") - field = str(error.get("loc")[-1]) - error_msg = error.get("msg") - message = f"{field} {error_msg}, input: {error_input}" - msg = f"Invalid request parameters: {message}" - data = {"errors": error} if settings.ENVIRONMENT == "dev" else None - content = { - "code": StandardResponseCode.HTTP_422, - "msg": msg, - "data": data, - } - request.state.__request_validation_exception__ = ( - content # For obtaining exception information in middleware - ) - return MsgSpecJSONResponse(status_code=422, content=content) - - -def register_exception(app: FastAPI): # noqa: C901 - @app.exception_handler(HTTPException) - async def http_exception_handler(request: Request, exc: HTTPException): - """ - Global HTTP exception handling - - :param request: - :param exc: - :return: - """ - if settings.ENVIRONMENT == "dev": - content = { - "code": exc.status_code, - "msg": exc.detail, - "data": None, - } - else: - res = await response_base.fail(res=CustomResponseCode.HTTP_400) - content = res.model_dump() - request.state.__request_http_exception__ = ( - content # For obtaining exception information in middleware - ) - return MsgSpecJSONResponse( - status_code=StandardResponseCode.HTTP_400, - content=content, - headers=exc.headers, - ) - - @app.exception_handler(RequestValidationError) - async def fastapi_validation_exception_handler(request: Request, exc: RequestValidationError): - """ - FastAPI data validation exception handling - - :param request: - :param exc: - :return: - """ - return await _validation_exception_handler(request, exc) - - @app.exception_handler(ValidationError) - async def pydantic_validation_exception_handler(request: Request, exc: ValidationError): - """ - Pydantic data validation exception handling - - :param request: - :param exc: - :return: - """ - return await _validation_exception_handler(request, exc) - - @app.exception_handler(PydanticUserError) - async def pydantic_user_error_handler(request: Request, exc: PydanticUserError): - """ - Pydantic user exception handling - - :param request: - :param exc: - :return: - """ - return MsgSpecJSONResponse( - status_code=StandardResponseCode.HTTP_500, - content={ - "code": StandardResponseCode.HTTP_500, - "msg": CUSTOM_USAGE_ERROR_MESSAGES.get(exc.code), - "data": None, - }, - ) - - @app.exception_handler(AssertionError) - async def assertion_error_handler(request: Request, exc: AssertionError): - """ - Assertion error handling - - :param request: - :param exc: - :return: - """ - if settings.ENVIRONMENT == "dev": - content = { - "code": StandardResponseCode.HTTP_500, - "msg": str("".join(exc.args) if exc.args else exc.__doc__), - "data": None, - } - else: - res = await response_base.fail(res=CustomResponseCode.HTTP_500) - content = res.model_dump() - return MsgSpecJSONResponse( - status_code=StandardResponseCode.HTTP_500, - content=content, - ) - - @app.exception_handler(Exception) - async def all_exception_handler(request: Request, exc: Exception): - """ - Global exception handling - - :param request: - :param exc: - :return: - """ - if isinstance(exc, BaseExceptionMixin): - return MsgSpecJSONResponse( - status_code=StandardResponseCode.HTTP_400, - content={ - "code": exc.code, - "msg": str(exc.msg), - "data": exc.data if exc.data else None, - }, - background=exc.background, - ) - else: - import traceback - - log.error(f"Unknown exception: {exc}") - log.error(traceback.format_exc()) - if settings.ENVIRONMENT == "dev": - content = { - "code": 500, - "msg": str(exc), - "data": None, - } - else: - res = await response_base.fail(res=CustomResponseCode.HTTP_500) - content = res.model_dump() - return MsgSpecJSONResponse(status_code=StandardResponseCode.HTTP_500, content=content) - - if settings.MIDDLEWARE_CORS: - - @app.exception_handler(StandardResponseCode.HTTP_500) - async def cors_status_code_500_exception_handler(request, exc): - """ - CORS 500 exception handling - - `Related issue `_ - - :param request: - :param exc: - :return: - """ - if isinstance(exc, BaseExceptionMixin): - content = { - "code": exc.code, - "msg": exc.msg, - "data": exc.data, - } - else: - if settings.ENVIRONMENT == "dev": - content = { - "code": StandardResponseCode.HTTP_500, - "msg": str(exc), - "data": None, - } - else: - res = await response_base.fail(res=CustomResponseCode.HTTP_500) - content = res.model_dump() - response = MsgSpecJSONResponse( - status_code=exc.code - if isinstance(exc, BaseExceptionMixin) - else StandardResponseCode.HTTP_500, - content=content, - background=exc.background if isinstance(exc, BaseExceptionMixin) else None, - ) - origin = request.headers.get("origin") - if origin: - cors = CORSMiddleware( - app=app, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - response.headers.update(cors.simple_headers) - has_cookie = "cookie" in request.headers - if cors.allow_all_origins and has_cookie: - response.headers["Access-Control-Allow-Origin"] = origin - elif not cors.allow_all_origins and cors.is_allowed_origin(origin=origin): - response.headers["Access-Control-Allow-Origin"] = origin - response.headers.add_vary_header("Origin") - return response diff --git a/arcade/arcade/actor/common/log.py b/arcade/arcade/actor/common/log.py index d8dbf199..22c9c2ec 100644 --- a/arcade/arcade/actor/common/log.py +++ b/arcade/arcade/actor/common/log.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 from __future__ import annotations import os @@ -16,7 +15,9 @@ if TYPE_CHECKING: class Logger: - def __init__(self): + """Logger for the Actor server""" + + def __init__(self) -> None: self.log_path = actor_log_path def log(self) -> loguru.Logger: @@ -36,19 +37,19 @@ class Logger: logger.add( log_stdout_file, level="INFO", - filter=lambda record: record["level"].name == "INFO" or record["level"].no <= 25, - **log_config, + filter=lambda record: record["level"].name == "INFO" or record["level"].no <= 25, # type: ignore[call-overload] backtrace=False, diagnose=False, + **log_config, ) # stderr logger.add( log_stderr_file, level="ERROR", - filter=lambda record: record["level"].name == "ERROR" or record["level"].no >= 30, - **log_config, + filter=lambda record: record["level"].name == "ERROR" or record["level"].no >= 30, # type: ignore[call-overload] backtrace=True, diagnose=True, + **log_config, ) return logger diff --git a/arcade/arcade/actor/common/response.py b/arcade/arcade/actor/common/response.py index ad5857e3..6738d14f 100644 --- a/arcade/arcade/actor/common/response.py +++ b/arcade/arcade/actor/common/response.py @@ -59,7 +59,7 @@ class ResponseBase: @staticmethod async def __response( *, - res: CustomResponseCode | CustomResponse = None, + res: CustomResponseCode | CustomResponse = CustomResponseCode.HTTP_200, msg: str | None = None, data: Any | None = None, ) -> ResponseModel: diff --git a/arcade/arcade/actor/common/response_code.py b/arcade/arcade/actor/common/response_code.py index c8b1a2c9..6c7dc277 100644 --- a/arcade/arcade/actor/common/response_code.py +++ b/arcade/arcade/actor/common/response_code.py @@ -1,28 +1,28 @@ -#!/usr/bin/env python3 import dataclasses from enum import Enum +from typing import Any class CustomCodeBase(Enum): - """自定义状态码基类""" + """Custom status code base class""" @property - def code(self): + def code(self) -> Any: """ - 获取状态码 + Get status code """ return self.value[0] @property - def msg(self): + def msg(self) -> Any: """ - 获取状态码信息 + Get status code information """ return self.value[1] class CustomResponseCode(CustomCodeBase): - """自定义响应状态码""" + """Custom response status codes""" HTTP_200 = (200, "Request Successful") HTTP_201 = (201, "Created Successfully") @@ -42,12 +42,6 @@ class CustomResponseCode(CustomCodeBase): HTTP_504 = (504, "Gateway Timeout") -class CustomErrorCode(CustomCodeBase): - """自定义错误状态码""" - - CAPTCHA_ERROR = (40001, "CAPTCHA Error") - - @dataclasses.dataclass class CustomResponse: """ diff --git a/arcade/arcade/actor/core/conf.py b/arcade/arcade/actor/core/conf.py index 56eda9ba..29c63c06 100644 --- a/arcade/arcade/actor/core/conf.py +++ b/arcade/arcade/actor/core/conf.py @@ -11,7 +11,7 @@ class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env") WORK_DIR: Path = Path.home() / ".arcade" - TOOLS_DIR: Path = os.getcwd() + TOOLS_DIR: Path = Path(os.getcwd()) # Env Config ENVIRONMENT: Literal["dev", "pro"] = "dev" @@ -60,12 +60,9 @@ class Settings(BaseSettings): @lru_cache -def get_settings(): - try: - env_path = Path(os.environ["TOOLSERVE_ENV"]) - except KeyError: - env_path = Path(__file__).parent.parent / ".env" - return Settings(_env_file=env_path) +def get_settings() -> Settings: + # TODO allow user to specify env file path as a Env Var + return Settings() settings = get_settings() diff --git a/arcade/arcade/actor/core/depends.py b/arcade/arcade/actor/core/depends.py index 40a0f7a5..b24feb4e 100644 --- a/arcade/arcade/actor/core/depends.py +++ b/arcade/arcade/actor/core/depends.py @@ -1,5 +1,8 @@ from starlette.requests import Request +from arcade.tool.catalog import ToolCatalog -def get_catalog(request: Request): - return request.app.state.catalog + +def get_catalog(request: Request) -> ToolCatalog: + # TODO figure out why this says return type is Any + return request.app.state.catalog # type: ignore[no-any-return] diff --git a/arcade/arcade/actor/core/generate.py b/arcade/arcade/actor/core/generate.py index 73aeb0fb..f8781bf7 100644 --- a/arcade/arcade/actor/core/generate.py +++ b/arcade/arcade/actor/core/generate.py @@ -1,39 +1,64 @@ import traceback -from textwrap import dedent +from typing import Callable -from fastapi import APIRouter +from fastapi import APIRouter, Body, Depends, Request from pydantic import BaseModel, ValidationError -from arcade.actor.common.response import response_base -from arcade.actor.common.response_code import CustomResponseCode from arcade.actor.core.conf import settings -from arcade.tool.catalog import ToolDefinition -from arcade.utils import snake_to_pascal_case +from arcade.tool.catalog import MaterializedTool +from arcade.tool.executor import ToolExecutor +from arcade.tool.response import ToolResponse, tool_response -def create_endpoint_function(name, description, func, input_model, output_model): +def create_endpoint_function( + name: str, + description: str, + func: Callable, + input_model: type[BaseModel], + output_model: type[BaseModel], +) -> Callable[..., ToolResponse]: """ Factory function to create endpoint functions with 'frozen' schema and input_model values. """ - async def run(body: input_model): + # dummy function to signal the parameters should be in the + # body of the request + def get_input_model(inputs: BaseModel = Body(...)) -> BaseModel: + return inputs + + async def run(request: Request, inputs: BaseModel = Depends(get_input_model)) -> ToolResponse: + """ + The function that will be executed when a user sends a POST request + to a tool endpoint + """ try: - # Execute the action - result = await func(**body.dict()) - return await response_base.success(data={"result": result}) + # get the body of the request without parsing and validating it + # as the executor will do that + body = await request.json() + response = await ToolExecutor.run(func, input_model, output_model, **body) + + # TODO: Does this catch validation errors on output? except ValidationError as e: - return await response_base.error(res=CustomResponseCode.HTTP_400, msg=str(e)) + return await tool_response.fail(msg=str(e)) + except Exception as e: - print(traceback.format_exc()) - return await response_base.error(res=CustomResponseCode.HTTP_500, msg=str(e)) + return await tool_response.fail( + msg=str(e), + data=traceback.format_exc(), + ) + return response run.__name__ = name run.__doc__ = description - return run + # TODO investigate this + return run # type: ignore[return-value] -def generate_endpoint(schemas: list[ToolDefinition]) -> APIRouter: +def generate_endpoint(schemas: list[MaterializedTool]) -> APIRouter: + """ + Generate a HTTP endpoint for each tool definition passed. + """ routers = [] top_level_router = APIRouter(prefix=settings.API_ACTION_STR) @@ -44,7 +69,7 @@ def generate_endpoint(schemas: list[ToolDefinition]) -> APIRouter: # Create the endpoint function run = create_endpoint_function( - name=snake_to_pascal_case(define.name), + name=define.name, description=define.description, func=schema.tool, input_model=schema.input_model, @@ -53,33 +78,17 @@ def generate_endpoint(schemas: list[ToolDefinition]) -> APIRouter: # Add the endpoint to the FastAPI app router.post( - f"/{snake_to_pascal_case(define.name)}", - name=snake_to_pascal_case(define.name), + f"/{define.name}", # Note: Names from the ToolCatalog are already in PascalCase + name=define.name, summary=define.description, tags=[schema.meta.module], - response_model=schema.output_model, + # TODO investigate this + response_model=ToolResponse[schema.output_model], # type: ignore[name-defined] response_model_exclude_unset=True, response_model_exclude_none=True, - response_description=create_output_description(schema.output_model), )(run) routers.append(router) for router in routers: top_level_router.include_router(router) return top_level_router - - -def create_output_description(output_model: type[BaseModel]) -> str: - """ - Create a description string for the output model. - """ - if not output_model: - return None - - output_description = dedent(output_model.__doc__ or "") - output_description += "\n\n**Attributes:**\n\n" - - for name, field in output_model.model_fields.items(): - output_description += f"- **{name}** ({field.annotation.__name__})\n" - - return output_description diff --git a/arcade/arcade/actor/core/registrar.py b/arcade/arcade/actor/core/registrar.py index 6b894591..c7a81b85 100644 --- a/arcade/arcade/actor/core/registrar.py +++ b/arcade/arcade/actor/core/registrar.py @@ -1,24 +1,13 @@ -#!/usr/bin/env python3 -from contextlib import asynccontextmanager - from fastapi import FastAPI from arcade.actor.common.serializers import MsgSpecJSONResponse from arcade.actor.core.conf import settings +from arcade.actor.core.generate import generate_endpoint from arcade.actor.routes import v1 +from arcade.tool.catalog import ToolCatalog -@asynccontextmanager -async def register_init(app: FastAPI): - """ - - :return: - """ - # eventually lifecycle hooks will be added here - yield - - -def register_app(): +def register_app() -> FastAPI: # FastAPI app = FastAPI( title=settings.TITLE, @@ -28,7 +17,6 @@ def register_app(): redoc_url=settings.REDOCS_URL, openapi_url=settings.OPENAPI_URL, default_response_class=MsgSpecJSONResponse, - lifespan=register_init, ) register_static_file(app) @@ -37,19 +25,16 @@ def register_app(): register_router(app) - # register_exception(app) - - generate_actions_routers(app) + generate_tool_routes(app) return app -def register_static_file(app: FastAPI): +def register_static_file(app: FastAPI) -> None: + """ + Register static files """ - :param app: - :return: - """ if settings.STATIC_FILES: import os @@ -60,11 +45,9 @@ def register_static_file(app: FastAPI): app.mount("/static", StaticFiles(directory="static"), name="static") -def register_middleware(app: FastAPI): +def register_middleware(app: FastAPI) -> None: """ - - :param app: - :return: + Register middleware for the FastAPI app """ # Gzip: Always at the top if settings.MIDDLEWARE_GZIP: @@ -85,12 +68,9 @@ def register_middleware(app: FastAPI): ) -def register_router(app: FastAPI): +def register_router(app: FastAPI) -> None: """ - 路由 - - :param app: FastAPI - :return: + Register routers for the FastAPI app """ dependencies = None @@ -98,16 +78,13 @@ def register_router(app: FastAPI): app.include_router(v1, dependencies=dependencies) -def generate_actions_routers(app: FastAPI): +def generate_tool_routes(app: FastAPI) -> None: """ - - :param app: FastAPI - :return: + Generate tool routes for each tool in the catalog + Add the routes to the FastAPI app and the tool + definitions to the catalog """ - from arcade.actor.core.generate import generate_endpoint - from arcade.tool.catalog import ToolCatalog - catalog = ToolCatalog() - router = generate_endpoint(catalog.tools.values()) + router = generate_endpoint(list(catalog.tools.values())) app.include_router(router) app.state.catalog = catalog diff --git a/arcade/arcade/actor/routes/tool.py b/arcade/arcade/actor/routes/tool.py index 8da7859c..800dadbe 100644 --- a/arcade/arcade/actor/routes/tool.py +++ b/arcade/arcade/actor/routes/tool.py @@ -1,3 +1,5 @@ +from typing import TYPE_CHECKING + from fastapi import APIRouter, Body, Depends, Query from pydantic import ValidationError @@ -6,6 +8,9 @@ from arcade.actor.common.response_code import CustomResponseCode from arcade.actor.core.depends import get_catalog from arcade.tool.openai import schema_to_openai_tool +if TYPE_CHECKING: + from arcade.tool.catalog import ToolCatalog + router = APIRouter() @@ -13,7 +18,7 @@ router = APIRouter() "/list", summary="List available tools", ) -async def list_tools(catalog=Depends(get_catalog)) -> ResponseModel: +async def list_tools(catalog: "ToolCatalog" = Depends(get_catalog)) -> ResponseModel: """List all available tools""" tools = catalog.list_tools() @@ -23,16 +28,21 @@ async def list_tools(catalog=Depends(get_catalog)) -> ResponseModel: @router.get("/json", summary="Get the JSON (openai) format of a tool") async def get_oai_function( tool_name: str = Query(..., title="Tool Name", description="The name of the tool"), - catalog=Depends(get_catalog), + catalog: "ToolCatalog" = Depends(get_catalog), ) -> ResponseModel: """Get the OpenAI function format of an tool""" try: - # TODO handle keyerror tool = catalog[tool_name] json_data = schema_to_openai_tool(tool) return await response_base.success(data=json_data) + + except KeyError: + return await response_base.fail( + res=CustomResponseCode.HTTP_404, + data=f"Tool '{tool_name}' not found in the catalog", + ) except ValidationError as e: return await response_base.fail(res=CustomResponseCode.HTTP_400, data=str(e)) except Exception as e: @@ -45,13 +55,21 @@ async def execute_tool( data: dict[str, str] = Body( ..., title="Tool Data", description="The data to execute the tool with" ), - catalog=Depends(get_catalog), + catalog: "ToolCatalog" = Depends(get_catalog), ) -> ResponseModel: """Execute a tool""" try: + # TODO use executor and error handling tool = catalog.get_tool(tool_name) - result = await tool(**data) + except ValueError: + return await response_base.fail( + res=CustomResponseCode.HTTP_404, + data=f"Tool '{tool_name}' not found in the catalog", + ) + + try: + result = await tool(**data) # type: ignore[misc] return await response_base.success(data=result) except ValidationError as e: return await response_base.fail(res=CustomResponseCode.HTTP_400, data=str(e)) diff --git a/arcade/arcade/actor/schemas/__init__.py b/arcade/arcade/actor/schemas/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/arcade/arcade/actor/schemas/base.py b/arcade/arcade/actor/schemas/base.py deleted file mode 100644 index 9972e67c..00000000 --- a/arcade/arcade/actor/schemas/base.py +++ /dev/null @@ -1,145 +0,0 @@ -from pydantic import BaseModel, ConfigDict, EmailStr, validate_email - -# Custom validation error messages do not include the expected content of validation (i.e., input content). For supported expected content fields, refer to the following link: -# https://github.com/pydantic/pydantic-core/blob/a5cb7382643415b716b1a7a5392914e50f726528/tests/test_errors.py#L266 -# For replacing expected content fields, refer to the following link: -# https://github.com/pydantic/pydantic/blob/caa78016433ec9b16a973f92f187a7b6bfde6cb5/docs/errors/errors.md?plain=1#L232 -CUSTOM_VALIDATION_ERROR_MESSAGES = { - "arguments_type": "Incorrect argument type input", - "assertion_error": "Assertion execution error", - "bool_parsing": "Boolean value parsing error", - "bool_type": "Boolean type input error", - "bytes_too_long": "Byte length input too long", - "bytes_too_short": "Byte length input too short", - "bytes_type": "Byte type input error", - "callable_type": "Callable object type input error", - "dataclass_exact_type": "Dataclass instance type input error", - "dataclass_type": "Dataclass type input error", - "date_from_datetime_inexact": "Non-zero date component input", - "date_from_datetime_parsing": "Date input parsing error", - "date_future": "Date input is not in the future", - "date_parsing": "Date input validation error", - "date_past": "Date input is not in the past", - "date_type": "Date type input error", - "datetime_future": "Datetime input is not in the future", - "datetime_object_invalid": "Datetime input object invalid", - "datetime_parsing": "Datetime input parsing error", - "datetime_past": "Datetime input is not in the past", - "datetime_type": "Datetime type input error", - "decimal_max_digits": "Decimal input has too many digits", - "decimal_max_places": "Decimal places input error", - "decimal_parsing": "Decimal input parsing error", - "decimal_type": "Decimal type input error", - "decimal_whole_digits": "Decimal whole digits input error", - "dict_type": "Dictionary type input error", - "enum": "Enum member input error, allowed {expected}", - "extra_forbidden": "Extra fields input forbidden", - "finite_number": "Finite value input error", - "float_parsing": "Float parsing error", - "float_type": "Float type input error", - "frozen_field": "Frozen field input error", - "frozen_instance": "Modification of frozen instance forbidden", - "frozen_set_type": "Frozen set type input forbidden", - "get_attribute_error": "Attribute retrieval error", - "greater_than": "Input value too large", - "greater_than_equal": "Input value too large or equal", - "int_from_float": "Integer type input error", - "int_parsing": "Integer input parsing error", - "int_parsing_size": "Integer input parsing size error", - "int_type": "Integer type input error", - "invalid_key": "Invalid key input", - "is_instance_of": "Instance type input error", - "is_subclass_of": "Subclass type input error", - "iterable_type": "Iterable type input error", - "iteration_error": "Iteration value input error", - "json_invalid": "JSON string input error", - "json_type": "JSON type input error", - "less_than": "Input value too small", - "less_than_equal": "Input value too small or equal", - "list_type": "List type input error", - "literal_error": "Literal input error", - "mapping_type": "Mapping type input error", - "missing": "Missing required field", - "missing_argument": "Missing argument", - "missing_keyword_only_argument": "Missing keyword-only argument", - "missing_positional_only_argument": "Missing positional-only argument", - "model_attributes_type": "Model attributes type input error", - "model_type": "Model instance input error", - "multiple_argument_values": "Multiple argument values input", - "multiple_of": "Input value not a multiple", - "no_such_attribute": "Invalid attribute assignment", - "none_required": "Input value must be None", - "recursion_loop": "Recursion loop in input", - "set_type": "Set type input error", - "string_pattern_mismatch": "String pattern mismatch input", - "string_sub_type": "String subtype (non-strict instance) input error", - "string_too_long": "String input too long", - "string_too_short": "String input too short", - "string_type": "String type input error", - "string_unicode": "String input not Unicode", - "time_delta_parsing": "Time delta parsing error", - "time_delta_type": "Time delta type input error", - "time_parsing": "Time input parsing error", - "time_type": "Time type input error", - "timezone_aware": "Missing timezone input", - "timezone_naive": "Timezone input forbidden", - "too_long": "Input too long", - "too_short": "Input too short", - "tuple_type": "Tuple type input error", - "unexpected_keyword_argument": "Unexpected keyword argument input", - "unexpected_positional_argument": "Unexpected positional argument input", - "union_tag_invalid": "Union tag literal input error", - "union_tag_not_found": "Union tag argument not found", - "url_parsing": "URL input parsing error", - "url_scheme": "URL scheme input error", - "url_syntax_violation": "URL syntax violation", - "url_too_long": "URL input too long", - "url_type": "URL type input error", - "uuid_parsing": "UUID parsing error", - "uuid_type": "UUID type input error", - "uuid_version": "UUID version type input error", - "value_error": "Value input error", -} - -CUSTOM_USAGE_ERROR_MESSAGES = { - "class-not-fully-defined": "Class attributes type not fully defined", - "custom-json-schema": "__modify_schema__ method deprecated in V2", - "decorator-missing-field": "Invalid field validator defined", - "discriminator-no-field": "Discriminator field not fully defined", - "discriminator-alias-type": "Discriminator field defined using non-string type", - "discriminator-needs-literal": "Discriminator field requires literal definition", - "discriminator-alias": "Inconsistent discriminator field alias definition", - "discriminator-validator": "Field validator forbidden on discriminator field", - "model-field-overridden": "Typeless field override forbidden", - "model-field-missing-annotation": "Missing field type definition", - "config-both": "Duplicate configuration item defined", - "removed-kwargs": "Removed keyword configuration parameter called", - "invalid-for-json-schema": "Invalid JSON type present", - "base-model-instantiated": "Instantiation of base model forbidden", - "undefined-annotation": "Missing type definition", - "schema-for-unknown-type": "Unknown type definition", - "create-model-field-definitions": "Field definition error", - "create-model-config-base": "Configuration item definition error", - "validator-no-fields": "Field validator without specified fields", - "validator-invalid-fields": "Field validator fields definition error", - "validator-instance-method": "Field validator must be a class method", - "model-serializer-instance-method": "Serializer must be an instance method", - "validator-v1-signature": "V1 field validator error deprecated", - "validator-signature": "Field validator signature error", - "field-serializer-signature": "Field serializer signature unrecognized", - "model-serializer-signature": "Model serializer signature unrecognized", - "multiple-field-serializers": "Field serializers defined multiple times", - "invalid_annotated_type": "Invalid type definition", - "type-adapter-config-unused": "Type adapter configuration item definition error", - "root-model-extra": "Extra fields on root model forbidden", -} - - -class CustomEmailStr(EmailStr): - @classmethod - def _validate(cls, __input_value: str) -> str: - return None if __input_value == "" else validate_email(__input_value)[1] - - -class SchemaBase(BaseModel): - model_config = ConfigDict(use_enum_values=True) diff --git a/arcade/arcade/actor/utils/__init__.py b/arcade/arcade/actor/utils/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/arcade/arcade/actor/utils/timezone.py b/arcade/arcade/actor/utils/timezone.py deleted file mode 100644 index 601db95b..00000000 --- a/arcade/arcade/actor/utils/timezone.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 -import zoneinfo -from datetime import datetime - -from arcade.actor.core.conf import settings - - -class TimeZone: - def __init__(self, tz: str = settings.DATETIME_TIMEZONE): - self.tz_info = zoneinfo.ZoneInfo(tz) - - def now(self) -> datetime: - """ - 获取时区时间 - - :return: - """ - return datetime.now(self.tz_info) - - def f_datetime(self, dt: datetime) -> datetime: - """ - datetime 时间转时区时间 - - :param dt: - :return: - """ - return dt.astimezone(self.tz_info) - - def f_str(self, date_str: str, format_str: str = settings.DATETIME_FORMAT) -> datetime: - """ - 时间字符串转时区时间 - - :param date_str: - :param format_str: - :return: - """ - return datetime.strptime(date_str, format_str).replace(tzinfo=self.tz_info) - - -timezone = TimeZone() diff --git a/arcade/arcade/apm/base.py b/arcade/arcade/apm/base.py index 00f14633..eb376bd5 100644 --- a/arcade/arcade/apm/base.py +++ b/arcade/arcade/apm/base.py @@ -8,6 +8,8 @@ from pydantic import BaseModel, EmailStr class PackInfo(BaseModel): + """Package Manager-esk info about a pack of tools.""" + name: str description: str version: str @@ -16,11 +18,15 @@ class PackInfo(BaseModel): class ToolPack(BaseModel): + """A package of tools and their dependencies.""" + pack: PackInfo depends: Optional[dict[str, str]] = None - tools: Optional[dict[str, str]] = {} + tools: dict[str, str] = {} + + def write_lock_file(self, pack_dir: Union[str, os.PathLike]) -> None: + """Write the pack definition to a lock file.""" - def write_lock_file(self, pack_dir: Union[str, os.PathLike]): lock_file = Path(pack_dir) / "pack.lock.toml" pack_dict = self.dict(by_alias=True, exclude_none=True) pack_ordered_dict = { @@ -39,7 +45,9 @@ class ToolPack(BaseModel): f.write(tomlkit.dumps(doc)) @classmethod - def from_lock_file(cls, pack_dir: Union[str, os.PathLike]): + def from_lock_file(cls, pack_dir: Union[str, os.PathLike]) -> "ToolPack": + """Create a ToolPack object from a lock file.""" + pack_dir = Path(pack_dir).resolve() lock_file = pack_dir / "pack.lock.toml" with open(lock_file) as f: diff --git a/arcade/arcade/apm/pack.py b/arcade/arcade/apm/pack.py index 8dc8f149..f2fe52d5 100644 --- a/arcade/arcade/apm/pack.py +++ b/arcade/arcade/apm/pack.py @@ -1,5 +1,4 @@ import os -import shutil from pathlib import Path from typing import Union @@ -29,10 +28,13 @@ class Packer: raise ValueError(f"Invalid 'pack.toml' format: {e}") self.tools = self.load_tools() - self.depends = {} # TODO - self.packs = [] # TODO + self.depends: dict[str, str] = {} # TODO + # self.packs = [] # TODO def load_tools(self) -> dict[str, str]: + """ + Find and load the from the tools defined within directory + """ tools = {} for tool_file in self.tools_dir.rglob("*.py"): if "__init__.py" in tool_file.name: @@ -49,21 +51,11 @@ class Packer: print(f"Error loading tool from {tool_file}: {e}") return tools - def _create_pack_dir(self, pack: ToolPack) -> Path: - # Make "packs" directory if it doesn't exist - packs_dir = self.pack_dir / "packs" - os.makedirs(packs_dir, exist_ok=True) - # make the dir for the action pack and the version (making parent dirs if needed) - top_pack_dir = packs_dir / pack.pack.name / pack.pack.version - # If the pack already exists, remove it and recreate it - if top_pack_dir.exists(): - shutil.rmtree(top_pack_dir) - os.makedirs(top_pack_dir, exist_ok=True) - return top_pack_dir - - def create_pack(self): - # Create an ActionPack instance from the loaded data + def create_pack(self) -> None: + """ + Create a tool pack + """ + if not self.tools: + raise ValueError("No tools found in the tools directory") pack = ToolPack(pack=self.pack, depends=self.depends, tools=self.tools) - # pack_dir = self._create_pack_dir(pack) - # Write the action pack to a TOML file pack.write_lock_file(self.pack_dir) diff --git a/arcade/arcade/apm/parse.py b/arcade/arcade/apm/parse.py index 0795defc..f684b759 100644 --- a/arcade/arcade/apm/parse.py +++ b/arcade/arcade/apm/parse.py @@ -2,17 +2,16 @@ import ast import importlib.metadata import importlib.util import sys -from typing import Optional +from pathlib import Path +from typing import Optional, Union from stdlib_list import stdlib_list -def load_ast_tree(filepath: str) -> ast.AST: +def load_ast_tree(filepath: str | Path) -> ast.AST: """ Load and parse the Abstract Syntax Tree (AST) from a Python file. - :param filepath: Path to the Python file. - :return: AST of the Python file. """ try: with open(filepath) as file: @@ -24,8 +23,6 @@ def load_ast_tree(filepath: str) -> ast.AST: def get_python_version() -> str: """ Get the current Python version. - - :return: The version of Python in use. """ return f"{sys.version_info.major}.{sys.version_info.minor}" @@ -33,9 +30,6 @@ def get_python_version() -> str: def retrieve_imported_libraries(tree: ast.AST) -> dict[str, Optional[str]]: """ Retrieve non-standard libraries imported in the AST. - - :param tree: The AST of the file. - :return: A dictionary with libraries as keys and their versions as values. """ libraries = {} python_version = get_python_version() @@ -44,36 +38,36 @@ def retrieve_imported_libraries(tree: ast.AST) -> dict[str, Optional[str]]: for node in ast.walk(tree): if isinstance(node, ast.ImportFrom): package_name = node.module.split(".")[0] if node.module else None - if package_name == "dstar" or package_name in stdlib_modules: + if package_name: + if package_name in stdlib_modules: + continue + else: + try: + package_version = importlib.metadata.version(package_name) + except importlib.metadata.PackageNotFoundError: + package_version = None + else: continue - try: - package_version = importlib.metadata.version(package_name) - except importlib.metadata.PackageNotFoundError: - package_version = None libraries[package_name] = package_version return libraries -def get_function_name_if_decorated(node: ast.FunctionDef) -> Optional[str]: +def get_function_name_if_decorated( + node: Union[ast.FunctionDef, ast.AsyncFunctionDef] +) -> Optional[str]: """ - Check if a function has a decorator of either "@toolserve.tool" or "tool" and return the function's name. - - :param node: The function definition node from the AST. - :return: The name of the function if it has the specified decorators, otherwise None. + Check if a function has a decorator """ - decorator_ids = {"toolserve.tool", "tool"} + decorator_ids = {"ar.tool", "tool"} for decorator in node.decorator_list: if isinstance(decorator, ast.Name) and decorator.id in decorator_ids: return node.name return None -def get_tools_from_file(filepath: str) -> list[str]: +def get_tools_from_file(filepath: str | Path) -> list[str]: """ - Get the names of all functions in a Python file that are decorated with either "@toolserve.tool" or "@tool". - - :param filepath: Path to the Python file. - :return: List of function names. + Retrieve tools from a Python file. """ tree = load_ast_tree(filepath) tools = [] diff --git a/arcade/arcade/cli/main.py b/arcade/arcade/cli/main.py index 10830bd4..7f413fd4 100644 --- a/arcade/arcade/cli/main.py +++ b/arcade/arcade/cli/main.py @@ -14,12 +14,16 @@ console = Console() @cli.command(help="Starts the ToolServer with specified configurations.") def serve( host: str = typer.Option( - settings.UVICORN_HOST, help="Host for the app, from settings by default.", show_default=True + settings.UVICORN_HOST, + help="Host for the app, from settings by default.", + show_default=True, ), port: int = typer.Option( - settings.UVICORN_PORT, help="Port for the app, settings default.", show_default=True + settings.UVICORN_PORT, + help="Port for the app, settings default.", + show_default=True, ), -): +) -> None: """ Starts the actor with host, port, and reload options. Uses Uvicorn as ASGI actor. Parameters allow runtime configuration. @@ -44,7 +48,7 @@ def serve( @cli.command(help="Build a new Tool Pack") def pack( directory: str = typer.Option(os.getcwd(), "--dir", help="tools directory path with pack.toml"), -): +) -> None: """ Creates a new tool pack with the given name, description, and result type. """ diff --git a/arcade/arcade/sdk/errors.py b/arcade/arcade/sdk/errors.py deleted file mode 100644 index ce0d08df..00000000 --- a/arcade/arcade/sdk/errors.py +++ /dev/null @@ -1,14 +0,0 @@ -class ToolError(Exception): - """ - Base class for all errors related to tools. - """ - - pass - - -class ToolDefinitionError(ToolError): - """ - Raised when there is an error in the definition of a tool. - """ - - pass diff --git a/arcade/arcade/sdk/tool.py b/arcade/arcade/sdk/tool.py index 8e104242..fe10f5ac 100644 --- a/arcade/arcade/sdk/tool.py +++ b/arcade/arcade/sdk/tool.py @@ -1,12 +1,13 @@ import os from typing import Any, Callable, Optional, TypeVar, Union -from arcade.sdk.schemas import ToolAuthorizationRequirement +from arcade.tool.schemas import ToolAuthorizationRequirement from arcade.utils import snake_to_pascal_case T = TypeVar("T") +# TODO change desc to description def tool( func: Callable | None = None, desc: str | None = None, @@ -14,9 +15,12 @@ def tool( requires_auth: Union[ToolAuthorizationRequirement, None] = None, ) -> Callable: def decorator(func: Callable) -> Callable: - func.__tool_name__ = name or snake_to_pascal_case(getattr(func, "__name__", None)) - func.__tool_description__ = desc or func.__doc__ - func.__tool_requires_auth__ = requires_auth + func_name = str(getattr(func, "__name__", None)) + tool_name = name or snake_to_pascal_case(func_name) + + setattr(func, "__tool_name__", tool_name) # noqa: B010 (Do not call `setattr` with a constant attribute value) + setattr(func, "__tool_description__", desc or func.__doc__) # noqa: B010 + setattr(func, "__tool_requires_auth__", requires_auth) # noqa: B010 return func @@ -25,7 +29,7 @@ def tool( return decorator -def get_secret(name: str, default: Optional[Any] = None) -> str: +def get_secret(name: str, default: Optional[Any] = None) -> Any: secret = os.getenv(name) if secret is None: if default is not None: diff --git a/arcade/arcade/tool/catalog.py b/arcade/arcade/tool/catalog.py index a03ec504..81058b47 100644 --- a/arcade/arcade/tool/catalog.py +++ b/arcade/arcade/tool/catalog.py @@ -1,11 +1,14 @@ import asyncio import inspect import sys +from collections.abc import Iterator +from dataclasses import dataclass from datetime import datetime from importlib import import_module from pathlib import Path from typing import ( Annotated, + Any, Callable, Literal, Optional, @@ -16,14 +19,14 @@ from typing import ( ) from pydantic import BaseModel, Field, create_model +from pydantic.fields import FieldInfo +from pydantic_core import PydanticUndefined -from arcade.actor.common.response import ResponseModel -from arcade.actor.common.response_code import CustomResponseCode from arcade.actor.core.conf import settings from arcade.apm.base import ToolPack from arcade.sdk.annotations import Inferrable -from arcade.sdk.errors import ToolDefinitionError -from arcade.sdk.schemas import ( +from arcade.tool.errors import ToolDefinitionError +from arcade.tool.schemas import ( InputParameter, ToolDefinition, ToolInputs, @@ -38,8 +41,14 @@ from arcade.utils import ( snake_to_pascal_case, ) +WireType = Literal["string", "integer", "float", "boolean", "json"] + class ToolMeta(BaseModel): + """ + Metadata for a tool once it's been materialized. + """ + module: str path: Optional[str] = None date_added: datetime = Field(default_factory=datetime.now) @@ -47,6 +56,10 @@ class ToolMeta(BaseModel): class MaterializedTool(BaseModel): + """ + Data structure that holds tool information while stored in the Catalog + """ + tool: Callable definition: ToolDefinition meta: ToolMeta @@ -68,12 +81,21 @@ class MaterializedTool(BaseModel): return self.definition.description +# TODO make a generate for catalog type + + class ToolCatalog: - def __init__(self, tools_dir: str = settings.TOOLS_DIR): - self.tools = self.read_tools(tools_dir) + """Singleton class that holds all tools for a given actor""" + + def __init__(self, tools_dir: Path = settings.TOOLS_DIR): + self.tools: dict[str, MaterializedTool] = self.read_tools(tools_dir) @staticmethod - def read_tools(directory: str) -> dict[str, MaterializedTool]: + def read_tools(directory: Path) -> dict[str, MaterializedTool]: + """ + Create tool definitions from a directory of python files + """ + toolpack = ToolPack.from_lock_file(directory) sys.path.append(str(Path(directory).resolve() / "tools")) @@ -85,9 +107,7 @@ class ToolCatalog: module = import_module(module_name) tool_func = getattr(module, func_name) input_model, output_model = create_func_models(tool_func) - tool_name = snake_to_pascal_case( - name - ) # TODO make sure this follows create_tool_definition + tool_name = name tools[tool_name] = MaterializedTool( definition=ToolCatalog.create_tool_definition(tool_func, version), tool=tool_func, @@ -100,6 +120,10 @@ class ToolCatalog: @staticmethod def create_tool_definition(tool: Callable, version: str) -> ToolDefinition: + """ + Given a tool function, create a ToolDefinition + """ + tool_name = getattr(tool, "__tool_name__", tool.__name__) # Hard requirement: tools must have descriptions @@ -122,18 +146,18 @@ class ToolCatalog: ), ) - def __getitem__(self, name: str) -> Optional[MaterializedTool]: + def __getitem__(self, name: str) -> MaterializedTool: # TODO error handling for tool_name, tool in self.tools.items(): if tool_name == name: return tool - return None + raise KeyError(f"Tool {name} not found.") - def __iter__(self) -> MaterializedTool: + def __iter__(self) -> Iterator[MaterializedTool]: yield from self.tools.values() def get_tool(self, name: str) -> Optional[Callable]: - for _, tool in self: + for tool in self.tools.values(): if tool.definition.name == name: return tool.tool raise ValueError(f"Tool {name} not found.") @@ -159,31 +183,29 @@ def create_input_definition(func: Callable) -> ToolInputs: """ input_parameters = [] for _, param in inspect.signature(func, follow_wrapped=True).parameters.items(): - field_info = extract_field_info(param) - - # Hard requirement: params must be described - if field_info["field_params"]["description"] is None: - raise ToolDefinitionError( - f"Parameter {field_info['field_params']['name']} is missing a description" - ) + tool_field_info = extract_field_info(param) is_enum = False enum_values: list[str] = [] # Special case: Literal["string1", "string2"] can be enumerated on the wire - if is_string_literal(field_info["field_params"]["type"]): + if is_string_literal(tool_field_info.field_type): is_enum = True - enum_values = [str(e) for e in get_args(field_info["field_params"]["type"])] + enum_values = [str(e) for e in get_args(tool_field_info.field_type)] + + # If the field has a default value, it is not required + # If the field is optional, it is not required + has_default_value = tool_field_info.default is not None + is_required = not tool_field_info.is_optional and not has_default_value input_parameters.append( InputParameter( - name=field_info["field_params"]["name"], - description=field_info["field_params"]["description"], - required=field_info["field_params"]["default"] is None - and not field_info["field_params"]["optional"], - inferrable=field_info["field_params"]["inferrable"], + name=tool_field_info.name, + description=tool_field_info.description, + required=is_required, + inferrable=tool_field_info.is_inferrable, value_schema=ValueSchema( - val_type=field_info["field_params"]["wire_type"], + val_type=tool_field_info.wire_type, enum=enum_values if is_enum else None, ), ) @@ -230,38 +252,108 @@ def create_output_definition(func: Callable) -> ToolOutput: ) -def extract_field_info(param: inspect.Parameter) -> dict: +@dataclass +class ParamInfo: + """ + Information about a function parameter found through inspection. + """ + + name: str + default: Any + original_type: type + field_type: type + description: str | None = None + is_optional: bool = True + + +@dataclass +class ToolParamInfo: + """ + Information about a tool parameter, including computed values. + """ + + name: str + default: Any + original_type: type + field_type: type + wire_type: WireType + description: str | None = None + is_optional: bool = True + is_inferrable: bool = True + + @classmethod + def from_param_info( + cls, param_info: ParamInfo, wire_type: WireType, is_inferrable: bool = True + ) -> "ToolParamInfo": + return cls( + name=param_info.name, + default=param_info.default, + original_type=param_info.original_type, + field_type=param_info.field_type, + description=param_info.description, + is_optional=param_info.is_optional, + wire_type=wire_type, + is_inferrable=is_inferrable, + ) + + +def extract_field_info(param: inspect.Parameter) -> ToolParamInfo: """ Extract type and field parameters from a function parameter. - - Args: - param (inspect.Parameter): The parameter to extract information from. - - Returns: - dict: A dictionary with 'type' and 'field_params'. """ annotation = param.annotation if annotation == inspect.Parameter.empty: - raise TypeError(f"Parameter {param} has no type annotation.") + raise ToolDefinitionError(f"Parameter {param} has no type annotation.") + + # Get the majority of the param info from either the Pydantic Field() or regular inspection + if isinstance(param.default, FieldInfo): + param_info = extract_pydantic_param_info(param) + else: + param_info = extract_regular_param_info(param) metadata = getattr(annotation, "__metadata__", []) - - name = param.name - description = None - str_annotations = [m for m in metadata if isinstance(m, str)] - if len(str_annotations) == 1: - description = str_annotations[0] + + # Get the description from annotations, if present + if len(str_annotations) == 0: + pass + elif len(str_annotations) == 1: + param_info.description = str_annotations[0] elif len(str_annotations) == 2: - name = str_annotations[0] - description = str_annotations[1] + param_info.name = str_annotations[0] + param_info.description = str_annotations[1] else: - raise ToolDefinitionError(f"Parameter {param} has multiple descriptions") + raise ToolDefinitionError( + f"Parameter {param} has too many string annotations. Expected 0, 1, or 2, got {len(str_annotations)}." + ) - default = param.default if param.default is not inspect.Parameter.empty else None + # Get the Inferrable annotation, if it exists + inferrable_annotation = first_or_none(Inferrable, get_args(annotation)) - # If the param is Annotated[], unwrap the annotation + # Params are inferrable by default + is_inferrable = inferrable_annotation.value if inferrable_annotation else True + + # Get the wire type + wire_type = ( + get_wire_type(str) + if is_string_literal(param_info.field_type) + else get_wire_type(param_info.field_type) + ) + + # Final reality check + if param_info.description is None: + raise ToolDefinitionError(f"Parameter {param_info.name} is missing a description") + + if wire_type is None: + raise ToolDefinitionError(f"Unknown parameter type: {param_info.field_type}") + + return ToolParamInfo.from_param_info(param_info, wire_type, is_inferrable) + + +def extract_regular_param_info(param: inspect.Parameter) -> ParamInfo: + # If the param is Annotated[], unwrap the annotation to get the "real" type # Otherwise, use the literal type + annotation = param.annotation original_type = annotation.__args__[0] if get_origin(annotation) is Annotated else annotation field_type = original_type @@ -271,30 +363,55 @@ def extract_field_info(param: inspect.Parameter) -> dict: field_type = next(arg for arg in get_args(field_type) if arg is not type(None)) is_optional = True - wire_type = get_wire_type(str) if is_string_literal(field_type) else get_wire_type(field_type) + return ParamInfo( + name=param.name, + default=param.default if param.default is not inspect.Parameter.empty else None, + is_optional=is_optional, + original_type=original_type, + field_type=field_type, + ) - # Get the Inferrable annotation, if it exists - inferrable_annotation = first_or_none(Inferrable, get_args(annotation)) - field_params = { - "name": name, - "description": str(description) if description else None, - "default": default, - "optional": is_optional, - "inferrable": inferrable_annotation.value - if inferrable_annotation - else True, # Params are inferrable by default - "type": field_type, - "wire_type": wire_type, - "original_type": original_type, - } +def extract_pydantic_param_info(param: inspect.Parameter) -> ParamInfo: + default_value = None if param.default.default is PydanticUndefined else param.default.default - return {"type": field_type, "field_params": field_params} + if param.default.default_factory is not None: + if callable(param.default.default_factory): + default_value = param.default.default_factory() + else: + raise ToolDefinitionError(f"Default factory for parameter {param} is not callable.") + + # If the param is Annotated[], unwrap the annotation to get the "real" type + # Otherwise, use the literal type + original_type = ( + param.annotation.__args__[0] + if get_origin(param.annotation) is Annotated + else param.annotation + ) + field_type = original_type + + # Unwrap Optional types + is_optional = False + if get_origin(field_type) is Union and type(None) in get_args(field_type): + field_type = next(arg for arg in get_args(field_type) if arg is not type(None)) + is_optional = True + + return ParamInfo( + name=param.name, + description=param.default.description, + default=default_value, + is_optional=is_optional, + original_type=original_type, + field_type=field_type, + ) def get_wire_type( _type: type, -) -> Literal["string", "integer", "float", "boolean", "json"]: +) -> WireType: + """ + Mapping between Python types and HTTP/JSON types + """ type_mapping = { str: "string", bool: "boolean", @@ -315,19 +432,12 @@ def get_wire_type( return "json" elif issubclass(_type, BaseModel): return "json" - else: - raise TypeError(f"Unsupported parameter type: {_type}") + raise ToolDefinitionError(f"Unsupported parameter type: {_type}") def create_func_models(func: Callable) -> tuple[type[BaseModel], type[BaseModel]]: """ Analyze a function to create corresponding Pydantic models for its input and output. - - Args: - func (Callable): The function to analyze. - - Returns: - Tuple[Type[BaseModel], Type[BaseModel]]: A tuple containing the input and output Pydantic models. """ input_fields = {} # TODO figure this out (Sam) @@ -335,16 +445,15 @@ def create_func_models(func: Callable) -> tuple[type[BaseModel], type[BaseModel] func = func.__wrapped__ for name, param in inspect.signature(func, follow_wrapped=True).parameters.items(): # TODO make this cleaner - field_info = extract_field_info(param) - field_data = field_info["field_params"] + tool_field_info = extract_field_info(param) param_fields = { - "default": field_data["default"], - "description": field_data["description"], + "default": tool_field_info.default, + "description": tool_field_info.description, # TODO more here? } - input_fields[name] = (field_info["type"], Field(**param_fields)) + input_fields[name] = (tool_field_info.field_type, Field(**param_fields)) - input_model = create_model(f"{snake_to_pascal_case(func.__name__)}Input", **input_fields) + input_model = create_model(f"{snake_to_pascal_case(func.__name__)}Input", **input_fields) # type: ignore[call-overload] output_model = determine_output_model(func) @@ -354,12 +463,6 @@ def create_func_models(func: Callable) -> tuple[type[BaseModel], type[BaseModel] def determine_output_model(func: Callable) -> type[BaseModel]: """ Determine the output model for a function based on its return annotation. - - Args: - func (Callable): The function to analyze. - - Returns: - Type[BaseModel]: A Pydantic model representing the output. """ return_annotation = inspect.signature(func).return_annotation output_model_name = f"{snake_to_pascal_case(func.__name__)}Output" @@ -367,7 +470,7 @@ def determine_output_model(func: Callable) -> type[BaseModel]: return create_model(output_model_name) elif hasattr(return_annotation, "__origin__"): if hasattr(return_annotation, "__metadata__"): - field_type = Optional[return_annotation.__args__[0]] + field_type = return_annotation.__args__[0] description = ( return_annotation.__metadata__[0] if return_annotation.__metadata__ else "" ) @@ -376,32 +479,18 @@ def determine_output_model(func: Callable) -> type[BaseModel]: output_model_name, result=(field_type, Field(description=str(description))), ) - else: - return create_model( - output_model_name, - result=( - return_annotation, - Field(description="No description provided."), - ), - ) + # when the return_annotation has an __origin__ attribute + # and does not have a __metadata__ attribute. + return create_model( + output_model_name, + result=( + return_annotation, + Field(description="No description provided."), + ), + ) else: # Handle simple return types (like str) return create_model( output_model_name, result=(return_annotation, Field(description="No description provided.")), ) - - -def create_response_model(name: str, output_model: type[BaseModel]) -> type[ResponseModel]: - """ - Create a response model for the given schema. - """ - # Create a new response model - response_model = create_model( - f"{snake_to_pascal_case(name)}Response", - code=(int, CustomResponseCode.HTTP_200.code), - msg=(str, CustomResponseCode.HTTP_200.msg), - data=(Optional[output_model], None), - ) - - return response_model diff --git a/arcade/arcade/tool/errors.py b/arcade/arcade/tool/errors.py new file mode 100644 index 00000000..20f5677f --- /dev/null +++ b/arcade/arcade/tool/errors.py @@ -0,0 +1,53 @@ +class ToolError(Exception): + """ + Base class for all errors related to tools. + """ + + pass + + +class ToolDefinitionError(ToolError): + """ + Raised when there is an error in the definition of a tool. + """ + + pass + + +# ------ runtime errors ------ + + +class ToolRuntimeError(RuntimeError): + pass + + +class ToolExecutionError(ToolRuntimeError): + """ + Raised when there is an error executing a tool. + """ + + pass + + +class ToolSerializationError(ToolRuntimeError): + """ + Raised when there is an error executing a tool. + """ + + pass + + +class ToolInputError(ToolSerializationError): + """ + Raised when there is an error in the input to a tool. + """ + + pass + + +class ToolOutputError(ToolSerializationError): + """ + Raised when there is an error in the output of a tool. + """ + + pass diff --git a/arcade/arcade/tool/executor.py b/arcade/arcade/tool/executor.py new file mode 100644 index 00000000..19b090b1 --- /dev/null +++ b/arcade/arcade/tool/executor.py @@ -0,0 +1,81 @@ +from typing import Any, Callable + +from pydantic import BaseModel, ValidationError + +from arcade.tool.errors import ( + ToolExecutionError, + ToolInputError, + ToolOutputError, + ToolSerializationError, +) +from arcade.tool.response import ToolResponse, tool_response + + +class ToolExecutor: + @staticmethod + async def run( + func: Callable, + input_model: type[BaseModel], + output_model: type[BaseModel], + *args: Any, + **kwargs: Any, + ) -> ToolResponse: + """ + Execute a callable function with validated inputs and outputs via Pydantic models. + """ + try: + # serialize the input model + inputs = await ToolExecutor._serialize_input(input_model, **kwargs) + + # execute the tool function + results = await func(**inputs.dict()) + + # serialize the output model + output = await ToolExecutor._serialize_output(output_model, results) + + # return the output + return await tool_response.success(data=output) + + except ToolSerializationError as e: + return await tool_response.fail(msg=str(e)) + + except ToolExecutionError as e: + return await tool_response.fail(msg=str(e)) + + # if we get here we're in trouble + # TODO: Debate if this is necessary + except Exception as e: + return await tool_response.fail(msg=str(e)) + + @staticmethod + async def _serialize_input(input_model: type[BaseModel], **kwargs: Any) -> BaseModel: + """ + Serialize the input to a tool function. + """ + try: + # TODO Logging and telemetry + + # build in the input model to the tool function + inputs = input_model(**kwargs) + + except ValidationError as e: + raise ToolInputError from e + + return inputs + + @staticmethod + async def _serialize_output(output_model: type[BaseModel], results: dict) -> BaseModel: + """ + Serialize the output of a tool function. + """ + # TODO how to type this the results object? + try: + # TODO Logging and telemetry + + # build the output model + output = output_model(**{"result": results}) + + except ValidationError as e: + raise ToolOutputError from e + + return output diff --git a/arcade/arcade/tool/openai.py b/arcade/arcade/tool/openai.py index 3233aa8a..003bbf41 100644 --- a/arcade/arcade/tool/openai.py +++ b/arcade/arcade/tool/openai.py @@ -7,7 +7,7 @@ from pydantic_core import PydanticUndefined from arcade.tool.catalog import MaterializedTool -PYTHON_TO_JSON_TYPES = { +PYTHON_TO_JSON_TYPES: dict[type, str] = { str: "string", int: "integer", float: "number", @@ -17,15 +17,10 @@ PYTHON_TO_JSON_TYPES = { } -def python_type_to_json_type(python_type: type) -> dict[str, Any]: +def python_type_to_json_type(python_type: type[Any]) -> dict[str, Any]: """ - Map Python types to JSON Schema types, including handling of complex types such as lists and dictionaries. - - Args: - python_type (Type): The Python type to be converted to a JSON schema type. - - Returns: - Dict[str, Any]: A dictionary representing the JSON schema for the given Python type. + Map Python types to JSON Schema types, including handling of + complex types such as lists and dictionaries. """ if hasattr(python_type, "__origin__"): origin = python_type.__origin__ @@ -40,23 +35,18 @@ def python_type_to_json_type(python_type: type) -> dict[str, Any]: elif issubclass(python_type, BaseModel): return model_to_json_schema(python_type) - return PYTHON_TO_JSON_TYPES.get(python_type, "string") + raise ValueError(f"Unsupported type: {python_type}") def model_to_json_schema(model: type[BaseModel]) -> dict[str, Any]: """ Convert a Pydantic model to a JSON schema. - - Args: - model (Type[BaseModel]): The Pydantic model to convert. - - Returns: - Dict[str, Any]: A dictionary representing the JSON schema for the given model. """ properties = {} required = [] for field_name, model_field in model.model_fields.items(): - type_json = python_type_to_json_type(model_field.annotation) + # TODO: remove type ignore + type_json = python_type_to_json_type(model_field.annotation) # type: ignore[arg-type] if isinstance(type_json, dict): field_schema = type_json else: @@ -104,12 +94,6 @@ def schema_to_openai_tool(tool: "MaterializedTool") -> str: } } } - - Args: - tool_schema (ToolDefinition): The tool schema to convert. - - Returns: - str: A JSON schema string representing the tool in the specified format. """ input_model_schema = model_to_json_schema(tool.input_model) function_schema = { diff --git a/arcade/arcade/tool/response.py b/arcade/arcade/tool/response.py new file mode 100644 index 00000000..2d9a03b6 --- /dev/null +++ b/arcade/arcade/tool/response.py @@ -0,0 +1,85 @@ +from datetime import datetime +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel, ConfigDict + +from arcade.actor.common.response import ( + CustomResponse, + CustomResponseCode, +) +from arcade.actor.core.conf import settings + +_ExcludeData = set[int | str] | dict[int | str, Any] +T = TypeVar("T") + + +# TODO: Mapping of tool response actions to http codes? + + +class ToolResponse(BaseModel, Generic[T]): + """ + Generic unified return model for Tools + + """ + + # TODO: json_encoders configuration failure: https://github.com/tiangolo/fastapi/discussions/10252 + model_config = ConfigDict( + json_encoders={datetime: lambda x: x.strftime(settings.DATETIME_FORMAT)} + ) + + code: int = CustomResponseCode.HTTP_200.code + msg: str = CustomResponseCode.HTTP_200.msg + + # + data: T | None = None + + +class ToolResponseFactory: + """ + Singleton pattern for unified return method from tools. + """ + + @staticmethod + async def __response( + *, + msg: str | None = None, + res: CustomResponseCode | CustomResponse = CustomResponseCode.HTTP_200, + data: T | None = None, + ) -> ToolResponse: + """ + General method for successful response + """ + if msg: + return ToolResponse(code=res.code, msg=msg, data=data) + return ToolResponse(code=res.code, msg=res.msg, data=data) + + async def success( + self, + *, + res: CustomResponseCode | CustomResponse = CustomResponseCode.HTTP_200, + data: T | None = None, + ) -> ToolResponse: + return await self.__response(res=res, data=data) + + async def retry( + self, + *, + res: CustomResponseCode | CustomResponse = CustomResponseCode.HTTP_200, + msg: str = CustomResponseCode.HTTP_200.msg, + data: T | None = None, + ) -> ToolResponse: + # TODO: Implement retry logic and ability to add messages to the response for + # the LLM + return await self.__response(res=res, msg=msg, data=data) + + async def fail( + self, + *, + res: CustomResponseCode | CustomResponse = CustomResponseCode.HTTP_400, + msg: str = CustomResponseCode.HTTP_400.msg, + data: Any = None, + ) -> ToolResponse: + return await self.__response(res=res, data=data) + + +tool_response = ToolResponseFactory() diff --git a/arcade/arcade/sdk/schemas.py b/arcade/arcade/tool/schemas.py similarity index 70% rename from arcade/arcade/sdk/schemas.py rename to arcade/arcade/tool/schemas.py index b6c36f87..ac327520 100644 --- a/arcade/arcade/sdk/schemas.py +++ b/arcade/arcade/tool/schemas.py @@ -1,15 +1,21 @@ from abc import ABC from typing import Literal, Optional, Union -from pydantic import AnyUrl, BaseModel, Field, conlist +from pydantic import AnyUrl, BaseModel, Field class ValueSchema(BaseModel): + """Value schema for input parameters and outputs.""" + val_type: Literal["string", "integer", "float", "boolean", "json"] + """The type of the value.""" + enum: Optional[list[str]] = None class InputParameter(BaseModel): + """A parameter that can be passed to a tool.""" + name: str = Field(..., description="The human-readable name of this parameter.") required: bool = Field( ..., @@ -29,20 +35,21 @@ class InputParameter(BaseModel): class ToolInputs(BaseModel): - parameters: conlist(InputParameter) + """The inputs that a tool accepts.""" + + parameters: list[InputParameter] + """The list of parameters that the tool accepts.""" class ToolOutput(BaseModel): + """The output of a tool.""" + description: Optional[str] = Field( None, description="A descriptive, human-readable explanation of the output." ) - available_modes: conlist( - Literal["value", "error", "null", "artifact", "requires_authorization"], - min_length=1, - ) = Field( - ..., - description="The available modes for the output.", + available_modes: list[str] = Field( default_factory=lambda: ["value", "error", "null"], + description="The available modes for the output.", ) value_schema: Optional[ValueSchema] = Field( None, description="The schema of the value of the output." @@ -50,19 +57,30 @@ class ToolOutput(BaseModel): class ToolAuthorizationRequirement(BaseModel, ABC): + """A requirement for authorization to use a tool.""" + pass class OAuth2AuthorizationRequirement(ToolAuthorizationRequirement): + """Specifies OAuth2 requirement for tool execution.""" + url: AnyUrl + """The URL to which the user should be redirected to authorize the tool.""" + scope: Optional[list[str]] = None + """The scope of the authorization.""" class ToolRequirements(BaseModel): + """The requirements for a tool to run.""" + authorization: Union[ToolAuthorizationRequirement, None] = None class ToolDefinition(BaseModel): + """The specification of a tool.""" + name: str description: str version: str diff --git a/arcade/arcade/utils/__init__.py b/arcade/arcade/utils/__init__.py index 5d06551d..4d2ce7fd 100644 --- a/arcade/arcade/utils/__init__.py +++ b/arcade/arcade/utils/__init__.py @@ -47,10 +47,10 @@ def does_function_return_value(func: Callable) -> bool: tree = ast.parse(source) class ReturnVisitor(ast.NodeVisitor): - def __init__(self): + def __init__(self) -> None: self.returns_value = False - def visit_Return(self, node): + def visit_Return(self, node: ast.Return) -> None: if node.value is not None: self.returns_value = True diff --git a/arcade/pyproject.toml b/arcade/pyproject.toml index 753663f4..3e805ea5 100644 --- a/arcade/pyproject.toml +++ b/arcade/pyproject.toml @@ -36,8 +36,8 @@ deptry = "^0.12.0" mypy = "^1.5.1" pre-commit = "^3.4.0" tox = "^4.11.1" - pytest-asyncio = "^0.23.7" + [tool.poetry.group.docs.dependencies] mkdocs = "^1.4.2" mkdocs-material = "^9.2.7" diff --git a/arcade/tests/sdk/test_tool_decorator.py b/arcade/tests/sdk/test_tool_decorator.py index e4cfd894..79cbb2ac 100644 --- a/arcade/tests/sdk/test_tool_decorator.py +++ b/arcade/tests/sdk/test_tool_decorator.py @@ -2,8 +2,8 @@ import asyncio import pytest -from arcade.sdk.schemas import OAuth2AuthorizationRequirement from arcade.sdk.tool import tool +from arcade.tool.schemas import OAuth2AuthorizationRequirement def test_sync_function(): diff --git a/arcade/tests/tool/test_create_tool_definition.py b/arcade/tests/tool/test_create_tool_definition.py index fc1c19b6..2734f902 100644 --- a/arcade/tests/tool/test_create_tool_definition.py +++ b/arcade/tests/tool/test_create_tool_definition.py @@ -3,7 +3,9 @@ from typing import Annotated, Literal, Optional import pytest from arcade.sdk.annotations import Inferrable -from arcade.sdk.schemas import ( +from arcade.sdk.tool import tool +from arcade.tool.catalog import ToolCatalog +from arcade.tool.schemas import ( InputParameter, OAuth2AuthorizationRequirement, ToolInputs, @@ -11,8 +13,6 @@ from arcade.sdk.schemas import ( ToolRequirements, ValueSchema, ) -from arcade.sdk.tool import tool -from arcade.tool.catalog import ToolCatalog ### Tests on @tool decorator @@ -89,6 +89,20 @@ def func_with_optional_param(param1: Annotated[Optional[str], "First param"]): pass +@tool(desc="A function with an optional input parameter (default: None)") +def func_with_optional_param_with_default_None( + param1: Annotated[Optional[str], "First param"] = None, +): + pass + + +@tool(desc="A function with an optional input parameter with default value") +def func_with_optional_param_with_default_value( + param1: Annotated[Optional[str], "First param"] = "default", +): + pass + + @tool(desc="A function with multiple parameters, some with default values") def func_with_mixed_params( param1: Annotated[str, "First param"], @@ -341,6 +355,46 @@ def func_with_complex_return() -> list[dict[str, str]]: }, id="func_with_optional_param", ), + pytest.param( + func_with_optional_param_with_default_None, + { + "inputs": ToolInputs( + parameters=[ + InputParameter( + name="param1", + description="First param", + inferrable=True, + required=False, # Because of Optional[str] + value_schema=ValueSchema(val_type="string", enum=None), + ) + ] + ), + "output": ToolOutput( + available_modes=["null"], description="No description provided." + ), + }, + id="func_with_optional_param_with_default_None", + ), + pytest.param( + func_with_optional_param_with_default_value, + { + "inputs": ToolInputs( + parameters=[ + InputParameter( + name="param1", + description="First param", + inferrable=True, + required=False, # Because of Optional[str] and default value + value_schema=ValueSchema(val_type="string", enum=None), + ) + ] + ), + "output": ToolOutput( + available_modes=["null"], description="No description provided." + ), + }, + id="func_with_optional_param_with_default_value", + ), pytest.param( func_with_mixed_params, { diff --git a/arcade/tests/tool/test_create_tool_definition_errors.py b/arcade/tests/tool/test_create_tool_definition_errors.py index 7a9b7023..f4d4386a 100644 --- a/arcade/tests/tool/test_create_tool_definition_errors.py +++ b/arcade/tests/tool/test_create_tool_definition_errors.py @@ -1,8 +1,8 @@ import pytest -from arcade.sdk.errors import ToolDefinitionError from arcade.sdk.tool import tool from arcade.tool.catalog import ToolCatalog +from arcade.tool.errors import ToolDefinitionError @tool @@ -15,6 +15,11 @@ def func_with_missing_return_type(): return "hello world" +@tool(desc="A function with a parameter type (illegal)") +def func_with_missing_param_type(param1): + pass + + @tool(desc="A function with a parameter missing a description (illegal)") def func_with_missing_param_description(param1: str): pass @@ -38,6 +43,11 @@ def func_with_unsupported_param(param1: complex): ToolDefinitionError, id=func_with_missing_return_type.__name__, ), + pytest.param( + func_with_missing_param_type, + ToolDefinitionError, + id=func_with_missing_param_type.__name__, + ), pytest.param( func_with_missing_param_description, ToolDefinitionError, diff --git a/arcade/tests/tool/test_create_tool_definition_pydantic.py b/arcade/tests/tool/test_create_tool_definition_pydantic.py index e4f9a3f4..7fb1c5f4 100644 --- a/arcade/tests/tool/test_create_tool_definition_pydantic.py +++ b/arcade/tests/tool/test_create_tool_definition_pydantic.py @@ -1,14 +1,16 @@ -from typing import Annotated +from typing import Annotated, Optional, Union import pytest from pydantic import BaseModel, Field -from arcade.sdk.schemas import ( +from arcade.sdk.tool import tool +from arcade.tool.catalog import ToolCatalog +from arcade.tool.schemas import ( + InputParameter, + ToolInputs, ToolOutput, ValueSchema, ) -from arcade.sdk.tool import tool -from arcade.tool.catalog import ToolCatalog class ProductOutput(BaseModel): @@ -26,10 +28,102 @@ def func_returns_pydantic_model() -> Annotated[ProductOutput, "The product, pric ) +@tool(desc="A function that accepts a required Pydantic Field with a description") +def func_takes_pydantic_field_with_description( + product_name: str = Field(..., description="The name of the product"), +) -> str: + return product_name + + +@tool(desc="A function that accepts an Pydantic Field") +def func_takes_pydantic_field_optional( + product_name: Optional[str] = Field(None, description="The name of the product"), +) -> str: + return product_name + + +# Annotated[] takes precedence over Field() properties +@tool(desc="A function that accepts an annotated Pydantic Field") +def func_takes_pydantic_field_annotated_description( + product_name: Annotated[str, "The name of the product"] = Field( + ..., description="The name of the product???" + ), +) -> str: + return product_name + + +# Annotated[] takes precedence over Field() properties +@tool(desc="A function that accepts an annotated Pydantic Field") +def func_takes_pydantic_field_annotated_name_and_description( + product_name: Annotated[str, "ProductName", "The name of the product"] = Field( + ..., title="The name of the product???" + ), +) -> str: + return product_name + + +@tool(desc="A function that accepts a Pydantic Field with a default value") +def func_takes_pydantic_field_default( + product_name: str = Field(description="The name of the product", default="Product 1"), +) -> str: + return product_name + + +@tool(desc="A function that accepts a Pydantic Field with a default value factory") +def func_takes_pydantic_field_default_factory( + product_name: str = Field( + ..., description="The name of the product", default_factory=lambda: "Product 1" + ), +) -> str: + return product_name + + # TODO: Function that takes a Pydantic model as an argument: break it down into components? Look at OpenAPI, do they represent nested arguments? -# TODO: Function that takes a Pydantic Field as an argument -# TODO: Pydantic Field() properties: description, default, title, default_factory, nullable -# TODO: Pydantic Field() properties stretch goal: gt, ge, lt, le, multiple_of, range, regex, max_length, min_length, max_items, min_items, unique_items, exclusive_maximum, exclusive_minimum +# TODO: Should title and default_value be added to JSON schema? +# TODO: Pydantic Field() properties stretch goal: gt, ge, lt, le, multiple_of, range, regex, max_length, min_length, max_items, min_items, unique_items, exclusive_maximum, exclusive_minimum, title? + + +### A complex, real-world example +class ProductFilter(BaseModel): + column: str = Field(..., description="The column to filter on") + + +class FilterRating(ProductFilter): + greater_than: int = Field(..., description="The rating to filter greater than", gt=0, lt=5) + + +class FilterPriceGreaterThan(ProductFilter): + price: int = Field(..., description="The price to filter greater than", gt=0) + + +class FilterPriceLessThan(ProductFilter): + price: int = Field(..., description="The price to filter less than", gt=0) + + +class ProductSearch(BaseModel): + column: str = Field("Product Name", description="The column to search in") + query: str = Field(..., description="The query to search for") + filter_operation: Union[FilterRating, FilterPriceGreaterThan, FilterPriceLessThan] = None + + +class ProductOutput(BaseModel): + product_name: str = Field(..., description="The name of the product") + price: int = Field(..., description="The price of the product") + stock_quantity: int = Field(..., description="The stock quantity of the product") + + +@tool +def read_products( + action: Annotated[ProductSearch, "The search query to perform"], + cols: list[str] = Field( + ..., + description="The columns to return", + default_factory=lambda: ["Product Name", "Price", "Stock Quantity"], + ), +) -> Annotated[list[ProductOutput], "Data with the selected columns"]: + """Used to search through products by name and filter by rating or price.""" + + pass @pytest.mark.parametrize( @@ -46,9 +140,139 @@ def func_returns_pydantic_model() -> Annotated[ProductOutput, "The product, pric }, id="func_returns_pydantic_model", ), + pytest.param( + func_takes_pydantic_field_with_description, + { + "inputs": ToolInputs( + parameters=[ + InputParameter( + name="product_name", + description="The name of the product", + required=True, + inferrable=True, + value_schema=ValueSchema(val_type="string", enum=None), + ) + ] + ) + }, + id="func_takes_pydantic_field_with_description", + ), + pytest.param( + func_takes_pydantic_field_optional, + { + "inputs": ToolInputs( + parameters=[ + InputParameter( + name="product_name", + description="The name of the product", + required=False, + inferrable=True, + value_schema=ValueSchema(val_type="string", enum=None), + ) + ] + ) + }, + id="func_takes_pydantic_field_optional", + ), + pytest.param( + func_takes_pydantic_field_annotated_description, + { + "inputs": ToolInputs( + parameters=[ + InputParameter( + name="product_name", + description="The name of the product", # Annotated[] takes precedence over Field() properties + required=True, + inferrable=True, + value_schema=ValueSchema(val_type="string", enum=None), + ) + ] + ) + }, + id="func_takes_pydantic_field_annotated_description", + ), + pytest.param( + func_takes_pydantic_field_annotated_name_and_description, + { + "inputs": ToolInputs( + parameters=[ + InputParameter( + name="ProductName", + description="The name of the product", # Annotated[] takes precedence over Field() properties + required=True, + inferrable=True, + value_schema=ValueSchema(val_type="string", enum=None), + ) + ] + ) + }, + id="func_takes_pydantic_field_annotated_name_and_description", + ), + pytest.param( + func_takes_pydantic_field_default, + { + "inputs": ToolInputs( + parameters=[ + InputParameter( + name="product_name", + description="The name of the product", + required=False, # Because it has a default value + inferrable=True, + value_schema=ValueSchema(val_type="string", enum=None), + ) + ] + ), + }, + id="func_takes_pydantic_field_default", + ), + pytest.param( + func_takes_pydantic_field_default_factory, + { + "inputs": ToolInputs( + parameters=[ + InputParameter( + name="product_name", + description="The name of the product", + required=False, # Because it has a default value factory + inferrable=True, + value_schema=ValueSchema(val_type="string", enum=None), + ) + ] + ), + }, + id="func_takes_pydantic_field_default_factory", + ), + pytest.param( + read_products, + { + "inputs": ToolInputs( + parameters=[ + InputParameter( + name="action", + description="The search query to perform", + required=True, + inferrable=True, + value_schema=ValueSchema(val_type="json", enum=None), + ), + InputParameter( + name="cols", + description="The columns to return", + required=False, + value_schema=ValueSchema(val_type="json", enum=None), + ), + ] + ), + "output": ToolOutput( + value_schema=ValueSchema(val_type="json", enum=None), + available_modes=["value", "error"], + description="Data with the selected columns", + ), + }, + id="read_products", + ), ], ) -def test_create_tool_def(func_under_test, expected_tool_def_fields): +def test_create_tool_def_from_pydantic(func_under_test, expected_tool_def_fields): tool_def = ToolCatalog.create_tool_definition(func_under_test, "1.0") assert tool_def.version == "1.0" diff --git a/arcade/tests/tool/test_create_tool_definition_pydantic_errors.py b/arcade/tests/tool/test_create_tool_definition_pydantic_errors.py new file mode 100644 index 00000000..405b3d66 --- /dev/null +++ b/arcade/tests/tool/test_create_tool_definition_pydantic_errors.py @@ -0,0 +1,36 @@ +from typing import Annotated + +import pytest +from pydantic import Field + +from arcade.sdk.tool import tool +from arcade.tool.catalog import ToolCatalog +from arcade.tool.errors import ToolDefinitionError + + +@tool +def field_with_literal_default_factory( + cols: list[str] = Field( + ..., + description="The columns to return", + default_factory=["Product Name", "Price", "Stock Quantity"], + ), +) -> Annotated[str, "Data with the selected columns"]: + """Used to search through products by name and filter by rating or price.""" + + pass + + +@pytest.mark.parametrize( + "func_under_test, exception_type", + [ + pytest.param( + field_with_literal_default_factory, + ToolDefinitionError, + id=field_with_literal_default_factory.__name__, + ), + ], +) +def test_missing_info_raises_error(func_under_test, exception_type): + with pytest.raises(exception_type): + ToolCatalog.create_tool_definition(func_under_test, "1.0") diff --git a/examples/generic/pack.lock.toml b/examples/generic/pack.lock.toml index aae3a4de..cc997ab6 100644 --- a/examples/generic/pack.lock.toml +++ b/examples/generic/pack.lock.toml @@ -9,9 +9,10 @@ email = "sam@partee.io" [tools] TextSearch = "BM25.text_search@0.0.1" -ReadProducts = "products.read_products@0.0.1" +ReadProducts = "products.read_products@latest" ReadSqlite = "read_sqlite.read_sqlite@0.0.1" SendEmail = "gmail.send_email@0.0.1" ReadEmail = "gmail.read_email@0.0.1" OauthReadEmail = "gmail.oauth_read_email@0.0.1" ListDriveFiles = "gmail.list_drive_files@0.0.1" +SearchEmployee = "people.search_employee@0.1.0" diff --git a/examples/generic/pack.toml b/examples/generic/pack.toml index 08081482..0779ac4a 100644 --- a/examples/generic/pack.toml +++ b/examples/generic/pack.toml @@ -11,4 +11,4 @@ email = "sam@partee.io" gmail = "0.0.1" read_sqlite = "0.0.1" BM25 = "0.0.1" -products = "0.0.1" \ No newline at end of file +people = "0.1.0" diff --git a/examples/generic/tools/products.py b/examples/generic/tools/products.py index 3fd67c73..7ba2aef7 100644 --- a/examples/generic/tools/products.py +++ b/examples/generic/tools/products.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import Union, Annotated, Literal from arcade.sdk.tool import tool, get_secret import pandas as pd from pydantic import BaseModel, Field @@ -23,11 +23,18 @@ class FilterPriceLessThan(ProductFilter): class ProductSearch(BaseModel): + """The search action to perform""" + column: str = Field("Product Name", description="The column to search in") + """the column to search in""" + query: str = Field(..., description="The query to search for") + """the query to search for""" + filter_operation: Union[ FilterRating, FilterPriceGreaterThan, FilterPriceLessThan ] = None + """The filter operation to perform""" class ProductOutput(BaseModel): @@ -38,13 +45,11 @@ class ProductOutput(BaseModel): @tool def read_products( - action: ProductSearch, - cols: list[str] = [ - "Product Name", - "Price", - "Stock Quantity", - ], -) -> list[ProductOutput]: + action: Annotated[ProductSearch, "The search action to perform"], + cols: Annotated[ + Literal["Product Name", "Price", "Stock Quantity"], "The columns to return" + ] = ["Product Name", "Price", "Stock Quantity"], +) -> Annotated[list[ProductOutput], "The list of products matching the search"]: """Used to search through products by name and filter by rating or price.""" file_path = get_secret( diff --git a/schemas/preview/tool_definition.schema.jsonc b/schemas/preview/tool_definition.schema.jsonc index 5e6343c6..38797326 100644 --- a/schemas/preview/tool_definition.schema.jsonc +++ b/schemas/preview/tool_definition.schema.jsonc @@ -4,13 +4,7 @@ "primitives": { // All supported primitive data types "type": "string", - "enum": [ - "string", - "integer", - "float", - "boolean", - "json" - ] + "enum": ["string", "integer", "float", "boolean", "json"] }, "value_schema": { // Represents a value schema (e.g. function input parameter) @@ -20,9 +14,7 @@ "$ref": "#/$defs/primitives" } }, - "required": [ - "type" - ], + "required": ["type"], "additionalProperties": false, "if": { "properties": { @@ -94,18 +86,12 @@ "default": true } }, - "required": [ - "name", - "required", - "schema" - ], + "required": ["name", "required", "schema"], "additionalProperties": false } } }, - "required": [ - "parameters" - ], + "required": ["parameters"], "additionalProperties": false }, "output": { @@ -116,13 +102,7 @@ "minItems": 1, "items": { "type": "string", - "enum": [ - "value", - "error", - "null", - "artifact", - "requires_authorization" - ] + "enum": ["value", "error", "null", "artifact", "requires_authorization"] } }, "value": { @@ -135,15 +115,11 @@ "$ref": "#/$defs/value_schema" } }, - "required": [ - "schema" - ], + "required": ["schema"], "additionalProperties": false } }, - "required": [ - "available_modes" - ], + "required": ["available_modes"], "additionalProperties": false }, "requirements": { @@ -153,10 +129,7 @@ "oneOf": [ { "type": "string", - "enum": [ - "none", - "token" - ] + "enum": ["none", "token"] }, { "type": "object", @@ -172,15 +145,11 @@ "type": "string" } }, - "required": [ - "url" - ], + "required": ["url"], "additionalProperties": false } }, - "required": [ - "oauth2" - ], + "required": ["oauth2"], "additionalProperties": false } ] @@ -188,11 +157,6 @@ } } }, - "required": [ - "name", - "version", - "input", - "output" - ], + "required": ["name", "version", "input", "output"], "additionalProperties": false } diff --git a/schemas/preview/tool_request.schema.jsonc b/schemas/preview/tool_request.schema.jsonc index 5c60550c..0599e8bc 100644 --- a/schemas/preview/tool_request.schema.jsonc +++ b/schemas/preview/tool_request.schema.jsonc @@ -68,13 +68,6 @@ } } }, - "required": [ - "run_id", - "invocation_id", - "created_at", - "tool", - "input", - "context" - ], + "required": ["run_id", "invocation_id", "created_at", "tool", "input", "context"], "additionalProperties": false }