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:
parent
7f3abfd1f9
commit
28fe56cfc1
44 changed files with 1015 additions and 947 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
11
.prettierrc.toml
Normal 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
|
||||
|
|
@ -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!
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
|
@ -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"})
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
53
arcade/arcade/tool/errors.py
Normal file
53
arcade/arcade/tool/errors.py
Normal 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
|
||||
81
arcade/arcade/tool/executor.py
Normal file
81
arcade/arcade/tool/executor.py
Normal 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
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
85
arcade/arcade/tool/response.py
Normal file
85
arcade/arcade/tool/response.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue