arcade-mcp/libs/tests/tool/test_create_tool_definition_pydantic.py
Sam Partee 27a6cd31a3
Support Tool Output in ValueSchema of ToolDefinition (#487)
## Before

### Tool: ``GoogleNews.SearchNewsStories``

```python
@tool(requires_secrets=["SERP_API_KEY"])
async def search_news_stories(
    context: ToolContext,
    keywords: Annotated[
        str,
        "Keywords to search for news articles. E.g. 'Apple launches new iPhone'.",
    ],
    country_code: Annotated[
        CountryCode | None,
        "2-character country code to search for news articles. "
        "E.g. 'us' (United States). "
        f"Defaults to '{DEFAULT_GOOGLE_NEWS_COUNTRY}'.",
    ] = None,
    language_code: Annotated[
        LanguageCode,
        "2-character language code to search for news articles. E.g. 'en' (English). "
        f"Defaults to '{DEFAULT_GOOGLE_NEWS_LANGUAGE}'.",
    ] = DEFAULT_GOOGLE_NEWS_LANGUAGE,
    limit: Annotated[
        int | None,
        "Maximum number of news articles to return. Defaults to None "
        "(returns all results found by the API).",
    ] = None,
) -> Annotated[dict[str, Any]]:
    """Search for news articles related to a given query."""
    ...
```


### Tool Definition: ``GoogleNews.SearchNewsStories``
```
  {
    "name": "SearchNewsStories",
    "fully_qualified_name": "GoogleNews.SearchNewsStories",
    "description": "Search for news articles related to a given query.",
    "toolkit": {
      "name": "GoogleNews",
      "description": "Arcade.dev LLM tools for getting new via Google News",
      "version": "2.0.0"
    },
    "input": {
      "parameters": [
        {
          "name": "keywords",
          "required": true,
          "description": "Keywords to search for news articles. E.g. 'Apple launches new iPhone'.",
          "value_schema": {
            "val_type": "string",
            "inner_val_type": null,
            "enum": null,
          },
          "inferrable": true
        },
        {
          "name": "country_code",
          "required": false,
          "description": "2-character country code to search for news articles. E.g. 'us' (United States). Defaults to 'None'.",
          "value_schema": {
            "val_type": "string",
            "inner_val_type": null,
            "enum": null,
          },
          "inferrable": true
        },
        {
          "name": "language_code",
          "required": false,
          "description": "2-character language code to search for news articles. E.g. 'en' (English). Defaults to 'en'.",
          "value_schema": {
            "val_type": "string",
            "inner_val_type": null,
            "enum": null,
          },
          "inferrable": true
        },
        {
          "name": "limit",
          "required": false,
          "description": "Maximum number of news articles to return. Defaults to None (returns all results found by the API).",
          "value_schema": {
            "val_type": "integer",
            "inner_val_type": null,
            "enum": null,

          },
          "inferrable": true
        }
      ]
    },
    "output": {
      "description": "News search results with article details.",
      "available_modes": [
        "value",
        "error"
      ],
      "value_schema": {
        "val_type": "json"
      }
    },
    "requirements": {
      "authorization": null,
      "secrets": [
        {
          "key": "serp_api_key"
        }
      ],
      "metadata": null
    },
    "deprecation_message": null
  },
```

## After

### Enhanced Tool: ``GoogleNews.SearchNewsStories``

```python

"""Type definitions for Google News API responses and parameters."""

from typing_extensions import TypedDict

CountryCode = str
LanguageCode = str


class SearchNewsParams(TypedDict):
    """Input parameters for searching news articles."""

    keywords: str
    """Search query terms to find relevant news articles \
    (e.g., 'Apple launches new iPhone')."""

    country_code: CountryCode | None
    """Optional 2-letter country code to filter news by region \
    (e.g., 'us' for United States, 'uk' for United Kingdom)."""

    language_code: LanguageCode | None
    """Optional 2-letter language code to filter news by language \
    (e.g., 'en' for English, 'es' for Spanish)."""

    limit: int | None
    """Optional maximum number of news articles to return. \
    If not specified, returns all results from the API."""


class SourceInfo(TypedDict, total=False):
    """Information about the news source/publication."""

    name: str
    """Name of the publication (e.g., 'CNN', 'BBC News', 'The New York Times')."""

    icon: str
    """URL to the source's favicon or logo image."""

    authors: list[str]
    """List of author names for the article, if available."""


class NewsResult(TypedDict, total=False):
    """Individual news article from the Google News API response."""

    position: int
    """Ranking position of this result in the search results."""

    title: str
    """Headline or title of the news article."""

    link: str
    """Full URL to the original news article."""

    source: SourceInfo
    """Information about the publication source."""

    date: str
    """Publication date and time (e.g., '2 hours ago', 'Dec 15, 2023')."""

    snippet: str
    """Brief excerpt or summary from the article content."""

    thumbnail: str
    """URL to a high-resolution thumbnail image for the article."""

    thumbnail_small: str
    """URL to a low-resolution thumbnail image for the article."""

    story_token: str
    """Token for accessing full coverage of this news story across multiple sources."""

    stories: list["NewsResult"]
    """Related news stories from other sources covering the same topic."""

    highlight: dict
    """Additional highlighted information about the story."""


class SearchMetadata(TypedDict, total=False):
    """Metadata about the search request and processing."""

    id: str
    """Unique identifier for this search request within SerpApi."""

    status: str
    """Current processing status ('Processing', 'Success', or 'Error')."""

    json_endpoint: str
    """URL to retrieve the JSON results for this search."""

    created_at: str
    """Timestamp when the search request was created."""

    processed_at: str
    """Timestamp when the search request was processed."""

    google_news_url: str
    """Original Google News URL that would return these results."""

    total_time_taken: float
    """Total time in seconds taken to process this search."""


class SearchParameters(TypedDict, total=False):
    """Parameters used for the search request."""

    engine: str
    """Search engine used (always 'google_news' for this API)."""

    q: str
    """Search query string."""

    gl: str
    """Country code used for geographic filtering."""

    hl: str
    """Language code used for language filtering."""

    topic_token: str
    """Token for accessing specific news topics (e.g., 'World', 'Business', 'Technology')."""

    publication_token: str
    """Token for accessing news from specific publishers."""


class MenuLink(TypedDict):
    """Navigation link for news categories or topics."""

    title: str
    """Display text for the menu item (e.g., 'Technology', 'Sports', 'Business')."""

    topic_token: str
    """Token to access this specific topic or category."""

    serpapi_link: str
    """SerpApi URL to search within this topic."""


class TopStoriesLink(TypedDict):
    """Link to top stories section."""

    topic_token: str
    """Token to access top stories."""

    serpapi_link: str
    """SerpApi URL to retrieve top stories."""


class GoogleNewsResponse(TypedDict, total=False):
    """Complete response from the Google News API."""

    search_metadata: SearchMetadata
    """Metadata about the search request and processing."""

    search_parameters: SearchParameters
    """Parameters that were used for this search."""

    news_results: list[NewsResult]
    """List of news articles matching the search criteria."""

    menu_links: list[MenuLink]
    """Navigation links to different news categories and topics."""

    top_stories_link: TopStoriesLink
    """Link to access top stories."""

    title: str
    """Title of the page or topic being displayed."""


class SimplifiedNewsResult(TypedDict):
    """Simplified news article format for tool output."""

    title: str
    """Headline of the news article."""

    link: str
    """URL to the full article."""

    source: str | None
    """Name of the publication source."""

    date: str | None
    """When the article was published."""

    snippet: str | None
    """Brief excerpt from the article."""


class SearchNewsOutput(TypedDict):
    """Output format for the search_news_stories tool."""

    news_results: list[SimplifiedNewsResult]
    """List of news articles in simplified format."""

@tool(requires_secrets=["SERP_API_KEY"])
async def search_news_stories(
    context: ToolContext,
    keywords: Annotated[
        str,
        "Keywords to search for news articles. E.g. 'Apple launches new iPhone'.",
    ],
    country_code: Annotated[
        CountryCode | None,
        "2-character country code to search for news articles. "
        "E.g. 'us' (United States). "
        f"Defaults to '{DEFAULT_GOOGLE_NEWS_COUNTRY}'.",
    ] = None,
    language_code: Annotated[
        LanguageCode,
        "2-character language code to search for news articles. E.g. 'en' (English). "
        f"Defaults to '{DEFAULT_GOOGLE_NEWS_LANGUAGE}'.",
    ] = DEFAULT_GOOGLE_NEWS_LANGUAGE,
    limit: Annotated[
        int | None,
        "Maximum number of news articles to return. Defaults to None "
        "(returns all results found by the API).",
    ] = None,
) -> Annotated[SearchNewsOutput, "News search results with article details."]:
    """Search for news articles related to a given query."""
    ...

```

### Enhanced Tool Definition: ``GoogleNews.SearchNewsStories`` 

```json

  {
    "name": "SearchNewsStories",
    "fully_qualified_name": "GoogleNews.SearchNewsStories",
    "description": "Search for news articles related to a given query.",
    "toolkit": {
      "name": "GoogleNews",
      "description": "Arcade.dev LLM tools for getting new via Google News",
      "version": "2.0.0"
    },
    "input": {
      "parameters": [
        {
          "name": "keywords",
          "required": true,
          "description": "Keywords to search for news articles. E.g. 'Apple launches new iPhone'.",
          "value_schema": {
            "val_type": "string",
            "inner_val_type": null,
            "enum": null,
            "properties": null,
            "inner_properties": null,
            "description": null
          },
          "inferrable": true
        },
        {
          "name": "country_code",
          "required": false,
          "description": "2-character country code to search for news articles. E.g. 'us' (United States). Defaults to 'None'.",
          "value_schema": {
            "val_type": "string",
            "inner_val_type": null,
            "enum": null,
            "properties": null,
            "inner_properties": null,
            "description": null
          },
          "inferrable": true
        },
        {
          "name": "language_code",
          "required": false,
          "description": "2-character language code to search for news articles. E.g. 'en' (English). Defaults to 'en'.",
          "value_schema": {
            "val_type": "string",
            "inner_val_type": null,
            "enum": null,
            "properties": null,
            "inner_properties": null,
            "description": null
          },
          "inferrable": true
        },
        {
          "name": "limit",
          "required": false,
          "description": "Maximum number of news articles to return. Defaults to None (returns all results found by the API).",
          "value_schema": {
            "val_type": "integer",
            "inner_val_type": null,
            "enum": null,
            "properties": null,
            "inner_properties": null,
            "description": null
          },
          "inferrable": true
        }
      ]
    },
    "output": {
      "description": "News search results with article details.",
      "available_modes": [
        "value",
        "error"
      ],
      "value_schema": {
        "val_type": "json",
        "inner_val_type": null,
        "enum": null,
        "properties": {
          "news_results": {
            "val_type": "array",
            "inner_val_type": "json",
            "enum": null,
            "properties": null,
            "inner_properties": {
              "title": {
                "val_type": "string",
                "inner_val_type": null,
                "enum": null,
                "properties": null,
                "inner_properties": null,
                "description": "Headline of the news article."
              },
              "link": {
                "val_type": "string",
                "inner_val_type": null,
                "enum": null,
                "properties": null,
                "inner_properties": null,
                "description": "URL to the full article."
              },
              "source": {
                "val_type": "string",
                "inner_val_type": null,
                "enum": null,
                "properties": null,
                "inner_properties": null,
                "description": "Name of the publication source."
              },
              "date": {
                "val_type": "string",
                "inner_val_type": null,
                "enum": null,
                "properties": null,
                "inner_properties": null,
                "description": "When the article was published."
              },
              "snippet": {
                "val_type": "string",
                "inner_val_type": null,
                "enum": null,
                "properties": null,
                "inner_properties": null,
                "description": "Brief excerpt from the article."
              }
            },
            "description": "List of news articles in simplified format."
          }
        },
        "inner_properties": null,
        "description": null
      }
    },
    "requirements": {
      "authorization": null,
      "secrets": [
        {
          "key": "serp_api_key"
        }
      ],
      "metadata": null
    },
    "deprecation_message": null
  },

```

---------

Co-authored-by: Eric Gustin <eric@arcade.dev>
2025-07-24 15:32:35 -07:00

436 lines
16 KiB
Python

from typing import Annotated
import pytest
from arcade_core.catalog import ToolCatalog
from arcade_core.schema import (
InputParameter,
ToolInput,
ToolOutput,
ValueSchema,
)
from arcade_tdk import tool
from pydantic import BaseModel, Field
class ProductOutputModel(BaseModel):
product_name: str
"""The name of the product"""
price: int
"""The price of the product"""
stock_quantity: int
"""The stock quantity of the product"""
class Config:
extra = "forbid"
@tool(desc="A function that returns a Pydantic model")
def func_returns_pydantic_model() -> Annotated[
ProductOutputModel, "The product, price, and quantity"
]:
"""
Returns a ProductOutput Pydantic model with sample data.
Returns:
ProductOutput: The product, price, and quantity.
Example:
>>> func_returns_pydantic_model()
ProductOutput(product_name='Product 1', price=100, stock_quantity=1000)
"""
return ProductOutputModel(
product_name="Product 1",
price=100,
stock_quantity=1000,
)
@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 optional Pydantic Field")
def func_takes_pydantic_field_optional(
product_name: str | None = Field(None, description="The name of the product"),
) -> str:
return product_name if product_name is not None else "Product 1"
@tool(desc="A function that accepts an optional Pydantic Field with bar syntax")
def func_takes_pydantic_field_optional_bar_syntax(
product_name: str | None = Field(None, description="The name of the product"),
) -> str | None:
return product_name if product_name is not None else None
@tool(desc="A function that accepts an optional Pydantic Field with union syntax")
def func_takes_pydantic_field_optional_union_syntax(
product_name: str | None = Field(None, description="The name of the product"),
) -> str:
return product_name if product_name is not None else "Product 1"
# 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(
default_factory=lambda: "Product 1", description="The name of the product"
),
) -> str:
"""
Accepts a product name with a default value provided by a factory.
Parameters:
product_name: The name of the product. Defaults to "Product 1" if not provided.
Returns:
str: The product name.
Example:
>>> func_takes_pydantic_field_default_factory()
'Product 1'
"""
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: 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(..., description="The column to search in")
query: str = Field(..., description="The query to search for")
filter_operation: FilterRating | None = Field(
default=None,
description="The filter operation to apply (rating or price filter).",
)
highest_price: FilterPriceGreaterThan | None = Field(
default=None, description="The highest price to filter by"
)
lowest_price: FilterPriceLessThan | None = Field(
default=None, description="The lowest price to filter by"
)
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(
default_factory=lambda: ["Product Name", "Price", "Stock Quantity"],
description="The columns to return",
),
) -> Annotated[list[ProductOutput], "Data with the selected columns"]:
"""
Used to search through products by name and filter by rating or price.
Parameters:
action: The search query to perform, as a ProductSearch model.
cols: The columns to return. Defaults to ["Product Name", "Price", "Stock Quantity"].
Returns:
list[ProductOutput]: Data with the selected columns.
Raises:
None
Example:
>>> await read_products(ProductSearch(query="Widget"), ["Product Name", "Price"])
"""
# This is a stub implementation for testing; in real code, this would query a database or service.
return [
ProductOutput(product_name="Widget", price=100, stock_quantity=50),
ProductOutput(product_name="Gadget", price=150, stock_quantity=20),
]
@pytest.mark.parametrize(
"func_under_test, expected_tool_def_fields",
[
pytest.param(
func_returns_pydantic_model,
{
"output": ToolOutput(
value_schema=ValueSchema(
val_type="json",
enum=None,
properties={
"product_name": ValueSchema(val_type="string", enum=None),
"price": ValueSchema(val_type="integer", enum=None),
"stock_quantity": ValueSchema(val_type="integer", enum=None),
},
),
available_modes=["value", "error"],
description="The product, price, and quantity",
)
},
id="func_returns_pydantic_model",
),
pytest.param(
func_takes_pydantic_field_with_description,
{
"input": ToolInput(
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,
{
"input": ToolInput(
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_optional_bar_syntax,
{
"input": ToolInput(
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_bar_syntax",
),
pytest.param(
func_takes_pydantic_field_optional_union_syntax,
{
"input": ToolInput(
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_union_syntax",
),
pytest.param(
func_takes_pydantic_field_annotated_description,
{
"input": ToolInput(
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,
{
"input": ToolInput(
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,
{
"input": ToolInput(
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,
{
"input": ToolInput(
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,
{
"input": ToolInput(
parameters=[
InputParameter(
name="action",
description="The search query to perform",
required=True,
inferrable=True,
value_schema=ValueSchema(
val_type="json",
enum=None,
properties={
"column": ValueSchema(val_type="string", enum=None),
"query": ValueSchema(val_type="string", enum=None),
"filter_operation": ValueSchema(
val_type="json",
enum=None,
properties={
"column": ValueSchema(val_type="string", enum=None),
"greater_than": ValueSchema(
val_type="integer", enum=None
),
},
),
"highest_price": ValueSchema(
val_type="json",
enum=None,
properties={
"column": ValueSchema(val_type="string", enum=None),
"price": ValueSchema(val_type="integer", enum=None),
},
),
"lowest_price": ValueSchema(
val_type="json",
enum=None,
properties={
"column": ValueSchema(val_type="string", enum=None),
"price": ValueSchema(val_type="integer", enum=None),
},
),
},
),
),
InputParameter(
name="cols",
description="The columns to return",
required=False,
inferrable=True,
value_schema=ValueSchema(
val_type="array", inner_val_type="string", enum=None
),
),
]
),
"output": ToolOutput(
value_schema=ValueSchema(
val_type="array",
inner_val_type="json",
enum=None,
inner_properties={
"product_name": ValueSchema(val_type="string", enum=None),
"price": ValueSchema(val_type="integer", enum=None),
"stock_quantity": ValueSchema(val_type="integer", enum=None),
},
),
available_modes=["value", "error"],
description="Data with the selected columns",
),
},
id="read_products",
),
],
)
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")
for field, expected_value in expected_tool_def_fields.items():
assert getattr(tool_def, field) == expected_value