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_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"
hooks:
- id: ruff
args: [--exit-non-zero-on-fix]
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-prettier
rev: "v3.0.3"
hooks:
- id: prettier

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:
- Your operating system name and version.
- Any details about your local setup that might be helpful in troubleshooting.
- Detailed steps to reproduce the bug.
- Your operating system name and version.
- Any details about your local setup that might be helpful in troubleshooting.
- Detailed steps to reproduce the bug.
## Fix Bugs
@ -37,10 +37,10 @@ The best way to send feedback is to file an issue at https://github.com/spartee/
If you are proposing a new feature:
- Explain in detail how it would work.
- Keep the scope as narrow as possible, to make it easier to implement.
- Remember that this is a volunteer-driven project, and that contributions
are welcome :)
- Explain in detail how it would work.
- Keep the scope as narrow as possible, to make it easier to implement.
- Remember that this is a volunteer-driven project, and that contributions
are welcome :)
# Get Started!

View file

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

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,8 @@
from starlette.requests import Request
from arcade.tool.catalog import ToolCatalog
def get_catalog(request: Request):
return request.app.state.catalog
def get_catalog(request: Request) -> ToolCatalog:
# TODO figure out why this says return type is Any
return request.app.state.catalog # type: ignore[no-any-return]

View file

@ -1,39 +1,64 @@
import traceback
from textwrap import dedent
from typing import Callable
from fastapi import APIRouter
from fastapi import APIRouter, Body, Depends, Request
from pydantic import BaseModel, ValidationError
from arcade.actor.common.response import response_base
from arcade.actor.common.response_code import CustomResponseCode
from arcade.actor.core.conf import settings
from arcade.tool.catalog import ToolDefinition
from arcade.utils import snake_to_pascal_case
from arcade.tool.catalog import MaterializedTool
from arcade.tool.executor import ToolExecutor
from arcade.tool.response import ToolResponse, tool_response
def create_endpoint_function(name, description, func, input_model, output_model):
def create_endpoint_function(
name: str,
description: str,
func: Callable,
input_model: type[BaseModel],
output_model: type[BaseModel],
) -> Callable[..., ToolResponse]:
"""
Factory function to create endpoint functions with 'frozen' schema and input_model values.
"""
async def run(body: input_model):
# dummy function to signal the parameters should be in the
# body of the request
def get_input_model(inputs: BaseModel = Body(...)) -> BaseModel:
return inputs
async def run(request: Request, inputs: BaseModel = Depends(get_input_model)) -> ToolResponse:
"""
The function that will be executed when a user sends a POST request
to a tool endpoint
"""
try:
# Execute the action
result = await func(**body.dict())
return await response_base.success(data={"result": result})
# get the body of the request without parsing and validating it
# as the executor will do that
body = await request.json()
response = await ToolExecutor.run(func, input_model, output_model, **body)
# TODO: Does this catch validation errors on output?
except ValidationError as e:
return await response_base.error(res=CustomResponseCode.HTTP_400, msg=str(e))
return await tool_response.fail(msg=str(e))
except Exception as e:
print(traceback.format_exc())
return await response_base.error(res=CustomResponseCode.HTTP_500, msg=str(e))
return await tool_response.fail(
msg=str(e),
data=traceback.format_exc(),
)
return response
run.__name__ = name
run.__doc__ = description
return run
# TODO investigate this
return run # type: ignore[return-value]
def generate_endpoint(schemas: list[ToolDefinition]) -> APIRouter:
def generate_endpoint(schemas: list[MaterializedTool]) -> APIRouter:
"""
Generate a HTTP endpoint for each tool definition passed.
"""
routers = []
top_level_router = APIRouter(prefix=settings.API_ACTION_STR)
@ -44,7 +69,7 @@ def generate_endpoint(schemas: list[ToolDefinition]) -> APIRouter:
# Create the endpoint function
run = create_endpoint_function(
name=snake_to_pascal_case(define.name),
name=define.name,
description=define.description,
func=schema.tool,
input_model=schema.input_model,
@ -53,33 +78,17 @@ def generate_endpoint(schemas: list[ToolDefinition]) -> APIRouter:
# Add the endpoint to the FastAPI app
router.post(
f"/{snake_to_pascal_case(define.name)}",
name=snake_to_pascal_case(define.name),
f"/{define.name}", # Note: Names from the ToolCatalog are already in PascalCase
name=define.name,
summary=define.description,
tags=[schema.meta.module],
response_model=schema.output_model,
# TODO investigate this
response_model=ToolResponse[schema.output_model], # type: ignore[name-defined]
response_model_exclude_unset=True,
response_model_exclude_none=True,
response_description=create_output_description(schema.output_model),
)(run)
routers.append(router)
for router in routers:
top_level_router.include_router(router)
return top_level_router
def create_output_description(output_model: type[BaseModel]) -> str:
"""
Create a description string for the output model.
"""
if not output_model:
return None
output_description = dedent(output_model.__doc__ or "")
output_description += "\n\n**Attributes:**\n\n"
for name, field in output_model.model_fields.items():
output_description += f"- **{name}** ({field.annotation.__name__})\n"
return output_description

View file

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

View file

@ -1,3 +1,5 @@
from typing import TYPE_CHECKING
from fastapi import APIRouter, Body, Depends, Query
from pydantic import ValidationError
@ -6,6 +8,9 @@ from arcade.actor.common.response_code import CustomResponseCode
from arcade.actor.core.depends import get_catalog
from arcade.tool.openai import schema_to_openai_tool
if TYPE_CHECKING:
from arcade.tool.catalog import ToolCatalog
router = APIRouter()
@ -13,7 +18,7 @@ router = APIRouter()
"/list",
summary="List available tools",
)
async def list_tools(catalog=Depends(get_catalog)) -> ResponseModel:
async def list_tools(catalog: "ToolCatalog" = Depends(get_catalog)) -> ResponseModel:
"""List all available tools"""
tools = catalog.list_tools()
@ -23,16 +28,21 @@ async def list_tools(catalog=Depends(get_catalog)) -> ResponseModel:
@router.get("/json", summary="Get the JSON (openai) format of a tool")
async def get_oai_function(
tool_name: str = Query(..., title="Tool Name", description="The name of the tool"),
catalog=Depends(get_catalog),
catalog: "ToolCatalog" = Depends(get_catalog),
) -> ResponseModel:
"""Get the OpenAI function format of an tool"""
try:
# TODO handle keyerror
tool = catalog[tool_name]
json_data = schema_to_openai_tool(tool)
return await response_base.success(data=json_data)
except KeyError:
return await response_base.fail(
res=CustomResponseCode.HTTP_404,
data=f"Tool '{tool_name}' not found in the catalog",
)
except ValidationError as e:
return await response_base.fail(res=CustomResponseCode.HTTP_400, data=str(e))
except Exception as e:
@ -45,13 +55,21 @@ async def execute_tool(
data: dict[str, str] = Body(
..., title="Tool Data", description="The data to execute the tool with"
),
catalog=Depends(get_catalog),
catalog: "ToolCatalog" = Depends(get_catalog),
) -> ResponseModel:
"""Execute a tool"""
try:
# TODO use executor and error handling
tool = catalog.get_tool(tool_name)
result = await tool(**data)
except ValueError:
return await response_base.fail(
res=CustomResponseCode.HTTP_404,
data=f"Tool '{tool_name}' not found in the catalog",
)
try:
result = await tool(**data) # type: ignore[misc]
return await response_base.success(data=result)
except ValidationError as e:
return await response_base.fail(res=CustomResponseCode.HTTP_400, data=str(e))

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):
"""Package Manager-esk info about a pack of tools."""
name: str
description: str
version: str
@ -16,11 +18,15 @@ class PackInfo(BaseModel):
class ToolPack(BaseModel):
"""A package of tools and their dependencies."""
pack: PackInfo
depends: Optional[dict[str, str]] = None
tools: Optional[dict[str, str]] = {}
tools: dict[str, str] = {}
def write_lock_file(self, pack_dir: Union[str, os.PathLike]) -> None:
"""Write the pack definition to a lock file."""
def write_lock_file(self, pack_dir: Union[str, os.PathLike]):
lock_file = Path(pack_dir) / "pack.lock.toml"
pack_dict = self.dict(by_alias=True, exclude_none=True)
pack_ordered_dict = {
@ -39,7 +45,9 @@ class ToolPack(BaseModel):
f.write(tomlkit.dumps(doc))
@classmethod
def from_lock_file(cls, pack_dir: Union[str, os.PathLike]):
def from_lock_file(cls, pack_dir: Union[str, os.PathLike]) -> "ToolPack":
"""Create a ToolPack object from a lock file."""
pack_dir = Path(pack_dir).resolve()
lock_file = pack_dir / "pack.lock.toml"
with open(lock_file) as f:

View file

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

View file

@ -2,17 +2,16 @@ import ast
import importlib.metadata
import importlib.util
import sys
from typing import Optional
from pathlib import Path
from typing import Optional, Union
from stdlib_list import stdlib_list
def load_ast_tree(filepath: str) -> ast.AST:
def load_ast_tree(filepath: str | Path) -> ast.AST:
"""
Load and parse the Abstract Syntax Tree (AST) from a Python file.
:param filepath: Path to the Python file.
:return: AST of the Python file.
"""
try:
with open(filepath) as file:
@ -24,8 +23,6 @@ def load_ast_tree(filepath: str) -> ast.AST:
def get_python_version() -> str:
"""
Get the current Python version.
:return: The version of Python in use.
"""
return f"{sys.version_info.major}.{sys.version_info.minor}"
@ -33,9 +30,6 @@ def get_python_version() -> str:
def retrieve_imported_libraries(tree: ast.AST) -> dict[str, Optional[str]]:
"""
Retrieve non-standard libraries imported in the AST.
:param tree: The AST of the file.
:return: A dictionary with libraries as keys and their versions as values.
"""
libraries = {}
python_version = get_python_version()
@ -44,36 +38,36 @@ def retrieve_imported_libraries(tree: ast.AST) -> dict[str, Optional[str]]:
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
package_name = node.module.split(".")[0] if node.module else None
if package_name == "dstar" or package_name in stdlib_modules:
if package_name:
if package_name in stdlib_modules:
continue
else:
try:
package_version = importlib.metadata.version(package_name)
except importlib.metadata.PackageNotFoundError:
package_version = None
else:
continue
try:
package_version = importlib.metadata.version(package_name)
except importlib.metadata.PackageNotFoundError:
package_version = None
libraries[package_name] = package_version
return libraries
def get_function_name_if_decorated(node: ast.FunctionDef) -> Optional[str]:
def get_function_name_if_decorated(
node: Union[ast.FunctionDef, ast.AsyncFunctionDef]
) -> Optional[str]:
"""
Check if a function has a decorator of either "@toolserve.tool" or "tool" and return the function's name.
:param node: The function definition node from the AST.
:return: The name of the function if it has the specified decorators, otherwise None.
Check if a function has a decorator
"""
decorator_ids = {"toolserve.tool", "tool"}
decorator_ids = {"ar.tool", "tool"}
for decorator in node.decorator_list:
if isinstance(decorator, ast.Name) and decorator.id in decorator_ids:
return node.name
return None
def get_tools_from_file(filepath: str) -> list[str]:
def get_tools_from_file(filepath: str | Path) -> list[str]:
"""
Get the names of all functions in a Python file that are decorated with either "@toolserve.tool" or "@tool".
:param filepath: Path to the Python file.
:return: List of function names.
Retrieve tools from a Python file.
"""
tree = load_ast_tree(filepath)
tools = []

View file

@ -14,12 +14,16 @@ console = Console()
@cli.command(help="Starts the ToolServer with specified configurations.")
def serve(
host: str = typer.Option(
settings.UVICORN_HOST, help="Host for the app, from settings by default.", show_default=True
settings.UVICORN_HOST,
help="Host for the app, from settings by default.",
show_default=True,
),
port: int = typer.Option(
settings.UVICORN_PORT, help="Port for the app, settings default.", show_default=True
settings.UVICORN_PORT,
help="Port for the app, settings default.",
show_default=True,
),
):
) -> None:
"""
Starts the actor with host, port, and reload options. Uses
Uvicorn as ASGI actor. Parameters allow runtime configuration.
@ -44,7 +48,7 @@ def serve(
@cli.command(help="Build a new Tool Pack")
def pack(
directory: str = typer.Option(os.getcwd(), "--dir", help="tools directory path with pack.toml"),
):
) -> None:
"""
Creates a new tool pack with the given name, description, and result type.
"""

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

View file

@ -1,11 +1,14 @@
import asyncio
import inspect
import sys
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import datetime
from importlib import import_module
from pathlib import Path
from typing import (
Annotated,
Any,
Callable,
Literal,
Optional,
@ -16,14 +19,14 @@ from typing import (
)
from pydantic import BaseModel, Field, create_model
from pydantic.fields import FieldInfo
from pydantic_core import PydanticUndefined
from arcade.actor.common.response import ResponseModel
from arcade.actor.common.response_code import CustomResponseCode
from arcade.actor.core.conf import settings
from arcade.apm.base import ToolPack
from arcade.sdk.annotations import Inferrable
from arcade.sdk.errors import ToolDefinitionError
from arcade.sdk.schemas import (
from arcade.tool.errors import ToolDefinitionError
from arcade.tool.schemas import (
InputParameter,
ToolDefinition,
ToolInputs,
@ -38,8 +41,14 @@ from arcade.utils import (
snake_to_pascal_case,
)
WireType = Literal["string", "integer", "float", "boolean", "json"]
class ToolMeta(BaseModel):
"""
Metadata for a tool once it's been materialized.
"""
module: str
path: Optional[str] = None
date_added: datetime = Field(default_factory=datetime.now)
@ -47,6 +56,10 @@ class ToolMeta(BaseModel):
class MaterializedTool(BaseModel):
"""
Data structure that holds tool information while stored in the Catalog
"""
tool: Callable
definition: ToolDefinition
meta: ToolMeta
@ -68,12 +81,21 @@ class MaterializedTool(BaseModel):
return self.definition.description
# TODO make a generate for catalog type
class ToolCatalog:
def __init__(self, tools_dir: str = settings.TOOLS_DIR):
self.tools = self.read_tools(tools_dir)
"""Singleton class that holds all tools for a given actor"""
def __init__(self, tools_dir: Path = settings.TOOLS_DIR):
self.tools: dict[str, MaterializedTool] = self.read_tools(tools_dir)
@staticmethod
def read_tools(directory: str) -> dict[str, MaterializedTool]:
def read_tools(directory: Path) -> dict[str, MaterializedTool]:
"""
Create tool definitions from a directory of python files
"""
toolpack = ToolPack.from_lock_file(directory)
sys.path.append(str(Path(directory).resolve() / "tools"))
@ -85,9 +107,7 @@ class ToolCatalog:
module = import_module(module_name)
tool_func = getattr(module, func_name)
input_model, output_model = create_func_models(tool_func)
tool_name = snake_to_pascal_case(
name
) # TODO make sure this follows create_tool_definition
tool_name = name
tools[tool_name] = MaterializedTool(
definition=ToolCatalog.create_tool_definition(tool_func, version),
tool=tool_func,
@ -100,6 +120,10 @@ class ToolCatalog:
@staticmethod
def create_tool_definition(tool: Callable, version: str) -> ToolDefinition:
"""
Given a tool function, create a ToolDefinition
"""
tool_name = getattr(tool, "__tool_name__", tool.__name__)
# Hard requirement: tools must have descriptions
@ -122,18 +146,18 @@ class ToolCatalog:
),
)
def __getitem__(self, name: str) -> Optional[MaterializedTool]:
def __getitem__(self, name: str) -> MaterializedTool:
# TODO error handling
for tool_name, tool in self.tools.items():
if tool_name == name:
return tool
return None
raise KeyError(f"Tool {name} not found.")
def __iter__(self) -> MaterializedTool:
def __iter__(self) -> Iterator[MaterializedTool]:
yield from self.tools.values()
def get_tool(self, name: str) -> Optional[Callable]:
for _, tool in self:
for tool in self.tools.values():
if tool.definition.name == name:
return tool.tool
raise ValueError(f"Tool {name} not found.")
@ -159,31 +183,29 @@ def create_input_definition(func: Callable) -> ToolInputs:
"""
input_parameters = []
for _, param in inspect.signature(func, follow_wrapped=True).parameters.items():
field_info = extract_field_info(param)
# Hard requirement: params must be described
if field_info["field_params"]["description"] is None:
raise ToolDefinitionError(
f"Parameter {field_info['field_params']['name']} is missing a description"
)
tool_field_info = extract_field_info(param)
is_enum = False
enum_values: list[str] = []
# Special case: Literal["string1", "string2"] can be enumerated on the wire
if is_string_literal(field_info["field_params"]["type"]):
if is_string_literal(tool_field_info.field_type):
is_enum = True
enum_values = [str(e) for e in get_args(field_info["field_params"]["type"])]
enum_values = [str(e) for e in get_args(tool_field_info.field_type)]
# If the field has a default value, it is not required
# If the field is optional, it is not required
has_default_value = tool_field_info.default is not None
is_required = not tool_field_info.is_optional and not has_default_value
input_parameters.append(
InputParameter(
name=field_info["field_params"]["name"],
description=field_info["field_params"]["description"],
required=field_info["field_params"]["default"] is None
and not field_info["field_params"]["optional"],
inferrable=field_info["field_params"]["inferrable"],
name=tool_field_info.name,
description=tool_field_info.description,
required=is_required,
inferrable=tool_field_info.is_inferrable,
value_schema=ValueSchema(
val_type=field_info["field_params"]["wire_type"],
val_type=tool_field_info.wire_type,
enum=enum_values if is_enum else None,
),
)
@ -230,38 +252,108 @@ def create_output_definition(func: Callable) -> ToolOutput:
)
def extract_field_info(param: inspect.Parameter) -> dict:
@dataclass
class ParamInfo:
"""
Information about a function parameter found through inspection.
"""
name: str
default: Any
original_type: type
field_type: type
description: str | None = None
is_optional: bool = True
@dataclass
class ToolParamInfo:
"""
Information about a tool parameter, including computed values.
"""
name: str
default: Any
original_type: type
field_type: type
wire_type: WireType
description: str | None = None
is_optional: bool = True
is_inferrable: bool = True
@classmethod
def from_param_info(
cls, param_info: ParamInfo, wire_type: WireType, is_inferrable: bool = True
) -> "ToolParamInfo":
return cls(
name=param_info.name,
default=param_info.default,
original_type=param_info.original_type,
field_type=param_info.field_type,
description=param_info.description,
is_optional=param_info.is_optional,
wire_type=wire_type,
is_inferrable=is_inferrable,
)
def extract_field_info(param: inspect.Parameter) -> ToolParamInfo:
"""
Extract type and field parameters from a function parameter.
Args:
param (inspect.Parameter): The parameter to extract information from.
Returns:
dict: A dictionary with 'type' and 'field_params'.
"""
annotation = param.annotation
if annotation == inspect.Parameter.empty:
raise TypeError(f"Parameter {param} has no type annotation.")
raise ToolDefinitionError(f"Parameter {param} has no type annotation.")
# Get the majority of the param info from either the Pydantic Field() or regular inspection
if isinstance(param.default, FieldInfo):
param_info = extract_pydantic_param_info(param)
else:
param_info = extract_regular_param_info(param)
metadata = getattr(annotation, "__metadata__", [])
name = param.name
description = None
str_annotations = [m for m in metadata if isinstance(m, str)]
if len(str_annotations) == 1:
description = str_annotations[0]
# Get the description from annotations, if present
if len(str_annotations) == 0:
pass
elif len(str_annotations) == 1:
param_info.description = str_annotations[0]
elif len(str_annotations) == 2:
name = str_annotations[0]
description = str_annotations[1]
param_info.name = str_annotations[0]
param_info.description = str_annotations[1]
else:
raise ToolDefinitionError(f"Parameter {param} has multiple descriptions")
raise ToolDefinitionError(
f"Parameter {param} has too many string annotations. Expected 0, 1, or 2, got {len(str_annotations)}."
)
default = param.default if param.default is not inspect.Parameter.empty else None
# Get the Inferrable annotation, if it exists
inferrable_annotation = first_or_none(Inferrable, get_args(annotation))
# If the param is Annotated[], unwrap the annotation
# Params are inferrable by default
is_inferrable = inferrable_annotation.value if inferrable_annotation else True
# Get the wire type
wire_type = (
get_wire_type(str)
if is_string_literal(param_info.field_type)
else get_wire_type(param_info.field_type)
)
# Final reality check
if param_info.description is None:
raise ToolDefinitionError(f"Parameter {param_info.name} is missing a description")
if wire_type is None:
raise ToolDefinitionError(f"Unknown parameter type: {param_info.field_type}")
return ToolParamInfo.from_param_info(param_info, wire_type, is_inferrable)
def extract_regular_param_info(param: inspect.Parameter) -> ParamInfo:
# If the param is Annotated[], unwrap the annotation to get the "real" type
# Otherwise, use the literal type
annotation = param.annotation
original_type = annotation.__args__[0] if get_origin(annotation) is Annotated else annotation
field_type = original_type
@ -271,30 +363,55 @@ def extract_field_info(param: inspect.Parameter) -> dict:
field_type = next(arg for arg in get_args(field_type) if arg is not type(None))
is_optional = True
wire_type = get_wire_type(str) if is_string_literal(field_type) else get_wire_type(field_type)
return ParamInfo(
name=param.name,
default=param.default if param.default is not inspect.Parameter.empty else None,
is_optional=is_optional,
original_type=original_type,
field_type=field_type,
)
# Get the Inferrable annotation, if it exists
inferrable_annotation = first_or_none(Inferrable, get_args(annotation))
field_params = {
"name": name,
"description": str(description) if description else None,
"default": default,
"optional": is_optional,
"inferrable": inferrable_annotation.value
if inferrable_annotation
else True, # Params are inferrable by default
"type": field_type,
"wire_type": wire_type,
"original_type": original_type,
}
def extract_pydantic_param_info(param: inspect.Parameter) -> ParamInfo:
default_value = None if param.default.default is PydanticUndefined else param.default.default
return {"type": field_type, "field_params": field_params}
if param.default.default_factory is not None:
if callable(param.default.default_factory):
default_value = param.default.default_factory()
else:
raise ToolDefinitionError(f"Default factory for parameter {param} is not callable.")
# If the param is Annotated[], unwrap the annotation to get the "real" type
# Otherwise, use the literal type
original_type = (
param.annotation.__args__[0]
if get_origin(param.annotation) is Annotated
else param.annotation
)
field_type = original_type
# Unwrap Optional types
is_optional = False
if get_origin(field_type) is Union and type(None) in get_args(field_type):
field_type = next(arg for arg in get_args(field_type) if arg is not type(None))
is_optional = True
return ParamInfo(
name=param.name,
description=param.default.description,
default=default_value,
is_optional=is_optional,
original_type=original_type,
field_type=field_type,
)
def get_wire_type(
_type: type,
) -> Literal["string", "integer", "float", "boolean", "json"]:
) -> WireType:
"""
Mapping between Python types and HTTP/JSON types
"""
type_mapping = {
str: "string",
bool: "boolean",
@ -315,19 +432,12 @@ def get_wire_type(
return "json"
elif issubclass(_type, BaseModel):
return "json"
else:
raise TypeError(f"Unsupported parameter type: {_type}")
raise ToolDefinitionError(f"Unsupported parameter type: {_type}")
def create_func_models(func: Callable) -> tuple[type[BaseModel], type[BaseModel]]:
"""
Analyze a function to create corresponding Pydantic models for its input and output.
Args:
func (Callable): The function to analyze.
Returns:
Tuple[Type[BaseModel], Type[BaseModel]]: A tuple containing the input and output Pydantic models.
"""
input_fields = {}
# TODO figure this out (Sam)
@ -335,16 +445,15 @@ def create_func_models(func: Callable) -> tuple[type[BaseModel], type[BaseModel]
func = func.__wrapped__
for name, param in inspect.signature(func, follow_wrapped=True).parameters.items():
# TODO make this cleaner
field_info = extract_field_info(param)
field_data = field_info["field_params"]
tool_field_info = extract_field_info(param)
param_fields = {
"default": field_data["default"],
"description": field_data["description"],
"default": tool_field_info.default,
"description": tool_field_info.description,
# TODO more here?
}
input_fields[name] = (field_info["type"], Field(**param_fields))
input_fields[name] = (tool_field_info.field_type, Field(**param_fields))
input_model = create_model(f"{snake_to_pascal_case(func.__name__)}Input", **input_fields)
input_model = create_model(f"{snake_to_pascal_case(func.__name__)}Input", **input_fields) # type: ignore[call-overload]
output_model = determine_output_model(func)
@ -354,12 +463,6 @@ def create_func_models(func: Callable) -> tuple[type[BaseModel], type[BaseModel]
def determine_output_model(func: Callable) -> type[BaseModel]:
"""
Determine the output model for a function based on its return annotation.
Args:
func (Callable): The function to analyze.
Returns:
Type[BaseModel]: A Pydantic model representing the output.
"""
return_annotation = inspect.signature(func).return_annotation
output_model_name = f"{snake_to_pascal_case(func.__name__)}Output"
@ -367,7 +470,7 @@ def determine_output_model(func: Callable) -> type[BaseModel]:
return create_model(output_model_name)
elif hasattr(return_annotation, "__origin__"):
if hasattr(return_annotation, "__metadata__"):
field_type = Optional[return_annotation.__args__[0]]
field_type = return_annotation.__args__[0]
description = (
return_annotation.__metadata__[0] if return_annotation.__metadata__ else ""
)
@ -376,32 +479,18 @@ def determine_output_model(func: Callable) -> type[BaseModel]:
output_model_name,
result=(field_type, Field(description=str(description))),
)
else:
return create_model(
output_model_name,
result=(
return_annotation,
Field(description="No description provided."),
),
)
# when the return_annotation has an __origin__ attribute
# and does not have a __metadata__ attribute.
return create_model(
output_model_name,
result=(
return_annotation,
Field(description="No description provided."),
),
)
else:
# Handle simple return types (like str)
return create_model(
output_model_name,
result=(return_annotation, Field(description="No description provided.")),
)
def create_response_model(name: str, output_model: type[BaseModel]) -> type[ResponseModel]:
"""
Create a response model for the given schema.
"""
# Create a new response model
response_model = create_model(
f"{snake_to_pascal_case(name)}Response",
code=(int, CustomResponseCode.HTTP_200.code),
msg=(str, CustomResponseCode.HTTP_200.msg),
data=(Optional[output_model], None),
)
return response_model

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
PYTHON_TO_JSON_TYPES = {
PYTHON_TO_JSON_TYPES: dict[type, str] = {
str: "string",
int: "integer",
float: "number",
@ -17,15 +17,10 @@ PYTHON_TO_JSON_TYPES = {
}
def python_type_to_json_type(python_type: type) -> dict[str, Any]:
def python_type_to_json_type(python_type: type[Any]) -> dict[str, Any]:
"""
Map Python types to JSON Schema types, including handling of complex types such as lists and dictionaries.
Args:
python_type (Type): The Python type to be converted to a JSON schema type.
Returns:
Dict[str, Any]: A dictionary representing the JSON schema for the given Python type.
Map Python types to JSON Schema types, including handling of
complex types such as lists and dictionaries.
"""
if hasattr(python_type, "__origin__"):
origin = python_type.__origin__
@ -40,23 +35,18 @@ def python_type_to_json_type(python_type: type) -> dict[str, Any]:
elif issubclass(python_type, BaseModel):
return model_to_json_schema(python_type)
return PYTHON_TO_JSON_TYPES.get(python_type, "string")
raise ValueError(f"Unsupported type: {python_type}")
def model_to_json_schema(model: type[BaseModel]) -> dict[str, Any]:
"""
Convert a Pydantic model to a JSON schema.
Args:
model (Type[BaseModel]): The Pydantic model to convert.
Returns:
Dict[str, Any]: A dictionary representing the JSON schema for the given model.
"""
properties = {}
required = []
for field_name, model_field in model.model_fields.items():
type_json = python_type_to_json_type(model_field.annotation)
# TODO: remove type ignore
type_json = python_type_to_json_type(model_field.annotation) # type: ignore[arg-type]
if isinstance(type_json, dict):
field_schema = type_json
else:
@ -104,12 +94,6 @@ def schema_to_openai_tool(tool: "MaterializedTool") -> str:
}
}
}
Args:
tool_schema (ToolDefinition): The tool schema to convert.
Returns:
str: A JSON schema string representing the tool in the specified format.
"""
input_model_schema = model_to_json_schema(tool.input_model)
function_schema = {

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,9 @@ from typing import Annotated, Literal, Optional
import pytest
from arcade.sdk.annotations import Inferrable
from arcade.sdk.schemas import (
from arcade.sdk.tool import tool
from arcade.tool.catalog import ToolCatalog
from arcade.tool.schemas import (
InputParameter,
OAuth2AuthorizationRequirement,
ToolInputs,
@ -11,8 +13,6 @@ from arcade.sdk.schemas import (
ToolRequirements,
ValueSchema,
)
from arcade.sdk.tool import tool
from arcade.tool.catalog import ToolCatalog
### Tests on @tool decorator
@ -89,6 +89,20 @@ def func_with_optional_param(param1: Annotated[Optional[str], "First param"]):
pass
@tool(desc="A function with an optional input parameter (default: None)")
def func_with_optional_param_with_default_None(
param1: Annotated[Optional[str], "First param"] = None,
):
pass
@tool(desc="A function with an optional input parameter with default value")
def func_with_optional_param_with_default_value(
param1: Annotated[Optional[str], "First param"] = "default",
):
pass
@tool(desc="A function with multiple parameters, some with default values")
def func_with_mixed_params(
param1: Annotated[str, "First param"],
@ -341,6 +355,46 @@ def func_with_complex_return() -> list[dict[str, str]]:
},
id="func_with_optional_param",
),
pytest.param(
func_with_optional_param_with_default_None,
{
"inputs": ToolInputs(
parameters=[
InputParameter(
name="param1",
description="First param",
inferrable=True,
required=False, # Because of Optional[str]
value_schema=ValueSchema(val_type="string", enum=None),
)
]
),
"output": ToolOutput(
available_modes=["null"], description="No description provided."
),
},
id="func_with_optional_param_with_default_None",
),
pytest.param(
func_with_optional_param_with_default_value,
{
"inputs": ToolInputs(
parameters=[
InputParameter(
name="param1",
description="First param",
inferrable=True,
required=False, # Because of Optional[str] and default value
value_schema=ValueSchema(val_type="string", enum=None),
)
]
),
"output": ToolOutput(
available_modes=["null"], description="No description provided."
),
},
id="func_with_optional_param_with_default_value",
),
pytest.param(
func_with_mixed_params,
{

View file

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

View file

@ -1,14 +1,16 @@
from typing import Annotated
from typing import Annotated, Optional, Union
import pytest
from pydantic import BaseModel, Field
from arcade.sdk.schemas import (
from arcade.sdk.tool import tool
from arcade.tool.catalog import ToolCatalog
from arcade.tool.schemas import (
InputParameter,
ToolInputs,
ToolOutput,
ValueSchema,
)
from arcade.sdk.tool import tool
from arcade.tool.catalog import ToolCatalog
class ProductOutput(BaseModel):
@ -26,10 +28,102 @@ def func_returns_pydantic_model() -> Annotated[ProductOutput, "The product, pric
)
@tool(desc="A function that accepts a required Pydantic Field with a description")
def func_takes_pydantic_field_with_description(
product_name: str = Field(..., description="The name of the product"),
) -> str:
return product_name
@tool(desc="A function that accepts an Pydantic Field")
def func_takes_pydantic_field_optional(
product_name: Optional[str] = Field(None, description="The name of the product"),
) -> str:
return product_name
# Annotated[] takes precedence over Field() properties
@tool(desc="A function that accepts an annotated Pydantic Field")
def func_takes_pydantic_field_annotated_description(
product_name: Annotated[str, "The name of the product"] = Field(
..., description="The name of the product???"
),
) -> str:
return product_name
# Annotated[] takes precedence over Field() properties
@tool(desc="A function that accepts an annotated Pydantic Field")
def func_takes_pydantic_field_annotated_name_and_description(
product_name: Annotated[str, "ProductName", "The name of the product"] = Field(
..., title="The name of the product???"
),
) -> str:
return product_name
@tool(desc="A function that accepts a Pydantic Field with a default value")
def func_takes_pydantic_field_default(
product_name: str = Field(description="The name of the product", default="Product 1"),
) -> str:
return product_name
@tool(desc="A function that accepts a Pydantic Field with a default value factory")
def func_takes_pydantic_field_default_factory(
product_name: str = Field(
..., description="The name of the product", default_factory=lambda: "Product 1"
),
) -> str:
return product_name
# TODO: Function that takes a Pydantic model as an argument: break it down into components? Look at OpenAPI, do they represent nested arguments?
# TODO: Function that takes a Pydantic Field as an argument
# TODO: Pydantic Field() properties: description, default, title, default_factory, nullable
# TODO: Pydantic Field() properties stretch goal: gt, ge, lt, le, multiple_of, range, regex, max_length, min_length, max_items, min_items, unique_items, exclusive_maximum, exclusive_minimum
# TODO: Should title and default_value be added to JSON schema?
# TODO: Pydantic Field() properties stretch goal: gt, ge, lt, le, multiple_of, range, regex, max_length, min_length, max_items, min_items, unique_items, exclusive_maximum, exclusive_minimum, title?
### A complex, real-world example
class ProductFilter(BaseModel):
column: str = Field(..., description="The column to filter on")
class FilterRating(ProductFilter):
greater_than: int = Field(..., description="The rating to filter greater than", gt=0, lt=5)
class FilterPriceGreaterThan(ProductFilter):
price: int = Field(..., description="The price to filter greater than", gt=0)
class FilterPriceLessThan(ProductFilter):
price: int = Field(..., description="The price to filter less than", gt=0)
class ProductSearch(BaseModel):
column: str = Field("Product Name", description="The column to search in")
query: str = Field(..., description="The query to search for")
filter_operation: Union[FilterRating, FilterPriceGreaterThan, FilterPriceLessThan] = None
class ProductOutput(BaseModel):
product_name: str = Field(..., description="The name of the product")
price: int = Field(..., description="The price of the product")
stock_quantity: int = Field(..., description="The stock quantity of the product")
@tool
def read_products(
action: Annotated[ProductSearch, "The search query to perform"],
cols: list[str] = Field(
...,
description="The columns to return",
default_factory=lambda: ["Product Name", "Price", "Stock Quantity"],
),
) -> Annotated[list[ProductOutput], "Data with the selected columns"]:
"""Used to search through products by name and filter by rating or price."""
pass
@pytest.mark.parametrize(
@ -46,9 +140,139 @@ def func_returns_pydantic_model() -> Annotated[ProductOutput, "The product, pric
},
id="func_returns_pydantic_model",
),
pytest.param(
func_takes_pydantic_field_with_description,
{
"inputs": ToolInputs(
parameters=[
InputParameter(
name="product_name",
description="The name of the product",
required=True,
inferrable=True,
value_schema=ValueSchema(val_type="string", enum=None),
)
]
)
},
id="func_takes_pydantic_field_with_description",
),
pytest.param(
func_takes_pydantic_field_optional,
{
"inputs": ToolInputs(
parameters=[
InputParameter(
name="product_name",
description="The name of the product",
required=False,
inferrable=True,
value_schema=ValueSchema(val_type="string", enum=None),
)
]
)
},
id="func_takes_pydantic_field_optional",
),
pytest.param(
func_takes_pydantic_field_annotated_description,
{
"inputs": ToolInputs(
parameters=[
InputParameter(
name="product_name",
description="The name of the product", # Annotated[] takes precedence over Field() properties
required=True,
inferrable=True,
value_schema=ValueSchema(val_type="string", enum=None),
)
]
)
},
id="func_takes_pydantic_field_annotated_description",
),
pytest.param(
func_takes_pydantic_field_annotated_name_and_description,
{
"inputs": ToolInputs(
parameters=[
InputParameter(
name="ProductName",
description="The name of the product", # Annotated[] takes precedence over Field() properties
required=True,
inferrable=True,
value_schema=ValueSchema(val_type="string", enum=None),
)
]
)
},
id="func_takes_pydantic_field_annotated_name_and_description",
),
pytest.param(
func_takes_pydantic_field_default,
{
"inputs": ToolInputs(
parameters=[
InputParameter(
name="product_name",
description="The name of the product",
required=False, # Because it has a default value
inferrable=True,
value_schema=ValueSchema(val_type="string", enum=None),
)
]
),
},
id="func_takes_pydantic_field_default",
),
pytest.param(
func_takes_pydantic_field_default_factory,
{
"inputs": ToolInputs(
parameters=[
InputParameter(
name="product_name",
description="The name of the product",
required=False, # Because it has a default value factory
inferrable=True,
value_schema=ValueSchema(val_type="string", enum=None),
)
]
),
},
id="func_takes_pydantic_field_default_factory",
),
pytest.param(
read_products,
{
"inputs": ToolInputs(
parameters=[
InputParameter(
name="action",
description="The search query to perform",
required=True,
inferrable=True,
value_schema=ValueSchema(val_type="json", enum=None),
),
InputParameter(
name="cols",
description="The columns to return",
required=False,
value_schema=ValueSchema(val_type="json", enum=None),
),
]
),
"output": ToolOutput(
value_schema=ValueSchema(val_type="json", enum=None),
available_modes=["value", "error"],
description="Data with the selected columns",
),
},
id="read_products",
),
],
)
def test_create_tool_def(func_under_test, expected_tool_def_fields):
def test_create_tool_def_from_pydantic(func_under_test, expected_tool_def_fields):
tool_def = ToolCatalog.create_tool_definition(func_under_test, "1.0")
assert tool_def.version == "1.0"

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]
TextSearch = "BM25.text_search@0.0.1"
ReadProducts = "products.read_products@0.0.1"
ReadProducts = "products.read_products@latest"
ReadSqlite = "read_sqlite.read_sqlite@0.0.1"
SendEmail = "gmail.send_email@0.0.1"
ReadEmail = "gmail.read_email@0.0.1"
OauthReadEmail = "gmail.oauth_read_email@0.0.1"
ListDriveFiles = "gmail.list_drive_files@0.0.1"
SearchEmployee = "people.search_employee@0.1.0"

View file

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

View file

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

View file

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