MyPy Compliant (#5)

MyPy compliance for the whole codebase

- systematic way of executing tools (`executor.py`)
- support for using pydantic models in tool inputs and outputs
- mypy compliance (most of the changes)
- removal of unused code (from previous iterations)

Co-authored-by: Nate Barbettini <nate@arcade-ai.com>
This commit is contained in:
Sam Partee 2024-07-16 17:01:38 -07:00 committed by GitHub
parent 7f3abfd1f9
commit 28fe56cfc1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 1015 additions and 947 deletions

View file

@ -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_style = space
indent_size = 4 indent_size = 4
max_line_length = 120
[*.{json,jsonc,yml,yaml}]
indent_style = space
indent_size = 2 # This is also set in .prettierrc.toml

View file

@ -13,10 +13,5 @@ repos:
rev: "v0.1.6" rev: "v0.1.6"
hooks: hooks:
- id: ruff - id: ruff
args: [--exit-non-zero-on-fix] args: [--fix]
- id: ruff-format - id: ruff-format
- repo: https://github.com/pre-commit/mirrors-prettier
rev: "v3.0.3"
hooks:
- id: prettier

11
.prettierrc.toml Normal file
View file

@ -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

View file

@ -13,9 +13,9 @@ Report bugs at https://github.com/spartee/arcade-ai/issues
If you are reporting a bug, please include: If you are reporting a bug, please include:
- Your operating system name and version. - Your operating system name and version.
- Any details about your local setup that might be helpful in troubleshooting. - Any details about your local setup that might be helpful in troubleshooting.
- Detailed steps to reproduce the bug. - Detailed steps to reproduce the bug.
## Fix Bugs ## 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: If you are proposing a new feature:
- Explain in detail how it would work. - Explain in detail how it would work.
- Keep the scope as narrow as possible, to make it easier to implement. - Keep the scope as narrow as possible, to make it easier to implement.
- Remember that this is a volunteer-driven project, and that contributions - Remember that this is a volunteer-driven project, and that contributions
are welcome :) are welcome :)
# Get Started! # Get Started!

View file

@ -13,8 +13,6 @@ check: ## Run code quality tools.
@poetry run pre-commit run -a @poetry run pre-commit run -a
@echo "🚀 Static type checking: Running mypy" @echo "🚀 Static type checking: Running mypy"
@poetry run mypy @poetry run mypy
@echo "🚀 Checking for obsolete dependencies: Running deptry"
@poetry run deptry .
.PHONY: test .PHONY: test
test: ## Test the code with pytest test: ## Test the code with pytest

View file

@ -1 +0,0 @@
#!/usr/bin/env python3

View file

@ -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"})

View file

@ -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 <https://github.com/encode/starlette/issues/1175>`_
: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

View file

@ -1,4 +1,3 @@
#!/usr/bin/env python3
from __future__ import annotations from __future__ import annotations
import os import os
@ -16,7 +15,9 @@ if TYPE_CHECKING:
class Logger: class Logger:
def __init__(self): """Logger for the Actor server"""
def __init__(self) -> None:
self.log_path = actor_log_path self.log_path = actor_log_path
def log(self) -> loguru.Logger: def log(self) -> loguru.Logger:
@ -36,19 +37,19 @@ class Logger:
logger.add( logger.add(
log_stdout_file, log_stdout_file,
level="INFO", level="INFO",
filter=lambda record: record["level"].name == "INFO" or record["level"].no <= 25, filter=lambda record: record["level"].name == "INFO" or record["level"].no <= 25, # type: ignore[call-overload]
**log_config,
backtrace=False, backtrace=False,
diagnose=False, diagnose=False,
**log_config,
) )
# stderr # stderr
logger.add( logger.add(
log_stderr_file, log_stderr_file,
level="ERROR", level="ERROR",
filter=lambda record: record["level"].name == "ERROR" or record["level"].no >= 30, filter=lambda record: record["level"].name == "ERROR" or record["level"].no >= 30, # type: ignore[call-overload]
**log_config,
backtrace=True, backtrace=True,
diagnose=True, diagnose=True,
**log_config,
) )
return logger return logger

View file

@ -59,7 +59,7 @@ class ResponseBase:
@staticmethod @staticmethod
async def __response( async def __response(
*, *,
res: CustomResponseCode | CustomResponse = None, res: CustomResponseCode | CustomResponse = CustomResponseCode.HTTP_200,
msg: str | None = None, msg: str | None = None,
data: Any | None = None, data: Any | None = None,
) -> ResponseModel: ) -> ResponseModel:

View file

@ -1,28 +1,28 @@
#!/usr/bin/env python3
import dataclasses import dataclasses
from enum import Enum from enum import Enum
from typing import Any
class CustomCodeBase(Enum): class CustomCodeBase(Enum):
"""自定义状态码基类""" """Custom status code base class"""
@property @property
def code(self): def code(self) -> Any:
""" """
获取状态码 Get status code
""" """
return self.value[0] return self.value[0]
@property @property
def msg(self): def msg(self) -> Any:
""" """
获取状态码信息 Get status code information
""" """
return self.value[1] return self.value[1]
class CustomResponseCode(CustomCodeBase): class CustomResponseCode(CustomCodeBase):
"""自定义响应状态码""" """Custom response status codes"""
HTTP_200 = (200, "Request Successful") HTTP_200 = (200, "Request Successful")
HTTP_201 = (201, "Created Successfully") HTTP_201 = (201, "Created Successfully")
@ -42,12 +42,6 @@ class CustomResponseCode(CustomCodeBase):
HTTP_504 = (504, "Gateway Timeout") HTTP_504 = (504, "Gateway Timeout")
class CustomErrorCode(CustomCodeBase):
"""自定义错误状态码"""
CAPTCHA_ERROR = (40001, "CAPTCHA Error")
@dataclasses.dataclass @dataclasses.dataclass
class CustomResponse: class CustomResponse:
""" """

View file

@ -11,7 +11,7 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env") model_config = SettingsConfigDict(env_file=".env")
WORK_DIR: Path = Path.home() / ".arcade" WORK_DIR: Path = Path.home() / ".arcade"
TOOLS_DIR: Path = os.getcwd() TOOLS_DIR: Path = Path(os.getcwd())
# Env Config # Env Config
ENVIRONMENT: Literal["dev", "pro"] = "dev" ENVIRONMENT: Literal["dev", "pro"] = "dev"
@ -60,12 +60,9 @@ class Settings(BaseSettings):
@lru_cache @lru_cache
def get_settings(): def get_settings() -> Settings:
try: # TODO allow user to specify env file path as a Env Var
env_path = Path(os.environ["TOOLSERVE_ENV"]) return Settings()
except KeyError:
env_path = Path(__file__).parent.parent / ".env"
return Settings(_env_file=env_path)
settings = get_settings() settings = get_settings()

View file

@ -1,5 +1,8 @@
from starlette.requests import Request 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]

View file

@ -1,39 +1,64 @@
import traceback 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 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.actor.core.conf import settings
from arcade.tool.catalog import ToolDefinition from arcade.tool.catalog import MaterializedTool
from arcade.utils import snake_to_pascal_case 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. 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: try:
# Execute the action # get the body of the request without parsing and validating it
result = await func(**body.dict()) # as the executor will do that
return await response_base.success(data={"result": result}) 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: 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: except Exception as e:
print(traceback.format_exc()) return await tool_response.fail(
return await response_base.error(res=CustomResponseCode.HTTP_500, msg=str(e)) msg=str(e),
data=traceback.format_exc(),
)
return response
run.__name__ = name run.__name__ = name
run.__doc__ = description 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 = [] routers = []
top_level_router = APIRouter(prefix=settings.API_ACTION_STR) top_level_router = APIRouter(prefix=settings.API_ACTION_STR)
@ -44,7 +69,7 @@ def generate_endpoint(schemas: list[ToolDefinition]) -> APIRouter:
# Create the endpoint function # Create the endpoint function
run = create_endpoint_function( run = create_endpoint_function(
name=snake_to_pascal_case(define.name), name=define.name,
description=define.description, description=define.description,
func=schema.tool, func=schema.tool,
input_model=schema.input_model, input_model=schema.input_model,
@ -53,33 +78,17 @@ def generate_endpoint(schemas: list[ToolDefinition]) -> APIRouter:
# Add the endpoint to the FastAPI app # Add the endpoint to the FastAPI app
router.post( router.post(
f"/{snake_to_pascal_case(define.name)}", f"/{define.name}", # Note: Names from the ToolCatalog are already in PascalCase
name=snake_to_pascal_case(define.name), name=define.name,
summary=define.description, summary=define.description,
tags=[schema.meta.module], 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_unset=True,
response_model_exclude_none=True, response_model_exclude_none=True,
response_description=create_output_description(schema.output_model),
)(run) )(run)
routers.append(router) routers.append(router)
for router in routers: for router in routers:
top_level_router.include_router(router) top_level_router.include_router(router)
return top_level_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

View file

@ -1,24 +1,13 @@
#!/usr/bin/env python3
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from arcade.actor.common.serializers import MsgSpecJSONResponse from arcade.actor.common.serializers import MsgSpecJSONResponse
from arcade.actor.core.conf import settings from arcade.actor.core.conf import settings
from arcade.actor.core.generate import generate_endpoint
from arcade.actor.routes import v1 from arcade.actor.routes import v1
from arcade.tool.catalog import ToolCatalog
@asynccontextmanager def register_app() -> FastAPI:
async def register_init(app: FastAPI):
"""
:return:
"""
# eventually lifecycle hooks will be added here
yield
def register_app():
# FastAPI # FastAPI
app = FastAPI( app = FastAPI(
title=settings.TITLE, title=settings.TITLE,
@ -28,7 +17,6 @@ def register_app():
redoc_url=settings.REDOCS_URL, redoc_url=settings.REDOCS_URL,
openapi_url=settings.OPENAPI_URL, openapi_url=settings.OPENAPI_URL,
default_response_class=MsgSpecJSONResponse, default_response_class=MsgSpecJSONResponse,
lifespan=register_init,
) )
register_static_file(app) register_static_file(app)
@ -37,19 +25,16 @@ def register_app():
register_router(app) register_router(app)
# register_exception(app) generate_tool_routes(app)
generate_actions_routers(app)
return 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: if settings.STATIC_FILES:
import os import os
@ -60,11 +45,9 @@ def register_static_file(app: FastAPI):
app.mount("/static", StaticFiles(directory="static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
def register_middleware(app: FastAPI): def register_middleware(app: FastAPI) -> None:
""" """
Register middleware for the FastAPI app
:param app:
:return:
""" """
# Gzip: Always at the top # Gzip: Always at the top
if settings.MIDDLEWARE_GZIP: if settings.MIDDLEWARE_GZIP:
@ -85,12 +68,9 @@ def register_middleware(app: FastAPI):
) )
def register_router(app: FastAPI): def register_router(app: FastAPI) -> None:
""" """
路由 Register routers for the FastAPI app
:param app: FastAPI
:return:
""" """
dependencies = None dependencies = None
@ -98,16 +78,13 @@ def register_router(app: FastAPI):
app.include_router(v1, dependencies=dependencies) app.include_router(v1, dependencies=dependencies)
def generate_actions_routers(app: FastAPI): def generate_tool_routes(app: FastAPI) -> None:
""" """
Generate tool routes for each tool in the catalog
:param app: FastAPI Add the routes to the FastAPI app and the tool
:return: definitions to the catalog
""" """
from arcade.actor.core.generate import generate_endpoint
from arcade.tool.catalog import ToolCatalog
catalog = ToolCatalog() catalog = ToolCatalog()
router = generate_endpoint(catalog.tools.values()) router = generate_endpoint(list(catalog.tools.values()))
app.include_router(router) app.include_router(router)
app.state.catalog = catalog app.state.catalog = catalog

View file

@ -1,3 +1,5 @@
from typing import TYPE_CHECKING
from fastapi import APIRouter, Body, Depends, Query from fastapi import APIRouter, Body, Depends, Query
from pydantic import ValidationError 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.actor.core.depends import get_catalog
from arcade.tool.openai import schema_to_openai_tool from arcade.tool.openai import schema_to_openai_tool
if TYPE_CHECKING:
from arcade.tool.catalog import ToolCatalog
router = APIRouter() router = APIRouter()
@ -13,7 +18,7 @@ router = APIRouter()
"/list", "/list",
summary="List available tools", 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""" """List all available tools"""
tools = catalog.list_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") @router.get("/json", summary="Get the JSON (openai) format of a tool")
async def get_oai_function( async def get_oai_function(
tool_name: str = Query(..., title="Tool Name", description="The name of the tool"), tool_name: str = Query(..., title="Tool Name", description="The name of the tool"),
catalog=Depends(get_catalog), catalog: "ToolCatalog" = Depends(get_catalog),
) -> ResponseModel: ) -> ResponseModel:
"""Get the OpenAI function format of an tool""" """Get the OpenAI function format of an tool"""
try: try:
# TODO handle keyerror
tool = catalog[tool_name] tool = catalog[tool_name]
json_data = schema_to_openai_tool(tool) json_data = schema_to_openai_tool(tool)
return await response_base.success(data=json_data) 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: except ValidationError as e:
return await response_base.fail(res=CustomResponseCode.HTTP_400, data=str(e)) return await response_base.fail(res=CustomResponseCode.HTTP_400, data=str(e))
except Exception as e: except Exception as e:
@ -45,13 +55,21 @@ async def execute_tool(
data: dict[str, str] = Body( data: dict[str, str] = Body(
..., title="Tool Data", description="The data to execute the tool with" ..., title="Tool Data", description="The data to execute the tool with"
), ),
catalog=Depends(get_catalog), catalog: "ToolCatalog" = Depends(get_catalog),
) -> ResponseModel: ) -> ResponseModel:
"""Execute a tool""" """Execute a tool"""
try: try:
# TODO use executor and error handling
tool = catalog.get_tool(tool_name) 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) return await response_base.success(data=result)
except ValidationError as e: except ValidationError as e:
return await response_base.fail(res=CustomResponseCode.HTTP_400, data=str(e)) return await response_base.fail(res=CustomResponseCode.HTTP_400, data=str(e))

View file

@ -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)

View file

@ -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()

View file

@ -8,6 +8,8 @@ from pydantic import BaseModel, EmailStr
class PackInfo(BaseModel): class PackInfo(BaseModel):
"""Package Manager-esk info about a pack of tools."""
name: str name: str
description: str description: str
version: str version: str
@ -16,11 +18,15 @@ class PackInfo(BaseModel):
class ToolPack(BaseModel): class ToolPack(BaseModel):
"""A package of tools and their dependencies."""
pack: PackInfo pack: PackInfo
depends: Optional[dict[str, str]] = None 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" lock_file = Path(pack_dir) / "pack.lock.toml"
pack_dict = self.dict(by_alias=True, exclude_none=True) pack_dict = self.dict(by_alias=True, exclude_none=True)
pack_ordered_dict = { pack_ordered_dict = {
@ -39,7 +45,9 @@ class ToolPack(BaseModel):
f.write(tomlkit.dumps(doc)) f.write(tomlkit.dumps(doc))
@classmethod @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() pack_dir = Path(pack_dir).resolve()
lock_file = pack_dir / "pack.lock.toml" lock_file = pack_dir / "pack.lock.toml"
with open(lock_file) as f: with open(lock_file) as f:

View file

@ -1,5 +1,4 @@
import os import os
import shutil
from pathlib import Path from pathlib import Path
from typing import Union from typing import Union
@ -29,10 +28,13 @@ class Packer:
raise ValueError(f"Invalid 'pack.toml' format: {e}") raise ValueError(f"Invalid 'pack.toml' format: {e}")
self.tools = self.load_tools() self.tools = self.load_tools()
self.depends = {} # TODO self.depends: dict[str, str] = {} # TODO
self.packs = [] # TODO # self.packs = [] # TODO
def load_tools(self) -> dict[str, str]: def load_tools(self) -> dict[str, str]:
"""
Find and load the from the tools defined within directory
"""
tools = {} tools = {}
for tool_file in self.tools_dir.rglob("*.py"): for tool_file in self.tools_dir.rglob("*.py"):
if "__init__.py" in tool_file.name: if "__init__.py" in tool_file.name:
@ -49,21 +51,11 @@ class Packer:
print(f"Error loading tool from {tool_file}: {e}") print(f"Error loading tool from {tool_file}: {e}")
return tools return tools
def _create_pack_dir(self, pack: ToolPack) -> Path: def create_pack(self) -> None:
# Make "packs" directory if it doesn't exist """
packs_dir = self.pack_dir / "packs" Create a tool pack
os.makedirs(packs_dir, exist_ok=True) """
# make the dir for the action pack and the version (making parent dirs if needed) if not self.tools:
top_pack_dir = packs_dir / pack.pack.name / pack.pack.version raise ValueError("No tools found in the tools directory")
# 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
pack = ToolPack(pack=self.pack, depends=self.depends, tools=self.tools) 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) pack.write_lock_file(self.pack_dir)

View file

@ -2,17 +2,16 @@ import ast
import importlib.metadata import importlib.metadata
import importlib.util import importlib.util
import sys import sys
from typing import Optional from pathlib import Path
from typing import Optional, Union
from stdlib_list import stdlib_list 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. 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: try:
with open(filepath) as file: with open(filepath) as file:
@ -24,8 +23,6 @@ def load_ast_tree(filepath: str) -> ast.AST:
def get_python_version() -> str: def get_python_version() -> str:
""" """
Get the current Python version. Get the current Python version.
:return: The version of Python in use.
""" """
return f"{sys.version_info.major}.{sys.version_info.minor}" 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]]: def retrieve_imported_libraries(tree: ast.AST) -> dict[str, Optional[str]]:
""" """
Retrieve non-standard libraries imported in the AST. 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 = {} libraries = {}
python_version = get_python_version() 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): for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom): if isinstance(node, ast.ImportFrom):
package_name = node.module.split(".")[0] if node.module else None 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 continue
try:
package_version = importlib.metadata.version(package_name)
except importlib.metadata.PackageNotFoundError:
package_version = None
libraries[package_name] = package_version libraries[package_name] = package_version
return libraries 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. Check if a function has a decorator
:param node: The function definition node from the AST.
:return: The name of the function if it has the specified decorators, otherwise None.
""" """
decorator_ids = {"toolserve.tool", "tool"} decorator_ids = {"ar.tool", "tool"}
for decorator in node.decorator_list: for decorator in node.decorator_list:
if isinstance(decorator, ast.Name) and decorator.id in decorator_ids: if isinstance(decorator, ast.Name) and decorator.id in decorator_ids:
return node.name return node.name
return None 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". Retrieve tools from a Python file.
:param filepath: Path to the Python file.
:return: List of function names.
""" """
tree = load_ast_tree(filepath) tree = load_ast_tree(filepath)
tools = [] tools = []

View file

@ -14,12 +14,16 @@ console = Console()
@cli.command(help="Starts the ToolServer with specified configurations.") @cli.command(help="Starts the ToolServer with specified configurations.")
def serve( def serve(
host: str = typer.Option( 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( 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 Starts the actor with host, port, and reload options. Uses
Uvicorn as ASGI actor. Parameters allow runtime configuration. Uvicorn as ASGI actor. Parameters allow runtime configuration.
@ -44,7 +48,7 @@ def serve(
@cli.command(help="Build a new Tool Pack") @cli.command(help="Build a new Tool Pack")
def pack( def pack(
directory: str = typer.Option(os.getcwd(), "--dir", help="tools directory path with pack.toml"), 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. Creates a new tool pack with the given name, description, and result type.
""" """

View file

@ -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

View file

@ -1,12 +1,13 @@
import os import os
from typing import Any, Callable, Optional, TypeVar, Union 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 from arcade.utils import snake_to_pascal_case
T = TypeVar("T") T = TypeVar("T")
# TODO change desc to description
def tool( def tool(
func: Callable | None = None, func: Callable | None = None,
desc: str | None = None, desc: str | None = None,
@ -14,9 +15,12 @@ def tool(
requires_auth: Union[ToolAuthorizationRequirement, None] = None, requires_auth: Union[ToolAuthorizationRequirement, None] = None,
) -> Callable: ) -> Callable:
def decorator(func: Callable) -> Callable: def decorator(func: Callable) -> Callable:
func.__tool_name__ = name or snake_to_pascal_case(getattr(func, "__name__", None)) func_name = str(getattr(func, "__name__", None))
func.__tool_description__ = desc or func.__doc__ tool_name = name or snake_to_pascal_case(func_name)
func.__tool_requires_auth__ = requires_auth
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 return func
@ -25,7 +29,7 @@ def tool(
return decorator 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) secret = os.getenv(name)
if secret is None: if secret is None:
if default is not None: if default is not None:

View file

@ -1,11 +1,14 @@
import asyncio import asyncio
import inspect import inspect
import sys import sys
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from importlib import import_module from importlib import import_module
from pathlib import Path from pathlib import Path
from typing import ( from typing import (
Annotated, Annotated,
Any,
Callable, Callable,
Literal, Literal,
Optional, Optional,
@ -16,14 +19,14 @@ from typing import (
) )
from pydantic import BaseModel, Field, create_model 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.actor.core.conf import settings
from arcade.apm.base import ToolPack from arcade.apm.base import ToolPack
from arcade.sdk.annotations import Inferrable from arcade.sdk.annotations import Inferrable
from arcade.sdk.errors import ToolDefinitionError from arcade.tool.errors import ToolDefinitionError
from arcade.sdk.schemas import ( from arcade.tool.schemas import (
InputParameter, InputParameter,
ToolDefinition, ToolDefinition,
ToolInputs, ToolInputs,
@ -38,8 +41,14 @@ from arcade.utils import (
snake_to_pascal_case, snake_to_pascal_case,
) )
WireType = Literal["string", "integer", "float", "boolean", "json"]
class ToolMeta(BaseModel): class ToolMeta(BaseModel):
"""
Metadata for a tool once it's been materialized.
"""
module: str module: str
path: Optional[str] = None path: Optional[str] = None
date_added: datetime = Field(default_factory=datetime.now) date_added: datetime = Field(default_factory=datetime.now)
@ -47,6 +56,10 @@ class ToolMeta(BaseModel):
class MaterializedTool(BaseModel): class MaterializedTool(BaseModel):
"""
Data structure that holds tool information while stored in the Catalog
"""
tool: Callable tool: Callable
definition: ToolDefinition definition: ToolDefinition
meta: ToolMeta meta: ToolMeta
@ -68,12 +81,21 @@ class MaterializedTool(BaseModel):
return self.definition.description return self.definition.description
# TODO make a generate for catalog type
class ToolCatalog: class ToolCatalog:
def __init__(self, tools_dir: str = settings.TOOLS_DIR): """Singleton class that holds all tools for a given actor"""
self.tools = self.read_tools(tools_dir)
def __init__(self, tools_dir: Path = settings.TOOLS_DIR):
self.tools: dict[str, MaterializedTool] = self.read_tools(tools_dir)
@staticmethod @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) toolpack = ToolPack.from_lock_file(directory)
sys.path.append(str(Path(directory).resolve() / "tools")) sys.path.append(str(Path(directory).resolve() / "tools"))
@ -85,9 +107,7 @@ class ToolCatalog:
module = import_module(module_name) module = import_module(module_name)
tool_func = getattr(module, func_name) tool_func = getattr(module, func_name)
input_model, output_model = create_func_models(tool_func) input_model, output_model = create_func_models(tool_func)
tool_name = snake_to_pascal_case( tool_name = name
name
) # TODO make sure this follows create_tool_definition
tools[tool_name] = MaterializedTool( tools[tool_name] = MaterializedTool(
definition=ToolCatalog.create_tool_definition(tool_func, version), definition=ToolCatalog.create_tool_definition(tool_func, version),
tool=tool_func, tool=tool_func,
@ -100,6 +120,10 @@ class ToolCatalog:
@staticmethod @staticmethod
def create_tool_definition(tool: Callable, version: str) -> ToolDefinition: def create_tool_definition(tool: Callable, version: str) -> ToolDefinition:
"""
Given a tool function, create a ToolDefinition
"""
tool_name = getattr(tool, "__tool_name__", tool.__name__) tool_name = getattr(tool, "__tool_name__", tool.__name__)
# Hard requirement: tools must have descriptions # 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 # TODO error handling
for tool_name, tool in self.tools.items(): for tool_name, tool in self.tools.items():
if tool_name == name: if tool_name == name:
return tool 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() yield from self.tools.values()
def get_tool(self, name: str) -> Optional[Callable]: def get_tool(self, name: str) -> Optional[Callable]:
for _, tool in self: for tool in self.tools.values():
if tool.definition.name == name: if tool.definition.name == name:
return tool.tool return tool.tool
raise ValueError(f"Tool {name} not found.") raise ValueError(f"Tool {name} not found.")
@ -159,31 +183,29 @@ def create_input_definition(func: Callable) -> ToolInputs:
""" """
input_parameters = [] input_parameters = []
for _, param in inspect.signature(func, follow_wrapped=True).parameters.items(): for _, param in inspect.signature(func, follow_wrapped=True).parameters.items():
field_info = extract_field_info(param) tool_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"
)
is_enum = False is_enum = False
enum_values: list[str] = [] enum_values: list[str] = []
# Special case: Literal["string1", "string2"] can be enumerated on the wire # 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 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( input_parameters.append(
InputParameter( InputParameter(
name=field_info["field_params"]["name"], name=tool_field_info.name,
description=field_info["field_params"]["description"], description=tool_field_info.description,
required=field_info["field_params"]["default"] is None required=is_required,
and not field_info["field_params"]["optional"], inferrable=tool_field_info.is_inferrable,
inferrable=field_info["field_params"]["inferrable"],
value_schema=ValueSchema( 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, 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. 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 annotation = param.annotation
if annotation == inspect.Parameter.empty: 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__", []) metadata = getattr(annotation, "__metadata__", [])
name = param.name
description = None
str_annotations = [m for m in metadata if isinstance(m, str)] 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: elif len(str_annotations) == 2:
name = str_annotations[0] param_info.name = str_annotations[0]
description = str_annotations[1] param_info.description = str_annotations[1]
else: 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 # Otherwise, use the literal type
annotation = param.annotation
original_type = annotation.__args__[0] if get_origin(annotation) is Annotated else annotation original_type = annotation.__args__[0] if get_origin(annotation) is Annotated else annotation
field_type = original_type 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)) field_type = next(arg for arg in get_args(field_type) if arg is not type(None))
is_optional = True 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 = { def extract_pydantic_param_info(param: inspect.Parameter) -> ParamInfo:
"name": name, default_value = None if param.default.default is PydanticUndefined else param.default.default
"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,
}
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( def get_wire_type(
_type: type, _type: type,
) -> Literal["string", "integer", "float", "boolean", "json"]: ) -> WireType:
"""
Mapping between Python types and HTTP/JSON types
"""
type_mapping = { type_mapping = {
str: "string", str: "string",
bool: "boolean", bool: "boolean",
@ -315,19 +432,12 @@ def get_wire_type(
return "json" return "json"
elif issubclass(_type, BaseModel): elif issubclass(_type, BaseModel):
return "json" return "json"
else: raise ToolDefinitionError(f"Unsupported parameter type: {_type}")
raise TypeError(f"Unsupported parameter type: {_type}")
def create_func_models(func: Callable) -> tuple[type[BaseModel], type[BaseModel]]: def create_func_models(func: Callable) -> tuple[type[BaseModel], type[BaseModel]]:
""" """
Analyze a function to create corresponding Pydantic models for its input and output. 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 = {} input_fields = {}
# TODO figure this out (Sam) # TODO figure this out (Sam)
@ -335,16 +445,15 @@ def create_func_models(func: Callable) -> tuple[type[BaseModel], type[BaseModel]
func = func.__wrapped__ func = func.__wrapped__
for name, param in inspect.signature(func, follow_wrapped=True).parameters.items(): for name, param in inspect.signature(func, follow_wrapped=True).parameters.items():
# TODO make this cleaner # TODO make this cleaner
field_info = extract_field_info(param) tool_field_info = extract_field_info(param)
field_data = field_info["field_params"]
param_fields = { param_fields = {
"default": field_data["default"], "default": tool_field_info.default,
"description": field_data["description"], "description": tool_field_info.description,
# TODO more here? # 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) 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]: def determine_output_model(func: Callable) -> type[BaseModel]:
""" """
Determine the output model for a function based on its return annotation. 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 return_annotation = inspect.signature(func).return_annotation
output_model_name = f"{snake_to_pascal_case(func.__name__)}Output" 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) return create_model(output_model_name)
elif hasattr(return_annotation, "__origin__"): elif hasattr(return_annotation, "__origin__"):
if hasattr(return_annotation, "__metadata__"): if hasattr(return_annotation, "__metadata__"):
field_type = Optional[return_annotation.__args__[0]] field_type = return_annotation.__args__[0]
description = ( description = (
return_annotation.__metadata__[0] if return_annotation.__metadata__ else "" return_annotation.__metadata__[0] if return_annotation.__metadata__ else ""
) )
@ -376,32 +479,18 @@ def determine_output_model(func: Callable) -> type[BaseModel]:
output_model_name, output_model_name,
result=(field_type, Field(description=str(description))), result=(field_type, Field(description=str(description))),
) )
else: # when the return_annotation has an __origin__ attribute
return create_model( # and does not have a __metadata__ attribute.
output_model_name, return create_model(
result=( output_model_name,
return_annotation, result=(
Field(description="No description provided."), return_annotation,
), Field(description="No description provided."),
) ),
)
else: else:
# Handle simple return types (like str) # Handle simple return types (like str)
return create_model( return create_model(
output_model_name, output_model_name,
result=(return_annotation, Field(description="No description provided.")), 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

View file

@ -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

View file

@ -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

View file

@ -7,7 +7,7 @@ from pydantic_core import PydanticUndefined
from arcade.tool.catalog import MaterializedTool from arcade.tool.catalog import MaterializedTool
PYTHON_TO_JSON_TYPES = { PYTHON_TO_JSON_TYPES: dict[type, str] = {
str: "string", str: "string",
int: "integer", int: "integer",
float: "number", 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. 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.
""" """
if hasattr(python_type, "__origin__"): if hasattr(python_type, "__origin__"):
origin = 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): elif issubclass(python_type, BaseModel):
return model_to_json_schema(python_type) 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]: def model_to_json_schema(model: type[BaseModel]) -> dict[str, Any]:
""" """
Convert a Pydantic model to a JSON schema. 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 = {} properties = {}
required = [] required = []
for field_name, model_field in model.model_fields.items(): 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): if isinstance(type_json, dict):
field_schema = type_json field_schema = type_json
else: 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) input_model_schema = model_to_json_schema(tool.input_model)
function_schema = { function_schema = {

View file

@ -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()

View file

@ -1,15 +1,21 @@
from abc import ABC from abc import ABC
from typing import Literal, Optional, Union from typing import Literal, Optional, Union
from pydantic import AnyUrl, BaseModel, Field, conlist from pydantic import AnyUrl, BaseModel, Field
class ValueSchema(BaseModel): class ValueSchema(BaseModel):
"""Value schema for input parameters and outputs."""
val_type: Literal["string", "integer", "float", "boolean", "json"] val_type: Literal["string", "integer", "float", "boolean", "json"]
"""The type of the value."""
enum: Optional[list[str]] = None enum: Optional[list[str]] = None
class InputParameter(BaseModel): class InputParameter(BaseModel):
"""A parameter that can be passed to a tool."""
name: str = Field(..., description="The human-readable name of this parameter.") name: str = Field(..., description="The human-readable name of this parameter.")
required: bool = Field( required: bool = Field(
..., ...,
@ -29,20 +35,21 @@ class InputParameter(BaseModel):
class ToolInputs(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): class ToolOutput(BaseModel):
"""The output of a tool."""
description: Optional[str] = Field( description: Optional[str] = Field(
None, description="A descriptive, human-readable explanation of the output." None, description="A descriptive, human-readable explanation of the output."
) )
available_modes: conlist( available_modes: list[str] = Field(
Literal["value", "error", "null", "artifact", "requires_authorization"],
min_length=1,
) = Field(
...,
description="The available modes for the output.",
default_factory=lambda: ["value", "error", "null"], default_factory=lambda: ["value", "error", "null"],
description="The available modes for the output.",
) )
value_schema: Optional[ValueSchema] = Field( value_schema: Optional[ValueSchema] = Field(
None, description="The schema of the value of the output." None, description="The schema of the value of the output."
@ -50,19 +57,30 @@ class ToolOutput(BaseModel):
class ToolAuthorizationRequirement(BaseModel, ABC): class ToolAuthorizationRequirement(BaseModel, ABC):
"""A requirement for authorization to use a tool."""
pass pass
class OAuth2AuthorizationRequirement(ToolAuthorizationRequirement): class OAuth2AuthorizationRequirement(ToolAuthorizationRequirement):
"""Specifies OAuth2 requirement for tool execution."""
url: AnyUrl url: AnyUrl
"""The URL to which the user should be redirected to authorize the tool."""
scope: Optional[list[str]] = None scope: Optional[list[str]] = None
"""The scope of the authorization."""
class ToolRequirements(BaseModel): class ToolRequirements(BaseModel):
"""The requirements for a tool to run."""
authorization: Union[ToolAuthorizationRequirement, None] = None authorization: Union[ToolAuthorizationRequirement, None] = None
class ToolDefinition(BaseModel): class ToolDefinition(BaseModel):
"""The specification of a tool."""
name: str name: str
description: str description: str
version: str version: str

View file

@ -47,10 +47,10 @@ def does_function_return_value(func: Callable) -> bool:
tree = ast.parse(source) tree = ast.parse(source)
class ReturnVisitor(ast.NodeVisitor): class ReturnVisitor(ast.NodeVisitor):
def __init__(self): def __init__(self) -> None:
self.returns_value = False self.returns_value = False
def visit_Return(self, node): def visit_Return(self, node: ast.Return) -> None:
if node.value is not None: if node.value is not None:
self.returns_value = True self.returns_value = True

View file

@ -36,8 +36,8 @@ deptry = "^0.12.0"
mypy = "^1.5.1" mypy = "^1.5.1"
pre-commit = "^3.4.0" pre-commit = "^3.4.0"
tox = "^4.11.1" tox = "^4.11.1"
pytest-asyncio = "^0.23.7" pytest-asyncio = "^0.23.7"
[tool.poetry.group.docs.dependencies] [tool.poetry.group.docs.dependencies]
mkdocs = "^1.4.2" mkdocs = "^1.4.2"
mkdocs-material = "^9.2.7" mkdocs-material = "^9.2.7"

View file

@ -2,8 +2,8 @@ import asyncio
import pytest import pytest
from arcade.sdk.schemas import OAuth2AuthorizationRequirement
from arcade.sdk.tool import tool from arcade.sdk.tool import tool
from arcade.tool.schemas import OAuth2AuthorizationRequirement
def test_sync_function(): def test_sync_function():

View file

@ -3,7 +3,9 @@ from typing import Annotated, Literal, Optional
import pytest import pytest
from arcade.sdk.annotations import Inferrable 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, InputParameter,
OAuth2AuthorizationRequirement, OAuth2AuthorizationRequirement,
ToolInputs, ToolInputs,
@ -11,8 +13,6 @@ from arcade.sdk.schemas import (
ToolRequirements, ToolRequirements,
ValueSchema, ValueSchema,
) )
from arcade.sdk.tool import tool
from arcade.tool.catalog import ToolCatalog
### Tests on @tool decorator ### Tests on @tool decorator
@ -89,6 +89,20 @@ def func_with_optional_param(param1: Annotated[Optional[str], "First param"]):
pass 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") @tool(desc="A function with multiple parameters, some with default values")
def func_with_mixed_params( def func_with_mixed_params(
param1: Annotated[str, "First param"], param1: Annotated[str, "First param"],
@ -341,6 +355,46 @@ def func_with_complex_return() -> list[dict[str, str]]:
}, },
id="func_with_optional_param", 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( pytest.param(
func_with_mixed_params, func_with_mixed_params,
{ {

View file

@ -1,8 +1,8 @@
import pytest import pytest
from arcade.sdk.errors import ToolDefinitionError
from arcade.sdk.tool import tool from arcade.sdk.tool import tool
from arcade.tool.catalog import ToolCatalog from arcade.tool.catalog import ToolCatalog
from arcade.tool.errors import ToolDefinitionError
@tool @tool
@ -15,6 +15,11 @@ def func_with_missing_return_type():
return "hello world" 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)") @tool(desc="A function with a parameter missing a description (illegal)")
def func_with_missing_param_description(param1: str): def func_with_missing_param_description(param1: str):
pass pass
@ -38,6 +43,11 @@ def func_with_unsupported_param(param1: complex):
ToolDefinitionError, ToolDefinitionError,
id=func_with_missing_return_type.__name__, id=func_with_missing_return_type.__name__,
), ),
pytest.param(
func_with_missing_param_type,
ToolDefinitionError,
id=func_with_missing_param_type.__name__,
),
pytest.param( pytest.param(
func_with_missing_param_description, func_with_missing_param_description,
ToolDefinitionError, ToolDefinitionError,

View file

@ -1,14 +1,16 @@
from typing import Annotated from typing import Annotated, Optional, Union
import pytest import pytest
from pydantic import BaseModel, Field 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, ToolOutput,
ValueSchema, ValueSchema,
) )
from arcade.sdk.tool import tool
from arcade.tool.catalog import ToolCatalog
class ProductOutput(BaseModel): 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 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: Should title and default_value be added to JSON schema?
# 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, title?
# 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
### 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( @pytest.mark.parametrize(
@ -46,9 +140,139 @@ def func_returns_pydantic_model() -> Annotated[ProductOutput, "The product, pric
}, },
id="func_returns_pydantic_model", 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") tool_def = ToolCatalog.create_tool_definition(func_under_test, "1.0")
assert tool_def.version == "1.0" assert tool_def.version == "1.0"

View file

@ -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")

View file

@ -9,9 +9,10 @@ email = "sam@partee.io"
[tools] [tools]
TextSearch = "BM25.text_search@0.0.1" 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" ReadSqlite = "read_sqlite.read_sqlite@0.0.1"
SendEmail = "gmail.send_email@0.0.1" SendEmail = "gmail.send_email@0.0.1"
ReadEmail = "gmail.read_email@0.0.1" ReadEmail = "gmail.read_email@0.0.1"
OauthReadEmail = "gmail.oauth_read_email@0.0.1" OauthReadEmail = "gmail.oauth_read_email@0.0.1"
ListDriveFiles = "gmail.list_drive_files@0.0.1" ListDriveFiles = "gmail.list_drive_files@0.0.1"
SearchEmployee = "people.search_employee@0.1.0"

View file

@ -11,4 +11,4 @@ email = "sam@partee.io"
gmail = "0.0.1" gmail = "0.0.1"
read_sqlite = "0.0.1" read_sqlite = "0.0.1"
BM25 = "0.0.1" BM25 = "0.0.1"
products = "0.0.1" people = "0.1.0"

View file

@ -1,4 +1,4 @@
from typing import Union from typing import Union, Annotated, Literal
from arcade.sdk.tool import tool, get_secret from arcade.sdk.tool import tool, get_secret
import pandas as pd import pandas as pd
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -23,11 +23,18 @@ class FilterPriceLessThan(ProductFilter):
class ProductSearch(BaseModel): class ProductSearch(BaseModel):
"""The search action to perform"""
column: str = Field("Product Name", description="The column to search in") 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") query: str = Field(..., description="The query to search for")
"""the query to search for"""
filter_operation: Union[ filter_operation: Union[
FilterRating, FilterPriceGreaterThan, FilterPriceLessThan FilterRating, FilterPriceGreaterThan, FilterPriceLessThan
] = None ] = None
"""The filter operation to perform"""
class ProductOutput(BaseModel): class ProductOutput(BaseModel):
@ -38,13 +45,11 @@ class ProductOutput(BaseModel):
@tool @tool
def read_products( def read_products(
action: ProductSearch, action: Annotated[ProductSearch, "The search action to perform"],
cols: list[str] = [ cols: Annotated[
"Product Name", Literal["Product Name", "Price", "Stock Quantity"], "The columns to return"
"Price", ] = ["Product Name", "Price", "Stock Quantity"],
"Stock Quantity", ) -> Annotated[list[ProductOutput], "The list of products matching the search"]:
],
) -> list[ProductOutput]:
"""Used to search through products by name and filter by rating or price.""" """Used to search through products by name and filter by rating or price."""
file_path = get_secret( file_path = get_secret(

View file

@ -4,13 +4,7 @@
"primitives": { "primitives": {
// All supported primitive data types // All supported primitive data types
"type": "string", "type": "string",
"enum": [ "enum": ["string", "integer", "float", "boolean", "json"]
"string",
"integer",
"float",
"boolean",
"json"
]
}, },
"value_schema": { "value_schema": {
// Represents a value schema (e.g. function input parameter) // Represents a value schema (e.g. function input parameter)
@ -20,9 +14,7 @@
"$ref": "#/$defs/primitives" "$ref": "#/$defs/primitives"
} }
}, },
"required": [ "required": ["type"],
"type"
],
"additionalProperties": false, "additionalProperties": false,
"if": { "if": {
"properties": { "properties": {
@ -94,18 +86,12 @@
"default": true "default": true
} }
}, },
"required": [ "required": ["name", "required", "schema"],
"name",
"required",
"schema"
],
"additionalProperties": false "additionalProperties": false
} }
} }
}, },
"required": [ "required": ["parameters"],
"parameters"
],
"additionalProperties": false "additionalProperties": false
}, },
"output": { "output": {
@ -116,13 +102,7 @@
"minItems": 1, "minItems": 1,
"items": { "items": {
"type": "string", "type": "string",
"enum": [ "enum": ["value", "error", "null", "artifact", "requires_authorization"]
"value",
"error",
"null",
"artifact",
"requires_authorization"
]
} }
}, },
"value": { "value": {
@ -135,15 +115,11 @@
"$ref": "#/$defs/value_schema" "$ref": "#/$defs/value_schema"
} }
}, },
"required": [ "required": ["schema"],
"schema"
],
"additionalProperties": false "additionalProperties": false
} }
}, },
"required": [ "required": ["available_modes"],
"available_modes"
],
"additionalProperties": false "additionalProperties": false
}, },
"requirements": { "requirements": {
@ -153,10 +129,7 @@
"oneOf": [ "oneOf": [
{ {
"type": "string", "type": "string",
"enum": [ "enum": ["none", "token"]
"none",
"token"
]
}, },
{ {
"type": "object", "type": "object",
@ -172,15 +145,11 @@
"type": "string" "type": "string"
} }
}, },
"required": [ "required": ["url"],
"url"
],
"additionalProperties": false "additionalProperties": false
} }
}, },
"required": [ "required": ["oauth2"],
"oauth2"
],
"additionalProperties": false "additionalProperties": false
} }
] ]
@ -188,11 +157,6 @@
} }
} }
}, },
"required": [ "required": ["name", "version", "input", "output"],
"name",
"version",
"input",
"output"
],
"additionalProperties": false "additionalProperties": false
} }

View file

@ -68,13 +68,6 @@
} }
} }
}, },
"required": [ "required": ["run_id", "invocation_id", "created_at", "tool", "input", "context"],
"run_id",
"invocation_id",
"created_at",
"tool",
"input",
"context"
],
"additionalProperties": false "additionalProperties": false
} }